Skip to content
Draft
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
16 changes: 16 additions & 0 deletions .github/scripts/run-android-tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail

variant="$1"
test_command="$2"

if [ "$variant" = "bare" ]; then
# These tests are independent of the bare/expo distinction. Bare tests are slightly faster.
echo "::group::Instrumented tests"
(cd android && ./gradlew :app:connectedAndroidTest)
echo "::endgroup::"
fi

echo "::group::E2E tests"
npm run "$test_command"
echo "::endgroup::"
31 changes: 16 additions & 15 deletions .github/workflows/ci-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,17 +104,12 @@ jobs:
arch: x86
disable-animations: true
# connectedAndroidTest is included here (rather than a separate job or step) to reuse
# this job's already-booted emulator, even though it means it runs once per matrix
# variant. The android-emulator-runner action has no post-cleanup step, so a second
# step would boot and tear down a second emulator; ::group:: markers keep the two
# test runs visually separated in the Actions log instead.
script: |
echo "::group::Instrumented tests"
(cd android && ./gradlew :app:connectedAndroidTest)
echo "::endgroup::"
echo "::group::E2E tests"
npm run ${{ matrix.test-command }}
echo "::endgroup::"
# this job's already-booted emulator. The emulator only lives for the duration of this
# action (it's killed at the end of the same invocation, not in a post/cleanup step),
# so the logic can't be split into a later workflow step - it has to run here.
# android-emulator-runner also runs each line of `script` as its own separate `sh -c`
# invocation, so multi-line shell logic is delegated to a script file instead of inlining here.
script: .github/scripts/run-android-tests.sh ${{ matrix.variant }} ${{ matrix.test-command }}

ios-test:
needs: lint
Expand All @@ -141,10 +136,16 @@ jobs:
restore-keys: |
${{ runner.os }}-npm-

- name: Run iOS Tests
run: |
npm install
npm run ${{ matrix.test-command }}
- name: Install dependencies
run: npm install

- name: Run iOS unit tests
# These tests are independent of the bare/expo distinction. Bare tests are slightly faster.
if: matrix.variant == 'bare'
run: npm run test:unit:ios
Comment thread
ofalvai marked this conversation as resolved.

- name: Run iOS E2E Tests
run: npm run ${{ matrix.test-command }}

- name: Upload iOS Simulator crash reports
if: failure()
Expand Down
2 changes: 2 additions & 0 deletions .npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ android/.gradle
android/**/*.iml
android/.idea

ios/CodePushDiffPatchTests/


# Windows
windows/.vs/
Expand Down
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ React Native CodePush is a native module that enables over-the-air updates for R

#### Unit tests

- `cd android && ./gradlew :app:test`
- iOS: no unit tests yet.
- `npm run test:unit:android`
- `npm run test:unit:ios`

Prefer unit testing what's possible (even though, on iOS, this involves a simulator). Legacy code used E2E tests for everything, which is complex, error-prone, and slow. The existing E2E tests are still useful, but this is not a pattern to follow.

