@hot-updater/react-native 1.0.0-rc.4 → 1.0.0-rc.41

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 (317) hide show
  1. package/android/build.gradle +13 -2
  2. package/android/proguard-rules.pro +8 -2
  3. package/android/src/bridge/BridgeReactInstance.kt +38 -0
  4. package/android/src/bridgeless/BridgeReactInstance.kt +24 -0
  5. package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +94 -0
  6. package/android/src/main/java/com/hotupdater/BuiltInAssetResolver.kt +286 -0
  7. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +1064 -844
  8. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +50 -11
  9. package/android/src/main/java/com/hotupdater/FileManagerService.kt +45 -0
  10. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +287 -22
  11. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +46 -30
  12. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +75 -192
  13. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryState.kt +64 -0
  14. package/android/src/main/java/com/hotupdater/InstallIdentityService.kt +37 -0
  15. package/android/src/main/java/com/hotupdater/KeyValueStorageService.kt +86 -0
  16. package/android/src/main/java/com/hotupdater/NetworkState.kt +31 -0
  17. package/android/src/main/java/com/hotupdater/NoBackupStorage.kt +17 -0
  18. package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +127 -50
  19. package/android/src/main/java/com/hotupdater/ReactNativeValueConverters.kt +34 -0
  20. package/android/src/main/java/com/hotupdater/TarArchiveInputStream.kt +214 -375
  21. package/android/src/main/java/com/hotupdater/TarBrArchiveExtractor.kt +119 -0
  22. package/android/src/newarch/HotUpdater.kt +7 -9
  23. package/android/src/newarch/HotUpdaterModule.kt +109 -84
  24. package/android/src/newarch/JavaScriptExceptionHooks.kt +48 -0
  25. package/android/src/newarch/ReactIntegrationManager.kt +6 -32
  26. package/android/src/oldarch/HotUpdater.kt +7 -9
  27. package/android/src/oldarch/HotUpdaterModule.kt +98 -74
  28. package/android/src/oldarch/HotUpdaterSpec.kt +4 -6
  29. package/android/src/oldarch/JavaScriptExceptionHooks.kt +71 -0
  30. package/ios/HotUpdater/Internal/BrotliFileDecompressor.swift +167 -0
  31. package/ios/HotUpdater/Internal/BuiltInAssetResolver.swift +196 -0
  32. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +1032 -1116
  33. package/ios/HotUpdater/Internal/BundleMetadata.swift +61 -54
  34. package/ios/HotUpdater/Internal/FileManagerService.swift +48 -0
  35. package/ios/HotUpdater/Internal/FileUtilities.swift +64 -0
  36. package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +0 -1
  37. package/ios/HotUpdater/Internal/HotUpdater.mm +32 -67
  38. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +229 -73
  39. package/ios/HotUpdater/Internal/KeyValueStorageService.swift +71 -0
  40. package/ios/HotUpdater/Internal/NotificationExtension.swift +0 -2
  41. package/ios/HotUpdater/Internal/TarArchiveExtractor.swift +182 -311
  42. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +119 -71
  43. package/ios/HotUpdater/Internal/UpdateFailure.swift +390 -0
  44. package/ios/HotUpdater/Recovery/HotUpdaterRecovery.h +16 -0
  45. package/ios/HotUpdater/Recovery/HotUpdaterRecovery.mm +83 -0
  46. package/ios/HotUpdater/Recovery/HotUpdaterRecoveryHooks.mm +49 -0
  47. package/lib/commonjs/appReady.js +96 -0
  48. package/lib/commonjs/appReady.js.map +1 -0
  49. package/lib/commonjs/checkForUpdate.js +121 -69
  50. package/lib/commonjs/checkForUpdate.js.map +1 -1
  51. package/lib/commonjs/clientPlugin.js +13 -0
  52. package/lib/commonjs/clientPlugin.js.map +1 -0
  53. package/lib/commonjs/error.js +5 -14
  54. package/lib/commonjs/error.js.map +1 -1
  55. package/lib/commonjs/fetchJSON.js +16 -27
  56. package/lib/commonjs/fetchJSON.js.map +1 -1
  57. package/lib/commonjs/fetchUpdateResponse.js +60 -0
  58. package/lib/commonjs/fetchUpdateResponse.js.map +1 -0
  59. package/lib/commonjs/httpClient.js +64 -95
  60. package/lib/commonjs/httpClient.js.map +1 -1
  61. package/lib/commonjs/index.js +355 -324
  62. package/lib/commonjs/index.js.map +1 -1
  63. package/lib/commonjs/init.types.js +6 -0
  64. package/lib/commonjs/init.types.js.map +1 -0
  65. package/lib/commonjs/native.js +77 -63
  66. package/lib/commonjs/native.js.map +1 -1
  67. package/lib/commonjs/pluginHost.js +41 -0
  68. package/lib/commonjs/pluginHost.js.map +1 -0
  69. package/lib/commonjs/releaseCatalogCache.js +36 -87
  70. package/lib/commonjs/releaseCatalogCache.js.map +1 -1
  71. package/lib/commonjs/sdkVersion.js +1 -1
  72. package/lib/commonjs/sdkVersion.js.map +1 -1
  73. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  74. package/lib/commonjs/store.js +3 -10
  75. package/lib/commonjs/store.js.map +1 -1
  76. package/lib/commonjs/updateError.js +145 -0
  77. package/lib/commonjs/updateError.js.map +1 -0
  78. package/lib/commonjs/wrap.js +46 -38
  79. package/lib/commonjs/wrap.js.map +1 -1
  80. package/lib/module/appReady.js +90 -0
  81. package/lib/module/appReady.js.map +1 -0
  82. package/lib/module/checkForUpdate.js +114 -64
  83. package/lib/module/checkForUpdate.js.map +1 -1
  84. package/lib/module/clientPlugin.js +4 -0
  85. package/lib/module/clientPlugin.js.map +1 -0
  86. package/lib/module/error.js +5 -14
  87. package/lib/module/error.js.map +1 -1
  88. package/lib/module/fetchJSON.js +14 -26
  89. package/lib/module/fetchJSON.js.map +1 -1
  90. package/lib/module/fetchUpdateResponse.js +55 -0
  91. package/lib/module/fetchUpdateResponse.js.map +1 -0
  92. package/lib/module/httpClient.js +63 -94
  93. package/lib/module/httpClient.js.map +1 -1
  94. package/lib/module/index.js +335 -327
  95. package/lib/module/index.js.map +1 -1
  96. package/lib/module/init.types.js +4 -0
  97. package/lib/module/init.types.js.map +1 -0
  98. package/lib/module/native.js +70 -56
  99. package/lib/module/native.js.map +1 -1
  100. package/lib/module/pluginHost.js +36 -0
  101. package/lib/module/pluginHost.js.map +1 -0
  102. package/lib/module/releaseCatalogCache.js +32 -82
  103. package/lib/module/releaseCatalogCache.js.map +1 -1
  104. package/lib/module/sdkVersion.js +1 -1
  105. package/lib/module/sdkVersion.js.map +1 -1
  106. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  107. package/lib/module/store.js +3 -10
  108. package/lib/module/store.js.map +1 -1
  109. package/lib/module/updateError.js +139 -0
  110. package/lib/module/updateError.js.map +1 -0
  111. package/lib/module/wrap.js +47 -38
  112. package/lib/module/wrap.js.map +1 -1
  113. package/lib/typescript/commonjs/appReady.d.ts +30 -0
  114. package/lib/typescript/commonjs/appReady.d.ts.map +1 -0
  115. package/lib/typescript/commonjs/checkForUpdate.d.ts +20 -6
  116. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  117. package/lib/typescript/commonjs/clientPlugin.d.ts +2 -0
  118. package/lib/typescript/commonjs/clientPlugin.d.ts.map +1 -0
  119. package/lib/typescript/commonjs/error.d.ts +5 -14
  120. package/lib/typescript/commonjs/error.d.ts.map +1 -1
  121. package/lib/typescript/commonjs/fetchJSON.d.ts +5 -5
  122. package/lib/typescript/commonjs/fetchJSON.d.ts.map +1 -1
  123. package/lib/typescript/commonjs/fetchUpdateResponse.d.ts +14 -0
  124. package/lib/typescript/commonjs/fetchUpdateResponse.d.ts.map +1 -0
  125. package/lib/typescript/commonjs/httpClient.d.ts +5 -34
  126. package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
  127. package/lib/typescript/commonjs/index.d.ts +124 -116
  128. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  129. package/lib/typescript/commonjs/init.types.d.ts +21 -0
  130. package/lib/typescript/commonjs/init.types.d.ts.map +1 -0
  131. package/lib/typescript/commonjs/native.d.ts +32 -25
  132. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  133. package/lib/typescript/commonjs/pluginHost.d.ts +8 -0
  134. package/lib/typescript/commonjs/pluginHost.d.ts.map +1 -0
  135. package/lib/typescript/commonjs/releaseCatalogCache.d.ts +8 -14
  136. package/lib/typescript/commonjs/releaseCatalogCache.d.ts.map +1 -1
  137. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  138. package/lib/typescript/commonjs/sdkVersion.d.ts.map +1 -1
  139. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +35 -47
  140. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  141. package/lib/typescript/commonjs/store.d.ts +1 -3
  142. package/lib/typescript/commonjs/store.d.ts.map +1 -1
  143. package/lib/typescript/commonjs/types.d.ts +1 -1
  144. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  145. package/lib/typescript/commonjs/updateError.d.ts +26 -0
  146. package/lib/typescript/commonjs/updateError.d.ts.map +1 -0
  147. package/lib/typescript/commonjs/wrap.d.ts +2 -3
  148. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  149. package/lib/typescript/commonjs/wrap.types.d.ts +13 -41
  150. package/lib/typescript/commonjs/wrap.types.d.ts.map +1 -1
  151. package/lib/typescript/module/appReady.d.ts +30 -0
  152. package/lib/typescript/module/appReady.d.ts.map +1 -0
  153. package/lib/typescript/module/checkForUpdate.d.ts +20 -6
  154. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  155. package/lib/typescript/module/clientPlugin.d.ts +2 -0
  156. package/lib/typescript/module/clientPlugin.d.ts.map +1 -0
  157. package/lib/typescript/module/error.d.ts +5 -14
  158. package/lib/typescript/module/error.d.ts.map +1 -1
  159. package/lib/typescript/module/fetchJSON.d.ts +5 -5
  160. package/lib/typescript/module/fetchJSON.d.ts.map +1 -1
  161. package/lib/typescript/module/fetchUpdateResponse.d.ts +14 -0
  162. package/lib/typescript/module/fetchUpdateResponse.d.ts.map +1 -0
  163. package/lib/typescript/module/httpClient.d.ts +5 -34
  164. package/lib/typescript/module/httpClient.d.ts.map +1 -1
  165. package/lib/typescript/module/index.d.ts +124 -116
  166. package/lib/typescript/module/index.d.ts.map +1 -1
  167. package/lib/typescript/module/init.types.d.ts +21 -0
  168. package/lib/typescript/module/init.types.d.ts.map +1 -0
  169. package/lib/typescript/module/native.d.ts +32 -25
  170. package/lib/typescript/module/native.d.ts.map +1 -1
  171. package/lib/typescript/module/pluginHost.d.ts +8 -0
  172. package/lib/typescript/module/pluginHost.d.ts.map +1 -0
  173. package/lib/typescript/module/releaseCatalogCache.d.ts +8 -14
  174. package/lib/typescript/module/releaseCatalogCache.d.ts.map +1 -1
  175. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  176. package/lib/typescript/module/sdkVersion.d.ts.map +1 -1
  177. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +35 -47
  178. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  179. package/lib/typescript/module/store.d.ts +1 -3
  180. package/lib/typescript/module/store.d.ts.map +1 -1
  181. package/lib/typescript/module/types.d.ts +1 -1
  182. package/lib/typescript/module/types.d.ts.map +1 -1
  183. package/lib/typescript/module/updateError.d.ts +26 -0
  184. package/lib/typescript/module/updateError.d.ts.map +1 -0
  185. package/lib/typescript/module/wrap.d.ts +2 -3
  186. package/lib/typescript/module/wrap.d.ts.map +1 -1
  187. package/lib/typescript/module/wrap.types.d.ts +13 -41
  188. package/lib/typescript/module/wrap.types.d.ts.map +1 -1
  189. package/package.json +8 -4
  190. package/src/appReady.ts +147 -0
  191. package/src/checkForUpdate.ts +156 -89
  192. package/src/clientPlugin.ts +21 -0
  193. package/src/error.ts +5 -15
  194. package/src/fetchJSON.ts +14 -30
  195. package/src/fetchUpdateResponse.ts +60 -0
  196. package/src/httpClient.ts +89 -160
  197. package/src/index.ts +413 -425
  198. package/src/init.types.ts +24 -0
  199. package/src/native.ts +101 -105
  200. package/src/pluginHost.ts +52 -0
  201. package/src/releaseCatalogCache.ts +43 -172
  202. package/src/sdkVersion.ts +1 -1
  203. package/src/specs/NativeHotUpdater.ts +34 -48
  204. package/src/store.ts +2 -23
  205. package/src/types.ts +1 -1
  206. package/src/updateError.ts +167 -0
  207. package/src/wrap.tsx +60 -43
  208. package/src/wrap.types.ts +18 -46
  209. package/android/src/main/java/com/hotupdater/DecompressService.kt +0 -89
  210. package/android/src/main/java/com/hotupdater/DecompressionStrategy.kt +0 -26
  211. package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +0 -117
  212. package/android/src/main/java/com/hotupdater/TarGzDecompressionStrategy.kt +0 -117
  213. package/android/src/main/java/com/hotupdater/ZipDecompressionStrategy.kt +0 -192
  214. package/android/src/test/java/com/hotupdater/BsdiffPatchTest.kt +0 -46
  215. package/android/src/test/java/com/hotupdater/BundleFileStorageServiceTest.kt +0 -799
  216. package/android/src/test/java/com/hotupdater/HotUpdaterImplTest.kt +0 -273
  217. package/android/src/test/java/com/hotupdater/NativeConfigUtilsTest.kt +0 -20
  218. package/android/src/test/java/com/hotupdater/OkHttpDownloadServiceTest.kt +0 -111
  219. package/android/src/test/java/com/hotupdater/RelativePathResolverTest.kt +0 -41
  220. package/android/src/test/java/com/hotupdater/ReleaseCatalogCacheServiceTest.kt +0 -118
  221. package/android/src/test/java/com/hotupdater/TarArchiveInputStreamTest.kt +0 -68
  222. package/ios/HotUpdater/Internal/ArchiveExtractionUtilities.swift +0 -178
  223. package/ios/HotUpdater/Internal/DecompressService.swift +0 -112
  224. package/ios/HotUpdater/Internal/DecompressionStrategy.swift +0 -22
  225. package/ios/HotUpdater/Internal/StreamingTarArchiveExtractor.swift +0 -370
  226. package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +0 -39
  227. package/ios/HotUpdater/Internal/TarGzDecompressionStrategy.swift +0 -59
  228. package/ios/HotUpdater/Internal/ZipArchiveExtractor.swift +0 -482
  229. package/ios/HotUpdater/Internal/ZipDecompressionStrategy.swift +0 -56
  230. package/lib/commonjs/catalogCacheNative.spec.js +0 -31
  231. package/lib/commonjs/catalogCacheNative.spec.js.map +0 -1
  232. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js +0 -616
  233. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js.map +0 -1
  234. package/lib/commonjs/httpClient.spec.js +0 -102
  235. package/lib/commonjs/httpClient.spec.js.map +0 -1
  236. package/lib/commonjs/index.spec.js +0 -302
  237. package/lib/commonjs/index.spec.js.map +0 -1
  238. package/lib/commonjs/iosInterop.spec.js +0 -18
  239. package/lib/commonjs/iosInterop.spec.js.map +0 -1
  240. package/lib/commonjs/native.spec.js +0 -883
  241. package/lib/commonjs/native.spec.js.map +0 -1
  242. package/lib/commonjs/notifyAppReadyInsights.boundary.spec.js +0 -218
  243. package/lib/commonjs/notifyAppReadyInsights.boundary.spec.js.map +0 -1
  244. package/lib/commonjs/notifyAppReadyInsights.js +0 -133
  245. package/lib/commonjs/notifyAppReadyInsights.js.map +0 -1
  246. package/lib/commonjs/notifyAppReadyInsights.spec.js +0 -246
  247. package/lib/commonjs/notifyAppReadyInsights.spec.js.map +0 -1
  248. package/lib/commonjs/notifyAppReadyInsights.test-utils.js +0 -24
  249. package/lib/commonjs/notifyAppReadyInsights.test-utils.js.map +0 -1
  250. package/lib/commonjs/notifyAppReadyInsights.transport.spec.js +0 -117
  251. package/lib/commonjs/notifyAppReadyInsights.transport.spec.js.map +0 -1
  252. package/lib/commonjs/releaseCatalogCache.spec.js +0 -195
  253. package/lib/commonjs/releaseCatalogCache.spec.js.map +0 -1
  254. package/lib/commonjs/releaseCatalogCacheNativeContract.spec.js +0 -64
  255. package/lib/commonjs/releaseCatalogCacheNativeContract.spec.js.map +0 -1
  256. package/lib/commonjs/store.notification.spec.js +0 -76
  257. package/lib/commonjs/store.notification.spec.js.map +0 -1
  258. package/lib/commonjs/store.spec.js +0 -316
  259. package/lib/commonjs/store.spec.js.map +0 -1
  260. package/lib/commonjs/wrap.spec.js +0 -329
  261. package/lib/commonjs/wrap.spec.js.map +0 -1
  262. package/lib/module/catalogCacheNative.spec.js +0 -31
  263. package/lib/module/catalogCacheNative.spec.js.map +0 -1
  264. package/lib/module/checkForUpdate.releaseCatalog.spec.js +0 -615
  265. package/lib/module/checkForUpdate.releaseCatalog.spec.js.map +0 -1
  266. package/lib/module/httpClient.spec.js +0 -102
  267. package/lib/module/httpClient.spec.js.map +0 -1
  268. package/lib/module/index.spec.js +0 -301
  269. package/lib/module/index.spec.js.map +0 -1
  270. package/lib/module/iosInterop.spec.js +0 -18
  271. package/lib/module/iosInterop.spec.js.map +0 -1
  272. package/lib/module/native.spec.js +0 -882
  273. package/lib/module/native.spec.js.map +0 -1
  274. package/lib/module/notifyAppReadyInsights.boundary.spec.js +0 -217
  275. package/lib/module/notifyAppReadyInsights.boundary.spec.js.map +0 -1
  276. package/lib/module/notifyAppReadyInsights.js +0 -128
  277. package/lib/module/notifyAppReadyInsights.js.map +0 -1
  278. package/lib/module/notifyAppReadyInsights.spec.js +0 -245
  279. package/lib/module/notifyAppReadyInsights.spec.js.map +0 -1
  280. package/lib/module/notifyAppReadyInsights.test-utils.js +0 -18
  281. package/lib/module/notifyAppReadyInsights.test-utils.js.map +0 -1
  282. package/lib/module/notifyAppReadyInsights.transport.spec.js +0 -116
  283. package/lib/module/notifyAppReadyInsights.transport.spec.js.map +0 -1
  284. package/lib/module/releaseCatalogCache.spec.js +0 -195
  285. package/lib/module/releaseCatalogCache.spec.js.map +0 -1
  286. package/lib/module/releaseCatalogCacheNativeContract.spec.js +0 -64
  287. package/lib/module/releaseCatalogCacheNativeContract.spec.js.map +0 -1
  288. package/lib/module/store.notification.spec.js +0 -77
  289. package/lib/module/store.notification.spec.js.map +0 -1
  290. package/lib/module/store.spec.js +0 -315
  291. package/lib/module/store.spec.js.map +0 -1
  292. package/lib/module/wrap.spec.js +0 -330
  293. package/lib/module/wrap.spec.js.map +0 -1
  294. package/lib/typescript/commonjs/notifyAppReadyInsights.d.ts +0 -13
  295. package/lib/typescript/commonjs/notifyAppReadyInsights.d.ts.map +0 -1
  296. package/lib/typescript/commonjs/notifyAppReadyInsights.test-utils.d.ts +0 -8
  297. package/lib/typescript/commonjs/notifyAppReadyInsights.test-utils.d.ts.map +0 -1
  298. package/lib/typescript/module/notifyAppReadyInsights.d.ts +0 -13
  299. package/lib/typescript/module/notifyAppReadyInsights.d.ts.map +0 -1
  300. package/lib/typescript/module/notifyAppReadyInsights.test-utils.d.ts +0 -8
  301. package/lib/typescript/module/notifyAppReadyInsights.test-utils.d.ts.map +0 -1
  302. package/src/catalogCacheNative.spec.ts +0 -63
  303. package/src/checkForUpdate.releaseCatalog.spec.ts +0 -612
  304. package/src/httpClient.spec.ts +0 -132
  305. package/src/index.spec.ts +0 -337
  306. package/src/iosInterop.spec.ts +0 -30
  307. package/src/native.spec.ts +0 -945
  308. package/src/notifyAppReadyInsights.boundary.spec.ts +0 -234
  309. package/src/notifyAppReadyInsights.spec.ts +0 -243
  310. package/src/notifyAppReadyInsights.test-utils.ts +0 -31
  311. package/src/notifyAppReadyInsights.transport.spec.ts +0 -130
  312. package/src/notifyAppReadyInsights.ts +0 -194
  313. package/src/releaseCatalogCache.spec.ts +0 -282
  314. package/src/releaseCatalogCacheNativeContract.spec.ts +0 -80
  315. package/src/store.notification.spec.ts +0 -92
  316. package/src/store.spec.ts +0 -372
  317. 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
 
