react-native-bspatch 1.1.3 → 2.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 (89) hide show
  1. package/README.md +47 -52
  2. package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +15 -102
  3. package/android/src/main/java/vn/reactnativebspatch/OtaBundleResolver.kt +21 -1
  4. package/cpp/bsdiff/bspatch.c +1 -0
  5. package/ios/OtaBundleResolver.swift +16 -1
  6. package/ios/RNBsPatch.mm +61 -154
  7. package/lib/commonjs/NativeBsPatch.js.map +1 -1
  8. package/lib/commonjs/index.js +4 -4
  9. package/lib/commonjs/index.js.map +1 -1
  10. package/lib/commonjs/ota/OtaProvider.js +26 -22
  11. package/lib/commonjs/ota/OtaProvider.js.map +1 -1
  12. package/lib/commonjs/ota/otaAssetResolver.js +8 -2
  13. package/lib/commonjs/ota/otaAssetResolver.js.map +1 -1
  14. package/lib/commonjs/ota/otaCore.js +89 -141
  15. package/lib/commonjs/ota/otaCore.js.map +1 -1
  16. package/lib/commonjs/ota/otaLogger.js +0 -2
  17. package/lib/commonjs/ota/otaLogger.js.map +1 -1
  18. package/lib/commonjs/ota/otaMetadata.js +18 -7
  19. package/lib/commonjs/ota/otaMetadata.js.map +1 -1
  20. package/lib/commonjs/ota/storage/GitHubRawStorage.js +3 -1
  21. package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +1 -1
  22. package/lib/commonjs/ota/types.js +1 -10
  23. package/lib/commonjs/ota/types.js.map +1 -1
  24. package/lib/module/NativeBsPatch.js.map +1 -1
  25. package/lib/module/index.js +1 -1
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/module/ota/OtaProvider.js +26 -22
  28. package/lib/module/ota/OtaProvider.js.map +1 -1
  29. package/lib/module/ota/otaAssetResolver.js +8 -2
  30. package/lib/module/ota/otaAssetResolver.js.map +1 -1
  31. package/lib/module/ota/otaCore.js +88 -139
  32. package/lib/module/ota/otaCore.js.map +1 -1
  33. package/lib/module/ota/otaLogger.js +0 -2
  34. package/lib/module/ota/otaLogger.js.map +1 -1
  35. package/lib/module/ota/otaMetadata.js +18 -7
  36. package/lib/module/ota/otaMetadata.js.map +1 -1
  37. package/lib/module/ota/storage/GitHubRawStorage.js +3 -1
  38. package/lib/module/ota/storage/GitHubRawStorage.js.map +1 -1
  39. package/lib/module/ota/types.js +0 -9
  40. package/lib/module/ota/types.js.map +1 -1
  41. package/lib/typescript/NativeBsPatch.d.ts +3 -15
  42. package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
  43. package/lib/typescript/index.d.ts +1 -1
  44. package/lib/typescript/index.d.ts.map +1 -1
  45. package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
  46. package/lib/typescript/ota/otaAssetResolver.d.ts.map +1 -1
  47. package/lib/typescript/ota/otaCore.d.ts +5 -42
  48. package/lib/typescript/ota/otaCore.d.ts.map +1 -1
  49. package/lib/typescript/ota/otaLogger.d.ts +1 -3
  50. package/lib/typescript/ota/otaLogger.d.ts.map +1 -1
  51. package/lib/typescript/ota/otaMetadata.d.ts +2 -2
  52. package/lib/typescript/ota/otaMetadata.d.ts.map +1 -1
  53. package/lib/typescript/ota/types.d.ts +18 -54
  54. package/lib/typescript/ota/types.d.ts.map +1 -1
  55. package/package.json +3 -6
  56. package/react-native-bspatch.podspec +0 -1
  57. package/src/NativeBsPatch.ts +3 -16
  58. package/src/index.ts +1 -1
  59. package/src/ota/OtaProvider.tsx +24 -23
  60. package/src/ota/otaAssetResolver.ts +5 -2
  61. package/src/ota/otaCore.ts +118 -176
  62. package/src/ota/otaLogger.ts +0 -2
  63. package/src/ota/otaMetadata.ts +18 -8
  64. package/src/ota/storage/GitHubRawStorage.ts +1 -1
  65. package/src/ota/types.ts +18 -57
  66. package/tools/build_ota.sh +356 -177
  67. package/tools/setup_project.js +95 -11
  68. package/tools/update_registry.js +24 -28
  69. package/tools/{upload_ota_custom.sh → upload_ota.sh} +33 -114
  70. package/lib/commonjs/crypto-js.d.js +0 -2
  71. package/lib/commonjs/crypto-js.d.js.map +0 -1
  72. package/lib/commonjs/ota/storage/CustomStorage.js +0 -160
  73. package/lib/commonjs/ota/storage/CustomStorage.js.map +0 -1
  74. package/lib/commonjs/ota/storage/GitHubReleaseStorage.js +0 -99
  75. package/lib/commonjs/ota/storage/GitHubReleaseStorage.js.map +0 -1
  76. package/lib/module/crypto-js.d.js +0 -2
  77. package/lib/module/crypto-js.d.js.map +0 -1
  78. package/lib/module/ota/storage/CustomStorage.js +0 -154
  79. package/lib/module/ota/storage/CustomStorage.js.map +0 -1
  80. package/lib/module/ota/storage/GitHubReleaseStorage.js +0 -93
  81. package/lib/module/ota/storage/GitHubReleaseStorage.js.map +0 -1
  82. package/lib/typescript/ota/storage/CustomStorage.d.ts +0 -12
  83. package/lib/typescript/ota/storage/CustomStorage.d.ts.map +0 -1
  84. package/lib/typescript/ota/storage/GitHubReleaseStorage.d.ts +0 -20
  85. package/lib/typescript/ota/storage/GitHubReleaseStorage.d.ts.map +0 -1
  86. package/src/crypto-js.d.ts +0 -1
  87. package/src/ota/storage/CustomStorage.ts +0 -166
  88. package/src/ota/storage/GitHubReleaseStorage.ts +0 -103
  89. package/tools/bsdiff +0 -0
package/README.md CHANGED
@@ -17,7 +17,6 @@ Premium OTA update solution for React Native using **TurboModules** + **bsdiff b
17
17
  - **Crash detection + auto-rollback** — native-side protection runs **before** JS boots; if the updated bundle crashes, the app automatically rolls back to the previous version
18
18
  - **Integrity verification** — SHA-256 hash check on both source bundle and result bundle
19
19
  - **Disk Garbage Collection** — automatically removes obsolete bundle versions and dangling temporary files
20
- - **Gzip decompression** — native 64KB-buffered gzip decompression on both Android and iOS
21
20
  - **Content-addressed asset store** — images/assets downloaded per-version and redirected via `setCustomSourceTransformer`
22
21
  - **Structured telemetry** — all OTA events forwarded to your analytics via `onEvent` callback
23
22
  - **New Architecture ready** — built with TurboModules (no legacy Bridge)
@@ -71,12 +70,9 @@ const config = {
71
70
  updateMode: OtaUpdateMode.BACKGROUND_DOWNLOAD, // Silent download without interrupting user
72
71
  allowDebugUpdates: false, // default: OTA is blocked in __DEV__
73
72
  customServer: {
74
- baseUrl: 'https://ota.yourcompany.com',
75
- username: 'your-user',
76
- password: 'your-pass',
73
+ baseUrl: 'https://raw.githubusercontent.com/<owner>/<storage-repo>/main',
77
74
  channel: 'production',
78
- baseFolder: 'bspatch',
79
- useMinio: false,
75
+ baseFolder: 'ota',
80
76
  },
81
77
  };
82
78
 
@@ -176,22 +172,20 @@ type OtaConfig = {
176
172
  onEvent?: (entry: OtaLogEntry) => void;
177
173
 
178
174
  /**
179
- * Base64-encoded public key for HMAC-SHA256 patch signature verification.
175
+ * SPKI public key (PEM or base64, RSA or EC P-256) for signature verification.
176
+ * When set, unsigned or wrongly signed patches/bundles are rejected.
180
177
  * Recommended for production to prevent MITM attacks.
181
178
  */
182
179
  publicKey?: string;
183
180
 
184
- /** Custom storage provider. Omit to use CustomStorageProvider. */
181
+ /** Custom storage provider. Omit to use GitHubRawStorageProvider. */
185
182
  storage?: OtaStorageProvider;
186
183
 
187
- /** Custom server configuration (used by CustomStorageProvider) */
184
+ /** Static storage layout read by GITHUB_RAW (public, read-only — no credentials) */
188
185
  customServer?: {
189
- baseUrl: string;
190
- username: string;
191
- password: string;
186
+ baseUrl: string; // https://raw.githubusercontent.com/<owner>/<repo>/<branch>
192
187
  channel: string;
193
- baseFolder?: string; // default: 'bspatch'
194
- useMinio?: boolean;
188
+ baseFolder?: string; // default: 'ota'
195
189
  };
196
190
  };
