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.
- package/README.md +47 -52
- package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +15 -102
- package/android/src/main/java/vn/reactnativebspatch/OtaBundleResolver.kt +21 -1
- package/cpp/bsdiff/bspatch.c +1 -0
- package/ios/OtaBundleResolver.swift +16 -1
- package/ios/RNBsPatch.mm +61 -154
- package/lib/commonjs/NativeBsPatch.js.map +1 -1
- package/lib/commonjs/index.js +4 -4
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ota/OtaProvider.js +26 -22
- package/lib/commonjs/ota/OtaProvider.js.map +1 -1
- package/lib/commonjs/ota/otaAssetResolver.js +8 -2
- package/lib/commonjs/ota/otaAssetResolver.js.map +1 -1
- package/lib/commonjs/ota/otaCore.js +89 -141
- package/lib/commonjs/ota/otaCore.js.map +1 -1
- package/lib/commonjs/ota/otaLogger.js +0 -2
- package/lib/commonjs/ota/otaLogger.js.map +1 -1
- package/lib/commonjs/ota/otaMetadata.js +18 -7
- package/lib/commonjs/ota/otaMetadata.js.map +1 -1
- package/lib/commonjs/ota/storage/GitHubRawStorage.js +3 -1
- package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +1 -1
- package/lib/commonjs/ota/types.js +1 -10
- package/lib/commonjs/ota/types.js.map +1 -1
- package/lib/module/NativeBsPatch.js.map +1 -1
- package/lib/module/index.js +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/ota/OtaProvider.js +26 -22
- package/lib/module/ota/OtaProvider.js.map +1 -1
- package/lib/module/ota/otaAssetResolver.js +8 -2
- package/lib/module/ota/otaAssetResolver.js.map +1 -1
- package/lib/module/ota/otaCore.js +88 -139
- package/lib/module/ota/otaCore.js.map +1 -1
- package/lib/module/ota/otaLogger.js +0 -2
- package/lib/module/ota/otaLogger.js.map +1 -1
- package/lib/module/ota/otaMetadata.js +18 -7
- package/lib/module/ota/otaMetadata.js.map +1 -1
- package/lib/module/ota/storage/GitHubRawStorage.js +3 -1
- package/lib/module/ota/storage/GitHubRawStorage.js.map +1 -1
- package/lib/module/ota/types.js +0 -9
- package/lib/module/ota/types.js.map +1 -1
- package/lib/typescript/NativeBsPatch.d.ts +3 -15
- package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +1 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
- package/lib/typescript/ota/otaAssetResolver.d.ts.map +1 -1
- package/lib/typescript/ota/otaCore.d.ts +5 -42
- package/lib/typescript/ota/otaCore.d.ts.map +1 -1
- package/lib/typescript/ota/otaLogger.d.ts +1 -3
- package/lib/typescript/ota/otaLogger.d.ts.map +1 -1
- package/lib/typescript/ota/otaMetadata.d.ts +2 -2
- package/lib/typescript/ota/otaMetadata.d.ts.map +1 -1
- package/lib/typescript/ota/types.d.ts +18 -54
- package/lib/typescript/ota/types.d.ts.map +1 -1
- package/package.json +3 -6
- package/react-native-bspatch.podspec +0 -1
- package/src/NativeBsPatch.ts +3 -16
- package/src/index.ts +1 -1
- package/src/ota/OtaProvider.tsx +24 -23
- package/src/ota/otaAssetResolver.ts +5 -2
- package/src/ota/otaCore.ts +118 -176
- package/src/ota/otaLogger.ts +0 -2
- package/src/ota/otaMetadata.ts +18 -8
- package/src/ota/storage/GitHubRawStorage.ts +1 -1
- package/src/ota/types.ts +18 -57
- package/tools/build_ota.sh +356 -177
- package/tools/setup_project.js +95 -11
- package/tools/update_registry.js +24 -28
- package/tools/{upload_ota_custom.sh → upload_ota.sh} +33 -114
- package/lib/commonjs/crypto-js.d.js +0 -2
- package/lib/commonjs/crypto-js.d.js.map +0 -1
- package/lib/commonjs/ota/storage/CustomStorage.js +0 -160
- package/lib/commonjs/ota/storage/CustomStorage.js.map +0 -1
- package/lib/commonjs/ota/storage/GitHubReleaseStorage.js +0 -99
- package/lib/commonjs/ota/storage/GitHubReleaseStorage.js.map +0 -1
- package/lib/module/crypto-js.d.js +0 -2
- package/lib/module/crypto-js.d.js.map +0 -1
- package/lib/module/ota/storage/CustomStorage.js +0 -154
- package/lib/module/ota/storage/CustomStorage.js.map +0 -1
- package/lib/module/ota/storage/GitHubReleaseStorage.js +0 -93
- package/lib/module/ota/storage/GitHubReleaseStorage.js.map +0 -1
- package/lib/typescript/ota/storage/CustomStorage.d.ts +0 -12
- package/lib/typescript/ota/storage/CustomStorage.d.ts.map +0 -1
- package/lib/typescript/ota/storage/GitHubReleaseStorage.d.ts +0 -20
- package/lib/typescript/ota/storage/GitHubReleaseStorage.d.ts.map +0 -1
- package/src/crypto-js.d.ts +0 -1
- package/src/ota/storage/CustomStorage.ts +0 -166
- package/src/ota/storage/GitHubReleaseStorage.ts +0 -103
- 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://
|
|
75
|
-
username: 'your-user',
|
|
76
|
-
password: 'your-pass',
|
|
73
|
+
baseUrl: 'https://raw.githubusercontent.com/<owner>/<storage-repo>/main',
|
|
77
74
|
channel: 'production',
|
|
78
|
-
baseFolder: '
|
|
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
|
-
*
|
|
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
|
|
181
|
+
/** Custom storage provider. Omit to use GitHubRawStorageProvider. */
|
|
185
182
|
storage?: OtaStorageProvider;
|
|
186
183
|
|
|
187
|
-
/**
|
|
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: '
|
|
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 `
|
|
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 ──►
|
|
376
|
-
│
|
|
377
|
-
│
|
|
378
|
-
│
|
|
379
|
-
│
|
|
380
|
-
│
|
|
381
|
-
│
|
|
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 —
|
|
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
|
|
399
|
-
| `yarn ota:
|
|
400
|
-
| `yarn ota:
|
|
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.
|
|
421
|
-
|
|
422
|
-
#
|
|
423
|
-
yarn ota:
|
|
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
|
-
|
|
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
|
-
|
|
441
|
-
|
|
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
|
|
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
|
-
| `
|
|
457
|
-
| `
|
|
458
|
-
| `
|
|
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 **
|
|
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
|
-
- **
|
|
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
|
|
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
|
-
│
|
|
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,
|
|
562
|
+
│ ├── BsPatchModule.kt # Native: applyPatch, extract, verifySignature
|
|
568
563
|
│ └── OtaBundleResolver.kt # Native: bundle resolution + rollback
|
|
569
564
|
├── ios/
|
|
570
|
-
│ ├── RNBsPatch.mm # Native iOS bridge +
|
|
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
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
125
|
+
sig.update(buffer, 0, read)
|
|
214
126
|
}
|
|
215
127
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
}
|
package/cpp/bsdiff/bspatch.c
CHANGED
|
@@ -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
|
}
|