Skip to content

Commit 693b769

Browse files
Yuri Kulikovalexandergoncharov-zz
Yuri Kulikov
authored andcommitted
[Feature] Re-try mechanism for CodePush Rollbacks (#1467)
1 parent ac5472e commit 693b769

File tree

9 files changed

+292
-4
lines changed

9 files changed

+292
-4
lines changed

CodePush.js

+73-1
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ async function tryReportStatus(statusReport, resumeListener) {
197197
log(`Reporting CodePush update success (${label})`);
198198
} else {
199199
log(`Reporting CodePush update rollback (${label})`);
200+
await NativeCodePush.setLatestRollbackInfo(statusReport.package.packageHash);
200201
}
201202

202203
config.deploymentKey = statusReport.package.deploymentKey;
@@ -225,6 +226,71 @@ async function tryReportStatus(statusReport, resumeListener) {
225226
}
226227
}
227228

229+
async function shouldUpdateBeIgnored(remotePackage, syncOptions) {
230+
let { rollbackRetryOptions } = syncOptions;
231+
232+
const isFailedPackage = remotePackage && remotePackage.failedInstall;
233+
if (!isFailedPackage || !syncOptions.ignoreFailedUpdates) {
234+
return false;
235+
}
236+
237+
if (!rollbackRetryOptions) {
238+
return true;
239+
}
240+
241+
if (typeof rollbackRetryOptions !== "object") {
242+
rollbackRetryOptions = CodePush.DEFAULT_ROLLBACK_RETRY_OPTIONS;
243+
} else {
244+
rollbackRetryOptions = { ...CodePush.DEFAULT_ROLLBACK_RETRY_OPTIONS, ...rollbackRetryOptions };
245+
}
246+
247+
if (!validateRollbackRetryOptions(rollbackRetryOptions)) {
248+
return true;
249+
}
250+
251+
const latestRollbackInfo = await NativeCodePush.getLatestRollbackInfo();
252+
if (!validateLatestRollbackInfo(latestRollbackInfo, remotePackage.packageHash)) {
253+
log("The latest rollback info is not valid.");
254+
return true;
255+
}
256+
257+
const { delayInHours, maxRetryAttempts } = rollbackRetryOptions;
258+
const hoursSinceLatestRollback = (Date.now() - latestRollbackInfo.time) / (1000 * 60 * 60);
259+
if (hoursSinceLatestRollback >= delayInHours && maxRetryAttempts >= latestRollbackInfo.count) {
260+
log("Previous rollback should be ignored due to rollback retry options.");
261+
return false;
262+
}
263+
264+
return true;
265+
}
266+
267+
function validateLatestRollbackInfo(latestRollbackInfo, packageHash) {
268+
return latestRollbackInfo &&
269+
latestRollbackInfo.time &&
270+
latestRollbackInfo.count &&
271+
latestRollbackInfo.packageHash &&
272+
latestRollbackInfo.packageHash === packageHash;
273+
}
274+
275+
function validateRollbackRetryOptions(rollbackRetryOptions) {
276+
if (typeof rollbackRetryOptions.delayInHours !== "number") {
277+
log("The 'delayInHours' rollback retry parameter must be a number.");
278+
return false;
279+
}
280+
281+
if (typeof rollbackRetryOptions.maxRetryAttempts !== "number") {
282+
log("The 'maxRetryAttempts' rollback retry parameter must be a number.");
283+
return false;
284+
}
285+
286+
if (rollbackRetryOptions.maxRetryAttempts < 1) {
287+
log("The 'maxRetryAttempts' rollback retry parameter cannot be less then 1.");
288+
return false;
289+
}
290+
291+
return true;
292+
}
293+
228294
var testConfig;
229295

230296
// 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
293359
const syncOptions = {
294360
deploymentKey: null,
295361
ignoreFailedUpdates: true,
362+
rollbackRetryOptions: null,
296363
installMode: CodePush.InstallMode.ON_NEXT_RESTART,
297364
mandatoryInstallMode: CodePush.InstallMode.IMMEDIATE,
298365
minimumBackgroundDuration: 0,
@@ -360,7 +427,8 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg
360427
return CodePush.SyncStatus.UPDATE_INSTALLED;
361428
};
362429