197
191
  ```
@@ -254,7 +248,6 @@ Your OTA server must expose a `ota_registry.json` at the configured path (e.g. `
254
248
  "baseHash": "sha256_of_v1_bundle",
255
249
  "bundleHash": "sha256_of_v2_bundle",
256
250
  "platform": "android",
257
- "compressed": false,
258
251
  "mandatory": false,
259
252
  "fullBundleUrl": "v2-full.zip"
260
253
  }
@@ -292,7 +285,7 @@ This means crash protection is **native-side and zero-cost** — it doesn't requ
292
285
 
293
286
  ## Custom Storage Provider
294
287
 
295
- The library ships with `CustomStorageProvider` for MinIO-based private servers. To use a different backend, implement the interface:
288
+ The library ships with `GitHubRawStorageProvider` (default). To use a different backend (e.g. Cloudflare), implement the interface:
296
289
 
297
290
  ```ts
298
291
  interface OtaStorageProvider {
@@ -363,6 +356,7 @@ Add these to your `package.json` (or use the existing ones in this project):
363
356
  "react-native-bspatch": "bash ./src/native_modules/react-native-bspatch/tools/build_ota.sh",
364
357
  "ota:setup": "yarn react-native-bspatch setup",
365
358
  "ota:init": "yarn react-native-bspatch base",
359
+ "ota:seed": "yarn react-native-bspatch seed",
366
360
  "ota:build": "yarn react-native-bspatch",
367
361
  "ota:clean": "yarn react-native-bspatch reset"
368
362
  }
@@ -372,22 +366,19 @@ Add these to your `package.json` (or use the existing ones in this project):
372
366
  ### Workflow
373
367
 
374
368
  ```
375
- ota:setup ──► ota:init ──► ota:build ──► (ota:build ◄── repeat for each release)
376
- │ │ │
377
- │ │ └── 1. Bundle JS → Hermes bytecode (.hbc)
378
- │ │ 2. Compare assets → generate asset-manifest-{ver}.json
379
- │ │ 3. bsdiff against previous bundle → .patch files
380
- │ │ 4. Upload to server + update ota_registry.json
381
- │ │ 5. Write otaPatches.generated.ts for bundled patches fallback
382
- │ │
383
- │ └── First build only: creates base bundle from APK/IPA.
384
- │ Stores base asset manifest so subsequent builds know
385
- │ which assets are already in the APK and skip them.
369
+ ota:setup ──► (Build Release APK/IPA) ──► ota:build ──► (repeat for each OTA update)
370
+ │ │ │
371
+ │ │ └── Incremental patch from Base Bundle v1
372
+ │ │
373
+ │ └── Automatic Base Capture (Gradle & Xcode):
374
+ │ Each release build automatically saves `bundle_{appVersion}_v1_{platform}.hbc`.
375
+ │ No manual `ota:init` is required!
386
376
  │
387
377
  └── Patches native integration into project files:
388
378
  • iOS: AppDelegate.swift — adds bundleURL() override
389
- • iOS: project.pbxproj — adds build-phase script to sync bundle → ios/output/
379
+ • iOS: project.pbxproj — syncs and auto-captures base bundle on Release archive
390
380
  • Android: MainApplication.kt — adds OtaBundleResolver.resolve() call
381
+ • Android: app/build.gradle — auto-captures base bundle upon assembleRelease / bundleRelease
391
382
  Idempotent: safe to run multiple times; skips already-patched files.
392
383
  ```
393
384
 
@@ -395,14 +386,22 @@ ota:setup ──► ota:init ──► ota:build ──► (ota:build ◄──
395
386
 
396
387
  | Command | Action |
397
388
  |---|---|
398
- | `yarn ota:setup` | Inject OTA integration into AppDelegate.swift, MainApplication.kt, project.pbxproj. Run once per project. |
399
- | `yarn ota:init` | First release: build base APK/IPA + bundle + base asset manifest. Creates `ota_registry.json`. |
400
- | `yarn ota:build` | Subsequent releases: generate incremental .patch from previous bundle, upload to server, update registry. |
389
+ | `yarn ota:setup` | Inject OTA integration into AppDelegate.swift, MainApplication.kt, project.pbxproj, and app/build.gradle. |
390
+ | `yarn ota:build` | Generate incremental `.patch` from previous bundle, upload to server, update registry. |
391
+ | `yarn ota:seed --apk <path>` | **Zero Mismatch Recovery:** Seed base bundle directly from an existing release APK/AAB. |
392
+ | `yarn ota:seed --ipa <path>` | **Zero Mismatch Recovery:** Seed base bundle directly from an existing release IPA. |
393
+ | `yarn ota:init` | Manual first release: build base APK/IPA + bundle + base asset manifest. Creates `ota_registry.json`. |
401
394
  | `yarn ota:build --platform android` | Build Android only |
402
395
  | `yarn ota:build --platform ios` | Build iOS only |
403
396
  | `yarn ota:build --flavor staging` | Build with flavor "staging" (default: "dev") |
404
397
  | `yarn ota:clean` | Wipe `ota_registry.json` + output files. Preserves base asset manifests so `ota:build` still works after clean. |
405
398
 
399
+ > [!TIP]
400
+ > **Zero Mismatch & Fail-Safe Protection:**
401
+ > 1. **Auto-Capture:** When you build a Release APK/AAB or Archive on Xcode, the bundle is automatically captured into `output/` as Base Bundle v1.
402
+ > 2. **Seed Binary:** If you forgot to capture the base bundle and already submitted your APK/IPA to the Store, run `yarn ota:seed --apk ./app-release.apk`. It extracts the exact byte-for-byte bundle directly from the release binary, guaranteeing 100% hash match.
403
+ > 3. **Fail-Safe Guard:** `ota:build` will refuse to generate an update if no authentic Base Bundle v1 is found, preventing corrupt patches that trigger full-bundle fallback downloads.
404
+
406
405
  ### How It Works
407
406
 
408
407
  1. **Bundle** — `npx react-native bundle` generates Hermes bytecode (`.hbc`). iOS base build uses the Xcode-compiled bundle to guarantee byte-exact match with the IPA.
@@ -417,11 +416,10 @@ ota:setup ──► ota:init ──► ota:build ──► (ota:build ◄──
417
416
  # 1. Add the npm scripts to package.json (or run directly)
418
417
  bash ./src/native_modules/react-native-bspatch/tools/build_ota.sh setup
419
418
 
420
- # 2. Initialize the first release (creates base APK + registry)
421
- yarn ota:init
422
- # Or per platform:
423
- yarn ota:init --platform android # Android only
424
- yarn ota:init --platform ios # iOS only
419
+ # 2. Build your app normally for App Store / Google Play
420
+ # Gradle and Xcode will automatically capture the base bundle (v1)!
421
+ # OR seed from an existing release binary:
422
+ yarn ota:seed --apk ./release.apk
425
423
 
426
424
  # 3. For each subsequent release:
427
425
  yarn ota:build
@@ -429,7 +427,7 @@ yarn ota:build
429
427
 
430
428
  ### Environment Variables
431
429
 
432
- The build script requires server credentials. You can provide them via:
430
+ Builds publish to a GitHub storage repository. Authentication uses `gh auth token` (or `GH_TOKEN` / `GITHUB_TOKEN`), so run `gh auth login` once.
433
431
 
434
432
  **Option 1: `.env` file (recommended)**
435
433
 
@@ -437,8 +435,8 @@ Create a `.env` file in your **React Native project root** (not in the module di
437
435
 
438
436
  ```bash
439
437
  # In your project root (e.g., /path/to/YourApp/)
440
- OTA_USER=your-username
441
- OTA_PASSWORD=your-password
438
+ OTA_BASE_URL=https://raw.githubusercontent.com/<owner>/<storage-repo>/main
439
+ OTA_FOLDER=ota
442
440
  ```
443
441
 
444
442
  The build script automatically loads `.env` from the project root.
@@ -446,17 +444,15 @@ The build script automatically loads `.env` from the project root.
446
444
  **Option 2: Export environment variables**
447
445
 
448
446
  ```bash
