Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions CodePush.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ Pod::Spec.new do |s|
s.ios.deployment_target = '15.5'
s.tvos.deployment_target = '15.5'
s.preserve_paths = '*.js'
s.library = 'z'
s.source_files = 'ios/CodePush/*.{h,m}'
# 'z' for SSZipArchive; 'bz2' for the FEAT-2 vendored bspatch (bsdiff/ota_bspatch.c).
s.libraries = ['z', 'bz2']
# Recurse into subdirs and include .c so the vendored bspatch core compiles.
s.source_files = 'ios/CodePush/**/*.{h,m,c}'
s.public_header_files = ['ios/CodePush/CodePush.h']

# Note: Even though there are copy/pasted versions of some of these dependencies in the repo,
Expand Down
48 changes: 48 additions & 0 deletions FEAT-2-NATIVE-INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# FEAT-2 — Native Diff Applier: Integration & Verification Guide

Status of the client (SDK) side of FEAT-2 (differential bundle updates).

## ✅ Done & verified (in this repo, branch `feat/feat-2-bundle-diff`)
- **SHARED-S4** — `client-capabilities.js`: advertises `client_capabilities` on update_check (empty by default). *6 assertions green.*
- **SDK-C1 patch** — `patches/code-push+4.2.2.patch`: forwards `is_diff/diff_url/source_hash/asset_manifest_url` from the server response into `remotePackage`. *Validated: applies clean, valid JS.*
- **T3.1 gate** — `diff-support.js` `shouldAttemptDiff()`: opt-in client gate (`experimental.bundleDiff`). *6 assertions green.*
- **Fields already flow to native:** `package-mixins.js` copies all package fields into `NativeCodePush.downloadUpdate(...)`, so `diffUrl`/`sourceHash` reach native automatically once advertised.

## ✅ Native hook IMPLEMENTED (both platforms) — device-validation pending
- **Android** (`CodePushUpdateManager.downloadPackage` + `CodePushDiffPatcher.java`, `jbsdiff`): when `update_check` offers `diffUrl/sourceHash/bundleHash`, it verifies the current bundle == `sourceHash`, downloads the patch, applies it (jbsdiff), copies the current package (assets) + replaces the bundle, verifies == `bundleHash`, writes metadata. **Any failure ⇒ falls through to the normal full download.**
- **iOS** (`CodePushPackage.downloadPackage` + `CodePushDiffPatcher.{h,m}` + vendored `bsdiff/ota_bspatch.c`, `libbz2`): the exact mirror, using bspatch.

Both reuse the apply core already proven (iOS simulator + jbsdiff format-verified; the vendored `ota_bspatch.c` recompiles to the exact target). The classic full-download path is the default and is preserved by construction.

> **Still required: on-device validation.** This cannot be done in the authoring environment (no Android/iOS app build of the SDK). A mobile dev must build the SDK into an app + point it at the OTA server, then run the device verification checklist below.

## Integration steps (do on a device-capable machine)

### Android
1. Add dependency in `android/build.gradle`: `implementation("io.sigpipe:jbsdiff:1.0")`.
2. In `CodePushUpdateManager.downloadPackage(...)` (or `CodePushNativeModule.downloadUpdate`), **before** the normal full download:
- if `updatePackage.diffUrl` + `sourceHash` are present **and** the on-disk current package hash == `sourceHash`:
- download the patch from `diffUrl`,
- `byte[] target = CodePushDiffPatcher.applyPatch(currentBundleBytes, patchBytes);`
- write `target` into the new update folder as the bundle,
- **verify**: compute the package hash via the existing `CodePushUpdateUtils` and assert it equals `updatePackage.packageHash`.
- on ANY failure or hash mismatch → **fall back** to the existing full download. Never install an unverified bundle.

### iOS
1. Vendor `bspatch.c`/`bspatch.h` (bsdiff project, BSD-2-Clause) into `ios/CodePush/bsdiff/`, exposing `codepush_bspatch(...)` as declared in `CodePushDiffPatcher.m`; add to `CodePush.podspec`; link `libbz2.tbd`.
2. In `CodePushPackage.m` download flow, mirror the Android logic: apply via `CodePushDiffPatcher applyPatch:toSource:error:`, verify hash via `CodePushUpdateUtils`, fall back to full on failure.

### Enable the capability (last)
Only after the native applier is verified on both platforms, add `"bundle-diff"` to `CLIENT_CAPABILITIES` in `client-capabilities.js`. Until then the server never offers diffs (capability not advertised), so everything stays classic.

## Device verification checklist (the real proof)
- [ ] Publish v_n then v_n+1 to a staging deployment (server `OTA_FEATURE_BUNDLE_DIFF=true`).
- [ ] Device on v_n with `experimental.bundleDiff` + `bundle-diff` capability enabled.
- [ ] Update_check returns `diff_url`; device downloads a **few KB** patch (not full).
- [ ] Patched bundle hash == target `packageHash`; app boots on v_n+1; `notifyAppReady` → no rollback.
- [ ] Force a corrupt patch → device **falls back to full download**, installs correctly.
- [ ] Classic build (no capability) → unaffected, full download.
- [ ] Measure bytes transferred vs full; confirm KB-scale.

## Why this split
The server side and JS plumbing are fully unit/integration-tested here. The native appliers require a device toolchain to verify; shipping them un-wired keeps the working OTA path safe while giving the team ready-to-integrate, reviewable code.
3 changes: 3 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,7 @@ android {
dependencies {
implementation "com.facebook.react:react-native:+"
implementation 'com.nimbusds:nimbus-jose-jwt:9.37.3'
// FEAT-2: bsdiff (BSDIFF40) patch applier — format-compatible with the OTA
// server's bsdiff output (verified). Pulls org.apache.commons:commons-compress.
implementation 'io.sigpipe:jbsdiff:1.0'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.microsoft.codepush.react;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.security.MessageDigest;

import io.sigpipe.jbsdiff.Patch;

/**
* FEAT-2 — Android bundle-diff patcher.
*
* Applies a bsdiff (BSDIFF40) patch to the current JS bundle to reconstruct the
* new bundle, and verifies the result hash. Format-compatible with the OTA
* server's `bsdiff` output (validated).
*
* All methods throw on failure; callers MUST catch and fall back to a full
* download (never install an unverified bundle).
*/
public final class CodePushDiffPatcher {

private CodePushDiffPatcher() {}

/**
* Applies {@code patchFile} to {@code sourceFile} (bsdiff) and writes the
* reconstructed bundle to {@code outFile}.
*/
public static void applyPatch(File sourceFile, File patchFile, File outFile) throws Exception {
byte[] source = readFile(sourceFile);
byte[] patch = readFile(patchFile);
ByteArrayOutputStream out = new ByteArrayOutputStream(Math.max(source.length, 1024));
Patch.patch(source, patch, out); // jbsdiff
FileOutputStream fos = new FileOutputStream(outFile);
try {
fos.write(out.toByteArray());
} finally {
fos.close();
}
}

/** Lowercase hex SHA-256 of a file. */
public static String sha256(File file) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(readFile(file));
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}

private static byte[] readFile(File file) throws Exception {
FileInputStream fis = new FileInputStream(file);
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream((int) Math.max(file.length(), 1024));
byte[] buf = new byte[8192];
int n;
while ((n = fis.read(buf)) >= 0) {
bos.write(buf, 0, n);
}
return bos.toByteArray();
} finally {
fis.close();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,107 @@ public JSONObject getPackage(String packageHash) {
}
}

/**
* FEAT-2: try to produce the new bundle by applying a bsdiff patch to the
* current bundle, instead of downloading the full bundle.
*
* Returns true only if a patch was applied AND the reconstructed bundle's hash
* matches the server's target (bundleHash). On any doubt/failure returns false
* so the caller falls back to the normal full download. Never throws; never
* installs an unverified bundle.
*/
private boolean tryApplyBundleDiff(JSONObject updatePackage, String newUpdateHash,
String newUpdateFolderPath, String newUpdateMetadataPath,
String expectedBundleFileName) {
String diffUrl = updatePackage.optString("diffUrl", null);
String sourceHash = updatePackage.optString("sourceHash", null);
String bundleHash = updatePackage.optString("bundleHash", null);
if (diffUrl == null || sourceHash == null || bundleHash == null) {
return false;
}

File patchFile = null;
try {
// 1) Locate the current on-device bundle (the patch source).
String currentBundlePath = getCurrentPackageBundlePath(expectedBundleFileName);
String currentPackageFolderPath = getCurrentPackageFolderPath();
if (currentBundlePath == null || currentPackageFolderPath == null) {
return false;
}
File currentBundle = new File(currentBundlePath);
if (!currentBundle.exists()) {
return false;
}

// 2) The current bundle must be exactly the patch's source.
if (!sourceHash.equalsIgnoreCase(CodePushDiffPatcher.sha256(currentBundle))) {
return false;
}

// 3) Download the (tiny) patch.
patchFile = new File(getCodePushPath(), "ota.patch");
patchFile.getParentFile().mkdirs();
downloadToFile(diffUrl, patchFile);

// 4) Stage: copy the current package (assets etc.) into the new folder, then
// replace the bundle with the patched one (in the same relative location).
FileUtils.copyDirectoryContents(currentPackageFolderPath, newUpdateFolderPath);
String relativeBundlePath = currentBundle.getAbsolutePath().substring(
new File(currentPackageFolderPath).getAbsolutePath().length() + 1);
File newBundle = new File(newUpdateFolderPath, relativeBundlePath);
newBundle.getParentFile().mkdirs();
CodePushDiffPatcher.applyPatch(currentBundle, patchFile, newBundle);

// 5) Verify the reconstructed bundle == the server's target hash.
if (!bundleHash.equalsIgnoreCase(CodePushDiffPatcher.sha256(newBundle))) {
FileUtils.deleteDirectoryAtPath(newUpdateFolderPath);
return false;
}

// 6) Record metadata, mirroring the normal staging path.
CodePushUtils.setJSONValueForKey(updatePackage, CodePushConstants.RELATIVE_BUNDLE_PATH_KEY, relativeBundlePath);
CodePushUtils.writeJsonToFile(updatePackage, newUpdateMetadataPath);
CodePushUtils.log("FEAT-2: applied bsdiff patch (verified) — staged from diff.");
return true;
} catch (Throwable t) {
CodePushUtils.log("FEAT-2: bundle-diff apply failed, falling back to full download: " + t.getMessage());
FileUtils.deleteDirectoryAtPath(newUpdateFolderPath);
return false;
} finally {
if (patchFile != null && patchFile.exists()) {
patchFile.delete();
}
}
}

/** Downloads {@code url} to {@code destFile} over HTTP(S). */
private void downloadToFile(String url, File destFile) throws IOException {
HttpURLConnection connection = null;
BufferedInputStream bin = null;
FileOutputStream fos = null;
BufferedOutputStream bout = null;
try {
connection = (HttpURLConnection) new URL(url).openConnection();
connection.setRequestProperty("Accept-Encoding", "identity");
bin = new BufferedInputStream(connection.getInputStream());
fos = new FileOutputStream(destFile);
bout = new BufferedOutputStream(fos, CodePushConstants.DOWNLOAD_BUFFER_SIZE);
byte[] data = new byte[CodePushConstants.DOWNLOAD_BUFFER_SIZE];
int n;
while ((n = bin.read(data, 0, CodePushConstants.DOWNLOAD_BUFFER_SIZE)) >= 0) {
bout.write(data, 0, n);
}
} finally {
try {
if (bout != null) bout.close();
if (fos != null) fos.close();
if (bin != null) bin.close();
if (connection != null) connection.disconnect();
} catch (IOException ignored) {
}
}
}

public void downloadPackage(JSONObject updatePackage, String expectedBundleFileName,
DownloadProgressCallback progressCallback,
String stringPublicKey) throws IOException {
Expand All @@ -155,6 +256,14 @@ public void downloadPackage(JSONObject updatePackage, String expectedBundleFileN
FileUtils.deleteDirectoryAtPath(newUpdateFolderPath);
}

// FEAT-2: if the server offered a bsdiff patch (diffUrl/sourceHash/bundleHash)
// and we can apply + verify it, stage the patched bundle and return. On ANY
// failure this returns false and we fall through to the normal full download.
if (tryApplyBundleDiff(updatePackage, newUpdateHash, newUpdateFolderPath,
newUpdateMetadataPath, expectedBundleFileName)) {
return;
}

String downloadUrlString = updatePackage.optString(CodePushConstants.DOWNLOAD_URL_KEY, null);
HttpURLConnection connection = null;
BufferedInputStream bin = null;
Expand Down
63 changes: 63 additions & 0 deletions client-capabilities.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Client Capability Advertisement (SHARED-S4)
*
* The SDK tells the OTA server which OPTIONAL features this build can actually
* handle, via the `client_capabilities` query param on `/update_check`. The
* server only returns an optimized response when the client advertised support
* (see server `isFeatureActive`); otherwise it returns the classic response.
*
* IMPORTANT: a capability is added to {@link CLIENT_CAPABILITIES} ONLY once its
* full client-side implementation (including native code) is shipped — never
* before. This guarantees the server never sends an optimization the client
* cannot use. The list is therefore EMPTY by default ⇒ classic behavior, no
* change to existing apps.
*
* This intercepts at our own request adapter (request-fetch-adapter.js), so no
* change to the upstream `code-push` acquisition SDK is required.
*/

/**
* Optional-feature capabilities THIS SDK build can handle.
* Uncomment a capability only when its client implementation is complete.
* @type {string[]}
*/
const CLIENT_CAPABILITIES = [
// "bundle-diff", // FEAT-2 — enable after native bspatch (FEAT-2-T3.2/T3.3) ships
// "asset-cache", // FEAT-1 — enable after native asset cache ships
];

/**
* Builds the comma-separated capability value.
* @param {string[]} [caps]
* @returns {string} e.g. "bundle-diff,asset-cache" (empty string if none)
*/
function buildCapabilityValue(caps) {
const list = caps || CLIENT_CAPABILITIES;
return list.filter(Boolean).join(",");
}

/**
* Appends `client_capabilities` to an `/update_check` URL.
* No-op when there are no capabilities, the input is not a string, or the URL is
* not an update_check request — so the classic request is byte-for-byte unchanged
* by default.
*
* @param {string} url - The fully-built request URL from the acquisition SDK.
* @param {string[]} [caps] - Override capability list (for tests).
* @returns {string} The URL, possibly with `client_capabilities` appended.
*/
function appendClientCapabilities(url, caps) {
const value = buildCapabilityValue(caps);
if (!value) return url;
if (typeof url !== "string") return url;
if (url.indexOf("/update_check") === -1) return url;

const separator = url.indexOf("?") === -1 ? "?" : "&";
return url + separator + "client_capabilities=" + encodeURIComponent(value);
}

module.exports = {
CLIENT_CAPABILITIES,
buildCapabilityValue,
appendClientCapabilities,
};
38 changes: 38 additions & 0 deletions client-capabilities.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Standalone unit test for client-capabilities (SHARED-S4).
* Pure logic, no React Native deps — run directly: `node client-capabilities.test.js`
*/
const assert = require("node:assert");
const { appendClientCapabilities, buildCapabilityValue } = require("./client-capabilities");

const UPDATE_URL = "https://ota.example.com/update_check?deployment_key=k&app_version=1.0.0";

// 1) Default (no capabilities) → URL unchanged (classic behavior preserved)
assert.strictEqual(appendClientCapabilities(UPDATE_URL, []), UPDATE_URL, "no caps → unchanged");
assert.strictEqual(buildCapabilityValue([]), "", "empty caps → empty value");

// 2) With capabilities → appended with '&' (URL already has '?')
assert.strictEqual(
appendClientCapabilities(UPDATE_URL, ["bundle-diff"]),
UPDATE_URL + "&client_capabilities=bundle-diff",
"appends with & when query exists",
);

// 3) Multiple capabilities, comma-joined + encoded
assert.strictEqual(
appendClientCapabilities("https://h/update_check", ["bundle-diff", "asset-cache"]),
"https://h/update_check?client_capabilities=bundle-diff%2Casset-cache",
"appends with ? when no query; comma-encoded",
);

// 4) Non-update_check URLs are never touched
assert.strictEqual(
appendClientCapabilities("https://h/report_status/deploy", ["bundle-diff"]),
"https://h/report_status/deploy",
"non-update_check → unchanged",
);

// 5) Non-string input is safe
assert.strictEqual(appendClientCapabilities(undefined, ["bundle-diff"]), undefined, "undefined → unchanged");

console.log("client-capabilities.test.js: all 6 assertions passed ✓");
34 changes: 34 additions & 0 deletions diff-support.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Client-side bundle-diff support (FEAT-2 · T3.1)
*
* The optional diff fields (`diffUrl`, `sourceHash`) reach the native layer
* automatically: `package-mixins.js` copies every field of the remote package
* into the object passed to `NativeCodePush.downloadUpdate`, and the SDK-C1
* patch makes the upstream acquisition SDK forward those fields.
*
* This module only provides the OPT-IN GATE: the client attempts a diff download
* exactly when the server offered one AND the app enabled it. Native (T3.2/T3.3)
* mirrors this check before applying a patch. Default (no opt-in) ⇒ classic full
* download, unchanged.
*
* @module diff-support
*/

/**
* Whether the client should attempt a differential (patch) download for a
* remote package, instead of a full download.
*
* @param {object} remotePackage - The remote package (may carry diffUrl/sourceHash).
* @param {object} [syncOptions] - Sync options; diff is opt-in via experimental.bundleDiff.
* @returns {boolean} true ⇒ attempt diff; false ⇒ full download (classic).
*/
function shouldAttemptDiff(remotePackage, syncOptions) {
if (!remotePackage || typeof remotePackage !== "object") return false;
const enabled = !!(syncOptions && syncOptions.experimental && syncOptions.experimental.bundleDiff);
if (!enabled) return false;
// Server must have offered a usable diff against the client's current bundle.
return typeof remotePackage.diffUrl === "string" && remotePackage.diffUrl.length > 0
&& typeof remotePackage.sourceHash === "string" && remotePackage.sourceHash.length > 0;
}

module.exports = { shouldAttemptDiff };
Loading