@hot-updater/react-native 0.30.11 → 0.31.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 (128) hide show
  1. package/HotUpdater.podspec +1 -0
  2. package/android/build.gradle +1 -0
  3. package/android/src/main/java/com/hotupdater/BsdiffPatch.kt +284 -0
  4. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +885 -5
  5. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +13 -2
  6. package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +37 -8
  7. package/android/src/main/java/com/hotupdater/ReactNativeValueConverters.kt +48 -12
  8. package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +60 -22
  9. package/android/src/newarch/HotUpdater.kt +14 -2
  10. package/android/src/newarch/HotUpdaterModule.kt +112 -1
  11. package/android/src/oldarch/HotUpdater.kt +14 -2
  12. package/android/src/oldarch/HotUpdaterModule.kt +107 -3
  13. package/android/src/test/java/com/hotupdater/BsdiffPatchTest.kt +46 -0
  14. package/android/src/test/java/com/hotupdater/BundleFileStorageServiceTest.kt +29 -1
  15. package/android/src/test/java/com/hotupdater/HotUpdaterImplTest.kt +4 -1
  16. package/ios/HotUpdater/Internal/BsdiffPatchBridge.h +16 -0
  17. package/ios/HotUpdater/Internal/BsdiffPatchBridge.mm +343 -0
  18. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +1093 -47
  19. package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +2 -0
  20. package/ios/HotUpdater/Internal/HotUpdater.mm +12 -2
  21. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +64 -5
  22. package/ios/HotUpdater/Internal/NotificationExtension.swift +2 -1
  23. package/ios/HotUpdater/Internal/SignatureVerifier.swift +28 -5
  24. package/ios/HotUpdater/Internal/StreamingTarArchiveExtractor.swift +11 -0
  25. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +11 -5
  26. package/lib/commonjs/DefaultResolver.js +18 -4
  27. package/lib/commonjs/DefaultResolver.js.map +1 -1
  28. package/lib/commonjs/DefaultResolver.spec.js +136 -0
  29. package/lib/commonjs/DefaultResolver.spec.js.map +1 -0
  30. package/lib/commonjs/checkForUpdate.js +6 -0
  31. package/lib/commonjs/checkForUpdate.js.map +1 -1
  32. package/lib/commonjs/index.js +53 -18
  33. package/lib/commonjs/index.js.map +1 -1
  34. package/lib/commonjs/index.spec.js +133 -1
  35. package/lib/commonjs/index.spec.js.map +1 -1
  36. package/lib/commonjs/native.js +40 -16
  37. package/lib/commonjs/native.js.map +1 -1
  38. package/lib/commonjs/native.spec.js +119 -3
  39. package/lib/commonjs/native.spec.js.map +1 -1
  40. package/lib/commonjs/sdkVersion.js +8 -0
  41. package/lib/commonjs/sdkVersion.js.map +1 -0
  42. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  43. package/lib/commonjs/store.js +72 -5
  44. package/lib/commonjs/store.js.map +1 -1
  45. package/lib/commonjs/store.spec.js +265 -8
  46. package/lib/commonjs/store.spec.js.map +1 -1
  47. package/lib/commonjs/types.js.map +1 -1
  48. package/lib/commonjs/wrap.js +19 -5
  49. package/lib/commonjs/wrap.js.map +1 -1
  50. package/lib/commonjs/wrap.spec.js +74 -0
  51. package/lib/commonjs/wrap.spec.js.map +1 -1
  52. package/lib/module/DefaultResolver.js +18 -4
  53. package/lib/module/DefaultResolver.js.map +1 -1
  54. package/lib/module/DefaultResolver.spec.js +135 -0
  55. package/lib/module/DefaultResolver.spec.js.map +1 -0
  56. package/lib/module/checkForUpdate.js +6 -0
  57. package/lib/module/checkForUpdate.js.map +1 -1
  58. package/lib/module/index.js +53 -18
  59. package/lib/module/index.js.map +1 -1
  60. package/lib/module/index.spec.js +133 -1
  61. package/lib/module/index.spec.js.map +1 -1
  62. package/lib/module/native.js +40 -16
  63. package/lib/module/native.js.map +1 -1
  64. package/lib/module/native.spec.js +119 -3
  65. package/lib/module/native.spec.js.map +1 -1
  66. package/lib/module/sdkVersion.js +4 -0
  67. package/lib/module/sdkVersion.js.map +1 -0
  68. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  69. package/lib/module/store.js +72 -5
  70. package/lib/module/store.js.map +1 -1
  71. package/lib/module/store.spec.js +265 -8
  72. package/lib/module/store.spec.js.map +1 -1
  73. package/lib/module/types.js.map +1 -1
  74. package/lib/module/wrap.js +19 -5
  75. package/lib/module/wrap.js.map +1 -1
  76. package/lib/module/wrap.spec.js +74 -0
  77. package/lib/module/wrap.spec.js.map +1 -1
  78. package/lib/typescript/commonjs/DefaultResolver.d.ts +4 -3
  79. package/lib/typescript/commonjs/DefaultResolver.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/checkForUpdate.d.ts +2 -2
  81. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/index.d.ts +37 -9
  83. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  84. package/lib/typescript/commonjs/native.d.ts +56 -3
  85. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  86. package/lib/typescript/commonjs/sdkVersion.d.ts +2 -0
  87. package/lib/typescript/commonjs/sdkVersion.d.ts.map +1 -0
  88. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +27 -0
  89. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  90. package/lib/typescript/commonjs/store.d.ts +5 -0
  91. package/lib/typescript/commonjs/store.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/types.d.ts +1 -0
  93. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  94. package/lib/typescript/commonjs/wrap.d.ts +48 -32
  95. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  96. package/lib/typescript/module/DefaultResolver.d.ts +4 -3
  97. package/lib/typescript/module/DefaultResolver.d.ts.map +1 -1
  98. package/lib/typescript/module/checkForUpdate.d.ts +2 -2
  99. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  100. package/lib/typescript/module/index.d.ts +37 -9
  101. package/lib/typescript/module/index.d.ts.map +1 -1
  102. package/lib/typescript/module/native.d.ts +56 -3
  103. package/lib/typescript/module/native.d.ts.map +1 -1
  104. package/lib/typescript/module/sdkVersion.d.ts +2 -0
  105. package/lib/typescript/module/sdkVersion.d.ts.map +1 -0
  106. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +27 -0
  107. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  108. package/lib/typescript/module/store.d.ts +5 -0
  109. package/lib/typescript/module/store.d.ts.map +1 -1
  110. package/lib/typescript/module/types.d.ts +1 -0
  111. package/lib/typescript/module/types.d.ts.map +1 -1
  112. package/lib/typescript/module/wrap.d.ts +48 -32
  113. package/lib/typescript/module/wrap.d.ts.map +1 -1
  114. package/package.json +17 -9
  115. package/src/DefaultResolver.spec.ts +178 -0
  116. package/src/DefaultResolver.ts +32 -6
  117. package/src/checkForUpdate.ts +9 -2
  118. package/src/index.spec.ts +170 -2
  119. package/src/index.ts +100 -40
  120. package/src/native.spec.ts +139 -1
  121. package/src/native.ts +145 -31
  122. package/src/sdkVersion.ts +1 -0
  123. package/src/specs/NativeHotUpdater.ts +28 -0
  124. package/src/store.spec.ts +324 -9
  125. package/src/store.ts +133 -7
  126. package/src/types.ts +2 -0
  127. package/src/wrap.spec.ts +91 -0
  128. package/src/wrap.tsx +84 -45
@@ -4,10 +4,52 @@ import android.os.StatFs
4
4
  import android.util.Log
5
5
  import kotlinx.coroutines.Dispatchers
6
6
  import kotlinx.coroutines.withContext
7
+ import org.brotli.dec.BrotliInputStream
7
8
  import org.json.JSONObject
8
9
  import java.io.File
10
+ import java.io.FileInputStream
11
+ import java.io.FileOutputStream
9
12
  import java.net.URL
10
13
 
14
+ data class ChangedAssetDescriptor(
15
+ val fileUrl: String?,
16
+ val fileHash: String,
17
+ val fileCompression: String? = null,
18
+ val patch: BsdiffPatchDescriptor? = null,
19
+ )
20
+
21
+ data class BsdiffPatchDescriptor(
22
+ val algorithm: String,
23
+ val baseBundleId: String,
24
+ val baseFileHash: String,
25
+ val patchFileHash: String,
26
+ val patchUrl: String,
27
+ )
28
+
29
+ data class UpdateProgressPayload(
30
+ val progress: Double,
31
+ val artifactType: String,
32
+ val downloadedBytes: Long? = null,
33
+ val totalBytes: Long? = null,
34
+ val details: DiffProgressDetails? = null,
35
+ )
36
+
37
+ data class DiffProgressFileSnapshot(
38
+ val path: String,
39
+ val downloadPath: String = path,
40
+ val status: String,
41
+ val progress: Double,
42
+ val order: Int,
43
+ val downloadedBytes: Long? = null,
44
+ val totalBytes: Long? = null,
45
+ )
46
+
47
+ data class DiffProgressDetails(
48
+ val totalFilesCount: Int,
49
+ val completedFilesCount: Int,
50
+ val files: List<DiffProgressFileSnapshot> = emptyList(),
51
+ )
52
+
11
53
  /**
12
54
  * Interface for bundle storage operations
13
55
  */
@@ -50,7 +92,10 @@ interface BundleStorageService {
50
92
  bundleId: String,
51
93
  fileUrl: String?,
52
94
  fileHash: String?,
53
- progressCallback: (Double) -> Unit,
95
+ manifestUrl: String?,
96
+ manifestFileHash: String?,
97
+ changedAssets: Map<String, ChangedAssetDescriptor>?,
98
+ progressCallback: (UpdateProgressPayload) -> Unit,
54
99
  )
55
100
 