@@ -123,6 +151,12 @@ interface BundleStorageService {
123
151
 
124
152
  suspend fun commitReleaseSelection(selection: PersistedSelection): Boolean = false
125
153
 
154
+ /**
155
+ * Records that the current launch of a verification-pending bundle can show UI.
156
+ * The next process rolls the bundle back unless first content verified it.
157
+ */
158
+ fun markLaunchStarted(launchedBundleId: String?)
159
+
126
160
  /**
127
161
  * Marks the current launch as successful after the first content appeared.
128
162
  */
@@ -134,31 +168,8 @@ interface BundleStorageService {
134
168
  fun notifyAppReady(): Map<String, Any?>
135
169
 
136
170
  /**
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
- /**
161
- * Gets the crashed bundle history
171
+ * Gets the crashed bundle history, plus a bundle that waits for its retry after
172
+ * an unfinished launch while this process still refuses it (see [InterruptedLaunch])
162
173
  * @return CrashedHistory containing crashed bundles
163
174
  */
164
175
  fun getCrashHistory(): CrashedHistory
@@ -183,7 +194,6 @@ interface BundleStorageService {
183
194
 
184
195
  /**
185
196
  * Gets the current active bundle ID from bundle storage.
186
- * Reads manifest.json first and falls back to older metadata when needed.
187
197
  */
188
198
  fun getBundleId(): String?
189
199
 
@@ -213,33 +223,35 @@ class BundleFileStorageService(
213
223
  private val context: android.content.Context,
214
224
  private val fileSystem: FileSystemService,
215
225
  private val downloadService: DownloadService,
216
- private val decompressService: DecompressService,
217
226
  private val preferences: PreferencesService,
218
227
  private val isolationKey: String,
219
228
  private val defaultChannelProvider: () -> String = { HotUpdaterImpl.getChannel(context) },
229
+ builtInAssetResolver: BuiltInAssetResolver? = null,
230
+ private val metadataWriter: (BundleMetadata, File) -> Boolean = { metadata, file ->
231
+ metadata.saveToFile(file)
232
+ },
233
+ private val directoryRenamer: (File, File) -> Boolean = { source, destination ->
234
+ source.renameTo(destination)
235
+ },
220
236
  ) : BundleStorageService {
221
237
  companion object {
222
238
  private const val TAG = "BundleStorage"
239
+ private const val MAX_SAFE_INTEGER = 9_007_199_254_740_991L
240
+ private val SHA256_PATTERN = Regex("^[0-9a-f]{64}$")
241
+
242
+ /** A patch produced a file that does not match the target, or none at all. */
243
+ private val PATCH_FAILURE =
244
+ UpdateFailure(UpdateFailureStage.INSTALL, UpdateFailureReason.PATCH, resource = UpdateFailureResource.PATCH)
223
245
  }
224
246
 
225
247
  private val releaseStateLock = Any()
226
248
  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
- ),
249
+ private val updateMutex = Mutex()
250
+ private val builtInAssetResolver =
251
+ builtInAssetResolver ?: AndroidBuiltInAssetResolver(
252
+ context,
253
+ File(getBundleStoreDir(), "builtin-index-v1.json"),
241
254
  )
242
- }
243
255
 
244
256
  private fun createDiffProgressFiles(changedAssets: Map<String, ChangedAssetDescriptor>): MutableList<DiffProgressFileSnapshot> =
245
257
  changedAssets.keys
@@ -394,6 +406,10 @@ class BundleFileStorageService(
394
406
  return File(compressedDir, "$safeName.$compression")
395
407
  }
396
408
 
409
+ /**
410
+ * Decompresses a downloaded `.br` asset. A decoding failure is reported as
411
+ * an extract failure and a failure writing the output as a storage failure.
412
+ */
397
413
  private fun decompressBrotliFile(
398
414
  sourceFile: File,
399
415
  targetFile: File,
@@ -408,7 +424,7 @@ class BundleFileStorageService(
408
424
  try {
409
425
  FileInputStream(sourceFile).use { fileInputStream ->
410
426
  BrotliInputStream(fileInputStream).use { brotliInputStream ->
411
- FileOutputStream(tempOutputFile).use { outputStream ->
427
+ LocalStorageOutputStream.open(tempOutputFile).use { outputStream ->
412
428
  brotliInputStream.copyTo(outputStream)
413
429
  }
414
430
  }
@@ -418,15 +434,30 @@ class BundleFileStorageService(
418
434
  targetFile.delete()
419
435
  }
420
436
  if (!tempOutputFile.renameTo(targetFile)) {
421
- tempOutputFile.copyTo(targetFile, overwrite = true)
437
+ storageOperation { tempOutputFile.copyTo(targetFile, overwrite = true) }
422
438
  tempOutputFile.delete()
423
439
  }
440
+ } catch (error: Exception) {
441
+ tempOutputFile.delete()
442
+ throw HotUpdaterException.downloadFailed(
443
+ error,
444
+ if (error is LocalStorageException) {
445
+ UpdateFailure.install(UpdateFailureReason.STORAGE)
446
+ } else {
447
+ UpdateFailure.install(UpdateFailureReason.EXTRACT)
448
+ },
449
+ )
424
450
  } catch (error: Throwable) {
425
451
  tempOutputFile.delete()
426
452
  throw error
427
453
  }
428
454
  }
429
455
 
456
+ /**
457
+ * Tries to produce [targetFile] from a bsdiff patch against the running
458
+ * bundle. A patch for another base bundle or algorithm is not attempted.
459
+ * Once attempted, a failure leaves the file to be downloaded instead.
460
+ */
430
461
  private suspend fun applyPatchAssetIfPossible(
431
462
  assetPath: String,
432
463
  changedAsset: ChangedAssetDescriptor,
@@ -437,91 +468,116 @@ class BundleFileStorageService(
437
468
  tempDir: File,
438
469
  diffFiles: MutableList<DiffProgressFileSnapshot>,
439
470
  progressCallback: (UpdateProgressPayload) -> Unit,
440
- ): Boolean {
441
- val patch = changedAsset.patch ?: return false
471
+ ): PatchOutcome {
472
+ val patch = changedAsset.patch ?: return PatchOutcome.NOT_ATTEMPTED
442
473
  if (patch.algorithm != "bsdiff" || currentBundleId != patch.baseBundleId) {
443
- return false
474
+ return PatchOutcome.NOT_ATTEMPTED
444
475
  }
445
476
 
446
- val sourceDir = activeBundleDir ?: return false
447
- val sourceFile = RelativePathResolver.resolveInside(sourceDir, assetPath) ?: return false
477
+ // The patch targets the running bundle, so from here a miss is a fallback.
478
+ val sourceDir = activeBundleDir ?: return PatchOutcome.FAILED
479
+ val sourceFile = RelativePathResolver.resolveInside(sourceDir, assetPath) ?: return PatchOutcome.FAILED
448
480
  if (!sourceFile.exists() || !HashUtils.verifyHash(sourceFile, patch.baseFileHash)) {
449
- return false
481
+ return PatchOutcome.FAILED
450
482
  }
451
483
 
452
484
  val patchFile = patchTempFile(tempDir, assetPath)
453
485
 
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
- )
486
+ val patched =
487
+ try {
488
+ when (
489
+ val patchDownloadResult =
490
+ downloadService.downloadFile(
491
+ URL(patch.patchUrl),
492
+ patchFile,
493
+ ) { downloadProgress ->
494
+ updateDiffProgressFile(
495
+ files = diffFiles,
496
+ assetPath = assetPath,
497
+ status = "downloading",
498
+ progress = downloadProgress.progress,
499
+ downloadPath = patchDownloadPath(assetPath),
500
+ downloadedBytes = downloadProgress.downloadedBytes,
501
+ totalBytes = downloadProgress.totalBytes,
502
+ )
503
+ emitDiffProgress(
504
+ progressCallback = progressCallback,
505
+ phase = "downloading",
506
+ files = diffFiles,
507
+ )
508
+ }
509
+ ) {
510
+ is DownloadResult.Error -> {
511
+ false
475
512
  }
476
- ) {
477
- is DownloadResult.Error -> {
478
- false
479
- }
480
513
 
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
- )
514
+ is DownloadResult.Success -> {
515
+ if (!HashUtils.verifyHash(patchDownloadResult.file, patch.patchFileHash)) {
516
+ false
517
+ } else {
518
+ withContext(Dispatchers.IO) {
519
+ BsdiffPatch.apply(sourceFile, patchDownloadResult.file, targetFile)
520
+ }
521
+ HashUtils.verifyHash(targetFile, expectedHash).also { matches ->
522
+ if (matches) {
523
+ Log.d(
524
+ TAG,
525
+ "HotUpdaterBsdiffPatchApplied asset=$assetPath baseBundleId=${patch.baseBundleId}",
526
+ )
527
+ }
494
528
  }
495
529
  }
496
530
  }
497
531
  }
532
+ } catch (_: Exception) {
533
+ false
534
+ } finally {
535
+ patchFile.delete()
536
+ if (!targetFile.exists() || !HashUtils.verifyHash(targetFile, expectedHash)) {
537
+ targetFile.delete()
538
+ }
498
539
  }
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
- }
540
+ if (!patched) {
541
+ resetDiffProgressFile(
542
+ files = diffFiles,
543
+ assetPath = assetPath,
544
+ progressCallback = progressCallback,
545
+ )
546
+ return PatchOutcome.FAILED
514
547
  }
548
+ return PatchOutcome.APPLIED
549
+ }
550
+
551
+ private enum class PatchOutcome {
552
+ NOT_ATTEMPTED,
553
+ APPLIED,
554
+ FAILED,
555
+ }
556
+
557
+ /** How one file arrived under the per-file plan. */
558
+ private enum class FileDelivery {
559
+ PATCHED,
560
+ DOWNLOADED,
561
+ DOWNLOADED_AFTER_PATCH_FAILURE,
515
562
  }
516
563
 
517
564
  private data class ParsedBundleManifest(
518
565
  val bundleId: String,
519
566
  val assets: Map<String, ParsedManifestAsset>,
567
+ val archive: ParsedArchive?,
520
568
  )
521
569
 
522
570
  private data class ParsedManifestAsset(
523
571
  val fileHash: String,
524
572
  val signature: String?,
573
+ val byteSize: Long?,
574
+ val downloadByteSize: Long?,
575
+ )
576
+
577
+ private data class ParsedArchive(
578
+ val downloadFileHash: String,
579
+ val downloadByteSize: Long,
580
+ val tarByteSize: Long,
525
581
  )
526
582
 
527
583
  private data class ActiveBundleMetadataSnapshot(
@@ -536,13 +592,22 @@ class BundleFileStorageService(
536
592
 
537
593
  // Clean up old bundles if isolationKey format changed
538
594
  checkAndCleanupIfIsolationKeyChanged()
595
+
596
+ recoverInterruptedPromotions()
539
597
  }
540
598
 
599
+ private var hasPreparedLaunch = false
541
600
  private var currentLaunchReport: LaunchReport? = null
542
601
 
543
- @Volatile
544
- private var currentInstallationIdentity: InstallationIdentity? = null
545
- private val installationIdentityLock = Any()
602
+ // The staged bundle this process launched before its first content. Only that
603
+ // launch is pending; a bundle staged later waits for the next launch.
604
+ private var pendingLaunchBundleId: String? = null
605
+
606
+ // Crash history is written at first content on the main thread while JS can read it.
607
+ private val crashedHistoryLock = Any()
608
+
609
+ // A bundle waiting for its retry that this process keeps refusing, see InterruptedLaunch.
610
+ private var retryHeldBundleId: String? = null
546
611
 
547
612
  @Volatile
548
613
  private var activeBundleMetadataSnapshot: ActiveBundleMetadataSnapshot? = null
@@ -561,15 +626,13 @@ class BundleFileStorageService(
561
626
 
562
627
  private fun getLaunchReportFile(): File = File(getBundleStoreDir(), LaunchReport.LAUNCH_REPORT_FILENAME)
563
628
 
564
- private fun getInstallationIdentityFile(): File = File(getBundleStoreDir(), InstallationIdentity.IDENTITY_FILENAME)
565
-
566
629
  // MARK: - Metadata Operations
567
630
 
568
631
  private fun loadMetadataOrNull(): BundleMetadata? = BundleMetadata.loadFromFile(getMetadataFile(), isolationKey)
569
632
 
570
633
  private fun saveMetadata(metadata: BundleMetadata): Boolean {
571
634
  val updatedMetadata = metadata.copy(isolationKey = isolationKey)
572
- return updatedMetadata.saveToFile(getMetadataFile())
635
+ return metadataWriter(updatedMetadata, getMetadataFile())
573
636
  }
574
637
 
575
638
  private fun loadLaunchReport(): LaunchReport? =
@@ -600,37 +663,10 @@ class BundleFileStorageService(
600
663
  "appVersion"
601
664
  }
602
665
 
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
666
  private fun getKnownLaunchBundleId(metadata: BundleMetadata): String =
623
667
  getCurrentVerifiedBundleId(metadata) ?: HotUpdaterImpl.getMinBundleId()
624
668
 
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
- }
669
+ private fun createInitialMetadata(): BundleMetadata = BundleMetadata()
634
670
 
635
671
  private fun extractBundleIdFromCurrentURL(): String? {
636
672
  val currentUrl = preferences.getItem("HotUpdaterBundleURL") ?: return null
@@ -663,7 +699,7 @@ class BundleFileStorageService(
663
699
  .singleOrNull()
664
700
  }
665
701
 
666
- return File(bundleDir, "index.android.bundle").absoluteFile.takeIf { it.isFile }
702
+ return null
667
703
  }
668
704
 
669
705
  private fun findBundleFile(bundleId: String): File? {
@@ -671,6 +707,40 @@ class BundleFileStorageService(
671
707
  return resolveBundleFile(bundleDir, bundleId)
672
708
  }
673
709
 
710
+ private fun installBackupDir(bundleId: String): File = File(getBundleStoreDir(), "$bundleId.install-backup")
711
+
712
+ private fun isCompleteBundleDirectory(
713
+ bundleDir: File,
714
+ bundleId: String,
715
+ ): Boolean {
716
+ val manifest = parseBundleManifestFromFile(File(bundleDir, "manifest.json")) ?: return false
717
+ if (manifest.bundleId != bundleId) return false
718
+ if (resolveBundleFile(bundleDir, bundleId) == null) return false
719
+ return manifest.assets.all { (assetPath, asset) ->
720
+ val file = RelativePathResolver.resolveInside(bundleDir, assetPath)
721
+ file?.isFile == true && runCatching { verifyManifestAssetFileOrThrow(file, asset) }.isSuccess
722
+ }
723
+ }
724
+
725
+ private fun recoverInterruptedPromotions(): Boolean {
726
+ val bundleStoreDir = getBundleStoreDir()
727
+ val backups =
728
+ bundleStoreDir.listFiles { file ->
729
+ file.isDirectory && file.name.endsWith(".install-backup")
730
+ } ?: return true
731
+
732
+ return backups.all { backupDir ->
733
+ val bundleId = backupDir.name.removeSuffix(".install-backup")
734
+ val finalDir = File(bundleStoreDir, bundleId)
735
+ when {
736
+ !finalDir.exists() -> directoryRenamer(backupDir, finalDir)
737
+ isCompleteBundleDirectory(finalDir, bundleId) -> backupDir.deleteRecursively()
738
+ !finalDir.deleteRecursively() -> false
739
+ else -> directoryRenamer(backupDir, finalDir)
740
+ }
741
+ }
742
+ }
743
+
674
744
  private fun getBundleUrlForId(bundleId: String): String? = findBundleFile(bundleId)?.absolutePath
675
745
 
676
746
  private fun getCurrentVerifiedBundleId(metadata: BundleMetadata): String? =
@@ -681,12 +751,16 @@ class BundleFileStorageService(
681
751
  }
682
752
 
683
753
  private fun getActiveBundleId(): String? {
684
- extractBundleIdFromCurrentURL()?.let { return it }
685
-
686
- val metadata = loadMetadataOrNull()
754
+ val metadata = loadMetadataOrNull() ?: return null
755
+ val cachedBundleId = extractBundleIdFromCurrentURL()
756
+ if (cachedBundleId != null &&
757
+ (cachedBundleId == metadata.stagingBundleId || cachedBundleId == metadata.stableBundleId)
758
+ ) {
759
+ return cachedBundleId
760
+ }
687
761
  return when {
688
- metadata?.stagingBundleId != null && !metadata.verificationPending -> metadata.stagingBundleId
689
- metadata?.stableBundleId != null -> metadata.stableBundleId
762
+ metadata.stagingBundleId != null && !metadata.verificationPending -> metadata.stagingBundleId
763
+ metadata.stableBundleId != null -> metadata.stableBundleId
690
764
  else -> null
691
765
  }
692
766
  }
@@ -734,7 +808,7 @@ class BundleFileStorageService(
734
808
 
735
809
  return ActiveBundleMetadataSnapshot(
736
810
  activeBundleId = bundleDir.name,
737
- bundleId = manifestBundleId ?: readCompatibilityBundleIdFromBundleDir(bundleDir),
811
+ bundleId = manifestBundleId,
738
812
  manifest = manifest,
739
813
  )
740
814
  }
@@ -752,25 +826,6 @@ class BundleFileStorageService(
752
826
  return resolveActiveBundleMetadataSnapshot(bundleDir)
753
827
  }
754
828
 
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
829
  private fun readManifestFromBundleDir(bundleDir: File): Map<String, Any?>? {
775
830
  val manifestFile = File(bundleDir, "manifest.json")
776
831
  if (!manifestFile.exists()) {
@@ -810,6 +865,33 @@ class BundleFileStorageService(
810
865
  else -> value
811
866
  }
812
867
 
868
+ private fun parseSafeInteger(value: Any?): Long? {
869
+ val parsed =
870
+ when (value) {
871
+ is Byte -> value.toLong()
872
+ is Short -> value.toLong()
873
+ is Int -> value.toLong()
874
+ is Long -> value
875
+ is Float -> value.toDouble().takeIf { it.isFinite() && it % 1.0 == 0.0 }?.toLong()
876
+ is Double -> value.takeIf { it.isFinite() && it % 1.0 == 0.0 }?.toLong()
877
+ else -> null
878
+ } ?: return null
879
+ return parsed.takeIf { it in 0..MAX_SAFE_INTEGER }
880
+ }
881
+
882
+ private fun parseArchive(value: Any?): ParsedArchive? {
883
+ val archive = value as? Map<*, *> ?: return null
884
+ val downloadFileHash = archive["downloadFileHash"] as? String ?: return null
885
+ if (!SHA256_PATTERN.matches(downloadFileHash)) return null
886
+ val downloadByteSize = parseSafeInteger(archive["downloadByteSize"])?.takeIf { it > 0 } ?: return null
887
+ val tarByteSize = parseSafeInteger(archive["tarByteSize"])?.takeIf { it >= 1024 } ?: return null
888
+ return ParsedArchive(
889
+ downloadFileHash = downloadFileHash,
890
+ downloadByteSize = downloadByteSize,
891
+ tarByteSize = tarByteSize,
892
+ )
893
+ }
894
+
813
895
  private fun parseBundleManifestFromMap(manifest: Map<String, Any?>): ParsedBundleManifest? {
814
896
  val manifestBundleId =
815
897
  (manifest["bundleId"] as? String)
@@ -835,12 +917,15 @@ class BundleFileStorageService(
835
917
  ParsedManifestAsset(
836
918
  fileHash = fileHash,
837
919
  signature = signature?.takeIf { it.isNotBlank() },
920
+ byteSize = parseSafeInteger(assetMap["byteSize"]),
921
+ downloadByteSize = parseSafeInteger(assetMap["downloadByteSize"]),
838
922
  )
839
923
  }
840
924
 
841
925
  return ParsedBundleManifest(
842
926
  bundleId = manifestBundleId,
843
927
  assets = assets,
928
+ archive = parseArchive(manifest["archive"]),
844
929
  )
845
930
  }
846
931
 
@@ -858,52 +943,12 @@ class BundleFileStorageService(
858
943
  }
859
944
  }
860
945
 
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
946
  private fun getActiveBundleDir(): File? {
888
947
  val activeBundleId = getActiveBundleId() ?: return null
889
948
  val bundleDir = File(getBundleStoreDir(), activeBundleId)
890
949
  return bundleDir.takeIf { it.exists() }
891
950
  }
892
951
 
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
952
  private fun copyBundleFile(
908
953
  source: File,
909
954
  destination: File,
@@ -935,14 +980,27 @@ class BundleFileStorageService(
935
980
  SignatureVerifier.verifyHashSignature(context, asset.fileHash, signature)
936
981
  }
937
982
 
983
+ /**
984
+ * @param hashMismatch how a hash mismatch is reported: a downloaded file
985
+ * that does not match is a hash mismatch, while a patched or extracted file
986
+ * that does not match is a patch or extract failure.
987
+ */
938
988
  private fun verifyManifestAssetFileOrThrow(
939
989
  file: File,
940
990
  asset: ParsedManifestAsset,
991
+ hashMismatch: UpdateFailure = UpdateFailure.download(UpdateFailureReason.HASH_MISMATCH),
941
992
  ) {
942
993
  try {
943
994
  verifyManifestAssetFile(file, asset)
944
995
  } catch (e: SignatureVerificationException) {
945
- throw HotUpdaterException.signatureVerificationFailed(e)
996
+ throw HotUpdaterException.signatureVerificationFailed(
997
+ e,
998
+ if (e is SignatureVerificationException.FileHashMismatch) {
999
+ hashMismatch
1000
+ } else {
1001
+ UpdateFailure.ofVerificationError(e)
1002
+ },
1003
+ )
946
1004
  }
947
1005
  }
948
1006
 
@@ -1019,7 +1077,7 @@ class BundleFileStorageService(
1019
1077
  getCurrentVerifiedBundleId(metadata)?.takeIf { it != bundleId }
1020
1078
  val incomingSelection = pendingInstallSelections.remove(bundleId)
1021
1079
  if (incomingSelection != null && !isSelectionCurrent(incomingSelection)) {
1022
- throw IllegalStateException("Release catalog selection is stale")
1080
+ throw StaleReleaseSelectionException()
1023
1081
  }
1024
1082
  val currentVerifiedSelection =
1025
1083
  when {
@@ -1046,11 +1104,19 @@ class BundleFileStorageService(
1046
1104
  toBundleId = stagingSelection.bundleId,
1047
1105
  ),
1048
1106
  verificationPending = true,
1107
+ launchInProgress = false,
1049
1108
  updatedAt = System.currentTimeMillis(),
1050
1109
  )
1051
1110
  }
1052
1111
 
1053
- private fun rollbackPendingBundle(stagingBundleId: String): Boolean {
1112
+ /**
1113
+ * Rolls back the pending staging bundle. [unfinishedLaunch] means its launch
1114
+ * ended before first content without a crash marker.
1115
+ */
1116
+ private fun rollbackPendingBundle(
1117
+ stagingBundleId: String,
1118
+ unfinishedLaunch: Boolean = false,
1119
+ ): Boolean {
1054
1120
  val metadata = loadMetadataOrNull() ?: return false
1055
1121
  if (metadata.stagingBundleId != stagingBundleId) {
1056
1122
  return false
@@ -1058,9 +1124,7 @@ class BundleFileStorageService(
1058
1124
 
1059
1125
  Log.w(TAG, "Rolling back crashed staging bundle: $stagingBundleId")
1060
1126
 
1061
- val crashedHistory = loadCrashedHistory()
1062
- crashedHistory.addEntry(stagingBundleId)
1063
- saveCrashedHistory(crashedHistory)
1127
+ recordFailedLaunch(stagingBundleId, unfinishedLaunch)
1064
1128
 
1065
1129
  val fallbackBundleId =
1066
1130
  metadata.stableBundleId?.takeIf { candidate ->
@@ -1076,6 +1140,7 @@ class BundleFileStorageService(
1076
1140
  pendingUpdateStrategy = null,
1077
1141
  pendingTransition = null,
1078
1142
  verificationPending = false,
1143
+ launchInProgress = false,
1079
1144
  updatedAt = System.currentTimeMillis(),
1080
1145
  )
1081
1146
 
@@ -1113,10 +1178,9 @@ class BundleFileStorageService(
1113
1178
  private fun selectLaunch(): LaunchSelection {
1114
1179
  val metadata = loadMetadataOrNull()
1115
1180
  if (metadata == null) {
1116
- val cached = getCachedBundleURL()
1117
1181
  return LaunchSelection(
1118
- bundleUrl = cached ?: getFallbackBundleURL(),
1119
- launchedBundleId = extractBundleIdFromCurrentURL(),
1182
+ bundleUrl = getFallbackBundleURL(),
1183
+ launchedBundleId = null,
1120
1184
  shouldRollbackOnCrash = false,
1121
1185
  )
1122
1186
  }
@@ -1147,10 +1211,9 @@ class BundleFileStorageService(
1147
1211
  }
1148
1212
  }
1149
1213
 
1150
- val cached = getCachedBundleURL()
1151
1214
  return LaunchSelection(
1152
- bundleUrl = cached ?: getFallbackBundleURL(),
1153
- launchedBundleId = extractBundleIdFromCurrentURL(),
1215
+ bundleUrl = getFallbackBundleURL(),
1216
+ launchedBundleId = null,
1154
1217
  shouldRollbackOnCrash = false,
1155
1218
  )
1156
1219
  }
@@ -1302,6 +1365,7 @@ class BundleFileStorageService(
1302
1365
  pendingUpdateStrategy = null,
1303
1366
  pendingTransition = null,
1304
1367
  verificationPending = false,
1368
+ launchInProgress = false,
1305
1369
  updatedAt = System.currentTimeMillis(),
1306
1370
  )
1307
1371
  }
@@ -1327,18 +1391,98 @@ class BundleFileStorageService(
1327
1391
 
1328
1392
  private fun saveCrashedHistory(history: CrashedHistory): Boolean = history.saveToFile(getCrashedHistoryFile())
1329
1393
 
1330
- private fun isBundleInCrashedHistory(bundleId: String): Boolean = loadCrashedHistory().contains(bundleId)
1394
+ private fun isBundleInCrashedHistory(bundleId: String): Boolean = getCrashHistory().contains(bundleId)
1331
1395
 
1332
- override fun getCrashHistory(): CrashedHistory = loadCrashedHistory()
1396
+ /**
1397
+ * The bundles this device refuses to install: crash history, plus a bundle
1398
+ * waiting for its retry until a process that may retry it.
1399
+ */
1400
+ override fun getCrashHistory(): CrashedHistory =
1401
+ synchronized(crashedHistoryLock) {
1402
+ val history = loadCrashedHistory()
1403
+ val interrupted = history.interruptedLaunch
1404
+ if (interrupted != null &&
1405
+ (!interrupted.retryReady || interrupted.bundleId == retryHeldBundleId) &&
1406
+ !history.contains(interrupted.bundleId)
1407
+ ) {
1408
+ history.bundles.add(CrashedBundleEntry(interrupted.bundleId, crashedAt = System.currentTimeMillis()))
1409
+ }
1410
+ history
1411
+ }
1333
1412
 
1334
1413
  override fun clearCrashHistory(): Boolean {
1335
- val history = CrashedHistory()
1336
- saveCrashedHistory(history)
1414
+ synchronized(crashedHistoryLock) {
1415
+ saveCrashedHistory(CrashedHistory())
1416
+ retryHeldBundleId = null
1417
+ }
1337
1418
  Log.d(TAG, "Cleared crash history")
1338
1419
  return true
1339
1420
  }
1340
1421
 
1422
+ /**
1423
+ * A crash, or a second unfinished launch of the same bundle, adds the bundle to
1424
+ * crash history. A first unfinished launch cannot tell a hang from a user leaving
1425
+ * early, so it leaves the bundle one retry instead.
1426
+ */
1427
+ private fun recordFailedLaunch(
1428
+ bundleId: String,
1429
+ unfinishedLaunch: Boolean,
1430
+ ) {
1431
+ synchronized(crashedHistoryLock) {
1432
+ val history = loadCrashedHistory()
1433
+ val interrupted = history.interruptedLaunch
1434
+ if (unfinishedLaunch && interrupted?.bundleId != bundleId) {
1435
+ Log.w(TAG, "Launch of $bundleId ended before first content; it gets one retry")
1436
+ history.interruptedLaunch = InterruptedLaunch(bundleId)
1437
+ retryHeldBundleId = bundleId
1438
+ } else {
1439
+ history.addEntry(bundleId)
1440
+ if (interrupted?.bundleId == bundleId) {
1441
+ history.interruptedLaunch = null
1442
+ }
1443
+ }
1444
+ saveCrashedHistory(history)
1445
+ }
1446
+ }
1447
+
1448
+ /**
1449
+ * First content appeared, so the app works again. A bundle waiting for its retry
1450
+ * may be installed from the next process on. This process keeps refusing it, so
1451
+ * the session that recovered never reloads into it. Content from that bundle
1452
+ * verifies it.
1453
+ */
1454
+ private fun readyInterruptedLaunchRetry(currentBundleId: String?) {
1455
+ synchronized(crashedHistoryLock) {
1456
+ val history = loadCrashedHistory()
1457
+ val interrupted = history.interruptedLaunch ?: return
1458
+ if (interrupted.bundleId == currentBundleId) {
1459
+ history.interruptedLaunch = null
1460
+ } else if (interrupted.retryReady) {
1461
+ return
1462
+ } else {
1463
+ history.interruptedLaunch = interrupted.copy(retryReady = true)
1464
+ retryHeldBundleId = interrupted.bundleId
1465
+ }
1466
+ saveCrashedHistory(history)
1467
+ }
1468
+ }
1469
+
1470
+ override fun markLaunchStarted(launchedBundleId: String?) {
1471
+ // Unlike prepareLaunch, this can run while JS stages a newer bundle.
1472
+ synchronized(releaseStateLock) {
1473
+ val metadata = loadMetadataOrNull() ?: return
1474
+ if (!isVerificationPending(metadata) ||
1475
+ metadata.launchInProgress ||
1476
+ metadata.stagingBundleId != launchedBundleId
1477
+ ) {
1478
+ return
1479
+ }
1480
+ saveMetadata(metadata.copy(launchInProgress = true))
1481
+ }
1482
+ }
1483
+
1341
1484
  override fun markLaunchCompleted(currentBundleId: String?) {
1485
+ readyInterruptedLaunchRetry(currentBundleId)
1342
1486
  val metadata = loadMetadataOrNull() ?: return
1343
1487
  val stagingBundleId = metadata.stagingBundleId ?: return
1344
1488
  if (!metadata.verificationPending || stagingBundleId != currentBundleId) {
@@ -1354,6 +1498,7 @@ class BundleFileStorageService(
1354
1498
  pendingUpdateStrategy = null,
1355
1499
  pendingTransition = null,
1356
1500
  verificationPending = false,
1501
+ launchInProgress = false,
1357
1502
  updatedAt = System.currentTimeMillis(),
1358
1503
  ),
1359
1504
  )
@@ -1375,7 +1520,11 @@ class BundleFileStorageService(
1375
1520
  val report = loadLaunchReport()
1376
1521
  if (report == null) {
1377
1522
  val metadata = loadMetadataOrNull()
1378
- if (metadata?.verificationPending == true && metadata.stagingBundleId != null) {
1523
+ val launchedBundleId = pendingLaunchBundleId
1524
+ if (launchedBundleId != null &&
1525
+ metadata?.verificationPending == true &&
1526
+ metadata.stagingBundleId == launchedBundleId
1527
+ ) {
1379
1528
  return mapOf("status" to "PENDING")
1380
1529
  }
1381
1530
  return mapOf("status" to "UNCHANGED")
@@ -1390,32 +1539,18 @@ class BundleFileStorageService(
1390
1539
  }
1391
1540
  }
1392
1541
 
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
1542
  // MARK: - Bundle URL Operations
1413
1543
 
1414
1544
  override fun setBundleURL(localPath: String?): Boolean {
1415
1545
  Log.d(TAG, "setBundleURL: $localPath")
1416
- preferences.setItem("HotUpdaterBundleURL", localPath)
1417
- clearActiveBundleMetadataSnapshot()
1418
- return true
1546
+ return try {
1547
+ preferences.setItem("HotUpdaterBundleURL", localPath)
1548
+ clearActiveBundleMetadataSnapshot()
1549
+ true
1550
+ } catch (e: Exception) {
1551
+ Log.e(TAG, "Failed to persist bundle URL", e)
1552
+ false
1553
+ }
1419
1554
  }
1420
1555
 
1421
1556
  override fun getCachedBundleURL(): String? {
@@ -1446,10 +1581,22 @@ class BundleFileStorageService(
1446
1581
  override fun getFallbackBundleURL(): String = "assets://index.android.bundle"
1447
1582
 
1448
1583
  override fun prepareLaunch(pendingRecovery: PendingCrashRecovery?): LaunchSelection {
1584
+ recoverInterruptedPromotions()
1449
1585
  saveLaunchReport(null)
1450
1586
  applyPendingRecoveryIfNeeded(pendingRecovery)
1587
+ // Only a new storage instance consumes an unfinished launch. Repeated bundle
1588
+ // lookups or bridge reloads in this process must not reject its own launch.
1589
+ if (!hasPreparedLaunch) {
1590
+ val metadata = loadMetadataOrNull()
1591
+ if (metadata?.verificationPending == true && metadata.launchInProgress) {
1592
+ metadata.stagingBundleId?.let { rollbackPendingBundle(it, unfinishedLaunch = true) }
1593
+ }
1594
+ }
1595
+ hasPreparedLaunch = true
1451
1596
 
1597
+ // A launch is recorded only once it can show UI (markLaunchStarted).
1452
1598
  val selection = selectLaunch()
1599
+ pendingLaunchBundleId = selection.launchedBundleId.takeIf { selection.shouldRollbackOnCrash }
1453
1600
  Log.d(
1454
1601
  TAG,
1455
1602
  "prepareLaunch: bundleId=${selection.launchedBundleId} shouldRollback=${selection.shouldRollbackOnCrash} url=${selection.bundleUrl}",
@@ -1459,49 +1606,35 @@ class BundleFileStorageService(
1459
1606
 
1460
1607
  override suspend fun updateBundle(
1461
1608
  bundleId: String,
1462
- fileUrl: String?,
1463
- fileHash: String?,
1464
- manifestUrl: String?,
1465
- manifestFileHash: String?,
1466
- changedAssets: Map<String, ChangedAssetDescriptor>?,
1609
+ manifestUrl: String,
1610
+ manifestFileHash: String,
1611
+ assets: Map<String, ChangedAssetDescriptor>,
1612
+ archiveUrl: String?,
1467
1613
  progressCallback: (UpdateProgressPayload) -> Unit,
1468
- ) {
1614
+ ): UpdateBundleResult =
1615
+ updateMutex.withLock {
1616
+ updateBundleSerialized(
1617
+ bundleId = bundleId,
1618
+ manifestUrl = manifestUrl,
1619
+ manifestFileHash = manifestFileHash,
1620
+ changedAssets = assets,
1621
+ archiveUrl = archiveUrl,
1622
+ progressCallback = progressCallback,
1623
+ )
1624
+ }
1625
+
1626
+ private suspend fun updateBundleSerialized(
1627
+ bundleId: String,
1628
+ manifestUrl: String,
1629
+ manifestFileHash: String,
1630
+ changedAssets: Map<String, ChangedAssetDescriptor>,
1631
+ archiveUrl: String?,
1632
+ progressCallback: (UpdateProgressPayload) -> Unit,
1633
+ ): UpdateBundleResult {
1469
1634
  Log.d(
1470
1635
  TAG,
1471
- "updateBundle bundleId $bundleId fileUrl $fileUrl fileHash $fileHash manifestUrl $manifestUrl",
1636
+ "updateBundle bundleId $bundleId manifestUrl $manifestUrl",
1472
1637
  )
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
1638
 
1506
1639
  // Check if bundle is in crashed history
1507
1640
  if (isBundleInCrashedHistory(bundleId)) {
@@ -1509,299 +1642,444 @@ class BundleFileStorageService(
1509
1642
  throw HotUpdaterException.bundleInCrashedHistory(bundleId)
1510
1643
  }
1511
1644
 
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")
1645
+ val bundleStoreDir = getBundleStoreDir()
1646
+ updateStep(UpdateFailureStage.INSTALL, resource = null) {
1647
+ if (!bundleStoreDir.exists()) {
1648
+ bundleStoreDir.mkdirs()
1649
+ }
1650
+ if (!recoverInterruptedPromotions()) {
1651
+ throw HotUpdaterException.moveOperationFailed()
1518
1652
  }
1519
-
1520
- val baseDir = fileSystem.getInternalFilesDir()
1521
- val bundleStoreDir = getBundleStoreDir()
1522
- if (!bundleStoreDir.exists()) {
1523
- bundleStoreDir.mkdirs()
1524
1653
  }
1525
1654
 
1526
1655
  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)
1656
+ return withContext(Dispatchers.IO) {
1657
+ updateBundleFromManifest(
1658
+ bundleId = bundleId,
1659
+ manifestUrl = manifestUrl,
1660
+ manifestFileHash = manifestFileHash,
1661
+ changedAssets = changedAssets,
1662
+ archiveUrl = archiveUrl,
1663
+ bundleStoreDir = bundleStoreDir,
1664
+ finalBundleDir = finalBundleDir,
1665
+ progressCallback = progressCallback,
1666
+ )
1667
+ }
1668
+ }
1544
1669
 
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
- }
1670
+ /**
1671
+ * Runs one step of the update pipeline and gives its failure a
1672
+ * classification for JS: [resource] is what the step fetches or applies,
1673
+ * and an error nothing anticipated is reported as unknown in [stage].
1674
+ */
1675
+ private inline fun <T> updateStep(
1676
+ stage: UpdateFailureStage,
1677
+ resource: UpdateFailureResource?,
1678
+ block: () -> T,
1679
+ ): T =
1680
+ try {
1681
+ block()
1682
+ } catch (e: Exception) {
1683
+ throw classifyUpdateFailure(e, stage, resource)
1551
1684
  }
1552
1685
 
1553
- val hasManifestDrivenArtifacts =
1554
- !manifestUrl.isNullOrEmpty() &&
1555
- !manifestFileHash.isNullOrEmpty() &&
1556
- changedAssets != null
1686
+ /**
1687
+ * Gives a failure of the update pipeline its classification for JS.
1688
+ * Failures that are not update failures pass through unchanged, and an
1689
+ * already classified failure only gains a [resource] it did not name.
1690
+ */
1691
+ private fun classifyUpdateFailure(
1692
+ error: Exception,
1693
+ stage: UpdateFailureStage,
1694
+ resource: UpdateFailureResource?,
1695
+ ): Exception =
1696
+ when (error) {
1697
+ is StaleReleaseSelectionException, is CancellationException -> {
1698
+ error
1699
+ }
1557
1700
 
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,
1701
+ is HotUpdaterException -> {
1702
+ error.withResource(resource)
1703
+ }
1704
+
1705
+ else -> {
1706
+ HotUpdaterException.unexpected(
1707
+ error,
1708
+ UpdateFailure.ofUnexpectedError(error, stage).copy(resource = resource),
1580
1709
  )
1581
1710
  }
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
1711
  }
1588
1712
 
1589
- val tempDirName = "bundle-temp"
1590
- val tempDir = File(baseDir, tempDirName)
1591
- if (tempDir.exists()) {
1592
- tempDir.deleteRecursively()
1713
+ /** The rejection for a download that returned [error]. */
1714
+ private fun downloadError(error: Exception): HotUpdaterException =
1715
+ if (error is IncompleteDownloadException) {
1716
+ HotUpdaterException.incompleteDownload(error.expectedSize, error.actualSize)
1717
+ } else {
1718
+ HotUpdaterException.downloadFailed(
1719
+ error,
1720
+ UpdateFailure.ofDownloadError(error) { NetworkState.hasNoActiveNetwork(context) },
1721
+ )
1593
1722
  }
1594
- tempDir.mkdirs()
1595
1723
 
1596
- withContext(Dispatchers.IO) {
1597
- val downloadUrl = URL(fileUrl)
1724
+ private suspend fun downloadManifestAsset(
1725
+ assetPath: String,
1726
+ expectedAsset: ParsedManifestAsset,
1727
+ changedAsset: ChangedAssetDescriptor,
1728
+ currentBundleId: String?,
1729
+ activeBundleDir: File?,
1730
+ targetFile: File,
1731
+ tempDir: File,
1732
+ progressFile: DiffProgressFileSnapshot,
1733
+ progressCallback: (UpdateProgressPayload) -> Unit,
1734
+ ): FileDelivery {
1735
+ val expectedHash = expectedAsset.fileHash
1736
+ val diffFiles = mutableListOf(progressFile)
1737
+ val patchOutcome =
1738
+ applyPatchAssetIfPossible(
1739
+ assetPath = assetPath,
1740
+ changedAsset = changedAsset,
1741
+ currentBundleId = currentBundleId,
1742
+ activeBundleDir = activeBundleDir,
1743
+ targetFile = targetFile,
1744
+ expectedHash = expectedHash,
1745
+ tempDir = tempDir,
1746
+ diffFiles = diffFiles,
1747
+ progressCallback = progressCallback,
1748
+ )
1749
+ if (patchOutcome == PatchOutcome.APPLIED) {
1750
+ verifyManifestAssetFileOrThrow(
1751
+ targetFile,
1752
+ expectedAsset,
1753
+ hashMismatch = PATCH_FAILURE,
1754
+ )
1755
+ updateDiffProgressFile(
1756
+ files = diffFiles,
1757
+ assetPath = assetPath,
1758
+ status = "downloaded",
1759
+ progress = 1.0,
1760
+ )
1761
+ emitDiffProgress(
1762
+ progressCallback = progressCallback,
1763
+ phase =
1764
+ if (diffFiles.all { it.status == "downloaded" }) {
1765
+ "finalizing"
1766
+ } else {
1767
+ "downloading"
1768
+ },
1769
+ files = diffFiles,
1770
+ )
1771
+ return FileDelivery.PATCHED
1772
+ }
1598
1773
 
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"
1774
+ val changedAssetFileUrl =
1775
+ changedAsset.fileUrl
1776
+ ?: run {
1777
+ updateDiffProgressFile(
1778
+ files = diffFiles,
1779
+ assetPath = assetPath,
1780
+ status = "failed",
1781
+ progress = 0.0,
1782
+ )
1783
+ emitDiffProgress(
1784
+ progressCallback = progressCallback,
1785
+ phase = "downloading",
1786
+ files = diffFiles,
1787
+ )
1788
+ throw HotUpdaterException.downloadFailed(
1789
+ IllegalStateException("Changed asset fileUrl missing and patch could not be applied: $assetPath"),
1790
+ PATCH_FAILURE,
1791
+ )
1605
1792
  }
1606
- val tempBundleFile = File(tempDir, bundleFileName)
1607
1793
 
1608
- // Download the file (0% - 80%)
1609
- // Disk space check will be performed in fileSizeCallback
1610
- var diskSpaceError: HotUpdaterException? = null
1794
+ val downloadPath = downloadPathForChangedAsset(assetPath, changedAsset)
1795
+ val downloadedAssetFile =
1796
+ if (changedAsset.fileCompression == "br") {
1797
+ compressedTempFile(tempDir, assetPath, "br")
1798
+ } else {
1799
+ targetFile
1800
+ }
1611
1801
 
1612
- val downloadResult =
1802
+ when (
1803
+ val assetDownloadResult =
1613
1804
  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
- },
1805
+ URL(changedAssetFileUrl),
1806
+ downloadedAssetFile,
1638
1807
  ) { 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,
1808
+ updateDiffProgressFile(
1809
+ files = diffFiles,
1810
+ assetPath = assetPath,
1811
+ status = "downloading",
1812
+ progress = downloadProgress.progress,
1813
+ downloadPath = downloadPath,
1814
+ downloadedBytes = downloadProgress.downloadedBytes,
1815
+ totalBytes = downloadProgress.totalBytes,
1816
+ )
1817
+ emitDiffProgress(
1818
+ progressCallback = progressCallback,
1819
+ phase = "downloading",
1820
+ files = diffFiles,
1645
1821
  )
1646
1822
  }
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
1823
+ ) {
1824
+ is DownloadResult.Error -> {
1825
+ updateDiffProgressFile(
1826
+ files = diffFiles,
1827
+ assetPath = assetPath,
1828
+ status = "failed",
1829
+ progress =
1830
+ diffFiles
1831
+ .firstOrNull { it.path == assetPath }
1832
+ ?.progress ?: 0.0,
1833
+ )
1834
+ emitDiffProgress(
1835
+ progressCallback = progressCallback,
1836
+ phase = "downloading",
1837
+ files = diffFiles,
1838
+ )
1839
+ throw downloadError(assetDownloadResult.exception)
1653
1840
  }
1654
1841
 
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,
1842
+ is DownloadResult.Success -> {
1843
+ try {
1844
+ if (changedAsset.fileCompression == "br") {
1845
+ decompressBrotliFile(
1846
+ assetDownloadResult.file,
1847
+ targetFile,
1666
1848
  )
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
1849
  }
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
- }
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()
1850
+ verifyManifestAssetFileOrThrow(
1851
+ targetFile,
1852
+ expectedAsset,
1853
+ )
1854
+ } catch (e: Exception) {
1855
+ updateDiffProgressFile(
1856
+ files = diffFiles,
1857
+ assetPath = assetPath,
1858
+ status = "failed",
1859
+ progress = 1.0,
1860
+ )
1861
+ emitDiffProgress(
1862
+ progressCallback = progressCallback,
1863
+ phase = "downloading",
1864
+ files = diffFiles,
1865
+ )
1866
+ if (e is HotUpdaterException) {
1867
+ throw e
1710
1868
  }
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()
1869
+ // The bytes arrived; what failed is reading them back.
1870
+ throw HotUpdaterException.downloadFailed(
1871
+ e,
1872
+ UpdateFailure.ofUnexpectedError(e, UpdateFailureStage.DOWNLOAD),
1873
+ )
1874
+ } finally {
1875
+ if (changedAsset.fileCompression == "br") {
1876
+ assetDownloadResult.file.delete()
1719
1877
  }
1878
+ }
1879
+ updateDiffProgressFile(
1880
+ files = diffFiles,
1881
+ assetPath = assetPath,
1882
+ status = "downloaded",
1883
+ progress = 1.0,
1884
+ )
1885
+ emitDiffProgress(
1886
+ progressCallback = progressCallback,
1887
+ phase = if (diffFiles.all { it.status == "downloaded" }) "finalizing" else "downloading",
1888
+ files = diffFiles,
1889
+ )
1890
+ }
1891
+ }
1892
+ return if (patchOutcome == PatchOutcome.FAILED) {
1893
+ FileDelivery.DOWNLOADED_AFTER_PATCH_FAILURE
1894
+ } else {
1895
+ FileDelivery.DOWNLOADED
1896
+ }
1897
+ }
1720
1898
 
1721
- // 5) Log extracted bundle file size
1722
- val bundleSize = extractedBundleFile.length()
1723
- Log.d("BundleStorage", "Extracted bundle size: $bundleSize bytes")
1899
+ private fun pruneStagingDirectory(
1900
+ directory: File,
1901
+ assets: Set<String>,
1902
+ prefix: String = "",
1903
+ ) {
1904
+ directory.listFiles()?.forEach { file ->
1905
+ val relativePath = prefix + file.name
1906
+ if (file.canonicalFile != File(directory.canonicalFile, file.name)) {
1907
+ if (!file.delete()) {
1908
+ throw HotUpdaterException.storageFailed("Cannot remove staging link: $relativePath")
1909
+ }
1910
+ } else if (file.isDirectory && assets.any { it.startsWith("$relativePath/") }) {
1911
+ pruneStagingDirectory(file, assets, "$relativePath/")
1912
+ } else if (!file.isFile || relativePath !in assets) {
1913
+ if (!file.deleteRecursively()) {
1914
+ throw HotUpdaterException.storageFailed("Cannot remove stale staging file: $relativePath")
1915
+ }
1916
+ }
1917
+ }
1918
+ }
1724
1919
 
1725
- // 6) If the realDir (bundle-store/<bundleId>) exists, delete it
1726
- if (finalBundleDir.exists()) {
1727
- finalBundleDir.deleteRecursively()
1728
- }
1920
+ private fun selectArchive(
1921
+ manifest: ParsedBundleManifest,
1922
+ downloads: Map<String, ChangedAssetDescriptor>,
1923
+ archiveUrl: String?,
1924
+ ): ParsedArchive? {
1925
+ if (archiveUrl.isNullOrBlank() || downloads.size < 2) return null
1926
+ val archive = manifest.archive ?: return null
1927
+ if (manifest.assets.values.any { it.byteSize == null }) return null
1928
+
1929
+ var individualBytes = 0L
1930
+ for ((assetPath, descriptor) in downloads) {
1931
+ val originalBytes = manifest.assets.getValue(assetPath).downloadByteSize ?: return null
1932
+ val expectedBytes =
1933
+ descriptor.patch?.let { patch ->
1934
+ val patchBytes = patch.byteSize?.takeIf { it in 0..MAX_SAFE_INTEGER } ?: return null
1935
+ minOf(originalBytes, patchBytes)
1936
+ } ?: originalBytes
1937
+ if (individualBytes > MAX_SAFE_INTEGER - expectedBytes) return null
1938
+ individualBytes += expectedBytes
1939
+ }
1940
+ var logicalBytes = 0L
1941
+ for (asset in manifest.assets.values) {
1942
+ val byteSize = asset.byteSize ?: return null
1943
+ if (logicalBytes > MAX_SAFE_INTEGER - byteSize) return null
1944
+ logicalBytes += byteSize
1945
+ }
1946
+ if (archive.tarByteSize < logicalBytes) return null
1947
+ val tarFramingBytes = archive.tarByteSize - logicalBytes
1948
+ if (individualBytes > MAX_SAFE_INTEGER - tarFramingBytes) return null
1949
+ val framedByteLimit = individualBytes + tarFramingBytes
1950
+
1951
+ if (archive.downloadByteSize <= individualBytes) return archive
1952
+ if (downloads.size != manifest.assets.size || downloads.values.any { it.patch != null }) return null
1953
+ return archive.takeIf { it.downloadByteSize <= framedByteLimit }
1954
+ }
1729
1955
 
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
- }
1956
+ private suspend fun tryInstallArchive(
1957
+ archiveUrl: String,
1958
+ archive: ParsedArchive,
1959
+ manifest: ParsedBundleManifest,
1960
+ tempDir: File,
1961
+ bundleStoreDir: File,
1962
+ tmpDir: File,
1963
+ bundleId: String,
1964
+ progressCallback: (UpdateProgressPayload) -> Unit,
1965
+ ): MutableList<DiffProgressFileSnapshot>? {
1966
+ val archiveScratch = File(tempDir, "archive")
1967
+ val archiveFile = File(archiveScratch, "bundle.tar.br")
1968
+ val extractedDir = File(bundleStoreDir, "$bundleId.archive.tmp")
1969
+ val localBackupDir = File(bundleStoreDir, "$bundleId.local.tmp")
1970
+ archiveScratch.deleteRecursively()
1971
+ extractedDir.deleteRecursively()
1972
+ localBackupDir.deleteRecursively()
1973
+ archiveScratch.mkdirs()
1974
+
1975
+ val progressFiles =
1976
+ mutableListOf(
1977
+ DiffProgressFileSnapshot(
1978
+ path = "bundle.tar.br",
1979
+ downloadPath = "bundle.tar.br",
1980
+ status = "pending",
1981
+ progress = 0.0,
1982
+ order = 0,
1983
+ ),
1984
+ )
1985
+ emitDiffProgress(progressCallback, "downloading", progressFiles)
1750
1986
 
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()
1987
+ return try {
1988
+ when (
1989
+ val result =
1990
+ downloadService.downloadFileOnce(
1991
+ URL(archiveUrl),
1992
+ archiveFile,
1993
+ ) { progress ->
1994
+ updateDiffProgressFile(
1995
+ files = progressFiles,
1996
+ assetPath = "bundle.tar.br",
1997
+ status = "downloading",
1998
+ progress = progress.progress,
1999
+ downloadPath = "bundle.tar.br",
2000
+ downloadedBytes = progress.downloadedBytes,
2001
+ totalBytes = progress.totalBytes,
2002
+ )
2003
+ emitDiffProgress(progressCallback, "downloading", progressFiles)
1758
2004
  }
2005
+ ) {
2006
+ is DownloadResult.Error -> throw result.exception
2007
+ is DownloadResult.Success -> Unit
2008
+ }
1759
2009
 
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()
2010
+ if (archiveFile.length() != archive.downloadByteSize) {
2011
+ throw IllegalStateException("Archive compressed size mismatch")
2012
+ }
2013
+ if (!HashUtils.verifyHash(archiveFile, archive.downloadFileHash)) {
2014
+ throw IllegalStateException("Archive compressed hash mismatch")
2015
+ }
1778
2016
 
1779
- // 12) Keep the fallback bundle and the new staging bundle.
1780
- cleanupOldBundles(bundleStoreDir, updatedMetadata.stableBundleId, bundleId)
2017
+ TarBrArchiveExtractor.extract(
2018
+ archiveFile = archiveFile,
2019
+ destination = extractedDir,
2020
+ expectedTarByteSize = archive.tarByteSize,
2021
+ expectedFiles = manifest.assets.mapValues { it.value.byteSize!! },
2022
+ )
2023
+ manifest.assets.forEach { (assetPath, expectedAsset) ->
2024
+ val extractedFile =
2025
+ RelativePathResolver.resolveInside(extractedDir, assetPath)
2026
+ ?: throw IllegalStateException("Archive output path is invalid")
2027
+ verifyManifestAssetFileOrThrow(
2028
+ extractedFile,
2029
+ expectedAsset,
2030
+ hashMismatch = UpdateFailure.install(UpdateFailureReason.EXTRACT),
2031
+ )
2032
+ }
1781
2033
 
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
- }
2034
+ if (!tmpDir.renameTo(localBackupDir)) {
2035
+ throw IllegalStateException("Cannot preserve locally prepared staging directory")
2036
+ }
2037
+ if (!extractedDir.renameTo(tmpDir)) {
2038
+ check(localBackupDir.renameTo(tmpDir)) { "Cannot restore locally prepared staging directory" }
2039
+ throw IllegalStateException("Cannot promote verified archive extraction")
1785
2040
  }
2041
+ localBackupDir.deleteRecursively()
2042
+
2043
+ updateDiffProgressFile(
2044
+ files = progressFiles,
2045
+ assetPath = "bundle.tar.br",
2046
+ status = "downloaded",
2047
+ progress = 1.0,
2048
+ downloadedBytes = archive.downloadByteSize,
2049
+ totalBytes = archive.downloadByteSize,
2050
+ )
2051
+ emitDiffProgress(progressCallback, "finalizing", progressFiles)
2052
+ progressFiles
2053
+ } catch (error: Exception) {
2054
+ Log.w(TAG, "Archive optimization failed; using individual files: ${error.message}")
2055
+ val canUseIndividualPlan =
2056
+ tmpDir.exists() ||
2057
+ (localBackupDir.exists() && localBackupDir.renameTo(tmpDir))
2058
+ if (!canUseIndividualPlan) {
2059
+ // Only the staging directory swap can leave tmpDir missing.
2060
+ throw HotUpdaterException.unexpected(error, UpdateFailure.install(UpdateFailureReason.STORAGE))
2061
+ }
2062
+ extractedDir.deleteRecursively()
2063
+ null
2064
+ } finally {
2065
+ archiveScratch.deleteRecursively()
2066
+ extractedDir.deleteRecursively()
2067
+ if (tmpDir.exists()) localBackupDir.deleteRecursively()
1786
2068
  }
1787
2069
  }
1788
2070
 
1789
- /**
1790
- * Removes old bundles except for the specified bundle IDs, and any leftover .tmp directories
1791
- */
1792
2071
  private suspend fun updateBundleFromManifest(
1793
2072
  bundleId: String,
1794
2073
  manifestUrl: String,
1795
2074
  manifestFileHash: String,
1796
2075
  changedAssets: Map<String, ChangedAssetDescriptor>,
2076
+ archiveUrl: String?,
1797
2077
  bundleStoreDir: File,
1798
2078
  finalBundleDir: File,
1799
2079
  progressCallback: (UpdateProgressPayload) -> Unit,
1800
- ) {
2080
+ ): UpdateBundleResult {
1801
2081
  val activeBundleDir = getActiveBundleDir()
1802
2082
  val currentBundleId = getBundleId()
1803
- val currentManifest =
1804
- getActiveBundleMetadataSnapshot()?.manifest?.let(::parseBundleManifestFromMap)
1805
2083
  val baseDir =
1806
2084
  fileSystem.getInternalFilesDir() ?: bundleStoreDir.parentFile ?: bundleStoreDir
1807
2085
  val tempDir = File(baseDir, "bundle-manifest-temp")
@@ -1812,345 +2090,284 @@ class BundleFileStorageService(
1812
2090
  }
1813
2091
  tempDir.mkdirs()
1814
2092
 
1815
- val diffFiles = createDiffProgressFiles(changedAssets)
2093
+ var diffFiles = mutableListOf<DiffProgressFileSnapshot>()
1816
2094
 
1817
2095
  try {
1818
2096
  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
- )
2097
+ val targetManifest =
2098
+ updateStep(UpdateFailureStage.DOWNLOAD, UpdateFailureResource.MANIFEST) {
1930
2099
  emitDiffProgress(
1931
2100
  progressCallback = progressCallback,
1932
- phase = "downloading",
2101
+ phase = "manifest",
1933
2102
  files = diffFiles,
2103
+ manifestProgress = 0.0,
1934
2104
  )
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
- )
1960
- emitDiffProgress(
1961
- progressCallback = progressCallback,
1962
- phase =
1963
- if (diffFiles.all { it.status == "downloaded" }) {
1964
- "finalizing"
1965
- } else {
1966
- "downloading"
1967
- },
1968
- files = diffFiles,
1969
- )
1970
- return@forEachIndexed
1971
- }
2105
+ when (
2106
+ val manifestDownloadResult =
2107
+ downloadService.downloadFile(
2108
+ URL(manifestUrl),
2109
+ manifestFile,
2110
+ ) { downloadProgress ->
2111
+ emitDiffProgress(
2112
+ progressCallback = progressCallback,
2113
+ phase = "manifest",
2114
+ files = diffFiles,
2115
+ manifestProgress = downloadProgress.progress,
2116
+ )
2117
+ }
2118
+ ) {
2119
+ is DownloadResult.Error -> {
2120
+ throw downloadError(manifestDownloadResult.exception)
2121
+ }
1972
2122
 
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
- )
2123
+ is DownloadResult.Success -> {
2124
+ Unit
1990
2125
  }
2126
+ }
1991
2127
 
1992
- val downloadPath = downloadPathForChangedAsset(assetPath, changedAsset)
1993
- val downloadedAssetFile =
1994
- if (changedAsset.fileCompression == "br") {
1995
- compressedTempFile(tempDir, assetPath, "br")
1996
- } else {
1997
- targetFile
2128
+ try {
2129
+ SignatureVerifier.verifyBundle(context, manifestFile, manifestFileHash)
2130
+ } catch (e: SignatureVerificationException) {
2131
+ throw HotUpdaterException.signatureVerificationFailed(e)
1998
2132
  }
1999
2133
 
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
- )
2134
+ val manifest = parseBundleManifestFromFile(manifestFile) ?: throw HotUpdaterException.invalidBundle()
2135
+ if (manifest.bundleId != bundleId) {
2136
+ throw HotUpdaterException.invalidBundle()
2137
+ }
2138
+ // Reuse never bypasses the complete artifact contract.
2139
+ if (manifest.assets.keys != changedAssets.keys) throw HotUpdaterException.invalidBundle()
2140
+ manifest.assets.forEach { (path, asset) ->
2141
+ val descriptor = changedAssets.getValue(path)
2142
+ if (RelativePathResolver.normalizeRelativePath(path) != path ||
2143
+ path == "manifest.json" ||
2144
+ descriptor.fileUrl.isNullOrBlank() ||
2145
+ !descriptor.fileHash.equals(asset.fileHash, ignoreCase = true) ||
2146
+ RelativePathResolver.resolveInside(tmpDir, path) == null
2147
+ ) {
2148
+ throw HotUpdaterException.invalidBundle()
2020
2149
  }
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,
2150
+ }
2151
+ manifest
2152
+ }
2153
+ updateStep(UpdateFailureStage.INSTALL, resource = null) {
2154
+ if (!tmpDir.isDirectory && !tmpDir.mkdirs()) throw HotUpdaterException.directoryCreationFailed()
2155
+ pruneStagingDirectory(tmpDir, targetManifest.assets.keys)
2156
+ }
2157
+ val downloads =
2158
+ updateStep(UpdateFailureStage.INSTALL, UpdateFailureResource.FILE) {
2159
+ val targetEntries = targetManifest.assets.entries.sortedBy { it.key }
2160
+ val localDirectories = listOfNotNull(activeBundleDir, finalBundleDir).distinct().filter { it.isDirectory }
2161
+ val downloads = mutableMapOf<String, ChangedAssetDescriptor>()
2162
+ targetEntries.forEachIndexed { index, (assetPath, expectedAsset) ->
2163
+ progressCallback(
2164
+ UpdateProgressPayload(
2165
+ progress = 0.15 + 0.05 * index / targetEntries.size.coerceAtLeast(1),
2166
+ artifactType = "diff",
2167
+ details = DiffProgressDetails(totalFilesCount = 0, completedFilesCount = 0),
2168
+ ),
2036
2169
  )
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
- )
2170
+ val targetFile = RelativePathResolver.resolveInside(tmpDir, assetPath) ?: throw HotUpdaterException.invalidBundle()
2171
+ targetFile.parentFile?.mkdirs()
2172
+ if (targetFile.isFile && runCatching { verifyManifestAssetFileOrThrow(targetFile, expectedAsset) }.isSuccess) {
2173
+ return@forEachIndexed
2044
2174
  }
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()
2175
+ if (targetFile.exists()) targetFile.delete()
2176
+ var reused = false
2177
+ for (directory in localDirectories) {
2178
+ val sourceFile = RelativePathResolver.resolveInside(directory, assetPath) ?: continue
2179
+ val sourceMatches =
2180
+ sourceFile.isFile && runCatching { verifyManifestAssetFileOrThrow(sourceFile, expectedAsset) }.isSuccess
2181
+ if (!sourceMatches) continue
2182
+ if (runCatching {
2183
+ copyBundleFile(sourceFile, targetFile)
2184
+ verifyManifestAssetFileOrThrow(targetFile, expectedAsset)
2185
+ }.isSuccess
2186
+ ) {
2187
+ reused = true
2188
+ break
2079
2189
  }
2190
+ targetFile.delete()
2080
2191
  }
2081
- updateDiffProgressFile(
2082
- files = diffFiles,
2083
- assetPath = assetPath,
2084
- status = "downloaded",
2085
- progress = 1.0,
2086
- )
2087
- emitDiffProgress(
2192
+ if (reused) return@forEachIndexed
2193
+ if (builtInAssetResolver.copyIfMatches(assetPath, expectedAsset.fileHash, targetFile)) {
2194
+ verifyManifestAssetFileOrThrow(targetFile, expectedAsset)
2195
+ return@forEachIndexed
2196
+ }
2197
+ downloads[assetPath] = changedAssets.getValue(assetPath)
2198
+ }
2199
+ downloads
2200
+ }
2201
+ val selectedArchive = selectArchive(targetManifest, downloads, archiveUrl)
2202
+ val archiveProgress =
2203
+ if (selectedArchive != null) {
2204
+ updateStep(UpdateFailureStage.DOWNLOAD, UpdateFailureResource.ARCHIVE) {
2205
+ tryInstallArchive(
2206
+ archiveUrl = archiveUrl!!,
2207
+ archive = selectedArchive,
2208
+ manifest = targetManifest,
2209
+ tempDir = tempDir,
2210
+ bundleStoreDir = bundleStoreDir,
2211
+ tmpDir = tmpDir,
2212
+ bundleId = bundleId,
2088
2213
  progressCallback = progressCallback,
2089
- phase = if (diffFiles.all { it.status == "downloaded" }) "finalizing" else "downloading",
2090
- files = diffFiles,
2091
2214
  )
2092
2215
  }
2216
+ } else {
2217
+ null
2093
2218
  }
2094
- }
2095
-
2096
- emitDiffProgress(
2097
- progressCallback = progressCallback,
2098
- phase = "finalizing",
2099
- files = diffFiles,
2100
- )
2101
2219
 
2102
- writeBundleManifestFile(File(tmpDir, "manifest.json"), targetManifest)
2220
+ val fileDeliveries =
2221
+ if (archiveProgress != null) {
2222
+ diffFiles = archiveProgress
2223
+ emptyList()
2224
+ } else {
2225
+ diffFiles = createDiffProgressFiles(downloads)
2226
+ emitDiffProgress(progressCallback, "downloading", diffFiles)
2227
+ val progressLock = Any()
2228
+ coroutineScope {
2229
+ val permits = Semaphore(4)
2230
+ diffFiles
2231
+ .toList()
2232
+ .map { progressFile ->
2233
+ async {
2234
+ permits.withPermit {
2235
+ updateStep(UpdateFailureStage.DOWNLOAD, UpdateFailureResource.FILE) {
2236
+ val path = progressFile.path
2237
+ val workerTemp =
2238
+ File(tempDir, progressFile.order.toString()).apply { mkdirs() }
2239
+ downloadManifestAsset(
2240
+ assetPath = path,
2241
+ expectedAsset = targetManifest.assets.getValue(path),
2242
+ changedAsset = downloads.getValue(path),
2243
+ currentBundleId = currentBundleId,
2244
+ activeBundleDir = activeBundleDir,
2245
+ targetFile =
2246
+ RelativePathResolver.resolveInside(tmpDir, path)
2247
+ ?: throw HotUpdaterException.invalidBundle(),
2248
+ tempDir = workerTemp,
2249
+ progressFile = progressFile,
2250
+ progressCallback = { payload ->
2251
+ synchronized(progressLock) {
2252
+ payload.details?.files?.firstOrNull()?.let { snapshot ->
2253
+ val index = diffFiles.indexOfFirst { it.path == path }
2254
+ diffFiles[index] = snapshot
2255
+ }
2256
+ emitDiffProgress(progressCallback, "downloading", diffFiles)
2257
+ }
2258
+ },
2259
+ )
2260
+ }
2261
+ }
2262
+ }
2263
+ }.awaitAll()
2264
+ }
2265
+ }
2103
2266
 
2104
- val extractedIndex = tmpDir.walk().find { it.name == "index.android.bundle" }
2105
- if (extractedIndex == null) {
2106
- throw HotUpdaterException.invalidBundle()
2107
- }
2267
+ updateStep(UpdateFailureStage.INSTALL, resource = null) {
2268
+ emitDiffProgress(
2269
+ progressCallback = progressCallback,
2270
+ phase = "finalizing",
2271
+ files = diffFiles,
2272
+ )
2108
2273
 
2109
- if (finalBundleDir.exists()) {
2110
- finalBundleDir.deleteRecursively()
2111
- }
2274
+ manifestFile.copyTo(File(tmpDir, "manifest.json"), overwrite = true)
2112
2275
 
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()
2276
+ val extractedIndex = tmpDir.walk().find { it.name == "index.android.bundle" }
2277
+ if (extractedIndex == null) {
2278
+ // The manifest lists no platform bundle.
2279
+ throw HotUpdaterException.invalidBundle().withResource(UpdateFailureResource.MANIFEST)
2120
2280
  }
2121
- }
2122
2281
 
2123
- val finalIndexFile = finalBundleDir.walk().find { it.name == "index.android.bundle" }
2124
- if (finalIndexFile == null) {
2125
- finalBundleDir.deleteRecursively()
2126
- throw HotUpdaterException.invalidBundle()
2127
- }
2282
+ val backupDir = installBackupDir(bundleId)
2283
+ val updatedMetadata =
2284
+ synchronized(releaseStateLock) {
2285
+ if (backupDir.exists()) {
2286
+ throw HotUpdaterException.moveOperationFailed()
2287
+ }
2288
+
2289
+ val hadExistingFinal = finalBundleDir.exists()
2290
+ var promoted = false
2291
+ val previousBundleUrl = preferences.getItem("HotUpdaterBundleURL")
2292
+ try {
2293
+ if (hadExistingFinal && !directoryRenamer(finalBundleDir, backupDir)) {
2294
+ throw HotUpdaterException.moveOperationFailed()
2295
+ }
2296
+ if (!directoryRenamer(tmpDir, finalBundleDir)) {
2297
+ if (hadExistingFinal) {
2298
+ directoryRenamer(backupDir, finalBundleDir)
2299
+ }
2300
+ throw HotUpdaterException.moveOperationFailed()
2301
+ }
2302
+ promoted = true
2128
2303
 
2129
- finalBundleDir.setLastModified(System.currentTimeMillis())
2304
+ val finalIndexFile =
2305
+ resolveBundleFile(finalBundleDir, bundleId)
2306
+ ?: throw HotUpdaterException.invalidBundle()
2307
+ if (!isCompleteBundleDirectory(finalBundleDir, bundleId)) {
2308
+ throw HotUpdaterException.invalidBundle()
2309
+ }
2310
+ finalBundleDir.setLastModified(System.currentTimeMillis())
2130
2311
 
2131
- val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
2132
- val updatedMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
2133
- saveMetadata(updatedMetadata)
2134
- setBundleURL(finalIndexFile.absolutePath)
2312
+ val currentMetadata = loadMetadataOrNull() ?: createInitialMetadata()
2313
+ val nextMetadata = prepareMetadataForNewStagingBundle(currentMetadata, bundleId)
2314
+ if (!setBundleURL(finalIndexFile.absolutePath)) {
2315
+ throw HotUpdaterException.storageFailed("Failed to persist bundle URL")
2316
+ }
2317
+ if (!saveMetadata(nextMetadata)) {
2318
+ throw HotUpdaterException.storageFailed("Failed to persist bundle metadata")
2319
+ }
2135
2320
 
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
- ),
2321
+ if (backupDir.exists() && !backupDir.deleteRecursively()) {
2322
+ Log.w(TAG, "Failed to remove committed install backup: ${backupDir.absolutePath}")
2323
+ }
2324
+ nextMetadata
2325
+ } catch (e: Exception) {
2326
+ runCatching { preferences.setItem("HotUpdaterBundleURL", previousBundleUrl) }
2327
+ clearActiveBundleMetadataSnapshot()
2328
+ if (promoted && finalBundleDir.exists() && !finalBundleDir.deleteRecursively()) {
2329
+ Log.e(TAG, "Failed to remove uncommitted bundle: ${finalBundleDir.absolutePath}")
2330
+ }
2331
+ if (hadExistingFinal &&
2332
+ !finalBundleDir.exists() &&
2333
+ backupDir.exists() &&
2334
+ !directoryRenamer(backupDir, finalBundleDir)
2335
+ ) {
2336
+ Log.e(TAG, "Failed to restore install backup: ${backupDir.absolutePath}")
2337
+ }
2338
+ throw e
2339
+ }
2340
+ }
2341
+
2342
+ tempDir.deleteRecursively()
2343
+ cleanupOldBundles(bundleStoreDir, updatedMetadata.stableBundleId, bundleId)
2344
+ progressCallback(
2345
+ UpdateProgressPayload(
2346
+ progress = 1.0,
2347
+ artifactType = "diff",
2348
+ details =
2349
+ DiffProgressDetails(
2350
+ totalFilesCount = diffFiles.size,
2351
+ completedFilesCount = diffFiles.count { it.status == "downloaded" },
2352
+ files = diffFiles.toList(),
2353
+ ),
2354
+ ),
2355
+ )
2356
+ }
2357
+
2358
+ return UpdateBundleResult(
2359
+ delivery =
2360
+ when {
2361
+ archiveProgress != null -> BundleDelivery.ARCHIVE
2362
+ FileDelivery.PATCHED in fileDeliveries -> BundleDelivery.PATCH
2363
+ else -> BundleDelivery.MANIFEST
2364
+ },
2365
+ patchFallback = FileDelivery.DOWNLOADED_AFTER_PATCH_FAILURE in fileDeliveries,
2149
2366
  )
2150
2367
  } catch (e: Exception) {
2151
2368
  tempDir.deleteRecursively()
2152
- tmpDir.deleteRecursively()
2153
- throw e
2369
+ // A retry rehashes completed files and rejects partial bytes.
2370
+ throw classifyUpdateFailure(e, UpdateFailureStage.DOWNLOAD, resource = null)
2154
2371
  }
2155
2372
  }
2156
2373
 
@@ -2164,7 +2381,9 @@ class BundleFileStorageService(
2164
2381
  val bundles =
2165
2382
  bundleStoreDir
2166
2383
  .listFiles { file ->
2167
- file.isDirectory && !file.name.endsWith(".tmp")
2384
+ file.isDirectory &&
2385
+ !file.name.endsWith(".tmp") &&
2386
+ !file.name.endsWith(".install-backup")
2168
2387
  }?.toList() ?: return
2169
2388
 
2170
2389
  // Keep only the specified bundle IDs (filter out null values)
@@ -2287,6 +2506,7 @@ class BundleFileStorageService(
2287
2506
  pendingUpdateStrategy = null,
2288
2507
  pendingTransition = null,
2289
2508
  verificationPending = false,
2509
+ launchInProgress = false,
2290
2510
  updatedAt = System.currentTimeMillis(),
2291
2511
  )
2292
2512