#### E2E Tests
- `npm run test:android` - Run Android-specific tests
Expand Down Expand Up @@ -54,7 +56,7 @@ React Native CodePush is a native module that enables over-the-air updates for R
- **Custom Test Runner**: TypeScript-based test framework in `test/`
- **Real App Testing**: Creates actual React Native apps for integration testing
- **Scenario Testing**: Update, rollback, and error scenarios
- **No unit test infra for JS/iOS yet**: JS/iOS only have the mocha-based integration suite above. `src/acquisition-sdk/__tests__/` contains tests ported from upstream `microsoft/code-push`, kept for future reference - they are deliberately not wired into `npm test` or any runner. Don't assume they're dead/forgotten code, and don't wire them in without setting up real unit test infra first.
- **No unit test infra for JS yet**: JS only has the mocha-based integration suite above. `src/acquisition-sdk/__tests__/` contains tests ported from upstream `microsoft/code-push`, kept for future reference - they are deliberately not wired into `npm test` or any runner. Don't assume they're dead/forgotten code, and don't wire them in without setting up real unit test infra first.
- **Templates**: `test/template/` holds native files (Podfile, AppDelegate, Android app files) and JS scenarios copied over top of a freshly generated RN/Expo app during test setup, overwriting its defaults — edit files here, not the generated project, for changes to persist
- **`test:ios` vs `test:setup:ios` vs `test:fast:ios`**: `test:ios` is just `test:setup:ios` followed by `test:fast:ios` — the two are meant to be split apart for local iteration.
- `test:setup:ios` (mocha `--ios --setup`) boots the simulator and provisions the test app once: copies templates, runs `pod install`, patches Info.plist/AppDelegate. It never builds or runs any test scenario.
Expand Down
19 changes: 16 additions & 3 deletions CodePush.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,23 @@ 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}'
s.libraries = 'z', 'bz2'
s.source_files = [
'ios/CodePush/*.{h,m}',
'shared/diffpatch/*.{c,h}',
'shared/third_party/hdiffpatch/libHDiffPatch/HPatch/patch.{c,h}',
'shared/third_party/hdiffpatch/bsdiff_wrapper/bspatch_wrapper.{c,h}',
'shared/third_party/hdiffpatch/file_for_patch.{c,h}',
]
s.public_header_files = ['ios/CodePush/CodePush.h']
s.pod_target_xcconfig = { "DEFINES_MODULE" => "YES" }
s.pod_target_xcconfig = {
"DEFINES_MODULE" => "YES",
# HDiffPatch's bspatch-only usage: no multithreading, no directory diff/patch, and no raw
# block device support (which would otherwise probe Linux-only <linux/fs.h> ioctls).
# Keep in sync with android/app/src/main/cpp/CMakeLists.txt.
"GCC_PREPROCESSOR_DEFINITIONS" => "$(inherited) _IS_NEED_BLOCK_DEV=0 _IS_USED_MULTITHREAD=0 _IS_NEED_DIR_DIFF_PATCH=0",
"HEADER_SEARCH_PATHS" => "$(inherited) $(PODS_TARGET_SRCROOT)/shared $(PODS_TARGET_SRCROOT)/shared/diffpatch $(PODS_TARGET_SRCROOT)/shared/third_party/hdiffpatch $(PODS_TARGET_SRCROOT)/shared/third_party/hdiffpatch/libHDiffPatch/HPatch",
}

