@hot-updater/react-native 1.0.0-rc.3 → 1.0.0-rc.30

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 (300) hide show
  1. package/android/proguard-rules.pro +17 -2
  2. package/android/src/main/java/com/hotupdater/BuiltInAssetResolver.kt +286 -0
  3. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +954 -837
  4. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +15 -11
  5. package/android/src/main/java/com/hotupdater/FileManagerService.kt +45 -0
  6. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +287 -22
  7. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +43 -31
  8. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +37 -54
  9. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryState.kt +64 -0
  10. package/android/src/main/java/com/hotupdater/InstallIdentityService.kt +37 -0
  11. package/android/src/main/java/com/hotupdater/KeyValueStorageService.kt +86 -0
  12. package/android/src/main/java/com/hotupdater/NetworkState.kt +31 -0
  13. package/android/src/main/java/com/hotupdater/NoBackupStorage.kt +17 -0
  14. package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +127 -50
  15. package/android/src/main/java/com/hotupdater/PreviousProcessExit.kt +89 -0
  16. package/android/src/main/java/com/hotupdater/ReactNativeValueConverters.kt +34 -0
  17. package/android/src/main/java/com/hotupdater/TarArchiveInputStream.kt +214 -375
  18. package/android/src/main/java/com/hotupdater/TarBrArchiveExtractor.kt +119 -0
  19. package/android/src/newarch/HotUpdater.kt +7 -9
  20. package/android/src/newarch/HotUpdaterModule.kt +109 -84
  21. package/android/src/oldarch/HotUpdater.kt +7 -9
  22. package/android/src/oldarch/HotUpdaterModule.kt +98 -74
  23. package/android/src/oldarch/HotUpdaterSpec.kt +4 -6
  24. package/ios/HotUpdater/Internal/BrotliFileDecompressor.swift +167 -0
  25. package/ios/HotUpdater/Internal/BuiltInAssetResolver.swift +196 -0
  26. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +950 -1112
  27. package/ios/HotUpdater/Internal/BundleMetadata.swift +8 -53
  28. package/ios/HotUpdater/Internal/FileManagerService.swift +48 -0
  29. package/ios/HotUpdater/Internal/FileUtilities.swift +64 -0
  30. package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +0 -1
  31. package/ios/HotUpdater/Internal/HotUpdater.mm +13 -45
  32. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +190 -73
  33. package/ios/HotUpdater/Internal/KeyValueStorageService.swift +71 -0
  34. package/ios/HotUpdater/Internal/NotificationExtension.swift +0 -2
  35. package/ios/HotUpdater/Internal/TarArchiveExtractor.swift +182 -311
  36. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +119 -71
  37. package/ios/HotUpdater/Internal/UpdateFailure.swift +390 -0
  38. package/ios/HotUpdater/Recovery/HotUpdaterRecovery.h +16 -0
  39. package/ios/HotUpdater/Recovery/HotUpdaterRecovery.mm +83 -0
  40. package/ios/HotUpdater/Recovery/HotUpdaterRecoveryHooks.mm +49 -0
  41. package/lib/commonjs/appReady.js +111 -0
  42. package/lib/commonjs/appReady.js.map +1 -0
  43. package/lib/commonjs/checkForUpdate.js +124 -69
  44. package/lib/commonjs/checkForUpdate.js.map +1 -1
  45. package/lib/commonjs/clientPlugin.js +13 -0
  46. package/lib/commonjs/clientPlugin.js.map +1 -0
  47. package/lib/commonjs/error.js +4 -13
  48. package/lib/commonjs/error.js.map +1 -1
  49. package/lib/commonjs/fetchJSON.js +16 -27
  50. package/lib/commonjs/fetchJSON.js.map +1 -1
  51. package/lib/commonjs/fetchUpdateResponse.js +54 -0
  52. package/lib/commonjs/fetchUpdateResponse.js.map +1 -0
  53. package/lib/commonjs/httpClient.js +63 -94
  54. package/lib/commonjs/httpClient.js.map +1 -1
  55. package/lib/commonjs/index.js +71 -18
  56. package/lib/commonjs/index.js.map +1 -1
  57. package/lib/commonjs/native.js +78 -60
  58. package/lib/commonjs/native.js.map +1 -1
  59. package/lib/commonjs/pluginHost.js +44 -0
  60. package/lib/commonjs/pluginHost.js.map +1 -0
  61. package/lib/commonjs/releaseCatalogCache.js +36 -87
  62. package/lib/commonjs/releaseCatalogCache.js.map +1 -1
  63. package/lib/commonjs/sdkVersion.js +1 -1
  64. package/lib/commonjs/sdkVersion.js.map +1 -1
  65. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  66. package/lib/commonjs/store.js +3 -10
  67. package/lib/commonjs/store.js.map +1 -1
  68. package/lib/commonjs/updateError.js +132 -0
  69. package/lib/commonjs/updateError.js.map +1 -0
  70. package/lib/commonjs/wrap.js +43 -26
  71. package/lib/commonjs/wrap.js.map +1 -1
  72. package/lib/module/appReady.js +103 -0
  73. package/lib/module/appReady.js.map +1 -0
  74. package/lib/module/checkForUpdate.js +117 -64
  75. package/lib/module/checkForUpdate.js.map +1 -1
  76. package/lib/module/clientPlugin.js +4 -0
  77. package/lib/module/clientPlugin.js.map +1 -0
  78. package/lib/module/error.js +4 -13
  79. package/lib/module/error.js.map +1 -1
  80. package/lib/module/fetchJSON.js +14 -26
  81. package/lib/module/fetchJSON.js.map +1 -1
  82. package/lib/module/fetchUpdateResponse.js +49 -0
  83. package/lib/module/fetchUpdateResponse.js.map +1 -0
  84. package/lib/module/httpClient.js +62 -93
  85. package/lib/module/httpClient.js.map +1 -1
  86. package/lib/module/index.js +58 -20
  87. package/lib/module/index.js.map +1 -1
  88. package/lib/module/native.js +71 -53
  89. package/lib/module/native.js.map +1 -1
  90. package/lib/module/pluginHost.js +35 -0
  91. package/lib/module/pluginHost.js.map +1 -0
  92. package/lib/module/releaseCatalogCache.js +32 -82
  93. package/lib/module/releaseCatalogCache.js.map +1 -1
  94. package/lib/module/sdkVersion.js +1 -1
  95. package/lib/module/sdkVersion.js.map +1 -1
  96. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  97. package/lib/module/store.js +3 -10
  98. package/lib/module/store.js.map +1 -1
  99. package/lib/module/updateError.js +126 -0
  100. package/lib/module/updateError.js.map +1 -0
  101. package/lib/module/wrap.js +43 -26
  102. package/lib/module/wrap.js.map +1 -1
  103. package/lib/typescript/commonjs/appReady.d.ts +23 -0
  104. package/lib/typescript/commonjs/appReady.d.ts.map +1 -0
  105. package/lib/typescript/commonjs/checkForUpdate.d.ts +16 -4
  106. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/clientPlugin.d.ts +2 -0
  108. package/lib/typescript/commonjs/clientPlugin.d.ts.map +1 -0
  109. package/lib/typescript/commonjs/error.d.ts +4 -13
  110. package/lib/typescript/commonjs/error.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/fetchJSON.d.ts +5 -5
  112. package/lib/typescript/commonjs/fetchJSON.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/fetchUpdateResponse.d.ts +14 -0
  114. package/lib/typescript/commonjs/fetchUpdateResponse.d.ts.map +1 -0
  115. package/lib/typescript/commonjs/httpClient.d.ts +4 -33
  116. package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
  117. package/lib/typescript/commonjs/index.d.ts +4 -8
  118. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  119. package/lib/typescript/commonjs/native.d.ts +31 -22
  120. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  121. package/lib/typescript/commonjs/pluginHost.d.ts +4 -0
  122. package/lib/typescript/commonjs/pluginHost.d.ts.map +1 -0
  123. package/lib/typescript/commonjs/releaseCatalogCache.d.ts +8 -14
  124. package/lib/typescript/commonjs/releaseCatalogCache.d.ts.map +1 -1
  125. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  126. package/lib/typescript/commonjs/sdkVersion.d.ts.map +1 -1
  127. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +42 -47
  128. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  129. package/lib/typescript/commonjs/store.d.ts +1 -3
  130. package/lib/typescript/commonjs/store.d.ts.map +1 -1
  131. package/lib/typescript/commonjs/types.d.ts +1 -1
  132. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  133. package/lib/typescript/commonjs/updateError.d.ts +26 -0
  134. package/lib/typescript/commonjs/updateError.d.ts.map +1 -0
  135. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  136. package/lib/typescript/commonjs/wrap.types.d.ts +6 -7
  137. package/lib/typescript/commonjs/wrap.types.d.ts.map +1 -1
  138. package/lib/typescript/module/appReady.d.ts +23 -0
  139. package/lib/typescript/module/appReady.d.ts.map +1 -0
  140. package/lib/typescript/module/checkForUpdate.d.ts +16 -4
  141. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  142. package/lib/typescript/module/clientPlugin.d.ts +2 -0
  143. package/lib/typescript/module/clientPlugin.d.ts.map +1 -0
  144. package/lib/typescript/module/error.d.ts +4 -13
  145. package/lib/typescript/module/error.d.ts.map +1 -1
  146. package/lib/typescript/module/fetchJSON.d.ts +5 -5
  147. package/lib/typescript/module/fetchJSON.d.ts.map +1 -1
  148. package/lib/typescript/module/fetchUpdateResponse.d.ts +14 -0
  149. package/lib/typescript/module/fetchUpdateResponse.d.ts.map +1 -0
  150. package/lib/typescript/module/httpClient.d.ts +4 -33
  151. package/lib/typescript/module/httpClient.d.ts.map +1 -1
  152. package/lib/typescript/module/index.d.ts +4 -8
  153. package/lib/typescript/module/index.d.ts.map +1 -1
  154. package/lib/typescript/module/native.d.ts +31 -22
  155. package/lib/typescript/module/native.d.ts.map +1 -1
  156. package/lib/typescript/module/pluginHost.d.ts +4 -0
  157. package/lib/typescript/module/pluginHost.d.ts.map +1 -0
  158. package/lib/typescript/module/releaseCatalogCache.d.ts +8 -14
  159. package/lib/typescript/module/releaseCatalogCache.d.ts.map +1 -1
  160. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  161. package/lib/typescript/module/sdkVersion.d.ts.map +1 -1
  162. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +42 -47
  163. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  164. package/lib/typescript/module/store.d.ts +1 -3
  165. package/lib/typescript/module/store.d.ts.map +1 -1
  166. package/lib/typescript/module/types.d.ts +1 -1
  167. package/lib/typescript/module/types.d.ts.map +1 -1
  168. package/lib/typescript/module/updateError.d.ts +26 -0
  169. package/lib/typescript/module/updateError.d.ts.map +1 -0
  170. package/lib/typescript/module/wrap.d.ts.map +1 -1
  171. package/lib/typescript/module/wrap.types.d.ts +6 -7
  172. package/lib/typescript/module/wrap.types.d.ts.map +1 -1
  173. package/package.json +7 -4
  174. package/src/appReady.ts +149 -0
  175. package/src/checkForUpdate.ts +151 -88
  176. package/src/clientPlugin.ts +18 -0
  177. package/src/error.ts +4 -14
  178. package/src/fetchJSON.ts +14 -30
  179. package/src/fetchUpdateResponse.ts +54 -0
  180. package/src/httpClient.ts +88 -159
  181. package/src/index.ts +90 -24
  182. package/src/native.ts +106 -102
  183. package/src/pluginHost.ts +45 -0
  184. package/src/releaseCatalogCache.ts +43 -172
  185. package/src/sdkVersion.ts +1 -1
  186. package/src/specs/NativeHotUpdater.ts +41 -48
  187. package/src/store.ts +2 -23
  188. package/src/types.ts +1 -1
  189. package/src/updateError.ts +153 -0
  190. package/src/wrap.tsx +57 -26
  191. package/src/wrap.types.ts +6 -7
  192. package/android/src/main/java/com/hotupdater/DecompressService.kt +0 -89
  193. package/android/src/main/java/com/hotupdater/DecompressionStrategy.kt +0 -26
  194. package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +0 -117
  195. package/android/src/main/java/com/hotupdater/TarGzDecompressionStrategy.kt +0 -117
  196. package/android/src/main/java/com/hotupdater/ZipDecompressionStrategy.kt +0 -192
  197. package/android/src/test/java/com/hotupdater/BsdiffPatchTest.kt +0 -46
  198. package/android/src/test/java/com/hotupdater/BundleFileStorageServiceTest.kt +0 -799
  199. package/android/src/test/java/com/hotupdater/HotUpdaterImplTest.kt +0 -273
  200. package/android/src/test/java/com/hotupdater/NativeConfigUtilsTest.kt +0 -20
  201. package/android/src/test/java/com/hotupdater/OkHttpDownloadServiceTest.kt +0 -111
  202. package/android/src/test/java/com/hotupdater/RelativePathResolverTest.kt +0 -41
  203. package/android/src/test/java/com/hotupdater/ReleaseCatalogCacheServiceTest.kt +0 -118
  204. package/android/src/test/java/com/hotupdater/TarArchiveInputStreamTest.kt +0 -68
  205. package/ios/HotUpdater/Internal/ArchiveExtractionUtilities.swift +0 -178
  206. package/ios/HotUpdater/Internal/DecompressService.swift +0 -112
  207. package/ios/HotUpdater/Internal/DecompressionStrategy.swift +0 -22
  208. package/ios/HotUpdater/Internal/StreamingTarArchiveExtractor.swift +0 -370
  209. package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +0 -39
  210. package/ios/HotUpdater/Internal/TarGzDecompressionStrategy.swift +0 -59
  211. package/ios/HotUpdater/Internal/ZipArchiveExtractor.swift +0 -482
  212. package/ios/HotUpdater/Internal/ZipDecompressionStrategy.swift +0 -56
  213. package/lib/commonjs/catalogCacheNative.spec.js +0 -31
  214. package/lib/commonjs/catalogCacheNative.spec.js.map +0 -1
  215. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js +0 -615
  216. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js.map +0 -1
  217. package/lib/commonjs/httpClient.spec.js +0 -102
  218. package/lib/commonjs/httpClient.spec.js.map +0 -1
  219. package/lib/commonjs/index.spec.js +0 -302
  220. package/lib/commonjs/index.spec.js.map +0 -1
  221. package/lib/commonjs/iosInterop.spec.js +0 -18
  222. package/lib/commonjs/iosInterop.spec.js.map +0 -1
  223. package/lib/commonjs/native.spec.js +0 -883
  224. package/lib/commonjs/native.spec.js.map +0 -1
  225. package/lib/commonjs/notifyAppReadyInsights.boundary.spec.js +0 -218
  226. package/lib/commonjs/notifyAppReadyInsights.boundary.spec.js.map +0 -1
  227. package/lib/commonjs/notifyAppReadyInsights.js +0 -133
  228. package/lib/commonjs/notifyAppReadyInsights.js.map +0 -1
  229. package/lib/commonjs/notifyAppReadyInsights.spec.js +0 -246
  230. package/lib/commonjs/notifyAppReadyInsights.spec.js.map +0 -1
  231. package/lib/commonjs/notifyAppReadyInsights.test-utils.js +0 -24
  232. package/lib/commonjs/notifyAppReadyInsights.test-utils.js.map +0 -1
  233. package/lib/commonjs/notifyAppReadyInsights.transport.spec.js +0 -117
  234. package/lib/commonjs/notifyAppReadyInsights.transport.spec.js.map +0 -1
  235. package/lib/commonjs/releaseCatalogCache.spec.js +0 -195
  236. package/lib/commonjs/releaseCatalogCache.spec.js.map +0 -1
  237. package/lib/commonjs/releaseCatalogCacheNativeContract.spec.js +0 -64
  238. package/lib/commonjs/releaseCatalogCacheNativeContract.spec.js.map +0 -1
  239. package/lib/commonjs/store.notification.spec.js +0 -76
  240. package/lib/commonjs/store.notification.spec.js.map +0 -1
  241. package/lib/commonjs/store.spec.js +0 -316
  242. package/lib/commonjs/store.spec.js.map +0 -1
  243. package/lib/commonjs/wrap.spec.js +0 -329
  244. package/lib/commonjs/wrap.spec.js.map +0 -1
  245. package/lib/module/catalogCacheNative.spec.js +0 -31
  246. package/lib/module/catalogCacheNative.spec.js.map +0 -1
  247. package/lib/module/checkForUpdate.releaseCatalog.spec.js +0 -614
  248. package/lib/module/checkForUpdate.releaseCatalog.spec.js.map +0 -1
  249. package/lib/module/httpClient.spec.js +0 -102
  250. package/lib/module/httpClient.spec.js.map +0 -1
  251. package/lib/module/index.spec.js +0 -301
  252. package/lib/module/index.spec.js.map +0 -1
  253. package/lib/module/iosInterop.spec.js +0 -18
  254. package/lib/module/iosInterop.spec.js.map +0 -1
  255. package/lib/module/native.spec.js +0 -882
  256. package/lib/module/native.spec.js.map +0 -1
  257. package/lib/module/notifyAppReadyInsights.boundary.spec.js +0 -217
  258. package/lib/module/notifyAppReadyInsights.boundary.spec.js.map +0 -1
  259. package/lib/module/notifyAppReadyInsights.js +0 -128
  260. package/lib/module/notifyAppReadyInsights.js.map +0 -1
  261. package/lib/module/notifyAppReadyInsights.spec.js +0 -245
  262. package/lib/module/notifyAppReadyInsights.spec.js.map +0 -1
  263. package/lib/module/notifyAppReadyInsights.test-utils.js +0 -18
  264. package/lib/module/notifyAppReadyInsights.test-utils.js.map +0 -1
  265. package/lib/module/notifyAppReadyInsights.transport.spec.js +0 -116
  266. package/lib/module/notifyAppReadyInsights.transport.spec.js.map +0 -1
  267. package/lib/module/releaseCatalogCache.spec.js +0 -195
  268. package/lib/module/releaseCatalogCache.spec.js.map +0 -1
  269. package/lib/module/releaseCatalogCacheNativeContract.spec.js +0 -64
  270. package/lib/module/releaseCatalogCacheNativeContract.spec.js.map +0 -1
  271. package/lib/module/store.notification.spec.js +0 -77
  272. package/lib/module/store.notification.spec.js.map +0 -1
  273. package/lib/module/store.spec.js +0 -315
  274. package/lib/module/store.spec.js.map +0 -1
  275. package/lib/module/wrap.spec.js +0 -330
  276. package/lib/module/wrap.spec.js.map +0 -1
  277. package/lib/typescript/commonjs/notifyAppReadyInsights.d.ts +0 -13
  278. package/lib/typescript/commonjs/notifyAppReadyInsights.d.ts.map +0 -1
  279. package/lib/typescript/commonjs/notifyAppReadyInsights.test-utils.d.ts +0 -8
  280. package/lib/typescript/commonjs/notifyAppReadyInsights.test-utils.d.ts.map +0 -1
  281. package/lib/typescript/module/notifyAppReadyInsights.d.ts +0 -13
  282. package/lib/typescript/module/notifyAppReadyInsights.d.ts.map +0 -1
  283. package/lib/typescript/module/notifyAppReadyInsights.test-utils.d.ts +0 -8
  284. package/lib/typescript/module/notifyAppReadyInsights.test-utils.d.ts.map +0 -1
  285. package/src/catalogCacheNative.spec.ts +0 -63
  286. package/src/checkForUpdate.releaseCatalog.spec.ts +0 -611
  287. package/src/httpClient.spec.ts +0 -132
  288. package/src/index.spec.ts +0 -337
  289. package/src/iosInterop.spec.ts +0 -30
  290. package/src/native.spec.ts +0 -945
  291. package/src/notifyAppReadyInsights.boundary.spec.ts +0 -234
  292. package/src/notifyAppReadyInsights.spec.ts +0 -243
  293. package/src/notifyAppReadyInsights.test-utils.ts +0 -31
  294. package/src/notifyAppReadyInsights.transport.spec.ts +0 -130
  295. package/src/notifyAppReadyInsights.ts +0 -194
  296. package/src/releaseCatalogCache.spec.ts +0 -282
  297. package/src/releaseCatalogCacheNativeContract.spec.ts +0 -80
  298. package/src/store.notification.spec.ts +0 -92
  299. package/src/store.spec.ts +0 -372
  300. package/src/wrap.spec.ts +0 -406