449
- export OTA_USER="your-username"
450
- export OTA_PASSWORD="your-password"
447
+ export OTA_BASE_URL="https://raw.githubusercontent.com/<owner>/<storage-repo>/main"
451
448
  yarn ota:build
452
449
  ```
453
450
 
454
451
  | Variable | Required | Default | Description |
455
452
  |----------|----------|---------|-------------|
456
- | `OTA_USER` | Yes | - | Server username |
457
- | `OTA_PASSWORD` | Yes | - | Server password |
458
- | `OTA_BASE_URL` | No | `https://sync-server.osp.com.vn` | Server base URL |
459
- | `OTA_FOLDER` | No | `congplqg` | Base folder path |
453
+ | `OTA_BASE_URL` | Yes | - | `https://raw.githubusercontent.com/<owner>/<repo>/<branch>` |
454
+ | `OTA_FOLDER` | No | `ota` | Base folder in the storage repo |
455
+ | `OTA_PRIVATE_KEY` | No | - | PEM private key (or `ota_private_key.pem` in project root) |
460
456
 
461
457
  > **Security Warning:** Never commit credentials to source control. The `.env` file is gitignored.
462
458
 
@@ -492,12 +488,12 @@ const otaConfig: OtaConfig = {
492
488
 
493
489
  ### 2. Automated Signing at Build Time
494
490
 
495
- 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 **cryptographically signs each binary patch file using SHA-256** and injects the signatures into `ota_registry.json`.
491
+ 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.
496
492
 
497
493
  On the client device:
498
494
  - **Android**: Uses Android Security Provider (`SHA256withRSA` / `SHA256withECDSA`).
499
495
  - **iOS**: Uses Apple Security framework (`SecKeyVerifySignature`).
500
- - **Backward Compatibility**: Automatically falls back to HMAC-SHA256 if a symmetric key is passed.
496
+ - **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).
501
497
 
502
498
  ---
503
499
 
@@ -542,7 +538,7 @@ When executing `tools/build_ota.sh`, the script auto-detects the version from `b
542
538
 
543
539
  - **POSIX `mmap()` Zero-Copy Patching**: Unlike standard bspatch which allocates heap memory with `malloc()`, `react-native-bspatch` memory-maps the input and output files using `mmap()` and `MADV_SEQUENTIAL`. Memory overhead is reduced by **~85–90%**, virtually eliminating out-of-memory crashes on low-end devices.
544
540
  - **SIMD Vectorization**: The inner patching loop includes `#pragma clang loop vectorize(enable)` for Clang ARM NEON 128-bit vectorization.
545
- - **64KB Streaming Buffers**: File hashing, gzip decompression, and stream copying are upgraded to 64KB chunk sizes across Android and iOS.
541
+ - **64KB Streaming Buffers**: File hashing and stream copying are upgraded to 64KB chunk sizes across Android and iOS.
546
542
  - **Disk Garbage Collection**: Calling `markHealthy()` automatically deletes obsolete bundle versions and dangling `.tmp` files from disk, keeping app storage minimal.
547
543
  - **HTTP 304 ETag Caching**: Registry queries send `If-None-Match`. On `304 Not Modified`, cached patches are returned instantly with zero JSON parsing overhead.
548
544
 
@@ -561,13 +557,12 @@ src/native_modules/react-native-bspatch/
561
557
  │ ├── OtaProvider.tsx # React Context provider + auto-update logic
562
558
  │ ├── types.ts # All TypeScript type definitions
563
559
  │ └── storage/
564
- │ ├── CustomStorage.ts # MinIO-based storage implementation
565
- │ └── GitHubReleaseStorage.ts # GitHub Releases storage (public / token-auth)
560
+ │ └── GitHubRawStorage.ts # GitHub raw repository storage (default)
566
561
  ├── android/src/main/java/vn/reactnativebspatch/
567
- │ ├── BsPatchModule.kt # Native: applyPatch, unzip, extract, gzip
562
+ │ ├── BsPatchModule.kt # Native: applyPatch, extract, verifySignature
568
563
  │ └── OtaBundleResolver.kt # Native: bundle resolution + rollback
569
564
  ├── ios/
570
- │ ├── RNBsPatch.mm # Native iOS bridge + gzip decompress
565
+ │ ├── RNBsPatch.mm # Native iOS bridge + signature verify
571
566
  │ └── OtaBundleResolver.swift # Native iOS bundle resolution + rollback
572
567
  ├── cpp/
