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