react-native-bspatch 4.0.3 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +87 -30
  2. package/android/build.gradle +25 -0
  3. package/android/src/main/AndroidManifest.xml +2 -0
  4. package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +86 -6
  5. package/android/src/main/res/values/bspatch_public_key.xml +5 -0
  6. package/cpp/bsdiff/bspatch_wrapper.c +34 -0
  7. package/cpp/bsdiff/bspatch_wrapper.h +2 -0
  8. package/cpp/bspatch_jni.cpp +18 -0
  9. package/ios/RNBsPatch.mm +142 -1
  10. package/lib/commonjs/NativeBsPatch.js.map +1 -1
  11. package/lib/commonjs/index.js +9 -2
  12. package/lib/commonjs/index.js.map +1 -1
  13. package/lib/commonjs/ota/OtaProvider.js +124 -32
  14. package/lib/commonjs/ota/OtaProvider.js.map +1 -1
  15. package/lib/commonjs/ota/eligibility.js +40 -0
  16. package/lib/commonjs/ota/eligibility.js.map +1 -0
  17. package/lib/commonjs/ota/entrySignature.js +23 -0
  18. package/lib/commonjs/ota/entrySignature.js.map +1 -0
  19. package/lib/commonjs/ota/installId.js +27 -0
  20. package/lib/commonjs/ota/installId.js.map +1 -0
  21. package/lib/commonjs/ota/otaCore.js +96 -21
  22. package/lib/commonjs/ota/otaCore.js.map +1 -1
  23. package/lib/commonjs/ota/storage/{GitHubRawStorage.js → R2Storage.js} +20 -15
  24. package/lib/commonjs/ota/storage/R2Storage.js.map +1 -0
  25. package/lib/commonjs/ota/types.js.map +1 -1
  26. package/lib/module/NativeBsPatch.js.map +1 -1
  27. package/lib/module/index.js +2 -1
  28. package/lib/module/index.js.map +1 -1
  29. package/lib/module/ota/OtaProvider.js +124 -32
  30. package/lib/module/ota/OtaProvider.js.map +1 -1
  31. package/lib/module/ota/eligibility.js +34 -0
  32. package/lib/module/ota/eligibility.js.map +1 -0
  33. package/lib/module/ota/entrySignature.js +17 -0
  34. package/lib/module/ota/entrySignature.js.map +1 -0
  35. package/lib/module/ota/installId.js +22 -0
  36. package/lib/module/ota/installId.js.map +1 -0
  37. package/lib/module/ota/otaCore.js +96 -21
  38. package/lib/module/ota/otaCore.js.map +1 -1
  39. package/lib/module/ota/storage/{GitHubRawStorage.js → R2Storage.js} +18 -14
  40. package/lib/module/ota/storage/R2Storage.js.map +1 -0
  41. package/lib/module/ota/types.js.map +1 -1
  42. package/lib/typescript/NativeBsPatch.d.ts +28 -2
  43. package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
  44. package/lib/typescript/index.d.ts +3 -1
  45. package/lib/typescript/index.d.ts.map +1 -1
  46. package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
  47. package/lib/typescript/ota/eligibility.d.ts +19 -0
  48. package/lib/typescript/ota/eligibility.d.ts.map +1 -0
  49. package/lib/typescript/ota/entrySignature.d.ts +9 -0
  50. package/lib/typescript/ota/entrySignature.d.ts.map +1 -0
  51. package/lib/typescript/ota/installId.d.ts +3 -0
  52. package/lib/typescript/ota/installId.d.ts.map +1 -0
  53. package/lib/typescript/ota/otaCore.d.ts.map +1 -1
  54. package/lib/typescript/ota/storage/{GitHubRawStorage.d.ts → R2Storage.d.ts} +4 -3
  55. package/lib/typescript/ota/storage/R2Storage.d.ts.map +1 -0
  56. package/lib/typescript/ota/types.d.ts +54 -5
  57. package/lib/typescript/ota/types.d.ts.map +1 -1
  58. package/package.json +3 -2
  59. package/react-native-bspatch.podspec +3 -1
  60. package/src/NativeBsPatch.ts +34 -2
  61. package/src/index.ts +5 -1
  62. package/src/ota/OtaProvider.tsx +128 -43
  63. package/src/ota/eligibility.ts +48 -0
  64. package/src/ota/entrySignature.ts +31 -0
  65. package/src/ota/installId.ts +18 -0
  66. package/src/ota/otaCore.ts +102 -21
  67. package/src/ota/storage/{GitHubRawStorage.ts → R2Storage.ts} +17 -13
  68. package/src/ota/types.ts +51 -5
  69. package/tools/build_ota.sh +343 -116
  70. package/tools/doctor.js +130 -0
  71. package/tools/ensure_signing_key.js +216 -0
  72. package/tools/fingerprint.js +111 -0
  73. package/tools/ios_fingerprint.sh +9 -0
  74. package/tools/ota_admin.js +131 -0
  75. package/tools/setup_project.js +41 -15
  76. package/tools/update_registry.js +111 -11
  77. package/tools/upload_ota.sh +85 -184
  78. package/tools/verify_publish.js +81 -0
  79. package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +0 -1
  80. package/lib/module/ota/storage/GitHubRawStorage.js.map +0 -1
  81. package/lib/typescript/ota/storage/GitHubRawStorage.d.ts.map +0 -1
package/README.md CHANGED
@@ -7,7 +7,7 @@ Premium OTA update solution for React Native using **TurboModules** + **bsdiff b
7
7
  - **Binary delta patches** — after the first OTA, clients download only the diff from the OTA bundle they already run (typically a few hundred KB)
8
8
  - **POSIX `mmap()` Zero-Copy Engine** — patches directly in mapped pages, saving ~85–90% heap RAM
9
9
  - **ARM NEON SIMD Fast-Path** — vectorized diff patching via Clang SIMD
10
- - **Silent Background Updates** — download and apply updates completely in the background; smoothly activates on next launch or app backgrounding
10
+ - **Silent Background Updates** — download and apply updates completely in the background; activates on next cold start or when you call `reloadBundle()`
11
11
  - **Auto Native Version Detection** — auto-detects `versionName` (Android) and `CFBundleShortVersionString` (iOS); no manual version sync in JS
12
12
  - **Asymmetric Cryptography (RSA & ECDSA)** — sign patches on server/CI with Private Key; client verifies with Public Key (zero secret leakage)
13
13
  - **Shorebird-style CLI** — `patch android|ios`, release version read from the native project and confirmed with one Enter
@@ -70,7 +70,7 @@ const config = {
70
70
  updateMode: OtaUpdateMode.BACKGROUND_DOWNLOAD, // Silent download without interrupting user
71
71
  allowDebugUpdates: false, // default: OTA is blocked in __DEV__
72
72
  customServer: {
73
- baseUrl: Config.OTA_BASE_URL!, // public storage URL (R2 / GitHub raw), no fallback
73
+ baseUrl: Config.OTA_BASE_URL!, // R2 public URL, no fallback
74
74
  channel: Config.ENVIRONMENT!, // flavor: development / staging / production
75
75
  },
76
76
  };