573
568
  │ ├── CMakeLists.txt # CMake build for bspatch C library
@@ -11,8 +11,6 @@ import java.io.FileInputStream
11
11
  import java.io.FileOutputStream
12
12
  import java.io.BufferedInputStream
13
13
  import java.io.BufferedOutputStream
14
- import java.util.zip.ZipInputStream
15
- import java.util.zip.GZIPInputStream
16
14
  import java.util.concurrent.Executors
17
15
 
18
16
  class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeBsPatchSpec(reactContext) {
@@ -44,36 +42,6 @@ class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeB
44
42
  }
45
43
  }
46
44
 
47
- override fun unzip(source: String, targetDir: String, promise: Promise) {
48
- backgroundExecutor.execute {
49
- try {
50
- val sourceFile = File(source)
51
- val destDir = File(targetDir)
52
- if (!destDir.exists()) destDir.mkdirs()
53
-
54
- ZipInputStream(BufferedInputStream(FileInputStream(sourceFile), IO_BUFFER_SIZE)).use { zis ->
55
- var entry = zis.nextEntry
56
- while (entry != null) {
57
- val file = File(destDir, entry.name)
58
- if (entry.isDirectory) {
59
- file.mkdirs()
60
- } else {
61
- file.parentFile?.mkdirs()
62
- BufferedOutputStream(FileOutputStream(file), IO_BUFFER_SIZE).use { bos ->
63
- zis.copyTo(bos, bufferSize = IO_BUFFER_SIZE)
64
- }
65
- }
66
- zis.closeEntry()
67
- entry = zis.nextEntry
68
- }
69
- }
70
- promise.resolve(true)
71
- } catch (e: Exception) {
72
- promise.reject("ERR_UNZIP", e.message, e)
73
- }
74
- }
75
- }
76
-
77
45
  /**
78
46
  * Extracts the JS bundle from APK assets using Android AssetManager.
79
47
  * Guarantees byte-exact copy — RNFS.copyFileAssets can produce different
@@ -119,25 +87,6 @@ class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeB
119
87
  android.util.Log.d("JS-OTA", message ?: "")
120
88
  }
121
89
 
122
- override fun decompressGzip(sourcePath: String, destPath: String, promise: Promise) {
123
- backgroundExecutor.execute {
124
- try {
125
- val source = File(sourcePath)
126
- val dest = File(destPath)
127
- dest.parentFile?.mkdirs()
128
-
129
- GZIPInputStream(BufferedInputStream(FileInputStream(source), IO_BUFFER_SIZE), IO_BUFFER_SIZE).use { gis ->
130
- BufferedOutputStream(FileOutputStream(dest), IO_BUFFER_SIZE).use { bos ->
131
- gis.copyTo(bos, bufferSize = IO_BUFFER_SIZE)
132
- }
133
- }
134
- promise.resolve(true)
135
- } catch (e: Exception) {
136
- promise.reject("ERR_GZIP_DECOMPRESS", e.message, e)
137
- }
138
- }
139
- }
140
-
141
90
  override fun verifySignature(filePath: String, signature: String, publicKey: String, promise: Promise) {
142
91
  backgroundExecutor.execute {
143
92
  try {
@@ -157,65 +106,29 @@ class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeB
157
106
  val keyBytes = android.util.Base64.decode(cleanKey, android.util.Base64.DEFAULT)
158
107
  val expectedSigBytes = android.util.Base64.decode(signature.trim(), android.util.Base64.DEFAULT)
159
108
 
160
- // 1. Try RSA Public Key (SHA256withRSA)
161
- try {
162
- val keySpec = java.security.spec.X509EncodedKeySpec(keyBytes)
163
- val keyFactory = java.security.KeyFactory.getInstance("RSA")
164
- val pubKey = keyFactory.generatePublic(keySpec)
165
- val sig = java.security.Signature.getInstance("SHA256withRSA")
166
- sig.initVerify(pubKey)
167
-
168
- val buffer = ByteArray(IO_BUFFER_SIZE)
169
- FileInputStream(file).use { fis ->
170
- var read: Int
171
- while (fis.read(buffer).also { read = it } != -1) {
172
- sig.update(buffer, 0, read)
173
- }
174
- }
175
- val isValid = sig.verify(expectedSigBytes)
176
- promise.resolve(isValid)
177
- return@execute
178
- } catch (rsaEx: Exception) {
179
- // Not RSA, continue
109
+ // Pick the algorithm from the key itself; never fall back to another scheme
110
+ val keySpec = java.security.spec.X509EncodedKeySpec(keyBytes)
111
+ val pubKey = try {
112
+ java.security.KeyFactory.getInstance("RSA").generatePublic(keySpec)
113
+ } catch (e: Exception) {
114
+ java.security.KeyFactory.getInstance("EC").generatePublic(keySpec)
180
115
  }
181
-
182
- // 2. Try ECDSA Public Key (SHA256withECDSA)
183
- try {
184
- val keySpec = java.security.spec.X509EncodedKeySpec(keyBytes)
185
- val keyFactory = java.security.KeyFactory.getInstance("EC")
186
- val pubKey = keyFactory.generatePublic(keySpec)
187
- val sig = java.security.Signature.getInstance("SHA256withECDSA")
188
- sig.initVerify(pubKey)
189
-
190
- val buffer = ByteArray(IO_BUFFER_SIZE)
191
- FileInputStream(file).use { fis ->
192
- var read: Int
193
- while (fis.read(buffer).also { read = it } != -1) {
194
- sig.update(buffer, 0, read)
195
- }
196
- }
197
- val isValid = sig.verify(expectedSigBytes)
198
- promise.resolve(isValid)
199
- return@execute
200
- } catch (ecEx: Exception) {
201
- // Not EC, continue
202
- }
203
-
204
- // 3. Fallback: HMAC-SHA256
205
- val mac = javax.crypto.Mac.getInstance("HmacSHA256")
206
- val secretKey = javax.crypto.spec.SecretKeySpec(keyBytes, "HmacSHA256")
207
- mac.init(secretKey)
116
+ val sig = java.security.Signature.getInstance(
117
+ if (pubKey.algorithm == "RSA") "SHA256withRSA" else "SHA256withECDSA"
118
+ )
119
+ sig.initVerify(pubKey)
208
120
 
209
121
  val buffer = ByteArray(IO_BUFFER_SIZE)
210
122
  FileInputStream(file).use { fis ->
211
123
  var read: Int
212
124
  while (fis.read(buffer).also { read = it } != -1) {
213
- mac.update(buffer, 0, read)
125
+ sig.update(buffer, 0, read)
214
126
  }
215
127
  }
216
- val computedSigBytes = mac.doFinal()
217
- val isValid = java.security.MessageDigest.isEqual(expectedSigBytes, computedSigBytes)
218
- promise.resolve(isValid)
128
+ promise.resolve(sig.verify(expectedSigBytes))
129
+ } catch (e: java.security.GeneralSecurityException) {
130
+ // Malformed key/signature → invalid (fail closed)
131
+ promise.resolve(false)
219
132
  } catch (e: Exception) {
220
133
  promise.reject("ERR_VERIFY_SIG", e.message, e)
221
134
  }
@@ -55,6 +55,18 @@ object OtaBundleResolver {
55
55
  }
56
56
 
57
57
  var metadata = JSONObject(metadataFile.readText())
58
+
59
+ // ── Step 0: Store update → OTA bundles were built for another native version ──
60
+ val otaAppVersion = metadata.optString("appVersion", "")
61
+ val installedAppVersion = context.packageManager
62
+ .getPackageInfo(context.packageName, 0).versionName ?: ""
63
+ if (otaAppVersion.isNotEmpty() && otaAppVersion != installedAppVersion) {
64
+ Log.i(TAG, "Native app $otaAppVersion → $installedAppVersion: discarding OTA state")
65
+ // JS bootstrap deletes ota_trash_* in the background
66
+ otaDir.renameTo(File(context.filesDir, "ota_trash_${System.currentTimeMillis()}"))
67
+ return null
68
+ }
69
+
58
70
  val currentVersion = metadata.optInt("currentVersion", baseVersion)
59
71
  val lastLaunchOk = metadata.optBoolean("lastLaunchSuccessful", true)
60
72
  val rollbackCount = metadata.optInt("rollbackCount", 0)
@@ -62,7 +74,12 @@ object OtaBundleResolver {
62
74
  // ── Step 1: Crash detection & rollback ──────────────────────────
63
75
  if (!lastLaunchOk && currentVersion > baseVersion) {
64
76
  Log.w(TAG, "Previous launch failed! Rolling back from V$currentVersion...")
77
+ // Remember the crashing version so JS never re-applies it
78
+ val failed = metadata.optJSONArray("failedVersions") ?: JSONArray()
79
+ failed.put(currentVersion)
65
80
  metadata = performRollback(metadata, baseVersion, rollbackCount, otaDir)
81
+ metadata.put("failedVersions", failed)
82
+ metadata.put("appVersion", otaAppVersion)
66
83
  writeMetadata(metadataFile, metadata)
67
84
 
68
85
  val newVersion = metadata.optInt("currentVersion", baseVersion)
@@ -219,7 +236,10 @@ object OtaBundleResolver {
219
236
  private fun writeMetadata(file: File, metadata: JSONObject) {
220
237
  try {
221
238
  file.parentFile?.mkdirs()
222
- file.writeText(metadata.toString(2))
239
+ // Write-then-rename: a crash mid-write never leaves truncated JSON
240
+ val tmp = File(file.parentFile, "${file.name}.tmp")
241
+ tmp.writeText(metadata.toString(2))
242
+ if (!tmp.renameTo(file)) throw java.io.IOException("rename failed")
223
243
  } catch (e: Exception) {
224
244
  Log.e(TAG, "Failed to write metadata: ${e.message}")
225
245
  }
@@ -65,6 +65,7 @@ int bspatch(const uint8_t* old, int64_t oldsize, uint8_t* new, int64_t newsize,
65
65
  /* Sanity-check */