363-
const updateShouldBeIgnored = remotePackage && (remotePackage.failedInstall && syncOptions.ignoreFailedUpdates);
430+
const updateShouldBeIgnored = await shouldUpdateBeIgnored(remotePackage, syncOptions);
431+
364432
if (!remotePackage || updateShouldBeIgnored) {
365433
if (updateShouldBeIgnored) {
366434
log("An update is available, but it is being ignored due to having been previously rolled back.");
@@ -585,6 +653,10 @@ if (NativeCodePush) {
585653
optionalInstallButtonLabel: "Install",
586654
optionalUpdateMessage: "An update is available. Would you like to install it?",
587655
title: "Update available"
656+
},
657+
DEFAULT_ROLLBACK_RETRY_OPTIONS: {
658+
delayInHours: 24,
659+
maxRetryAttempts: 1
588660
}
589661
});
590662
} else {

android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java

+4
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,8 @@ public class CodePushConstants {
2727
public static final String UNZIPPED_FOLDER_NAME = "unzipped";
2828
public static final String CODE_PUSH_APK_BUILD_TIME_KEY = "CODE_PUSH_APK_BUILD_TIME";
2929
public static final String BUNDLE_JWT_FILE = ".codepushrelease";
30+
public static final String LATEST_ROLLBACK_INFO_KEY = "LATEST_ROLLBACK_INFO";
31+
public static final String LATEST_ROLLBACK_PACKAGE_HASH_KEY = "packageHash";
32+
public static final String LATEST_ROLLBACK_TIME_KEY = "time";
33+
public static final String LATEST_ROLLBACK_COUNT_KEY = "count";
3034
}

android/app/src/main/java/com/microsoft/codepush/react/CodePushNativeModule.java

+27-1
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,33 @@ public void onHostDestroy() {
510510
public void isFailedUpdate(String packageHash, Promise promise) {
511511
try {
512512
promise.resolve(mSettingsManager.isFailedHash(packageHash));
513-
} catch(CodePushUnknownException e) {
513+
} catch (CodePushUnknownException e) {
514+
CodePushUtils.log(e);
515+
promise.reject(e);
516+
}
517+
}
518+
519+
@ReactMethod
520+
public void getLatestRollbackInfo(Promise promise) {
521+
try {
522+
JSONObject latestRollbackInfo = mSettingsManager.getLatestRollbackInfo();
523+
if (latestRollbackInfo != null) {
524+
promise.resolve(CodePushUtils.convertJsonObjectToWritable(latestRollbackInfo));
525+
} else {
526+
promise.resolve(null);
527+
}
528+
} catch (CodePushUnknownException e) {
529+
CodePushUtils.log(e);
530+
promise.reject(e);
531+
}
532+
}
533+
534+
@ReactMethod
535+
public void setLatestRollbackInfo(String packageHash, Promise promise) {
536+
try {
537+
mSettingsManager.setLatestRollbackInfo(packageHash);
538+
promise.resolve(null);
539+
} catch (CodePushUnknownException e) {
514540
CodePushUtils.log(e);
515541
promise.reject(e);
516542
}

android/app/src/main/java/com/microsoft/codepush/react/CodePushUtils.java

+2
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ else if (obj instanceof String)
8181
map.putString(key, (String) obj);
8282
else if (obj instanceof Double)
8383
map.putDouble(key, (Double) obj);
84+
else if (obj instanceof Long)
85+
map.putDouble(key, ((Long) obj).doubleValue());
8486
else if (obj instanceof Integer)
8587
map.putInt(key, (Integer) obj);
8688
else if (obj instanceof Boolean)

android/app/src/main/java/com/microsoft/codepush/react/SettingsManager.java

+53-2
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,7 @@ public boolean isPendingUpdate(String packageHash) {
7474
return pendingUpdate != null &&
7575
!pendingUpdate.getBoolean(CodePushConstants.PENDING_UPDATE_IS_LOADING_KEY) &&
7676
(packageHash == null || pendingUpdate.getString(CodePushConstants.PENDING_UPDATE_HASH_KEY).equals(packageHash));
77-
}
78-
catch (JSONException e) {
77+
} catch (JSONException e) {
7978
throw new CodePushUnknownException("Unable to read pending update metadata in isPendingUpdate.", e);
8079
}
8180
}
@@ -89,6 +88,15 @@ public void removePendingUpdate() {
8988
}
9089

9190
public void saveFailedUpdate(JSONObject failedPackage) {
91+
try {
92+
if (isFailedHash(failedPackage.getString(CodePushConstants.PACKAGE_HASH_KEY))) {
93+
// Do not need to add the package if it is already in the failedUpdates.
94+
return;
95+
}
96+
} catch (JSONException e) {
97+
throw new CodePushUnknownException("Unable to read package hash from package.", e);
98+
}
99+
92100
String failedUpdatesString = mSettings.getString(CodePushConstants.FAILED_UPDATES_KEY, null);
93101
JSONArray failedUpdates;
94102
if (failedUpdatesString == null) {
@@ -107,6 +115,49 @@ public void saveFailedUpdate(JSONObject failedPackage) {
107115
mSettings.edit().putString(CodePushConstants.FAILED_UPDATES_KEY, failedUpdates.toString()).commit();
108116
}
109117

118+
public JSONObject getLatestRollbackInfo() {
119+
String latestRollbackInfoString = mSettings.getString(CodePushConstants.LATEST_ROLLBACK_INFO_KEY, null);
120+
if (latestRollbackInfoString == null) {
121+
return null;
122+
}
123+
124+
try {
125+
return new JSONObject(latestRollbackInfoString);
126+
} catch (JSONException e) {
127+
// Should not happen.
128+
CodePushUtils.log("Unable to parse latest rollback metadata " + latestRollbackInfoString +
129+
" stored in SharedPreferences");
130+
return null;
131+
}
132+
}
133+
134+
public void setLatestRollbackInfo(String packageHash) {
135+
JSONObject latestRollbackInfo = getLatestRollbackInfo();
136+
int count = 0;
137+
138+
if (latestRollbackInfo != null) {
139+
try {
140+
String latestRollbackPackageHash = latestRollbackInfo.getString(CodePushConstants.LATEST_ROLLBACK_PACKAGE_HASH_KEY);
141+
if (latestRollbackPackageHash.equals(packageHash)) {
142+
count = latestRollbackInfo.getInt(CodePushConstants.LATEST_ROLLBACK_COUNT_KEY);
143+
}
144+
} catch (JSONException e) {
145+
CodePushUtils.log("Unable to parse latest rollback info.");
146+
}
147+
} else {
148+
latestRollbackInfo = new JSONObject();
149+
}
150+
151+
try {
152+
latestRollbackInfo.put(CodePushConstants.LATEST_ROLLBACK_PACKAGE_HASH_KEY, packageHash);
153+
latestRollbackInfo.put(CodePushConstants.LATEST_ROLLBACK_TIME_KEY, System.currentTimeMillis());
154+
latestRollbackInfo.put(CodePushConstants.LATEST_ROLLBACK_COUNT_KEY, count + 1);
155+
mSettings.edit().putString(CodePushConstants.LATEST_ROLLBACK_INFO_KEY, latestRollbackInfo.toString()).commit();
156+
} catch (JSONException e) {
157+
throw new CodePushUnknownException("Unable to save latest rollback info.", e);
158+
}
159+
}
160+
110161
public void savePendingUpdate(String packageHash, boolean isLoading) {
111162
JSONObject pendingUpdate = new JSONObject();
112163
try {

docs/api-js.md

+8
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,14 @@ The `codePush` decorator accepts an "options" object that allows you to customiz
148148

149149
* __title__ *(String)* - The text used as the header of an update notification that is displayed to the end user. Defaults to `"Update available"`.
150150

151+
* __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).
152+
153+
The following list represents the available options and their defaults:
154+
155+
* __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`.
156+
157+
* __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`.
158+
151159
##### codePushStatusDidChange (event hook)
152160

153161
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.

ios/CodePush/CodePush.h

+19
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,25 @@
6262
*/
6363
+ (BOOL)isFailedHash:(NSString*)packageHash;
6464

65+
66+
/*
67+
* This method is used to get information about the latest rollback.
68+
* This information will be used to decide whether the application
69+
* should ignore the update or not.
70+
*/
71+
+ (NSDictionary*)getRollbackInfo;
72+
/*
73+
* This method is used to save information about the latest rollback.
74+
* This information will be used to decide whether the application
75+
* should ignore the update or not.
76+
*/
77+
+ (void)setLatestRollbackInfo:(NSString*)packageHash;
78+
/*
79+
* This method is used to get the count of rollback for the package
80+
* using the latest rollback information.
81+
*/
82+
+ (int)getRollbackCountForPackage:(NSString*) packageHash fromLatestRollbackInfo:(NSMutableDictionary*) latestRollbackInfo;
83+
6584
/*
6685
* This method checks to see whether a specific package hash
6786
* represents a downloaded and installed update, that hasn't

ios/CodePush/CodePush.m

+83
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ @implementation CodePush {
7373
static NSString *bundleResourceName = @"main";
7474
static NSString *bundleResourceSubdirectory = nil;
7575

76+
// These keys represent the names we use to store information about the latest rollback
77+
static NSString *const LatestRollbackInfoKey = @"LATEST_ROLLBACK_INFO";
78+
static NSString *const LatestRollbackPackageHashKey = @"packageHash";
79+
static NSString *const LatestRollbackTimeKey = @"time";
80+
static NSString *const LatestRollbackCountKey = @"count";
81+
7682
+ (void)initialize
7783
{
7884
[super initialize];
@@ -403,6 +409,64 @@ - (void)initializeUpdateAfterRestart
403409
}
404410
}
405411

412+
/*
413+
* This method is used to get information about the latest rollback.
414+
* This information will be used to decide whether the application
415+
* should ignore the update or not.
416+
*/
417+
+ (NSDictionary *)getLatestRollbackInfo
418+
{
419+
NSUserDefaults *preferences = [NSUserDefaults standardUserDefaults];
420+
NSDictionary *latestRollbackInfo = [preferences objectForKey:LatestRollbackInfoKey];
421+
return latestRollbackInfo;
422+
}
423+
424+
/*
425+
* This method is used to save information about the latest rollback.
426+
* This information will be used to decide whether the application
427+
* should ignore the update or not.
428+
*/
429+
+ (void)setLatestRollbackInfo:(NSString*)packageHash
430+
{
431+
if (packageHash == nil) {
432+
return;
433+
}
434+
435+
NSUserDefaults *preferences = [NSUserDefaults standardUserDefaults];
436+
NSMutableDictionary *latestRollbackInfo = [preferences objectForKey:LatestRollbackInfoKey];
437+
if (latestRollbackInfo == nil) {
438+
latestRollbackInfo = [[NSMutableDictionary alloc] init];
439+
} else {
440+
latestRollbackInfo = [latestRollbackInfo mutableCopy];
441+
}
442+
443+
int initialRollbackCount = [self getRollbackCountForPackage: packageHash fromLatestRollbackInfo: latestRollbackInfo];
444+
NSNumber *count = [NSNumber numberWithInt: initialRollbackCount + 1];
445+
NSNumber *currentTimeMillis = [NSNumber numberWithDouble: [[NSDate date] timeIntervalSince1970] * 1000];
446+
447+
[latestRollbackInfo setValue:count forKey:LatestRollbackCountKey];
448+
[latestRollbackInfo setValue:currentTimeMillis forKey:LatestRollbackTimeKey];
449+
[latestRollbackInfo setValue:packageHash forKey:LatestRollbackPackageHashKey];
450+
451+
[preferences setObject:latestRollbackInfo forKey:LatestRollbackInfoKey];
452+
[preferences synchronize];
453+
}
454+
455+
/*
456+
* This method is used to get the count of rollback for the package
457+
* using the latest rollback information.
458+
*/
459+
+ (int)getRollbackCountForPackage:(NSString*) packageHash fromLatestRollbackInfo:(NSMutableDictionary*) latestRollbackInfo
460+
{
461+
NSString *oldPackageHash = [latestRollbackInfo objectForKey:LatestRollbackPackageHashKey];
462+
if ([packageHash isEqualToString: oldPackageHash]) {
463+
NSNumber *oldCount = [latestRollbackInfo objectForKey:LatestRollbackCountKey];
464+
return [oldCount intValue];
465+
} else {
466+
return 0;
467+
}
468+
}
469+
406470
/*
407471
* This method checks to see whether a specific package hash
408472
* has previously failed installation.
@@ -508,6 +572,10 @@ - (void)rollbackPackage
508572
*/
509573
- (void)saveFailedUpdate:(NSDictionary *)failedPackage
510574
{
575+
if ([[self class] isFailedHash:[failedPackage objectForKey:PackageHashKey]]) {
576+
return;
577+
}
578+
511579
NSUserDefaults *preferences = [NSUserDefaults standardUserDefaults];
512580
NSMutableArray *failedUpdates = [preferences objectForKey:FailedUpdatesKey];
513581
if (failedUpdates == nil) {
@@ -822,6 +890,21 @@ -(void)loadBundleOnTick:(NSTimer *)timer {
822890
resolve(@(isFailedHash));
823891
}
824892

893+
RCT_EXPORT_METHOD(setLatestRollbackInfo:(NSString *)packageHash
894+
resolve:(RCTPromiseResolveBlock)resolve
895+
reject:(RCTPromiseRejectBlock)reject)
896+
{
897+
[[self class] setLatestRollbackInfo:packageHash];
898+
}
899+
900+
901+
RCT_EXPORT_METHOD(getLatestRollbackInfo:(RCTPromiseResolveBlock)resolve
902+
rejecter:(RCTPromiseRejectBlock)reject)
903+
{
904+
NSDictionary *latestRollbackInfo = [[self class] getLatestRollbackInfo];
905+
resolve(latestRollbackInfo);
906+
}
907+
825908
/*
826909
* This method isn't publicly exposed via the "react-native-code-push"
827910
* module, and is only used internally to populate the LocalPackage.isFirstRun property.

0 commit comments

Comments
 (0)