@@ -1,16 +1,21 @@
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
10
16
  import java.io.FileInputStream
11
- import java.io.FileOutputStream
12
17
  import java.net.URL
13
- import java.util.UUID
18
+ import kotlin.coroutines.cancellation.CancellationException
14
19
 
15
20
  data class ChangedAssetDescriptor(
16
21
  val fileUrl: String?,
@@ -25,13 +30,12 @@ data class BsdiffPatchDescriptor(
25
30
  val baseFileHash: String,
26
31
  val patchFileHash: String,
27
32
  val patchUrl: String,
33
+ val byteSize: Long? = null,
28
34
  )
29
35
 
30
36
  data class UpdateProgressPayload(
31
37
  val progress: Double,
32
38
  val artifactType: String,
33
- val downloadedBytes: Long? = null,
34
- val totalBytes: Long? = null,
35
39
  val details: DiffProgressDetails? = null,
36
40
  )
37
41
 
@@ -51,6 +55,30 @@ data class DiffProgressDetails(
51
55
  val files: List<DiffProgressFileSnapshot> = emptyList(),
52
56
  )
53
57
 
58
+ /**
59
+ * How a staged bundle arrived, the `delivery` of the `updateBundle` result:
60
+ * [PATCH] when a bsdiff patch produced at least one file, [MANIFEST] when only
61
+ * the changed files were downloaded (possibly none), and [ARCHIVE] when the
62
+ * full tar.br archive was downloaded.
63
+ */
64
+ enum class BundleDelivery(
65
+ val value: String,
66
+ ) {
67
+ PATCH("patch"),
68
+ MANIFEST("manifest"),
69
+ ARCHIVE("archive"),
70
+ }
71
+
72
+ /**
73
+ * The `updateBundle` result. [patchFallback] is true when a patch for the
74
+ * running bundle could not produce its file, so the whole file was downloaded
75
+ * instead.
76
+ */
77
+ data class UpdateBundleResult(
78
+ val delivery: BundleDelivery,
79
+ val patchFallback: Boolean,
80
+ )
81
+
54
82
  /**
55
83
  * Interface for bundle storage operations
56
84
  */
@@ -84,20 +112,20 @@ interface BundleStorageService {
84
112
  /**
85
113
  * Updates the bundle from the specified URL
86
114
  * @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>)
115
+ * @param manifestUrl URL of the target bundle manifest
116
+ * @param manifestFileHash Manifest hash or signature
89
117
  * @param progressCallback Callback for download progress updates
118
+ * @return how the staged bundle arrived
90
119
  * @throws HotUpdaterException if the update fails
91
120
  */
92
121
  suspend fun updateBundle(
93
122
  bundleId: String,
94
- fileUrl: String?,
95
- fileHash: String?,
96
- manifestUrl: String?,
97
- manifestFileHash: String?,
98
- changedAssets: Map<String, ChangedAssetDescriptor>?,
123
+ manifestUrl: String,
124
+ manifestFileHash: String,
125
+ assets: Map<String, ChangedAssetDescriptor>,
126
+ archiveUrl: String? = null,
99
127
  progressCallback: (UpdateProgressPayload) -> Unit,
100
- )
128
+ ): UpdateBundleResult
101
129
 
102
130
  fun stageReleaseSelection(selection: PersistedSelection): Boolean = false
103
131
 
@@ -133,30 +161,6 @@ interface BundleStorageService {
133
161
  */
134
162
  fun notifyAppReady(): Map<String, Any?>
135
163
 
136
- /**
137
- * Returns the stable install ID for this app installation.
138
- */
139
- fun getInstallId(): String
140
-
141
- /**
142
- * Returns the persisted nullable user id for this app installation.
143
- */
144
- fun getUserId(): String?
145
-
146
- /**
147
- * Returns the persisted nullable username for this app installation.
148
- */
149
- fun getUsername(): String?
150
-
151
- /**
152
- * Persists the optional user envelope for insights.
153
- * Passing null clears the stored user identity.
154
- */
155
- fun setUser(
156
- userId: String?,
157
- username: String?,
158
- )
159
-
160
164
  /**
161
165
  * Gets the crashed bundle history
162
166
  * @return CrashedHistory containing crashed bundles
@@ -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,35 @@ 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}$")
234
+
235
+ /** A patch produced a file that does not match the target, or none at all. */
236
+ private val PATCH_FAILURE =
237
+ UpdateFailure(UpdateFailureStage.INSTALL, UpdateFailureReason.PATCH, resource = UpdateFailureResource.PATCH)
223
238
  }
224
239
 
225
240
  private val releaseStateLock = Any()
226
241
  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
- ),
242
+ private val updateMutex = Mutex()
243
+ private val builtInAssetResolver =
244
+ builtInAssetResolver ?: AndroidBuiltInAssetResolver(
245
+ context,
246
+ File(getBundleStoreDir(), "builtin-index-v1.json"),
241
247
  )
