@hot-updater/react-native 1.0.0-rc.14 → 1.0.0-rc.16

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 (202) hide show
  1. package/android/proguard-rules.pro +2 -2
  2. package/android/src/main/java/com/hotupdater/BuiltInAssetResolver.kt +286 -0
  3. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +650 -636
  4. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +3 -0
  5. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +0 -13
  6. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +10 -14
  7. package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +19 -0
  8. package/android/src/main/java/com/hotupdater/TarArchiveInputStream.kt +214 -375
  9. package/android/src/main/java/com/hotupdater/TarBrArchiveExtractor.kt +119 -0
  10. package/android/src/newarch/HotUpdater.kt +7 -9
  11. package/android/src/newarch/HotUpdaterModule.kt +40 -20
  12. package/android/src/oldarch/HotUpdater.kt +7 -9
  13. package/android/src/oldarch/HotUpdaterModule.kt +40 -20
  14. package/android/src/test/java/com/hotupdater/BundleFileStorageServiceTest.kt +1126 -74
  15. package/android/src/test/java/com/hotupdater/HotUpdaterImplTest.kt +4 -5
  16. package/android/src/test/java/com/hotupdater/TarBrArchiveExtractorTest.kt +206 -0
  17. package/ios/HotUpdater/Internal/BrotliFileDecompressor.swift +167 -0
  18. package/ios/HotUpdater/Internal/BuiltInAssetResolver.swift +196 -0
  19. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +773 -1016
  20. package/ios/HotUpdater/Internal/BundleMetadata.swift +5 -0
  21. package/ios/HotUpdater/Internal/FileUtilities.swift +64 -0
  22. package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +0 -1
  23. package/ios/HotUpdater/Internal/HotUpdater.mm +5 -27
  24. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +47 -31
  25. package/ios/HotUpdater/Internal/NotificationExtension.swift +0 -2
  26. package/ios/HotUpdater/Internal/TarArchiveExtractor.swift +182 -311
  27. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +13 -72
  28. package/lib/commonjs/checkForUpdate.js +7 -10
  29. package/lib/commonjs/checkForUpdate.js.map +1 -1
  30. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js +9 -4
  31. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js.map +1 -1
  32. package/lib/commonjs/error.js +4 -13
  33. package/lib/commonjs/error.js.map +1 -1
  34. package/lib/commonjs/fetchJSON.js +9 -2
  35. package/lib/commonjs/fetchJSON.js.map +1 -1
  36. package/lib/commonjs/httpClient.js +151 -39
  37. package/lib/commonjs/httpClient.js.map +1 -1
  38. package/lib/commonjs/httpClient.spec.js +247 -5
  39. package/lib/commonjs/httpClient.spec.js.map +1 -1
  40. package/lib/commonjs/index.js +15 -8
  41. package/lib/commonjs/index.js.map +1 -1
  42. package/lib/commonjs/index.spec.js +101 -0
  43. package/lib/commonjs/index.spec.js.map +1 -1
  44. package/lib/commonjs/native.js +7 -8
  45. package/lib/commonjs/native.js.map +1 -1
  46. package/lib/commonjs/native.spec.js +21 -16
  47. package/lib/commonjs/native.spec.js.map +1 -1
  48. package/lib/commonjs/notifyAppReadyInsights.boundary.spec.js +7 -0
  49. package/lib/commonjs/notifyAppReadyInsights.boundary.spec.js.map +1 -1
  50. package/lib/commonjs/notifyAppReadyInsights.js +4 -1
  51. package/lib/commonjs/notifyAppReadyInsights.js.map +1 -1
  52. package/lib/commonjs/notifyAppReadyInsights.spec.js +59 -0
  53. package/lib/commonjs/notifyAppReadyInsights.spec.js.map +1 -1
  54. package/lib/commonjs/notifyAppReadyInsights.transport.spec.js +55 -1
  55. package/lib/commonjs/notifyAppReadyInsights.transport.spec.js.map +1 -1
  56. package/lib/commonjs/sdkVersion.js +1 -1
  57. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  58. package/lib/commonjs/store.js +3 -10
  59. package/lib/commonjs/store.js.map +1 -1
  60. package/lib/commonjs/store.notification.spec.js +12 -4
  61. package/lib/commonjs/store.notification.spec.js.map +1 -1
  62. package/lib/commonjs/store.spec.js +2 -136
  63. package/lib/commonjs/store.spec.js.map +1 -1
  64. package/lib/commonjs/uuidv7.js +41 -0
  65. package/lib/commonjs/uuidv7.js.map +1 -0
  66. package/lib/commonjs/uuidv7.spec.js +30 -0
  67. package/lib/commonjs/uuidv7.spec.js.map +1 -0
  68. package/lib/commonjs/wrap.js +1 -3
  69. package/lib/commonjs/wrap.js.map +1 -1
  70. package/lib/commonjs/wrap.spec.js +7 -0
  71. package/lib/commonjs/wrap.spec.js.map +1 -1
  72. package/lib/module/checkForUpdate.js +7 -10
  73. package/lib/module/checkForUpdate.js.map +1 -1
  74. package/lib/module/checkForUpdate.releaseCatalog.spec.js +9 -4
  75. package/lib/module/checkForUpdate.releaseCatalog.spec.js.map +1 -1
  76. package/lib/module/error.js +4 -13
  77. package/lib/module/error.js.map +1 -1
  78. package/lib/module/fetchJSON.js +7 -1
  79. package/lib/module/fetchJSON.js.map +1 -1
  80. package/lib/module/httpClient.js +153 -41
  81. package/lib/module/httpClient.js.map +1 -1
  82. package/lib/module/httpClient.spec.js +248 -6
  83. package/lib/module/httpClient.spec.js.map +1 -1
  84. package/lib/module/index.js +15 -8
  85. package/lib/module/index.js.map +1 -1
  86. package/lib/module/index.spec.js +102 -1
  87. package/lib/module/index.spec.js.map +1 -1
  88. package/lib/module/native.js +7 -8
  89. package/lib/module/native.js.map +1 -1
  90. package/lib/module/native.spec.js +21 -16
  91. package/lib/module/native.spec.js.map +1 -1
  92. package/lib/module/notifyAppReadyInsights.boundary.spec.js +7 -0
  93. package/lib/module/notifyAppReadyInsights.boundary.spec.js.map +1 -1
  94. package/lib/module/notifyAppReadyInsights.js +5 -2
  95. package/lib/module/notifyAppReadyInsights.js.map +1 -1
  96. package/lib/module/notifyAppReadyInsights.spec.js +59 -0
  97. package/lib/module/notifyAppReadyInsights.spec.js.map +1 -1
  98. package/lib/module/notifyAppReadyInsights.transport.spec.js +55 -1
  99. package/lib/module/notifyAppReadyInsights.transport.spec.js.map +1 -1
  100. package/lib/module/sdkVersion.js +1 -1
  101. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  102. package/lib/module/store.js +3 -10
  103. package/lib/module/store.js.map +1 -1
  104. package/lib/module/store.notification.spec.js +12 -4
  105. package/lib/module/store.notification.spec.js.map +1 -1
  106. package/lib/module/store.spec.js +2 -136
  107. package/lib/module/store.spec.js.map +1 -1
  108. package/lib/module/uuidv7.js +36 -0
  109. package/lib/module/uuidv7.js.map +1 -0
  110. package/lib/module/uuidv7.spec.js +30 -0
  111. package/lib/module/uuidv7.spec.js.map +1 -0
  112. package/lib/module/wrap.js +1 -3
  113. package/lib/module/wrap.js.map +1 -1
  114. package/lib/module/wrap.spec.js +7 -0
  115. package/lib/module/wrap.spec.js.map +1 -1
  116. package/lib/typescript/commonjs/checkForUpdate.d.ts +1 -2
  117. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  118. package/lib/typescript/commonjs/error.d.ts +4 -13
  119. package/lib/typescript/commonjs/error.d.ts.map +1 -1
  120. package/lib/typescript/commonjs/fetchJSON.d.ts +4 -0
  121. package/lib/typescript/commonjs/fetchJSON.d.ts.map +1 -1
  122. package/lib/typescript/commonjs/httpClient.d.ts +6 -0
  123. package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
  124. package/lib/typescript/commonjs/index.d.ts +0 -2
  125. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  126. package/lib/typescript/commonjs/native.d.ts +1 -6
  127. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  128. package/lib/typescript/commonjs/notifyAppReadyInsights.d.ts +1 -1
  129. package/lib/typescript/commonjs/notifyAppReadyInsights.d.ts.map +1 -1
  130. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  131. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +8 -35
  132. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  133. package/lib/typescript/commonjs/store.d.ts +1 -3
  134. package/lib/typescript/commonjs/store.d.ts.map +1 -1
  135. package/lib/typescript/commonjs/uuidv7.d.ts +10 -0
  136. package/lib/typescript/commonjs/uuidv7.d.ts.map +1 -0
  137. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  138. package/lib/typescript/commonjs/wrap.types.d.ts +7 -4
  139. package/lib/typescript/commonjs/wrap.types.d.ts.map +1 -1
  140. package/lib/typescript/module/checkForUpdate.d.ts +1 -2
  141. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  142. package/lib/typescript/module/error.d.ts +4 -13
  143. package/lib/typescript/module/error.d.ts.map +1 -1
  144. package/lib/typescript/module/fetchJSON.d.ts +4 -0
  145. package/lib/typescript/module/fetchJSON.d.ts.map +1 -1
  146. package/lib/typescript/module/httpClient.d.ts +6 -0
  147. package/lib/typescript/module/httpClient.d.ts.map +1 -1
  148. package/lib/typescript/module/index.d.ts +0 -2
  149. package/lib/typescript/module/index.d.ts.map +1 -1
  150. package/lib/typescript/module/native.d.ts +1 -6
  151. package/lib/typescript/module/native.d.ts.map +1 -1
  152. package/lib/typescript/module/notifyAppReadyInsights.d.ts +1 -1
  153. package/lib/typescript/module/notifyAppReadyInsights.d.ts.map +1 -1
  154. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  155. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +8 -35
  156. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  157. package/lib/typescript/module/store.d.ts +1 -3
  158. package/lib/typescript/module/store.d.ts.map +1 -1
  159. package/lib/typescript/module/uuidv7.d.ts +10 -0
  160. package/lib/typescript/module/uuidv7.d.ts.map +1 -0
  161. package/lib/typescript/module/wrap.d.ts.map +1 -1
  162. package/lib/typescript/module/wrap.types.d.ts +7 -4
  163. package/lib/typescript/module/wrap.types.d.ts.map +1 -1
  164. package/package.json +3 -3
  165. package/src/checkForUpdate.releaseCatalog.spec.ts +10 -4
  166. package/src/checkForUpdate.ts +5 -11
  167. package/src/error.ts +4 -14
  168. package/src/fetchJSON.ts +12 -1
  169. package/src/httpClient.spec.ts +289 -8
  170. package/src/httpClient.ts +224 -67
  171. package/src/index.spec.ts +70 -1
  172. package/src/index.ts +18 -7
  173. package/src/native.spec.ts +23 -16
  174. package/src/native.ts +12 -24
  175. package/src/notifyAppReadyInsights.boundary.spec.ts +8 -0
  176. package/src/notifyAppReadyInsights.spec.ts +60 -0
  177. package/src/notifyAppReadyInsights.transport.spec.ts +66 -0
  178. package/src/notifyAppReadyInsights.ts +13 -1
  179. package/src/sdkVersion.ts +1 -1
  180. package/src/specs/NativeHotUpdater.ts +8 -36
  181. package/src/store.notification.spec.ts +13 -3
  182. package/src/store.spec.ts +0 -157
  183. package/src/store.ts +2 -23
  184. package/src/uuidv7.spec.ts +25 -0
  185. package/src/uuidv7.ts +38 -0
  186. package/src/wrap.spec.ts +8 -0
  187. package/src/wrap.tsx +0 -2
  188. package/src/wrap.types.ts +9 -4
  189. package/android/src/main/java/com/hotupdater/DecompressService.kt +0 -89
  190. package/android/src/main/java/com/hotupdater/DecompressionStrategy.kt +0 -26
  191. package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +0 -117
  192. package/android/src/main/java/com/hotupdater/TarGzDecompressionStrategy.kt +0 -117
  193. package/android/src/main/java/com/hotupdater/ZipDecompressionStrategy.kt +0 -192
  194. package/android/src/test/java/com/hotupdater/TarArchiveInputStreamTest.kt +0 -68
  195. package/ios/HotUpdater/Internal/ArchiveExtractionUtilities.swift +0 -178
  196. package/ios/HotUpdater/Internal/DecompressService.swift +0 -112
  197. package/ios/HotUpdater/Internal/DecompressionStrategy.swift +0 -22
  198. package/ios/HotUpdater/Internal/StreamingTarArchiveExtractor.swift +0 -370
  199. package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +0 -39
  200. package/ios/HotUpdater/Internal/TarGzDecompressionStrategy.swift +0 -59
  201. package/ios/HotUpdater/Internal/ZipArchiveExtractor.swift +0 -482
  202. package/ios/HotUpdater/Internal/ZipDecompressionStrategy.swift +0 -56