66
66
  if (ctrl[0]<0 || ctrl[0]>INT_MAX ||
67
67
  ctrl[1]<0 || ctrl[1]>INT_MAX ||
68
+ ctrl[2]<-INT_MAX || ctrl[2]>INT_MAX ||
68
69
  newpos+ctrl[0]>newsize)
69
70
  return -1;
70
71
 
@@ -62,6 +62,17 @@ public class OtaBundleResolver: NSObject {
62
62
  return nil
63
63
  }
64
64
 
65
+ // ── Step 0: Store update → OTA bundles were built for another native version ──
66
+ let otaAppVersion = metadata["appVersion"] as? String ?? ""
67
+ let installedAppVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
68
+ if !otaAppVersion.isEmpty && otaAppVersion != installedAppVersion {
69
+ NSLog("[OtaBundleResolver] Native app \(otaAppVersion) → \(installedAppVersion): discarding OTA state")
70
+ // JS bootstrap deletes ota_trash_* in the background
71
+ let trash = documentsDir.appendingPathComponent("ota_trash_\(Int(Date().timeIntervalSince1970 * 1000))")
72
+ try? FileManager.default.moveItem(at: otaDir, to: trash)
73
+ return nil
74
+ }
75
+
65
76
  let currentVersion = metadata["currentVersion"] as? Int ?? baseVersion