242
- }
243
248
 
244
249
  private fun createDiffProgressFiles(changedAssets: Map<String, ChangedAssetDescriptor>): MutableList<DiffProgressFileSnapshot> =
245
250
  changedAssets.keys
@@ -394,6 +399,10 @@ class BundleFileStorageService(
394
399
  return File(compressedDir, "$safeName.$compression")
395
400
  }
396
401
 
402
+ /**
403
+ * Decompresses a downloaded `.br` asset. A decoding failure is reported as
404
+ * an extract failure and a failure writing the output as a storage failure.
405
+ */
397
406
  private fun decompressBrotliFile(
398
407
  sourceFile: File,
399
408
  targetFile: File,
@@ -408,7 +417,7 @@ class BundleFileStorageService(
408
417
  try {
409
418
  FileInputStream(sourceFile).use { fileInputStream ->
410
419
  BrotliInputStream(fileInputStream).use { brotliInputStream ->
411
- FileOutputStream(tempOutputFile).use { outputStream ->
420
+ LocalStorageOutputStream.open(tempOutputFile).use { outputStream ->
412
421
  brotliInputStream.copyTo(outputStream)
413
422
  }
414
423
  }
@@ -418,15 +427,30 @@ class BundleFileStorageService(
418
427
  targetFile.delete()
419
428
  }
420
429
  if (!tempOutputFile.renameTo(targetFile)) {
421
- tempOutputFile.copyTo(targetFile, overwrite = true)
430
+ storageOperation { tempOutputFile.copyTo(targetFile, overwrite = true) }
422
431
  tempOutputFile.delete()
423
432
  }
433
+ } catch (error: Exception) {
434
+ tempOutputFile.delete()
435
+ throw HotUpdaterException.downloadFailed(
436
+ error,
437
+ if (error is LocalStorageException) {
438
+ UpdateFailure.install(UpdateFailureReason.STORAGE)
439
+ } else {
440
+ UpdateFailure.install(UpdateFailureReason.EXTRACT)
441
+ },
442
+ )
424
443
  } catch (error: Throwable) {
425
444
  tempOutputFile.delete()
426
445
  throw error
427
446
  }
428
447
  }
429
448
 
449
+ /**
450
+ * Tries to produce [targetFile] from a bsdiff patch against the running
451
+ * bundle. A patch for another base bundle or algorithm is not attempted.
452
+ * Once attempted, a failure leaves the file to be downloaded instead.
453
+ */
430
454
  private suspend fun applyPatchAssetIfPossible(
431
455
  assetPath: String,
432
456
  changedAsset: ChangedAssetDescriptor,
@@ -437,91 +461,116 @@ class BundleFileStorageService(
437
461
  tempDir: File,
438
462
  diffFiles: MutableList<DiffProgressFileSnapshot>,
439
463
  progressCallback: (UpdateProgressPayload) -> Unit,
440
- ): Boolean {
441
- val patch = changedAsset.patch ?: return false
464
+ ): PatchOutcome {
465
+ val patch = changedAsset.patch ?: return PatchOutcome.NOT_ATTEMPTED
442
466
  if (patch.algorithm != "bsdiff" || currentBundleId != patch.baseBundleId) {
443
- return false
467
+ return PatchOutcome.NOT_ATTEMPTED
444
468
  }
445
469
 
446
- val sourceDir = activeBundleDir ?: return false
447
- val sourceFile = RelativePathResolver.resolveInside(sourceDir, assetPath) ?: return false
470
+ // The patch targets the running bundle, so from here a miss is a fallback.
471
+ val sourceDir = activeBundleDir ?: return PatchOutcome.FAILED
472
+ val sourceFile = RelativePathResolver.resolveInside(sourceDir, assetPath) ?: return PatchOutcome.FAILED
448
473
  if (!sourceFile.exists() || !HashUtils.verifyHash(sourceFile, patch.baseFileHash)) {
449
- return false
474
+ return PatchOutcome.FAILED
450
475
  }
451
476
 
452
477
  val patchFile = patchTempFile(tempDir, assetPath)
453
478
 
454
- return try {
455
- when (
456
- val patchDownloadResult =
457
- downloadService.downloadFile(
458
- URL(patch.patchUrl),
459
- patchFile,
460
- ) { downloadProgress ->
461
- updateDiffProgressFile(
462
- files = diffFiles,
463
- assetPath = assetPath,
464
- status = "downloading",
465
- progress = downloadProgress.progress,
466
- downloadPath = patchDownloadPath(assetPath),
467
- downloadedBytes = downloadProgress.downloadedBytes,
468
- totalBytes = downloadProgress.totalBytes,
469
- )
470
- emitDiffProgress(
471
- progressCallback = progressCallback,
472
- phase = "downloading",
473
- files = diffFiles,
474
- )
479
+ val patched =
480
+ try {
481
+ when (
482
+ val patchDownloadResult =
483
+ downloadService.downloadFile(
484
+ URL(patch.patchUrl),
485
+ patchFile,
486
+ ) { downloadProgress ->
487
+ updateDiffProgressFile(
488
+ files = diffFiles,
489
+ assetPath = assetPath,
490
+ status = "downloading",
491
+ progress = downloadProgress.progress,
492
+ downloadPath = patchDownloadPath(assetPath),
493
+ downloadedBytes = downloadProgress.downloadedBytes,
494
+ totalBytes = downloadProgress.totalBytes,
495
+ )
496
+ emitDiffProgress(
497
+ progressCallback = progressCallback,
498
+ phase = "downloading",
499
+ files = diffFiles,
500
+ )
501
+ }
502
+ ) {
503
+ is DownloadResult.Error -> {
504
+ false
475
505
  }
476
- ) {
477
- is DownloadResult.Error -> {
478
- false
479
- }
480
506
 
481
- is DownloadResult.Success -> {
482
- if (!HashUtils.verifyHash(patchDownloadResult.file, patch.patchFileHash)) {
483
- false
484
- } else {
485
- withContext(Dispatchers.IO) {
486
- BsdiffPatch.apply(sourceFile, patchDownloadResult.file, targetFile)
487
- }
488
- HashUtils.verifyHash(targetFile, expectedHash).also { patched ->
489
- if (patched) {
490
- Log.d(
491
- TAG,
492
- "HotUpdaterBsdiffPatchApplied asset=$assetPath baseBundleId=${patch.baseBundleId}",
493
- )
507
+ is DownloadResult.Success -> {
508
+ if (!HashUtils.verifyHash(patchDownloadResult.file, patch.patchFileHash)) {
509
+ false
510
+ } else {
511
+ withContext(Dispatchers.IO) {
512
+ BsdiffPatch.apply(sourceFile, patchDownloadResult.file, targetFile)
513
+ }
514
+ HashUtils.verifyHash(targetFile, expectedHash).also { matches ->
515
+ if (matches) {
516
+ Log.d(
517
+ TAG,
518
+ "HotUpdaterBsdiffPatchApplied asset=$assetPath baseBundleId=${patch.baseBundleId}",
519
+ )
520
+ }
494
521
  }
495
522
  }
496
523
  }
497
524
  }
525
+ } catch (_: Exception) {
526
+ false
527
+ } finally {
528
+ patchFile.delete()
529
+ if (!targetFile.exists() || !HashUtils.verifyHash(targetFile, expectedHash)) {
530
+ targetFile.delete()
531
+ }
498
532
  }
499
- } catch (_: Exception) {
500
- false
501
- } finally {
502
- patchFile.delete()
503
- if (!targetFile.exists() || !HashUtils.verifyHash(targetFile, expectedHash)) {
504
- targetFile.delete()
505
- }
506
- }.also { patched ->
507
- if (!patched) {
508
- resetDiffProgressFile(
509
- files = diffFiles,
510
- assetPath = assetPath,
511
- progressCallback = progressCallback,
512
- )
513
- }
533
+ if (!patched) {
534
+ resetDiffProgressFile(
535
+ files = diffFiles,
536
+ assetPath = assetPath,
537
+ progressCallback = progressCallback,
538
+ )
539
+ return PatchOutcome.FAILED
514
540
  }
541
+ return PatchOutcome.APPLIED
542
+ }
543
+
544
+ private enum class PatchOutcome {
545
+ NOT_ATTEMPTED,
546
+ APPLIED,
547
+ FAILED,
548
+ }
549
+
550
+ /** How one file arrived under the per-file plan. */
551
+ private enum class FileDelivery {
552
+ PATCHED,
553
+ DOWNLOADED,
554
+ DOWNLOADED_AFTER_PATCH_FAILURE,
515
555
  }
516
556
 
517
557
  private data class ParsedBundleManifest(
518
558
  val bundleId: String,
519
559
  val assets: Map<String, ParsedManifestAsset>,
560
+ val archive: ParsedArchive?,
520
561
  )
521
562
 
522
563
  private data class ParsedManifestAsset(
523
564
  val fileHash: String,
524
565
  val signature: String?,
566
+ val byteSize: Long?,
567
+ val downloadByteSize: Long?,
568
+ )
569
+
570
+ private data class ParsedArchive(
571
+ val downloadFileHash: String,
572
+ val downloadByteSize: Long,
573
+ val tarByteSize: Long,
525
574
  )
526
575
 
527
576
  private data class ActiveBundleMetadataSnapshot(
@@ -536,14 +585,13 @@ class BundleFileStorageService(
536
585
 
537
586
  // Clean up old bundles if isolationKey format changed
538
587
  checkAndCleanupIfIsolationKeyChanged()
588
+
589
+ recoverInterruptedPromotions()
539
590
  }
540
591
 
592
+ private var hasPreparedLaunch = false
541
593
  private var currentLaunchReport: LaunchReport? = null
542
594
 
543
- @Volatile
544
- private var currentInstallationIdentity: InstallationIdentity? = null
545
- private val installationIdentityLock = Any()
546
-
547
595
  @Volatile
548
596
  private var activeBundleMetadataSnapshot: ActiveBundleMetadataSnapshot? = null
549
597
  private val activeBundleMetadataLock = Any()
@@ -561,15 +609,13 @@ class BundleFileStorageService(
561
609
 
562
610
  private fun getLaunchReportFile(): File = File(getBundleStoreDir(), LaunchReport.LAUNCH_REPORT_FILENAME)
563
611
 
564
- private fun getInstallationIdentityFile(): File = File(getBundleStoreDir(), InstallationIdentity.IDENTITY_FILENAME)
565
-
566
612
  // MARK: - Metadata Operations
567
613
 
568
614
  private fun loadMetadataOrNull(): BundleMetadata? = BundleMetadata.loadFromFile(getMetadataFile(), isolationKey)
569
615
 
570
616
  private fun saveMetadata(metadata: BundleMetadata): Boolean {
571
617
  val updatedMetadata = metadata.copy(isolationKey = isolationKey)
572
- return updatedMetadata.saveToFile(getMetadataFile())
618
+ return metadataWriter(updatedMetadata, getMetadataFile())
573
619
  }
574
620
 
575
621
  private fun loadLaunchReport(): LaunchReport? =
@@ -600,37 +646,10 @@ class BundleFileStorageService(
600
646
  "appVersion"
601
647
  }
602
648
 
603
- private fun loadInstallationIdentity(): InstallationIdentity? =
604
- currentInstallationIdentity ?: InstallationIdentity.loadFromFile(getInstallationIdentityFile())?.also {
605
- currentInstallationIdentity = it
606
- }
607
-
608
- private fun saveInstallationIdentity(identity: InstallationIdentity): Boolean {
609
- currentInstallationIdentity = identity
610
- return identity.saveToFile(getInstallationIdentityFile())
611
- }
612
-
613
- private fun getOrCreateInstallationIdentity(): InstallationIdentity =
614
- synchronized(installationIdentityLock) {
615
- loadInstallationIdentity()?.let { return@synchronized it }
616
-
617
- InstallationIdentity(installId = UUID.randomUUID().toString()).also {
618
- saveInstallationIdentity(it)
619
- }
620
- }
621
-
622
649
  private fun getKnownLaunchBundleId(metadata: BundleMetadata): String =
623
650
  getCurrentVerifiedBundleId(metadata) ?: HotUpdaterImpl.getMinBundleId()
624
651
 
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
- }
652
+ private fun createInitialMetadata(): BundleMetadata = BundleMetadata()
634
653
 
635
654
  private fun extractBundleIdFromCurrentURL(): String? {
636
655
  val currentUrl = preferences.getItem("HotUpdaterBundleURL") ?: return null
@@ -663,7 +682,7 @@ class BundleFileStorageService(
663
682
  .singleOrNull()
664
683
  }
665
684
 
666
- return File(bundleDir, "index.android.bundle").absoluteFile.takeIf { it.isFile }
685
+ return null
667
686
  }
668
687
 
669
688
  private fun findBundleFile(bundleId: String): File? {
@@ -671,6 +690,40 @@ class BundleFileStorageService(
671
690
  return resolveBundleFile(bundleDir, bundleId)
672
691
  }
673
692
 
693
+ private fun installBackupDir(bundleId: String): File = File(getBundleStoreDir(), "$bundleId.install-backup")
694
+
695
+ private fun isCompleteBundleDirectory(
696
+ bundleDir: File,
697
+ bundleId: String,
698
+ ): Boolean {
699
+ val manifest = parseBundleManifestFromFile(File(bundleDir, "manifest.json")) ?: return false
700
+ if (manifest.bundleId != bundleId) return false
701
+ if (resolveBundleFile(bundleDir, bundleId) == null) return false
702
+ return manifest.assets.all { (assetPath, asset) ->
703
+ val file = RelativePathResolver.resolveInside(bundleDir, assetPath)
704
+ file?.isFile == true && runCatching { verifyManifestAssetFileOrThrow(file, asset) }.isSuccess
705
+ }
706
+ }
707
+
708
+ private fun recoverInterruptedPromotions(): Boolean {
709
+ val bundleStoreDir = getBundleStoreDir()
710
+ val backups =
711
+ bundleStoreDir.listFiles { file ->
712
+ file.isDirectory && file.name.endsWith(".install-backup")
713
+ } ?: return true
714
+
715
+ return backups.all { backupDir ->
716
+ val bundleId = backupDir.name.removeSuffix(".install-backup")
717
+ val finalDir = File(bundleStoreDir, bundleId)
718
+ when {
719
+ !finalDir.exists() -> directoryRenamer(backupDir, finalDir)
720
+ isCompleteBundleDirectory(finalDir, bundleId) -> backupDir.deleteRecursively()
721
+ !finalDir.deleteRecursively() -> false
722
+ else -> directoryRenamer(backupDir, finalDir)
723
+ }
724
+ }
725
+ }
726
+
674
727
  private fun getBundleUrlForId(bundleId: String): String? = findBundleFile(bundleId)?.absolutePath
675
728
 
676
729
  private fun getCurrentVerifiedBundleId(metadata: BundleMetadata): String? =
@@ -681,12 +734,16 @@ class BundleFileStorageService(
681
734
  }
682
735
 
683
736
  private fun getActiveBundleId(): String? {
684
- extractBundleIdFromCurrentURL()?.let { return it }
685
-
686
- val metadata = loadMetadataOrNull()
737
+ val metadata = loadMetadataOrNull() ?: return null
738
+ val cachedBundleId = extractBundleIdFromCurrentURL()
739
+ if (cachedBundleId != null &&
740
+ (cachedBundleId == metadata.stagingBundleId || cachedBundleId == metadata.stableBundleId)
741
+ ) {
742
+ return cachedBundleId
743
+ }
687
744
  return when {
688
- metadata?.stagingBundleId != null && !metadata.verificationPending -> metadata.stagingBundleId
689
- metadata?.stableBundleId != null -> metadata.stableBundleId
745
+ metadata.stagingBundleId != null && !metadata.verificationPending -> metadata.stagingBundleId
746
+ metadata.stableBundleId != null -> metadata.stableBundleId
690
747
  else -> null
691
748
  }
692
749
  }
@@ -734,7 +791,7 @@ class BundleFileStorageService(
734
791
 
735
792
  return ActiveBundleMetadataSnapshot(
736
793
  activeBundleId = bundleDir.name,
737
- bundleId = manifestBundleId ?: readCompatibilityBundleIdFromBundleDir(bundleDir),
794
+ bundleId = manifestBundleId,
738
795
  manifest = manifest,
739
796
  )
740
797
  }
@@ -752,25 +809,6 @@ class BundleFileStorageService(
752
809
  return resolveActiveBundleMetadataSnapshot(bundleDir)
753
810
  }
754
811
 
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
812
  private fun readManifestFromBundleDir(bundleDir: File): Map<String, Any?>? {
775
813
  val manifestFile = File(bundleDir, "manifest.json")
776
814
  if (!manifestFile.exists()) {
@@ -810,6 +848,33 @@ class BundleFileStorageService(
810
848
  else -> value
811
849
  }
812
850
 
851
+ private fun parseSafeInteger(value: Any?): Long? {
852
+ val parsed =
853
+ when (value) {
854
+ is Byte -> value.toLong()
855
+ is Short -> value.toLong()
856
+ is Int -> value.toLong()
857
+ is Long -> value
858
+ is Float -> value.toDouble().takeIf { it.isFinite() && it % 1.0 == 0.0 }?.toLong()
859
+ is Double -> value.takeIf { it.isFinite() && it % 1.0 == 0.0 }?.toLong()
860
+ else -> null
861
+ } ?: return null
862
+ return parsed.takeIf { it in 0..MAX_SAFE_INTEGER }
863
+ }
864
+
865
+ private fun parseArchive(value: Any?): ParsedArchive? {
866
+ val archive = value as? Map<*, *> ?: return null
867
+ val downloadFileHash = archive["downloadFileHash"] as? String ?: return null
868
+ if (!SHA256_PATTERN.matches(downloadFileHash)) return null
869
+ val downloadByteSize = parseSafeInteger(archive["downloadByteSize"])?.takeIf { it > 0 } ?: return null
870
+ val tarByteSize = parseSafeInteger(archive["tarByteSize"])?.takeIf { it >= 1024 } ?: return null
871
+ return ParsedArchive(
872
+ downloadFileHash = downloadFileHash,
873
+ downloadByteSize = downloadByteSize,
874
+ tarByteSize = tarByteSize,
875
+ )
876
+ }
877
+
813
878
  private fun parseBundleManifestFromMap(manifest: Map<String, Any?>): ParsedBundleManifest? {
814
879
  val manifestBundleId =
815
880
  (manifest["bundleId"] as? String)
@@ -835,12 +900,15 @@ class BundleFileStorageService(
835
900
  ParsedManifestAsset(
836
901
  fileHash = fileHash,
837
902
  signature = signature?.takeIf { it.isNotBlank() },
903
+ byteSize = parseSafeInteger(assetMap["byteSize"]),
904
+ downloadByteSize = parseSafeInteger(assetMap["downloadByteSize"]),
838
905
  )
839
906
  }
840
907
 
841
908
  return ParsedBundleManifest(
842
909
  bundleId = manifestBundleId,
843
910
  assets = assets,
911
+ archive = parseArchive(manifest["archive"]),
844
912
  )
845
913
  }
846
914
 
@@ -858,52 +926,12 @@ class BundleFileStorageService(
858
926
  }
859
927
  }
860
928
 
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
929
  private fun getActiveBundleDir(): File? {
888
930
  val activeBundleId = getActiveBundleId() ?: return null
889
931
  val bundleDir = File(getBundleStoreDir(), activeBundleId)
890
932
  return bundleDir.takeIf { it.exists() }
891
933
  }
892
934
 
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
935
  private fun copyBundleFile(
908
936
  source: File,
909
937
  destination: File,
@@ -935,14 +963,27 @@ class BundleFileStorageService(
935
963
  SignatureVerifier.verifyHashSignature(context, asset.fileHash, signature)
936
964
  }
937
965
 
966
+ /**
967
+ * @param hashMismatch how a hash mismatch is reported: a downloaded file
968
+ * that does not match is a hash mismatch, while a patched or extracted file
969
+ * that does not match is a patch or extract failure.
970
+ */
938
971
  private fun verifyManifestAssetFileOrThrow(
939
972
  file: File,
940
973
  asset: ParsedManifestAsset,
974
+ hashMismatch: UpdateFailure = UpdateFailure.download(UpdateFailureReason.HASH_MISMATCH),
941
975
  ) {
942
976
  try {
943
977
  verifyManifestAssetFile(file, asset)
944
978
  } catch (e: SignatureVerificationException) {
945
- throw HotUpdaterException.signatureVerificationFailed(e)
979
+ throw HotUpdaterException.signatureVerificationFailed(
980
+ e,
981
+ if (e is SignatureVerificationException.FileHashMismatch) {
982
+ hashMismatch
983
+ } else {
984
+ UpdateFailure.ofVerificationError(e)
985
+ },
986
+ )
946
987
  }
947
988
  }
948
989
 
@@ -1019,7 +1060,7 @@ class BundleFileStorageService(
1019
1060
  getCurrentVerifiedBundleId(metadata)?.takeIf { it != bundleId }
1020
1061
  val incomingSelection = pendingInstallSelections.remove(bundleId)
1021
1062
  if (incomingSelection != null && !isSelectionCurrent(incomingSelection)) {
1022
- throw IllegalStateException("Release catalog selection is stale")
1063
+ throw StaleReleaseSelectionException()
1023
1064
  }
1024
1065
  val currentVerifiedSelection =
1025
1066
  when {
@@ -1046,6 +1087,7 @@ class BundleFileStorageService(
1046
1087
  toBundleId = stagingSelection.bundleId,
1047
1088
  ),
1048
1089
  verificationPending = true,
1090
+ launchInProgress = false,
1049
1091
  updatedAt = System.currentTimeMillis(),
1050
1092
  )
1051
1093
  }
@@ -1076,6 +1118,7 @@ class BundleFileStorageService(
1076
1118
  pendingUpdateStrategy = null,
1077
1119
  pendingTransition = null,
1078
1120
  verificationPending = false,
1121
+ launchInProgress = false,
1079
1122
  updatedAt = System.currentTimeMillis(),
1080
1123
  )
1081
1124
 
@@ -1113,10 +1156,9 @@ class BundleFileStorageService(
1113
1156
  private fun selectLaunch(): LaunchSelection {
1114
1157
  val metadata = loadMetadataOrNull()
1115
1158
  if (metadata == null) {
1116
- val cached = getCachedBundleURL()
1117
1159
  return LaunchSelection(
1118
- bundleUrl = cached ?: getFallbackBundleURL(),
1119
- launchedBundleId = extractBundleIdFromCurrentURL(),
1160
+ bundleUrl = getFallbackBundleURL(),
1161
+ launchedBundleId = null,
1120
1162
  shouldRollbackOnCrash = false,
1121
1163
  )
1122
1164
  }
@@ -1147,10 +1189,9 @@ class BundleFileStorageService(
1147
1189
  }
1148
1190
  }
1149
1191
 
1150
- val cached = getCachedBundleURL()
1151
1192
  return LaunchSelection(
1152
- bundleUrl = cached ?: getFallbackBundleURL(),
1153
- launchedBundleId = extractBundleIdFromCurrentURL(),
1193
+ bundleUrl = getFallbackBundleURL(),
1194
+ launchedBundleId = null,
1154
1195
  shouldRollbackOnCrash = false,
1155
1196
  )
1156
1197
  }
@@ -1302,6 +1343,7 @@ class BundleFileStorageService(
1302
1343
  pendingUpdateStrategy = null,
1303
1344
  pendingTransition = null,
1304
1345
  verificationPending = false,
1346
+ launchInProgress = false,
1305
1347
  updatedAt = System.currentTimeMillis(),
1306
1348
  )
1307
1349
  }
@@ -1354,6 +1396,7 @@ class BundleFileStorageService(
1354
1396
  pendingUpdateStrategy = null,
1355
1397
  pendingTransition = null,
1356
1398
  verificationPending = false,
1399
+ launchInProgress = false,
1357
1400
  updatedAt = System.currentTimeMillis(),
1358
1401
  ),
1359
1402
  )
@@ -1390,32 +1433,18 @@ class BundleFileStorageService(
1390
1433
  }
1391
1434
  }
1392
1435
 
1393
- override fun getInstallId(): String = getOrCreateInstallationIdentity().installId
1394
-
1395
- override fun getUserId(): String? = getOrCreateInstallationIdentity().userId
1396
-
1397
- override fun getUsername(): String? = getOrCreateInstallationIdentity().username
1398
-
1399
- override fun setUser(
1400
- userId: String?,
1401
- username: String?,
1402
- ) = synchronized(installationIdentityLock) {
1403
- saveInstallationIdentity(
1404
- getOrCreateInstallationIdentity().copy(
1405
- userId = userId?.trim()?.takeIf { it.isNotEmpty() },
1406
- username = username?.trim()?.takeIf { it.isNotEmpty() },
1407
- ),
1408
- )
1409
- Unit
1410
- }
1411
-
1412
1436
  // MARK: - Bundle URL Operations
1413
1437
 
1414
1438
  override fun setBundleURL(localPath: String?): Boolean {
1415
1439
  Log.d(TAG, "setBundleURL: $localPath")
1416
- preferences.setItem("HotUpdaterBundleURL", localPath)
1417
- clearActiveBundleMetadataSnapshot()
1418
- return true
1440
+ return try {
1441
+ preferences.setItem("HotUpdaterBundleURL", localPath)
1442
+ clearActiveBundleMetadataSnapshot()
1443
+ true
1444
+ } catch (e: Exception) {
1445
+ Log.e(TAG, "Failed to persist bundle URL", e)
1446
+ false
1447
+ }
1419
1448
  }
1420
1449
 
1421
1450
  override fun getCachedBundleURL(): String? {
@@ -1446,10 +1475,25 @@ class BundleFileStorageService(
1446
1475
  override fun getFallbackBundleURL(): String = "assets://index.android.bundle"
1447
1476
 
1448
1477
  override fun prepareLaunch(pendingRecovery: PendingCrashRecovery?): LaunchSelection {
1478
+ recoverInterruptedPromotions()
1449
1479
  saveLaunchReport(null)
1450
1480
  applyPendingRecoveryIfNeeded(pendingRecovery)
1481
+ // Only a new storage instance consumes an unfinished launch. Repeated bundle
1482
+ // lookups or bridge reloads in this process must not reject its own launch.
1483
+ if (!hasPreparedLaunch) {
1484
+ val metadata = loadMetadataOrNull()
1485
+ if (metadata?.verificationPending == true && metadata.launchInProgress) {
1486
+ metadata.stagingBundleId?.let { rollbackPendingBundle(it) }
1487
+ }
1488
+ }
1489
+ hasPreparedLaunch = true
1451
1490
 
1452
1491
  val selection = selectLaunch()
1492
+ if (selection.shouldRollbackOnCrash) {
1493
+ loadMetadataOrNull()?.let { metadata ->
1494
+ saveMetadata(metadata.copy(launchInProgress = true))
1495
+ }
1496
+ }
1453
1497
  Log.d(
1454
1498
  TAG,
1455
1499
  "prepareLaunch: bundleId=${selection.launchedBundleId} shouldRollback=${selection.shouldRollbackOnCrash} url=${selection.bundleUrl}",
@@ -1459,49 +1503,35 @@ class BundleFileStorageService(
1459
1503
 
1460
1504
  override suspend fun updateBundle(
1461
1505
  bundleId: String,
1462
- fileUrl: String?,
1463
- fileHash: String?,
1464
- manifestUrl: String?,
1465
- manifestFileHash: String?,
1466
- changedAssets: Map<String, ChangedAssetDescriptor>?,
1506
+ manifestUrl: String,
1507
+ manifestFileHash: String,
1508
+ assets: Map<String, ChangedAssetDescriptor>,
1509
+ archiveUrl: String?,
1467
1510
  progressCallback: (UpdateProgressPayload) -> Unit,
1468
- ) {
1511
+ ): UpdateBundleResult =
1512
+ updateMutex.withLock {
1513
+ updateBundleSerialized(
1514
+ bundleId = bundleId,
1515
+ manifestUrl = manifestUrl,
1516
+ manifestFileHash = manifestFileHash,
1517
+ changedAssets = assets,
1518
+ archiveUrl = archiveUrl,
1519
+ progressCallback = progressCallback,
1520
+ )
1521
+ }
1522
+
1523
+ private suspend fun updateBundleSerialized(
1524
+ bundleId: String,
1525
+ manifestUrl: String,
1526
+ manifestFileHash: String,
1527
+ changedAssets: Map<String, ChangedAssetDescriptor>,
1528
+ archiveUrl: String?,
1529
+ progressCallback: (UpdateProgressPayload) -> Unit,
1530
+ ): UpdateBundleResult {
1469
1531
  Log.d(
1470
1532
  TAG,
1471
- "updateBundle bundleId $bundleId fileUrl $fileUrl fileHash $fileHash manifestUrl $manifestUrl",
1533
+ "updateBundle bundleId $bundleId manifestUrl $manifestUrl",
1472
1534
  )
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
1535
 
1506
1536
  // Check if bundle is in crashed history
1507
1537
  if (isBundleInCrashedHistory(bundleId)) {
@@ -1509,299 +1539,444 @@ class BundleFileStorageService(
1509
1539
  throw HotUpdaterException.bundleInCrashedHistory(bundleId)
1510
1540
  }
1511
1541
 
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
1542
  val bundleStoreDir = getBundleStoreDir()
1522
- if (!bundleStoreDir.exists()) {
1523
- bundleStoreDir.mkdirs()
1543
+ updateStep(UpdateFailureStage.INSTALL, resource = null) {
1544
+ if (!bundleStoreDir.exists()) {
1545
+ bundleStoreDir.mkdirs()
1546
+ }
1547
+ if (!recoverInterruptedPromotions()) {
1548
+ throw HotUpdaterException.moveOperationFailed()
1549
+ }
1524
1550
  }
1525
1551
 
1526
1552
  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)
1553
+ return withContext(Dispatchers.IO) {
1554
+ updateBundleFromManifest(
1555
+ bundleId = bundleId,
1556
+ manifestUrl = manifestUrl,
1557
+ manifestFileHash = manifestFileHash,
1558
+ changedAssets = changedAssets,
1559
+ archiveUrl = archiveUrl,
1560
+ bundleStoreDir = bundleStoreDir,
1561
+ finalBundleDir = finalBundleDir,
1562
+ progressCallback = progressCallback,
1563
+ )
1564
+ }
1565
+ }
1544
1566
 
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
- }
1567
+ /**
1568
+ * Runs one step of the update pipeline and gives its failure a
1569
+ * classification for JS: [resource] is what the step fetches or applies,
1570
+ * and an error nothing anticipated is reported as unknown in [stage].
1571
+ */
1572
+ private inline fun <T> updateStep(
1573
+ stage: UpdateFailureStage,
1574
+ resource: UpdateFailureResource?,
1575
+ block: () -> T,
1576
+ ): T =
1577
+ try {
1578
+ block()
1579
+ } catch (e: Exception) {
1580
+ throw classifyUpdateFailure(e, stage, resource)
1551
1581
  }
1552
1582
 
1553
- val hasManifestDrivenArtifacts =
1554
- !manifestUrl.isNullOrEmpty() &&
1555
- !manifestFileHash.isNullOrEmpty() &&
1556
- changedAssets != null
1583
+ /**
1584
+ * Gives a failure of the update pipeline its classification for JS.
1585
+ * Failures that are not update failures pass through unchanged, and an
1586
+ * already classified failure only gains a [resource] it did not name.
1587
+ */
1588
+ private fun classifyUpdateFailure(
1589
+ error: Exception,
1590
+ stage: UpdateFailureStage,
1591
+ resource: UpdateFailureResource?,
1592
+ ): Exception =
1593
+ when (error) {
1594
+ is StaleReleaseSelectionException, is CancellationException -> {
1595
+ error
1596
+ }
1557
1597
 
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,
1598
+ is HotUpdaterException -> {
1599
+ error.withResource(resource)
1600
+ }
1601
+
1602
+ else -> {
1603
+ HotUpdaterException.unexpected(
1604
+ error,
1605
+ UpdateFailure.ofUnexpectedError(error, stage).copy(resource = resource),
1580
1606
  )
1581
1607
  }
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.",
1586
- )
1587
1608
  }
1588
1609
 
1589
- val tempDirName = "bundle-temp"
1590
- val tempDir = File(baseDir, tempDirName)
1591
- if (tempDir.exists()) {
1592
- tempDir.deleteRecursively()
1610
+ /** The rejection for a download that returned [error]. */
1611
+ private fun downloadError(error: Exception): HotUpdaterException =
1612
+ if (error is IncompleteDownloadException) {
1613
+ HotUpdaterException.incompleteDownload(error.expectedSize, error.actualSize)
1614
+ } else {
1615
+ HotUpdaterException.downloadFailed(
1616
+ error,
1617
+ UpdateFailure.ofDownloadError(error) { NetworkState.hasNoActiveNetwork(context) },
1618
+ )
1593
1619
  }
1594
- tempDir.mkdirs()
1595
1620
 
1596
- withContext(Dispatchers.IO) {
1597
- val downloadUrl = URL(fileUrl)
1598
-
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"
1605
- }
1606
- val tempBundleFile = File(tempDir, bundleFileName)
1621
+ private suspend fun downloadManifestAsset(
1622
+ assetPath: String,
1623
+ expectedAsset: ParsedManifestAsset,
1624
+ changedAsset: ChangedAssetDescriptor,
1625
+ currentBundleId: String?,
1626
+ activeBundleDir: File?,
1627
+ targetFile: File,
1628
+ tempDir: File,
1629
+ progressFile: DiffProgressFileSnapshot,
1630
+ progressCallback: (UpdateProgressPayload) -> Unit,
1631
+ ): FileDelivery {
1632
+ val expectedHash = expectedAsset.fileHash
1633
+ val diffFiles = mutableListOf(progressFile)
1634
+ val patchOutcome =
1635
+ applyPatchAssetIfPossible(
1636
+ assetPath = assetPath,
1637
+ changedAsset = changedAsset,
1638
+ currentBundleId = currentBundleId,
1639
+ activeBundleDir = activeBundleDir,
1640
+ targetFile = targetFile,
1641
+ expectedHash = expectedHash,
1642
+ tempDir = tempDir,
1643
+ diffFiles = diffFiles,
1644
+ progressCallback = progressCallback,
1645
+ )
1646
+ if (patchOutcome == PatchOutcome.APPLIED) {
1647
+ verifyManifestAssetFileOrThrow(
1648
+ targetFile,
1649
+ expectedAsset,
1650
+ hashMismatch = PATCH_FAILURE,
1651
+ )
1652
+ updateDiffProgressFile(
1653
+ files = diffFiles,
1654
+ assetPath = assetPath,
1655
+ status = "downloaded",
1656
+ progress = 1.0,
1657
+ )
1658
+ emitDiffProgress(
1659
+ progressCallback = progressCallback,
1660
+ phase =
1661
+ if (diffFiles.all { it.status == "downloaded" }) {
1662
+ "finalizing"
1663
+ } else {
1664
+ "downloading"
1665
+ },
1666
+ files = diffFiles,
1667
+ )
1668
+ return FileDelivery.PATCHED
1669
+ }
1670
+
1671
+ val changedAssetFileUrl =
1672
+ changedAsset.fileUrl
1673
+ ?: run {
1674
+ updateDiffProgressFile(
1675
+ files = diffFiles,
1676
+ assetPath = assetPath,
1677
+ status = "failed",
1678
+ progress = 0.0,
1679
+ )
1680
+ emitDiffProgress(
1681
+ progressCallback = progressCallback,
1682
+ phase = "downloading",
1683
+ files = diffFiles,
1684
+ )
1685
+ throw HotUpdaterException.downloadFailed(
1686
+ IllegalStateException("Changed asset fileUrl missing and patch could not be applied: $assetPath"),
1687
+ PATCH_FAILURE,
1688
+ )
1689
+ }
1607
1690
 
1608
- // Download the file (0% - 80%)
1609
- // Disk space check will be performed in fileSizeCallback
1610
- var diskSpaceError: HotUpdaterException? = null
1691
+ val downloadPath = downloadPathForChangedAsset(assetPath, changedAsset)
1692
+ val downloadedAssetFile =
1693
+ if (changedAsset.fileCompression == "br") {
1694
+ compressedTempFile(tempDir, assetPath, "br")
1695
+ } else {
1696
+ targetFile
1697
+ }
1611
1698
 
1612
- val downloadResult =
1699
+ when (
1700
+ val assetDownloadResult =
1613
1701
  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
- },
1702
+ URL(changedAssetFileUrl),
1703
+ downloadedAssetFile,
1638
1704
  ) { 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,
1705
+ updateDiffProgressFile(
1706
+ files = diffFiles,
1707
+ assetPath = assetPath,
1708
+ status = "downloading",
1709
+ progress = downloadProgress.progress,
1710
+ downloadPath = downloadPath,
1711
+ downloadedBytes = downloadProgress.downloadedBytes,
1712
+ totalBytes = downloadProgress.totalBytes,
1713
+ )
1714
+ emitDiffProgress(
1715
+ progressCallback = progressCallback,
1716
+ phase = "downloading",
1717
+ files = diffFiles,
1645
1718
  )
1646
1719
  }
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
1720
+ ) {
1721
+ is DownloadResult.Error -> {
1722
+ updateDiffProgressFile(
1723
+ files = diffFiles,
1724
+ assetPath = assetPath,
1725
+ status = "failed",
1726
+ progress =
1727
+ diffFiles
1728
+ .firstOrNull { it.path == assetPath }
1729
+ ?.progress ?: 0.0,
1730
+ )
1731
+ emitDiffProgress(
1732
+ progressCallback = progressCallback,
1733
+ phase = "downloading",
1734
+ files = diffFiles,
1735
+ )
1736
+ throw downloadError(assetDownloadResult.exception)
1653
1737
  }
1654
1738
 
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,
1739
+ is DownloadResult.Success -> {
1740
+ try {
1741
+ if (changedAsset.fileCompression == "br") {
1742
+ decompressBrotliFile(
1743
+ assetDownloadResult.file,
1744
+ targetFile,
1666
1745
  )
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
1746
  }
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()
1747
+ verifyManifestAssetFileOrThrow(
1748
+ targetFile,
1749
+ expectedAsset,
1750
+ )
1751
+ } catch (e: Exception) {
1752
+ updateDiffProgressFile(
1753
+ files = diffFiles,
1754
+ assetPath = assetPath,
1755
+ status = "failed",
1756
+ progress = 1.0,
1757
+ )
1758
+ emitDiffProgress(
1759
+ progressCallback = progressCallback,
1760
+ phase = "downloading",
1761
+ files = diffFiles,
1762
+ )
1763
+ if (e is HotUpdaterException) {
1764
+ throw e
1710
1765
  }
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()
1766
+ // The bytes arrived; what failed is reading them back.
1767
+ throw HotUpdaterException.downloadFailed(
1768
+ e,
1769
+ UpdateFailure.ofUnexpectedError(e, UpdateFailureStage.DOWNLOAD),
1770
+ )
1771
+ } finally {
1772
+ if (changedAsset.fileCompression == "br") {
1773
+ assetDownloadResult.file.delete()
1719
1774
  }
1775
+ }
1776
+ updateDiffProgressFile(
1777
+ files = diffFiles,
1778
+ assetPath = assetPath,
1779
+ status = "downloaded",
1780
+ progress = 1.0,
1781
+ )
1782
+ emitDiffProgress(
1783
+ progressCallback = progressCallback,
1784
+ phase = if (diffFiles.all { it.status == "downloaded" }) "finalizing" else "downloading",
1785
+ files = diffFiles,
1786
+ )
1787
+ }
1788
+ }
1789
+ return if (patchOutcome == PatchOutcome.FAILED) {
1790
+ FileDelivery.DOWNLOADED_AFTER_PATCH_FAILURE
1791
+ } else {
1792
+ FileDelivery.DOWNLOADED
1793
+ }
1794
+ }
1720
1795
 
1721
- // 5) Log extracted bundle file size
1722
- val bundleSize = extractedBundleFile.length()
1723
- Log.d("BundleStorage", "Extracted bundle size: $bundleSize bytes")
1796
+ private fun pruneStagingDirectory(
1797
+ directory: File,
1798
+ assets: Set<String>,
1799
+ prefix: String = "",
1800
+ ) {
1801
+ directory.listFiles()?.forEach { file ->
1802
+ val relativePath = prefix + file.name
1803
+ if (file.canonicalFile != File(directory.canonicalFile, file.name)) {
1804
+ if (!file.delete()) {
1805
+ throw HotUpdaterException.storageFailed("Cannot remove staging link: $relativePath")
1806
+ }
1807
+ } else if (file.isDirectory && assets.any { it.startsWith("$relativePath/") }) {
1808
+ pruneStagingDirectory(file, assets, "$relativePath/")
1809
+ } else if (!file.isFile || relativePath !in assets) {
1810
+ if (!file.deleteRecursively()) {
1811
+ throw HotUpdaterException.storageFailed("Cannot remove stale staging file: $relativePath")
1812
+ }
1813
+ }
1814
+ }
1815
+ }
1724
1816
 
1725
- // 6) If the realDir (bundle-store/<bundleId>) exists, delete it
1726
- if (finalBundleDir.exists()) {
1727
- finalBundleDir.deleteRecursively()
1728
- }
1817
+ private fun selectArchive(
1818
+ manifest: ParsedBundleManifest,
1819
+ downloads: Map<String, ChangedAssetDescriptor>,
1820
+ archiveUrl: String?,
1821
+ ): ParsedArchive? {
1822
+ if (archiveUrl.isNullOrBlank() || downloads.size < 2) return null
1823
+ val archive = manifest.archive ?: return null
1824
+ if (manifest.assets.values.any { it.byteSize == null }) return null
1825
+
1826
+ var individualBytes = 0L
1827
+ for ((assetPath, descriptor) in downloads) {
1828
+ val originalBytes = manifest.assets.getValue(assetPath).downloadByteSize ?: return null
1829
+ val expectedBytes =
1830
+ descriptor.patch?.let { patch ->
1831
+ val patchBytes = patch.byteSize?.takeIf { it in 0..MAX_SAFE_INTEGER } ?: return null
1832
+ minOf(originalBytes, patchBytes)
1833
+ } ?: originalBytes
1834
+ if (individualBytes > MAX_SAFE_INTEGER - expectedBytes) return null
1835
+ individualBytes += expectedBytes
1836
+ }
1837
+ var logicalBytes = 0L
1838
+ for (asset in manifest.assets.values) {
1839
+ val byteSize = asset.byteSize ?: return null
1840
+ if (logicalBytes > MAX_SAFE_INTEGER - byteSize) return null
1841
+ logicalBytes += byteSize
1842
+ }
1843
+ if (archive.tarByteSize < logicalBytes) return null
1844
+ val tarFramingBytes = archive.tarByteSize - logicalBytes
1845
+ if (individualBytes > MAX_SAFE_INTEGER - tarFramingBytes) return null
1846
+ val framedByteLimit = individualBytes + tarFramingBytes
1847
+
1848
+ if (archive.downloadByteSize <= individualBytes) return archive
1849
+ if (downloads.size != manifest.assets.size || downloads.values.any { it.patch != null }) return null
1850
+ return archive.takeIf { it.downloadByteSize <= framedByteLimit }
1851
+ }
1729
1852
 
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
- }
1853
+ private suspend fun tryInstallArchive(
1854
+ archiveUrl: String,
1855
+ archive: ParsedArchive,
1856
+ manifest: ParsedBundleManifest,
1857
+ tempDir: File,
1858
+ bundleStoreDir: File,
1859
+ tmpDir: File,
1860
+ bundleId: String,
1861
+ progressCallback: (UpdateProgressPayload) -> Unit,
1862
+ ): MutableList<DiffProgressFileSnapshot>? {
1863
+ val archiveScratch = File(tempDir, "archive")
1864
+ val archiveFile = File(archiveScratch, "bundle.tar.br")
1865
+ val extractedDir = File(bundleStoreDir, "$bundleId.archive.tmp")
1866
+ val localBackupDir = File(bundleStoreDir, "$bundleId.local.tmp")
1867
+ archiveScratch.deleteRecursively()
1868
+ extractedDir.deleteRecursively()
1869
+ localBackupDir.deleteRecursively()
1870
+ archiveScratch.mkdirs()
1871
+
1872
+ val progressFiles =
1873
+ mutableListOf(
1874
+ DiffProgressFileSnapshot(
1875
+ path = "bundle.tar.br",
1876
+ downloadPath = "bundle.tar.br",
1877
+ status = "pending",
1878
+ progress = 0.0,
1879
+ order = 0,
1880
+ ),
1881
+ )
1882
+ emitDiffProgress(progressCallback, "downloading", progressFiles)
1750
1883
 
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()
1884
+ return try {
1885
+ when (
1886
+ val result =
1887
+ downloadService.downloadFileOnce(
1888
+ URL(archiveUrl),
1889
+ archiveFile,
1890
+ ) { progress ->
1891
+ updateDiffProgressFile(
1892
+ files = progressFiles,
1893
+ assetPath = "bundle.tar.br",
1894
+ status = "downloading",
1895
+ progress = progress.progress,
1896
+ downloadPath = "bundle.tar.br",
1897
+ downloadedBytes = progress.downloadedBytes,
1898
+ totalBytes = progress.totalBytes,
1899
+ )
1900
+ emitDiffProgress(progressCallback, "downloading", progressFiles)
1758
1901
  }
1902
+ ) {
1903
+ is DownloadResult.Error -> throw result.exception
1904
+ is DownloadResult.Success -> Unit
1905
+ }
1759
1906
 
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()
1907
+ if (archiveFile.length() != archive.downloadByteSize) {
1908
+ throw IllegalStateException("Archive compressed size mismatch")
1909
+ }
1910
+ if (!HashUtils.verifyHash(archiveFile, archive.downloadFileHash)) {
1911
+ throw IllegalStateException("Archive compressed hash mismatch")
1912
+ }
1778
1913
 
1779
- // 12) Keep the fallback bundle and the new staging bundle.
1780
- cleanupOldBundles(bundleStoreDir, updatedMetadata.stableBundleId, bundleId)
1914
+ TarBrArchiveExtractor.extract(
1915
+ archiveFile = archiveFile,
1916
+ destination = extractedDir,
1917
+ expectedTarByteSize = archive.tarByteSize,
1918
+ expectedFiles = manifest.assets.mapValues { it.value.byteSize!! },
1919
+ )
1920
+ manifest.assets.forEach { (assetPath, expectedAsset) ->
1921
+ val extractedFile =
1922
+ RelativePathResolver.resolveInside(extractedDir, assetPath)
1923
+ ?: throw IllegalStateException("Archive output path is invalid")
1924
+ verifyManifestAssetFileOrThrow(
1925
+ extractedFile,
1926
+ expectedAsset,
1927
+ hashMismatch = UpdateFailure.install(UpdateFailureReason.EXTRACT),
1928
+ )
1929
+ }
1781
1930
 
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
- }
1931
+ if (!tmpDir.renameTo(localBackupDir)) {
1932
+ throw IllegalStateException("Cannot preserve locally prepared staging directory")
1933
+ }
1934
+ if (!extractedDir.renameTo(tmpDir)) {
1935
+ check(localBackupDir.renameTo(tmpDir)) { "Cannot restore locally prepared staging directory" }
1936
+ throw IllegalStateException("Cannot promote verified archive extraction")
1937
+ }
1938
+ localBackupDir.deleteRecursively()
1939
+
1940
+ updateDiffProgressFile(
1941
+ files = progressFiles,
1942
+ assetPath = "bundle.tar.br",
1943
+ status = "downloaded",
1944
+ progress = 1.0,
1945
+ downloadedBytes = archive.downloadByteSize,
1946
+ totalBytes = archive.downloadByteSize,
1947
+ )
1948
+ emitDiffProgress(progressCallback, "finalizing", progressFiles)
1949
+ progressFiles
1950
+ } catch (error: Exception) {
1951
+ Log.w(TAG, "Archive optimization failed; using individual files: ${error.message}")
1952
+ val canUseIndividualPlan =
1953
+ tmpDir.exists() ||
1954
+ (localBackupDir.exists() && localBackupDir.renameTo(tmpDir))
1955
+ if (!canUseIndividualPlan) {
1956
+ // Only the staging directory swap can leave tmpDir missing.
1957
+ throw HotUpdaterException.unexpected(error, UpdateFailure.install(UpdateFailureReason.STORAGE))
1785
1958
  }
1959
+ extractedDir.deleteRecursively()
1960
+ null
1961
+ } finally {
1962
+ archiveScratch.deleteRecursively()
1963
+ extractedDir.deleteRecursively()
1964
+ if (tmpDir.exists()) localBackupDir.deleteRecursively()
1786
1965
  }
1787
1966
  }
1788
1967
 
1789
- /**
1790
- * Removes old bundles except for the specified bundle IDs, and any leftover .tmp directories
1791
- */
1792
1968
  private suspend fun updateBundleFromManifest(
1793
1969
  bundleId: String,
1794
1970
  manifestUrl: String,
1795
1971
  manifestFileHash: String,
1796
1972
  changedAssets: Map<String, ChangedAssetDescriptor>,
1973
+ archiveUrl: String?,
1797
1974
  bundleStoreDir: File,
1798
1975
  finalBundleDir: File,
1799
1976
  progressCallback: (UpdateProgressPayload) -> Unit,
1800
- ) {
1977
+ ): UpdateBundleResult {
1801
1978
  val activeBundleDir = getActiveBundleDir()
1802
1979
  val currentBundleId = getBundleId()
1803
- val currentManifest =
1804
- getActiveBundleMetadataSnapshot()?.manifest?.let(::parseBundleManifestFromMap)
1805
1980
  val baseDir =
1806
1981
  fileSystem.getInternalFilesDir() ?: bundleStoreDir.parentFile ?: bundleStoreDir
1807
1982
  val tempDir = File(baseDir, "bundle-manifest-temp")
@@ -1812,345 +1987,284 @@ class BundleFileStorageService(
1812
1987
  }
1813
1988
  tempDir.mkdirs()
1814
1989
 
1815
- val diffFiles = createDiffProgressFiles(changedAssets)
1990
+ var diffFiles = mutableListOf<DiffProgressFileSnapshot>()
1816
1991
 
1817
1992
  try {
1818
1993
  val manifestFile = File(tempDir, "manifest.json")
1819
- emitDiffProgress(
1820
- progressCallback = progressCallback,
1821
- phase = "manifest",
1822
- files = diffFiles,
1823
- manifestProgress = 0.0,
1824
- )
1825
- when (
1826
- val manifestDownloadResult =
1827
- downloadService.downloadFile(
1828
- URL(manifestUrl),
1829
- manifestFile,
1830
- ) { downloadProgress ->
1831
- emitDiffProgress(
1832
- progressCallback = progressCallback,
1833
- phase = "manifest",
1834
- files = diffFiles,
1835
- manifestProgress = downloadProgress.progress,
1836
- )
1837
- }
1838
- ) {
1839
- is DownloadResult.Error -> {
1840
- if (manifestDownloadResult.exception is IncompleteDownloadException) {
1841
- val incompleteEx =
1842
- manifestDownloadResult.exception as IncompleteDownloadException
1843
- throw HotUpdaterException.incompleteDownload(
1844
- incompleteEx.expectedSize,
1845
- incompleteEx.actualSize,
1846
- )
1847
- }
1848
- throw HotUpdaterException.downloadFailed(manifestDownloadResult.exception)
1849
- }
1850
-
1851
- is DownloadResult.Success -> {
1852
- Unit
1853
- }
1854
- }
1855
-
1856
- try {
1857
- SignatureVerifier.verifyBundle(context, manifestFile, manifestFileHash)
1858
- } catch (e: SignatureVerificationException) {
1859
- throw HotUpdaterException.signatureVerificationFailed(e)
1860
- }
1861
-
1862
- val targetManifest = parseBundleManifestFromFile(manifestFile) ?: throw HotUpdaterException.invalidBundle()
1863
- if (targetManifest.bundleId != bundleId) {
1864
- throw HotUpdaterException.invalidBundle()
1865
- }
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()
1874
- }
1875
- tmpDir.mkdirs()
1876
-
1877
- val targetEntries = targetManifest.assets.entries.toList()
1878
- 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
- )
1898
- }
1899
- copyBundleFile(sourceFile, targetFile)
1900
- verifyManifestAssetFileOrThrow(targetFile, expectedAsset)
1901
- return@forEachIndexed
1902
- }
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,
1948
- 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
- )
1994
+ val targetManifest =
1995
+ updateStep(UpdateFailureStage.DOWNLOAD, UpdateFailureResource.MANIFEST) {
1960
1996
  emitDiffProgress(
1961
1997
  progressCallback = progressCallback,
1962
- phase =
1963
- if (diffFiles.all { it.status == "downloaded" }) {
1964
- "finalizing"
1965
- } else {
1966
- "downloading"
1967
- },
1998
+ phase = "manifest",
1968
1999
  files = diffFiles,
2000
+ manifestProgress = 0.0,
1969
2001
  )
1970
- return@forEachIndexed
1971
- }
2002
+ when (
2003
+ val manifestDownloadResult =
2004
+ downloadService.downloadFile(
2005
+ URL(manifestUrl),
2006
+ manifestFile,
2007
+ ) { downloadProgress ->
2008
+ emitDiffProgress(
2009
+ progressCallback = progressCallback,
2010
+ phase = "manifest",
2011
+ files = diffFiles,
2012
+ manifestProgress = downloadProgress.progress,
2013
+ )
2014
+ }
2015
+ ) {
2016
+ is DownloadResult.Error -> {
2017
+ throw downloadError(manifestDownloadResult.exception)
2018
+ }
1972
2019
 
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
- )
2020
+ is DownloadResult.Success -> {
2021
+ Unit
1990
2022
  }
2023
+ }
1991
2024
 
1992
- val downloadPath = downloadPathForChangedAsset(assetPath, changedAsset)
1993
- val downloadedAssetFile =
1994
- if (changedAsset.fileCompression == "br") {
1995
- compressedTempFile(tempDir, assetPath, "br")
1996
- } else {
1997
- targetFile
2025
+ try {
2026
+ SignatureVerifier.verifyBundle(context, manifestFile, manifestFileHash)
2027
+ } catch (e: SignatureVerificationException) {
2028
+ throw HotUpdaterException.signatureVerificationFailed(e)
1998
2029
  }
1999
2030
 
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
- )
2031
+ val manifest = parseBundleManifestFromFile(manifestFile) ?: throw HotUpdaterException.invalidBundle()
2032
+ if (manifest.bundleId != bundleId) {
2033
+ throw HotUpdaterException.invalidBundle()
2034
+ }
2035
+ // Reuse never bypasses the complete artifact contract.
2036
+ if (manifest.assets.keys != changedAssets.keys) throw HotUpdaterException.invalidBundle()
2037
+ manifest.assets.forEach { (path, asset) ->
2038
+ val descriptor = changedAssets.getValue(path)
2039
+ if (RelativePathResolver.normalizeRelativePath(path) != path ||
2040
+ path == "manifest.json" ||
2041
+ descriptor.fileUrl.isNullOrBlank() ||
2042
+ !descriptor.fileHash.equals(asset.fileHash, ignoreCase = true) ||
2043
+ RelativePathResolver.resolveInside(tmpDir, path) == null
2044
+ ) {
2045
+ throw HotUpdaterException.invalidBundle()
2020
2046
  }
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,
2047
+ }
2048
+ manifest
2049
+ }
2050
+ updateStep(UpdateFailureStage.INSTALL, resource = null) {
2051
+ if (!tmpDir.isDirectory && !tmpDir.mkdirs()) throw HotUpdaterException.directoryCreationFailed()
2052
+ pruneStagingDirectory(tmpDir, targetManifest.assets.keys)
2053
+ }
2054
+ val downloads =
2055
+ updateStep(UpdateFailureStage.INSTALL, UpdateFailureResource.FILE) {
2056
+ val targetEntries = targetManifest.assets.entries.sortedBy { it.key }
2057
+ val localDirectories = listOfNotNull(activeBundleDir, finalBundleDir).distinct().filter { it.isDirectory }
2058
+ val downloads = mutableMapOf<String, ChangedAssetDescriptor>()
2059
+ targetEntries.forEachIndexed { index, (assetPath, expectedAsset) ->
2060
+ progressCallback(
2061
+ UpdateProgressPayload(
2062
+ progress = 0.15 + 0.05 * index / targetEntries.size.coerceAtLeast(1),
2063
+ artifactType = "diff",
2064
+ details = DiffProgressDetails(totalFilesCount = 0, completedFilesCount = 0),
2065
+ ),
2036
2066
  )
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
- )
2067
+ val targetFile = RelativePathResolver.resolveInside(tmpDir, assetPath) ?: throw HotUpdaterException.invalidBundle()
2068
+ targetFile.parentFile?.mkdirs()
2069
+ if (targetFile.isFile && runCatching { verifyManifestAssetFileOrThrow(targetFile, expectedAsset) }.isSuccess) {
2070
+ return@forEachIndexed
2044
2071
  }
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()
2072
+ if (targetFile.exists()) targetFile.delete()
2073
+ var reused = false
2074
+ for (directory in localDirectories) {
2075
+ val sourceFile = RelativePathResolver.resolveInside(directory, assetPath) ?: continue
2076
+ val sourceMatches =
2077
+ sourceFile.isFile && runCatching { verifyManifestAssetFileOrThrow(sourceFile, expectedAsset) }.isSuccess
2078
+ if (!sourceMatches) continue
2079
+ if (runCatching {
2080
+ copyBundleFile(sourceFile, targetFile)
2081
+ verifyManifestAssetFileOrThrow(targetFile, expectedAsset)
2082
+ }.isSuccess
2083
+ ) {
2084
+ reused = true
2085
+ break
2079
2086
  }
2087
+ targetFile.delete()
2080
2088
  }
2081
- updateDiffProgressFile(
2082
- files = diffFiles,
2083
- assetPath = assetPath,
2084
- status = "downloaded",
2085
- progress = 1.0,
2086
- )
2087
- emitDiffProgress(
2089
+ if (reused) return@forEachIndexed
2090
+ if (builtInAssetResolver.copyIfMatches(assetPath, expectedAsset.fileHash, targetFile)) {
2091
+ verifyManifestAssetFileOrThrow(targetFile, expectedAsset)
2092
+ return@forEachIndexed
2093
+ }
2094
+ downloads[assetPath] = changedAssets.getValue(assetPath)
2095
+ }
2096
+ downloads
2097
+ }
2098
+ val selectedArchive = selectArchive(targetManifest, downloads, archiveUrl)
2099
+ val archiveProgress =
2100
+ if (selectedArchive != null) {
2101
+ updateStep(UpdateFailureStage.DOWNLOAD, UpdateFailureResource.ARCHIVE) {
2102
+ tryInstallArchive(
2103
+ archiveUrl = archiveUrl!!,
2104
+ archive = selectedArchive,
2105
+ manifest = targetManifest,
2106
+ tempDir = tempDir,
2107
+ bundleStoreDir = bundleStoreDir,
2108
+ tmpDir = tmpDir,
2109
+ bundleId = bundleId,
2088
2110
  progressCallback = progressCallback,
2089
- phase = if (diffFiles.all { it.status == "downloaded" }) "finalizing" else "downloading",
2090
- files = diffFiles,
2091
2111
  )
2092
2112
  }
2113
+ } else {
2114
+ null
2093
2115
  }
2094
- }
2095
2116
 
2096
- emitDiffProgress(
2097
- progressCallback = progressCallback,
2098
- phase = "finalizing",
2099
- files = diffFiles,
2100
- )
2101
-
2102
- writeBundleManifestFile(File(tmpDir, "manifest.json"), targetManifest)
2117
+ val fileDeliveries =
2118
+ if (archiveProgress != null) {
2119
+ diffFiles = archiveProgress
2120
+ emptyList()
2121
+ } else {
2122
+ diffFiles = createDiffProgressFiles(downloads)
2123
+ emitDiffProgress(progressCallback, "downloading", diffFiles)
2124
+ val progressLock = Any()
2125
+ coroutineScope {
2126
+ val permits = Semaphore(4)
2127
+ diffFiles
2128
+ .toList()
2129
+ .map { progressFile ->
2130
+ async {
2131
+ permits.withPermit {
2132
+ updateStep(UpdateFailureStage.DOWNLOAD, UpdateFailureResource.FILE) {
2133
+ val path = progressFile.path
2134
+ val workerTemp =
2135
+ File(tempDir, progressFile.order.toString()).apply { mkdirs() }
2136
+ downloadManifestAsset(
2137
+ assetPath = path,
2138
+ expectedAsset = targetManifest.assets.getValue(path),
2139
+ changedAsset = downloads.getValue(path),
2140
+ currentBundleId = currentBundleId,
2141
+ activeBundleDir = activeBundleDir,
2142
+ targetFile =
2143
+ RelativePathResolver.resolveInside(tmpDir, path)
2144
+ ?: throw HotUpdaterException.invalidBundle(),
2145
+ tempDir = workerTemp,
2146
+ progressFile = progressFile,
2147
+ progressCallback = { payload ->
2148
+ synchronized(progressLock) {
2149
+ payload.details?.files?.firstOrNull()?.let { snapshot ->
2150
+ val index = diffFiles.indexOfFirst { it.path == path }
2151
+ diffFiles[index] = snapshot
2152
+ }
2153
+ emitDiffProgress(progressCallback, "downloading", diffFiles)
2154
+ }
2155
+ },
2156
+ )
2157
+ }
2158
+ }
2159
+ }
2160
+ }.awaitAll()
2161
+ }
2162
+ }
2103
2163
 
2104
- val extractedIndex = tmpDir.walk().find { it.name == "index.android.bundle" }
2105
- if (extractedIndex == null) {
2106
- throw HotUpdaterException.invalidBundle()
2107
- }
2164
+ updateStep(UpdateFailureStage.INSTALL, resource = null) {
2165
+ emitDiffProgress(
2166
+ progressCallback = progressCallback,
2167
+ phase = "finalizing",
2168
+ files = diffFiles,
2169
+ )
2108
2170
 
2109
- if (finalBundleDir.exists()) {
2110
- finalBundleDir.deleteRecursively()
2111
- }
2171
+ manifestFile.copyTo(File(tmpDir, "manifest.json"), overwrite = true)
2112
2172
 
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)) {
2117
- throw HotUpdaterException.moveOperationFailed()
2118
- }
2119
- tmpDir.deleteRecursively()
2173
+ val extractedIndex = tmpDir.walk().find { it.name == "index.android.bundle" }
2174
+ if (extractedIndex == null) {
2175
+ // The manifest lists no platform bundle.
2176
+ throw HotUpdaterException.invalidBundle().withResource(UpdateFailureResource.MANIFEST)
2120
2177
  }
2121
- }
2122
2178
 
2123
- val finalIndexFile = finalBundleDir.walk().find { it.name == "index.android.bundle" }
2124
- if (finalIndexFile == null) {
2125
- finalBundleDir.deleteRecursively()
2126
- throw HotUpdaterException.invalidBundle()
2127
- }
2179
+ val backupDir = installBackupDir(bundleId)
2180
+ val updatedMetadata =
2181
+ synchronized(releaseStateLock) {
2182
+ if (backupDir.exists()) {
2183
+ throw HotUpdaterException.moveOperationFailed()
2184
+ }
2128
2185
 
2129
- finalBundleDir.setLastModified(System.currentTimeMillis())
2186
+ val hadExistingFinal = finalBundleDir.exists()
2187
+ var promoted = false
2188
+ val previousBundleUrl = preferences.getItem("HotUpdaterBundleURL")
2189
+ try {
2190
+ if (hadExistingFinal && !directoryRenamer(finalBundleDir, backupDir)) {
2191
+ throw HotUpdaterException.moveOperationFailed()
2192
+ }
2193
+ if (!directoryRenamer(tmpDir, finalBundleDir)) {
2194
+ if (hadExistingFinal) {
2195
+ directoryRenamer(backupDir, finalBundleDir)
2196
+ }
2197
+ throw HotUpdaterException.moveOperationFailed()
2198
+ }
2199
+ promoted = true
2130
2200
 
2131
- val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
2132
- val updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
2133
- saveMetadata(updatedMetadata)
2134
- setBundleURL(finalIndexFile.absolutePath)
2201
+ val finalIndexFile =
2202
+ resolveBundleFile(finalBundleDir, bundleId)
2203
+ ?: throw HotUpdaterException.invalidBundle()
2204
+ if (!isCompleteBundleDirectory(finalBundleDir, bundleId)) {
2205
+ throw HotUpdaterException.invalidBundle()
2206
+ }
2207
+ finalBundleDir.setLastModified(System.currentTimeMillis())
2135
2208
 
2136
- tempDir.deleteRecursively()
2137
- cleanupOldBundles(bundleStoreDir, updatedMetadata.stableBundleId, bundleId)
2138
- progressCallback(
2139
- UpdateProgressPayload(
2140
- progress = 1.0,
2141
- artifactType = "diff",
2142
- details =
2143
- DiffProgressDetails(
2144
- totalFilesCount = diffFiles.size,
2145
- completedFilesCount = diffFiles.count { it.status == "downloaded" },
2146
- files = diffFiles.toList(),
2147
- ),
2148
- ),
2209
+ val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
2210
+ val nextMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
2211
+ if (!setBundleURL(finalIndexFile.absolutePath)) {
2212
+ throw HotUpdaterException.storageFailed("Failed to persist bundle URL")
2213
+ }
2214
+ if (!saveMetadata(nextMetadata)) {
2215
+ throw HotUpdaterException.storageFailed("Failed to persist bundle metadata")
2216
+ }
2217
+
2218
+ if (backupDir.exists() && !backupDir.deleteRecursively()) {
2219
+ Log.w(TAG, "Failed to remove committed install backup: ${backupDir.absolutePath}")
2220
+ }
2221
+ nextMetadata
2222
+ } catch (e: Exception) {
2223
+ runCatching { preferences.setItem("HotUpdaterBundleURL", previousBundleUrl) }
2224
+ clearActiveBundleMetadataSnapshot()
2225
+ if (promoted && finalBundleDir.exists() && !finalBundleDir.deleteRecursively()) {
2226
+ Log.e(TAG, "Failed to remove uncommitted bundle: ${finalBundleDir.absolutePath}")
2227
+ }
2228
+ if (hadExistingFinal &&
2229
+ !finalBundleDir.exists() &&
2230
+ backupDir.exists() &&
2231
+ !directoryRenamer(backupDir, finalBundleDir)
2232
+ ) {
2233
+ Log.e(TAG, "Failed to restore install backup: ${backupDir.absolutePath}")
2234
+ }
2235
+ throw e
2236
+ }
2237
+ }
2238
+
2239
+ tempDir.deleteRecursively()
2240
+ cleanupOldBundles(bundleStoreDir, updatedMetadata.stableBundleId, bundleId)
2241
+ progressCallback(
2242
+ UpdateProgressPayload(
2243
+ progress = 1.0,
2244
+ artifactType = "diff",
2245
+ details =
2246
+ DiffProgressDetails(
2247
+ totalFilesCount = diffFiles.size,
2248
+ completedFilesCount = diffFiles.count { it.status == "downloaded" },
2249
+ files = diffFiles.toList(),
2250
+ ),
2251
+ ),
2252
+ )
2253
+ }
2254
+
2255
+ return UpdateBundleResult(
2256
+ delivery =
2257
+ when {
2258
+ archiveProgress != null -> BundleDelivery.ARCHIVE
2259
+ FileDelivery.PATCHED in fileDeliveries -> BundleDelivery.PATCH
2260
+ else -> BundleDelivery.MANIFEST
2261
+ },
2262
+ patchFallback = FileDelivery.DOWNLOADED_AFTER_PATCH_FAILURE in fileDeliveries,
2149
2263
  )
2150
2264
  } catch (e: Exception) {
2151
2265
  tempDir.deleteRecursively()
2152
- tmpDir.deleteRecursively()
2153
- throw e
2266
+ // A retry rehashes completed files and rejects partial bytes.
2267
+ throw classifyUpdateFailure(e, UpdateFailureStage.DOWNLOAD, resource = null)
2154
2268
  }
2155
2269
  }
2156
2270
 
@@ -2164,7 +2278,9 @@ class BundleFileStorageService(
2164
2278
  val bundles =
2165
2279
  bundleStoreDir
2166
2280
  .listFiles { file ->
2167
- file.isDirectory && !file.name.endsWith(".tmp")
2281
+ file.isDirectory &&
2282
+ !file.name.endsWith(".tmp") &&
2283
+ !file.name.endsWith(".install-backup")
2168
2284
  }?.toList() ?: return
2169
2285
 
2170
2286
  // Keep only the specified bundle IDs (filter out null values)
@@ -2287,6 +2403,7 @@ class BundleFileStorageService(
2287
2403
  pendingUpdateStrategy = null,
2288
2404
  pendingTransition = null,
2289
2405
  verificationPending = false,
2406
+ launchInProgress = false,
2290
2407
  updatedAt = System.currentTimeMillis(),
2291
2408
  )
2292
2409