From fb6f322e54e8234e2965b2ea6430be1d8fd430dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliv=C3=A9r=20Falvai?= Date: Tue, 22 Sep 2026 16:58:59 +0200 Subject: [PATCH] JS SDK: advertise binary_diff:bsdiff capability to the server Sends the client's capabilities on both update_check (as a plain repeated capabilities= query param, no bracket notation) and report_status/deploy (as a JSON array), so the server can serve v2 (bsdiff) diffs and meter their size correctly. The two calls must agree: report_status/deploy's capabilities drive the server's byte-count reconstruction independently of what update_check advertised, so omitting it there would silently over-report downloaded bytes for clients that received a v2 diff. The capability is advertised only when the app enables delta updates (CodePushEnableDeltaUpdates in Info.plist / strings.xml). The native side rejects a v2 diff when the flag is off, and the server would keep serving the same diff, so advertising it unconditionally would block that release on the device. Both platforms now expose the flag through getConfiguration(). On iOS it stays a typed property on CodePushConfig and is serialized only at the JS boundary, not stored in the untyped config dictionary. Replaces queryStringify with a toQueryString that expands array values into a repeated key. It builds the string by hand, because the URLSearchParams polyfill in RN 0.76-0.79 accepts only a plain object, which cannot hold a repeated key. The E2E test apps enable delta updates, and checkForUpdate.update asserts the capability on update_check, which covers the flag end to end (native config -> getConfiguration() -> SDK). Also adds CodePushEnableDeltaUpdates to the Expo config plugin for both platforms, since expo prebuild regenerates the native config and Expo apps had no way to set the flag. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../microsoft/codepush/react/CodePush.java | 9 +- .../codepush/react/CodePushNativeModule.java | 1 + expo.js | 16 +++ ios/CodePush/CodePushConfig.m | 8 +- .../__tests__/capabilities.test.ts | 133 ++++++++++++++++++ src/acquisition-sdk/acquisition-sdk.ts | 38 ++--- src/acquisition-sdk/types.ts | 2 + .../app/src/main/res/values/strings.xml | 1 + test/template/app.json | 6 +- test/test.ts | 7 + 10 files changed, 198 insertions(+), 23 deletions(-) create mode 100644 src/acquisition-sdk/__tests__/capabilities.test.ts 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 d66e9a1b..75b8d6a2 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 @@ -51,6 +51,7 @@ public static CodePush getInstance(String deploymentKey, Context context, boolea // Config properties. private String mDeploymentKey; private static String mServerUrl = "https://codepush.appcenter.ms/"; + private final boolean mEnableDeltaUpdates; private Context mContext; private final boolean mIsDebugMode; @@ -70,8 +71,8 @@ public static String getServiceUrl() { private CodePush(String deploymentKey, Context context, boolean isDebugMode) { mContext = context.getApplicationContext(); - boolean enableDeltaUpdates = getBooleanCustomPropertyFromStringsIfExist("EnableDeltaUpdates", false); - mUpdateManager = new CodePushUpdateManager(context.getFilesDir().getAbsolutePath(), enableDeltaUpdates); + mEnableDeltaUpdates = getBooleanCustomPropertyFromStringsIfExist("EnableDeltaUpdates", false); + mUpdateManager = new CodePushUpdateManager(context.getFilesDir().getAbsolutePath(), mEnableDeltaUpdates); mTelemetryManager = new CodePushTelemetryManager(mContext); mDeploymentKey = deploymentKey; mIsDebugMode = isDebugMode; @@ -289,6 +290,10 @@ public String getServerUrl() { return mServerUrl; } + public boolean isDeltaUpdatesEnabled() { + return mEnableDeltaUpdates; + } + void initializeUpdateAfterRestart() { // Reset the state which indicates that // the app was just freshly updated. 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 45194bb2..224f1228 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 @@ -426,6 +426,7 @@ public void getConfiguration(Promise promise) { configMap.putString("clientUniqueId", mClientUniqueId); configMap.putString("deploymentKey", mCodePush.getDeploymentKey()); configMap.putString("serverUrl", mCodePush.getServerUrl()); + configMap.putBoolean("enableDeltaUpdates", mCodePush.isDeltaUpdatesEnabled()); // The binary hash may be null in debug builds if (mBinaryContentsHash != null) { diff --git a/expo.js b/expo.js index 49b3cf25..fd266828 100644 --- a/expo.js +++ b/expo.js @@ -92,6 +92,9 @@ const withCodePushInfoPlist = (config, options = {}) => { if (options.ios && options.ios.CodePushPublicKey) { config.modResults.CodePushPublicKey = options.ios.CodePushPublicKey; } + if (typeof options.ios?.CodePushEnableDeltaUpdates === 'boolean') { + config.modResults.CodePushEnableDeltaUpdates = options.ios.CodePushEnableDeltaUpdates; + } return config; }); }; @@ -330,6 +333,19 @@ const withAndroidStrings = (config, options) => { if (options.android?.CodePushPublicKey) { setString('CodePushPublicKey', options.android.CodePushPublicKey); } + // Read natively as a bool resource (not a string), so it goes into its own element. + if (typeof options.android?.CodePushEnableDeltaUpdates === 'boolean') { + if (!config.modResults.resources.bool) config.modResults.resources.bool = []; + const bools = config.modResults.resources.bool; + const name = 'CodePushEnableDeltaUpdates'; + const value = String(options.android.CodePushEnableDeltaUpdates); + const existing = bools.find(b => b.$.name === name); + if (existing) { + existing._ = value; + } else { + bools.push({ $: { name }, _: value }); + } + } return config; }); }; diff --git a/ios/CodePush/CodePushConfig.m b/ios/CodePush/CodePushConfig.m index 3a6e3091..f3302947 100644 --- a/ios/CodePush/CodePushConfig.m +++ b/ios/CodePush/CodePushConfig.m @@ -1,6 +1,9 @@ #import "CodePush.h" #import +// Legacy untyped store: do not add new fields here. Keep new config as typed properties +// (like enableDeltaUpdates) and serialize them only in -configuration, at the JS boundary. +// A TurboModule spec with codegen should eventually replace this dictionary entirely. @implementation CodePushConfig { NSMutableDictionary *_configDictionary; } @@ -13,6 +16,7 @@ @implementation CodePushConfig { static NSString * const DeploymentKeyConfigKey = @"deploymentKey"; static NSString * const ServerURLConfigKey = @"serverUrl"; static NSString * const PublicKeyKey = @"publicKey"; +static NSString * const EnableDeltaUpdatesConfigKey = @"enableDeltaUpdates"; + (instancetype)current { @@ -74,7 +78,9 @@ - (NSString *)buildVersion - (NSDictionary *)configuration { - return _configDictionary; + NSMutableDictionary *configuration = [_configDictionary mutableCopy]; + configuration[EnableDeltaUpdatesConfigKey] = @(_enableDeltaUpdates); + return configuration; } - (NSString *)deploymentKey diff --git a/src/acquisition-sdk/__tests__/capabilities.test.ts b/src/acquisition-sdk/__tests__/capabilities.test.ts new file mode 100644 index 00000000..f488f870 --- /dev/null +++ b/src/acquisition-sdk/__tests__/capabilities.test.ts @@ -0,0 +1,133 @@ +import * as assert from "assert"; +import * as querystring from "querystring"; + +import * as acquisitionSdk from "../acquisition-sdk"; +import * as mockApi from "./acquisition-rest-mock"; + +const configuration: acquisitionSdk.Configuration = { + appVersion: "1.5.0", + clientUniqueId: "My iPhone", + deploymentKey: mockApi.validDeploymentKey, + serverUrl: mockApi.serverUrl, + enableDeltaUpdates: true +}; + +const deltaUpdatesDisabledConfiguration: acquisitionSdk.Configuration = { ...configuration, enableDeltaUpdates: undefined }; + +const currentPackage: acquisitionSdk.Package = { + deploymentKey: mockApi.validDeploymentKey, + description: "Standard description", + label: "v1", + appVersion: "1.5.0", + packageHash: "hash001", + isMandatory: false, + packageSize: 100 +}; + +const okResponse: acquisitionSdk.Http.Response = { + statusCode: 200, + body: JSON.stringify({ update_info: { is_available: false } }) +}; + +// Captures the exact request the SDK sends, instead of simulating a server response - the +// point of these tests is what goes over the wire, not how the SDK reacts to a reply. +class CapturingRequester implements acquisitionSdk.Http.Requester { + public lastUrl: string; + public lastBody: string; + + public request( + verb: acquisitionSdk.Http.Verb, + url: string, + requestBodyOrCallback: string | acquisitionSdk.Callback, + callback?: acquisitionSdk.Callback + ): void { + this.lastUrl = url; + + if (typeof requestBodyOrCallback === "string") { + this.lastBody = requestBodyOrCallback; + callback(/*error*/ null, okResponse); + } else { + requestBodyOrCallback(/*error*/ null, okResponse); + } + } +} + +describe("Capabilities advertisement", () => { + it("update_check sends capabilities as a plain repeated query param, not bracketed", (done: Mocha.Done) => { + var requester = new CapturingRequester(); + var acquisition = new acquisitionSdk.AcquisitionManager(requester, configuration); + + acquisition.queryUpdateWithCurrentPackage(currentPackage, () => { + var query = requester.lastUrl.split("?")[1]; + var params = querystring.parse(query); + + assert.strictEqual(params.capabilities, "binary_diff:bsdiff"); + assert.strictEqual(query.includes("capabilities%5B%5D"), false, "must not use bracket notation"); + done(); + }); + }); + + it("update_check omits undefined optional fields instead of the literal string \"undefined\"", (done: Mocha.Done) => { + var requester = new CapturingRequester(); + var acquisition = new acquisitionSdk.AcquisitionManager(requester, configuration); + var freshInstallPackage: acquisitionSdk.Package = { ...currentPackage, packageHash: undefined, label: undefined }; + + acquisition.queryUpdateWithCurrentPackage(freshInstallPackage, () => { + var query = requester.lastUrl.split("?")[1]; + var params = querystring.parse(query); + + assert.strictEqual(params.package_hash, undefined); + assert.strictEqual(params.label, undefined); + assert.strictEqual(query.includes("undefined"), false); + done(); + }); + }); + + it("report_status/deploy sends capabilities as a JSON array", (done: Mocha.Done) => { + var requester = new CapturingRequester(); + var acquisition = new acquisitionSdk.AcquisitionManager(requester, configuration); + + acquisition.reportStatusDeploy( + currentPackage, + acquisitionSdk.AcquisitionStatus.DeploymentSucceeded, + /*previousLabelOrAppVersion*/ undefined, + /*previousDeploymentKey*/ undefined, + () => { + var body = JSON.parse(requester.lastBody); + + assert.deepStrictEqual(body.capabilities, ["binary_diff:bsdiff"]); + done(); + } + ); + }); + + it("update_check sends no capabilities when delta updates are not enabled", (done: Mocha.Done) => { + var requester = new CapturingRequester(); + var acquisition = new acquisitionSdk.AcquisitionManager(requester, deltaUpdatesDisabledConfiguration); + + acquisition.queryUpdateWithCurrentPackage(currentPackage, () => { + var params = querystring.parse(requester.lastUrl.split("?")[1]); + + assert.strictEqual(params.capabilities, undefined); + done(); + }); + }); + + it("report_status/deploy sends an empty capabilities array when delta updates are not enabled", (done: Mocha.Done) => { + var requester = new CapturingRequester(); + var acquisition = new acquisitionSdk.AcquisitionManager(requester, deltaUpdatesDisabledConfiguration); + + acquisition.reportStatusDeploy( + currentPackage, + acquisitionSdk.AcquisitionStatus.DeploymentSucceeded, + /*previousLabelOrAppVersion*/ undefined, + /*previousDeploymentKey*/ undefined, + () => { + var body = JSON.parse(requester.lastBody); + + assert.deepStrictEqual(body.capabilities, []); + done(); + } + ); + }); +}); diff --git a/src/acquisition-sdk/acquisition-sdk.ts b/src/acquisition-sdk/acquisition-sdk.ts index d570a2c1..7e1904dc 100644 --- a/src/acquisition-sdk/acquisition-sdk.ts +++ b/src/acquisition-sdk/acquisition-sdk.ts @@ -57,6 +57,7 @@ export interface Configuration { deploymentKey: string; serverUrl: string; ignoreAppVersion?: boolean + enableDeltaUpdates?: boolean; } export class AcquisitionStatus { @@ -72,6 +73,7 @@ export class DownloadStatus { export class AcquisitionManager { private readonly BASE_URL_PART = "appcenter.ms"; private _appVersion: string; + private _capabilities: string[]; private _clientUniqueId: string; private _deploymentKey: string; private _httpRequester: Http.Requester; @@ -92,6 +94,10 @@ export class AcquisitionManager { this._clientUniqueId = configuration.clientUniqueId; this._deploymentKey = configuration.deploymentKey; this._ignoreAppVersion = configuration.ignoreAppVersion; + + // Sent on update_check and report_status/deploy. Must match what the native side will + // accept: it rejects binary diffs unless delta updates are enabled in the app config. + this._capabilities = configuration.enableDeltaUpdates ? ["binary_diff:bsdiff"] : []; } private isRecoverable = (statusCode: number): boolean => statusCode >= 500 || statusCode === 408 || statusCode === 429; @@ -119,10 +125,11 @@ export class AcquisitionManager { package_hash: currentPackage.packageHash, is_companion: this._ignoreAppVersion, label: currentPackage.label, - client_unique_id: this._clientUniqueId + client_unique_id: this._clientUniqueId, + capabilities: this._capabilities }; - var requestUrl: string = this._serverUrl + this._publicPrefixUrl + "update_check?" + queryStringify(updateRequest); + var requestUrl: string = this._serverUrl + this._publicPrefixUrl + "update_check?" + toQueryString(updateRequest); this._httpRequester.request(Http.Verb.GET, requestUrl, (error: Error, response: Http.Response) => { if (error) { @@ -187,6 +194,7 @@ export class AcquisitionManager { var url: string = this._serverUrl + this._publicPrefixUrl + "report_status/deploy"; var body: DeploymentStatusReport = { app_version: this._appVersion, + capabilities: this._capabilities, deployment_key: this._deploymentKey }; @@ -283,25 +291,19 @@ export class AcquisitionManager { } } -function queryStringify(object: Object): string { - var queryString = ""; - var isFirst: boolean = true; +// Built by hand because RN 0.76-0.79's URLSearchParams polyfill accepts only a plain object, +// which cannot hold a repeated key. RN 0.80 accepts [key, value] pairs, so this can become +// `new URLSearchParams(pairs)` once support for RN 0.76-0.79 is dropped. +function toQueryString(request: UpdateCheckRequest): string { + var pairs: string[] = []; - for (var property in object) { - if (object.hasOwnProperty(property)) { - var value: string = (object)[property]; - if (value !== null && typeof value !== "undefined") { - if (!isFirst) { - queryString += "&"; - } - - queryString += encodeURIComponent(property) + "="; - queryString += encodeURIComponent(value); + for (var [key, value] of Object.entries(request)) { + for (var element of Array.isArray(value) ? value : [value]) { + if (element !== null && typeof element !== "undefined") { + pairs.push(encodeURIComponent(key) + "=" + encodeURIComponent(String(element))); } - - isFirst = false; } } - return queryString; + return pairs.join("&"); } diff --git a/src/acquisition-sdk/types.ts b/src/acquisition-sdk/types.ts index 798f45ee..50dd3a2c 100644 --- a/src/acquisition-sdk/types.ts +++ b/src/acquisition-sdk/types.ts @@ -5,6 +5,7 @@ /*in*/ export interface DeploymentStatusReport { app_version: string; + capabilities?: string[]; client_unique_id?: string; deployment_key: string; previous_deployment_key?: string; @@ -44,6 +45,7 @@ export interface UpdateCheckResponse { /*in*/ export interface UpdateCheckRequest { app_version: string; + capabilities?: string[]; client_unique_id?: string; deployment_key: string; is_companion?: boolean; diff --git a/test/template/android/app/src/main/res/values/strings.xml b/test/template/android/app/src/main/res/values/strings.xml index f82ef1d2..af9228e7 100644 --- a/test/template/android/app/src/main/res/values/strings.xml +++ b/test/template/android/app/src/main/res/values/strings.xml @@ -2,4 +2,5 @@ TestCodePush CODE_PUSH_ANDROID_DEPLOYMENT_KEY CODE_PUSH_SERVER_URL + true diff --git a/test/template/app.json b/test/template/app.json index a4b29d25..a8400a2b 100644 --- a/test/template/app.json +++ b/test/template/app.json @@ -20,12 +20,14 @@ "ios": { "CodePushDeploymentKey": "mock-ios-deployment-key", "CodePushServerURL": "http://127.0.0.1:3000", - "CodePushPublicKey": "{{CODE_SIGNING_PUBLIC_KEY}}" + "CodePushPublicKey": "{{CODE_SIGNING_PUBLIC_KEY}}", + "CodePushEnableDeltaUpdates": true }, "android": { "CodePushDeploymentKey": "mock-android-deployment-key", "CodePushServerURL": "http://10.0.2.2:3001", - "CodePushPublicKey": "{{CODE_SIGNING_PUBLIC_KEY}}" + "CodePushPublicKey": "{{CODE_SIGNING_PUBLIC_KEY}}", + "CodePushEnableDeltaUpdates": true } } ] diff --git a/test/test.ts b/test/test.ts index 579e4c60..c9fdd995 100644 --- a/test/test.ts +++ b/test/test.ts @@ -46,6 +46,10 @@ async function setPlistStringValue(plistPath: string, key: string, value: string await promisify(childProcess.execFile)("plutil", ["-replace", key, "-string", value, plistPath]); } +async function setPlistBoolValue(plistPath: string, key: string, value: boolean): Promise { + await promisify(childProcess.execFile)("plutil", ["-replace", key, "-bool", String(value), plistPath]); +} + /** * Returns a " --platform " flag for `expo prebuild` when exactly one platform is * under test in this mocha run, so prebuild only regenerates that platform's native project @@ -253,6 +257,7 @@ class RNIOS extends Platform.IOS implements RNPlatform { .then(() => setPlistStringValue(infoPlistPath, "CodePushDeploymentKey", this.getDefaultDeploymentKey())) .then(() => setPlistStringValue(infoPlistPath, "CodePushServerURL", this.getServerUrl())) .then(() => setPlistStringValue(infoPlistPath, "CodePushPublicKey", codeSigningPublicKey)) + .then(() => setPlistBoolValue(infoPlistPath, "CodePushEnableDeltaUpdates", true)) // Fix the linker flag list in project.pbxproj (pod install adds an extra comma) .then(TestUtil.replaceString.bind(undefined, path.join(iOSProject, TestConfig.TestAppName + ".xcodeproj", "project.pbxproj"), "\"[$][(]inherited[)]\",\\s*[)];", "\"$(inherited)\"\n\t\t\t\t);")) @@ -865,6 +870,8 @@ PluginTestingFramework.initializeTests(new RNProjectManager(), supportedTargetPl try { assert.notStrictEqual(null, request); assert.strictEqual(request.query.deployment_key, targetPlatform.getDefaultDeploymentKey()); + // The test apps enable delta updates, so this checks the flag end to end: native config -> getConfiguration() -> SDK. + assert.strictEqual(request.query.capabilities, "binary_diff:bsdiff"); } catch (e) { done(e); }