diff --git a/.github/scripts/run-android-tests.sh b/.github/scripts/run-android-tests.sh new file mode 100755 index 00000000..1b2ede21 --- /dev/null +++ b/.github/scripts/run-android-tests.sh @@ -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::" diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index 4384db86..1b9fa23d 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -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 @@ -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 + + - name: Run iOS E2E Tests + run: npm run ${{ matrix.test-command }} - name: Upload iOS Simulator crash reports if: failure() diff --git a/.npmignore b/.npmignore index 57e0aa55..ef1963ef 100644 --- a/.npmignore +++ b/.npmignore @@ -52,6 +52,8 @@ android/.gradle android/**/*.iml android/.idea +ios/CodePushDiffPatchTests/ + # Windows windows/.vs/ diff --git a/CLAUDE.md b/CLAUDE.md index 475ba23b..5b09c227 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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. diff --git a/CodePush.podspec b/CodePush.podspec index 84e5f9f2..e16fa4c7 100644 --- a/CodePush.podspec +++ b/CodePush.podspec @@ -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 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 diff --git a/android/app/build.gradle b/android/app/build.gradle index a1879ecc..3b152015 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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' diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt index 16080710..0ada956a 100644 --- a/android/app/src/main/cpp/CMakeLists.txt +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -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 @@ -28,6 +28,8 @@ add_library(codepush_diffpatch SHARED target_include_directories(codepush_diffpatch PRIVATE . + ../../../../../shared/diffpatch + ../../../../../shared third_party/bzip2 ) diff --git a/android/app/src/main/cpp/third_party/README.md b/android/app/src/main/cpp/third_party/README.md index 3cbf62e2..70255e73 100644 --- a/android/app/src/main/cpp/third_party/README.md +++ b/android/app/src/main/cpp/third_party/README.md @@ -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/ 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 ceafd545..d66e9a1b 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 @@ -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; @@ -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 diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java index 90d43263..cd621f4a 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushConstants.java @@ -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"; diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java index 0bbe38cb..a4fd6998 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateManager.java @@ -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; @@ -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() { @@ -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 diff --git a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java index 2c90b851..4aaac8b4 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java +++ b/android/app/src/main/java/com/microsoft/codepush/react/CodePushUpdateUtils.java @@ -3,6 +3,9 @@ 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; @@ -10,8 +13,6 @@ import java.security.interfaces.*; import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; import java.io.ByteArrayInputStream; import java.io.File; @@ -19,10 +20,7 @@ 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; @@ -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); } } diff --git a/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/BinaryDiffPatcher.kt b/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/BinaryDiffPatcher.kt new file mode 100644 index 00000000..756a4079 --- /dev/null +++ b/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/BinaryDiffPatcher.kt @@ -0,0 +1,54 @@ +@file:JvmName("BinaryDiffPatcher") +package com.microsoft.codepush.react.diffpatch + +import java.io.File +import java.io.IOException + +class BinaryDiffApplyException(val relativePath: String, reason: String) : + IOException("Failed to apply binary diff patch for \"$relativePath\": $reason") + +@JvmOverloads +fun applyBinaryDiffPatches( + manifest: DiffManifest, + currentPackageFolder: File, + unzippedFolder: File, + newUpdateFolder: File, + patchApplier: PatchApplier = NativeBsdiffPatchApplier, +) { + for ((relativePath, entry) in manifest.patchedFiles) { + if (entry.algo != "bsdiff") { + throw BinaryDiffApplyException(relativePath, "unsupported patch algorithm: ${entry.algo}") + } + } + + for ((relativePath, entry) in manifest.patchedFiles) { + val oldFile = resolveWithin(currentPackageFolder, relativePath) + if (sha256Hex(oldFile) != entry.baseHash) { + throw BinaryDiffApplyException(relativePath, "baseHash mismatch") + } + + val diffFile = resolveWithin(unzippedFolder, entry.patch) + val newFile = resolveWithin(newUpdateFolder, relativePath).apply { parentFile?.mkdirs() } + + val result = patchApplier.apply(oldFile, diffFile, newFile) + if (result != DiffPatch.PatchResult.OK) { + throw BinaryDiffApplyException(relativePath, "patch failed: $result") + } + + if (sha256Hex(newFile) != entry.targetHash) { + throw BinaryDiffApplyException(relativePath, "targetHash mismatch") + } + } +} + +// Manifest-supplied paths come from the update's JSON, so we treat them as untrusted. +// Resolve them strictly under `base` and reject anything ("../../etc", an absolute path) that would otherwise +// let a manifest entry read or write outside the package/patch folders. +private fun resolveWithin(base: File, relativePath: String): File { + val baseCanonical = base.canonicalFile + val resolved = File(base, relativePath).canonicalFile + if (resolved != baseCanonical && !resolved.path.startsWith(baseCanonical.path + File.separator)) { + throw BinaryDiffApplyException(relativePath, "path escapes expected directory") + } + return resolved +} diff --git a/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffManifest.kt b/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffManifest.kt new file mode 100644 index 00000000..b45f9a3d --- /dev/null +++ b/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffManifest.kt @@ -0,0 +1,59 @@ +package com.microsoft.codepush.react.diffpatch + +import org.json.JSONException +import org.json.JSONObject + +data class PatchedFileEntry( + // The only value this client understands at the moment is "bsdiff". + val algo: String, + // SHA-256 hex of the file's content in the currently installed package + // Should be checked before patching. + val baseHash: String, + // SHA-256 hex the patched output must match, should be checked after patching. + val targetHash: String, + // Zip-relative path to the patch file, under the reserved prefix (CodePushConstants.DIFF_PATCHES_FOLDER_NAME). + val patch: String, +) + +data class DiffManifest( + // No version field, or version 1: original format, file-by-file patching only. + // Version 2: adds support for binary diff patching. + val version: Int, + // Relative paths, from the old package, to delete rather than carry over into the new one. + val deletedFiles: List, + // Map key: file's relative path in the package being installed. + val patchedFiles: Map, +) + +@Throws(JSONException::class) +fun parseDiffManifest(json: JSONObject): DiffManifest { + val version = if (json.has("version")) json.getInt("version") else 1 + + val deletedFilesJson = json.optJSONArray("deletedFiles") + val deletedFiles = if (deletedFilesJson != null) { + (0 until deletedFilesJson.length()).map { deletedFilesJson.getString(it) } + } else { + emptyList() + } + + val patchedFilesJson = json.optJSONObject("patchedFiles") + val patchedFiles = if (patchedFilesJson != null) { + patchedFilesJson.keys().asSequence().associateWith { relativePath -> + val entry = patchedFilesJson.getJSONObject(relativePath) + PatchedFileEntry( + algo = entry.getString("algo"), + baseHash = entry.getString("baseHash"), + targetHash = entry.getString("targetHash"), + patch = entry.getString("patch"), + ) + } + } else { + emptyMap() + } + + if (version != 2 && patchedFiles.isNotEmpty()) { + throw JSONException("Diff manifest declares version $version but contains patchedFiles, which requires version 2.") + } + + return DiffManifest(version = version, deletedFiles = deletedFiles, patchedFiles = patchedFiles) +} diff --git a/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffPatch.kt b/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffPatch.kt index 651a8c3e..c6b1214e 100644 --- a/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffPatch.kt +++ b/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/DiffPatch.kt @@ -1,5 +1,19 @@ package com.microsoft.codepush.react.diffpatch +import java.io.File + +// Purposes of this interface: +// 1. Allows unit testing the business logic by substituting a fake PatchApplier. +// 2. Allows the SDK to support multiple patching algorithms in the future, if we ever need to. +interface PatchApplier { + fun apply(oldFile: File, diffFile: File, newFile: File): DiffPatch.PatchResult +} + +object NativeBsdiffPatchApplier : PatchApplier { + override fun apply(oldFile: File, diffFile: File, newFile: File) = + DiffPatch.applyPatch(oldFile.path, diffFile.path, newFile.path) +} + object DiffPatch { /** @@ -23,7 +37,7 @@ object DiffPatch { UNKNOWN; companion object { - // Keep in sync with cpp/bspatch_bridge.h + // Keep in sync with shared/diffpatch/bspatch_bridge.h fun fromNativeCode(code: Int): PatchResult = when (code) { 0 -> OK 1 -> BAD_DIFF_HEADER diff --git a/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/Sha256.kt b/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/Sha256.kt new file mode 100644 index 00000000..8acaea09 --- /dev/null +++ b/android/app/src/main/java/com/microsoft/codepush/react/diffpatch/Sha256.kt @@ -0,0 +1,21 @@ +@file:JvmName("Sha256") +package com.microsoft.codepush.react.diffpatch + +import java.io.File +import java.io.InputStream +import java.math.BigInteger +import java.security.DigestInputStream +import java.security.MessageDigest + +fun sha256Hex(file: File): String = file.inputStream().use { sha256Hex(it) } + +fun sha256Hex(inputStream: InputStream): String { + val messageDigest = MessageDigest.getInstance("SHA-256") + DigestInputStream(inputStream, messageDigest).use { digestInputStream -> + val buffer = ByteArray(1024 * 8) + while (digestInputStream.read(buffer) != -1) { + // Drain the stream; DigestInputStream updates the digest as a side effect. + } + } + return String.format("%064x", BigInteger(1, messageDigest.digest())) +} diff --git a/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/BinaryDiffPatcherTest.kt b/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/BinaryDiffPatcherTest.kt new file mode 100644 index 00000000..d094f293 --- /dev/null +++ b/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/BinaryDiffPatcherTest.kt @@ -0,0 +1,350 @@ +package com.microsoft.codepush.react.diffpatch + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +private class FakePatchApplier(private val apply: (File, File, File) -> DiffPatch.PatchResult) : PatchApplier { + var invocationCount = 0 + private set + + override fun apply(oldFile: File, diffFile: File, newFile: File): DiffPatch.PatchResult { + invocationCount++ + return apply.invoke(oldFile, diffFile, newFile) + } +} + +class BinaryDiffPatcherTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private fun manifestOf(patchedFiles: Map) = + DiffManifest(version = 2, deletedFiles = emptyList(), patchedFiles = patchedFiles) + + @Test + fun applyBinaryDiffPatches_happyPath_writesPatchedFileAtRightPath() { + // Given + val currentPackageFolder = tempFolder.newFolder("current") + val oldFile = File(currentPackageFolder, "index.android.bundle").apply { + parentFile?.mkdirs() + writeText("old hermes bytecode contents") + } + val unzippedFolder = tempFolder.newFolder("unzipped") + val diffFile = File(unzippedFolder, "__hcp_patches/index.android.bundle").apply { + parentFile?.mkdirs() + writeText("fake diff bytes") + } + val newUpdateFolder = tempFolder.newFolder("newUpdate") + val patchedBytes = "new hermes bytecode contents".toByteArray() + + val manifest = manifestOf( + mapOf( + "index.android.bundle" to PatchedFileEntry( + algo = "bsdiff", + baseHash = sha256Hex(oldFile), + targetHash = sha256Hex(patchedBytes.inputStream()), + patch = "__hcp_patches/index.android.bundle", + ) + ) + ) + val applier = FakePatchApplier { _, _, newFile -> newFile.writeBytes(patchedBytes); DiffPatch.PatchResult.OK } + + // When + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + + // Then + val newFile = File(newUpdateFolder, "index.android.bundle") + assertTrue(newFile.exists()) + assertEquals("new hermes bytecode contents", newFile.readText()) + assertEquals(1, applier.invocationCount) + } + + @Test + fun applyBinaryDiffPatches_baseHashMismatch_throwsWithoutInvokingApplier() { + // Given + val currentPackageFolder = tempFolder.newFolder("current") + File(currentPackageFolder, "index.android.bundle").apply { + parentFile?.mkdirs() + writeText("old hermes bytecode contents") + } + val unzippedFolder = tempFolder.newFolder("unzipped") + val newUpdateFolder = tempFolder.newFolder("newUpdate") + + val manifest = manifestOf( + mapOf( + "index.android.bundle" to PatchedFileEntry( + algo = "bsdiff", + baseHash = "wrong-hash", + targetHash = "irrelevant", + patch = "__hcp_patches/index.android.bundle", + ) + ) + ) + val applier = FakePatchApplier { _, _, _ -> DiffPatch.PatchResult.OK } + + // When / Then + try { + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + fail("expected BinaryDiffApplyException") + } catch (e: BinaryDiffApplyException) { + assertEquals("index.android.bundle", e.relativePath) + } + assertEquals(0, applier.invocationCount) + } + + @Test + fun applyBinaryDiffPatches_applierReturnsNonOk_throws() { + // Given + val currentPackageFolder = tempFolder.newFolder("current") + val oldFile = File(currentPackageFolder, "index.android.bundle").apply { + parentFile?.mkdirs() + writeText("old hermes bytecode contents") + } + val unzippedFolder = tempFolder.newFolder("unzipped") + val newUpdateFolder = tempFolder.newFolder("newUpdate") + + val manifest = manifestOf( + mapOf( + "index.android.bundle" to PatchedFileEntry( + algo = "bsdiff", + baseHash = sha256Hex(oldFile), + targetHash = "irrelevant", + patch = "__hcp_patches/index.android.bundle", + ) + ) + ) + val applier = FakePatchApplier { _, _, _ -> DiffPatch.PatchResult.PATCH_FAILED } + + // When / Then + try { + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + fail("expected BinaryDiffApplyException") + } catch (e: BinaryDiffApplyException) { + assertEquals("index.android.bundle", e.relativePath) + } + assertEquals(1, applier.invocationCount) + } + + @Test + fun applyBinaryDiffPatches_targetHashMismatchAfterSuccessfulApply_throws() { + // Given + val currentPackageFolder = tempFolder.newFolder("current") + val oldFile = File(currentPackageFolder, "index.android.bundle").apply { + parentFile?.mkdirs() + writeText("old hermes bytecode contents") + } + val unzippedFolder = tempFolder.newFolder("unzipped") + val newUpdateFolder = tempFolder.newFolder("newUpdate") + + val manifest = manifestOf( + mapOf( + "index.android.bundle" to PatchedFileEntry( + algo = "bsdiff", + baseHash = sha256Hex(oldFile), + targetHash = "wrong-target-hash", + patch = "__hcp_patches/index.android.bundle", + ) + ) + ) + val applier = FakePatchApplier { _, _, newFile -> newFile.writeText("actual output"); DiffPatch.PatchResult.OK } + + // When / Then + try { + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + fail("expected BinaryDiffApplyException") + } catch (e: BinaryDiffApplyException) { + assertEquals("index.android.bundle", e.relativePath) + } + } + + @Test + fun applyBinaryDiffPatches_unknownAlgo_throwsWithoutInvokingApplier() { + // Given + val currentPackageFolder = tempFolder.newFolder("current") + val unzippedFolder = tempFolder.newFolder("unzipped") + val newUpdateFolder = tempFolder.newFolder("newUpdate") + + val manifest = manifestOf( + mapOf( + "index.android.bundle" to PatchedFileEntry( + algo = "some-other-algo", + baseHash = "irrelevant", + targetHash = "irrelevant", + patch = "__hcp_patches/index.android.bundle", + ) + ) + ) + val applier = FakePatchApplier { _, _, _ -> DiffPatch.PatchResult.OK } + + // When / Then + try { + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + fail("expected BinaryDiffApplyException") + } catch (e: BinaryDiffApplyException) { + assertEquals("index.android.bundle", e.relativePath) + } + assertEquals(0, applier.invocationCount) + } + + @Test + fun applyBinaryDiffPatches_oneOfMultipleEntriesFails_wholeInstallAborts() { + // Given + val currentPackageFolder = tempFolder.newFolder("current") + val goodOldFile = File(currentPackageFolder, "index.android.bundle").apply { writeText("good old hermes bytecode") } + val badOldFile = File(currentPackageFolder, "assets/drawable-mdpi/ic_launcher.png").apply { + parentFile?.mkdirs() + writeText("bad old") + } + val unzippedFolder = tempFolder.newFolder("unzipped") + val newUpdateFolder = tempFolder.newFolder("newUpdate") + + val manifest = manifestOf( + mapOf( + "index.android.bundle" to PatchedFileEntry( + algo = "bsdiff", + baseHash = sha256Hex(goodOldFile), + targetHash = sha256Hex("good new hermes bytecode".toByteArray().inputStream()), + patch = "__hcp_patches/index.android.bundle", + ), + "assets/drawable-mdpi/ic_launcher.png" to PatchedFileEntry( + algo = "bsdiff", + baseHash = sha256Hex(badOldFile), + targetHash = "wrong-target-hash", + patch = "__hcp_patches/assets/drawable-mdpi/ic_launcher.png", + ), + ) + ) + val applier = FakePatchApplier { _, _, newFile -> newFile.writeText("good new hermes bytecode"); DiffPatch.PatchResult.OK } + + // When / Then + try { + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + fail("expected BinaryDiffApplyException") + } catch (e: BinaryDiffApplyException) { + // one of the two entries is expected to fail; which one depends on map iteration order + assertTrue(e.relativePath == "index.android.bundle" || e.relativePath == "assets/drawable-mdpi/ic_launcher.png") + } + } + + @Test + fun applyBinaryDiffPatches_relativePathEscapesCurrentPackageFolder_throwsWithoutInvokingApplier() { + // Given + val currentPackageFolder = tempFolder.newFolder("current") + val unzippedFolder = tempFolder.newFolder("unzipped") + val newUpdateFolder = tempFolder.newFolder("newUpdate") + val secret = File(tempFolder.root, "secret.bundle").apply { writeText("outside the package folder") } + + val manifest = manifestOf( + mapOf( + "../secret.bundle" to PatchedFileEntry( + algo = "bsdiff", + baseHash = sha256Hex(secret), + targetHash = "irrelevant", + patch = "__hcp_patches/secret.bundle", + ) + ) + ) + val applier = FakePatchApplier { _, _, _ -> DiffPatch.PatchResult.OK } + + // When / Then + try { + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + fail("expected BinaryDiffApplyException") + } catch (e: BinaryDiffApplyException) { + assertEquals("../secret.bundle", e.relativePath) + } + assertEquals(0, applier.invocationCount) + } + + @Test + fun applyBinaryDiffPatches_patchFieldEscapesUnzippedFolder_throwsWithoutInvokingApplier() { + // Given + val currentPackageFolder = tempFolder.newFolder("current") + val oldFile = File(currentPackageFolder, "index.android.bundle").apply { + parentFile?.mkdirs() + writeText("old hermes bytecode contents") + } + val unzippedFolder = tempFolder.newFolder("unzipped") + val newUpdateFolder = tempFolder.newFolder("newUpdate") + File(tempFolder.root, "outside.bsdiff").writeText("fake diff bytes") + + val manifest = manifestOf( + mapOf( + "index.android.bundle" to PatchedFileEntry( + algo = "bsdiff", + baseHash = sha256Hex(oldFile), + targetHash = "irrelevant", + patch = "../outside.bsdiff", + ) + ) + ) + val applier = FakePatchApplier { _, _, _ -> DiffPatch.PatchResult.OK } + + // When / Then + try { + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + fail("expected BinaryDiffApplyException") + } catch (e: BinaryDiffApplyException) { + assertEquals("../outside.bsdiff", e.relativePath) + } + assertEquals(0, applier.invocationCount) + } + + @Test + fun applyBinaryDiffPatches_realBsdiffFixtureShape_appliesSuccessfully() { + fun fixture(name: String) = + checkNotNull(javaClass.getResourceAsStream("/binarydiff/basic/$name")) { "missing fixture $name" } + + // Given + val currentPackageFolder = tempFolder.newFolder("current") + val oldFile = File(currentPackageFolder, "index.android.bundle").apply { + parentFile?.mkdirs() + fixture("old.dat").use { input -> outputStream().use { input.copyTo(it) } } + } + val unzippedFolder = tempFolder.newFolder("unzipped") + File(unzippedFolder, "__hcp_patches/index.android.bundle").apply { + parentFile?.mkdirs() + fixture("patch.bsdiff").use { input -> outputStream().use { input.copyTo(it) } } + } + val expectedNewBytes = fixture("new.dat").use { it.readBytes() } + val newUpdateFolder = tempFolder.newFolder("newUpdate") + + val manifestJson = JSONObject( + """ + { + "version": 2, + "deletedFiles": [], + "patchedFiles": { + "index.android.bundle": { + "algo": "bsdiff", + "baseHash": "${sha256Hex(oldFile)}", + "targetHash": "${sha256Hex(expectedNewBytes.inputStream())}", + "patch": "__hcp_patches/index.android.bundle" + } + } + } + """.trimIndent() + ) + val manifest = parseDiffManifest(manifestJson) + + val applier = FakePatchApplier { _, diffFile, newFile -> + assertTrue("diff file should exist at the manifest-resolved path", diffFile.exists()) + newFile.writeBytes(expectedNewBytes) + DiffPatch.PatchResult.OK + } + + // When + applyBinaryDiffPatches(manifest, currentPackageFolder, unzippedFolder, newUpdateFolder, applier) + + // Then + val newFile = File(newUpdateFolder, "index.android.bundle") + assertTrue(newFile.exists()) + assertTrue(expectedNewBytes.contentEquals(newFile.readBytes())) + } +} diff --git a/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/DiffManifestTest.kt b/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/DiffManifestTest.kt new file mode 100644 index 00000000..f363c4be --- /dev/null +++ b/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/DiffManifestTest.kt @@ -0,0 +1,147 @@ +package com.microsoft.codepush.react.diffpatch + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DiffManifestTest { + + @Test + fun parseDiffManifest_v1Shape_defaultsVersionToOneAndPatchedFilesToEmpty() { + // Given + val json = JSONObject().put("deletedFiles", org.json.JSONArray(listOf("stale.js", "old/asset.png"))) + + // When + val manifest = parseDiffManifest(json) + + // Then + assertEquals(1, manifest.version) + assertEquals(listOf("stale.js", "old/asset.png"), manifest.deletedFiles) + assertTrue(manifest.patchedFiles.isEmpty()) + } + + @Test + fun parseDiffManifest_missingDeletedFiles_defaultsToEmptyList() { + // Given + val json = JSONObject() + + // When + val manifest = parseDiffManifest(json) + + // Then + assertEquals(1, manifest.version) + assertTrue(manifest.deletedFiles.isEmpty()) + assertTrue(manifest.patchedFiles.isEmpty()) + } + + @Test + fun parseDiffManifest_v2Shape_parsesMultiplePatchedFilesEntries() { + // Given + val json = JSONObject( + """ + { + "version": 2, + "deletedFiles": ["removed.js"], + "patchedFiles": { + "relative/path.js": { + "algo": "bsdiff", + "baseHash": "base-hash-1", + "targetHash": "target-hash-1", + "patch": "__hcp_patches/relative/path.js" + }, + "another/file.js": { + "algo": "bsdiff", + "baseHash": "base-hash-2", + "targetHash": "target-hash-2", + "patch": "__hcp_patches/another/file.js" + } + } + } + """.trimIndent() + ) + + // When + val manifest = parseDiffManifest(json) + + // Then + assertEquals(2, manifest.version) + assertEquals(listOf("removed.js"), manifest.deletedFiles) + assertEquals(2, manifest.patchedFiles.size) + assertEquals( + PatchedFileEntry( + algo = "bsdiff", + baseHash = "base-hash-1", + targetHash = "target-hash-1", + patch = "__hcp_patches/relative/path.js", + ), + manifest.patchedFiles["relative/path.js"], + ) + assertEquals( + PatchedFileEntry( + algo = "bsdiff", + baseHash = "base-hash-2", + targetHash = "target-hash-2", + patch = "__hcp_patches/another/file.js", + ), + manifest.patchedFiles["another/file.js"], + ) + } + + @Test + fun parseDiffManifest_missingPatchedFiles_defaultsToEmptyMap() { + // Given + val json = JSONObject().put("version", 2).put("deletedFiles", org.json.JSONArray()) + + // When + val manifest = parseDiffManifest(json) + + // Then + assertEquals(2, manifest.version) + assertTrue(manifest.patchedFiles.isEmpty()) + } + + @Test(expected = org.json.JSONException::class) + fun parseDiffManifest_v1ShapeWithPatchedFiles_throws() { + // Given + val json = JSONObject( + """ + { + "version": 1, + "patchedFiles": { + "relative/path.js": { + "algo": "bsdiff", + "baseHash": "base-hash-1", + "targetHash": "target-hash-1", + "patch": "__hcp_patches/relative/path.js" + } + } + } + """.trimIndent() + ) + + // When / Then (parseDiffManifest is expected to throw) + parseDiffManifest(json) + } + + @Test(expected = org.json.JSONException::class) + fun parseDiffManifest_patchedFileEntryMissingRequiredField_throws() { + // Given + val json = JSONObject( + """ + { + "version": 2, + "patchedFiles": { + "relative/path.js": { + "algo": "bsdiff", + "baseHash": "base-hash-1" + } + } + } + """.trimIndent() + ) + + // When / Then (parseDiffManifest is expected to throw) + parseDiffManifest(json) + } +} diff --git a/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/Sha256Test.kt b/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/Sha256Test.kt new file mode 100644 index 00000000..e73e2cc5 --- /dev/null +++ b/android/app/src/test/java/com/microsoft/codepush/react/diffpatch/Sha256Test.kt @@ -0,0 +1,65 @@ +package com.microsoft.codepush.react.diffpatch + +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class Sha256Test { + + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun sha256Hex_emptyFile_matchesKnownHash() { + // Given + val file = tempFolder.newFile("empty.dat") + + // When + val hash = sha256Hex(file) + + // Then + // SHA-256 of the empty byte sequence, a widely published constant. + assertEquals("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hash) + } + + @Test + fun sha256Hex_knownBytes_matchesKnownHash() { + // Given + val file = tempFolder.newFile("abc.dat").apply { writeBytes("abc".toByteArray()) } + + // When + val hash = sha256Hex(file) + + // Then + // SHA-256("abc"), a widely published constant. + assertEquals("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", hash) + } + + @Test + fun sha256Hex_isZeroPaddedToSixtyFourLowercaseHexChars() { + // Given + val file = tempFolder.newFile("small.dat").apply { writeBytes(byteArrayOf(0)) } + + // When + val hash = sha256Hex(file) + + // Then + assertEquals(64, hash.length) + assertEquals(hash.lowercase(), hash) + } + + @Test + fun sha256Hex_fileAndInputStreamOverloads_agree() { + // Given + val file = tempFolder.newFile("agree.dat").apply { writeBytes("some content".toByteArray()) } + + // When + val fromFile = sha256Hex(file) + val fromStream = file.inputStream().use { sha256Hex(it) } + + // Then + assertEquals(fromFile, fromStream) + } +} diff --git a/android/app/src/test/resources/binarydiff/basic/new.dat b/android/app/src/test/resources/binarydiff/basic/new.dat new file mode 100644 index 00000000..54241f83 --- /dev/null +++ b/android/app/src/test/resources/binarydiff/basic/new.dat @@ -0,0 +1,25 @@ +function greet(name) { + console.log("Hello there, " + name + "!"); + return "Hello there, " + name + "!"; +} + +function farewell(name) { + console.log("Goodbye, " + name + "."); + return "Goodbye, " + name + "."; +} + +function shout(name) { + console.log("HEY, " + name.toUpperCase() + "!!!"); + return "HEY, " + name.toUpperCase() + "!!!"; +} + +var VERSION = "1.1.0"; +var BUILD_NUMBER = 43; + +module.exports = { + greet: greet, + farewell: farewell, + shout: shout, + VERSION: VERSION, + BUILD_NUMBER: BUILD_NUMBER, +}; diff --git a/android/app/src/test/resources/binarydiff/basic/old.dat b/android/app/src/test/resources/binarydiff/basic/old.dat new file mode 100644 index 00000000..b4667705 --- /dev/null +++ b/android/app/src/test/resources/binarydiff/basic/old.dat @@ -0,0 +1,19 @@ +function greet(name) { + console.log("Hello, " + name + "!"); + return "Hello, " + name + "!"; +} + +function farewell(name) { + console.log("Goodbye, " + name + "."); + return "Goodbye, " + name + "."; +} + +var VERSION = "1.0.0"; +var BUILD_NUMBER = 42; + +module.exports = { + greet: greet, + farewell: farewell, + VERSION: VERSION, + BUILD_NUMBER: BUILD_NUMBER, +}; diff --git a/android/app/src/test/resources/binarydiff/basic/patch.bsdiff b/android/app/src/test/resources/binarydiff/basic/patch.bsdiff new file mode 100644 index 00000000..a0e9d31a Binary files /dev/null and b/android/app/src/test/resources/binarydiff/basic/patch.bsdiff differ diff --git a/docs/api-android.md b/docs/api-android.md index 092bfbb4..78b79fc2 100644 --- a/docs/api-android.md +++ b/docs/api-android.md @@ -15,6 +15,11 @@ Since `autolinking` uses `react-native.config.js` to link plugins, constructors https://yourcodepush.server.com ``` +- **Enable Delta Updates** - switch for applying binary diff (bsdiff) patches during a diff update, off by default (at the moment). When disabled, only file-by-file diffing is applied (for example, skipping assets if only the main JS bundle changed, but that whole file is downloaded byte for byte). Add a `bool` resource named `CodePushEnableDeltaUpdates` to `strings.xml` to turn it on: + ```xml + true + ``` + The Java API is made available by importing the `com.microsoft.codepush.react.CodePush` class into your `MainActivity.java` file, and consists of a single public class named `CodePush`. ### Java API Reference (Android) diff --git a/ios/CodePush.xcodeproj/project.pbxproj b/ios/CodePush.xcodeproj/project.pbxproj index 3010ff50..958a0d6f 100644 --- a/ios/CodePush.xcodeproj/project.pbxproj +++ b/ios/CodePush.xcodeproj/project.pbxproj @@ -7,6 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 0ABCB5DEFE01A7A15552A498 /* file_for_patch.c in Sources */ = {isa = PBXBuildFile; fileRef = 70779807AB59EA4711737F4E /* file_for_patch.c */; }; + 08B8B3B8260E70B7ECA85451 /* bspatch_bridge.c in Sources */ = {isa = PBXBuildFile; fileRef = A430CBE260F09A3233110E28 /* bspatch_bridge.c */; }; + A75C1A7A555663186E25AA3D /* libHDiffPatch/HPatch/patch.c in Sources */ = {isa = PBXBuildFile; fileRef = 827016DF6E52E356F4B89D18 /* libHDiffPatch/HPatch/patch.c */; }; + D8C294C1D2625E79BC302CCC /* bsdiff_wrapper/bspatch_wrapper.c in Sources */ = {isa = PBXBuildFile; fileRef = 0DC7989C75C72A774EF3685F /* bsdiff_wrapper/bspatch_wrapper.c */; }; + 78E9CECCB820DC4BA09BF09A /* file_for_patch.c in Sources */ = {isa = PBXBuildFile; fileRef = 70779807AB59EA4711737F4E /* file_for_patch.c */; }; 13BE3DEE1AC21097009241FE /* CodePush.m in Sources */ = {isa = PBXBuildFile; fileRef = 13BE3DED1AC21097009241FE /* CodePush.m */; }; 1B23B9141BF9267B000BB2F0 /* RCTConvert+CodePushInstallMode.m in Sources */ = {isa = PBXBuildFile; fileRef = 1B23B9131BF9267B000BB2F0 /* RCTConvert+CodePushInstallMode.m */; }; 1B762E901C9A5E9A006EF800 /* CodePushErrorUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 1B762E8F1C9A5E9A006EF800 /* CodePushErrorUtils.m */; }; @@ -77,10 +82,12 @@ 3221E4902C8ABE1400268379 /* SSZipCommon.h in Headers */ = {isa = PBXBuildFile; fileRef = 3221E44E2C8ABE1300268379 /* SSZipCommon.h */; }; 3221E4912C8ABE1400268379 /* SSZipArchive.m in Sources */ = {isa = PBXBuildFile; fileRef = 3221E44F2C8ABE1300268379 /* SSZipArchive.m */; }; 3221E4922C8ABE1400268379 /* SSZipArchive.m in Sources */ = {isa = PBXBuildFile; fileRef = 3221E44F2C8ABE1300268379 /* SSZipArchive.m */; }; + 47F66D5AF3C3185E1A3E3B15 /* bspatch_bridge.c in Sources */ = {isa = PBXBuildFile; fileRef = A430CBE260F09A3233110E28 /* bspatch_bridge.c */; }; 540D20121C7684FE00D6EF41 /* CodePushUpdateUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 540D20111C7684FE00D6EF41 /* CodePushUpdateUtils.m */; }; 5421FE311C58AD5A00986A55 /* CodePushTelemetryManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 5421FE301C58AD5A00986A55 /* CodePushTelemetryManager.m */; }; 5498D8F61D21F14100B5EB43 /* CodePushUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 5498D8F51D21F14100B5EB43 /* CodePushUtils.m */; }; 54FFEDE01BF550630061DD23 /* CodePushDownloadHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 54FFEDDF1BF550630061DD23 /* CodePushDownloadHandler.m */; }; + 5C6B00FACE8707503D366F7C /* Fixtures in Resources */ = {isa = PBXBuildFile; fileRef = E74D80ECD8DD03C4C0772B7B /* Fixtures */; }; 6463C82D1EBA0CFB0095B8CD /* CodePushUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 5498D8F51D21F14100B5EB43 /* CodePushUtils.m */; }; 6463C82E1EBA0CFB0095B8CD /* CodePush.m in Sources */ = {isa = PBXBuildFile; fileRef = 13BE3DED1AC21097009241FE /* CodePush.m */; }; 6463C82F1EBA0CFB0095B8CD /* CodePushConfig.m in Sources */ = {isa = PBXBuildFile; fileRef = 81D51F391B6181C2000DA084 /* CodePushConfig.m */; }; @@ -97,6 +104,9 @@ 81D51F3A1B6181C2000DA084 /* CodePushConfig.m in Sources */ = {isa = PBXBuildFile; fileRef = 81D51F391B6181C2000DA084 /* CodePushConfig.m */; }; 8482F84C1E24C58300F793DB /* CodePush.h in CopyFiles */ = {isa = PBXBuildFile; fileRef = 13BE3DEC1AC21097009241FE /* CodePush.h */; }; 8482F84E1E24C66300F793DB /* CodePush.h in Headers */ = {isa = PBXBuildFile; fileRef = 13BE3DEC1AC21097009241FE /* CodePush.h */; }; + A88F11124A2120A8373A8B61 /* bsdiff_wrapper/bspatch_wrapper.c in Sources */ = {isa = PBXBuildFile; fileRef = 0DC7989C75C72A774EF3685F /* bsdiff_wrapper/bspatch_wrapper.c */; }; + B5B50F91FAAF80444988E284 /* BSPatchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95D6DD0EACAC8D095880DFD0 /* BSPatchTests.swift */; }; + DC983F1C71E0E7131BB343C5 /* libHDiffPatch/HPatch/patch.c in Sources */ = {isa = PBXBuildFile; fileRef = 827016DF6E52E356F4B89D18 /* libHDiffPatch/HPatch/patch.c */; }; F85736761F4F03BF00C9C00A /* MF_Base64Additions.h in Headers */ = {isa = PBXBuildFile; fileRef = F85736731F4F03BF00C9C00A /* MF_Base64Additions.h */; }; F85736771F4F03BF00C9C00A /* MF_Base64Additions.m in Sources */ = {isa = PBXBuildFile; fileRef = F85736741F4F03BF00C9C00A /* MF_Base64Additions.m */; }; F886644D1F4AD1EE0036D01B /* JWTAlgorithm.h in Headers */ = {isa = PBXBuildFile; fileRef = F88664151F4AD1EE0036D01B /* JWTAlgorithm.h */; }; @@ -171,6 +181,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0BF68F85125CF81D7EB65ABB /* bspatch_bridge.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = bspatch_bridge.h; sourceTree = ""; }; + 0DC7989C75C72A774EF3685F /* bsdiff_wrapper/bspatch_wrapper.c */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.c; path = bsdiff_wrapper/bspatch_wrapper.c; sourceTree = ""; }; 134814201AA4EA6300B7C361 /* libCodePush.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libCodePush.a; sourceTree = BUILT_PRODUCTS_DIR; }; 13BE3DEC1AC21097009241FE /* CodePush.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = CodePush.h; path = CodePush/CodePush.h; sourceTree = ""; }; 13BE3DED1AC21097009241FE /* CodePush.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = CodePush.m; path = CodePush/CodePush.m; sourceTree = ""; }; @@ -214,12 +226,19 @@ 3221E44E2C8ABE1300268379 /* SSZipCommon.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SSZipCommon.h; sourceTree = ""; }; 3221E44F2C8ABE1300268379 /* SSZipArchive.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SSZipArchive.m; sourceTree = ""; }; 3221E4502C8ABE1300268379 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 4AB96C756000A7E833CBA8B7 /* CodePushTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CodePushTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 540D20111C7684FE00D6EF41 /* CodePushUpdateUtils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = CodePushUpdateUtils.m; path = CodePush/CodePushUpdateUtils.m; sourceTree = ""; }; 5421FE301C58AD5A00986A55 /* CodePushTelemetryManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = CodePushTelemetryManager.m; path = CodePush/CodePushTelemetryManager.m; sourceTree = ""; }; 5498D8F51D21F14100B5EB43 /* CodePushUtils.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = CodePushUtils.m; path = CodePush/CodePushUtils.m; sourceTree = ""; }; 54FFEDDF1BF550630061DD23 /* CodePushDownloadHandler.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = CodePushDownloadHandler.m; path = CodePush/CodePushDownloadHandler.m; sourceTree = ""; }; + 70779807AB59EA4711737F4E /* file_for_patch.c */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.c; path = file_for_patch.c; sourceTree = ""; }; 810D4E6C1B96935000B397E9 /* CodePushPackage.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = CodePushPackage.m; path = CodePush/CodePushPackage.m; sourceTree = ""; }; 81D51F391B6181C2000DA084 /* CodePushConfig.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = CodePushConfig.m; path = CodePush/CodePushConfig.m; sourceTree = ""; }; + 827016DF6E52E356F4B89D18 /* libHDiffPatch/HPatch/patch.c */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.c; path = libHDiffPatch/HPatch/patch.c; sourceTree = ""; }; + 95D6DD0EACAC8D095880DFD0 /* BSPatchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BSPatchTests.swift; sourceTree = ""; }; + A430CBE260F09A3233110E28 /* bspatch_bridge.c */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.c; path = bspatch_bridge.c; sourceTree = ""; }; + E74D80ECD8DD03C4C0772B7B /* Fixtures */ = {isa = PBXFileReference; explicitFileType = folder; includeInIndex = 0; path = Fixtures; sourceTree = ""; }; + E9FA144425AE78B97AD6C870 /* CodePushTests-Bridging-Header.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "CodePushTests-Bridging-Header.h"; sourceTree = ""; }; F85736731F4F03BF00C9C00A /* MF_Base64Additions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MF_Base64Additions.h; sourceTree = ""; }; F85736741F4F03BF00C9C00A /* MF_Base64Additions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MF_Base64Additions.m; sourceTree = ""; }; F85736751F4F03BF00C9C00A /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; @@ -291,6 +310,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 92F210124F3F87350E684641 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -298,10 +324,21 @@ isa = PBXGroup; children = ( 134814201AA4EA6300B7C361 /* libCodePush.a */, + 4AB96C756000A7E833CBA8B7 /* CodePushTests.xctest */, ); name = Products; sourceTree = ""; }; + 2EB4092D590839720984EE01 /* RepoRoot */ = { + isa = PBXGroup; + children = ( + 66FDDF29DBB202771A6A06DD /* shared-diffpatch */, + 41A7D02DA375C6871A90065B /* third_party-hdiffpatch */, + ); + name = RepoRoot; + path = ..; + sourceTree = ""; + }; 3221E4282C8ABE1300268379 /* SSZipArchive */ = { isa = PBXGroup; children = ( @@ -371,6 +408,17 @@ path = "Supporting Files"; sourceTree = ""; }; + 41A7D02DA375C6871A90065B /* third_party-hdiffpatch */ = { + isa = PBXGroup; + children = ( + 827016DF6E52E356F4B89D18 /* libHDiffPatch/HPatch/patch.c */, + 0DC7989C75C72A774EF3685F /* bsdiff_wrapper/bspatch_wrapper.c */, + 70779807AB59EA4711737F4E /* file_for_patch.c */, + ); + name = "third_party-hdiffpatch"; + path = shared/third_party/hdiffpatch; + sourceTree = ""; + }; 58B511D21A9E6C8500147676 = { isa = PBXGroup; children = ( @@ -392,7 +440,29 @@ 134814211AA4EA7D00B7C361 /* Products */, C31BB4D5018A48D5288C5137 /* Frameworks */, F886647B1F4ADB500036D01B /* libCodePush.a */, + 2EB4092D590839720984EE01 /* RepoRoot */, + C283376E6F60830C5CB41994 /* CodePushTests */, + ); + sourceTree = ""; + }; + 66FDDF29DBB202771A6A06DD /* shared-diffpatch */ = { + isa = PBXGroup; + children = ( + A430CBE260F09A3233110E28 /* bspatch_bridge.c */, + 0BF68F85125CF81D7EB65ABB /* bspatch_bridge.h */, + ); + name = "shared-diffpatch"; + path = shared/diffpatch; + sourceTree = ""; + }; + C283376E6F60830C5CB41994 /* CodePushTests */ = { + isa = PBXGroup; + children = ( + 95D6DD0EACAC8D095880DFD0 /* BSPatchTests.swift */, + E9FA144425AE78B97AD6C870 /* CodePushTests-Bridging-Header.h */, + E74D80ECD8DD03C4C0772B7B /* Fixtures */, ); + path = CodePushTests; sourceTree = ""; }; C31BB4D5018A48D5288C5137 /* Frameworks */ = { @@ -692,6 +762,23 @@ productReference = F886647B1F4ADB500036D01B /* libCodePush.a */; productType = "com.apple.product-type.library.static"; }; + 9750B3DE460E834FF67380BD /* CodePushTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = A11B0911B46B588F8C213676 /* Build configuration list for PBXNativeTarget "CodePushTests" */; + buildPhases = ( + 7F757C5E9DC4CC7D30C29506 /* Sources */, + 92F210124F3F87350E684641 /* Frameworks */, + C50E6815B9794CFCD65A8F0C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = CodePushTests; + productName = CodePushTests; + productReference = 4AB96C756000A7E833CBA8B7 /* CodePushTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -708,6 +795,9 @@ CreatedOnToolsVersion = 8.3.2; ProvisioningStyle = Automatic; }; + 9750B3DE460E834FF67380BD = { + ProvisioningStyle = Automatic; + }; }; }; buildConfigurationList = 58B511D61A9E6C8500147676 /* Build configuration list for PBXProject "CodePush" */; @@ -725,15 +815,31 @@ targets = ( 58B511DA1A9E6C8500147676 /* CodePush */, 6463C8231EBA0CB60095B8CD /* CodePush-tvOS */, + 9750B3DE460E834FF67380BD /* CodePushTests */, ); }; /* End PBXProject section */ +/* Begin PBXResourcesBuildPhase section */ + C50E6815B9794CFCD65A8F0C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 5C6B00FACE8707503D366F7C /* Fixtures in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 58B511D71A9E6C8500147676 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 08B8B3B8260E70B7ECA85451 /* bspatch_bridge.c in Sources */, + A75C1A7A555663186E25AA3D /* libHDiffPatch/HPatch/patch.c in Sources */, + D8C294C1D2625E79BC302CCC /* bsdiff_wrapper/bspatch_wrapper.c in Sources */, + 78E9CECCB820DC4BA09BF09A /* file_for_patch.c in Sources */, 3221E47D2C8ABE1400268379 /* mz_os_posix.c in Sources */, 3221E4792C8ABE1300268379 /* mz_os.c in Sources */, F88664621F4AD1EE0036D01B /* JWTCryptoSecurity.m in Sources */, @@ -818,9 +924,52 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7F757C5E9DC4CC7D30C29506 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B5B50F91FAAF80444988E284 /* BSPatchTests.swift in Sources */, + 47F66D5AF3C3185E1A3E3B15 /* bspatch_bridge.c in Sources */, + DC983F1C71E0E7131BB343C5 /* libHDiffPatch/HPatch/patch.c in Sources */, + A88F11124A2120A8373A8B61 /* bsdiff_wrapper/bspatch_wrapper.c in Sources */, + 0ABCB5DEFE01A7A15552A498 /* file_for_patch.c in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ + 4E5D9A15C8BA6CEA802EA38B /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "_IS_NEED_BLOCK_DEV=0", + "_IS_USED_MULTITHREAD=0", + "_IS_NEED_DIR_DIFF_PATCH=0", + ); + GENERATE_INFOPLIST_FILE = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../shared", + "$(SRCROOT)/../shared/diffpatch", + "$(SRCROOT)/../shared/third_party/hdiffpatch", + "$(SRCROOT)/../shared/third_party/hdiffpatch/libHDiffPatch/HPatch", + ); + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + OTHER_LDFLAGS = ( + "$(inherited)", + "-lbz2", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.microsoft.codepush.CodePushTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_OBJC_BRIDGING_HEADER = "CodePushTests/CodePushTests-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; 58B511ED1A9E6C8500147676 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -931,18 +1080,26 @@ HAVE_WZAES, HAVE_ZLIB, ZLIB_COMPAT, + "_IS_NEED_BLOCK_DEV=0", + "_IS_USED_MULTITHREAD=0", + "_IS_NEED_DIR_DIFF_PATCH=0", ); HEADER_SEARCH_PATHS = ( "$(inherited)", /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include, "$(SRCROOT)/../../react-native/React/**", "$(SRC_ROOT)/JWT/**", + "$(SRCROOT)/../shared", + "$(SRCROOT)/../shared/diffpatch", + "$(SRCROOT)/../shared/third_party/hdiffpatch", + "$(SRCROOT)/../shared/third_party/hdiffpatch/libHDiffPatch/HPatch", ); IPHONEOS_DEPLOYMENT_TARGET = 15.5; LIBRARY_SEARCH_PATHS = "$(inherited)"; OTHER_LDFLAGS = ( "-ObjC", "-lz", + "-lbz2", ); PRODUCT_NAME = CodePush; SKIP_INSTALL = YES; @@ -960,18 +1117,26 @@ HAVE_WZAES, HAVE_ZLIB, ZLIB_COMPAT, + "_IS_NEED_BLOCK_DEV=0", + "_IS_USED_MULTITHREAD=0", + "_IS_NEED_DIR_DIFF_PATCH=0", ); HEADER_SEARCH_PATHS = ( "$(inherited)", /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include, "$(SRCROOT)/../../react-native/React/**", "$(SRC_ROOT)/JWT/**", + "$(SRCROOT)/../shared", + "$(SRCROOT)/../shared/diffpatch", + "$(SRCROOT)/../shared/third_party/hdiffpatch", + "$(SRCROOT)/../shared/third_party/hdiffpatch/libHDiffPatch/HPatch", ); IPHONEOS_DEPLOYMENT_TARGET = 15.5; LIBRARY_SEARCH_PATHS = "$(inherited)"; OTHER_LDFLAGS = ( "-ObjC", "-lz", + "-lbz2", ); PRODUCT_NAME = CodePush; SKIP_INSTALL = YES; @@ -1016,6 +1181,38 @@ }; name = Release; }; + D41A1215D79D9643EC63FA1F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "_IS_NEED_BLOCK_DEV=0", + "_IS_USED_MULTITHREAD=0", + "_IS_NEED_DIR_DIFF_PATCH=0", + ); + GENERATE_INFOPLIST_FILE = YES; + HEADER_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../shared", + "$(SRCROOT)/../shared/diffpatch", + "$(SRCROOT)/../shared/third_party/hdiffpatch", + "$(SRCROOT)/../shared/third_party/hdiffpatch/libHDiffPatch/HPatch", + ); + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + OTHER_LDFLAGS = ( + "$(inherited)", + "-lbz2", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.microsoft.codepush.CodePushTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_OBJC_BRIDGING_HEADER = "CodePushTests/CodePushTests-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -1046,6 +1243,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A11B0911B46B588F8C213676 /* Build configuration list for PBXNativeTarget "CodePushTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D41A1215D79D9643EC63FA1F /* Release */, + 4E5D9A15C8BA6CEA802EA38B /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 58B511D31A9E6C8500147676 /* Project object */; diff --git a/ios/CodePush.xcodeproj/xcshareddata/xcschemes/CodePushTests.xcscheme b/ios/CodePush.xcodeproj/xcshareddata/xcschemes/CodePushTests.xcscheme new file mode 100644 index 00000000..8420f54f --- /dev/null +++ b/ios/CodePush.xcodeproj/xcshareddata/xcschemes/CodePushTests.xcscheme @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/CodePushTests/BSPatchTests.swift b/ios/CodePushTests/BSPatchTests.swift new file mode 100644 index 00000000..726ddff6 --- /dev/null +++ b/ios/CodePushTests/BSPatchTests.swift @@ -0,0 +1,127 @@ +import XCTest + +final class BSPatchTests: XCTestCase { + + private var tempDir: URL! + + override func setUpWithError() throws { + tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: tempDir) + } + + private func fixtureURL(_ relativePath: String) -> URL { + let bundle = Bundle(for: type(of: self)) + guard let resourceURL = bundle.url(forResource: "Fixtures", withExtension: nil) else { + fatalError("Fixtures resource folder not found in test bundle") + } + return resourceURL.appendingPathComponent(relativePath) + } + + private func outputURL(_ name: String) -> URL { + tempDir.appendingPathComponent(name) + } + + private func applyPatch(oldFile: URL, diffFile: URL, outFile: URL) -> CodePushBSPatchResult { + oldFile.path.withCString { oldPath in + diffFile.path.withCString { diffPath in + outFile.path.withCString { outPath in + codepush_bspatch_apply(oldPath, diffPath, outPath) + } + } + } + } + + // An ordinary text-file diff, several inserted/changed/copied regions. + func testApplyPatch_basicDiff_succeedsAndMatchesExpectedOutput() throws { + let oldFile = fixtureURL("basic/old.dat") + let diffFile = fixtureURL("basic/patch.bsdiff") + let expectedNewFile = fixtureURL("basic/new.dat") + let outFile = outputURL("basic_out.dat") + + let result = applyPatch(oldFile: oldFile, diffFile: diffFile, outFile: outFile) + + XCTAssertEqual(result, CODEPUSH_BSPATCH_OK) + XCTAssertEqual(try Data(contentsOf: outFile), try Data(contentsOf: expectedNewFile)) + } + + // Real BSDIFF40 patch whose only control entry is a single full-length copy from the old file. + func testApplyPatch_identicalOldAndNew_succeeds() throws { + let oldFile = fixtureURL("identical/old.dat") + let diffFile = fixtureURL("identical/patch.bsdiff") + let expectedNewFile = fixtureURL("identical/new.dat") + let outFile = outputURL("identical_out.dat") + + let result = applyPatch(oldFile: oldFile, diffFile: diffFile, outFile: outFile) + + XCTAssertEqual(result, CODEPUSH_BSPATCH_OK) + XCTAssertEqual(try Data(contentsOf: outFile), try Data(contentsOf: expectedNewFile)) + } + + func testApplyPatch_emptyOldFile_succeeds() throws { + let oldFile = fixtureURL("empty_old/old.dat") + let diffFile = fixtureURL("empty_old/patch.bsdiff") + let expectedNewFile = fixtureURL("empty_old/new.dat") + let outFile = outputURL("empty_old_out.dat") + + let result = applyPatch(oldFile: oldFile, diffFile: diffFile, outFile: outFile) + + XCTAssertEqual(result, CODEPUSH_BSPATCH_OK) + XCTAssertEqual(try Data(contentsOf: outFile), try Data(contentsOf: expectedNewFile)) + } + + // Well-formed length, wrong magic bytes (hand-written, not a real bsdiff output). + func testApplyPatch_badDiffHeader_returnsBadDiffHeader() { + let oldFile = fixtureURL("bad_header/old.dat") + let diffFile = fixtureURL("bad_header/patch.bsdiff") + let outFile = outputURL("bad_header_out.dat") + + let result = applyPatch(oldFile: oldFile, diffFile: diffFile, outFile: outFile) + + XCTAssertEqual(result, CODEPUSH_BSPATCH_ERR_BAD_DIFF_HEADER) + XCTAssertFalse(FileManager.default.fileExists(atPath: outFile.path), + "output file should not be left behind after a failed patch") + } + + // HDiffPatch's bounds checks must reject these inputs rather than reading out of range or + // silently emitting corrupt output. wrong_old/old.dat is unrelated to (and shorter than) + // basic/old.dat, so basic/patch.bsdiff's copy instructions reference offsets out of range for it. + func testApplyPatch_mismatchedOldFile_returnsPatchFailed() { + let oldFile = fixtureURL("wrong_old/old.dat") + let diffFile = fixtureURL("basic/patch.bsdiff") + let outFile = outputURL("mismatched_old_out.dat") + + let result = applyPatch(oldFile: oldFile, diffFile: diffFile, outFile: outFile) + + XCTAssertEqual(result, CODEPUSH_BSPATCH_ERR_PATCH_FAILED) + XCTAssertFalse(FileManager.default.fileExists(atPath: outFile.path), + "output file should not be left behind after a failed patch") + } + + func testApplyPatch_missingOldFile_returnsOpenOldFailed() { + let missingOldFile = outputURL("does_not_exist_old.dat") + let diffFile = fixtureURL("basic/patch.bsdiff") + let outFile = outputURL("missing_old_out.dat") + + let result = applyPatch(oldFile: missingOldFile, diffFile: diffFile, outFile: outFile) + + XCTAssertEqual(result, CODEPUSH_BSPATCH_ERR_OPEN_OLD) + XCTAssertFalse(FileManager.default.fileExists(atPath: outFile.path), + "output file should not be left behind after a failed patch") + } + + func testApplyPatch_missingDiffFile_returnsOpenDiffFailed() { + let oldFile = fixtureURL("basic/old.dat") + let missingDiffFile = outputURL("does_not_exist.bsdiff") + let outFile = outputURL("missing_diff_out.dat") + + let result = applyPatch(oldFile: oldFile, diffFile: missingDiffFile, outFile: outFile) + + XCTAssertEqual(result, CODEPUSH_BSPATCH_ERR_OPEN_DIFF) + XCTAssertFalse(FileManager.default.fileExists(atPath: outFile.path), + "output file should not be left behind after a failed patch") + } +} diff --git a/ios/CodePushTests/CodePushTests-Bridging-Header.h b/ios/CodePushTests/CodePushTests-Bridging-Header.h new file mode 100644 index 00000000..43382433 --- /dev/null +++ b/ios/CodePushTests/CodePushTests-Bridging-Header.h @@ -0,0 +1 @@ +#import "bspatch_bridge.h" diff --git a/ios/CodePushTests/Fixtures/bad_header/old.dat b/ios/CodePushTests/Fixtures/bad_header/old.dat new file mode 100644 index 00000000..f6bfa2d0 --- /dev/null +++ b/ios/CodePushTests/Fixtures/bad_header/old.dat @@ -0,0 +1,2 @@ +Irrelevant content - old.dat just needs to open successfully so the +test reaches the diff-header check this fixture is actually exercising. diff --git a/ios/CodePushTests/Fixtures/bad_header/patch.bsdiff b/ios/CodePushTests/Fixtures/bad_header/patch.bsdiff new file mode 100644 index 00000000..d574da6b Binary files /dev/null and b/ios/CodePushTests/Fixtures/bad_header/patch.bsdiff differ diff --git a/ios/CodePushTests/Fixtures/basic/new.dat b/ios/CodePushTests/Fixtures/basic/new.dat new file mode 100644 index 00000000..54241f83 --- /dev/null +++ b/ios/CodePushTests/Fixtures/basic/new.dat @@ -0,0 +1,25 @@ +function greet(name) { + console.log("Hello there, " + name + "!"); + return "Hello there, " + name + "!"; +} + +function farewell(name) { + console.log("Goodbye, " + name + "."); + return "Goodbye, " + name + "."; +} + +function shout(name) { + console.log("HEY, " + name.toUpperCase() + "!!!"); + return "HEY, " + name.toUpperCase() + "!!!"; +} + +var VERSION = "1.1.0"; +var BUILD_NUMBER = 43; + +module.exports = { + greet: greet, + farewell: farewell, + shout: shout, + VERSION: VERSION, + BUILD_NUMBER: BUILD_NUMBER, +}; diff --git a/ios/CodePushTests/Fixtures/basic/old.dat b/ios/CodePushTests/Fixtures/basic/old.dat new file mode 100644 index 00000000..b4667705 --- /dev/null +++ b/ios/CodePushTests/Fixtures/basic/old.dat @@ -0,0 +1,19 @@ +function greet(name) { + console.log("Hello, " + name + "!"); + return "Hello, " + name + "!"; +} + +function farewell(name) { + console.log("Goodbye, " + name + "."); + return "Goodbye, " + name + "."; +} + +var VERSION = "1.0.0"; +var BUILD_NUMBER = 42; + +module.exports = { + greet: greet, + farewell: farewell, + VERSION: VERSION, + BUILD_NUMBER: BUILD_NUMBER, +}; diff --git a/ios/CodePushTests/Fixtures/basic/patch.bsdiff b/ios/CodePushTests/Fixtures/basic/patch.bsdiff new file mode 100644 index 00000000..a0e9d31a Binary files /dev/null and b/ios/CodePushTests/Fixtures/basic/patch.bsdiff differ diff --git a/ios/CodePushTests/Fixtures/empty_old/new.dat b/ios/CodePushTests/Fixtures/empty_old/new.dat new file mode 100644 index 00000000..3559a9ff --- /dev/null +++ b/ios/CodePushTests/Fixtures/empty_old/new.dat @@ -0,0 +1,3 @@ +Everything in this file is new: the old side is a zero-byte file, so +the whole patch body is a literal insert with no copy-from-old control +entries at all. diff --git a/ios/CodePushTests/Fixtures/empty_old/old.dat b/ios/CodePushTests/Fixtures/empty_old/old.dat new file mode 100644 index 00000000..e69de29b diff --git a/ios/CodePushTests/Fixtures/empty_old/patch.bsdiff b/ios/CodePushTests/Fixtures/empty_old/patch.bsdiff new file mode 100644 index 00000000..4b376b26 Binary files /dev/null and b/ios/CodePushTests/Fixtures/empty_old/patch.bsdiff differ diff --git a/ios/CodePushTests/Fixtures/identical/new.dat b/ios/CodePushTests/Fixtures/identical/new.dat new file mode 100644 index 00000000..3e69fff2 --- /dev/null +++ b/ios/CodePushTests/Fixtures/identical/new.dat @@ -0,0 +1,4 @@ +This file is byte-for-byte identical on both sides of the patch. +It exercises the zero-delta path: a real BSDIFF40 patch whose only +control entry is a single full-length copy from the old file, with +no literal bytes and no seek. Nothing to add or skip. diff --git a/ios/CodePushTests/Fixtures/identical/old.dat b/ios/CodePushTests/Fixtures/identical/old.dat new file mode 100644 index 00000000..3e69fff2 --- /dev/null +++ b/ios/CodePushTests/Fixtures/identical/old.dat @@ -0,0 +1,4 @@ +This file is byte-for-byte identical on both sides of the patch. +It exercises the zero-delta path: a real BSDIFF40 patch whose only +control entry is a single full-length copy from the old file, with +no literal bytes and no seek. Nothing to add or skip. diff --git a/ios/CodePushTests/Fixtures/identical/patch.bsdiff b/ios/CodePushTests/Fixtures/identical/patch.bsdiff new file mode 100644 index 00000000..59b175d0 Binary files /dev/null and b/ios/CodePushTests/Fixtures/identical/patch.bsdiff differ diff --git a/ios/CodePushTests/Fixtures/wrong_old/old.dat b/ios/CodePushTests/Fixtures/wrong_old/old.dat new file mode 100644 index 00000000..9c90f626 --- /dev/null +++ b/ios/CodePushTests/Fixtures/wrong_old/old.dat @@ -0,0 +1,3 @@ +This is a completely unrelated old file, deliberately shaped so that +applying fixtures/basic/patch.bsdiff against it does not match the old +file bsdiff was actually built from. diff --git a/package.json b/package.json index f9003811..fe662829 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "test:fast:expo:ios": "mocha --recursive --bail bin/test --ios --expo", "test:debugger:android": "mocha --recursive --inspect-brk=0.0.0.0 bin/test --android", "test:debugger:ios": "mocha --recursive --inspect-brk=0.0.0.0 bin/test --ios", + "test:unit:android": "cd android && ./gradlew :app:test", + "test:unit:ios": "xcodebuild test -project ios/CodePush.xcodeproj -scheme CodePushTests -destination \"platform=iOS Simulator,id=$(xcrun simctl list devices available | grep -m1 iPhone | grep -oE '[0-9A-F-]{36}')\"", "tslint": "tslint -c tslint.json test/**/*.ts" }, "repository": { diff --git a/android/app/src/main/cpp/bspatch_bridge.c b/shared/diffpatch/bspatch_bridge.c similarity index 100% rename from android/app/src/main/cpp/bspatch_bridge.c rename to shared/diffpatch/bspatch_bridge.c diff --git a/android/app/src/main/cpp/bspatch_bridge.h b/shared/diffpatch/bspatch_bridge.h similarity index 100% rename from android/app/src/main/cpp/bspatch_bridge.h rename to shared/diffpatch/bspatch_bridge.h diff --git a/shared/third_party/README.md b/shared/third_party/README.md new file mode 100644 index 00000000..338762e6 --- /dev/null +++ b/shared/third_party/README.md @@ -0,0 +1,16 @@ +# Vendored sources + +Vendored libraries shared across native iOS and Android modules. + +## 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.). diff --git a/android/app/src/main/cpp/third_party/hdiffpatch/LICENSE b/shared/third_party/hdiffpatch/LICENSE similarity index 100% rename from android/app/src/main/cpp/third_party/hdiffpatch/LICENSE rename to shared/third_party/hdiffpatch/LICENSE diff --git a/android/app/src/main/cpp/third_party/hdiffpatch/bsdiff_wrapper/bspatch_wrapper.c b/shared/third_party/hdiffpatch/bsdiff_wrapper/bspatch_wrapper.c similarity index 100% rename from android/app/src/main/cpp/third_party/hdiffpatch/bsdiff_wrapper/bspatch_wrapper.c rename to shared/third_party/hdiffpatch/bsdiff_wrapper/bspatch_wrapper.c diff --git a/android/app/src/main/cpp/third_party/hdiffpatch/bsdiff_wrapper/bspatch_wrapper.h b/shared/third_party/hdiffpatch/bsdiff_wrapper/bspatch_wrapper.h similarity index 100% rename from android/app/src/main/cpp/third_party/hdiffpatch/bsdiff_wrapper/bspatch_wrapper.h rename to shared/third_party/hdiffpatch/bsdiff_wrapper/bspatch_wrapper.h diff --git a/android/app/src/main/cpp/third_party/hdiffpatch/decompress_plugin_demo.h b/shared/third_party/hdiffpatch/decompress_plugin_demo.h similarity index 100% rename from android/app/src/main/cpp/third_party/hdiffpatch/decompress_plugin_demo.h rename to shared/third_party/hdiffpatch/decompress_plugin_demo.h diff --git a/android/app/src/main/cpp/third_party/hdiffpatch/dirDiffPatch/dir_patch/dir_patch_types.h b/shared/third_party/hdiffpatch/dirDiffPatch/dir_patch/dir_patch_types.h similarity index 100% rename from android/app/src/main/cpp/third_party/hdiffpatch/dirDiffPatch/dir_patch/dir_patch_types.h rename to shared/third_party/hdiffpatch/dirDiffPatch/dir_patch/dir_patch_types.h diff --git a/android/app/src/main/cpp/third_party/hdiffpatch/file_for_patch.c b/shared/third_party/hdiffpatch/file_for_patch.c similarity index 100% rename from android/app/src/main/cpp/third_party/hdiffpatch/file_for_patch.c rename to shared/third_party/hdiffpatch/file_for_patch.c diff --git a/android/app/src/main/cpp/third_party/hdiffpatch/file_for_patch.h b/shared/third_party/hdiffpatch/file_for_patch.h similarity index 100% rename from android/app/src/main/cpp/third_party/hdiffpatch/file_for_patch.h rename to shared/third_party/hdiffpatch/file_for_patch.h diff --git a/android/app/src/main/cpp/third_party/hdiffpatch/libHDiffPatch/HPatch/checksum_plugin.h b/shared/third_party/hdiffpatch/libHDiffPatch/HPatch/checksum_plugin.h similarity index 100% rename from android/app/src/main/cpp/third_party/hdiffpatch/libHDiffPatch/HPatch/checksum_plugin.h rename to shared/third_party/hdiffpatch/libHDiffPatch/HPatch/checksum_plugin.h diff --git a/android/app/src/main/cpp/third_party/hdiffpatch/libHDiffPatch/HPatch/hpatch_mt/hpatch_mt.h b/shared/third_party/hdiffpatch/libHDiffPatch/HPatch/hpatch_mt/hpatch_mt.h similarity index 100% rename from android/app/src/main/cpp/third_party/hdiffpatch/libHDiffPatch/HPatch/hpatch_mt/hpatch_mt.h rename to shared/third_party/hdiffpatch/libHDiffPatch/HPatch/hpatch_mt/hpatch_mt.h diff --git a/android/app/src/main/cpp/third_party/hdiffpatch/libHDiffPatch/HPatch/patch.c b/shared/third_party/hdiffpatch/libHDiffPatch/HPatch/patch.c similarity index 100% rename from android/app/src/main/cpp/third_party/hdiffpatch/libHDiffPatch/HPatch/patch.c rename to shared/third_party/hdiffpatch/libHDiffPatch/HPatch/patch.c diff --git a/android/app/src/main/cpp/third_party/hdiffpatch/libHDiffPatch/HPatch/patch.h b/shared/third_party/hdiffpatch/libHDiffPatch/HPatch/patch.h similarity index 100% rename from android/app/src/main/cpp/third_party/hdiffpatch/libHDiffPatch/HPatch/patch.h rename to shared/third_party/hdiffpatch/libHDiffPatch/HPatch/patch.h diff --git a/android/app/src/main/cpp/third_party/hdiffpatch/libHDiffPatch/HPatch/patch_private.h b/shared/third_party/hdiffpatch/libHDiffPatch/HPatch/patch_private.h similarity index 100% rename from android/app/src/main/cpp/third_party/hdiffpatch/libHDiffPatch/HPatch/patch_private.h rename to shared/third_party/hdiffpatch/libHDiffPatch/HPatch/patch_private.h diff --git a/android/app/src/main/cpp/third_party/hdiffpatch/libHDiffPatch/HPatch/patch_types.h b/shared/third_party/hdiffpatch/libHDiffPatch/HPatch/patch_types.h similarity index 100% rename from android/app/src/main/cpp/third_party/hdiffpatch/libHDiffPatch/HPatch/patch_types.h rename to shared/third_party/hdiffpatch/libHDiffPatch/HPatch/patch_types.h