diff --git a/CodePush.js b/CodePush.js index c55d15a22..12cd2380f 100644 --- a/CodePush.js +++ b/CodePush.js @@ -6,8 +6,62 @@ import { SemverVersioning } from './versioning/SemverVersioning' let NativeCodePush = require("react-native").NativeModules.CodePush; const PackageMixins = require("./package-mixins")(NativeCodePush); +const RolloutStorage = require("react-native").NativeModules.RolloutStorage; -const DEPLOYMENT_KEY = 'deprecated_deployment_key'; +const DEPLOYMENT_KEY = 'deprecated_deployment_key', + ROLLOUT_CACHE_PREFIX = 'CodePushRolloutDecision_', + ROLLOUT_CACHE_KEY = 'CodePushRolloutKey'; + +function hashDeviceId(deviceId) { + let hash = 0; + for (let i = 0; i < deviceId.length; i++) { + hash = ((hash << 5) - hash) + deviceId.charCodeAt(i); + hash |= 0; // Convert to 32bit int + } + return Math.abs(hash); +} + +function getRolloutKey(label, rollout) { + return `${ROLLOUT_CACHE_PREFIX}${label}_rollout_${rollout ?? 100}`; +} + +function getBucket(clientId, packageHash) { + const hash = hashDeviceId(`${clientId ?? ''}_${packageHash ?? ''}`); + return (Math.abs(hash) % 100); +} + +export async function shouldApplyCodePushUpdate(remotePackage, clientId, onRolloutSkipped) { + if (remotePackage.rollout === undefined || remotePackage.rollout >= 100) { + return true; + } + + const rolloutKey = getRolloutKey(remotePackage.label, remotePackage.rollout), + cachedDecision = await RolloutStorage.getItem(rolloutKey); + + if (cachedDecision != null) { + // should apply if cachedDecision is true + return cachedDecision === 'true'; + } + + const bucket = getBucket(clientId, remotePackage.packageHash), + inRollout = bucket < remotePackage.rollout, + prevRolloutCacheKey = await RolloutStorage.getItem(ROLLOUT_CACHE_KEY); + + console.log(`[CodePush] Bucket: ${bucket}, rollout: ${remotePackage.rollout} → ${inRollout ? 'IN' : 'OUT'}`); + + if(prevRolloutCacheKey) + await RolloutStorage.removeItem(prevRolloutCacheKey); + + await RolloutStorage.setItem(ROLLOUT_CACHE_KEY, rolloutKey); + await RolloutStorage.setItem(rolloutKey, inRollout.toString()); + + if (!inRollout) { + console.log(`[CodePush] Skipping update due to rollout. Bucket ${bucket} >= rollout ${remotePackage.rollout}`); + onRolloutSkipped?.(remotePackage.label); + } + + return inRollout; +} async function checkForUpdate(handleBinaryVersionMismatchCallback = null) { /* @@ -121,6 +175,7 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) { package_size: 0, // not used at runtime. should_run_binary_version: false, + rollout: latestReleaseInfo.rollout }; return mapToRemotePackageMetadata(updateInfo); @@ -164,6 +219,13 @@ async function checkForUpdate(handleBinaryVersionMismatchCallback = null) { return null; } else { const remotePackage = { ...update, ...PackageMixins.remote() }; + + // Rollout filtering + const shouldApply = await shouldApplyCodePushUpdate(remotePackage, nativeConfig.clientUniqueId, sharedCodePushOptions?.onRolloutSkipped); + + if(!shouldApply) + return { skipRollout: true }; + remotePackage.failedInstall = await NativeCodePush.isFailedUpdate(remotePackage.packageHash); return remotePackage; } @@ -193,6 +255,7 @@ function mapToRemotePackageMetadata(updateInfo) { packageHash: updateInfo.package_hash ?? '', packageSize: updateInfo.package_size ?? 0, downloadUrl: updateInfo.download_url ?? '', + rollout: updateInfo.rollout ?? 100, }; } @@ -493,6 +556,11 @@ async function syncInternal(options = {}, syncStatusChangeCallback, downloadProg return CodePush.SyncStatus.UPDATE_INSTALLED; }; + if(remotePackage?.skipRollout){ + syncStatusChangeCallback(CodePush.SyncStatus.UP_TO_DATE); + return CodePush.SyncStatus.UP_TO_DATE; + } + const updateShouldBeIgnored = await shouldUpdateBeIgnored(remotePackage, syncOptions); if (!remotePackage || updateShouldBeIgnored) { @@ -609,6 +677,9 @@ let CodePush; * * onSyncError: (label: string, error: Error) => void | undefined, * setOnSyncError(onSyncErrorFunction: (label: string, error: Error) => void | undefined): void, + * + * onRolloutSkipped: (label: string, error: Error) => void | undefined, + * setOnRolloutSkipped(onRolloutSkippedFunction: (label: string, error: Error) => void | undefined): void, * }} */ const sharedCodePushOptions = { @@ -653,6 +724,12 @@ const sharedCodePushOptions = { if (typeof onSyncErrorFunction !== 'function') throw new Error('Please pass a function to onSyncError'); this.onSyncError = onSyncErrorFunction; }, + onRolloutSkipped: undefined, + setOnRolloutSkipped(onRolloutSkippedFunction) { + if (!onRolloutSkippedFunction) return; + if (typeof onRolloutSkippedFunction !== 'function') throw new Error('Please pass a function to onRolloutSkipped'); + this.onRolloutSkipped = onRolloutSkippedFunction; + } } function codePushify(options = {}) { @@ -688,6 +765,7 @@ function codePushify(options = {}) { sharedCodePushOptions.setOnDownloadStart(options.onDownloadStart); sharedCodePushOptions.setOnDownloadSuccess(options.onDownloadSuccess); sharedCodePushOptions.setOnSyncError(options.onSyncError); + sharedCodePushOptions.setOnRolloutSkipped(options.onRolloutSkipped); const decorator = (RootComponent) => { class CodePushComponent extends React.Component { diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePush.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePush.java index 5e8688f41..aee9d84b9 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePush.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePush.java @@ -10,6 +10,7 @@ import com.facebook.react.bridge.JavaScriptModule; import com.facebook.react.bridge.NativeModule; import com.facebook.react.bridge.ReactApplicationContext; +import com.microsoft.codepush.react.RolloutStorageModule; import com.facebook.react.devsupport.interfaces.DevSupportManager; import com.facebook.react.modules.debug.interfaces.DeveloperSettings; import com.facebook.react.uimanager.ViewManager; @@ -404,10 +405,12 @@ static ReactInstanceManager getReactInstanceManager() { public List createNativeModules(ReactApplicationContext reactApplicationContext) { CodePushNativeModule codePushModule = new CodePushNativeModule(reactApplicationContext, this, mUpdateManager, mTelemetryManager, mSettingsManager); CodePushDialog dialogModule = new CodePushDialog(reactApplicationContext); + RolloutStorageModule rolloutStorageModule = new RolloutStorageModule(reactApplicationContext); List nativeModules = new ArrayList<>(); nativeModules.add(codePushModule); nativeModules.add(dialogModule); + nativeModules.add(rolloutStorageModule); return nativeModules; } diff --git a/android/app/src/main/java/com/microsoft/codepush/react/RolloutStorageModule.java b/android/app/src/main/java/com/microsoft/codepush/react/RolloutStorageModule.java new file mode 100644 index 000000000..2fa6d6edb --- /dev/null +++ b/android/app/src/main/java/com/microsoft/codepush/react/RolloutStorageModule.java @@ -0,0 +1,38 @@ +package com.microsoft.codepush.react; + +import android.content.Context; +import android.content.SharedPreferences; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.Promise; + +public class RolloutStorageModule extends ReactContextBaseJavaModule { + + private final SharedPreferences prefs; + + public RolloutStorageModule(ReactApplicationContext reactContext) { + super(reactContext); + prefs = reactContext.getSharedPreferences("CodePushPrefs", Context.MODE_PRIVATE); + } + + @Override + public String getName() { + return "RolloutStorage"; + } + + @ReactMethod + public void getItem(String key, Promise promise) { + promise.resolve(prefs.getString(key, null)); + } + + @ReactMethod + public void setItem(String key, String value) { + prefs.edit().putString(key, value).apply(); + } + + @ReactMethod + public void removeItem(String key) { + prefs.edit().remove(key).apply(); + } +} diff --git a/cli/commands/createHistoryCommand/createReleaseHistory.js b/cli/commands/createHistoryCommand/createReleaseHistory.js index ab435e794..c8a97f652 100644 --- a/cli/commands/createHistoryCommand/createReleaseHistory.js +++ b/cli/commands/createHistoryCommand/createReleaseHistory.js @@ -27,6 +27,7 @@ async function createReleaseHistory( mandatory: false, downloadUrl: "", packageHash: "", + rollout: 100 }; /** @type {ReleaseHistoryInterface} */ diff --git a/cli/commands/releaseCommand/addToReleaseHistory.js b/cli/commands/releaseCommand/addToReleaseHistory.js index 4d8ed85a0..4af97b8fc 100644 --- a/cli/commands/releaseCommand/addToReleaseHistory.js +++ b/cli/commands/releaseCommand/addToReleaseHistory.js @@ -25,6 +25,7 @@ const fs = require("fs"); * @param identifier {string?} * @param mandatory {boolean?} * @param enable {boolean?} + * @param rollout {number?} * @returns {Promise} */ async function addToReleaseHistory( @@ -38,6 +39,7 @@ async function addToReleaseHistory( identifier, mandatory, enable, + rollout ) { const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier); @@ -54,6 +56,7 @@ async function addToReleaseHistory( mandatory: mandatory, downloadUrl: bundleDownloadUrl, packageHash: packageHash, + rollout: rollout } try { diff --git a/cli/commands/releaseCommand/index.js b/cli/commands/releaseCommand/index.js index 6863a885f..fffeefe26 100644 --- a/cli/commands/releaseCommand/index.js +++ b/cli/commands/releaseCommand/index.js @@ -16,6 +16,7 @@ program.command('release') .option('-j, --js-bundle-name ', 'JS bundle file name (default-ios: "main.jsbundle" / default-android: "index.android.bundle")') .option('-m, --mandatory ', 'make the release to be mandatory', parseBoolean, false) .option('--enable ', 'make the release to be enabled', parseBoolean, true) + .option('--rollout ', 'rollout percentage (0-100)', parseFloat) .option('--skip-bundle ', 'skip bundle process', parseBoolean, false) .option('--skip-cleanup ', 'skip cleanup process', parseBoolean, false) .option('--output-bundle-dir ', 'name of directory containing the bundle file created by the "bundle" command', OUTPUT_BUNDLE_DIR) @@ -32,6 +33,7 @@ program.command('release') * @param {string} options.bundleName * @param {string} options.mandatory * @param {string} options.enable + * @param {number} options.rollout * @param {string} options.skipBundle * @param {string} options.skipCleanup * @param {string} options.outputBundleDir @@ -54,6 +56,7 @@ program.command('release') options.bundleName, options.mandatory, options.enable, + options.rollout, options.skipBundle, options.skipCleanup, `${options.outputPath}/${options.outputBundleDir}`, diff --git a/cli/commands/releaseCommand/release.js b/cli/commands/releaseCommand/release.js index 2aa1745fb..9f4be2230 100644 --- a/cli/commands/releaseCommand/release.js +++ b/cli/commands/releaseCommand/release.js @@ -33,6 +33,7 @@ const { addToReleaseHistory } = require("./addToReleaseHistory"); * @param jsBundleName {string} * @param mandatory {boolean} * @param enable {boolean} + * @param rollout {number} * @param skipBundle {boolean} * @param skipCleanup {boolean} * @param bundleDirectory {string} @@ -52,6 +53,7 @@ async function release( jsBundleName, mandatory, enable, + rollout, skipBundle, skipCleanup, bundleDirectory, @@ -82,6 +84,7 @@ async function release( identifier, mandatory, enable, + rollout, ) if (!skipCleanup) { diff --git a/cli/commands/updateHistoryCommand/index.js b/cli/commands/updateHistoryCommand/index.js index 7f76781bb..f2b5a14c7 100644 --- a/cli/commands/updateHistoryCommand/index.js +++ b/cli/commands/updateHistoryCommand/index.js @@ -12,6 +12,7 @@ program.command('update-history') .option('-c, --config ', 'set config file name (JS/TS)', CONFIG_FILE_NAME) .option('-m, --mandatory ', 'make the release to be mandatory', parseBoolean, undefined) .option('-e, --enable ', 'make the release to be enabled', parseBoolean, undefined) + .option('--rollout ', 'rollout percentage (0-100)', parseFloat, undefined) /** * @param {Object} options * @param {string} options.appVersion @@ -21,6 +22,7 @@ program.command('update-history') * @param {string} options.config * @param {string} options.mandatory * @param {string} options.enable + * @param {number} options.rollout * @return {void} */ .action(async (options) => { @@ -40,6 +42,7 @@ program.command('update-history') options.identifier, options.mandatory, options.enable, + options.rollout ) }); diff --git a/cli/commands/updateHistoryCommand/updateReleaseHistory.js b/cli/commands/updateHistoryCommand/updateReleaseHistory.js index d6a7e31eb..0e70db26f 100644 --- a/cli/commands/updateHistoryCommand/updateReleaseHistory.js +++ b/cli/commands/updateHistoryCommand/updateReleaseHistory.js @@ -23,6 +23,7 @@ const path = require('path'); * @param identifier {string?} * @param mandatory {boolean?} * @param enable {boolean?} + * @param rollout {number?} * @returns {Promise} */ async function updateReleaseHistory( @@ -34,6 +35,7 @@ async function updateReleaseHistory( identifier, mandatory, enable, + rollout ) { const releaseHistory = await getReleaseHistory(binaryVersion, platform, identifier); @@ -42,6 +44,7 @@ async function updateReleaseHistory( if (typeof mandatory === "boolean") updateInfo.mandatory = mandatory; if (typeof enable === "boolean") updateInfo.enabled = enable; + if (typeof rollout === "number") updateInfo.rollout = rollout; try { const JSON_FILE_NAME = `${binaryVersion}.json`; diff --git a/ios/CodePush/RolloutStorage.h b/ios/CodePush/RolloutStorage.h new file mode 100644 index 000000000..e98e78ef5 --- /dev/null +++ b/ios/CodePush/RolloutStorage.h @@ -0,0 +1,4 @@ +#import + +@interface RolloutStorage : NSObject +@end diff --git a/ios/CodePush/RolloutStorage.m b/ios/CodePush/RolloutStorage.m new file mode 100644 index 000000000..76f463002 --- /dev/null +++ b/ios/CodePush/RolloutStorage.m @@ -0,0 +1,22 @@ +#import "RolloutStorage.h" + +@implementation RolloutStorage + +RCT_EXPORT_MODULE(); + +RCT_EXPORT_METHOD(setItem:(NSString *)key value:(NSString *)value) { + [[NSUserDefaults standardUserDefaults] setObject:value forKey:key]; +} + +RCT_EXPORT_METHOD(getItem:(NSString *)key + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + NSString *value = [[NSUserDefaults standardUserDefaults] stringForKey:key]; + resolve(value); +} + +RCT_EXPORT_METHOD(removeItem:(NSString *)key) { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:key]; +} + +@end diff --git a/typings/react-native-code-push.d.ts b/typings/react-native-code-push.d.ts index 60cf47018..cc38559ab 100644 --- a/typings/react-native-code-push.d.ts +++ b/typings/react-native-code-push.d.ts @@ -29,6 +29,7 @@ export interface ReleaseInfo { mandatory: boolean; downloadUrl: string; packageHash: string; + rollout: number; } // from code-push SDK @@ -93,6 +94,10 @@ export interface CodePushOptions extends SyncOptions { * Callback function that is called when sync process failed. */ onSyncError?: (label: string, error: Error) => void; + /** + * Callback function that is called when rollout is skipped. + */ + onRolloutSkipped?: (label: string, error: Error) => void; } export interface DownloadProgress {