react-native-bspatch 4.1.0 → 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 +83 -27
  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 +116 -20
  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 +116 -20
  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 +52 -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 +115 -24
  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 +49 -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
@@ -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
  };
@@ -151,12 +151,16 @@ type OtaConfig = {
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
  ```
@@ -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,11 +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
329
350
  applyVersion, // (version) => Promise<void> — download and apply a specific version from the manifest
330
351
  resetToFactory, // () => Promise<void> — wipe OTA state, reload APK/IPA
331
352
  markAsHealthy, // () => Promise<void> — manually mark launch OK
332
- checkUpdate, // () => Promise<void> — manual update check
353
+ checkUpdate, // ({ force }?) => Promise<void> — check now (throttled by minCheckIntervalMs unless force)
333
354
  reloadBundle, // () => void — immediately reload JS bundle to apply ready update
334
355
  } = useOta();
335
356
  ```
@@ -356,31 +377,53 @@ The library ships with an **automated build pipeline** (`tools/build_ota.sh`) th
356
377
  ### Commands
357
378
 
358
379
  ```bash
359
- npx react-native-bspatch setup # once: wire OtaBundleResolver into the app
360
380
  npx react-native-bspatch version 1.0.5 # set native version (package.json, Gradle, Xcode)
361
- # … 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) …
362
383
  npx react-native-bspatch patch android # build + publish the next OTA
363
- npx react-native-bspatch patch ios --flavor staging
364
- 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
365
395
  npx react-native-bspatch reset --flavor staging --push # wipe this release's OTAs
366
396
  ```
367
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
+
368
400
  | Option | Default | Description |
369
401
  |---|---|---|
370
402
  | `android` / `ios` | both | Platform to patch |
371
403
  | `--flavor <name>` | `dev` | Channel (`dev`→`development`, `prod`→`production`) |
372
404
  | `--release-version <x.y.z>` | read from `versionName` / `MARKETING_VERSION`, confirmed by prompt | Store release the OTA targets |
373
- | `-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 |
374
410
 
375
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.
376
412
 
377
413
  ### How It Works
378
414
 
379
- 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.
380
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).
381
417
  3. **Patch** — `bsdiff` generates a delta from each of the last 5 OTA bundles (v2+) to the new one.
382
418
  4. **Registry** — Updates `ota_registry.json` with new `baseHash`, `bundleHash`, and asset manifest URL for each platform/channel.
383
- 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.
384
427
 
385
428
  ### One-time Setup
386
429
 
@@ -416,9 +459,9 @@ customServer: {
416
459
  },
417
460
  ```
418
461
 
419
- 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.
420
463
 
421
- 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.
422
465
 
423
466
  ```bash
424
467
  # .env.<flavor>
@@ -433,9 +476,9 @@ OTA_R2_SECRET_ACCESS_KEY=...
433
476
 
434
477
  | Variable | File | Required | Default | Description |
435
478
  |----------|------|----------|---------|-------------|
436
- | `OTA_BASE_URL` | `.env.<flavor>` | Yes | - | Public base URL (R2 public URL, or `https://raw.githubusercontent.com/<owner>/<repo>/<branch>`) |
437
- | `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) |
438
- | `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` |
439
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) |
440
483
 
441
484
  Keep both files out of git (`.env.*` in `.gitignore`).
@@ -470,14 +513,27 @@ const otaConfig: OtaConfig = {
470
513
  };
471
514
  ```
472
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
+
473
525
  ### 2. Automated Signing at Build Time
474
526
 
475
- 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.
476
532
 
477
533
  On the client device:
478
534
  - **Android**: Uses Android Security Provider (`SHA256withRSA` / `SHA256withECDSA`).
479
535
  - **iOS**: Uses Apple Security framework (`SecKeyVerifySignature`).
480
- - **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).
481
537
 
482
538
  ---
483
539
 
@@ -531,7 +587,7 @@ react-native-bspatch/
531
587
  │ ├── OtaProvider.tsx # React provider + update modes
532
588
  │ ├── types.ts
533
589
  │ └── storage/
534
- │ └── GitHubRawStorage.ts # Default provider: any static host (R2, GitHub raw, CDN)
590
+ │ └── R2Storage.ts # Default provider: R2 public URL
535
591
  ├── android/src/main/java/vn/reactnativebspatch/
536
592
  │ ├── BsPatchModule.kt # applyPatch, verifySignature, reload
537
593
  │ └── OtaBundleResolver.kt # Bundle resolution + crash rollback (before JS)
@@ -542,7 +598,7 @@ react-native-bspatch/
542
598
  └── tools/
543
599
  ├── cli.js # bin entry (Yarn Berry runs bins with node)
544
600
  ├── build_ota.sh # CLI: patch / reset / setup / version
545
- ├── upload_ota.sh # Upload to Cloudflare R2 or a GitHub storage repo
601
+ ├── upload_ota.sh # Upload to Cloudflare R2
546
602
  ├── update_registry.js # Writes ota_registry.json entries + signatures
547
603
  ├── setup_project.js # AppDelegate / MainApplication wiring
548
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