@@ -1,9 +1,15 @@
1
1
  package com.hotupdater
2
2
 
3
- import android.os.StatFs
4
3
  import android.util.Log
5
4
  import com.hotupdater.vendor.brotli.dec.BrotliInputStream
6
5
  import kotlinx.coroutines.Dispatchers
6
+ import kotlinx.coroutines.async
7
+ import kotlinx.coroutines.awaitAll
8
+ import kotlinx.coroutines.coroutineScope
9
+ import kotlinx.coroutines.sync.Mutex
10
+ import kotlinx.coroutines.sync.Semaphore
11
+ import kotlinx.coroutines.sync.withLock
12
+ import kotlinx.coroutines.sync.withPermit
7
13
  import kotlinx.coroutines.withContext
8
14
  import org.json.JSONObject
9
15
  import java.io.File
@@ -25,13 +31,12 @@ data class BsdiffPatchDescriptor(
25
31
  val baseFileHash: String,
26
32
  val patchFileHash: String,
27
33
  val patchUrl: String,
34
+ val byteSize: Long? = null,
28
35
  )
29
36
 
30
37
  data class UpdateProgressPayload(
31
38
  val progress: Double,
32
39
  val artifactType: String,
33
- val downloadedBytes: Long? = null,
34
- val totalBytes: Long? = null,
35
40
  val details: DiffProgressDetails? = null,
36
41
  )
37
42
 