66
77
  let lastLaunchOk = metadata["lastLaunchSuccessful"] as? Bool ?? true
67
78
  let rollbackCount = metadata["rollbackCount"] as? Int ?? 0
@@ -70,7 +81,11 @@ public class OtaBundleResolver: NSObject {
70
81
  if !lastLaunchOk && currentVersion > baseVersion {
71
82
  NSLog("[OtaBundleResolver] Previous launch failed! Rolling back from V\(currentVersion)...")
72
83
 
84
+ // Remember the crashing version so JS never re-applies it
85
+ let failed = (metadata["failedVersions"] as? [Int] ?? []) + [currentVersion]
73
86
  metadata = performRollback(metadata: metadata, baseVersion: baseVersion, currentRollbackCount: rollbackCount, otaDir: otaDir)
87
+ metadata["failedVersions"] = failed
88
+ metadata["appVersion"] = otaAppVersion
74
89
  writeMetadata(url: metadataURL, metadata: metadata)
75
90
 
76
91
  let newVersion = metadata["currentVersion"] as? Int ?? baseVersion
@@ -233,7 +248,7 @@ public class OtaBundleResolver: NSObject {
233
248
  private static func writeMetadata(url: URL, metadata: [String: Any]) {
234
249
  do {
235
250
  let data = try JSONSerialization.data(withJSONObject: metadata, options: .prettyPrinted)
236
- try data.write(to: url)
251
+ try data.write(to: url, options: .atomic)
237
252
  } catch {
238
253
  NSLog("[OtaBundleResolver] Failed to write metadata: \(error.localizedDescription)")
239
254
  }