Skip to content

feat(android): apply binary patch updates - #154

Draft
floyd-soomgo wants to merge 3 commits into
feature/binary-patch-metadatafrom
feature/binary-patch-android-apply
Draft

feat(android): apply binary patch updates#154
floyd-soomgo wants to merge 3 commits into
feature/binary-patch-metadatafrom
feature/binary-patch-android-apply

Conversation

@floyd-soomgo

Copy link
Copy Markdown
Member

Summary

Fourth PR of the binary differential OTA series (stacked on feat: add optional binary patch metadata — merge that first).

The previous PR delivered binaryPatchDownloadUrl to native download metadata without consuming it. This PR makes Android consume it: when the field is present, the client downloads the patch archive, applies the patch against the bundle embedded in the app binary, and installs the reconstructed contents — verifying them at every step. Any failure at any patch stage falls back to the existing full archive exactly once, and the fallback is structural (the full path re-enters through an internal call that cannot reach the patch branch — no counters, no persisted state). iOS is the next PR.

A package without the field takes the untouched legacy path; the existing full zip, non-zip, and hotcodepush.json diff flows are unchanged downstream.

Trust model: the patch archive is treated as unverified input until proven otherwise.

  • Manifest schema, algorithm/formatVersion, and path confinement (no entry may escape the archive) are checked first, then a size bound and available disk space.
  • The embedded base bundle is read from assets and its SHA-256 must match baseBundleHash before anything is applied.
  • The patch header's compression type and old/new sizes are cross-checked against the manifest inside the native call, before the patch runs.
  • The reconstructed bundle must match targetBundleSize and targetBundleHash before it is moved into place — this post-hash is the only defense against corrupted patch bodies (the compressed stream carries no content checksum), so it is never skipped. The existing folder-hash verification then confirms the final packageHash, same as a full download.
  • Every failure logs a stable reason (base_hash_mismatch, invalid_manifest, unsupported_format, patch_apply_failed, target_verification_failed, base_bundle_unavailable) through the existing CodePush log channel, and a success logs the apply duration — the signal for validating on a staging identifier before enabling the URL in production. Temp files live under <CodePushPath>/binary-patch/ and are cleaned on success, fallback, and failure, including stale leftovers from a killed process.

Changes

  • Native applier (android/app/src/main/cpp/) — a single JNI entry point (HDiffPatchNative.applyPatch) plus a CMake target that compiles the shared C sources at cpp/binarypatch/ (HDiffPatch apply path, zstd decompress set, and the project's zstd adapter) by relative path — nothing is re-vendored under android/. ZSTD_DISABLE_ASM=1, _IS_USED_MULTITHREAD=0, no assembly files. The wrapper validates the patch header against the caller's expectations, applies with sequential writes to a temp file, and returns distinct error codes that Java maps to fallback reasons.
  • CodePushUpdateManager — exactly two branch points: download-URL selection at the top of downloadPackage, and patch application right after unzip, beside the existing diff-manifest detection. The manager now takes the application Context (single construction site) to read the embedded bundle via AssetManager straight into memory — no disk copy. A patch download that turns out not to be a zip archive (e.g. an error page served with HTTP 200) is rejected as invalid_manifest and falls back, instead of flowing into the raw-bundle path. OutOfMemoryError on the patch path — the only path holding a whole bundle in memory — is caught and treated as a patch failure so the streaming full download still runs; the base-bundle buffer is pre-sized to cut the allocation peak.
  • CodePushBinaryPatch / BinaryPatchResult — the validation pipeline above as a self-contained, JVM-testable unit behind two seams (base-bundle provider, patch applier). Archive contents are resolved the same way the CLI lays them out (everything nested under a single root directory whose name is part of packageHash) — the integration tests caught that reading the manifest at the unzip root would have quietly sent every patch to the fallback.
  • GradleexternalNativeBuild/CMake wiring in the library module. Consumer builds now need NDK + CMake, which React Native apps generally already satisfy; documented in the README.
  • Tests — 29 JVM unit tests, run from the example app's Gradle project. CodePushBinaryPatchTest drives real files, real zips, and real SHA-256 (expected hashes computed independently of the production hash code); CodePushUpdateManagerDownloadTest goes end-to-end through a real loopback HTTP server, the real download/unzip/restore/folder-hash pipeline, with only the two native seams stubbed — covering patch install, non-archive responses, out-of-memory fallback, and request-order assertions ([patch, full]). A package-private constructor exists solely to inject the seams.
  • Docs — README section for the Android integration and the NDK/CMake requirement (EN/KO CLI docs updated alongside); the outdated note in src/CodePush.js claiming Android cannot install binary-based updates now describes the actual behavior.

Test plan

npm run typecheck
npm run jest                       # root JS suites, unchanged behavior pinned

cd Examples/RN0840/android
./gradlew :bravemobile_react-native-code-push:testDebugUnitTest   # 29/29
./gradlew :app:compileReleaseJavaWithJavac                        # + native build (4 ABIs)

Mutation-checked: reverting the target-hash check, the fallback branch, or the fix commit's guards fails exactly the tests that cover them. On-device end-to-end scenarios (real patch bytes on a real archive) land with the E2E PR later in the series.

A release published with a binary patch offers two archives of the same
update: the full one, and a patch of the JS bundle against the bundle
inside the app binary. Download the patch when the release has one, apply
it to the binary's bundle and put the restored bundle where the archive
left a patch, which leaves contents identical to the full archive - and
therefore the folder hash check that follows unchanged.

Nothing about a patch is trusted. The manifest is checked before anything
is read, the paths it points at have to stay inside the archive, the base
bundle is hashed before the patch is applied and the restored bundle is
hashed afterwards, and the restored bytes only reach the update contents
once both hashes match. A patch carries no checksum of what it produces,
so those two hashes are the only thing standing between a corrupted patch
and a broken app.

Any failure along that path downloads the full archive instead, and does
so exactly once: the fallback download is not allowed to take the patch
path, so it has no patch failure of its own to fall back from and needs no
counter or stored state to say so. Each failure is logged with the reason
it fell back, which is what a rollout is judged from.

The applier itself is the shared C code the host build compiles too,
reached from CMake where it lives rather than copied in.
The android client now installs an update from its patch archive and falls
back to the full one, so say so where patch bundles are documented, and
record what that adds to a consumer's android build: the NDK and CMake,
which compile the applier.

The note about installing updates against the binary's bundle being
something android cannot do is no longer true, so it now explains why the
update check still does not carry the binary's hash.
Three ways an update could go wrong on the patch path, found by testing the
download and install steps together instead of apart:

An archive wraps its files in a single directory, and a manifest's paths are
relative to that directory, so the manifest was looked for one level above
where it is. Every patch update would have fallen back to the full archive.

A patch URL that answers with something other than an archive - an error page
served with a 200, say - took the branch that treats a download as a bare JS
bundle and moves it into the package folder under the update's hash, with no
patch applied and no hash ever checked, and then reported success so nothing
fell back. The patch path now refuses anything that is not an archive.

Applying a patch is the one path that holds a whole bundle in memory, and an
OutOfMemoryError there escaped the fallback, the native module's error
handling and the download task, leaving the promise unsettled - the worst
answer to the failure the fallback exists for. It is now absorbed like any
other patch failure, and the bundle is read into a buffer sized from the
asset instead of one that grows into a second copy of itself.

The download and install steps are now covered together, over a real socket
with real archives, with only the applier's two seams stubbed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant