react-native-bspatch 1.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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +471 -0
  3. package/android/build.gradle +100 -0
  4. package/android/src/main/AndroidManifest.xml +3 -0
  5. package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +139 -0
  6. package/android/src/main/java/vn/reactnativebspatch/BsPatchPackage.kt +16 -0
  7. package/android/src/main/java/vn/reactnativebspatch/OtaBundleResolver.kt +227 -0
  8. package/cpp/CMakeLists.txt +43 -0
  9. package/cpp/bsdiff/bspatch.c +194 -0
  10. package/cpp/bsdiff/bspatch.h +41 -0
  11. package/cpp/bsdiff/bspatch_wrapper.c +150 -0
  12. package/cpp/bsdiff/bspatch_wrapper.h +6 -0
  13. package/cpp/bspatch_jni.cpp +41 -0
  14. package/cpp/bzip2/LICENSE +42 -0
  15. package/cpp/bzip2/blocksort.c +1094 -0
  16. package/cpp/bzip2/bzlib.c +1572 -0
  17. package/cpp/bzip2/bzlib.h +282 -0
  18. package/cpp/bzip2/bzlib_private.h +509 -0
  19. package/cpp/bzip2/compress.c +672 -0
  20. package/cpp/bzip2/crctable.c +104 -0
  21. package/cpp/bzip2/decompress.c +652 -0
  22. package/cpp/bzip2/huffman.c +205 -0
  23. package/cpp/bzip2/randtable.c +84 -0
  24. package/ios/OtaBundleResolver.swift +241 -0
  25. package/ios/RNBsPatch.h +8 -0
  26. package/ios/RNBsPatch.mm +198 -0
  27. package/lib/commonjs/NativeBsPatch.js +9 -0
  28. package/lib/commonjs/NativeBsPatch.js.map +1 -0
  29. package/lib/commonjs/crypto-js.d.js +2 -0
  30. package/lib/commonjs/crypto-js.d.js.map +1 -0
  31. package/lib/commonjs/index.js +90 -0
  32. package/lib/commonjs/index.js.map +1 -0
  33. package/lib/commonjs/ota/OtaProvider.js +306 -0
  34. package/lib/commonjs/ota/OtaProvider.js.map +1 -0
  35. package/lib/commonjs/ota/otaAssetResolver.js +145 -0
  36. package/lib/commonjs/ota/otaAssetResolver.js.map +1 -0
  37. package/lib/commonjs/ota/otaCore.js +736 -0
  38. package/lib/commonjs/ota/otaCore.js.map +1 -0
  39. package/lib/commonjs/ota/otaLogger.js +64 -0
  40. package/lib/commonjs/ota/otaLogger.js.map +1 -0
  41. package/lib/commonjs/ota/otaMetadata.js +138 -0
  42. package/lib/commonjs/ota/otaMetadata.js.map +1 -0
  43. package/lib/commonjs/ota/storage/CustomStorage.js +140 -0
  44. package/lib/commonjs/ota/storage/CustomStorage.js.map +1 -0
  45. package/lib/commonjs/ota/storage/GitHubReleaseStorage.js +96 -0
  46. package/lib/commonjs/ota/storage/GitHubReleaseStorage.js.map +1 -0
  47. package/lib/commonjs/ota/types.js +39 -0
  48. package/lib/commonjs/ota/types.js.map +1 -0
  49. package/lib/commonjs/package.json +1 -0
  50. package/lib/module/NativeBsPatch.js +5 -0
  51. package/lib/module/NativeBsPatch.js.map +1 -0
  52. package/lib/module/crypto-js.d.js +2 -0
  53. package/lib/module/crypto-js.d.js.map +1 -0
  54. package/lib/module/index.js +23 -0
  55. package/lib/module/index.js.map +1 -0
  56. package/lib/module/ota/OtaProvider.js +299 -0
  57. package/lib/module/ota/OtaProvider.js.map +1 -0
  58. package/lib/module/ota/otaAssetResolver.js +138 -0
  59. package/lib/module/ota/otaAssetResolver.js.map +1 -0
  60. package/lib/module/ota/otaCore.js +725 -0
  61. package/lib/module/ota/otaCore.js.map +1 -0
  62. package/lib/module/ota/otaLogger.js +59 -0
  63. package/lib/module/ota/otaLogger.js.map +1 -0
  64. package/lib/module/ota/otaMetadata.js +127 -0
  65. package/lib/module/ota/otaMetadata.js.map +1 -0
  66. package/lib/module/ota/storage/CustomStorage.js +134 -0
  67. package/lib/module/ota/storage/CustomStorage.js.map +1 -0
  68. package/lib/module/ota/storage/GitHubReleaseStorage.js +90 -0
  69. package/lib/module/ota/storage/GitHubReleaseStorage.js.map +1 -0
  70. package/lib/module/ota/types.js +38 -0
  71. package/lib/module/ota/types.js.map +1 -0
  72. package/lib/typescript/NativeBsPatch.d.ts +39 -0
  73. package/lib/typescript/NativeBsPatch.d.ts.map +1 -0
  74. package/lib/typescript/index.d.ts +15 -0
  75. package/lib/typescript/index.d.ts.map +1 -0
  76. package/lib/typescript/ota/OtaProvider.d.ts +10 -0
  77. package/lib/typescript/ota/OtaProvider.d.ts.map +1 -0
  78. package/lib/typescript/ota/otaAssetResolver.d.ts +15 -0
  79. package/lib/typescript/ota/otaAssetResolver.d.ts.map +1 -0
  80. package/lib/typescript/ota/otaCore.d.ts +109 -0
  81. package/lib/typescript/ota/otaCore.d.ts.map +1 -0
  82. package/lib/typescript/ota/otaLogger.d.ts +44 -0
  83. package/lib/typescript/ota/otaLogger.d.ts.map +1 -0
  84. package/lib/typescript/ota/otaMetadata.d.ts +46 -0
  85. package/lib/typescript/ota/otaMetadata.d.ts.map +1 -0
  86. package/lib/typescript/ota/storage/CustomStorage.d.ts +12 -0
  87. package/lib/typescript/ota/storage/CustomStorage.d.ts.map +1 -0
  88. package/lib/typescript/ota/storage/GitHubReleaseStorage.d.ts +20 -0
  89. package/lib/typescript/ota/storage/GitHubReleaseStorage.d.ts.map +1 -0
  90. package/lib/typescript/ota/types.d.ts +191 -0
  91. package/lib/typescript/ota/types.d.ts.map +1 -0
  92. package/package.json +89 -0
  93. package/react-native-bspatch.podspec +46 -0
  94. package/src/NativeBsPatch.ts +52 -0
  95. package/src/crypto-js.d.ts +1 -0
  96. package/src/index.ts +29 -0
  97. package/src/ota/OtaProvider.tsx +399 -0
  98. package/src/ota/otaAssetResolver.ts +182 -0
  99. package/src/ota/otaCore.ts +963 -0
  100. package/src/ota/otaLogger.ts +72 -0
  101. package/src/ota/otaMetadata.ts +149 -0
  102. package/src/ota/storage/CustomStorage.ts +143 -0
  103. package/src/ota/storage/GitHubReleaseStorage.ts +98 -0
  104. package/src/ota/types.ts +211 -0
  105. package/tools/bsdiff +0 -0
  106. package/tools/bsdiff.c +445 -0
  107. package/tools/bsdiff.h +45 -0
  108. package/tools/build_ota.sh +457 -0
  109. package/tools/setup_project.js +273 -0
  110. package/tools/update_registry.js +145 -0
  111. package/tools/upload_ota_custom.sh +138 -0
@@ -0,0 +1,963 @@
1
+ /**
2
+ * Core OTA Logic (Stateless)
3
+ *
4
+ * Responsibilities:
5
+ * - Download patches with retry
6
+ * - Verify integrity (SHA-256 hash)
7
+ * - Verify security (HMAC signature)
8
+ * - Handle gzip decompression
9
+ * - Apply binary patch (bsdiff)
10
+ * - Atomic bundle swap
11
+ * - Full bundle fallback on failure
12
+ * - Structured telemetry logging
13
+ */
14
+
15
+ import { Platform } from 'react-native';
16
+ import ReactNativeBlobUtil from 'react-native-blob-util';
17
+ import CryptoJS from 'crypto-js';
18
+ import NativeBsPatch from '../NativeBsPatch';
19
+ import { createOtaLogger, OtaEvent } from './otaLogger';
20
+ import {
21
+ ensureOtaDir,
22
+ getBundlePath,
23
+ loadMetadata,
24
+ registerNewVersion,
25
+ saveMetadata,
26
+ } from './otaMetadata';
27
+ import { CustomStorageProvider } from './storage/CustomStorage';
28
+ import { GitHubReleaseStorageProvider } from './storage/GitHubReleaseStorage';
29
+ import {
30
+ OtaStatus,
31
+ OtaStorageType,
32
+ type OtaAsset,
33
+ type OtaConfig,
34
+ type OtaMetrics,
35
+ type OtaPatch,
36
+ type OtaStorageProvider,
37
+ } from './types';
38
+
39
+ const OTA_DIR = `${ReactNativeBlobUtil.fs.dirs.DocumentDir}/ota`;
40
+
41
+ // ─── Storage Provider Factory ──────────────────────────────────────────
42
+
43
+ const DEFAULT_STORAGE = new CustomStorageProvider();
44
+ const GITHUB_STORAGE = new GitHubReleaseStorageProvider();
45
+
46
+ /**
47
+ * Get the storage provider for the given configuration.
48
+ * Priority: explicit storage instance > storageType > default (CustomStorage)
49
+ */
50
+ const getStorage = (config: OtaConfig): OtaStorageProvider => {
51
+ if (config.storage) return config.storage;
52
+ if (config.storageType === OtaStorageType.GITHUB) return GITHUB_STORAGE;
53
+ return DEFAULT_STORAGE;
54
+ };
55
+
56
+ // ─── Helpers ──────────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * Compares two semantic version strings.
60
+ *
61
+ * @param a - First version string (e.g., "1.2.3")
62
+ * @param b - Second version string (e.g., "1.2.4")
63
+ * @returns `true` if `a >= b`, `false` otherwise
64
+ *
65
+ * @example
66
+ * semverGte("1.2.3", "1.2.3") // true
67
+ * semverGte("1.2.4", "1.2.3") // true
68
+ * semverGte("1.2.2", "1.2.3") // false
69
+ */
70
+ export const semverGte = (a: string, b: string): boolean => {
71
+ const pa = a.split('.').map(Number);
72
+ const pb = b.split('.').map(Number);
73
+ for (let i = 0; i < 3; i++) {
74
+ const va = pa[i] || 0;
75
+ const vb = pb[i] || 0;
76
+ if (va > vb) return true;
77
+ if (va < vb) return false;
78
+ }
79
+ return true; // equal
80
+ };
81
+
82
+ /**
83
+ * Verifies the SHA-256 hash of a file.
84
+ *
85
+ * @param path - Absolute path to the file to verify
86
+ * @param expectedHash - Expected SHA-256 hash (hex string). If undefined, verification is skipped.
87
+ * @returns `true` if hash matches or `expectedHash` is undefined, `false` otherwise
88
+ */
89
+ export const verifyHash = async (
90
+ path: string,
91
+ expectedHash?: string,
92
+ ): Promise<boolean> => {
93
+ if (!expectedHash) return true;
94
+ try {
95
+ const hash = await ReactNativeBlobUtil.fs.hash(path, 'sha256');
96
+ return hash.toLowerCase() === expectedHash.toLowerCase();
97
+ } catch {
98
+ return false;
99
+ }
100
+ };
101
+
102
+ /**
103
+ * Verify HMAC-SHA256 signature of a file.
104
+ *
105
+ * Uses CryptoJS to verify that the signature matches the file content
106
+ * using the provided HMAC key.
107
+ *
108
+ * @param filePath - Path to the file to verify
109
+ * @param signature - Base64-encoded HMAC signature to verify against
110
+ * @param publicKey - Base64-encoded HMAC key
111
+ * @returns true if signature is valid, false otherwise
112
+ *
113
+ * @example
114
+ * // Server-side (Node.js) - generate signature:
115
+ * const crypto = require('crypto');
116
+ * const key = crypto.randomBytes(32).toString('base64');
117
+ * const fileBuffer = fs.readFileSync('bundle.patch');
118
+ * const signature = crypto.createHmac('sha256', Buffer.from(key, 'base64'))
119
+ * .update(fileBuffer)
120
+ * .digest('base64');
121
+ */
122
+ export const verifySignature = async (
123
+ filePath: string,
124
+ signature?: string,
125
+ publicKey?: string,
126
+ ): Promise<boolean> => {
127
+ if (!signature || !publicKey) return true; // Skip if not configured
128
+ try {
129
+ // Read file content
130
+ const fileData = await ReactNativeBlobUtil.fs.readFile(filePath, 'base64');
131
+
132
+ // Convert base64 to WordArray for CryptoJS
133
+ const fileWords = CryptoJS.enc.Base64.parse(fileData);
134
+ const keyWords = CryptoJS.enc.Base64.parse(publicKey);
135
+ const signatureWords = CryptoJS.enc.Base64.parse(signature);
136
+
137
+ // Compute HMAC
138
+ const hmac = CryptoJS.HmacSHA256(fileWords, keyWords);
139
+
140
+ // Compare computed HMAC with provided signature
141
+ const isValid = hmac.toString(CryptoJS.enc.Base64) === signatureWords.toString(CryptoJS.enc.Base64);
142
+
143
+ if (!isValid && __DEV__) {
144
+ console.warn('[otaCore] Signature mismatch');
145
+ }
146
+
147
+ return isValid;
148
+ } catch (error) {
149
+ if (__DEV__) {
150
+ console.error('[otaCore] Signature verification failed:', error);
151
+ }
152
+ return false;
153
+ }
154
+ };
155
+
156
+ /**
157
+ * Decompress a .gz file in-place using native helpers.
158
+ * Falls back to skipping decompression if no native helper is available,
159
+ * but enforces that the patch file must NOT be gzip-compressed in that case.
160
+ */
161
+ const decompressGzip = async (filePath: string): Promise<string> => {
162
+ if (!filePath.endsWith('.gz')) {
163
+ return filePath;
164
+ }
165
+
166
+ const decompressedPath = filePath.replace(/\.gz$/, '');
167
+ try {
168
+ const success = await NativeBsPatch.decompressGzip(
169
+ filePath,
170
+ decompressedPath,
171
+ );
172
+ if (success) {
173
+ return decompressedPath;
174
+ }
175
+ } catch {
176
+ // Native decompress not available — fall through
177
+ }
178
+
179
+ // No native gzip support: abort if file is actually compressed.
180
+ // Do NOT silently rename a .gz file to .patch — bspatch will read gzip
181
+ // magic bytes and fail with a confusing error.
182
+ console.error(
183
+ '[otaCore] .gz decompression is not supported on this platform. ' +
184
+ 'Ensure the OTA server sends uncompressed patches (compressed: false).',
185
+ );
186
+ throw new Error('Gzip decompression is not supported on this platform.');
187
+ };
188
+
189
+ /**
190
+ * Download a file with retry logic using exponential backoff.
191
+ * Cancels any in-flight request before retrying to avoid resource leaks.
192
+ */
193
+ const downloadWithRetry = async (
194
+ url: string,
195
+ destPath: string,
196
+ config: OtaConfig,
197
+ onProgress: (percent: number) => void,
198
+ headers: Record<string, string> = {},
199
+ ): Promise<void> => {
200
+ const retryLimit = config.network?.retry ?? 3;
201
+ const timeout = config.network?.timeoutMs ?? 30000;
202
+
203
+ for (let i = 0; i < retryLimit; i++) {
204
+ let taskRef: { cancel: () => void } | null = null;
205
+ try {
206
+ const task = ReactNativeBlobUtil.config({
207
+ fileCache: true,
208
+ path: destPath,
209
+ followRedirect: true,
210
+ timeout,
211
+ })
212
+ .fetch('GET', url, {
213
+ 'Cache-Control': 'no-cache',
214
+ ...headers,
215
+ })
216
+ .progress((received: any, total: any) => {
217
+ const percent = Math.floor(
218
+ (Number(received) / Number(total)) * 100,
219
+ );
220
+ onProgress(percent);
221
+ });
222
+ taskRef = { cancel: () => task.cancel() };
223
+ await task;
224
+ return;
225
+ } catch (e) {
226
+ // Cancel any lingering request before retrying
227
+ taskRef?.cancel();
228
+ if (i === retryLimit - 1) throw e;
229
+ const delay = Math.pow(2, i) * 1000; // Exponential backoff: 1s, 2s, 4s...
230
+ if (__DEV__) {
231
+ console.warn(
232
+ `[otaCore] Download attempt ${
233
+ i + 1
234
+ } failed. Retrying in ${delay}ms...`,
235
+ );
236
+ }
237
+ await new Promise<void>(resolve => setTimeout(resolve, delay));
238
+ }
239
+ }
240
+ };
241
+
242
+ // ─── Manifest ─────────────────────────────────────────────────────────
243
+
244
+ /**
245
+ * Fetches the list of available OTA patches from the remote manifest.
246
+ *
247
+ * Falls back to bundled patches if the remote fetch fails, ensuring
248
+ * the app can still apply updates that were bundled at build time.
249
+ *
250
+ * @param config - OTA configuration containing storage provider and fallback patches
251
+ * @returns Array of available patches, or bundled patches if remote fetch fails
252
+ */
253
+ export const fetchOtaManifest = async (
254
+ config: OtaConfig,
255
+ ): Promise<OtaPatch[]> => {
256
+ try {
257
+ const storage = getStorage(config);
258
+ return await storage.fetchManifest(config);
259
+ } catch (error) {
260
+ console.warn(
261
+ '[otaCore] Manifest fetch error:',
262
+ (error as Error).message,
263
+ );
264
+ return config.bundledPatches || [];
265
+ }
266
+ };
267
+
268
+ // ─── Hashed Asset Store ───────────────────────────────────────────────
269
+
270
+ const ASSET_STORE_DIR = `${ReactNativeBlobUtil.fs.dirs.DocumentDir}/ota/assets`;
271
+
272
+ /**
273
+ * Downloads and syncs assets from the content-addressed asset store.
274
+ * Only downloads assets that are not already cached locally.
275
+ *
276
+ * @param assetManifestUrl URL to the asset-manifest.json for this version
277
+ * @param config OTA configuration
278
+ * @param log Logger instance
279
+ * @param onStatus Status update callback
280
+ */
281
+ const downloadAndSyncAssets = async (
282
+ assetManifestUrl: string,
283
+ version: number,
284
+ config: OtaConfig,
285
+ log: ReturnType<typeof createOtaLogger>,
286
+ onStatus: (
287
+ status: OtaStatus,
288
+ message: string,
289
+ metrics?: OtaMetrics,
290
+ ) => void,
291
+ onProgress: (percent: number) => void,
292
+ ): Promise<void> => {
293
+ onStatus(OtaStatus.SYNCING_ASSETS, 'Syncing assets...');
294
+
295
+ const versionDir = `${OTA_DIR}/v${version}`;
296
+ const manifestPath = `${versionDir}/asset-manifest-v${version}.json`;
297
+
298
+ // Ensure asset store dir exists
299
+ if (!(await ReactNativeBlobUtil.fs.exists(ASSET_STORE_DIR))) {
300
+ await ReactNativeBlobUtil.fs.mkdir(ASSET_STORE_DIR);
301
+ }
302
+
303
+ // Fetch the asset manifest
304
+ let assets: OtaAsset[] = [];
305
+ try {
306
+ if (__DEV__) {
307
+ console.log(
308
+ `[otaCore] Fetching asset manifest from: ${assetManifestUrl}`,
309
+ );
310
+ }
311
+ const resp = await ReactNativeBlobUtil.config({
312
+ followRedirect: true,
313
+ timeout: 10000,
314
+ }).fetch('GET', assetManifestUrl, {
315
+ 'Cache-Control': 'no-cache',
316
+ });
317
+
318
+ if (resp.respInfo.status !== 200) {
319
+ throw new Error(
320
+ `Manifest fetch failed with status ${resp.respInfo.status}`,
321
+ );
322
+ }
323
+
324
+ // Save manifest to disk for future reloads (AssetResolver needs it)
325
+ if (!(await ReactNativeBlobUtil.fs.exists(versionDir))) {
326
+ await ReactNativeBlobUtil.fs.mkdir(versionDir);
327
+ }
328
+
329
+ const manifestData =
330
+ typeof resp.data === 'string'
331
+ ? resp.data
332
+ : JSON.stringify(resp.data);
333
+ await ReactNativeBlobUtil.fs.writeFile(
334
+ manifestPath,
335
+ manifestData,
336
+ 'utf8',
337
+ );
338
+ if (__DEV__) {
339
+ console.log(`[otaCore] Saved asset manifest to: ${manifestPath}`);
340
+ }
341
+
342
+ const manifest = JSON.parse(manifestData);
343
+ // Filter out base assets, we don't need to download them
344
+ assets = (manifest.assets || []).filter((a: any) => !a.isBase);
345
+ } catch (e) {
346
+ if (__DEV__) {
347
+ console.warn('[otaCore] Failed to fetch asset manifest:', e);
348
+ }
349
+ return;
350
+ }
351
+
352
+ if (assets.length === 0) {
353
+ if (__DEV__) {
354
+ console.log('[otaCore] No new assets to sync for this version.');
355
+ }
356
+ return;
357
+ }
358
+
359
+ // Only notify UI now that we actually have assets to download
360
+ onStatus(OtaStatus.SYNCING_ASSETS, `Syncing ${assets.length} asset(s)...`);
361
+
362
+ let processed = 0;
363
+ let downloaded = 0;
364
+ let cached = 0;
365
+
366
+ const updateProgress = () => {
367
+ processed++;
368
+ const percent = Math.round((processed / assets.length) * 100);
369
+ onProgress(percent);
370
+ };
371
+
372
+ // Concurrency-limited download queue
373
+ const maxConcurrency = 6; // Optimal for most mobile networks
374
+ let index = 0;
375
+
376
+ const downloadNext = async (): Promise<void> => {
377
+ if (index >= assets.length) return;
378
+
379
+ const asset = assets[index++];
380
+ const filename = `${asset.hash}.${asset.ext}`;
381
+ const localPath = `${ASSET_STORE_DIR}/${filename}`;
382
+
383
+ // Check if already cached
384
+ if (await ReactNativeBlobUtil.fs.exists(localPath)) {
385
+ cached++;
386
+ updateProgress();
387
+ await downloadNext(); // Start next download
388
+ return;
389
+ }
390
+
391
+ // Download missing asset
392
+ onStatus(
393
+ OtaStatus.SYNCING_ASSETS,
394
+ `Syncing assets (${processed + 1}/${assets.length})...`,
395
+ );
396
+ try {
397
+ const storage = getStorage(config);
398
+ const assetUrl = await storage.getAssetUrl(
399
+ asset.hash,
400
+ asset.ext,
401
+ config,
402
+ );
403
+ if (!assetUrl) {
404
+ if (__DEV__) {
405
+ console.warn(
406
+ `[otaCore] Could not get URL for asset ${filename}`,
407
+ );
408
+ }
409
+ } else {
410
+ await ReactNativeBlobUtil.config({
411
+ fileCache: true,
412
+ path: localPath,
413
+ followRedirect: true,
414
+ timeout: config.network?.timeoutMs ?? 30000,
415
+ }).fetch('GET', assetUrl);
416
+ downloaded++;
417
+ log.emit(OtaEvent.DOWNLOAD_SUCCESS, {
418
+ message: `Asset downloaded: ${filename}`,
419
+ });
420
+ }
421
+ } catch (e) {
422
+ if (__DEV__) {
423
+ console.warn(
424
+ `[otaCore] Failed to download asset ${filename}:`,
425
+ e,
426
+ );
427
+ }
428
+ // Non-fatal: continue with other assets
429
+ } finally {
430
+ updateProgress();
431
+ await downloadNext(); // Start next download
432
+ }
433
+ };
434
+
435
+ // Start initial batch of downloads
436
+ const initialWorkers = Math.min(maxConcurrency, assets.length);
437
+ const workers = Array(initialWorkers)
438
+ .fill(null)
439
+ .map(() => downloadNext());
440
+ await Promise.all(workers);
441
+
442
+ onStatus(
443
+ OtaStatus.SYNCING_ASSETS,
444
+ `Assets synced (${downloaded} new, ${cached} cached)`,
445
+ );
446
+ console.log(
447
+ `[otaCore] Assets: ${downloaded} downloaded, ${cached} from cache`,
448
+ );
449
+ };
450
+
451
+ /**
452
+ * Gets the local filesystem path for a content-addressed asset.
453
+ *
454
+ * Assets are stored by their hash, so the same asset file is shared
455
+ * across all OTA versions that reference it.
456
+ *
457
+ * @param hash - SHA-256 hash of the asset content
458
+ * @param ext - File extension (e.g., "png", "jpg")
459
+ * @returns Absolute path to the asset in the local store
460
+ *
461
+ * @example
462
+ * getLocalAssetPath("abc123...", "png")
463
+ * // Returns: "/var/mobile/Containers/Data/Application/.../ota/assets/abc123....png"
464
+ */
465
+ export const getLocalAssetPath = (hash: string, ext: string): string =>
466
+ `${ASSET_STORE_DIR}/${hash}.${ext}`;
467
+
468
+ /**
469
+ * Gets the local filesystem path for a downloaded patch file.
470
+ *
471
+ * @param patch - Patch metadata object
472
+ * @param docDir - Document directory path (use `ReactNativeBlobUtil.fs.dirs.DocumentDir`)
473
+ * @returns Absolute path to the patch file
474
+ */
475
+ export const getLocalPatchPath = (patch: OtaPatch, docDir: string): string =>
476
+ `${docDir}/ota/patch-${patch.id}.patch`;
477
+
478
+ // ─── Main Update Pipeline ─────────────────────────────────────────────
479
+
480
+ /**
481
+ * Applies a single patch and reloads the bundle.
482
+ *
483
+ * Pipeline:
484
+ * 1. Version compatibility check
485
+ * 2. Download patch (with retry)
486
+ * 3. Decompress (if .gz)
487
+ * 4. Security signature check
488
+ * 5. Resolve source bundle
489
+ * 6. Verify source hash
490
+ * 7. Apply binary patch
491
+ * 8. Verify result hash
492
+ * 9. Atomic commit (mv)
493
+ * 10. Update metadata
494
+ * 11. Reload bundle
495
+ *
496
+ * On failure → fallback to full bundle download if URL available.
497
+ */
498
+ export const applyOtaPatchInternal = async (
499
+ patch: OtaPatch,
500
+ config: OtaConfig,
501
+ onStatus: (
502
+ status: OtaStatus,
503
+ message: string,
504
+ metrics?: OtaMetrics,
505
+ ) => void,
506
+ onProgress: (percent: number) => void,
507
+ ) => {
508
+ const log = createOtaLogger(config.onEvent);
509
+ await ensureOtaDir();
510
+ const totalStart = Date.now();
511
+
512
+ const finalBundlePath =
513
+ getBundlePath(patch.toVersion, config.baseAppVersion) || '';
514
+ const tempBundle = `${finalBundlePath}.tmp`;
515
+ const localPatchPath = getLocalPatchPath(
516
+ patch,
517
+ ReactNativeBlobUtil.fs.dirs.DocumentDir,
518
+ );
519
+
520
+ try {
521
+ // ── Step 1: Version Compatibility ──
522
+ if (patch.minAppVersion) {
523
+ if (!semverGte(config.appVersion, patch.minAppVersion)) {
524
+ log.emit(OtaEvent.VERSION_INCOMPATIBLE, {
525
+ message: `App ${config.appVersion} < required ${patch.minAppVersion}`,
526
+ version: patch.toVersion,
527
+ });
528
+ throw new Error(
529
+ `Native app ${config.appVersion} is too old. Update to ${patch.minAppVersion}+ first.`,
530
+ );
531
+ }
532
+ }
533
+
534
+ // ── Step 2: Download ──
535
+ let downloadMs = 0;
536
+ let patchSizeBytes = 0;
537
+ let fromCache = false;
538
+
539
+ if (await ReactNativeBlobUtil.fs.exists(localPatchPath)) {
540
+ const stat = await ReactNativeBlobUtil.fs.stat(localPatchPath);
541
+ patchSizeBytes = Number(stat.size);
542
+ fromCache = true;
543
+ onProgress(100);
544
+ } else {
545
+ log.emit(OtaEvent.DOWNLOAD_START, { version: patch.toVersion });
546
+ onStatus(
547
+ OtaStatus.DOWNLOADING,
548
+ `Downloading V${patch.toVersion}...`,
549
+ );
550
+ const dlStart = Date.now();
551
+ try {
552
+ const storage = getStorage(config);
553
+ const url = await storage.getUpdateFileUrl(
554
+ patch.filename,
555
+ config,
556
+ );
557
+
558
+ await downloadWithRetry(
559
+ url,
560
+ localPatchPath,
561
+ config,
562
+ onProgress,
563
+ {},
564
+ );
565
+ downloadMs = Date.now() - dlStart;
566
+ const stat = await ReactNativeBlobUtil.fs.stat(localPatchPath);
567
+ patchSizeBytes = Number(stat.size);
568
+ log.emit(OtaEvent.DOWNLOAD_SUCCESS, {
569
+ version: patch.toVersion,
570
+ durationMs: downloadMs,
571
+ sizeKB: patchSizeBytes / 1024,
572
+ });
573
+ } catch (dlError) {
574
+ log.emit(OtaEvent.DOWNLOAD_FAIL, {
575
+ error: (dlError as Error).message,
576
+ version: patch.toVersion,
577
+ });
578
+ throw dlError;
579
+ }
580
+ }
581
+
582
+ // ── Step 3: Decompress if needed ──
583
+ let actualPatchPath = localPatchPath;
584
+ if (patch.compressed) {
585
+ log.emit(OtaEvent.DECOMPRESS_START);
586
+ onStatus(OtaStatus.APPLYING, 'Decompressing...');
587
+ actualPatchPath = await decompressGzip(localPatchPath);
588
+ log.emit(OtaEvent.DECOMPRESS_SUCCESS);
589
+ }
590
+
591
+ // ── Step 4: Signature Verification ──
592
+ if (patch.signature && config.publicKey) {
593
+ onStatus(OtaStatus.APPLYING, 'Verifying signature...');
594
+ const sigValid = await verifySignature(
595
+ actualPatchPath,
596
+ patch.signature,
597
+ config.publicKey,
598
+ );
599
+ if (!sigValid) {
600
+ log.emit(OtaEvent.SIGNATURE_INVALID, {
601
+ version: patch.toVersion,
602
+ });
603
+ throw new Error(
604
+ 'Patch signature verification failed. Possible MITM.',
605
+ );
606
+ }
607
+ }
608
+
609
+ // ── Step 4.2: Download & Sync Assets ──
610
+ if (patch.assetManifestUrl) {
611
+ const storage = getStorage(config);
612
+ const manifestUrl = await storage.getUpdateFileUrl(
613
+ patch.assetManifestUrl,
614
+ config,
615
+ );
616
+ await downloadAndSyncAssets(
617
+ manifestUrl,
618
+ patch.toVersion,
619
+ config,
620
+ log,
621
+ onStatus,
622
+ onProgress,
623
+ );
624
+ }
625
+
626
+ // ── Step 5: Resolve Source Bundle ──
627
+ onStatus(OtaStatus.APPLYING, 'Preparing source...');
628
+ const metadata = await loadMetadata(config.baseAppVersion);
629
+ const oldBundlePath = getBundlePath(
630
+ metadata.currentVersion,
631
+ config.baseAppVersion,
632
+ );
633
+
634
+ // ── Resolve the actual old bundle path ──────────────────────────────
635
+ // getBundlePath returns null when currentVersion == baseAppVersion, meaning
636
+ // "use the APK's baked-in bundle". However, if BASE_APP_VERSION was bumped
637
+ // to match a version that was applied via OTA (not actually baked into the
638
+ // installed APK), direct APK extraction would produce a DIFFERENT file than
639
+ // what the build script recorded as the baseHash for the next patch.
640
+ //
641
+ // Strategy (in order of precedence):
642
+ // 1. Use the explicit OTA bundle path from getBundlePath (version > base) ✓
643
+ // 2. If null (version == base), check for a previously OTA-stored bundle
644
+ // at ota/v{currentVersion}/ — its hash matches the server registry.
645
+ // 3. Fall back to APK extraction only if no OTA bundle exists on disk.
646
+ const bundleFilename =
647
+ Platform.OS === 'android'
648
+ ? 'index.android.bundle'
649
+ : 'main.jsbundle';
650
+
651
+ let actualOldPath = '';
652
+ if (!oldBundlePath) {
653
+ // currentVersion == baseAppVersion: prefer OTA-stored bundle for this
654
+ // version over APK extraction (handles BASE_APP_VERSION bump scenarios).
655
+ const otaStoredPath = `${OTA_DIR}/v${metadata.currentVersion}/${bundleFilename}`;
656
+ if (await ReactNativeBlobUtil.fs.exists(otaStoredPath)) {
657
+ if (__DEV__) {
658
+ console.log(
659
+ `[otaCore] Source: OTA-stored bundle for V${metadata.currentVersion} (BASE_APP_VERSION match)`,
660
+ );
661
+ }
662
+ actualOldPath = otaStoredPath;
663
+ } else {
664
+ // True base: extract byte-for-byte from APK assets.
665
+ // RNFS.copyFileAssets is known to produce inconsistent bytes in some
666
+ // APK configurations (e.g., release signing + resource shrinking).
667
+ actualOldPath = `${OTA_DIR}/base.hbc`;
668
+ if (!(await ReactNativeBlobUtil.fs.exists(actualOldPath))) {
669
+ if (__DEV__) {
670
+ console.log(
671
+ `[otaCore] Source: extracting bundle from APK assets → ${actualOldPath}`,
672
+ );
673
+ }
674
+ await NativeBsPatch.extractBundleFromAssets(
675
+ bundleFilename,
676
+ actualOldPath,
677
+ );
678
+ } else if (__DEV__) {
679
+ console.log(
680
+ `[otaCore] Source: cached APK-extracted bundle → ${actualOldPath}`,
681
+ );
682
+ }
683
+ }
684
+ } else {
685
+ actualOldPath = oldBundlePath;
686
+ }
687
+
688
+ // ── Debug: log file sizes for diagnostic ──
689
+ try {
690
+ const oldStat = await ReactNativeBlobUtil.fs.stat(actualOldPath);
691
+ const patchStat = await ReactNativeBlobUtil.fs.stat(
692
+ actualPatchPath,
693
+ );
694
+ if (__DEV__) {
695
+ console.log(
696
+ `[otaCore] DEBUG old bundle size=${oldStat.size} path=${actualOldPath}`,
697
+ );
698
+ console.log(
699
+ `[otaCore] DEBUG patch size=${patchStat.size} path=${actualPatchPath}`,
700
+ );
701
+ console.log(
702
+ `[otaCore] DEBUG baseHash expected=${patch.baseHash}`,
703
+ );
704
+ }
705
+ } catch {
706
+ // Non-fatal: stat is for diagnostic only
707
+ }
708
+
709
+ // ── Step 6: Verify Source Integrity ──
710
+ if (patch.baseHash) {
711
+ log.emit(OtaEvent.HASH_VERIFY_START, {
712
+ message: 'Verifying source bundle',
713
+ });
714
+ onStatus(OtaStatus.APPLYING, 'Verifying source integrity...');
715
+ const isValid = await verifyHash(actualOldPath, patch.baseHash);
716
+ if (!isValid) {
717
+ // Re-hash only for the diagnostic log (verifyHash doesn't return the actual hash)
718
+ let actualSourceHash = '';
719
+ let srcSize = 'unknown';
720
+ try {
721
+ actualSourceHash = await ReactNativeBlobUtil.fs.hash(
722
+ actualOldPath,
723
+ 'sha256',
724
+ );
725
+ const srcStat = await ReactNativeBlobUtil.fs.stat(
726
+ actualOldPath,
727
+ );
728
+ srcSize = `${srcStat.size} bytes`;
729
+ } catch {
730
+ // stat failure is non-fatal for the error message
731
+ }
732
+ if (__DEV__) {
733
+ console.error(
734
+ '[otaCore] SOURCE HASH MISMATCH\n' +
735
+ ` path: ${actualOldPath}\n` +
736
+ ` size: ${srcSize}\n` +
737
+ ` expected: ${patch.baseHash}\n` +
738
+ ` actual: ${actualSourceHash}\n` +
739
+ ` patch.id: ${patch.id} (v${patch.fromVersion}→v${patch.toVersion})`,
740
+ );
741
+ }
742
+ log.emit(OtaEvent.HASH_MISMATCH, {
743
+ version: metadata.currentVersion,
744
+ message: 'Source bundle hash mismatch',
745
+ metadata: {
746
+ expected: patch.baseHash,
747
+ actual: actualSourceHash,
748
+ path: actualOldPath,
749
+ },
750
+ });
751
+ throw new Error(
752
+ 'Source bundle hash mismatch. Fallback required.',
753
+ );
754
+ }
755
+ log.emit(OtaEvent.HASH_VERIFY_SUCCESS, {
756
+ message: 'Source bundle verified',
757
+ });
758
+ }
759
+
760
+ // ── Step 7: Apply Patch ──
761
+ const versionDir = `${OTA_DIR}/v${patch.toVersion}`;
762
+ if (!(await ReactNativeBlobUtil.fs.exists(versionDir))) {
763
+ await ReactNativeBlobUtil.fs.mkdir(versionDir);
764
+ }
765
+
766
+ log.emit(OtaEvent.PATCH_START, { version: patch.toVersion });
767
+ onStatus(OtaStatus.APPLYING, 'Applying binary patch...');
768
+ const applyStart = Date.now();
769
+ const success = await NativeBsPatch.applyPatch(
770
+ actualOldPath,
771
+ tempBundle,
772
+ actualPatchPath,
773
+ );
774
+ const applyMs = Date.now() - applyStart;
775
+
776
+ if (!success) {
777
+ log.emit(OtaEvent.PATCH_FAIL, {
778
+ message: 'BsPatch returned false',
779
+ version: patch.toVersion,
780
+ });
781
+ throw new Error('BsPatch application failed');
782
+ }
783
+
784
+ // ── Step 8: Verify Result Integrity ──
785
+ if (patch.bundleHash) {
786
+ log.emit(OtaEvent.HASH_VERIFY_START, {
787
+ message: 'Verifying result bundle',
788
+ });
789
+ onStatus(OtaStatus.APPLYING, 'Verifying integrity...');
790
+ const isValid = await verifyHash(tempBundle, patch.bundleHash);
791
+
792
+ if (!isValid) {
793
+ console.error(
794
+ '[otaCore] Result bundle hash mismatch | expected:',
795
+ patch.bundleHash,
796
+ 'path:',
797
+ tempBundle,
798
+ );
799
+ log.emit(OtaEvent.HASH_MISMATCH, {
800
+ message: 'Result bundle hash mismatch',
801
+ version: patch.toVersion,
802
+ metadata: {
803
+ expected: patch.bundleHash,
804
+ path: tempBundle,
805
+ },
806
+ });
807
+ throw new Error('Result bundle hash mismatch');
808
+ }
809
+ log.emit(OtaEvent.HASH_VERIFY_SUCCESS, {
810
+ message: 'Result bundle verified',
811
+ });
812
+ }
813
+
814
+ // ── Step 9: Atomic Commit ──
815
+ if (await ReactNativeBlobUtil.fs.exists(finalBundlePath)) {
816
+ await ReactNativeBlobUtil.fs.unlink(finalBundlePath);
817
+ }
818
+ await ReactNativeBlobUtil.fs.mv(tempBundle, finalBundlePath);
819
+
820
+ // ── Step 10: Update Metadata ──
821
+ const newMetadata = registerNewVersion(metadata, patch.toVersion);
822
+ await saveMetadata(newMetadata);
823
+
824
+ const totalMs = Date.now() - totalStart;
825
+ log.emit(OtaEvent.PATCH_SUCCESS, {
826
+ version: patch.toVersion,
827
+ durationMs: totalMs,
828
+ sizeKB: patchSizeBytes / 1024,
829
+ });
830
+
831
+ onStatus(OtaStatus.SUCCESS, `Update V${patch.toVersion} Successful!`, {
832
+ patchSizeKB: patchSizeBytes / 1024,
833
+ downloadMs,
834
+ applyMs,
835
+ totalMs,
836
+ fromCache,
837
+ });
838
+
839
+ // ── Step 11: Reload ──
840
+ // CRITICAL: Mark healthy BEFORE reload — reloadBundle() calls Runtime.exit(0)
841
+ // and JS will NOT run again before restart. Native OtaBundleResolver sets
842
+ // lastLaunchSuccessful=false before loading the new bundle. Without this call,
843
+ // the next cold boot sees false → rollback to previous version.
844
+ NativeBsPatch.markAsHealthy();
845
+ if (__DEV__) {
846
+ console.log(
847
+ `[otaCore] V${patch.toVersion} marked healthy — reloading.`,
848
+ );
849
+ }
850
+ NativeBsPatch.reloadBundle();
851
+ } catch (error) {
852
+ if (__DEV__) {
853
+ console.error('[otaCore] Pipeline failed:', error);
854
+ }
855
+
856
+ // ── Fallback: Full Bundle Download ──
857
+ if (patch.fullBundleUrl) {
858
+ try {
859
+ onStatus(
860
+ OtaStatus.DOWNLOADING,
861
+ 'Fallback: Downloading full bundle...',
862
+ );
863
+ const storage = getStorage(config);
864
+ const url = await storage.getUpdateFileUrl(
865
+ patch.fullBundleUrl || patch.filename,
866
+ config,
867
+ );
868
+
869
+ await downloadWithRetry(
870
+ url,
871
+ tempBundle,
872
+ config,
873
+ onProgress,
874
+ {},
875
+ );
876
+
877
+ // Verify full bundle hash
878
+ if (patch.bundleHash) {
879
+ const isValid = await verifyHash(
880
+ tempBundle,
881
+ patch.bundleHash,
882
+ );
883
+ if (!isValid) {
884
+ let actualHash = '';
885
+ let size: number | undefined;
886
+ try {
887
+ actualHash = await ReactNativeBlobUtil.fs.hash(
888
+ tempBundle,
889
+ 'sha256',
890
+ );
891
+ const stat = await ReactNativeBlobUtil.fs.stat(
892
+ tempBundle,
893
+ );
894
+ size = stat.size;
895
+ } catch {
896
+ // stat failure is non-fatal
897
+ }
898
+ if (__DEV__) {
899
+ console.error(
900
+ '[otaCore] Full bundle hash mismatch | expected:',
901
+ patch.bundleHash,
902
+ 'actual:',
903
+ actualHash,
904
+ );
905
+ }
906
+ log.emit(OtaEvent.HASH_MISMATCH, {
907
+ message: 'Full bundle hash mismatch',
908
+ metadata: {
909
+ expected: patch.bundleHash,
910
+ actual: actualHash,
911
+ size,
912
+ },
913
+ });
914
+ throw new Error(
915
+ 'Full bundle hash mismatch after fallback',
916
+ );
917
+ }
918
+ }
919
+
920
+ // Atomic swap
921
+ if (await ReactNativeBlobUtil.fs.exists(finalBundlePath)) {
922
+ await ReactNativeBlobUtil.fs.unlink(finalBundlePath);
923
+ }
924
+ await ReactNativeBlobUtil.fs.mv(tempBundle, finalBundlePath);
925
+
926
+ const metadata = await loadMetadata(config.baseAppVersion);
927
+ const newMetadata = registerNewVersion(
928
+ metadata,
929
+ patch.toVersion,
930
+ );
931
+ await saveMetadata(newMetadata);
932
+
933
+ // ── CRITICAL: Mark healthy BEFORE reload so native OtaBundleResolver
934
+ // does NOT see lastLaunchSuccessful=false on the very next boot and
935
+ // immediately rollback the version we just successfully downloaded.
936
+ // (reloadBundle() calls Runtime.exit(0) — JS won't run again before restart)
937
+ NativeBsPatch.markAsHealthy();
938
+ if (__DEV__) {
939
+ console.log(
940
+ `[otaCore] Fallback V${patch.toVersion} marked healthy — reloading.`,
941
+ );
942
+ }
943
+
944
+ onStatus(
945
+ OtaStatus.SUCCESS,
946
+ `Fallback successful! Reloading...`,
947
+ );
948
+ NativeBsPatch.reloadBundle();
949
+ return;
950
+ } catch (fallbackError) {
951
+ if (__DEV__) {
952
+ console.error(
953
+ '[otaCore] Full bundle fallback also failed:',
954
+ fallbackError,
955
+ );
956
+ }
957
+ }
958
+ }
959
+
960
+ onStatus(OtaStatus.ERROR, `Error: ${(error as Error).message}`);
961
+ throw error;
962
+ }
963
+ };