@@ -84,18 +89,17 @@ interface BundleStorageService {
84
89
  /**
85
90
  * Updates the bundle from the specified URL
86
91
  * @param bundleId ID of the bundle to update
87
- * @param fileUrl URL of the bundle file to download (or null to reset)
88
- * @param fileHash Combined hash string for verification (sig:<signature> or <hex_hash>)
92
+ * @param manifestUrl URL of the target bundle manifest
93
+ * @param manifestFileHash Manifest hash or signature
89
94
  * @param progressCallback Callback for download progress updates
90
95
  * @throws HotUpdaterException if the update fails
91
96
  */
92
97
  suspend fun updateBundle(
93
98
  bundleId: String,
94
- fileUrl: String?,
95
- fileHash: String?,
96
- manifestUrl: String?,
97
- manifestFileHash: String?,
98
- changedAssets: Map<String, ChangedAssetDescriptor>?,
99
+ manifestUrl: String,
100
+ manifestFileHash: String,
101
+ assets: Map<String, ChangedAssetDescriptor>,
102
+ archiveUrl: String? = null,
99
103
  progressCallback: (UpdateProgressPayload) -> Unit,
100
104
  )
101
105
 
@@ -183,7 +187,6 @@ interface BundleStorageService {
183
187
 
184
188
  /**
185
189
  * Gets the current active bundle ID from bundle storage.
186
- * Reads manifest.json first and falls back to older metadata when needed.
187
190
  */
188
191
  fun getBundleId(): String?
189
192
 
@@ -213,33 +216,31 @@ class BundleFileStorageService(
213
216
  private val context: android.content.Context,
214
217
  private val fileSystem: FileSystemService,
215
218
  private val downloadService: DownloadService,
216
- private val decompressService: DecompressService,
217
219
  private val preferences: PreferencesService,
218
220
  private val isolationKey: String,
219
221
  private val defaultChannelProvider: () -> String = { HotUpdaterImpl.getChannel(context) },
222
+ builtInAssetResolver: BuiltInAssetResolver? = null,
223
+ private val metadataWriter: (BundleMetadata, File) -> Boolean = { metadata, file ->
224
+ metadata.saveToFile(file)
225
+ },
226
+ private val directoryRenamer: (File, File) -> Boolean = { source, destination ->
227
+ source.renameTo(destination)
228
+ },
220
229
  ) : BundleStorageService {
221
230
  companion object {
222
231
  private const val TAG = "BundleStorage"
232
+ private const val MAX_SAFE_INTEGER = 9_007_199_254_740_991L
233
+ private val SHA256_PATTERN = Regex("^[0-9a-f]{64}$")
223
234
  }
224
235
 
225
236
  private val releaseStateLock = Any()
226
237
  private val pendingInstallSelections = java.util.concurrent.ConcurrentHashMap<String, PersistedSelection>()
227
-
228
- private fun emitArchiveProgress(
229
- progressCallback: (UpdateProgressPayload) -> Unit,
230
- progress: Double,
231
- downloadedBytes: Long? = null,
232
- totalBytes: Long? = null,
233
- ) {
234
- progressCallback(
235
- UpdateProgressPayload(
236
- progress = progress.coerceIn(0.0, 1.0),
237
- artifactType = "archive",
238
- downloadedBytes = downloadedBytes,
239
- totalBytes = totalBytes,
240
- ),
238
+ private val updateMutex = Mutex()
239
+ private val builtInAssetResolver =
240
+ builtInAssetResolver ?: AndroidBuiltInAssetResolver(
241
+ context,
242
+ File(getBundleStoreDir(), "builtin-index-v1.json"),
241
243
  )
242
- }
243
244
 
244
245
  private fun createDiffProgressFiles(changedAssets: Map<String, ChangedAssetDescriptor>): MutableList<DiffProgressFileSnapshot> =
245
246
  changedAssets.keys
@@ -517,11 +518,20 @@ class BundleFileStorageService(
517
518
  private data class ParsedBundleManifest(
518
519
  val bundleId: String,
519
520
  val assets: Map<String, ParsedManifestAsset>,
521
+ val archive: ParsedArchive?,
520
522
  )
521
523
 
522
524
  private data class ParsedManifestAsset(
523
525
  val fileHash: String,
524
526
  val signature: String?,
527
+ val byteSize: Long?,
528
+ val downloadByteSize: Long?,
529
+ )
530
+
531
+ private data class ParsedArchive(
532
+ val downloadFileHash: String,
533
+ val downloadByteSize: Long,
534
+ val tarByteSize: Long,
525
535
  )
526
536
 
527
537
  private data class ActiveBundleMetadataSnapshot(
@@ -536,8 +546,11 @@ class BundleFileStorageService(
536
546
 
537
547
  // Clean up old bundles if isolationKey format changed
538
548
  checkAndCleanupIfIsolationKeyChanged()
549
+
550
+ recoverInterruptedPromotions()
539
551
  }
540
552
 
553
+ private var hasPreparedLaunch = false
541
554
  private var currentLaunchReport: LaunchReport? = null
542
555
 
543
556
  @Volatile
@@ -569,7 +582,7 @@ class BundleFileStorageService(
569
582
 
570
583
  private fun saveMetadata(metadata: BundleMetadata): Boolean {
571
584
  val updatedMetadata = metadata.copy(isolationKey = isolationKey)
572
- return updatedMetadata.saveToFile(getMetadataFile())
585
+ return metadataWriter(updatedMetadata, getMetadataFile())
573
586
  }
574
587
 
575
588
  private fun loadLaunchReport(): LaunchReport? =
@@ -622,15 +635,7 @@ class BundleFileStorageService(
622
635
  private fun getKnownLaunchBundleId(metadata: BundleMetadata): String =
623
636
  getCurrentVerifiedBundleId(metadata) ?: HotUpdaterImpl.getMinBundleId()
624
637
 
625
- private fun createInitialMetadata(): BundleMetadata {
626
- val currentBundleId = extractBundleIdFromCurrentURL()
627
- Log.d(TAG, "Creating initial metadata with stagingBundleId: $currentBundleId")
628
- return BundleMetadata(
629
- stableBundleId = null,
630
- stagingBundleId = currentBundleId,
631
- verificationPending = false,
632
- )
633
- }
638
+ private fun createInitialMetadata(): BundleMetadata = BundleMetadata()
634
639
 
635
640
  private fun extractBundleIdFromCurrentURL(): String? {
636
641
  val currentUrl = preferences.getItem("HotUpdaterBundleURL") ?: return null
@@ -663,7 +668,7 @@ class BundleFileStorageService(
663
668
  .singleOrNull()
664
669
  }
665
670
 
666
- return File(bundleDir, "index.android.bundle").absoluteFile.takeIf { it.isFile }
671
+ return null
667
672
  }
668
673
 
669
674
  private fun findBundleFile(bundleId: String): File? {
@@ -671,6 +676,40 @@ class BundleFileStorageService(
671
676
  return resolveBundleFile(bundleDir, bundleId)
672
677
  }
673
678
 
679
+ private fun installBackupDir(bundleId: String): File = File(getBundleStoreDir(), "$bundleId.install-backup")
680
+
681
+ private fun isCompleteBundleDirectory(
682
+ bundleDir: File,
683
+ bundleId: String,
684
+ ): Boolean {
685
+ val manifest = parseBundleManifestFromFile(File(bundleDir, "manifest.json")) ?: return false
686
+ if (manifest.bundleId != bundleId) return false
687
+ if (resolveBundleFile(bundleDir, bundleId) == null) return false
688
+ return manifest.assets.all { (assetPath, asset) ->
689
+ val file = RelativePathResolver.resolveInside(bundleDir, assetPath)
690
+ file?.isFile == true && runCatching { verifyManifestAssetFileOrThrow(file, asset) }.isSuccess
691
+ }
692
+ }
693
+
694
+ private fun recoverInterruptedPromotions(): Boolean {
695
+ val bundleStoreDir = getBundleStoreDir()
696
+ val backups =
697
+ bundleStoreDir.listFiles { file ->
698
+ file.isDirectory && file.name.endsWith(".install-backup")
699
+ } ?: return true
700
+
701
+ return backups.all { backupDir ->
702
+ val bundleId = backupDir.name.removeSuffix(".install-backup")
703
+ val finalDir = File(bundleStoreDir, bundleId)
704
+ when {
705
+ !finalDir.exists() -> directoryRenamer(backupDir, finalDir)
706
+ isCompleteBundleDirectory(finalDir, bundleId) -> backupDir.deleteRecursively()
707
+ !finalDir.deleteRecursively() -> false
708
+ else -> directoryRenamer(backupDir, finalDir)
709
+ }
710
+ }
711
+ }
712
+
674
713
  private fun getBundleUrlForId(bundleId: String): String? = findBundleFile(bundleId)?.absolutePath
675
714
 
676
715
  private fun getCurrentVerifiedBundleId(metadata: BundleMetadata): String? =
@@ -681,12 +720,16 @@ class BundleFileStorageService(
681
720
  }
682
721
 
683
722
  private fun getActiveBundleId(): String? {
684
- extractBundleIdFromCurrentURL()?.let { return it }
685
-
686
- val metadata = loadMetadataOrNull()
723
+ val metadata = loadMetadataOrNull() ?: return null
724
+ val cachedBundleId = extractBundleIdFromCurrentURL()
725
+ if (cachedBundleId != null &&
726
+ (cachedBundleId == metadata.stagingBundleId || cachedBundleId == metadata.stableBundleId)
727
+ ) {
728
+ return cachedBundleId
729
+ }
687
730
  return when {
688
- metadata?.stagingBundleId != null && !metadata.verificationPending -> metadata.stagingBundleId
689
- metadata?.stableBundleId != null -> metadata.stableBundleId
731
+ metadata.stagingBundleId != null && !metadata.verificationPending -> metadata.stagingBundleId
732
+ metadata.stableBundleId != null -> metadata.stableBundleId
690
733
  else -> null
691
734
  }
692
735
  }
@@ -734,7 +777,7 @@ class BundleFileStorageService(
734
777
 
735
778
  return ActiveBundleMetadataSnapshot(
736
779
  activeBundleId = bundleDir.name,
737
- bundleId = manifestBundleId ?: readCompatibilityBundleIdFromBundleDir(bundleDir),
780
+ bundleId = manifestBundleId,
738
781
  manifest = manifest,
739
782
  )
740
783
  }
@@ -752,25 +795,6 @@ class BundleFileStorageService(
752
795
  return resolveActiveBundleMetadataSnapshot(bundleDir)
753
796
  }
754
797
 
755
- private fun readCompatibilityBundleIdFromBundleDir(bundleDir: File): String? {
756
- val compatibilityBundleIdFile = File(bundleDir, compatibilityBundleIdFilename())
757
- if (!compatibilityBundleIdFile.exists()) {
758
- return null
759
- }
760
-
761
- return try {
762
- compatibilityBundleIdFile.readText().trim().takeIf { it.isNotEmpty() }
763
- } catch (e: Exception) {
764
- Log.w(
765
- TAG,
766
- "Failed to read compatibility bundle metadata from ${compatibilityBundleIdFile.absolutePath}: ${e.message}",
767
- )
768
- null
769
- }
770
- }
771
-
772
- private fun compatibilityBundleIdFilename(): String = "BUNDLE_ID"
773
-
774
798
  private fun readManifestFromBundleDir(bundleDir: File): Map<String, Any?>? {
775
799
  val manifestFile = File(bundleDir, "manifest.json")
776
800
  if (!manifestFile.exists()) {
@@ -810,6 +834,33 @@ class BundleFileStorageService(
810
834
  else -> value
811
835
  }
812
836
 
837
+ private fun parseSafeInteger(value: Any?): Long? {
838
+ val parsed =
839
+ when (value) {
840
+ is Byte -> value.toLong()
841
+ is Short -> value.toLong()
842
+ is Int -> value.toLong()
843
+ is Long -> value
844
+ is Float -> value.toDouble().takeIf { it.isFinite() && it % 1.0 == 0.0 }?.toLong()
845
+ is Double -> value.takeIf { it.isFinite() && it % 1.0 == 0.0 }?.toLong()
846
+ else -> null
847
+ } ?: return null
848
+ return parsed.takeIf { it in 0..MAX_SAFE_INTEGER }
849
+ }
850
+
851
+ private fun parseArchive(value: Any?): ParsedArchive? {
852
+ val archive = value as? Map<*, *> ?: return null
853
+ val downloadFileHash = archive["downloadFileHash"] as? String ?: return null
854
+ if (!SHA256_PATTERN.matches(downloadFileHash)) return null
855
+ val downloadByteSize = parseSafeInteger(archive["downloadByteSize"])?.takeIf { it > 0 } ?: return null
856
+ val tarByteSize = parseSafeInteger(archive["tarByteSize"])?.takeIf { it >= 1024 } ?: return null
857
+ return ParsedArchive(
858
+ downloadFileHash = downloadFileHash,
859
+ downloadByteSize = downloadByteSize,
860
+ tarByteSize = tarByteSize,
861
+ )
862
+ }
863
+
813
864
  private fun parseBundleManifestFromMap(manifest: Map<String, Any?>): ParsedBundleManifest? {
814
865
  val manifestBundleId =
815
866
  (manifest["bundleId"] as? String)
@@ -835,12 +886,15 @@ class BundleFileStorageService(
835
886
  ParsedManifestAsset(
836
887
  fileHash = fileHash,
837
888
  signature = signature?.takeIf { it.isNotBlank() },
889
+ byteSize = parseSafeInteger(assetMap["byteSize"]),
890
+ downloadByteSize = parseSafeInteger(assetMap["downloadByteSize"]),
838
891
  )
839
892
  }
840
893
 
841
894
  return ParsedBundleManifest(
842
895
  bundleId = manifestBundleId,
843
896
  assets = assets,
897
+ archive = parseArchive(manifest["archive"]),
844
898
  )
845
899
  }
846
900
 
@@ -858,52 +912,12 @@ class BundleFileStorageService(
858
912
  }
859
913
  }
860
914
 
861
- private fun writeBundleManifestFile(
862
- destination: File,
863
- manifest: ParsedBundleManifest,
864
- ) {
865
- destination.parentFile?.mkdirs()
866
- val assetsObject = JSONObject()
867
-
868
- manifest.assets.toSortedMap().forEach { (assetPath, asset) ->
869
- val assetObject = JSONObject().put("fileHash", asset.fileHash)
870
- if (!asset.signature.isNullOrBlank()) {
871
- assetObject.put("signature", asset.signature)
872
- }
873
- assetsObject.put(
874
- assetPath,
875
- assetObject,
876
- )
877
- }
878
-
879
- val manifestObject =
880
- JSONObject()
881
- .put("bundleId", manifest.bundleId)
882
- .put("assets", assetsObject)
883
-
884
- destination.writeText("${manifestObject}\n")
885
- }
886
-
887
915
  private fun getActiveBundleDir(): File? {
888
916
  val activeBundleId = getActiveBundleId() ?: return null
889
917
  val bundleDir = File(getBundleStoreDir(), activeBundleId)
890
918
  return bundleDir.takeIf { it.exists() }
891
919
  }
892
920
 
893
- private fun canUseManifestDrivenInstall(): Boolean {
894
- val activeBundleDir = getActiveBundleDir() ?: return false
895
- if (!activeBundleDir.exists()) {
896
- return false
897
- }
898
-
899
- val currentManifest =
900
- getActiveBundleMetadataSnapshot()
901
- ?.manifest
902
- ?.let(::parseBundleManifestFromMap) ?: return false
903
-
904
- return currentManifest.assets.isNotEmpty()
905
- }
906
-
907
921
  private fun copyBundleFile(
908
922
  source: File,
909
923
  destination: File,
@@ -1046,6 +1060,7 @@ class BundleFileStorageService(
1046
1060
  toBundleId = stagingSelection.bundleId,
1047
1061
  ),
1048
1062
  verificationPending = true,
1063
+ launchInProgress = false,
1049
1064
  updatedAt = System.currentTimeMillis(),
1050
1065
  )
1051
1066
  }
@@ -1076,6 +1091,7 @@ class BundleFileStorageService(
1076
1091
  pendingUpdateStrategy = null,
1077
1092
  pendingTransition = null,
1078
1093
  verificationPending = false,
1094
+ launchInProgress = false,
1079
1095
  updatedAt = System.currentTimeMillis(),
1080
1096
  )
1081
1097
 
@@ -1113,10 +1129,9 @@ class BundleFileStorageService(
1113
1129
  private fun selectLaunch(): LaunchSelection {
1114
1130
  val metadata = loadMetadataOrNull()
1115
1131
  if (metadata == null) {
1116
- val cached = getCachedBundleURL()
1117
1132
  return LaunchSelection(
1118
- bundleUrl = cached ?: getFallbackBundleURL(),
1119
- launchedBundleId = extractBundleIdFromCurrentURL(),
1133
+ bundleUrl = getFallbackBundleURL(),
1134
+ launchedBundleId = null,
1120
1135
  shouldRollbackOnCrash = false,
1121
1136
  )
1122
1137
  }
@@ -1147,10 +1162,9 @@ class BundleFileStorageService(
1147
1162
  }
1148
1163
  }
1149
1164
 
1150
- val cached = getCachedBundleURL()
1151
1165
  return LaunchSelection(
1152
- bundleUrl = cached ?: getFallbackBundleURL(),
1153
- launchedBundleId = extractBundleIdFromCurrentURL(),
1166
+ bundleUrl = getFallbackBundleURL(),
1167
+ launchedBundleId = null,
1154
1168
  shouldRollbackOnCrash = false,
1155
1169
  )
1156
1170
  }
@@ -1302,6 +1316,7 @@ class BundleFileStorageService(
1302
1316
  pendingUpdateStrategy = null,
1303
1317
  pendingTransition = null,
1304
1318
  verificationPending = false,
1319
+ launchInProgress = false,
1305
1320
  updatedAt = System.currentTimeMillis(),
1306
1321
  )
1307
1322
  }
@@ -1354,6 +1369,7 @@ class BundleFileStorageService(
1354
1369
  pendingUpdateStrategy = null,
1355
1370
  pendingTransition = null,
1356
1371
  verificationPending = false,
1372
+ launchInProgress = false,
1357
1373
  updatedAt = System.currentTimeMillis(),
1358
1374
  ),
1359
1375
  )
@@ -1413,9 +1429,14 @@ class BundleFileStorageService(
1413
1429
 
1414
1430
  override fun setBundleURL(localPath: String?): Boolean {
1415
1431
  Log.d(TAG, "setBundleURL: $localPath")
1416
- preferences.setItem("HotUpdaterBundleURL", localPath)
1417
- clearActiveBundleMetadataSnapshot()
1418
- return true
1432
+ return try {
1433
+ preferences.setItem("HotUpdaterBundleURL", localPath)
1434
+ clearActiveBundleMetadataSnapshot()
1435
+ true
1436
+ } catch (e: Exception) {
1437
+ Log.e(TAG, "Failed to persist bundle URL", e)
1438
+ false
1439
+ }
1419
1440
  }
1420
1441
 
1421
1442
  override fun getCachedBundleURL(): String? {
@@ -1446,10 +1467,25 @@ class BundleFileStorageService(
1446
1467
  override fun getFallbackBundleURL(): String = "assets://index.android.bundle"
1447
1468
 
1448
1469
  override fun prepareLaunch(pendingRecovery: PendingCrashRecovery?): LaunchSelection {
1470
+ recoverInterruptedPromotions()
1449
1471
  saveLaunchReport(null)
1450
1472
  applyPendingRecoveryIfNeeded(pendingRecovery)
1473
+ // Only a new storage instance consumes an unfinished launch. Repeated bundle
1474
+ // lookups or bridge reloads in this process must not reject its own launch.
1475
+ if (!hasPreparedLaunch) {
1476
+ val metadata = loadMetadataOrNull()
1477
+ if (metadata?.verificationPending == true && metadata.launchInProgress) {
1478
+ metadata.stagingBundleId?.let { rollbackPendingBundle(it) }
1479
+ }
1480
+ }
1481
+ hasPreparedLaunch = true
1451
1482
 
1452
1483
  val selection = selectLaunch()
1484
+ if (selection.shouldRollbackOnCrash) {
1485
+ loadMetadataOrNull()?.let { metadata ->
1486
+ saveMetadata(metadata.copy(launchInProgress = true))
1487
+ }
1488
+ }
1453
1489
  Log.d(
1454
1490
  TAG,
1455
1491
  "prepareLaunch: bundleId=${selection.launchedBundleId} shouldRollback=${selection.shouldRollbackOnCrash} url=${selection.bundleUrl}",
@@ -1459,49 +1495,34 @@ class BundleFileStorageService(
1459
1495
 
1460
1496
  override suspend fun updateBundle(
1461
1497
  bundleId: String,
1462
- fileUrl: String?,
1463
- fileHash: String?,
1464
- manifestUrl: String?,
1465
- manifestFileHash: String?,
1466
- changedAssets: Map<String, ChangedAssetDescriptor>?,
1498
+ manifestUrl: String,
1499
+ manifestFileHash: String,
1500
+ assets: Map<String, ChangedAssetDescriptor>,
1501
+ archiveUrl: String?,
1502
+ progressCallback: (UpdateProgressPayload) -> Unit,
1503
+ ) = updateMutex.withLock {
1504
+ updateBundleSerialized(
1505
+ bundleId = bundleId,
1506
+ manifestUrl = manifestUrl,
1507
+ manifestFileHash = manifestFileHash,
1508
+ changedAssets = assets,
1509
+ archiveUrl = archiveUrl,
1510
+ progressCallback = progressCallback,
1511
+ )
1512
+ }
1513
+
1514
+ private suspend fun updateBundleSerialized(
1515
+ bundleId: String,
1516
+ manifestUrl: String,
1517
+ manifestFileHash: String,
1518
+ changedAssets: Map<String, ChangedAssetDescriptor>,
1519
+ archiveUrl: String?,
1467
1520
  progressCallback: (UpdateProgressPayload) -> Unit,
1468
1521
  ) {
1469
1522
  Log.d(
1470
1523
  TAG,
1471
- "updateBundle bundleId $bundleId fileUrl $fileUrl fileHash $fileHash manifestUrl $manifestUrl",
1524
+ "updateBundle bundleId $bundleId manifestUrl $manifestUrl",
1472
1525
  )
1473
- // If no URL is provided, reset to fallback and clean up all bundles
1474
- if (fileUrl.isNullOrEmpty()) {
1475
- Log.d(TAG, "fileUrl is null or empty, resetting to fallback bundle")
1476
-
1477
- withContext(Dispatchers.IO) {
1478
- // 1. Set bundle URL to null (reset preference)
1479
- val setResult = setBundleURL(null)
1480
- if (!setResult) {
1481
- Log.w(TAG, "Failed to reset bundle URL")
1482
- }
1483
-
1484
- // 2. Reset metadata to initial state (clear all bundle references)
1485
- val previousMetadata = loadMetadataOrNull()
1486
- val metadata =
1487
- createInitialMetadata().copy(
1488
- highestSeenCatalogs = previousMetadata?.highestSeenCatalogs ?: emptyMap(),
1489
- currentSelectionContexts = previousMetadata?.currentSelectionContexts ?: emptyMap(),
1490
- )
1491
- val saveResult = saveMetadata(metadata)
1492
- if (!saveResult) {
1493
- Log.w(TAG, "Failed to reset metadata")
1494
- }
1495
-
1496
- // 3. Clean up all downloaded bundles
1497
- // Pass null for currentBundleId to remove all bundles except the new bundleId
1498
- val bundleStoreDir = getBundleStoreDir()
1499
- cleanupOldBundles(bundleStoreDir, null, bundleId)
1500
-
1501
- Log.d(TAG, "Successfully reset to fallback bundle and cleaned up downloads")
1502
- }
1503
- return
1504
- }
1505
1526
 
1506
1527
  // Check if bundle is in crashed history
1507
1528
  if (isBundleInCrashedHistory(bundleId)) {
@@ -1509,299 +1530,373 @@ class BundleFileStorageService(
1509
1530
  throw HotUpdaterException.bundleInCrashedHistory(bundleId)
1510
1531
  }
1511
1532
 
1512
- // Initialize metadata if it doesn't exist (lazy initialization)
1513
- val existingMetadata = loadMetadataOrNull()
1514
- val metadata =
1515
- existingMetadata ?: createInitialMetadata().also {
1516
- saveMetadata(it)
1517
- Log.d(TAG, "Created initial metadata during updateBundle")
1518
- }
1519
-
1520
- val baseDir = fileSystem.getInternalFilesDir()
1521
1533
  val bundleStoreDir = getBundleStoreDir()
1522
1534
  if (!bundleStoreDir.exists()) {
1523
1535
  bundleStoreDir.mkdirs()
1524
1536
  }
1525
-
1526
- val finalBundleDir = File(bundleStoreDir, bundleId)
1527
- if (finalBundleDir.exists()) {
1528
- Log.d(TAG, "Bundle for bundleId $bundleId already exists. Using cached bundle.")
1529
- val existingBundleFile = resolveBundleFile(finalBundleDir, bundleId)
1530
- if (existingBundleFile != null) {
1531
- // Update last modified time
1532
- finalBundleDir.setLastModified(System.currentTimeMillis())
1533
-
1534
- // Update metadata: set as staging
1535
- val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
1536
- val updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
1537
- saveMetadata(updatedMetadata)
1538
-
1539
- // Set bundle URL for backwards compatibility
1540
- setBundleURL(existingBundleFile.absolutePath)
1541
-
1542
- // Keep the current verified bundle as a fallback if one exists.
1543
- cleanupOldBundles(bundleStoreDir, updatedMetadata.stableBundleId, bundleId)
1544
-
1545
- Log.d(TAG, "Existing bundle set as staging bundle for next launch")
1546
- return
1547
- } else {
1548
- // If index.android.bundle is missing, delete and re-download
1549
- finalBundleDir.deleteRecursively()
1550
- }
1537
+ if (!recoverInterruptedPromotions()) {
1538
+ throw HotUpdaterException.moveOperationFailed()
1551
1539
  }
1552
1540
 
1553
- val hasManifestDrivenArtifacts =
1554
- !manifestUrl.isNullOrEmpty() &&
1555
- !manifestFileHash.isNullOrEmpty() &&
1556
- changedAssets != null
1557
-
1558
- if (hasManifestDrivenArtifacts && canUseManifestDrivenInstall()) {
1559
- try {
1560
- withContext(Dispatchers.IO) {
1561
- updateBundleFromManifest(
1562
- bundleId = bundleId,
1563
- manifestUrl = manifestUrl!!,
1564
- manifestFileHash = manifestFileHash!!,
1565
- changedAssets = changedAssets!!,
1566
- bundleStoreDir = bundleStoreDir,
1567
- finalBundleDir = finalBundleDir,
1568
- progressCallback = progressCallback,
1569
- )
1570
- }
1571
- return
1572
- } catch (e: Exception) {
1573
- if (fileUrl.isNullOrEmpty()) {
1574
- throw e
1575
- }
1576
- Log.w(
1577
- TAG,
1578
- "Manifest-driven install failed for $bundleId. Falling back to archive: ${e.message}",
1579
- e,
1580
- )
1581
- }
1582
- } else if (hasManifestDrivenArtifacts) {
1583
- Log.d(
1584
- TAG,
1585
- "Skipping manifest-driven install for $bundleId because no active OTA manifest is available. Using archive.",
1541
+ val finalBundleDir = File(bundleStoreDir, bundleId)
1542
+ withContext(Dispatchers.IO) {
1543
+ updateBundleFromManifest(
1544
+ bundleId = bundleId,
1545
+ manifestUrl = manifestUrl,
1546
+ manifestFileHash = manifestFileHash,
1547
+ changedAssets = changedAssets,
1548
+ archiveUrl = archiveUrl,
1549
+ bundleStoreDir = bundleStoreDir,
1550
+ finalBundleDir = finalBundleDir,
1551
+ progressCallback = progressCallback,
1586
1552
  )
1587
1553
  }
1554
+ }
1588
1555
 
1589
- val tempDirName = "bundle-temp"
1590
- val tempDir = File(baseDir, tempDirName)
1591
- if (tempDir.exists()) {
1592
- tempDir.deleteRecursively()
1556
+ private suspend fun downloadManifestAsset(
1557
+ assetPath: String,
1558
+ expectedAsset: ParsedManifestAsset,
1559
+ changedAsset: ChangedAssetDescriptor,
1560
+ currentBundleId: String?,
1561
+ activeBundleDir: File?,
1562
+ targetFile: File,
1563
+ tempDir: File,
1564
+ progressFile: DiffProgressFileSnapshot,
1565
+ progressCallback: (UpdateProgressPayload) -> Unit,
1566
+ ) {
1567
+ val expectedHash = expectedAsset.fileHash
1568
+ val diffFiles = mutableListOf(progressFile)
1569
+ val patched =
1570
+ applyPatchAssetIfPossible(
1571
+ assetPath = assetPath,
1572
+ changedAsset = changedAsset,
1573
+ currentBundleId = currentBundleId,
1574
+ activeBundleDir = activeBundleDir,
1575
+ targetFile = targetFile,
1576
+ expectedHash = expectedHash,
1577
+ tempDir = tempDir,
1578
+ diffFiles = diffFiles,
1579
+ progressCallback = progressCallback,
1580
+ )
1581
+ if (patched) {
1582
+ verifyManifestAssetFileOrThrow(targetFile, expectedAsset)
1583
+ updateDiffProgressFile(
1584
+ files = diffFiles,
1585
+ assetPath = assetPath,
1586
+ status = "downloaded",
1587
+ progress = 1.0,
1588
+ )
1589
+ emitDiffProgress(
1590
+ progressCallback = progressCallback,
1591
+ phase =
1592
+ if (diffFiles.all { it.status == "downloaded" }) {
1593
+ "finalizing"
1594
+ } else {
1595
+ "downloading"
1596
+ },
1597
+ files = diffFiles,
1598
+ )
1599
+ return
1593
1600
  }
1594
- tempDir.mkdirs()
1595
-
1596
- withContext(Dispatchers.IO) {
1597
- val downloadUrl = URL(fileUrl)
1598
1601
 
1599
- // Determine bundle filename from URL
1600
- val bundleFileName =
1601
- if (downloadUrl.path.isNotEmpty()) {
1602
- File(downloadUrl.path).name.ifEmpty { "bundle.zip" }
1603
- } else {
1604
- "bundle.zip"
1602
+ val changedAssetFileUrl =
1603
+ changedAsset.fileUrl
1604
+ ?: run {
1605
+ updateDiffProgressFile(
1606
+ files = diffFiles,
1607
+ assetPath = assetPath,
1608
+ status = "failed",
1609
+ progress = 0.0,
1610
+ )
1611
+ emitDiffProgress(
1612
+ progressCallback = progressCallback,
1613
+ phase = "downloading",
1614
+ files = diffFiles,
1615
+ )
1616
+ throw HotUpdaterException.downloadFailed(
1617
+ IllegalStateException("Changed asset fileUrl missing and patch could not be applied: $assetPath"),
1618
+ )
1605
1619
  }
1606
- val tempBundleFile = File(tempDir, bundleFileName)
1607
1620
 
1608
- // Download the file (0% - 80%)
1609
- // Disk space check will be performed in fileSizeCallback
1610
- var diskSpaceError: HotUpdaterException? = null
1621
+ val downloadPath = downloadPathForChangedAsset(assetPath, changedAsset)
1622
+ val downloadedAssetFile =
1623
+ if (changedAsset.fileCompression == "br") {
1624
+ compressedTempFile(tempDir, assetPath, "br")
1625
+ } else {
1626
+ targetFile
1627
+ }
1611
1628
 
1612
- val downloadResult =
1629
+ when (
1630
+ val assetDownloadResult =
1613
1631
  downloadService.downloadFile(
1614
- downloadUrl,
1615
- tempBundleFile,
1616
- fileSizeCallback = { fileSize ->
1617
- // Perform disk space check when file size is known
1618
- if (baseDir != null) {
1619
- val stat = StatFs(baseDir.absolutePath)
1620
- val availableBytes = stat.availableBlocksLong * stat.blockSizeLong
1621
- val requiredSpace = fileSize * 2 // ZIP + extracted files
1622
-
1623
- Log.d(
1624
- "BundleStorage",
1625
- "File size: $fileSize bytes, Available: $availableBytes bytes, Required: $requiredSpace bytes",
1626
- )
1627
-
1628
- if (availableBytes < requiredSpace) {
1629
- Log.d(
1630
- TAG,
1631
- "Insufficient disk space detected: need $requiredSpace bytes, available $availableBytes bytes",
1632
- )
1633
- // Store error to be thrown after download completes/cancels
1634
- diskSpaceError = HotUpdaterException.insufficientDiskSpace(requiredSpace, availableBytes)
1635
- }
1636
- }
1637
- },
1632
+ URL(changedAssetFileUrl),
1633
+ downloadedAssetFile,
1638
1634
  ) { downloadProgress ->
1639
- // Map download progress to 0.0 - 0.8
1640
- emitArchiveProgress(
1641
- progressCallback,
1642
- downloadProgress.progress * 0.8,
1643
- downloadProgress.downloadedBytes,
1644
- downloadProgress.totalBytes,
1635
+ updateDiffProgressFile(
1636
+ files = diffFiles,
1637
+ assetPath = assetPath,
1638
+ status = "downloading",
1639
+ progress = downloadProgress.progress,
1640
+ downloadPath = downloadPath,
1641
+ downloadedBytes = downloadProgress.downloadedBytes,
1642
+ totalBytes = downloadProgress.totalBytes,
1643
+ )
1644
+ emitDiffProgress(
1645
+ progressCallback = progressCallback,
1646
+ phase = "downloading",
1647
+ files = diffFiles,
1645
1648
  )
1646
1649
  }
1647
-
1648
- // Check for disk space error first before processing download result
1649
- diskSpaceError?.let {
1650
- Log.d(TAG, "Throwing disk space error")
1651
- tempDir.deleteRecursively()
1652
- throw it
1650
+ ) {
1651
+ is DownloadResult.Error -> {
1652
+ updateDiffProgressFile(
1653
+ files = diffFiles,
1654
+ assetPath = assetPath,
1655
+ status = "failed",
1656
+ progress =
1657
+ diffFiles
1658
+ .firstOrNull { it.path == assetPath }
1659
+ ?.progress ?: 0.0,
1660
+ )
1661
+ emitDiffProgress(
1662
+ progressCallback = progressCallback,
1663
+ phase = "downloading",
1664
+ files = diffFiles,
1665
+ )
1666
+ if (assetDownloadResult.exception is IncompleteDownloadException) {
1667
+ val incompleteEx =
1668
+ assetDownloadResult.exception as IncompleteDownloadException
1669
+ throw HotUpdaterException.incompleteDownload(
1670
+ incompleteEx.expectedSize,
1671
+ incompleteEx.actualSize,
1672
+ )
1673
+ }
1674
+ throw HotUpdaterException.downloadFailed(assetDownloadResult.exception)
1653
1675
  }
1654
1676
 
1655
- when (downloadResult) {
1656
- is DownloadResult.Error -> {
1657
- Log.d("BundleStorage", "Download failed: ${downloadResult.exception.message}")
1658
- tempDir.deleteRecursively()
1659
-
1660
- // Check if this is an incomplete download error
1661
- if (downloadResult.exception is IncompleteDownloadException) {
1662
- val incompleteEx = downloadResult.exception as IncompleteDownloadException
1663
- throw HotUpdaterException.incompleteDownload(
1664
- incompleteEx.expectedSize,
1665
- incompleteEx.actualSize,
1677
+ is DownloadResult.Success -> {
1678
+ try {
1679
+ if (changedAsset.fileCompression == "br") {
1680
+ decompressBrotliFile(
1681
+ assetDownloadResult.file,
1682
+ targetFile,
1666
1683
  )
1667
- } else {
1668
- throw HotUpdaterException.downloadFailed(downloadResult.exception)
1669
- }
1670
- }
1671
-
1672
- is DownloadResult.Success -> {
1673
- Log.d("BundleStorage", "Download successful")
1674
- // 1) Verify bundle integrity (hash or signature based on fileHash format)
1675
- Log.d("BundleStorage", "Verifying bundle integrity...")
1676
- try {
1677
- SignatureVerifier.verifyBundle(context, tempBundleFile, fileHash)
1678
- Log.d("BundleStorage", "Bundle verification completed successfully")
1679
- } catch (e: SignatureVerificationException) {
1680
- Log.e("BundleStorage", "Bundle verification failed", e)
1681
- tempDir.deleteRecursively()
1682
- tempBundleFile.delete()
1683
- throw HotUpdaterException.signatureVerificationFailed(e)
1684
- }
1685
-
1686
- // 2) Create a .tmp directory under bundle-store (to avoid colliding with an existing bundleId folder)
1687
- val tmpDir = File(bundleStoreDir, "$bundleId.tmp")
1688
- if (tmpDir.exists()) {
1689
- tmpDir.deleteRecursively()
1690
1684
  }
1691
- tmpDir.mkdirs()
1692
-
1693
- // 3) Extract archive into tmpDir (80% - 100%)
1694
- Log.d("BundleStorage", "Extracting $tempBundleFile → $tmpDir")
1695
- if (!decompressService.extractZipFile(
1696
- tempBundleFile.absolutePath,
1697
- tmpDir.absolutePath,
1698
- ) { unzipProgress ->
1699
- // Map unzip progress (0.0 - 1.0) to overall progress (0.8 - 1.0)
1700
- emitArchiveProgress(
1701
- progressCallback,
1702
- 0.8 + (unzipProgress * 0.2),
1703
- )
1704
- }
1705
- ) {
1706
- Log.d("BundleStorage", "Failed to extract archive into tmpDir.")
1707
- tempDir.deleteRecursively()
1708
- tmpDir.deleteRecursively()
1709
- throw HotUpdaterException.extractionFormatError()
1685
+ verifyManifestAssetFileOrThrow(
1686
+ targetFile,
1687
+ expectedAsset,
1688
+ )
1689
+ } catch (e: Exception) {
1690
+ updateDiffProgressFile(
1691
+ files = diffFiles,
1692
+ assetPath = assetPath,
1693
+ status = "failed",
1694
+ progress = 1.0,
1695
+ )
1696
+ emitDiffProgress(
1697
+ progressCallback = progressCallback,
1698
+ phase = "downloading",
1699
+ files = diffFiles,
1700
+ )
1701
+ if (e is HotUpdaterException) {
1702
+ throw e
1710
1703
  }
1711
-
1712
- // 4) Resolve the extracted Android bundle file.
1713
- val extractedBundleFile = resolveBundleFile(tmpDir, bundleId)
1714
- if (extractedBundleFile == null) {
1715
- Log.d("BundleStorage", "Android bundle file could not be resolved in tmpDir.")
1716
- tempDir.deleteRecursively()
1717
- tmpDir.deleteRecursively()
1718
- throw HotUpdaterException.invalidBundle()
1704
+ throw HotUpdaterException.downloadFailed(e)
1705
+ } finally {
1706
+ if (changedAsset.fileCompression == "br") {
1707
+ assetDownloadResult.file.delete()
1719
1708
  }
1709
+ }
1710
+ updateDiffProgressFile(
1711
+ files = diffFiles,
1712
+ assetPath = assetPath,
1713
+ status = "downloaded",
1714
+ progress = 1.0,
1715
+ )
1716
+ emitDiffProgress(
1717
+ progressCallback = progressCallback,
1718
+ phase = if (diffFiles.all { it.status == "downloaded" }) "finalizing" else "downloading",
1719
+ files = diffFiles,
1720
+ )
1721
+ }
1722
+ }
1723
+ }
1720
1724
 
1721
- // 5) Log extracted bundle file size
1722
- val bundleSize = extractedBundleFile.length()
1723
- Log.d("BundleStorage", "Extracted bundle size: $bundleSize bytes")
1725
+ private fun pruneStagingDirectory(
1726
+ directory: File,
1727
+ assets: Set<String>,
1728
+ prefix: String = "",
1729
+ ) {
1730
+ directory.listFiles()?.forEach { file ->
1731
+ val relativePath = prefix + file.name
1732
+ if (file.canonicalFile != File(directory.canonicalFile, file.name)) {
1733
+ check(file.delete()) { "Cannot remove staging link: $relativePath" }
1734
+ } else if (file.isDirectory && assets.any { it.startsWith("$relativePath/") }) {
1735
+ pruneStagingDirectory(file, assets, "$relativePath/")
1736
+ } else if (!file.isFile || relativePath !in assets) {
1737
+ check(file.deleteRecursively()) { "Cannot remove stale staging file: $relativePath" }
1738
+ }
1739
+ }
1740
+ }
1724
1741
 
1725
- // 6) If the realDir (bundle-store/<bundleId>) exists, delete it
1726
- if (finalBundleDir.exists()) {
1727
- finalBundleDir.deleteRecursively()
1728
- }
1742
+ private fun selectArchive(
1743
+ manifest: ParsedBundleManifest,
1744
+ downloads: Map<String, ChangedAssetDescriptor>,
1745
+ archiveUrl: String?,
1746
+ ): ParsedArchive? {
1747
+ if (archiveUrl.isNullOrBlank() || downloads.size < 2) return null
1748
+ val archive = manifest.archive ?: return null
1749
+ if (manifest.assets.values.any { it.byteSize == null }) return null
1750
+
1751
+ var individualBytes = 0L
1752
+ for ((assetPath, descriptor) in downloads) {
1753
+ val originalBytes = manifest.assets.getValue(assetPath).downloadByteSize ?: return null
1754
+ val expectedBytes =
1755
+ descriptor.patch?.let { patch ->
1756
+ val patchBytes = patch.byteSize?.takeIf { it in 0..MAX_SAFE_INTEGER } ?: return null
1757
+ minOf(originalBytes, patchBytes)
1758
+ } ?: originalBytes
1759
+ if (individualBytes > MAX_SAFE_INTEGER - expectedBytes) return null
1760
+ individualBytes += expectedBytes
1761
+ }
1762
+ var logicalBytes = 0L
1763
+ for (asset in manifest.assets.values) {
1764
+ val byteSize = asset.byteSize ?: return null
1765
+ if (logicalBytes > MAX_SAFE_INTEGER - byteSize) return null
1766
+ logicalBytes += byteSize
1767
+ }
1768
+ if (archive.tarByteSize < logicalBytes) return null
1769
+ val tarFramingBytes = archive.tarByteSize - logicalBytes
1770
+ if (individualBytes > MAX_SAFE_INTEGER - tarFramingBytes) return null
1771
+ val framedByteLimit = individualBytes + tarFramingBytes
1772
+
1773
+ if (archive.downloadByteSize <= individualBytes) return archive
1774
+ if (downloads.size != manifest.assets.size || downloads.values.any { it.patch != null }) return null
1775
+ return archive.takeIf { it.downloadByteSize <= framedByteLimit }
1776
+ }
1729
1777
 
1730
- // 7) Attempt to rename tmpDir → finalBundleDir (atomic within the same parent folder)
1731
- val renamed = tmpDir.renameTo(finalBundleDir)
1732
- if (!renamed) {
1733
- // If rename fails, use moveItem as fallback
1734
- if (!fileSystem.moveItem(tmpDir.absolutePath, finalBundleDir.absolutePath)) {
1735
- // If move also fails, try copy + delete as last resort
1736
- if (!fileSystem.copyItem(tmpDir.absolutePath, finalBundleDir.absolutePath)) {
1737
- // All strategies failed
1738
- Log.e(
1739
- "BundleStorage",
1740
- "Failed to move bundle from tmpDir to finalBundleDir (rename, move, and copy all failed)",
1741
- )
1742
- tempDir.deleteRecursively()
1743
- tmpDir.deleteRecursively()
1744
- throw HotUpdaterException.moveOperationFailed()
1745
- }
1746
- // Copy succeeded, clean up tmpDir
1747
- tmpDir.deleteRecursively()
1748
- }
1749
- }
1778
+ private suspend fun tryInstallArchive(
1779
+ archiveUrl: String,
1780
+ archive: ParsedArchive,
1781
+ manifest: ParsedBundleManifest,
1782
+ tempDir: File,
1783
+ bundleStoreDir: File,
1784
+ tmpDir: File,
1785
+ bundleId: String,
1786
+ progressCallback: (UpdateProgressPayload) -> Unit,
1787
+ ): MutableList<DiffProgressFileSnapshot>? {
1788
+ val archiveScratch = File(tempDir, "archive")
1789
+ val archiveFile = File(archiveScratch, "bundle.tar.br")
1790
+ val extractedDir = File(bundleStoreDir, "$bundleId.archive.tmp")
1791
+ val localBackupDir = File(bundleStoreDir, "$bundleId.local.tmp")
1792
+ archiveScratch.deleteRecursively()
1793
+ extractedDir.deleteRecursively()
1794
+ localBackupDir.deleteRecursively()
1795
+ archiveScratch.mkdirs()
1796
+
1797
+ val progressFiles =
1798
+ mutableListOf(
1799
+ DiffProgressFileSnapshot(
1800
+ path = "bundle.tar.br",
1801
+ downloadPath = "bundle.tar.br",
1802
+ status = "pending",
1803
+ progress = 0.0,
1804
+ order = 0,
1805
+ ),
1806
+ )
1807
+ emitDiffProgress(progressCallback, "downloading", progressFiles)
1750
1808
 
1751
- // 8) Verify the Android bundle file exists inside finalBundleDir.
1752
- val finalBundleFile = resolveBundleFile(finalBundleDir, bundleId)
1753
- if (finalBundleFile == null) {
1754
- Log.d("BundleStorage", "Android bundle file could not be resolved in realDir.")
1755
- tempDir.deleteRecursively()
1756
- finalBundleDir.deleteRecursively()
1757
- throw HotUpdaterException.invalidBundle()
1809
+ return try {
1810
+ when (
1811
+ val result =
1812
+ downloadService.downloadFileOnce(
1813
+ URL(archiveUrl),
1814
+ archiveFile,
1815
+ ) { progress ->
1816
+ updateDiffProgressFile(
1817
+ files = progressFiles,
1818
+ assetPath = "bundle.tar.br",
1819
+ status = "downloading",
1820
+ progress = progress.progress,
1821
+ downloadPath = "bundle.tar.br",
1822
+ downloadedBytes = progress.downloadedBytes,
1823
+ totalBytes = progress.totalBytes,
1824
+ )
1825
+ emitDiffProgress(progressCallback, "downloading", progressFiles)
1758
1826
  }
1827
+ ) {
1828
+ is DownloadResult.Error -> throw result.exception
1829
+ is DownloadResult.Success -> Unit
1830
+ }
1759
1831
 
1760
- // 9) Update finalBundleDir's last modified time
1761
- finalBundleDir.setLastModified(System.currentTimeMillis())
1762
-
1763
- // 10) Save the new bundle as STAGING with verification pending
1764
- val bundlePath = finalBundleFile.absolutePath
1765
- Log.d(TAG, "Setting bundle as staging: $bundlePath")
1766
-
1767
- // Update metadata: set new bundle as staging
1768
- val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
1769
- val updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
1770
- saveMetadata(updatedMetadata)
1771
-
1772
- // Also update HotUpdaterBundleURL for backwards compatibility
1773
- // This will point to the staging bundle that will be loaded
1774
- setBundleURL(bundlePath)
1775
-
1776
- // 11) Clean up temporary and download folders
1777
- tempDir.deleteRecursively()
1832
+ if (archiveFile.length() != archive.downloadByteSize) {
1833
+ throw IllegalStateException("Archive compressed size mismatch")
1834
+ }
1835
+ if (!HashUtils.verifyHash(archiveFile, archive.downloadFileHash)) {
1836
+ throw IllegalStateException("Archive compressed hash mismatch")
1837
+ }
1778
1838
 
1779
- // 12) Keep the fallback bundle and the new staging bundle.
1780
- cleanupOldBundles(bundleStoreDir, updatedMetadata.stableBundleId, bundleId)
1839
+ TarBrArchiveExtractor.extract(
1840
+ archiveFile = archiveFile,
1841
+ destination = extractedDir,
1842
+ expectedTarByteSize = archive.tarByteSize,
1843
+ expectedFiles = manifest.assets.mapValues { it.value.byteSize!! },
1844
+ )
1845
+ manifest.assets.forEach { (assetPath, expectedAsset) ->
1846
+ val extractedFile =
1847
+ RelativePathResolver.resolveInside(extractedDir, assetPath)
1848
+ ?: throw IllegalStateException("Archive output path is invalid")
1849
+ verifyManifestAssetFileOrThrow(extractedFile, expectedAsset)
1850
+ }
1781
1851
 
1782
- Log.d(TAG, "Downloaded and set bundle as staging successfully for the next launch.")
1783
- // Progress already at 1.0 from unzip completion
1784
- }
1852
+ if (!tmpDir.renameTo(localBackupDir)) {
1853
+ throw IllegalStateException("Cannot preserve locally prepared staging directory")
1854
+ }
1855
+ if (!extractedDir.renameTo(tmpDir)) {
1856
+ check(localBackupDir.renameTo(tmpDir)) { "Cannot restore locally prepared staging directory" }
1857
+ throw IllegalStateException("Cannot promote verified archive extraction")
1858
+ }
1859
+ localBackupDir.deleteRecursively()
1860
+
1861
+ updateDiffProgressFile(
1862
+ files = progressFiles,
1863
+ assetPath = "bundle.tar.br",
1864
+ status = "downloaded",
1865
+ progress = 1.0,
1866
+ downloadedBytes = archive.downloadByteSize,
1867
+ totalBytes = archive.downloadByteSize,
1868
+ )
1869
+ emitDiffProgress(progressCallback, "finalizing", progressFiles)
1870
+ progressFiles
1871
+ } catch (error: Exception) {
1872
+ Log.w(TAG, "Archive optimization failed; using individual files: ${error.message}")
1873
+ val canUseIndividualPlan =
1874
+ tmpDir.exists() ||
1875
+ (localBackupDir.exists() && localBackupDir.renameTo(tmpDir))
1876
+ if (!canUseIndividualPlan) {
1877
+ throw error
1785
1878
  }
1879
+ extractedDir.deleteRecursively()
1880
+ null
1881
+ } finally {
1882
+ archiveScratch.deleteRecursively()
1883
+ extractedDir.deleteRecursively()
1884
+ if (tmpDir.exists()) localBackupDir.deleteRecursively()
1786
1885
  }
1787
1886
  }
1788
1887
 
1789
- /**
1790
- * Removes old bundles except for the specified bundle IDs, and any leftover .tmp directories
1791
- */
1792
1888
  private suspend fun updateBundleFromManifest(
1793
1889
  bundleId: String,
1794
1890
  manifestUrl: String,
1795
1891
  manifestFileHash: String,
1796
1892
  changedAssets: Map<String, ChangedAssetDescriptor>,
1893
+ archiveUrl: String?,
1797
1894
  bundleStoreDir: File,
1798
1895
  finalBundleDir: File,
1799
1896
  progressCallback: (UpdateProgressPayload) -> Unit,
1800
1897
  ) {
1801
1898
  val activeBundleDir = getActiveBundleDir()
1802
1899
  val currentBundleId = getBundleId()
1803
- val currentManifest =
1804
- getActiveBundleMetadataSnapshot()?.manifest?.let(::parseBundleManifestFromMap)
1805
1900
  val baseDir =
1806
1901
  fileSystem.getInternalFilesDir() ?: bundleStoreDir.parentFile ?: bundleStoreDir
1807
1902
  val tempDir = File(baseDir, "bundle-manifest-temp")
@@ -1812,7 +1907,7 @@ class BundleFileStorageService(
1812
1907
  }
1813
1908
  tempDir.mkdirs()
1814
1909
 
1815
- val diffFiles = createDiffProgressFiles(changedAssets)
1910
+ var diffFiles = mutableListOf<DiffProgressFileSnapshot>()
1816
1911
 
1817
1912
  try {
1818
1913
  val manifestFile = File(tempDir, "manifest.json")
@@ -1863,233 +1958,116 @@ class BundleFileStorageService(
1863
1958
  if (targetManifest.bundleId != bundleId) {
1864
1959
  throw HotUpdaterException.invalidBundle()
1865
1960
  }
1866
- emitDiffProgress(
1867
- progressCallback = progressCallback,
1868
- phase = if (diffFiles.isEmpty()) "finalizing" else "downloading",
1869
- files = diffFiles,
1870
- )
1871
-
1872
- if (tmpDir.exists()) {
1873
- tmpDir.deleteRecursively()
1961
+ // Reuse never bypasses the complete artifact contract.
1962
+ if (targetManifest.assets.keys != changedAssets.keys) throw HotUpdaterException.invalidBundle()
1963
+ targetManifest.assets.forEach { (path, asset) ->
1964
+ val descriptor = changedAssets.getValue(path)
1965
+ if (RelativePathResolver.normalizeRelativePath(path) != path ||
1966
+ path == "manifest.json" ||
1967
+ descriptor.fileUrl.isNullOrBlank() ||
1968
+ !descriptor.fileHash.equals(asset.fileHash, ignoreCase = true) ||
1969
+ RelativePathResolver.resolveInside(tmpDir, path) == null
1970
+ ) {
1971
+ throw HotUpdaterException.invalidBundle()
1972
+ }
1874
1973
  }
1875
- tmpDir.mkdirs()
1876
-
1877
- val targetEntries = targetManifest.assets.entries.toList()
1974
+ if (!tmpDir.isDirectory && !tmpDir.mkdirs()) throw HotUpdaterException.directoryCreationFailed()
1975
+ pruneStagingDirectory(tmpDir, targetManifest.assets.keys)
1976
+ val targetEntries = targetManifest.assets.entries.sortedBy { it.key }
1977
+ val localDirectories = listOfNotNull(activeBundleDir, finalBundleDir).distinct().filter { it.isDirectory }
1978
+ val downloads = mutableMapOf<String, ChangedAssetDescriptor>()
1878
1979
  targetEntries.forEachIndexed { index, (assetPath, expectedAsset) ->
1879
- val expectedHash = expectedAsset.fileHash
1880
- val targetFile =
1881
- RelativePathResolver.resolveInside(tmpDir, assetPath)
1882
- ?: throw HotUpdaterException.invalidBundle()
1883
- val currentAsset = currentManifest?.assets?.get(assetPath)
1884
-
1885
- if (currentAsset?.fileHash == expectedHash) {
1886
- val sourceDir =
1887
- activeBundleDir
1888
- ?: throw HotUpdaterException.downloadFailed(
1889
- IllegalStateException("Current bundle directory unavailable for reused asset: $assetPath"),
1890
- )
1891
- val sourceFile =
1892
- RelativePathResolver.resolveInside(sourceDir, assetPath)
1893
- ?: throw HotUpdaterException.invalidBundle()
1894
- if (!sourceFile.exists() || !HashUtils.verifyHash(sourceFile, expectedHash)) {
1895
- throw HotUpdaterException.downloadFailed(
1896
- IllegalStateException("Reusable asset missing or corrupted: $assetPath"),
1897
- )
1980
+ progressCallback(
1981
+ UpdateProgressPayload(
1982
+ progress = 0.15 + 0.05 * index / targetEntries.size.coerceAtLeast(1),
1983
+ artifactType = "diff",
1984
+ details = DiffProgressDetails(totalFilesCount = 0, completedFilesCount = 0),
1985
+ ),
1986
+ )
1987
+ val targetFile = RelativePathResolver.resolveInside(tmpDir, assetPath) ?: throw HotUpdaterException.invalidBundle()
1988
+ targetFile.parentFile?.mkdirs()
1989
+ if (targetFile.isFile && runCatching { verifyManifestAssetFileOrThrow(targetFile, expectedAsset) }.isSuccess) {
1990
+ return@forEachIndexed
1991
+ }
1992
+ if (targetFile.exists()) targetFile.delete()
1993
+ var reused = false
1994
+ for (directory in localDirectories) {
1995
+ val sourceFile = RelativePathResolver.resolveInside(directory, assetPath) ?: continue
1996
+ if (!sourceFile.isFile || runCatching { verifyManifestAssetFileOrThrow(sourceFile, expectedAsset) }.isFailure) continue
1997
+ if (runCatching {
1998
+ copyBundleFile(sourceFile, targetFile)
1999
+ verifyManifestAssetFileOrThrow(targetFile, expectedAsset)
2000
+ }.isSuccess
2001
+ ) {
2002
+ reused = true
2003
+ break
1898
2004
  }
1899
- copyBundleFile(sourceFile, targetFile)
2005
+ targetFile.delete()
2006
+ }
2007
+ if (reused) return@forEachIndexed
2008
+ if (builtInAssetResolver.copyIfMatches(assetPath, expectedAsset.fileHash, targetFile)) {
1900
2009
  verifyManifestAssetFileOrThrow(targetFile, expectedAsset)
1901
2010
  return@forEachIndexed
1902
2011
  }
1903
-
1904
- val changedAsset =
1905
- changedAssets[assetPath]
1906
- ?: run {
1907
- updateDiffProgressFile(
1908
- files = diffFiles,
1909
- assetPath = assetPath,
1910
- status = "failed",
1911
- progress = 0.0,
1912
- )
1913
- emitDiffProgress(
1914
- progressCallback = progressCallback,
1915
- phase = "downloading",
1916
- files = diffFiles,
1917
- )
1918
- throw HotUpdaterException.downloadFailed(
1919
- IllegalStateException("Changed asset missing from update response: $assetPath"),
1920
- )
1921
- }
1922
-
1923
- if (!changedAsset.fileHash.equals(expectedHash, ignoreCase = true)) {
1924
- updateDiffProgressFile(
1925
- files = diffFiles,
1926
- assetPath = assetPath,
1927
- status = "failed",
1928
- progress = 0.0,
1929
- )
1930
- emitDiffProgress(
1931
- progressCallback = progressCallback,
1932
- phase = "downloading",
1933
- files = diffFiles,
1934
- )
1935
- throw HotUpdaterException.signatureVerificationFailed(
1936
- SignatureVerificationException.FileHashMismatch(),
1937
- )
1938
- }
1939
-
1940
- val patched =
1941
- applyPatchAssetIfPossible(
1942
- assetPath = assetPath,
1943
- changedAsset = changedAsset,
1944
- currentBundleId = currentBundleId,
1945
- activeBundleDir = activeBundleDir,
1946
- targetFile = targetFile,
1947
- expectedHash = expectedHash,
2012
+ downloads[assetPath] = changedAssets.getValue(assetPath)
2013
+ }
2014
+ val selectedArchive = selectArchive(targetManifest, downloads, archiveUrl)
2015
+ val archiveProgress =
2016
+ if (selectedArchive != null) {
2017
+ tryInstallArchive(
2018
+ archiveUrl = archiveUrl!!,
2019
+ archive = selectedArchive,
2020
+ manifest = targetManifest,
1948
2021
  tempDir = tempDir,
1949
- diffFiles = diffFiles,
1950
- progressCallback = progressCallback,
1951
- )
1952
- if (patched) {
1953
- verifyManifestAssetFileOrThrow(targetFile, expectedAsset)
1954
- updateDiffProgressFile(
1955
- files = diffFiles,
1956
- assetPath = assetPath,
1957
- status = "downloaded",
1958
- progress = 1.0,
1959
- )
1960
- emitDiffProgress(
2022
+ bundleStoreDir = bundleStoreDir,
2023
+ tmpDir = tmpDir,
2024
+ bundleId = bundleId,
1961
2025
  progressCallback = progressCallback,
1962
- phase =
1963
- if (diffFiles.all { it.status == "downloaded" }) {
1964
- "finalizing"
1965
- } else {
1966
- "downloading"
1967
- },
1968
- files = diffFiles,
1969
2026
  )
1970
- return@forEachIndexed
2027
+ } else {
2028
+ null
1971
2029
  }
1972
2030
 
1973
- val changedAssetFileUrl =
1974
- changedAsset.fileUrl
1975
- ?: run {
1976
- updateDiffProgressFile(
1977
- files = diffFiles,
1978
- assetPath = assetPath,
1979
- status = "failed",
1980
- progress = 0.0,
1981
- )
1982
- emitDiffProgress(
1983
- progressCallback = progressCallback,
1984
- phase = "downloading",
1985
- files = diffFiles,
1986
- )
1987
- throw HotUpdaterException.downloadFailed(
1988
- IllegalStateException("Changed asset fileUrl missing and patch could not be applied: $assetPath"),
1989
- )
1990
- }
1991
-
1992
- val downloadPath = downloadPathForChangedAsset(assetPath, changedAsset)
1993
- val downloadedAssetFile =
1994
- if (changedAsset.fileCompression == "br") {
1995
- compressedTempFile(tempDir, assetPath, "br")
1996
- } else {
1997
- targetFile
1998
- }
1999
-
2000
- when (
2001
- val assetDownloadResult =
2002
- downloadService.downloadFile(
2003
- URL(changedAssetFileUrl),
2004
- downloadedAssetFile,
2005
- ) { downloadProgress ->
2006
- updateDiffProgressFile(
2007
- files = diffFiles,
2008
- assetPath = assetPath,
2009
- status = "downloading",
2010
- progress = downloadProgress.progress,
2011
- downloadPath = downloadPath,
2012
- downloadedBytes = downloadProgress.downloadedBytes,
2013
- totalBytes = downloadProgress.totalBytes,
2014
- )
2015
- emitDiffProgress(
2016
- progressCallback = progressCallback,
2017
- phase = "downloading",
2018
- files = diffFiles,
2019
- )
2020
- }
2021
- ) {
2022
- is DownloadResult.Error -> {
2023
- updateDiffProgressFile(
2024
- files = diffFiles,
2025
- assetPath = assetPath,
2026
- status = "failed",
2027
- progress =
2028
- diffFiles
2029
- .firstOrNull { it.path == assetPath }
2030
- ?.progress ?: 0.0,
2031
- )
2032
- emitDiffProgress(
2033
- progressCallback = progressCallback,
2034
- phase = "downloading",
2035
- files = diffFiles,
2036
- )
2037
- if (assetDownloadResult.exception is IncompleteDownloadException) {
2038
- val incompleteEx =
2039
- assetDownloadResult.exception as IncompleteDownloadException
2040
- throw HotUpdaterException.incompleteDownload(
2041
- incompleteEx.expectedSize,
2042
- incompleteEx.actualSize,
2043
- )
2044
- }
2045
- throw HotUpdaterException.downloadFailed(assetDownloadResult.exception)
2046
- }
2047
-
2048
- is DownloadResult.Success -> {
2049
- try {
2050
- if (changedAsset.fileCompression == "br") {
2051
- decompressBrotliFile(
2052
- assetDownloadResult.file,
2053
- targetFile,
2054
- )
2055
- }
2056
- verifyManifestAssetFileOrThrow(
2057
- targetFile,
2058
- expectedAsset,
2059
- )
2060
- } catch (e: Exception) {
2061
- updateDiffProgressFile(
2062
- files = diffFiles,
2063
- assetPath = assetPath,
2064
- status = "failed",
2065
- progress = 1.0,
2066
- )
2067
- emitDiffProgress(
2068
- progressCallback = progressCallback,
2069
- phase = "downloading",
2070
- files = diffFiles,
2071
- )
2072
- if (e is HotUpdaterException) {
2073
- throw e
2074
- }
2075
- throw HotUpdaterException.downloadFailed(e)
2076
- } finally {
2077
- if (changedAsset.fileCompression == "br") {
2078
- assetDownloadResult.file.delete()
2031
+ if (archiveProgress != null) {
2032
+ diffFiles = archiveProgress
2033
+ } else {
2034
+ diffFiles = createDiffProgressFiles(downloads)
2035
+ emitDiffProgress(progressCallback, "downloading", diffFiles)
2036
+ val progressLock = Any()
2037
+ coroutineScope {
2038
+ val permits = Semaphore(4)
2039
+ diffFiles
2040
+ .toList()
2041
+ .map { progressFile ->
2042
+ async {
2043
+ permits.withPermit {
2044
+ val path = progressFile.path
2045
+ val workerTemp =
2046
+ File(tempDir, progressFile.order.toString()).apply { mkdirs() }
2047
+ downloadManifestAsset(
2048
+ assetPath = path,
2049
+ expectedAsset = targetManifest.assets.getValue(path),
2050
+ changedAsset = downloads.getValue(path),
2051
+ currentBundleId = currentBundleId,
2052
+ activeBundleDir = activeBundleDir,
2053
+ targetFile =
2054
+ RelativePathResolver.resolveInside(tmpDir, path)
2055
+ ?: throw HotUpdaterException.invalidBundle(),
2056
+ tempDir = workerTemp,
2057
+ progressFile = progressFile,
2058
+ progressCallback = { payload ->
2059
+ synchronized(progressLock) {
2060
+ payload.details?.files?.firstOrNull()?.let { snapshot ->
2061
+ val index = diffFiles.indexOfFirst { it.path == path }
2062
+ diffFiles[index] = snapshot
2063
+ }
2064
+ emitDiffProgress(progressCallback, "downloading", diffFiles)
2065
+ }
2066
+ },
2067
+ )
2068
+ }
2079
2069
  }
2080
- }
2081
- updateDiffProgressFile(
2082
- files = diffFiles,
2083
- assetPath = assetPath,
2084
- status = "downloaded",
2085
- progress = 1.0,
2086
- )
2087
- emitDiffProgress(
2088
- progressCallback = progressCallback,
2089
- phase = if (diffFiles.all { it.status == "downloaded" }) "finalizing" else "downloading",
2090
- files = diffFiles,
2091
- )
2092
- }
2070
+ }.awaitAll()
2093
2071
  }
2094
2072
  }
2095
2073
 
@@ -2099,39 +2077,72 @@ class BundleFileStorageService(
2099
2077
  files = diffFiles,
2100
2078
  )
2101
2079
 
2102
- writeBundleManifestFile(File(tmpDir, "manifest.json"), targetManifest)
2080
+ manifestFile.copyTo(File(tmpDir, "manifest.json"), overwrite = true)
2103
2081
 
2104
2082
  val extractedIndex = tmpDir.walk().find { it.name == "index.android.bundle" }
2105
2083
  if (extractedIndex == null) {
2106
2084
  throw HotUpdaterException.invalidBundle()
2107
2085
  }
2108
2086
 
2109
- if (finalBundleDir.exists()) {
2110
- finalBundleDir.deleteRecursively()
2111
- }
2112
-
2113
- val renamed = tmpDir.renameTo(finalBundleDir)
2114
- if (!renamed) {
2115
- if (!fileSystem.moveItem(tmpDir.absolutePath, finalBundleDir.absolutePath)) {
2116
- if (!fileSystem.copyItem(tmpDir.absolutePath, finalBundleDir.absolutePath)) {
2087
+ val backupDir = installBackupDir(bundleId)
2088
+ val updatedMetadata =
2089
+ synchronized(releaseStateLock) {
2090
+ if (backupDir.exists()) {
2117
2091
  throw HotUpdaterException.moveOperationFailed()
2118
2092
  }
2119
- tmpDir.deleteRecursively()
2120
- }
2121
- }
2122
2093
 
2123
- val finalIndexFile = finalBundleDir.walk().find { it.name == "index.android.bundle" }
2124
- if (finalIndexFile == null) {
2125
- finalBundleDir.deleteRecursively()
2126
- throw HotUpdaterException.invalidBundle()
2127
- }
2094
+ val hadExistingFinal = finalBundleDir.exists()
2095
+ var promoted = false
2096
+ val previousBundleUrl = preferences.getItem("HotUpdaterBundleURL")
2097
+ try {
2098
+ if (hadExistingFinal && !directoryRenamer(finalBundleDir, backupDir)) {
2099
+ throw HotUpdaterException.moveOperationFailed()
2100
+ }
2101
+ if (!directoryRenamer(tmpDir, finalBundleDir)) {
2102
+ if (hadExistingFinal) {
2103
+ directoryRenamer(backupDir, finalBundleDir)
2104
+ }
2105
+ throw HotUpdaterException.moveOperationFailed()
2106
+ }
2107
+ promoted = true
2108
+
2109
+ val finalIndexFile =
2110
+ resolveBundleFile(finalBundleDir, bundleId)
2111
+ ?: throw HotUpdaterException.invalidBundle()
2112
+ if (!isCompleteBundleDirectory(finalBundleDir, bundleId)) {
2113
+ throw HotUpdaterException.invalidBundle()
2114
+ }
2115
+ finalBundleDir.setLastModified(System.currentTimeMillis())
2128
2116
 
2129
- finalBundleDir.setLastModified(System.currentTimeMillis())
2117
+ val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
2118
+ val nextMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
2119
+ if (!setBundleURL(finalIndexFile.absolutePath)) {
2120
+ throw IllegalStateException("Failed to persist bundle URL")
2121
+ }
2122
+ if (!saveMetadata(nextMetadata)) {
2123
+ throw IllegalStateException("Failed to persist bundle metadata")
2124
+ }
2130
2125
 
2131
- val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
2132
- val updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
2133
- saveMetadata(updatedMetadata)
2134
- setBundleURL(finalIndexFile.absolutePath)
2126
+ if (backupDir.exists() && !backupDir.deleteRecursively()) {
2127
+ Log.w(TAG, "Failed to remove committed install backup: ${backupDir.absolutePath}")
2128
+ }
2129
+ nextMetadata
2130
+ } catch (e: Exception) {
2131
+ runCatching { preferences.setItem("HotUpdaterBundleURL", previousBundleUrl) }
2132
+ clearActiveBundleMetadataSnapshot()
2133
+ if (promoted && finalBundleDir.exists() && !finalBundleDir.deleteRecursively()) {
2134
+ Log.e(TAG, "Failed to remove uncommitted bundle: ${finalBundleDir.absolutePath}")
2135
+ }
2136
+ if (hadExistingFinal &&
2137
+ !finalBundleDir.exists() &&
2138
+ backupDir.exists() &&
2139
+ !directoryRenamer(backupDir, finalBundleDir)
2140
+ ) {
2141
+ Log.e(TAG, "Failed to restore install backup: ${backupDir.absolutePath}")
2142
+ }
2143
+ throw e
2144
+ }
2145
+ }
2135
2146
 
2136
2147
  tempDir.deleteRecursively()
2137
2148
  cleanupOldBundles(bundleStoreDir, updatedMetadata.stableBundleId, bundleId)
@@ -2149,7 +2160,7 @@ class BundleFileStorageService(
2149
2160
  )
2150
2161
  } catch (e: Exception) {
2151
2162
  tempDir.deleteRecursively()
2152
- tmpDir.deleteRecursively()
2163
+ // A retry rehashes completed files and rejects partial bytes.
2153
2164
  throw e
2154
2165
  }
2155
2166
  }
@@ -2164,7 +2175,9 @@ class BundleFileStorageService(
2164
2175
  val bundles =
2165
2176
  bundleStoreDir
2166
2177
  .listFiles { file ->
2167
- file.isDirectory && !file.name.endsWith(".tmp")
2178
+ file.isDirectory &&
2179
+ !file.name.endsWith(".tmp") &&
2180
+ !file.name.endsWith(".install-backup")
2168
2181
  }?.toList() ?: return
2169
2182
 
2170
2183
  // Keep only the specified bundle IDs (filter out null values)
@@ -2287,6 +2300,7 @@ class BundleFileStorageService(
2287
2300
  pendingUpdateStrategy = null,
2288
2301
  pendingTransition = null,
2289
2302
  verificationPending = false,
2303
+ launchInProgress = false,
2290
2304
  updatedAt = System.currentTimeMillis(),
2291
2305
  )
2292
2306