56
101
  /**
@@ -127,6 +172,303 @@ class BundleFileStorageService(
127
172
  private const val TAG = "BundleStorage"
128
173
  }
129
174
 
175
+ private fun emitArchiveProgress(
176
+ progressCallback: (UpdateProgressPayload) -> Unit,
177
+ progress: Double,
178
+ downloadedBytes: Long? = null,
179
+ totalBytes: Long? = null,
180
+ ) {
181
+ progressCallback(
182
+ UpdateProgressPayload(
183
+ progress = progress.coerceIn(0.0, 1.0),
184
+ artifactType = "archive",
185
+ downloadedBytes = downloadedBytes,
186
+ totalBytes = totalBytes,
187
+ ),
188
+ )
189
+ }
190
+
191
+ private fun createDiffProgressFiles(changedAssets: Map<String, ChangedAssetDescriptor>): MutableList<DiffProgressFileSnapshot> =
192
+ changedAssets.keys
193
+ .sorted()
194
+ .mapIndexed { order, path ->
195
+ DiffProgressFileSnapshot(
196
+ path = path,
197
+ downloadPath = path,
198
+ status = "pending",
199
+ progress = 0.0,
200
+ order = order,
201
+ )
202
+ }.toMutableList()
203
+
204
+ private fun updateDiffProgressFile(
205
+ files: MutableList<DiffProgressFileSnapshot>,
206
+ assetPath: String,
207
+ status: String,
208
+ progress: Double,
209
+ downloadPath: String? = null,
210
+ downloadedBytes: Long? = null,
211
+ totalBytes: Long? = null,
212
+ ) {
213
+ val fileIndex = files.indexOfFirst { it.path == assetPath }
214
+ if (fileIndex == -1) {
215
+ return
216
+ }
217
+
218
+ files[fileIndex] =
219
+ files[fileIndex].copy(
220
+ status = status,
221
+ progress = progress.coerceIn(0.0, 1.0),
222
+ downloadPath = downloadPath ?: files[fileIndex].downloadPath,
223
+ downloadedBytes = downloadedBytes ?: files[fileIndex].downloadedBytes,
224
+ totalBytes = totalBytes ?: files[fileIndex].totalBytes,
225
+ )
226
+ }
227
+
228
+ private fun calculateDiffOverallProgress(
229
+ phase: String,
230
+ files: List<DiffProgressFileSnapshot>,
231
+ manifestProgress: Double = 0.0,
232
+ ): Double {
233
+ val normalizedManifestProgress = manifestProgress.coerceIn(0.0, 1.0)
234
+ return when (phase) {
235
+ "manifest" -> {
236
+ normalizedManifestProgress * 0.15
237
+ }
238
+
239
+ "downloading" -> {
240
+ if (files.isEmpty()) {
241
+ 0.92
242
+ } else {
243
+ val completedFilesCount = files.count { it.status == "downloaded" }
244
+ val activeProgressUnits =
245
+ files
246
+ .filter { it.status == "downloading" }
247
+ .sumOf { it.progress.coerceIn(0.0, 1.0) }
248
+ (0.2 + ((completedFilesCount + activeProgressUnits) / files.size) * 0.72)
249
+ .coerceIn(0.2, 0.92)
250
+ }
251
+ }
252
+
253
+ "finalizing" -> {
254
+ 0.97
255
+ }
256
+
257
+ "completed" -> {
258
+ 1.0
259
+ }
260
+
261
+ else -> {
262
+ 0.0
263
+ }
264
+ }
265
+ }
266
+
267
+ private fun emitDiffProgress(
268
+ progressCallback: (UpdateProgressPayload) -> Unit,
269
+ phase: String,
270
+ files: List<DiffProgressFileSnapshot>,
271
+ manifestProgress: Double = 0.0,
272
+ ) {
273
+ progressCallback(
274
+ UpdateProgressPayload(
275
+ progress =
276
+ calculateDiffOverallProgress(
277
+ phase = phase,
278
+ files = files,
279
+ manifestProgress = manifestProgress,
280
+ ),
281
+ artifactType = "diff",
282
+ details =
283
+ DiffProgressDetails(
284
+ totalFilesCount = files.size,
285
+ completedFilesCount = files.count { it.status == "downloaded" },
286
+ files = files.toList(),
287
+ ),
288
+ ),
289
+ )
290
+ }
291
+
292
+ private fun resetDiffProgressFile(
293
+ files: MutableList<DiffProgressFileSnapshot>,
294
+ assetPath: String,
295
+ progressCallback: (UpdateProgressPayload) -> Unit,
296
+ ) {
297
+ updateDiffProgressFile(
298
+ files = files,
299
+ assetPath = assetPath,
300
+ status = "pending",
301
+ progress = 0.0,
302
+ downloadPath = assetPath,
303
+ )
304
+ emitDiffProgress(
305
+ progressCallback = progressCallback,
306
+ phase = "downloading",
307
+ files = files,
308
+ )
309
+ }
310
+
311
+ private fun patchTempFile(
312
+ tempDir: File,
313
+ assetPath: String,
314
+ ): File {
315
+ val safeName = assetPath.replace("/", "__").replace("\\", "__")
316
+ val patchDir = File(tempDir, "patches")
317
+ patchDir.mkdirs()
318
+ return File(patchDir, "$safeName.bsdiff")
319
+ }
320
+
321
+ private fun patchDownloadPath(assetPath: String): String = "$assetPath.bsdiff"
322
+
323
+ private fun downloadPathForChangedAsset(
324
+ assetPath: String,
325
+ changedAsset: ChangedAssetDescriptor,
326
+ ): String =
327
+ if (changedAsset.fileCompression == "br") {
328
+ "$assetPath.br"
329
+ } else {
330
+ assetPath
331
+ }
332
+
333
+ private fun compressedTempFile(
334
+ tempDir: File,
335
+ assetPath: String,
336
+ compression: String,
337
+ ): File {
338
+ val safeName = assetPath.replace("/", "__").replace("\\", "__")
339
+ val compressedDir = File(tempDir, "compressed")
340
+ compressedDir.mkdirs()
341
+ return File(compressedDir, "$safeName.$compression")
342
+ }
343
+
344
+ private fun decompressBrotliFile(
345
+ sourceFile: File,
346
+ targetFile: File,
347
+ ) {
348
+ targetFile.parentFile?.mkdirs()
349
+ val tempOutputFile = File(targetFile.parentFile, "${targetFile.name}.tmp")
350
+
351
+ if (tempOutputFile.exists()) {
352
+ tempOutputFile.delete()
353
+ }
354
+
355
+ try {
356
+ FileInputStream(sourceFile).use { fileInputStream ->
357
+ BrotliInputStream(fileInputStream).use { brotliInputStream ->
358
+ FileOutputStream(tempOutputFile).use { outputStream ->
359
+ brotliInputStream.copyTo(outputStream)
360
+ }
361
+ }
362
+ }
363
+
364
+ if (targetFile.exists()) {
365
+ targetFile.delete()
366
+ }
367
+ if (!tempOutputFile.renameTo(targetFile)) {
368
+ tempOutputFile.copyTo(targetFile, overwrite = true)
369
+ tempOutputFile.delete()
370
+ }
371
+ } catch (error: Throwable) {
372
+ tempOutputFile.delete()
373
+ throw error
374
+ }
375
+ }
376
+
377
+ private suspend fun applyPatchAssetIfPossible(
378
+ assetPath: String,
379
+ changedAsset: ChangedAssetDescriptor,
380
+ currentBundleId: String?,
381
+ activeBundleDir: File?,
382
+ targetFile: File,
383
+ expectedHash: String,
384
+ tempDir: File,
385
+ diffFiles: MutableList<DiffProgressFileSnapshot>,
386
+ progressCallback: (UpdateProgressPayload) -> Unit,
387
+ ): Boolean {
388
+ val patch = changedAsset.patch ?: return false
389
+ if (patch.algorithm != "bsdiff" || currentBundleId != patch.baseBundleId) {
390
+ return false
391
+ }
392
+
393
+ val sourceDir = activeBundleDir ?: return false
394
+ val sourceFile = File(sourceDir, assetPath)
395
+ if (!sourceFile.exists() || !HashUtils.verifyHash(sourceFile, patch.baseFileHash)) {
396
+ return false
397
+ }
398
+
399
+ val patchFile = patchTempFile(tempDir, assetPath)
400
+
401
+ return try {
402
+ when (
403
+ val patchDownloadResult =
404
+ downloadService.downloadFile(
405
+ URL(patch.patchUrl),
406
+ patchFile,
407
+ ) { downloadProgress ->
408
+ updateDiffProgressFile(
409
+ files = diffFiles,
410
+ assetPath = assetPath,
411
+ status = "downloading",
412
+ progress = downloadProgress.progress,
413
+ downloadPath = patchDownloadPath(assetPath),
414
+ downloadedBytes = downloadProgress.downloadedBytes,
415
+ totalBytes = downloadProgress.totalBytes,
416
+ )
417
+ emitDiffProgress(
418
+ progressCallback = progressCallback,
419
+ phase = "downloading",
420
+ files = diffFiles,
421
+ )
422
+ }
423
+ ) {
424
+ is DownloadResult.Error -> {
425
+ false
426
+ }
427
+
428
+ is DownloadResult.Success -> {
429
+ if (!HashUtils.verifyHash(patchDownloadResult.file, patch.patchFileHash)) {
430
+ false
431
+ } else {
432
+ BsdiffPatch.apply(sourceFile, patchDownloadResult.file, targetFile)
433
+ HashUtils.verifyHash(targetFile, expectedHash).also { patched ->
434
+ if (patched) {
435
+ Log.d(
436
+ TAG,
437
+ "HotUpdaterBsdiffPatchApplied asset=$assetPath baseBundleId=${patch.baseBundleId}",
438
+ )
439
+ }
440
+ }
441
+ }
442
+ }
443
+ }
444
+ } catch (_: Exception) {
445
+ false
446
+ } finally {
447
+ patchFile.delete()
448
+ if (!targetFile.exists() || !HashUtils.verifyHash(targetFile, expectedHash)) {
449
+ targetFile.delete()
450
+ }
451
+ }.also { patched ->
452
+ if (!patched) {
453
+ resetDiffProgressFile(
454
+ files = diffFiles,
455
+ assetPath = assetPath,
456
+ progressCallback = progressCallback,
457
+ )
458
+ }
459
+ }
460
+ }
461
+
462
+ private data class ParsedBundleManifest(
463
+ val bundleId: String,
464
+ val assets: Map<String, ParsedManifestAsset>,
465
+ )
466
+
467
+ private data class ParsedManifestAsset(
468
+ val fileHash: String,
469
+ val signature: String?,
470
+ )
471
+
130
472
  private data class ActiveBundleMetadataSnapshot(
131
473
  val activeBundleId: String,
132
474
  val bundleId: String?,
@@ -380,6 +722,138 @@ class BundleFileStorageService(
380
722
  else -> value
381
723
  }
382
724
 
725
+ private fun parseBundleManifestFromMap(manifest: Map<String, Any?>): ParsedBundleManifest? {
726
+ val manifestBundleId =
727
+ (manifest["bundleId"] as? String)
728
+ ?.trim()
729
+ ?.takeIf { it.isNotEmpty() }
730
+ ?: return null
731
+ val assetsValue = manifest["assets"] as? Map<*, *> ?: return null
732
+ val assets = linkedMapOf<String, ParsedManifestAsset>()
733
+
734
+ for ((assetPath, assetValue) in assetsValue) {
735
+ if (assetPath !is String) {
736
+ return null
737
+ }
738
+
739
+ val assetMap = assetValue as? Map<*, *> ?: return null
740
+ val fileHash = assetMap["fileHash"] as? String ?: return null
741
+ val signature = assetMap["signature"] as? String
742
+ assets[assetPath] =
743
+ ParsedManifestAsset(
744
+ fileHash = fileHash,
745
+ signature = signature?.takeIf { it.isNotBlank() },
746
+ )
747
+ }
748
+
749
+ return ParsedBundleManifest(
750
+ bundleId = manifestBundleId,
751
+ assets = assets,
752
+ )
753
+ }
754
+
755
+ private fun parseBundleManifestFromFile(manifestFile: File): ParsedBundleManifest? {
756
+ if (!manifestFile.exists()) {
757
+ return null
758
+ }
759
+
760
+ return try {
761
+ val parsed = JSONObject(manifestFile.readText()).let(::jsonObjectToMap)
762
+ parseBundleManifestFromMap(parsed)
763
+ } catch (e: Exception) {
764
+ Log.w(TAG, "Failed to parse manifest ${manifestFile.absolutePath}: ${e.message}")
765
+ null
766
+ }
767
+ }
768
+
769
+ private fun writeBundleManifestFile(
770
+ destination: File,
771
+ manifest: ParsedBundleManifest,
772
+ ) {
773
+ destination.parentFile?.mkdirs()
774
+ val assetsObject = JSONObject()
775
+
776
+ manifest.assets.toSortedMap().forEach { (assetPath, asset) ->
777
+ val assetObject = JSONObject().put("fileHash", asset.fileHash)
778
+ if (!asset.signature.isNullOrBlank()) {
779
+ assetObject.put("signature", asset.signature)
780
+ }
781
+ assetsObject.put(
782
+ assetPath,
783
+ assetObject,
784
+ )
785
+ }
786
+
787
+ val manifestObject =
788
+ JSONObject()
789
+ .put("bundleId", manifest.bundleId)
790
+ .put("assets", assetsObject)
791
+
792
+ destination.writeText("${manifestObject}\n")
793
+ }
794
+
795
+ private fun getActiveBundleDir(): File? {
796
+ val activeBundleId = getActiveBundleId() ?: return null
797
+ val bundleDir = File(getBundleStoreDir(), activeBundleId)
798
+ return bundleDir.takeIf { it.exists() }
799
+ }
800
+
801
+ private fun canUseManifestDrivenInstall(): Boolean {
802
+ val activeBundleDir = getActiveBundleDir() ?: return false
803
+ if (!activeBundleDir.exists()) {
804
+ return false
805
+ }
806
+
807
+ val currentManifest =
808
+ getActiveBundleMetadataSnapshot()
809
+ ?.manifest
810
+ ?.let(::parseBundleManifestFromMap) ?: return false
811
+
812
+ return currentManifest.assets.isNotEmpty()
813
+ }
814
+
815
+ private fun copyBundleFile(
816
+ source: File,
817
+ destination: File,
818
+ ) {
819
+ destination.parentFile?.mkdirs()
820
+ source.copyTo(destination, overwrite = true)
821
+ }
822
+
823
+ private fun verifyManifestAssetFile(
824
+ file: File,
825
+ asset: ParsedManifestAsset,
826
+ ) {
827
+ val actualHash =
828
+ HashUtils.calculateSHA256(file)
829
+ ?: throw SignatureVerificationException.FileReadFailed()
830
+
831
+ if (!actualHash.equals(asset.fileHash, ignoreCase = true)) {
832
+ throw SignatureVerificationException.FileHashMismatch()
833
+ }
834
+
835
+ if (!SignatureVerifier.isSigningEnabled(context)) {
836
+ return
837
+ }
838
+
839
+ val signature =
840
+ asset.signature
841
+ ?: throw SignatureVerificationException.InvalidSignatureFormat()
842
+
843
+ SignatureVerifier.verifyHashSignature(context, asset.fileHash, signature)
844
+ }
845
+
846
+ private fun verifyManifestAssetFileOrThrow(
847
+ file: File,
848
+ asset: ParsedManifestAsset,
849
+ ) {
850
+ try {
851
+ verifyManifestAssetFile(file, asset)
852
+ } catch (e: SignatureVerificationException) {
853
+ throw HotUpdaterException.signatureVerificationFailed(e)
854
+ }
855
+ }
856
+
383
857
  /**
384
858
  * Checks if isolationKey has changed and cleans up old bundles if needed.
385
859
  * This handles migration when isolationKey format changes.
@@ -639,11 +1113,14 @@ class BundleFileStorageService(
639
1113
  bundleId: String,
640
1114
  fileUrl: String?,
641
1115
  fileHash: String?,
642
- progressCallback: (Double) -> Unit,
1116
+ manifestUrl: String?,
1117
+ manifestFileHash: String?,
1118
+ changedAssets: Map<String, ChangedAssetDescriptor>?,
1119
+ progressCallback: (UpdateProgressPayload) -> Unit,
643
1120
  ) {
644
1121
  Log.d(
645
1122
  TAG,
646
- "updateBundle bundleId $bundleId fileUrl $fileUrl fileHash $fileHash",
1123
+ "updateBundle bundleId $bundleId fileUrl $fileUrl fileHash $fileHash manifestUrl $manifestUrl",
647
1124
  )
648
1125
 
649
1126
  // If no URL is provided, reset to fallback and clean up all bundles
@@ -721,6 +1198,40 @@ class BundleFileStorageService(
721
1198
  }
722
1199
  }
723
1200
 
1201
+ val hasManifestDrivenArtifacts =
1202
+ !manifestUrl.isNullOrEmpty() &&
1203
+ !manifestFileHash.isNullOrEmpty() &&
1204
+ changedAssets != null
1205
+
1206
+ if (hasManifestDrivenArtifacts && canUseManifestDrivenInstall()) {
1207
+ try {
1208
+ updateBundleFromManifest(
1209
+ bundleId = bundleId,
1210
+ manifestUrl = manifestUrl!!,
1211
+ manifestFileHash = manifestFileHash!!,
1212
+ changedAssets = changedAssets!!,
1213
+ bundleStoreDir = bundleStoreDir,
1214
+ finalBundleDir = finalBundleDir,
1215
+ progressCallback = progressCallback,
1216
+ )
1217
+ return
1218
+ } catch (e: Exception) {
1219
+ if (fileUrl.isNullOrEmpty()) {
1220
+ throw e
1221
+ }
1222
+ Log.w(
1223
+ TAG,
1224
+ "Manifest-driven install failed for $bundleId. Falling back to archive: ${e.message}",
1225
+ e,
1226
+ )
1227
+ }
1228
+ } else if (hasManifestDrivenArtifacts) {
1229
+ Log.d(
1230
+ TAG,
1231
+ "Skipping manifest-driven install for $bundleId because no active OTA manifest is available. Using archive.",
1232
+ )
1233
+ }
1234
+
724
1235
  val tempDirName = "bundle-temp"
725
1236
  val tempDir = File(baseDir, tempDirName)
726
1237
  if (tempDir.exists()) {
@@ -772,7 +1283,12 @@ class BundleFileStorageService(
772
1283
  },
773
1284
  ) { downloadProgress ->
774
1285
  // Map download progress to 0.0 - 0.8
775
- progressCallback(downloadProgress * 0.8)
1286
+ emitArchiveProgress(
1287
+ progressCallback,
1288
+ downloadProgress.progress * 0.8,
1289
+ downloadProgress.downloadedBytes,
1290
+ downloadProgress.totalBytes,
1291
+ )
776
1292
  }
777
1293
 
778
1294
  // Check for disk space error first before processing download result
@@ -827,7 +1343,10 @@ class BundleFileStorageService(
827
1343
  tmpDir.absolutePath,
828
1344
  ) { unzipProgress ->
829
1345
  // Map unzip progress (0.0 - 1.0) to overall progress (0.8 - 1.0)
830
- progressCallback(0.8 + (unzipProgress * 0.2))
1346
+ emitArchiveProgress(
1347
+ progressCallback,
1348
+ 0.8 + (unzipProgress * 0.2),
1349
+ )
831
1350
  }
832
1351
  ) {
833
1352
  Log.d("BundleStorage", "Failed to extract archive into tmpDir.")
@@ -916,6 +1435,367 @@ class BundleFileStorageService(
916
1435
  /**
917
1436
  * Removes old bundles except for the specified bundle IDs, and any leftover .tmp directories
918
1437
  */