@@ -147,16 +147,20 @@ type OtaConfig = {
147
147
  * - MANUAL: call checkUpdate() / applyUpdate() manually
148
148
  * - ON_APP_START: check and apply automatically on cold start
149
149
  * - ON_APP_FOREGROUND: check when app returns to foreground
150
- * - BACKGROUND_DOWNLOAD: download and patch in background; applies on next launch or app backgrounding
150
+ * - BACKGROUND_DOWNLOAD: download and patch in background; applies on next cold start or reloadBundle()
151
151
  */
152
152
  updateMode?: OtaUpdateMode;
153
153
 
154
- /** Network retry and timeout settings */
154
+ /** Network settings */
155
155
  network?: {
156
- retry?: number; // max retry attempts (default: 3)
157
- timeoutMs?: number; // request timeout in ms (default: 30000)
156
+ retry?: number; // attempts per file (default: 3); each retry resumes the partial download
157
+ timeoutMs?: number; // idle timeout in ms (default: 30000)
158
+ wifiOnly?: boolean; // automatic downloads only on Wi-Fi/Ethernet (applyUpdate() always downloads)
158
159
  };
159
160
 
161
+ /** Minimum time between automatic checks (start, foreground, checkUpdate()). Default 10 min. */
162
+ minCheckIntervalMs?: number;
163
+
160
164
  /**
161
165
  * Optional telemetry callback — all OTA lifecycle events are forwarded here.
162
166
  * Use it to pipe events to your analytics service.
@@ -164,9 +168,15 @@ type OtaConfig = {
164
168
  onEvent?: (entry: OtaLogEntry) => void;
165
169
 
166
170
  /**
167
- * SPKI public key (PEM or base64, RSA or EC P-256) for signature verification.
168
- * When set, unsigned or wrongly signed patches/bundles are rejected.
169
- * Recommended for production to prevent MITM attacks.
171
+ * POST every event as JSON to `url` (fire-and-forget), with installId, platform,
172
+ * appVersion, appBuild, runtimeFingerprint, flavor and the running OTA version attached.
173
+ * A Cloudflare Worker writing to D1/Analytics Engine is enough for a dashboard.
174
+ */
175
+ telemetry?: { url: string; headers?: Record<string, string> };
176
+
177
+ /**
178
+ * SPKI public key(s), PEM or base64 (comma-separated for several). Defaults to the
179
+ * signing + backup keys `patch` embedded in the app: leave it unset.
170
180
  */
171
181
  publicKey?: string;
172
182
 
@@ -178,9 +188,10 @@ type OtaConfig = {
178
188
  * `storage` is set; invalid values disable OTA (red box in __DEV__), no fallback.
179
189
  */
180
190
  customServer?: {
181
- baseUrl: string; // R2 public URL, CDN, or https://raw.githubusercontent.com/<owner>/<repo>/<branch>
191
+ baseUrl: string; // R2 public URL (r2.dev or custom domain)
182
192
  channel: string; // flavor
183
193
  baseFolder?: string; // default: 'ota'
194
+ headers?: Record<string, string>; // sent with every request (e.g. auth for a private server)
184
195
  };
185
196
  };
186
197
  ```
@@ -192,7 +203,7 @@ enum OtaUpdateMode {
192
203
  MANUAL = 'MANUAL', // No auto behavior
193
204
  ON_APP_START = 'ON_APP_START', // Check + apply on every cold start
194
205
  ON_APP_FOREGROUND = 'ON_APP_FOREGROUND', // Check when app returns to foreground
195
- BACKGROUND_DOWNLOAD = 'BACKGROUND_DOWNLOAD', // Silent download; applies on next start or app backgrounding
206
+ BACKGROUND_DOWNLOAD = 'BACKGROUND_DOWNLOAD', // Silent download; applies on next cold start or reloadBundle()
196
207
  }
197
208
  ```
198
209
 
@@ -240,7 +251,13 @@ Your OTA server must expose a `ota_registry.json` at the configured path (e.g. `
240
251
  "toVersion": 3,
241
252
  "bundleHash": "sha256_of_v3_bundle",
242
253
  "platform": "android",
243
- "fullBundleUrl": "bundle_1.0.0_v3_android_production.hbc"
254
+ "fullBundleUrl": "bundle_1.0.0_v3_android_production.hbc",
255
+ "bundleSize": 8558054,
256
+ "assetManifestUrl": "asset-manifest_1.0.0_v3_android_production.json",
257
+ "assetManifestHash": "sha256_of_the_asset_manifest",
258
+ "flavor": "production",
259
+ "bundleSignature": "base64…",
260
+ "entrySignature": "base64…"
244
261
  },
245
262
  {
246
263
  "id": "v2-to-v3-production-android",
@@ -261,6 +278,8 @@ Your OTA server must expose a `ota_registry.json` at the configured path (e.g. `
261
278
  }
262
279
  ```
263
280
 
281
+ Every version also gets `release_<app>_v<N>_<platform>_<flavor>.json` next to its bundle: who published it and when, release notes (`-m`), git commit/branch/dirty, bundle (raw + gzip), patch and asset sizes, and the signing key id. Clients never read it; it's there for a dashboard. It is public like everything in the bucket, so keep notes free of secrets.
282
+
264
283
  Every version has one full-bundle entry (no `filename`) plus patches from the last 5 OTA versions. The client applies the patch whose `fromVersion` is the OTA version installed on the device; otherwise (store build = v1, too far behind, or patch failure) it installs `fullBundleUrl`. The store bundle is never a patch source, so it never has to match byte-for-byte.
265
284
 
266
285
  ---
@@ -291,7 +310,7 @@ This means crash protection is **native-side and zero-cost** — it doesn't requ
291
310
 
292
311
  ## Custom Storage Provider
293
312
 
294
- The default provider (`GitHubRawStorageProvider`) reads any public static host with the `customServer` layout — Cloudflare R2, GitHub raw, a CDN. Implement the interface only for a backend that needs something else (signed URLs, an API):
313
+ The default provider (`R2StorageProvider`, formerly `GitHubRawStorageProvider`) reads the bucket's public URL with the `customServer` layout. Implement the interface only for a backend that needs something else (signed URLs, an API):
295
314
 
296
315
  ```ts
297
316
  interface OtaStorageProvider {
@@ -325,10 +344,13 @@ const {
325
344
  isChecking, // True while fetching manifest
326
345
  metrics, // OtaMetrics | null — timing/size data after update
327
346
  isUpdateReady, // True if background update is downloaded and ready
347
+ isMandatory, // The ready update is mandatory: it reloads as soon as the app is in the foreground
348
+ installId, // Stable random ID of this install (rollout bucket, QA lists, telemetry)
328
349
  applyUpdate, // () => Promise<void> — download and apply update immediately
350
+ applyVersion, // (version) => Promise<void> — download and apply a specific version from the manifest
329
351
  resetToFactory, // () => Promise<void> — wipe OTA state, reload APK/IPA
330
352
  markAsHealthy, // () => Promise<void> — manually mark launch OK
331
- checkUpdate, // () => Promise<void> — manual update check
353
+ checkUpdate, // ({ force }?) => Promise<void> — check now (throttled by minCheckIntervalMs unless force)
332
354
  reloadBundle, // () => void — immediately reload JS bundle to apply ready update
333
355
  } = useOta();
334
356
  ```
@@ -355,31 +377,53 @@ The library ships with an **automated build pipeline** (`tools/build_ota.sh`) th
355
377
  ### Commands
356
378
 
357
379
  ```bash
358
- npx react-native-bspatch setup # once: wire OtaBundleResolver into the app
359
380
  npx react-native-bspatch version 1.0.5 # set native version (package.json, Gradle, Xcode)
360
- # … release 1.0.5 to the stores (this is OTA v1) …
381
+ npx react-native-bspatch patch # first run also creates the signing keys and wires the native project
382
+ # … build + release 1.0.5 to the stores (this is OTA v1) …
361
383
  npx react-native-bspatch patch android # build + publish the next OTA
362
- npx react-native-bspatch patch ios --flavor staging
363
- npx react-native-bspatch patch # both platforms (versions must match)
384
+ npx react-native-bspatch patch ios --flavor staging -m "fix checkout"
385
+ npx react-native-bspatch patch --dry-run # build everything, upload nothing
386
+
387
+ npx react-native-bspatch status --flavor production # what's live, who published it, controls
388
+ npx react-native-bspatch doctor # check storage URL, keys, native wiring, fingerprint
389
+ npx react-native-bspatch rollout 7 20 --flavor production # v7 to 20% of installs (100 = everyone)
390
+ npx react-native-bspatch disable 7 # kill switch (enable 7 undoes it)
391
+ npx react-native-bspatch mandatory 7 # apply v7 as soon as it's downloaded (--off undoes it)
392
+ npx react-native-bspatch qa 7 --ids <installId>,… # v7 only for these installs (--off undoes it)
393
+ npx react-native-bspatch promote --from staging --flavor production # ship staging's bundle, no build
394
+ npx react-native-bspatch rollback --to 5 # publish v5's bundle again as the next version
364
395
  npx react-native-bspatch reset --flavor staging --push # wipe this release's OTAs
365
396
  ```
366
397
 
398
+ Every command takes `--flavor`, `android`/`ios`, `--release-version`, `-y`; `patch`, `promote`, `rollback` and `status` take `--json` (machine-readable output for CI). Controls act on the live version's registry entries and aren't signed: they can only hold an update back or release one that is already signed. Clients pick changes up on their next check.
399
+
367
400
  | Option | Default | Description |
368
401
  |---|---|---|
369
402
  | `android` / `ios` | both | Platform to patch |
370
403
  | `--flavor <name>` | `dev` | Channel (`dev`→`development`, `prod`→`production`) |
371
404
  | `--release-version <x.y.z>` | read from `versionName` / `MARKETING_VERSION`, confirmed by prompt | Store release the OTA targets |
372
- | `-y` | | Skip the prompt, use the detected version |
405
+ | `-m "<notes>"` | asked in a terminal | Release notes, kept in the version's `release_*.json` |
406
+ | `-y` | | Skip the prompts, use the detected version |
407
+ | `--reset-cache` | | Clear the Metro cache before bundling (it's cleared automatically when `.env.<flavor>` changed since the last build) |
408
+ | `--dry-run` | | Build, diff and sign, but upload nothing |
409
+ | `--json` | | Print the version's `release_*.json` after the summary |
373
410
 
374
411
  The release version is read from the native project (`versionName` / `MARKETING_VERSION`) and confirmed in a prompt — Enter keeps it. `-y` or `--release-version` skip the prompt (CI). Every patch ships a full bundle plus bsdiff patches from the last 5 OTA versions; v1 (the store build) is never a patch source, so nothing has to be captured from the store binary.
375
412
 
376
413
  ### How It Works
377
414
 
378
- 1. **Bundle** — `npx react-native bundle` + `hermesc` generate Hermes bytecode (`.hbc`), uploaded as the version's full bundle.
415
+ 1. **Bundle** — `npx react-native bundle` + `hermesc` generate Hermes bytecode (`.hbc`), uploaded gzipped (`.hbc.gz`, ~60% smaller) as the version's full bundle. Clients download it resumably and decompress it natively.
379
416
  2. **Assets** — Every asset is copied to the content-addressed `asset-store/` (`{sha256}.{ext}`) and listed in the per-version manifest with its Metro path. Clients download each hash once (cached in `ota/assets/`) and copy it to `ota/vN/<path>` — the location React Native resolves for a bundle loaded from `ota/vN/` (`drawable-*/`, `raw/` on Android; `assets/…` on iOS).
380
417
  3. **Patch** — `bsdiff` generates a delta from each of the last 5 OTA bundles (v2+) to the new one.
381
418
  4. **Registry** — Updates `ota_registry.json` with new `baseHash`, `bundleHash`, and asset manifest URL for each platform/channel.
382
- 5. **Upload** — Uploads bundles, patches, and asset store to the configured server.
419
+ 5. **Upload** — Uploads the bundle, patches and new assets to R2, then the registry.
420
+ 6. **Verify** — Reads the release back through `OTA_BASE_URL`, the way the app will: the registry must list the new version, every file must be served and the entries must verify against the signing key. An app pointed at another bucket fails here, not weeks later.
421
+
422
+ ### Runtime fingerprint (native compatibility)
423
+
424
+ An OTA bundle only installs on a binary with the same native code. `setup` (run by every `patch`) adds a step to the Xcode "Bundle React Native code and images" phase, and the library's Gradle file a `resValue`, that stamp the store build with a fingerprint of its native dependencies: react-native, every direct dependency with native code (name@version), `ios/Podfile.lock` / the Gradle dependency lines, and this library's own native sources (so a JS-only release of the library still ships by OTA). `patch` records the same fingerprint in every entry; a client skips entries for other native code, so adding a native module and publishing an OTA can't crash installed apps. The summary flags it when the fingerprint changed since the live version. Not covered: your own AppDelegate/MainApplication code (changing it needs a store build anyway).
425
+
426
+ Entries also carry the Hermes bytecode version and a `requires` list of client features (e.g. `gzip`): a client skips what its Hermes can't read or what it doesn't support, instead of failing on it.
383
427
 
384
428
  ### One-time Setup
385
429
 
@@ -415,9 +459,9 @@ customServer: {
415
459
  },
416
460
  ```
417
461
 
418
- Builds publish to **Cloudflare R2** when `.env.ota` sets `OTA_R2_BUCKET`, otherwise to a GitHub storage repository (auth via `gh auth token` / `GH_TOKEN` / `GITHUB_TOKEN`).
462
+ Builds publish to **Cloudflare R2**, the only supported storage for now.
419
463
 
420
- R2 setup: enable the bucket's Public Development URL (or a custom domain) and create an R2 API token with *Object Read & Write* on the bucket. Uploads use `curl --aws-sigv4`, no extra CLI. Bundles, patches and assets are uploaded once with an immutable cache header; `ota_registry.json` is uploaded last with `no-cache`.
464
+ R2 setup: enable the bucket's Public Development URL (or a custom domain) and create an R2 API token with *Object Read & Write* on the bucket. Uploads use `curl --aws-sigv4`, no extra CLI. Bundles, patches and assets are uploaded once with an immutable cache header; `ota_registry.json` is uploaded last with `no-cache`. Bundles are stored gzip `Content-Encoding` (Hermes bytecode shrinks ~60%): every client's HTTP stack decodes them transparently, older app versions included.
421
465
 
422
466
  ```bash
423
467
  # .env.<flavor>
@@ -432,9 +476,9 @@ OTA_R2_SECRET_ACCESS_KEY=...
432
476
 
433
477
  | Variable | File | Required | Default | Description |
434
478
  |----------|------|----------|---------|-------------|
435
- | `OTA_BASE_URL` | `.env.<flavor>` | Yes | - | Public base URL (R2 public URL, or `https://raw.githubusercontent.com/<owner>/<repo>/<branch>`) |
436
- | `OTA_FOLDER` | `.env.<flavor>` | No | `ota` | Folder prefix in storage; only set it to share one bucket/repo with other data (and set `customServer.baseFolder` to match) |
437
- | `OTA_R2_*` | `.env.ota` | For R2 | - | `ACCOUNT_ID`, `BUCKET`, `ACCESS_KEY_ID`, `SECRET_ACCESS_KEY` |
479
+ | `OTA_BASE_URL` | `.env.<flavor>` | Yes | - | R2 public URL (r2.dev or custom domain) |
480
+ | `OTA_FOLDER` | `.env.<flavor>` | No | `ota` | Folder prefix in storage; only set it to share one bucket with other data (and set `customServer.baseFolder` to match) |
481
+ | `OTA_R2_*` | `.env.ota` | Yes | - | `ACCOUNT_ID`, `BUCKET`, `ACCESS_KEY_ID`, `SECRET_ACCESS_KEY` |
438
482
  | `OTA_PRIVATE_KEY` | shell env (CI) | No | - | PEM private key; locally keep `ota_private_key.pem` in the project root instead (`.env.ota` values are single-line) |
439
483
 
440
484
  Keep both files out of git (`.env.*` in `.gitignore`).
@@ -469,14 +513,27 @@ const otaConfig: OtaConfig = {
469
513
  };
470
514
  ```
471
515
 
516
+ ### Zero-setup signing
517
+
518
+ `patch` handles the key itself, so there is nothing to set up:
519
+
520
+ - **No key yet** (`ota_private_key.pem` / `OTA_PRIVATE_KEY`) and the app has never been signed: it creates the signing key and a **backup key** (`ota_private_key.backup.pem`) in the project root, gitignores both, and embeds both public keys in the app (`Info.plist` → `BsPatchPublicKey`, `android/app/src/main/res/values/bspatch_public_key.xml`). `OtaProvider` verifies with them when `config.publicKey` isn't set. Commit those files; store builds from then on verify every update.
521
+ - **Move the backup key offline** (password manager) and delete it from the machine. If the signing key is ever lost or leaked, put the backup in its place as `ota_private_key.pem`: installed apps already trust it, so no store build is needed.
522
+ - **Key present**: it must be one of the public keys the app already has (embedded, or `publicKey:` in JS), otherwise `patch` stops before building. An app without embedded keys gets them (plus a backup) on the next patch.
523
+ - **Key missing but the app was signed before** (new machine, CI): `patch` stops and asks for the original (or backup) key instead of creating one, since installed apps would reject anything signed with a new key. On CI, set `OTA_PRIVATE_KEY`.
524
+
472
525
  ### 2. Automated Signing at Build Time
473
526
 
474
- When you run `tools/build_ota.sh`, it automatically searches for `ota_private_key.pem` (or the `OTA_PRIVATE_KEY` environment variable). If found, it **signs each patch (`signature`) and the resulting bundle (`bundleSignature`) with SHA-256** and injects the signatures into `ota_registry.json`. The bundle signature is checked on both the patch path and the full-bundle fallback. If the key is present but a file can't be signed, the build fails.
527
+ When you run `tools/build_ota.sh`, it automatically searches for `ota_private_key.pem` (or the `OTA_PRIVATE_KEY` environment variable). If found, it **signs each patch (`signature`), the resulting bundle (`bundleSignature`) and each registry entry (`entrySignature`) with SHA-256** and injects the signatures into `ota_registry.json`. The bundle signature is checked on both the patch path and the full-bundle fallback. If the key is present but a file can't be signed, the build fails.
528
+
529
+ `entrySignature` covers the fields that decide what a client installs: `appVersion`, `flavor`, `platform`, `fromVersion`, `toVersion`, `bundleHash`, `assetManifestHash` and `minAppVersion`. The client checks that appVersion, flavor and platform are its own and that the asset manifest matches its hash, so whoever serves the registry (say, a lapsed domain) can't replay another flavor's, platform's or release's signed bundle, or swap images.
530
+
531
+ > **Upgrading from 4.x:** clients with `publicKey` reject entries without `entrySignature`. Publish once for **each** platform after upgrading, before shipping a store build with the new version.
475
532
 
476
533
  On the client device:
477
534
  - **Android**: Uses Android Security Provider (`SHA256withRSA` / `SHA256withECDSA`).
478
535
  - **iOS**: Uses Apple Security framework (`SecKeyVerifySignature`).
479
- - **Fail closed**: when `publicKey` is set, a missing or invalid signature rejects the update. HMAC/symmetric keys are no longer supported (the public key ships in the app, so it can't be a secret).
536
+ - **Fail closed**: when `publicKey` is set, a missing or invalid signature (bundle, patch or registry entry) rejects the update. HMAC/symmetric keys are no longer supported (the public key ships in the app, so it can't be a secret).
480
537
 
481
538
  ---
482
539
 
@@ -530,7 +587,7 @@ react-native-bspatch/
530
587
  │ ├── OtaProvider.tsx # React provider + update modes
531
588
  │ ├── types.ts
532
589
  │ └── storage/
533
- │ └── GitHubRawStorage.ts # Default provider: any static host (R2, GitHub raw, CDN)
590
+ │ └── R2Storage.ts # Default provider: R2 public URL
534
591
  ├── android/src/main/java/vn/reactnativebspatch/
535
592
  │ ├── BsPatchModule.kt # applyPatch, verifySignature, reload
536
593
  │ └── OtaBundleResolver.kt # Bundle resolution + crash rollback (before JS)
@@ -541,7 +598,7 @@ react-native-bspatch/
541
598
  └── tools/
542
599
  ├── cli.js # bin entry (Yarn Berry runs bins with node)
543
600
  ├── build_ota.sh # CLI: patch / reset / setup / version
544
- ├── upload_ota.sh # Upload to Cloudflare R2 or a GitHub storage repo
601
+ ├── upload_ota.sh # Upload to Cloudflare R2
545
602
  ├── update_registry.js # Writes ota_registry.json entries + signatures
546
603
  ├── setup_project.js # AppDelegate / MainApplication wiring
547
604
  ├── bump_version.js # Native version sync
@@ -18,6 +18,26 @@ def reactNativeArchitectures() {
18
18
  return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
19
19
  }
20
20
 
21
+ // The app's native runtime fingerprint (tools/fingerprint.js), read by getRuntimeFingerprint():
22
+ // an OTA only installs on a binary with the same one. Never fails the build, but a "" build
23
+ // takes no OTA with a fingerprint: fix node/the script and rebuild.
24
+ def runtimeFingerprint() {
25
+ def appRoot = rootProject.projectDir.parentFile
26
+ try {
27
+ def result = providers.exec {
28
+ commandLine "node", file("../tools/fingerprint.js").absolutePath, appRoot.absolutePath, "android"
29
+ workingDir appRoot
30
+ ignoreExitValue = true
31
+ }
32
+ def out = result.standardOutput.asText.get().trim()
33
+ if (result.result.get().exitValue == 0 && out) return out
34
+ logger.warn("react-native-bspatch: runtime fingerprint failed (${result.standardError.asText.get().trim()}): this build will not accept OTA updates")
35
+ } catch (Exception e) {
36
+ logger.warn("react-native-bspatch: runtime fingerprint failed (${e.message}): this build will not accept OTA updates")
37
+ }
38
+ return ""
39
+ }
40
+
21
41
  android {
22
42
  namespace "vn.reactnativebspatch"
23
43
 
@@ -26,6 +46,7 @@ android {
26
46
  defaultConfig {
27
47
  minSdkVersion getExtOrIntegerDefault("minSdkVersion")
28
48
  targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
49
+ resValue "string", "bspatch_runtime_fingerprint", runtimeFingerprint()
29
50
  externalNativeBuild {
30
51
  cmake {
31
52
  cppFlags "-Wall -fstack-protector-all"
@@ -40,6 +61,10 @@ android {
40
61
  }
41
62
  }
42
63
 
64
+ buildFeatures {
65
+ resValues true
66
+ }
67
+
43
68
  buildTypes {
44
69
  release {
45
70
  minifyEnabled false
@@ -1,3 +1,5 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android"
2
2
  package="vn.reactnativebspatch">
3
+ <!-- getNetworkType() -->
4
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
3
5
  </manifest>
@@ -1,7 +1,12 @@
1
1
  package vn.reactnativebspatch
2
2
 
3
+ import android.content.Context
4
+ import android.net.ConnectivityManager
5
+ import android.net.NetworkCapabilities
6
+ import android.os.Build
3
7
  import android.os.Handler
4
8
  import android.os.Looper
9
+ import android.os.StatFs
5
10
  import android.os.SystemClock
6
11
  import com.facebook.react.bridge.Arguments
7
12
  import com.facebook.react.bridge.ReactApplicationContext
@@ -15,6 +20,8 @@ import java.net.HttpURLConnection
15
20
  import java.net.URL
16
21
  import java.security.MessageDigest
17
22
  import java.util.concurrent.Executors
23
+ import java.util.zip.GZIPInputStream
24
+ import org.json.JSONObject
18
25
 
19
26
  class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeBsPatchSpec(reactContext) {
20
27
 
@@ -35,6 +42,7 @@ class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeB
35
42
  }
36
43
 
37
44
  private external fun nativeApplyPatch(oldPath: String, newPath: String, patchPath: String): Boolean
45
+ private external fun nativeDecompressBz2(srcPath: String, destPath: String): Boolean
38
46
 
39
47
  override fun applyPatch(oldPath: String, newPath: String, patchPath: String, promise: com.facebook.react.bridge.Promise) {
40
48
  backgroundExecutor.execute {
@@ -147,26 +155,98 @@ class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeB
147
155
 
148
156
  override fun getDocumentDir(): String = reactContext.filesDir.absolutePath
149
157
 
150
- override fun downloadFile(url: String, destPath: String, timeoutMs: Double, promise: Promise) {
158
+ // App resources override the library's empty default (see res/values/bspatch_public_key.xml)
159
+ override fun getEmbeddedPublicKeys(): String = reactContext.getString(R.string.bspatch_public_key)
160
+
161
+ override fun getAppBuild(): String {
162
+ val info = reactContext.packageManager.getPackageInfo(reactContext.packageName, 0)
163
+ return if (Build.VERSION.SDK_INT >= 28) info.longVersionCode.toString()
164
+ else @Suppress("DEPRECATION") info.versionCode.toString()
165
+ }
166
+
167
+ // resValue generated by this library's build.gradle (tools/fingerprint.js) at app build time
168
+ override fun getRuntimeFingerprint(): String = reactContext.getString(R.string.bspatch_runtime_fingerprint)
169
+
170
+ override fun getFreeDiskSpace(promise: Promise) = io(promise) {
171
+ StatFs(reactContext.filesDir.path).availableBytes.toDouble()
172
+ }
173
+
174
+ override fun getNetworkType(promise: Promise) {
175
+ val type = try {
176
+ val cm = reactContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
177
+ val caps = cm.getNetworkCapabilities(cm.activeNetwork)
178
+ when {
179
+ caps == null -> "none"
180
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
181
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
182
+ caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
183
+ else -> "unknown"
184
+ }
185
+ } catch (e: Exception) {
186
+ "unknown"
187
+ }
188
+ promise.resolve(type)
189
+ }
190
+
191
+ override fun decompressFile(srcPath: String, destPath: String, format: String, promise: Promise) = io(promise) {
192
+ val dest = File(destPath)
193
+ val part = File("$destPath.part")
194
+ try {
195
+ when (format) {
196
+ "gzip" -> GZIPInputStream(FileInputStream(srcPath), IO_BUFFER_SIZE).use { input ->
197
+ FileOutputStream(part).use { output ->
198
+ input.copyTo(output, IO_BUFFER_SIZE)
199
+ output.fd.sync()
200
+ }
201
+ }
202
+ "bz2" -> if (!nativeDecompressBz2(srcPath, part.path)) throw IOException("Corrupt or unreadable bz2 file: $srcPath")
203
+ else -> throw IllegalArgumentException("Unknown compression format: $format")
204
+ }
205
+ if (!part.renameTo(dest)) throw IOException("Rename to $destPath failed")
206
+ } catch (e: Exception) {
207
+ part.delete()
208
+ dest.delete()
209
+ throw e
210
+ }
211
+ }
212
+
213
+ override fun downloadFile(url: String, destPath: String, timeoutMs: Double, headersJson: String, promise: Promise) {
151
214
  backgroundExecutor.execute {
152
215
  val part = File("$destPath.part")
153
216
  var conn: HttpURLConnection? = null
154
217
  try {
218
+ val headers = JSONObject(headersJson)
219
+ // A .part left by a failed attempt is resumed with a Range request
220
+ val resumeFrom = if (part.exists()) part.length() else 0L
155
221
  conn = (URL(url).openConnection() as HttpURLConnection).apply {
156
222
  connectTimeout = timeoutMs.toInt()
157
223
  readTimeout = timeoutMs.toInt()
158
224
  instanceFollowRedirects = true
159
225
  setRequestProperty("Cache-Control", "no-cache")
226
+ headers.keys().forEach { setRequestProperty(it, headers.getString(it)) }
227
+ if (resumeFrom > 0) {
228
+ setRequestProperty("Range", "bytes=$resumeFrom-")
229
+ // Byte ranges of a transparently gunzipped body don't line up
230
+ setRequestProperty("Accept-Encoding", "identity")
231
+ }
160
232
  }
161
233
  val status = conn.responseCode
162
- if (status !in 200..299) throw IOException("HTTP $status for $url")
234
+ if (status !in 200..299) {
235
+ part.delete() // a stale .part (e.g. 416) must not be resumed again
236
+ throw IOException("HTTP $status for $url")
237
+ }
238
+ val append = status == 206 && resumeFrom > 0
163
239
  // -1 when unknown (also when the body is transparently gunzipped)
164
- val total = conn.contentLengthLong
240
+ val length = conn.contentLengthLong
241
+ val total = if (append) {
242
+ conn.getHeaderField("Content-Range")?.substringAfter('/')?.toLongOrNull()
243
+ ?: if (length >= 0) resumeFrom + length else -1L
244
+ } else length
165
245
  part.parentFile?.mkdirs()
166
- var received = 0L
246
+ var received = if (append) resumeFrom else 0L
167
247
  var lastEmit = 0L
168
248
  conn.inputStream.use { input ->
169
- FileOutputStream(part).use { output ->
249
+ FileOutputStream(part, append).use { output ->
170
250
  val buffer = ByteArray(IO_BUFFER_SIZE)
171
251
  while (true) {
172
252
  val n = input.read(buffer)
@@ -189,7 +269,7 @@ class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeB
189
269
  if (!part.renameTo(File(destPath))) throw IOException("Rename to $destPath failed")
190
270
  promise.resolve(received.toDouble())
191
271
  } catch (e: Exception) {
192
- part.delete()
272
+ // .part is kept for the next attempt to resume (deleted above on HTTP errors)
193
273
  promise.reject("ERR_DOWNLOAD", e.message, e)
194
274
  } finally {
195
275
  conn?.disconnect()
@@ -0,0 +1,5 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- Empty default: the app's own res/values/bspatch_public_key.xml (written by the CLI) overrides it -->
3
+ <resources>
4
+ <string name="bspatch_public_key" translatable="false"></string>
5
+ </resources>
@@ -233,3 +233,37 @@ int apply_bspatch(const char *oldfile, const char *newfile, const char *patchfil
233
233
 
234
234
  return ret;
235
235
  }
236
+
237
+ int decompress_bz2(const char *srcfile, const char *dstfile)
238
+ {
239
+ FILE *in, *out;
240
+ BZFILE *bz2 = NULL;
241
+ int bz2err = BZ_OK, n, rc = -1;
242
+ char buf[64 * 1024];
243
+
244
+ if ((in = fopen(srcfile, "rb")) == NULL) {
245
+ LOGE("decompress_bz2: cannot open %s", srcfile);
246
+ return -1;
247
+ }
248
+ if ((out = fopen(dstfile, "wb")) == NULL) {
249
+ LOGE("decompress_bz2: cannot create %s", dstfile);
250
+ fclose(in);
251
+ return -1;
252
+ }
253
+ if ((bz2 = BZ2_bzReadOpen(&bz2err, in, 0, 0, NULL, 0)) != NULL) {
254
+ while (bz2err == BZ_OK) {
255
+ n = BZ2_bzRead(&bz2err, bz2, buf, sizeof(buf));
256
+ if ((bz2err == BZ_OK || bz2err == BZ_STREAM_END) && n > 0 &&
257
+ fwrite(buf, 1, n, out) != (size_t)n) {
258
+ bz2err = BZ_IO_ERROR;
259
+ }
260
+ }
261
+ if (bz2err == BZ_STREAM_END) rc = 0;
262
+ else LOGE("decompress_bz2: bzip2 error %d", bz2err);
263
+ BZ2_bzReadClose(&bz2err, bz2);
264
+ }
265
+ if (fclose(out) != 0) rc = -1;
266
+ fclose(in);
267
+ if (rc != 0) unlink(dstfile);
268
+ return rc;
269
+ }
@@ -2,5 +2,7 @@
2
2
  #define BSPATCH_WRAPPER_H
3
3
 
4
4
  int apply_bspatch(const char *oldfile, const char *newfile, const char *patchfile);
5
+ /* Streams a single-stream .bz2 file to dstfile. 0 on success; dstfile is removed on failure. */
6
+ int decompress_bz2(const char *srcfile, const char *dstfile);
5
7
 
6
8
  #endif // BSPATCH_WRAPPER_H
@@ -38,4 +38,22 @@ Java_vn_reactnativebspatch_BsPatchModule_nativeApplyPatch(
38
38
 
39
39
  return (result == 0) ? JNI_TRUE : JNI_FALSE;
40
40
  }
41
+
42
+ extern "C" JNIEXPORT jboolean JNICALL
43
+ Java_vn_reactnativebspatch_BsPatchModule_nativeDecompressBz2(
44
+ JNIEnv* env,
45
+ jobject /* this */,
46
+ jstring srcPath,
47
+ jstring destPath) {
48
+
49
+ const char *src_file = env->GetStringUTFChars(srcPath, 0);
50
+ const char *dest_file = env->GetStringUTFChars(destPath, 0);
51
+
52
+ int result = decompress_bz2(src_file, dest_file);
53
+
54
+ env->ReleaseStringUTFChars(srcPath, src_file);
55
+ env->ReleaseStringUTFChars(destPath, dest_file);
56
+
57
+ return (result == 0) ? JNI_TRUE : JNI_FALSE;
58
+ }
41
59
  #endif