From 693b769ba604ed8be50c04555083f067888d583c Mon Sep 17 00:00:00 2001 From: Yuri Kulikov Date: Thu, 6 Dec 2018 13:48:26 +0300 Subject: [PATCH] [Feature] Re-try mechanism for CodePush Rollbacks (#1467) --- CodePush.js | 74 ++++++++++++++++- .../codepush/react/CodePushConstants.java | 4 + .../codepush/react/CodePushNativeModule.java | 28 ++++++- .../codepush/react/CodePushUtils.java | 2 + .../codepush/react/SettingsManager.java | 55 +++++++++++- docs/api-js.md | 8 ++ ios/CodePush/CodePush.h | 19 +++++ ios/CodePush/CodePush.m | 83 +++++++++++++++++++ typings/react-native-code-push.d.ts | 23 +++++ 9 files changed, 292 insertions(+), 4 deletions(-) diff --git a/CodePush.js b/CodePush.js index 99722d6cf..86344a3bc 100644 --- a/CodePush.js +++ b/CodePush.js @@ -197,6 +197,7 @@ async function tryReportStatus(statusReport, resumeListener) { log(`Reporting CodePush update success (${label})`); } else { log(`Reporting CodePush update rollback (${label})`); + await NativeCodePush.setLatestRollbackInfo(statusReport.package.packageHash); } config.deploymentKey = statusReport.package.deploymentKey; @@ -225,6 +226,71 @@ async function tryReportStatus(statusReport, resumeListener) { } } +async function shouldUpdateBeIgnored(remotePackage, syncOptions) { + let { rollbackRetryOptions } = syncOptions; + + const isFailedPackage = remotePackage && remotePackage.failedInstall; + if (!isFailedPackage || !syncOptions.ignoreFailedUpdates) { + return false; + } + + if (!rollbackRetryOptions) { + return true; + } + + if (typeof rollbackRetryOptions !== "object") { + rollbackRetryOptions = CodePush.DEFAULT_ROLLBACK_RETRY_OPTIONS; + } else { + rollbackRetryOptions = { ...CodePush.DEFAULT_ROLLBACK_RETRY_OPTIONS, ...rollbackRetryOptions }; + } + + if (!validateRollbackRetryOptions(rollbackRetryOptions)) { + return true; + } + + const latestRollbackInfo = await NativeCodePush.getLatestRollbackInfo(); + if (!validateLatestRollbackInfo(latestRollbackInfo, remotePackage.packageHash)) { + log("The latest rollback info is not valid."); + return true; + } + + const { delayInHours, maxRetryAttempts } = rollbackRetryOptions; + const hoursSinceLatestRollback = (Date.now() - latestRollbackInfo.time) / (1000 * 60 * 60); + if (hoursSinceLatestRollback >= delayInHours && maxRetryAttempts >= latestRollbackInfo.count) { + log("Previous rollback should be ignored due to rollback retry options."); + return false; + } + + return true; +} + +function validateLatestRollbackInfo(latestRollbackInfo, packageHash) { + return latestRollbackInfo && + latestRollbackInfo.time && + latestRollbackInfo.count && + latestRollbackInfo.packageHash && + latestRollbackInfo.packageHash === packageHash; +} + +function validateRollbackRetryOptions(rollbackRetryOptions) { + if (typeof rollbackRetryOptions.delayInHours !== "number") { + log("The 'delayInHours' rollback retry parameter must be a number."); + return false; + } + + if (typeof rollbackRetryOptions.maxRetryAttempts !== "number") { + log("The 'maxRetryAttempts' rollback retry parameter must be a number."); + return false; + } + + if (rollbackRetryOptions.maxRetryAttempts < 1) { + log("The 'maxRetryAttempts' rollback retry parameter cannot be less then 1."); + return false; + } + + return true; +} + var testConfig; // This function is only used for tests. Replaces the default SDK, configuration and native bridge @@ -293,6 +359,7 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg const syncOptions = { deploymentKey: null, ignoreFailedUpdates: true, + rollbackRetryOptions: null, installMode: CodePush.InstallMode.ON_NEXT_RESTART, mandatoryInstallMode: CodePush.InstallMode.IMMEDIATE, minimumBackgroundDuration: 0, @@ -360,7 +427,8 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg return CodePush.SyncStatus.UPDATE_INSTALLED; }; - const updateShouldBeIgnored = remotePackage && (remotePackage.failedInstall && syncOptions.ignoreFailedUpdates); + const updateShouldBeIgnored = await shouldUpdateBeIgnored(remotePackage, syncOptions); + if (!remotePackage || updateShouldBeIgnored) { if (updateShouldBeIgnored) { log("An update is available, but it is being ignored due to having been previously rolled back."); @@ -585,6 +653,10 @@ if (NativeCodePush) { optionalInstallButtonLabel: "Install", optionalUpdateMessage: "An update is available. Would you like to install it?", title: "Update available" + }, + DEFAULT_ROLLBACK_RETRY_OPTIONS: { + delayInHours: 24, + maxRetryAttempts: 1 } }); } else { diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java index fb585d8c1..aecccd65c 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java @@ -27,4 +27,8 @@ public class CodePushConstants { public static final String UNZIPPED_FOLDER_NAME = "unzipped"; public static final String CODE_PUSH_APK_BUILD_TIME_KEY = "CODE_PUSH_APK_BUILD_TIME"; public static final String BUNDLE_JWT_FILE = ".codepushrelease"; + public static final String LATEST_ROLLBACK_INFO_KEY = "LATEST_ROLLBACK_INFO"; + public static final String LATEST_ROLLBACK_PACKAGE_HASH_KEY = "packageHash"; + public static final String LATEST_ROLLBACK_TIME_KEY = "time"; + public static final String LATEST_ROLLBACK_COUNT_KEY = "count"; } diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java index 0ea11cbdd..f19c7c2e7 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java @@ -510,7 +510,33 @@ public void onHostDestroy() { public void isFailedUpdate(String packageHash, Promise promise) { try { promise.resolve(mSettingsManager.isFailedHash(packageHash)); - } catch(CodePushUnknownException e) { + } catch (CodePushUnknownException e) { + CodePushUtils.log(e); + promise.reject(e); + } + } + + @ReactMethod + public void getLatestRollbackInfo(Promise promise) { + try { + JSONObject latestRollbackInfo = mSettingsManager.getLatestRollbackInfo(); + if (latestRollbackInfo != null) { + promise.resolve(CodePushUtils.convertJsonObjectToWritable(latestRollbackInfo)); + } else { + promise.resolve(null); + } + } catch (CodePushUnknownException e) { + CodePushUtils.log(e); + promise.reject(e); + } + } + + @ReactMethod + public void setLatestRollbackInfo(String packageHash, Promise promise) { + try { + mSettingsManager.setLatestRollbackInfo(packageHash); + promise.resolve(null); + } catch (CodePushUnknownException e) { CodePushUtils.log(e); promise.reject(e); } diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUtils.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUtils.java index 7c41a8043..eb099bd9e 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUtils.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUtils.java @@ -81,6 +81,8 @@ else if (obj instanceof String) map.putString(key, (String) obj); else if (obj instanceof Double) map.putDouble(key, (Double) obj); + else if (obj instanceof Long) + map.putDouble(key, ((Long) obj).doubleValue()); else if (obj instanceof Integer) map.putInt(key, (Integer) obj); else if (obj instanceof Boolean) diff --git a/android/app/src/main/java/com/microsoft/codepush/react/SettingsManager.java b/android/app/src/main/java/com/microsoft/codepush/react/SettingsManager.java index e00c979cf..46f497ba7 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/SettingsManager.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/SettingsManager.java @@ -74,8 +74,7 @@ public boolean isPendingUpdate(String packageHash) { return pendingUpdate != null && !pendingUpdate.getBoolean(CodePushConstants.PENDING_UPDATE_IS_LOADING_KEY) && (packageHash == null || pendingUpdate.getString(CodePushConstants.PENDING_UPDATE_HASH_KEY).equals(packageHash)); - } - catch (JSONException e) { + } catch (JSONException e) { throw new CodePushUnknownException("Unable to read pending update metadata in isPendingUpdate.", e); } } @@ -89,6 +88,15 @@ public void removePendingUpdate() { } public void saveFailedUpdate(JSONObject failedPackage) { + try { + if (isFailedHash(failedPackage.getString(CodePushConstants.PACKAGE_HASH_KEY))) { + // Do not need to add the package if it is already in the failedUpdates. + return; + } + } catch (JSONException e) { + throw new CodePushUnknownException("Unable to read package hash from package.", e); + } + String failedUpdatesString = mSettings.getString(CodePushConstants.FAILED_UPDATES_KEY, null); JSONArray failedUpdates; if (failedUpdatesString == null) { @@ -107,6 +115,49 @@ public void saveFailedUpdate(JSONObject failedPackage) { mSettings.edit().putString(CodePushConstants.FAILED_UPDATES_KEY, failedUpdates.toString()).commit(); } + public JSONObject getLatestRollbackInfo() { + String latestRollbackInfoString = mSettings.getString(CodePushConstants.LATEST_ROLLBACK_INFO_KEY, null); + if (latestRollbackInfoString == null) { + return null; + } + + try { + return new JSONObject(latestRollbackInfoString); + } catch (JSONException e) { + // Should not happen. + CodePushUtils.log("Unable to parse latest rollback metadata " + latestRollbackInfoString + + " stored in SharedPreferences"); + return null; + } + } + + public void setLatestRollbackInfo(String packageHash) { + JSONObject latestRollbackInfo = getLatestRollbackInfo(); + int count = 0; + + if (latestRollbackInfo != null) { + try { + String latestRollbackPackageHash = latestRollbackInfo.getString(CodePushConstants.LATEST_ROLLBACK_PACKAGE_HASH_KEY); + if (latestRollbackPackageHash.equals(packageHash)) { + count = latestRollbackInfo.getInt(CodePushConstants.LATEST_ROLLBACK_COUNT_KEY); + } + } catch (JSONException e) { + CodePushUtils.log("Unable to parse latest rollback info."); + } + } else { + latestRollbackInfo = new JSONObject(); + } + + try { + latestRollbackInfo.put(CodePushConstants.LATEST_ROLLBACK_PACKAGE_HASH_KEY, packageHash); + latestRollbackInfo.put(CodePushConstants.LATEST_ROLLBACK_TIME_KEY, System.currentTimeMillis()); + latestRollbackInfo.put(CodePushConstants.LATEST_ROLLBACK_COUNT_KEY, count + 1); + mSettings.edit().putString(CodePushConstants.LATEST_ROLLBACK_INFO_KEY, latestRollbackInfo.toString()).commit(); + } catch (JSONException e) { + throw new CodePushUnknownException("Unable to save latest rollback info.", e); + } + } + public void savePendingUpdate(String packageHash, boolean isLoading) { JSONObject pendingUpdate = new JSONObject(); try { diff --git a/docs/api-js.md b/docs/api-js.md index 10fd9a9e3..646f54ab2 100644 --- a/docs/api-js.md +++ b/docs/api-js.md @@ -148,6 +148,14 @@ The `codePush` decorator accepts an "options" object that allows you to customiz * __title__ *(String)* - The text used as the header of an update notification that is displayed to the end user. Defaults to `"Update available"`. +* __rollbackRetryOptions__ *(RollbackRetryOptions)* - An "options" object used to determine whether a rollback retry mechanism should be enabled, and if so, what settings to use. Defaults to `null`, which has the effect of disabling the retry mechanism completely. Setting this to any truthy value will enable the retry mechanism with the default settings, and passing an object to this parameter allows enabling the retry mechanism as well as overriding one or more of the default values. The rollback retry mechanism allows the application to attempt to reinstall an update that was previously rolled back (with the restrictions specified in the options). + + The following list represents the available options and their defaults: + + * __delayInHours__ *(Number)* - Specifies the minimum time in hours that the app will wait after the latest rollback before attempting to reinstall the same rolled-back package. Defaults to `24`. + + * __maxRetryAttempts__ *(Number)* - Specifies the maximum number of retry attempts that the app can make before it stops trying. Cannot be less than `1`. Defaults to `1`. + ##### codePushStatusDidChange (event hook) Called when the sync process moves from one stage to another in the overall update process. The event hook is called with a status code which represents the current state, and can be any of the [`SyncStatus`](#syncstatus) values. diff --git a/ios/CodePush/CodePush.h b/ios/CodePush/CodePush.h index 34dc815ca..f1bc26f45 100644 --- a/ios/CodePush/CodePush.h +++ b/ios/CodePush/CodePush.h @@ -62,6 +62,25 @@ */ + (BOOL)isFailedHash:(NSString*)packageHash; + +/* + * This method is used to get information about the latest rollback. + * This information will be used to decide whether the application + * should ignore the update or not. + */ ++ (NSDictionary*)getRollbackInfo; +/* + * This method is used to save information about the latest rollback. + * This information will be used to decide whether the application + * should ignore the update or not. + */ ++ (void)setLatestRollbackInfo:(NSString*)packageHash; +/* + * This method is used to get the count of rollback for the package + * using the latest rollback information. + */ ++ (int)getRollbackCountForPackage:(NSString*) packageHash fromLatestRollbackInfo:(NSMutableDictionary*) latestRollbackInfo; + /* * This method checks to see whether a specific package hash * represents a downloaded and installed update, that hasn't diff --git a/ios/CodePush/CodePush.m b/ios/CodePush/CodePush.m index 198a3e4f9..66ce6ba52 100644 --- a/ios/CodePush/CodePush.m +++ b/ios/CodePush/CodePush.m @@ -73,6 +73,12 @@ @implementation CodePush { static NSString *bundleResourceName = @"main"; static NSString *bundleResourceSubdirectory = nil; +// These keys represent the names we use to store information about the latest rollback +static NSString *const LatestRollbackInfoKey = @"LATEST_ROLLBACK_INFO"; +static NSString *const LatestRollbackPackageHashKey = @"packageHash"; +static NSString *const LatestRollbackTimeKey = @"time"; +static NSString *const LatestRollbackCountKey = @"count"; + + (void)initialize { [super initialize]; @@ -403,6 +409,64 @@ - (void)initializeUpdateAfterRestart } } +/* + * This method is used to get information about the latest rollback. + * This information will be used to decide whether the application + * should ignore the update or not. + */ ++ (NSDictionary *)getLatestRollbackInfo +{ + NSUserDefaults *preferences = [NSUserDefaults standardUserDefaults]; + NSDictionary *latestRollbackInfo = [preferences objectForKey:LatestRollbackInfoKey]; + return latestRollbackInfo; +} + +/* + * This method is used to save information about the latest rollback. + * This information will be used to decide whether the application + * should ignore the update or not. + */ ++ (void)setLatestRollbackInfo:(NSString*)packageHash +{ + if (packageHash == nil) { + return; + } + + NSUserDefaults *preferences = [NSUserDefaults standardUserDefaults]; + NSMutableDictionary *latestRollbackInfo = [preferences objectForKey:LatestRollbackInfoKey]; + if (latestRollbackInfo == nil) { + latestRollbackInfo = [[NSMutableDictionary alloc] init]; + } else { + latestRollbackInfo = [latestRollbackInfo mutableCopy]; + } + + int initialRollbackCount = [self getRollbackCountForPackage: packageHash fromLatestRollbackInfo: latestRollbackInfo]; + NSNumber *count = [NSNumber numberWithInt: initialRollbackCount + 1]; + NSNumber *currentTimeMillis = [NSNumber numberWithDouble: [[NSDate date] timeIntervalSince1970] * 1000]; + + [latestRollbackInfo setValue:count forKey:LatestRollbackCountKey]; + [latestRollbackInfo setValue:currentTimeMillis forKey:LatestRollbackTimeKey]; + [latestRollbackInfo setValue:packageHash forKey:LatestRollbackPackageHashKey]; + + [preferences setObject:latestRollbackInfo forKey:LatestRollbackInfoKey]; + [preferences synchronize]; +} + +/* + * This method is used to get the count of rollback for the package + * using the latest rollback information. + */ ++ (int)getRollbackCountForPackage:(NSString*) packageHash fromLatestRollbackInfo:(NSMutableDictionary*) latestRollbackInfo +{ + NSString *oldPackageHash = [latestRollbackInfo objectForKey:LatestRollbackPackageHashKey]; + if ([packageHash isEqualToString: oldPackageHash]) { + NSNumber *oldCount = [latestRollbackInfo objectForKey:LatestRollbackCountKey]; + return [oldCount intValue]; + } else { + return 0; + } +} + /* * This method checks to see whether a specific package hash * has previously failed installation. @@ -508,6 +572,10 @@ - (void)rollbackPackage */ - (void)saveFailedUpdate:(NSDictionary *)failedPackage { + if ([[self class] isFailedHash:[failedPackage objectForKey:PackageHashKey]]) { + return; + } + NSUserDefaults *preferences = [NSUserDefaults standardUserDefaults]; NSMutableArray *failedUpdates = [preferences objectForKey:FailedUpdatesKey]; if (failedUpdates == nil) { @@ -822,6 +890,21 @@ -(void)loadBundleOnTick:(NSTimer *)timer { resolve(@(isFailedHash)); } +RCT_EXPORT_METHOD(setLatestRollbackInfo:(NSString *)packageHash + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) +{ + [[self class] setLatestRollbackInfo:packageHash]; +} + + +RCT_EXPORT_METHOD(getLatestRollbackInfo:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSDictionary *latestRollbackInfo = [[self class] getLatestRollbackInfo]; + resolve(latestRollbackInfo); +} + /* * This method isn't publicly exposed via the "react-native-code-push" * module, and is only used internally to populate the LocalPackage.isFirstRun property. diff --git a/typings/react-native-code-push.d.ts b/typings/react-native-code-push.d.ts index dc24e2a38..8ba9a912f 100644 --- a/typings/react-native-code-push.d.ts +++ b/typings/react-native-code-push.d.ts @@ -135,6 +135,15 @@ export interface SyncOptions { * overriding one or more of the default strings. */ updateDialog?: UpdateDialog; + + /** + * An "options" object used to determine whether a rollback retry mechanism should be enabled, and if so, what settings to use. + * Defaults to `null`, which has the effect of disabling the retry mechanism completely. Setting this to any truthy value will enable + * the retry mechanism with the default settings, and passing an object to this parameter allows enabling the retry mechanism as well + * as overriding one or more of the default values. The rollback retry mechanism allows the application to attempt to reinstall + * an update that was previously rolled back (with the restrictions specified in the options). + */ + rollbackRetryOptions?: RollbackRetryOptions; } export interface UpdateDialog { @@ -182,6 +191,20 @@ export interface UpdateDialog { title?: string; } +export interface RollbackRetryOptions { + /** + * Specifies the minimum time in hours that the app will wait after the latest rollback + * before attempting to reinstall same rolled-back package. Defaults to `24`. + */ + delayInHours?: number; + + /** + * Specifies the maximum number of retry attempts that the app can make before it stops trying. + * Cannot be less than `1`. Defaults to `1`. + */ + maxRetryAttempts?: number; +} + export interface StatusReport { /** * Whether the deployment succeeded or failed.