1438
+ private suspend fun updateBundleFromManifest(
1439
+ bundleId: String,
1440
+ manifestUrl: String,
1441
+ manifestFileHash: String,
1442
+ changedAssets: Map<String, ChangedAssetDescriptor>,
1443
+ bundleStoreDir: File,
1444
+ finalBundleDir: File,
1445
+ progressCallback: (UpdateProgressPayload) -> Unit,
1446
+ ) {
1447
+ val activeBundleDir = getActiveBundleDir()
1448
+ val currentBundleId = getBundleId()
1449
+ val currentManifest =
1450
+ getActiveBundleMetadataSnapshot()?.manifest?.let(::parseBundleManifestFromMap)
1451
+ val baseDir =
1452
+ fileSystem.getInternalFilesDir() ?: bundleStoreDir.parentFile ?: bundleStoreDir
1453
+ val tempDir = File(baseDir, "bundle-manifest-temp")
1454
+ val tmpDir = File(bundleStoreDir, "$bundleId.tmp")
1455
+
1456
+ if (tempDir.exists()) {
1457
+ tempDir.deleteRecursively()
1458
+ }
1459
+ tempDir.mkdirs()
1460
+
1461
+ val diffFiles = createDiffProgressFiles(changedAssets)
1462
+
1463
+ try {
1464
+ val manifestFile = File(tempDir, "manifest.json")
1465
+ emitDiffProgress(
1466
+ progressCallback = progressCallback,
1467
+ phase = "manifest",
1468
+ files = diffFiles,
1469
+ manifestProgress = 0.0,
1470
+ )
1471
+ when (
1472
+ val manifestDownloadResult =
1473
+ downloadService.downloadFile(
1474
+ URL(manifestUrl),
1475
+ manifestFile,
1476
+ ) { downloadProgress ->
1477
+ emitDiffProgress(
1478
+ progressCallback = progressCallback,
1479
+ phase = "manifest",
1480
+ files = diffFiles,
1481
+ manifestProgress = downloadProgress.progress,
1482
+ )
1483
+ }
1484
+ ) {
1485
+ is DownloadResult.Error -> {
1486
+ if (manifestDownloadResult.exception is IncompleteDownloadException) {
1487
+ val incompleteEx =
1488
+ manifestDownloadResult.exception as IncompleteDownloadException
1489
+ throw HotUpdaterException.incompleteDownload(
1490
+ incompleteEx.expectedSize,
1491
+ incompleteEx.actualSize,
1492
+ )
1493
+ }
1494
+ throw HotUpdaterException.downloadFailed(manifestDownloadResult.exception)
1495
+ }
1496
+
1497
+ is DownloadResult.Success -> {
1498
+ Unit
1499
+ }
1500
+ }
1501
+
1502
+ try {
1503
+ SignatureVerifier.verifyBundle(context, manifestFile, manifestFileHash)
1504
+ } catch (e: SignatureVerificationException) {
1505
+ throw HotUpdaterException.signatureVerificationFailed(e)
1506
+ }
1507
+
1508
+ val targetManifest = parseBundleManifestFromFile(manifestFile) ?: throw HotUpdaterException.invalidBundle()
1509
+ if (targetManifest.bundleId != bundleId) {
1510
+ throw HotUpdaterException.invalidBundle()
1511
+ }
1512
+ emitDiffProgress(
1513
+ progressCallback = progressCallback,
1514
+ phase = if (diffFiles.isEmpty()) "finalizing" else "downloading",
1515
+ files = diffFiles,
1516
+ )
1517
+
1518
+ if (tmpDir.exists()) {
1519
+ tmpDir.deleteRecursively()
1520
+ }
1521
+ tmpDir.mkdirs()
1522
+
1523
+ val targetEntries = targetManifest.assets.entries.toList()
1524
+ targetEntries.forEachIndexed { index, (assetPath, expectedAsset) ->
1525
+ val expectedHash = expectedAsset.fileHash
1526
+ val targetFile = File(tmpDir, assetPath)
1527
+ val currentAsset = currentManifest?.assets?.get(assetPath)
1528
+
1529
+ if (currentAsset?.fileHash == expectedHash) {
1530
+ val sourceDir =
1531
+ activeBundleDir
1532
+ ?: throw HotUpdaterException.downloadFailed(
1533
+ IllegalStateException("Current bundle directory unavailable for reused asset: $assetPath"),
1534
+ )
1535
+ val sourceFile = File(sourceDir, assetPath)
1536
+ if (!sourceFile.exists() || !HashUtils.verifyHash(sourceFile, expectedHash)) {
1537
+ throw HotUpdaterException.downloadFailed(
1538
+ IllegalStateException("Reusable asset missing or corrupted: $assetPath"),
1539
+ )
1540
+ }
1541
+ copyBundleFile(sourceFile, targetFile)
1542
+ verifyManifestAssetFileOrThrow(targetFile, expectedAsset)
1543
+ return@forEachIndexed
1544
+ }
1545
+
1546
+ val changedAsset =
1547
+ changedAssets[assetPath]
1548
+ ?: run {
1549
+ updateDiffProgressFile(
1550
+ files = diffFiles,
1551
+ assetPath = assetPath,
1552
+ status = "failed",
1553
+ progress = 0.0,
1554
+ )
1555
+ emitDiffProgress(
1556
+ progressCallback = progressCallback,
1557
+ phase = "downloading",
1558
+ files = diffFiles,
1559
+ )
1560
+ throw HotUpdaterException.downloadFailed(
1561
+ IllegalStateException("Changed asset missing from update response: $assetPath"),
1562
+ )
1563
+ }
1564
+
1565
+ if (!changedAsset.fileHash.equals(expectedHash, ignoreCase = true)) {
1566
+ updateDiffProgressFile(
1567
+ files = diffFiles,
1568
+ assetPath = assetPath,
1569
+ status = "failed",
1570
+ progress = 0.0,
1571
+ )
1572
+ emitDiffProgress(
1573
+ progressCallback = progressCallback,
1574
+ phase = "downloading",
1575
+ files = diffFiles,
1576
+ )
1577
+ throw HotUpdaterException.signatureVerificationFailed(
1578
+ SignatureVerificationException.FileHashMismatch(),
1579
+ )
1580
+ }
1581
+
1582
+ val patched =
1583
+ applyPatchAssetIfPossible(
1584
+ assetPath = assetPath,
1585
+ changedAsset = changedAsset,
1586
+ currentBundleId = currentBundleId,
1587
+ activeBundleDir = activeBundleDir,
1588
+ targetFile = targetFile,
1589
+ expectedHash = expectedHash,
1590
+ tempDir = tempDir,
1591
+ diffFiles = diffFiles,
1592
+ progressCallback = progressCallback,
1593
+ )
1594
+ if (patched) {
1595
+ verifyManifestAssetFileOrThrow(targetFile, expectedAsset)
1596
+ updateDiffProgressFile(
1597
+ files = diffFiles,
1598
+ assetPath = assetPath,
1599
+ status = "downloaded",
1600
+ progress = 1.0,
1601
+ )
1602
+ emitDiffProgress(
1603
+ progressCallback = progressCallback,
1604
+ phase =
1605
+ if (diffFiles.all { it.status == "downloaded" }) {
1606
+ "finalizing"
1607
+ } else {
1608
+ "downloading"
1609
+ },
1610
+ files = diffFiles,
1611
+ )
1612
+ return@forEachIndexed
1613
+ }
1614
+
1615
+ val changedAssetFileUrl =
1616
+ changedAsset.fileUrl
1617
+ ?: run {
1618
+ updateDiffProgressFile(
1619
+ files = diffFiles,
1620
+ assetPath = assetPath,
1621
+ status = "failed",
1622
+ progress = 0.0,
1623
+ )
1624
+ emitDiffProgress(
1625
+ progressCallback = progressCallback,
1626
+ phase = "downloading",
1627
+ files = diffFiles,
1628
+ )
1629
+ throw HotUpdaterException.downloadFailed(
1630
+ IllegalStateException("Changed asset fileUrl missing and patch could not be applied: $assetPath"),
1631
+ )
1632
+ }
1633
+
1634
+ val downloadPath = downloadPathForChangedAsset(assetPath, changedAsset)
1635
+ val downloadedAssetFile =
1636
+ if (changedAsset.fileCompression == "br") {
1637
+ compressedTempFile(tempDir, assetPath, "br")
1638
+ } else {
1639
+ targetFile
1640
+ }
1641
+
1642
+ when (
1643
+ val assetDownloadResult =
1644
+ downloadService.downloadFile(
1645
+ URL(changedAssetFileUrl),
1646
+ downloadedAssetFile,
1647
+ ) { downloadProgress ->
1648
+ updateDiffProgressFile(
1649
+ files = diffFiles,
1650
+ assetPath = assetPath,
1651
+ status = "downloading",
1652
+ progress = downloadProgress.progress,
1653
+ downloadPath = downloadPath,
1654
+ downloadedBytes = downloadProgress.downloadedBytes,
1655
+ totalBytes = downloadProgress.totalBytes,
1656
+ )
1657
+ emitDiffProgress(
1658
+ progressCallback = progressCallback,
1659
+ phase = "downloading",
1660
+ files = diffFiles,
1661
+ )
1662
+ }
1663
+ ) {
1664
+ is DownloadResult.Error -> {
1665
+ updateDiffProgressFile(
1666
+ files = diffFiles,
1667
+ assetPath = assetPath,
1668
+ status = "failed",
1669
+ progress =
1670
+ diffFiles
1671
+ .firstOrNull { it.path == assetPath }
1672
+ ?.progress ?: 0.0,
1673
+ )
1674
+ emitDiffProgress(
1675
+ progressCallback = progressCallback,
1676
+ phase = "downloading",
1677
+ files = diffFiles,
1678
+ )
1679
+ if (assetDownloadResult.exception is IncompleteDownloadException) {
1680
+ val incompleteEx =
1681
+ assetDownloadResult.exception as IncompleteDownloadException
1682
+ throw HotUpdaterException.incompleteDownload(
1683
+ incompleteEx.expectedSize,
1684
+ incompleteEx.actualSize,
1685
+ )
1686
+ }
1687
+ throw HotUpdaterException.downloadFailed(assetDownloadResult.exception)
1688
+ }
1689
+
1690
+ is DownloadResult.Success -> {
1691
+ try {
1692
+ if (changedAsset.fileCompression == "br") {
1693
+ decompressBrotliFile(
1694
+ assetDownloadResult.file,
1695
+ targetFile,
1696
+ )
1697
+ }
1698
+ verifyManifestAssetFileOrThrow(
1699
+ targetFile,
1700
+ expectedAsset,
1701
+ )
1702
+ } catch (e: Exception) {
1703
+ updateDiffProgressFile(
1704
+ files = diffFiles,
1705
+ assetPath = assetPath,
1706
+ status = "failed",
1707
+ progress = 1.0,
1708
+ )
1709
+ emitDiffProgress(
1710
+ progressCallback = progressCallback,
1711
+ phase = "downloading",
1712
+ files = diffFiles,
1713
+ )
1714
+ if (e is HotUpdaterException) {
1715
+ throw e
1716
+ }
1717
+ throw HotUpdaterException.downloadFailed(e)
1718
+ } finally {
1719
+ if (changedAsset.fileCompression == "br") {
1720
+ assetDownloadResult.file.delete()
1721
+ }
1722
+ }
1723
+ updateDiffProgressFile(
1724
+ files = diffFiles,
1725
+ assetPath = assetPath,
1726
+ status = "downloaded",
1727
+ progress = 1.0,
1728
+ )
1729
+ emitDiffProgress(
1730
+ progressCallback = progressCallback,
1731
+ phase = if (diffFiles.all { it.status == "downloaded" }) "finalizing" else "downloading",
1732
+ files = diffFiles,
1733
+ )
1734
+ }
1735
+ }
1736
+ }
1737
+
1738
+ emitDiffProgress(
1739
+ progressCallback = progressCallback,
1740
+ phase = "finalizing",
1741
+ files = diffFiles,
1742
+ )
1743
+
1744
+ writeBundleManifestFile(File(tmpDir, "manifest.json"), targetManifest)
1745
+
1746
+ val extractedIndex = tmpDir.walk().find { it.name == "index.android.bundle" }
1747
+ if (extractedIndex == null) {
1748
+ throw HotUpdaterException.invalidBundle()
1749
+ }
1750
+
1751
+ if (finalBundleDir.exists()) {
1752
+ finalBundleDir.deleteRecursively()
1753
+ }
1754
+
1755
+ val renamed = tmpDir.renameTo(finalBundleDir)
1756
+ if (!renamed) {
1757
+ if (!fileSystem.moveItem(tmpDir.absolutePath, finalBundleDir.absolutePath)) {
1758
+ if (!fileSystem.copyItem(tmpDir.absolutePath, finalBundleDir.absolutePath)) {
1759
+ throw HotUpdaterException.moveOperationFailed()
1760
+ }
1761
+ tmpDir.deleteRecursively()
1762
+ }
1763
+ }
1764
+
1765
+ val finalIndexFile = finalBundleDir.walk().find { it.name == "index.android.bundle" }
1766
+ if (finalIndexFile == null) {
1767
+ finalBundleDir.deleteRecursively()
1768
+ throw HotUpdaterException.invalidBundle()
1769
+ }
1770
+
1771
+ finalBundleDir.setLastModified(System.currentTimeMillis())
1772
+
1773
+ val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
1774
+ val updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
1775
+ saveMetadata(updatedMetadata)
1776
+ setBundleURL(finalIndexFile.absolutePath)
1777
+
1778
+ tempDir.deleteRecursively()
1779
+ cleanupOldBundles(bundleStoreDir, updatedMetadata.stableBundleId, bundleId)
1780
+ progressCallback(
1781
+ UpdateProgressPayload(
1782
+ progress = 1.0,
1783
+ artifactType = "diff",
1784
+ details =
1785
+ DiffProgressDetails(
1786
+ totalFilesCount = diffFiles.size,
1787
+ completedFilesCount = diffFiles.count { it.status == "downloaded" },
1788
+ files = diffFiles.toList(),
1789
+ ),
1790
+ ),
1791
+ )
1792
+ } catch (e: Exception) {
1793
+ tempDir.deleteRecursively()
1794
+ tmpDir.deleteRecursively()
1795
+ throw e
1796
+ }
1797
+ }
1798
+
919
1799
  private fun cleanupOldBundles(
920
1800
  bundleStoreDir: File,
921
1801
  currentBundleId: String?,