# Note: Even though there are copy/pasted versions of some of these dependencies in the repo,
# we explicitly let CocoaPods pull in the versions below so all dependencies are resolved and
Expand Down
1 change: 1 addition & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ dependencies {
implementation 'com.nimbusds:nimbus-jose-jwt:9.37.3'

testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20231013'

androidTestImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
Expand Down
12 changes: 7 additions & 5 deletions android/app/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ project(codepush_diffpatch C)
# See third_party/README.md for what we vendor and why.
add_library(codepush_diffpatch SHARED
diffpatch_jni.c
bspatch_bridge.c
../../../../../shared/diffpatch/bspatch_bridge.c

../../../../../shared/third_party/hdiffpatch/libHDiffPatch/HPatch/patch.c
../../../../../shared/third_party/hdiffpatch/bsdiff_wrapper/bspatch_wrapper.c
../../../../../shared/third_party/hdiffpatch/file_for_patch.c

third_party/hdiffpatch/libHDiffPatch/HPatch/patch.c
third_party/hdiffpatch/bsdiff_wrapper/bspatch_wrapper.c
third_party/hdiffpatch/file_for_patch.c

bzip2_error_stub.c
third_party/bzip2/bzlib.c
third_party/bzip2/decompress.c
Expand All @@ -28,6 +28,8 @@ add_library(codepush_diffpatch SHARED

target_include_directories(codepush_diffpatch PRIVATE
.
../../../../../shared/diffpatch
../../../../../shared
third_party/bzip2
)

Expand Down
19 changes: 1 addition & 18 deletions android/app/src/main/cpp/third_party/README.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,6 @@
# Vendored sources

These directories contain trimmed copies of two upstream libraries, pinned
to a specific commit. Only the files needed to *apply* a BSDIFF40-style
patch (the `hdiffz -BSD` producer output) are vendored. `hdiffpatch/` is an
unmodified-source copy; `bzip2/bzlib.c` carries one small, documented patch
(see below).

## hdiffpatch/

Source: https://github.com/sisong/HDiffPatch
Pinned commit: `3b9dca715ca492873bf2c49e22e5d5b7d2a78620` (2026-07-31)
License: MIT.

Files were chosen by tracing the actual dependency graph of
`bsdiff_wrapper/bspatch_wrapper.c` (the BSDIFF40-compatible patch applier),
not by directory boundaries. In particular `libHDiffPatch/HPatch/patch.c`
(~157KB) is HDiffPatch's own diff-format decoder, but it's still required
here because `bspatch_wrapper.c` shares its low-level stream-cache helpers
(`_TOutStreamCache_*`, `getStreamClip`, `_patch_cache_all_old`, etc.).
Note: `shared/third_party/` also contains vendored sources, shared between Android and iOS.

## bzip2/

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ public static String getServiceUrl() {
private CodePush(String deploymentKey, Context context, boolean isDebugMode) {
mContext = context.getApplicationContext();

mUpdateManager = new CodePushUpdateManager(context.getFilesDir().getAbsolutePath());
boolean enableDeltaUpdates = getBooleanCustomPropertyFromStringsIfExist("EnableDeltaUpdates", false);
mUpdateManager = new CodePushUpdateManager(context.getFilesDir().getAbsolutePath(), enableDeltaUpdates);
mTelemetryManager = new CodePushTelemetryManager(mContext);
mDeploymentKey = deploymentKey;
mIsDebugMode = isDebugMode;
Expand Down Expand Up @@ -156,6 +157,17 @@ private String getCustomPropertyFromStringsIfExist(String propertyName) {
return null;
}

private boolean getBooleanCustomPropertyFromStringsIfExist(String propertyName, boolean defaultValue) {
String packageName = mContext.getPackageName();
int resId = mContext.getResources().getIdentifier("CodePush" + propertyName, "bool", packageName);

if (resId != 0) {
return mContext.getResources().getBoolean(resId);
}

return defaultValue;
}

public void clearDebugCacheIfNeeded(boolean isLiveReloadEnabled) {
// for checking if we use LiveReload mode. In this case we should not remove ReactNativeDevBundle.js file
// because we get error with trying to get this after reloading. Issue: https://github.com/microsoft/react-native-code-push/issues/1272
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ public class CodePushConstants {
public static final String CURRENT_PACKAGE_KEY = "currentPackage";
public static final String DEFAULT_JS_BUNDLE_NAME = "index.android.bundle";
public static final String DIFF_MANIFEST_FILE_NAME = "hotcodepush.json";
// Folder within the update ZIP that contains the diff patches. Must be in sync with server-side impl.
public static final String DIFF_PATCHES_FOLDER_NAME = "__hcp_patches";
public static final int DOWNLOAD_BUFFER_SIZE = 1024 * 256;
public static final String DOWNLOAD_FILE_NAME = "download.zip";
public static final String DOWNLOAD_PROGRESS_EVENT_NAME = "CodePushDownloadProgress";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

import android.os.Build;

import com.microsoft.codepush.react.diffpatch.BinaryDiffPatcher;
import com.microsoft.codepush.react.diffpatch.DiffManifest;
import com.microsoft.codepush.react.diffpatch.DiffManifestKt;

import org.json.JSONException;
import org.json.JSONObject;

import java.io.BufferedInputStream;
Expand All @@ -19,9 +24,11 @@
public class CodePushUpdateManager {

private String mDocumentsDirectory;
private boolean mEnableDeltaUpdates;

public CodePushUpdateManager(String documentsDirectory) {
public CodePushUpdateManager(String documentsDirectory, boolean enableDeltaUpdates) {
mDocumentsDirectory = documentsDirectory;
mEnableDeltaUpdates = enableDeltaUpdates;
}

private String getDownloadFilePath() {
Expand Down Expand Up @@ -237,14 +244,38 @@ public void downloadPackage(JSONObject updatePackage, String expectedBundleFileN
String diffManifestFilePath = CodePushUtils.appendPathComponent(unzippedFolderPath,
CodePushConstants.DIFF_MANIFEST_FILE_NAME);
boolean isDiffUpdate = FileUtils.fileAtPathExists(diffManifestFilePath);
DiffManifest diffManifest = null;
if (isDiffUpdate) {
try {
diffManifest = DiffManifestKt.parseDiffManifest(CodePushUtils.getJsonObjectFromFile(diffManifestFilePath));
} catch (JSONException e) {
throw new CodePushMalformedDataException(diffManifestFilePath, e);
}
String currentPackageFolderPath = getCurrentPackageFolderPath();
CodePushUpdateUtils.copyNecessaryFilesFromCurrentPackage(diffManifestFilePath, currentPackageFolderPath, newUpdateFolderPath);
CodePushUpdateUtils.copyNecessaryFilesFromCurrentPackage(diffManifest, currentPackageFolderPath, newUpdateFolderPath);
File diffManifestFile = new File(diffManifestFilePath);
diffManifestFile.delete();
}

FileUtils.copyDirectoryContents(unzippedFolderPath, newUpdateFolderPath);

if (isDiffUpdate) {
// Run patching after copyNecessaryFilesFromCurrentPackage() so patched output overwrites
// bytes copied in from the old package at the same paths.
if (diffManifest.getVersion() > 2 || diffManifest.getVersion() < 1) {
throw new IOException("Diff manifest version " + diffManifest.getVersion() + " is not supported by this SDK version.");
} else if (diffManifest.getVersion() == 2 && !mEnableDeltaUpdates) {
throw new IOException("Received a binary diff update, but delta updates are not enabled on this client. Set CodePushEnableDeltaUpdates to true in strings.xml to enable them.");
} else if (diffManifest.getVersion() == 2) {
String currentPackageFolderPath = getCurrentPackageFolderPath();
if (currentPackageFolderPath == null) {
throw new CodePushInvalidUpdateException("Received a binary diff update, but no currently installed package exists to diff against (this is likely the first CodePush update for this app install). Diffing against the embedded app binary is not yet supported.");
}
BinaryDiffPatcher.applyBinaryDiffPatches(diffManifest, new File(currentPackageFolderPath), new File(unzippedFolderPath), new File(newUpdateFolderPath));
FileUtils.deleteDirectoryAtPath(new File(newUpdateFolderPath, CodePushConstants.DIFF_PATCHES_FOLDER_NAME).getPath());
}
}

FileUtils.deleteFileAtPathSilently(unzippedFolderPath);

// For zip updates, we need to find the relative path to the jsBundle and save it in the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,24 @@
import android.content.Context;
import android.util.Base64;

import com.microsoft.codepush.react.diffpatch.DiffManifest;
import com.microsoft.codepush.react.diffpatch.Sha256;

import com.nimbusds.jose.JWSVerifier;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jwt.SignedJWT;

import java.security.interfaces.*;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.security.DigestInputStream;
import java.security.KeyFactory;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.X509EncodedKeySpec;
import java.util.ArrayList;
Expand Down Expand Up @@ -73,51 +71,32 @@ private static void addContentsOfFolderToManifest(String folderPath, String path
}

private static String computeHash(InputStream dataStream) {
MessageDigest messageDigest = null;
DigestInputStream digestInputStream = null;
try {
messageDigest = MessageDigest.getInstance("SHA-256");
digestInputStream = new DigestInputStream(dataStream, messageDigest);
byte[] byteBuffer = new byte[1024 * 8];
while (digestInputStream.read(byteBuffer) != -1) ;
} catch (NoSuchAlgorithmException | IOException e) {
return Sha256.sha256Hex(dataStream);
} catch (Exception e) {
// Should not happen.
throw new CodePushUnknownException("Unable to compute hash of update contents.", e);
} finally {
try {
if (digestInputStream != null) {
digestInputStream.close();
}
if (dataStream != null) {
dataStream.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}

byte[] hash = messageDigest.digest();
return String.format("%064x", new java.math.BigInteger(1, hash));
}

public static void copyNecessaryFilesFromCurrentPackage(String diffManifestFilePath, String currentPackageFolderPath, String newPackageFolderPath) throws IOException {
public static void copyNecessaryFilesFromCurrentPackage(DiffManifest diffManifest, String currentPackageFolderPath, String newPackageFolderPath) throws IOException {
if (currentPackageFolderPath == null || !new File(currentPackageFolderPath).exists()) {
CodePushUtils.log("Unable to copy files from current package during diff update, because currentPackageFolderPath is invalid.");
return;
}
FileUtils.copyDirectoryContents(currentPackageFolderPath, newPackageFolderPath);
JSONObject diffManifest = CodePushUtils.getJsonObjectFromFile(diffManifestFilePath);
try {
JSONArray deletedFiles = diffManifest.getJSONArray("deletedFiles");
for (int i = 0; i < deletedFiles.length(); i++) {
String fileNameToDelete = deletedFiles.getString(i);
File fileToDelete = new File(newPackageFolderPath, fileNameToDelete);
if (fileToDelete.exists()) {
fileToDelete.delete();
}
File newPackageFolderCanonical = new File(newPackageFolderPath).getCanonicalFile();
for (String fileNameToDelete : diffManifest.getDeletedFiles()) {
// deletedFiles comes from the update's diff manifest, so treat it as untrusted: reject any
// entry (e.g. "../../etc/passwd") that would resolve outside newPackageFolderPath.
File fileToDelete = new File(newPackageFolderPath, fileNameToDelete).getCanonicalFile();
if (!fileToDelete.equals(newPackageFolderCanonical)
&& !fileToDelete.getPath().startsWith(newPackageFolderCanonical.getPath() + File.separator)) {
throw new CodePushInvalidUpdateException("Diff manifest deletedFiles entry \"" + fileNameToDelete + "\" escapes the update package directory.");
}
if (fileToDelete.exists()) {
fileToDelete.delete();
}
} catch (JSONException e) {
throw new CodePushUnknownException("Unable to copy files from current package during diff update", e);
}
}

Expand Down
Loading