@hot-updater/react-native 0.36.7 → 1.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +357 -6
  2. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +268 -34
  3. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +75 -9
  4. package/android/src/main/java/com/hotupdater/NativeConfigUtils.kt +2 -16
  5. package/android/src/main/java/com/hotupdater/ReleaseCatalogCacheService.kt +166 -0
  6. package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +1 -2
  7. package/android/src/newarch/HotUpdater.kt +1 -1
  8. package/android/src/newarch/HotUpdaterModule.kt +116 -0
  9. package/android/src/oldarch/HotUpdater.kt +1 -1
  10. package/android/src/oldarch/HotUpdaterModule.kt +128 -0
  11. package/android/src/oldarch/HotUpdaterSpec.kt +38 -0
  12. package/android/src/test/java/com/hotupdater/BundleFileStorageServiceTest.kt +280 -2
  13. package/android/src/test/java/com/hotupdater/HotUpdaterImplTest.kt +57 -2
  14. package/android/src/test/java/com/hotupdater/ReleaseCatalogCacheServiceTest.kt +118 -0
  15. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +491 -30
  16. package/ios/HotUpdater/Internal/BundleMetadata.swift +229 -6
  17. package/ios/HotUpdater/Internal/HotUpdater.mm +122 -0
  18. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +176 -7
  19. package/ios/HotUpdater/Internal/ReleaseCatalogCacheService.swift +213 -0
  20. package/lib/commonjs/catalogCacheNative.js +44 -0
  21. package/lib/commonjs/catalogCacheNative.js.map +1 -0
  22. package/lib/commonjs/catalogCacheNative.spec.js +31 -0
  23. package/lib/commonjs/catalogCacheNative.spec.js.map +1 -0
  24. package/lib/commonjs/checkForUpdate.js +231 -60
  25. package/lib/commonjs/checkForUpdate.js.map +1 -1
  26. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js +604 -0
  27. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js.map +1 -0
  28. package/lib/commonjs/error.js +8 -1
  29. package/lib/commonjs/error.js.map +1 -1
  30. package/lib/commonjs/{fetchUpdateInfo.js → fetchJSON.js} +11 -14
  31. package/lib/commonjs/fetchJSON.js.map +1 -0
  32. package/lib/commonjs/httpClient.js +153 -0
  33. package/lib/commonjs/httpClient.js.map +1 -0
  34. package/lib/commonjs/httpClient.spec.js +102 -0
  35. package/lib/commonjs/httpClient.spec.js.map +1 -0
  36. package/lib/commonjs/index.js +48 -71
  37. package/lib/commonjs/index.js.map +1 -1
  38. package/lib/commonjs/index.spec.js +117 -161
  39. package/lib/commonjs/index.spec.js.map +1 -1
  40. package/lib/commonjs/iosInterop.spec.js +18 -0
  41. package/lib/commonjs/iosInterop.spec.js.map +1 -0
  42. package/lib/commonjs/native.js +222 -55
  43. package/lib/commonjs/native.js.map +1 -1
  44. package/lib/commonjs/native.spec.js +226 -12
  45. package/lib/commonjs/native.spec.js.map +1 -1
  46. package/lib/commonjs/notifyAppReadyAnalytics.boundary.spec.js +218 -0
  47. package/lib/commonjs/notifyAppReadyAnalytics.boundary.spec.js.map +1 -0
  48. package/lib/commonjs/notifyAppReadyAnalytics.js +133 -0
  49. package/lib/commonjs/notifyAppReadyAnalytics.js.map +1 -0
  50. package/lib/commonjs/notifyAppReadyAnalytics.spec.js +246 -0
  51. package/lib/commonjs/notifyAppReadyAnalytics.spec.js.map +1 -0
  52. package/lib/commonjs/notifyAppReadyAnalytics.test-utils.js +24 -0
  53. package/lib/commonjs/notifyAppReadyAnalytics.test-utils.js.map +1 -0
  54. package/lib/commonjs/notifyAppReadyAnalytics.transport.spec.js +117 -0
  55. package/lib/commonjs/notifyAppReadyAnalytics.transport.spec.js.map +1 -0
  56. package/lib/commonjs/releaseCatalogCache.js +179 -0
  57. package/lib/commonjs/releaseCatalogCache.js.map +1 -0
  58. package/lib/commonjs/releaseCatalogCache.spec.js +195 -0
  59. package/lib/commonjs/releaseCatalogCache.spec.js.map +1 -0
  60. package/lib/commonjs/releaseCatalogCacheNativeContract.spec.js +64 -0
  61. package/lib/commonjs/releaseCatalogCacheNativeContract.spec.js.map +1 -0
  62. package/lib/commonjs/sdkVersion.js +1 -1
  63. package/lib/commonjs/sdkVersion.js.map +1 -1
  64. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  65. package/lib/commonjs/types.js +0 -12
  66. package/lib/commonjs/types.js.map +1 -1
  67. package/lib/commonjs/wrap.js +7 -82
  68. package/lib/commonjs/wrap.js.map +1 -1
  69. package/lib/commonjs/wrap.spec.js +203 -56
  70. package/lib/commonjs/wrap.spec.js.map +1 -1
  71. package/lib/commonjs/wrap.types.js +6 -0
  72. package/lib/commonjs/wrap.types.js.map +1 -0
  73. package/lib/module/catalogCacheNative.js +36 -0
  74. package/lib/module/catalogCacheNative.js.map +1 -0
  75. package/lib/module/catalogCacheNative.spec.js +31 -0
  76. package/lib/module/catalogCacheNative.spec.js.map +1 -0
  77. package/lib/module/checkForUpdate.js +233 -62
  78. package/lib/module/checkForUpdate.js.map +1 -1
  79. package/lib/module/checkForUpdate.releaseCatalog.spec.js +603 -0
  80. package/lib/module/checkForUpdate.releaseCatalog.spec.js.map +1 -0
  81. package/lib/module/error.js +6 -0
  82. package/lib/module/error.js.map +1 -1
  83. package/lib/module/{fetchUpdateInfo.js → fetchJSON.js} +9 -12
  84. package/lib/module/fetchJSON.js.map +1 -0
  85. package/lib/module/httpClient.js +148 -0
  86. package/lib/module/httpClient.js.map +1 -0
  87. package/lib/module/httpClient.spec.js +102 -0
  88. package/lib/module/httpClient.spec.js.map +1 -0
  89. package/lib/module/index.js +49 -66
  90. package/lib/module/index.js.map +1 -1
  91. package/lib/module/index.spec.js +117 -161
  92. package/lib/module/index.spec.js.map +1 -1
  93. package/lib/module/iosInterop.spec.js +18 -0
  94. package/lib/module/iosInterop.spec.js.map +1 -0
  95. package/lib/module/native.js +206 -51
  96. package/lib/module/native.js.map +1 -1
  97. package/lib/module/native.spec.js +226 -12
  98. package/lib/module/native.spec.js.map +1 -1
  99. package/lib/module/notifyAppReadyAnalytics.boundary.spec.js +217 -0
  100. package/lib/module/notifyAppReadyAnalytics.boundary.spec.js.map +1 -0
  101. package/lib/module/notifyAppReadyAnalytics.js +128 -0
  102. package/lib/module/notifyAppReadyAnalytics.js.map +1 -0
  103. package/lib/module/notifyAppReadyAnalytics.spec.js +245 -0
  104. package/lib/module/notifyAppReadyAnalytics.spec.js.map +1 -0
  105. package/lib/module/notifyAppReadyAnalytics.test-utils.js +18 -0
  106. package/lib/module/notifyAppReadyAnalytics.test-utils.js.map +1 -0
  107. package/lib/module/notifyAppReadyAnalytics.transport.spec.js +116 -0
  108. package/lib/module/notifyAppReadyAnalytics.transport.spec.js.map +1 -0
  109. package/lib/module/releaseCatalogCache.js +171 -0
  110. package/lib/module/releaseCatalogCache.js.map +1 -0
  111. package/lib/module/releaseCatalogCache.spec.js +195 -0
  112. package/lib/module/releaseCatalogCache.spec.js.map +1 -0
  113. package/lib/module/releaseCatalogCacheNativeContract.spec.js +64 -0
  114. package/lib/module/releaseCatalogCacheNativeContract.spec.js.map +1 -0
  115. package/lib/module/sdkVersion.js +1 -1
  116. package/lib/module/sdkVersion.js.map +1 -1
  117. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  118. package/lib/module/types.js +0 -12
  119. package/lib/module/types.js.map +1 -1
  120. package/lib/module/wrap.js +6 -81
  121. package/lib/module/wrap.js.map +1 -1
  122. package/lib/module/wrap.spec.js +203 -56
  123. package/lib/module/wrap.spec.js.map +1 -1
  124. package/lib/module/wrap.types.js +4 -0
  125. package/lib/module/wrap.types.js.map +1 -0
  126. package/lib/typescript/commonjs/catalogCacheNative.d.ts +4 -0
  127. package/lib/typescript/commonjs/catalogCacheNative.d.ts.map +1 -0
  128. package/lib/typescript/commonjs/checkForUpdate.d.ts +14 -4
  129. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  130. package/lib/typescript/commonjs/error.d.ts +3 -0
  131. package/lib/typescript/commonjs/error.d.ts.map +1 -1
  132. package/lib/typescript/commonjs/fetchJSON.d.ts +6 -0
  133. package/lib/typescript/commonjs/fetchJSON.d.ts.map +1 -0
  134. package/lib/typescript/commonjs/httpClient.d.ts +56 -0
  135. package/lib/typescript/commonjs/httpClient.d.ts.map +1 -0
  136. package/lib/typescript/commonjs/index.d.ts +32 -41
  137. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  138. package/lib/typescript/commonjs/native.d.ts +83 -18
  139. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  140. package/lib/typescript/commonjs/notifyAppReadyAnalytics.d.ts +13 -0
  141. package/lib/typescript/commonjs/notifyAppReadyAnalytics.d.ts.map +1 -0
  142. package/lib/typescript/commonjs/notifyAppReadyAnalytics.test-utils.d.ts +8 -0
  143. package/lib/typescript/commonjs/notifyAppReadyAnalytics.test-utils.d.ts.map +1 -0
  144. package/lib/typescript/commonjs/releaseCatalogCache.d.ts +25 -0
  145. package/lib/typescript/commonjs/releaseCatalogCache.d.ts.map +1 -0
  146. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  147. package/lib/typescript/commonjs/sdkVersion.d.ts.map +1 -1
  148. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +40 -5
  149. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  150. package/lib/typescript/commonjs/types.d.ts +0 -118
  151. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  152. package/lib/typescript/commonjs/wrap.d.ts +2 -191
  153. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  154. package/lib/typescript/commonjs/wrap.types.d.ts +65 -0
  155. package/lib/typescript/commonjs/wrap.types.d.ts.map +1 -0
  156. package/lib/typescript/module/catalogCacheNative.d.ts +4 -0
  157. package/lib/typescript/module/catalogCacheNative.d.ts.map +1 -0
  158. package/lib/typescript/module/checkForUpdate.d.ts +14 -4
  159. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  160. package/lib/typescript/module/error.d.ts +3 -0
  161. package/lib/typescript/module/error.d.ts.map +1 -1
  162. package/lib/typescript/module/fetchJSON.d.ts +6 -0
  163. package/lib/typescript/module/fetchJSON.d.ts.map +1 -0
  164. package/lib/typescript/module/httpClient.d.ts +56 -0
  165. package/lib/typescript/module/httpClient.d.ts.map +1 -0
  166. package/lib/typescript/module/index.d.ts +32 -41
  167. package/lib/typescript/module/index.d.ts.map +1 -1
  168. package/lib/typescript/module/native.d.ts +83 -18
  169. package/lib/typescript/module/native.d.ts.map +1 -1
  170. package/lib/typescript/module/notifyAppReadyAnalytics.d.ts +13 -0
  171. package/lib/typescript/module/notifyAppReadyAnalytics.d.ts.map +1 -0
  172. package/lib/typescript/module/notifyAppReadyAnalytics.test-utils.d.ts +8 -0
  173. package/lib/typescript/module/notifyAppReadyAnalytics.test-utils.d.ts.map +1 -0
  174. package/lib/typescript/module/releaseCatalogCache.d.ts +25 -0
  175. package/lib/typescript/module/releaseCatalogCache.d.ts.map +1 -0
  176. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  177. package/lib/typescript/module/sdkVersion.d.ts.map +1 -1
  178. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +40 -5
  179. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  180. package/lib/typescript/module/types.d.ts +0 -118
  181. package/lib/typescript/module/types.d.ts.map +1 -1
  182. package/lib/typescript/module/wrap.d.ts +2 -191
  183. package/lib/typescript/module/wrap.d.ts.map +1 -1
  184. package/lib/typescript/module/wrap.types.d.ts +65 -0
  185. package/lib/typescript/module/wrap.types.d.ts.map +1 -0
  186. package/package.json +6 -24
  187. package/src/catalogCacheNative.spec.ts +63 -0
  188. package/src/catalogCacheNative.ts +74 -0
  189. package/src/checkForUpdate.releaseCatalog.spec.ts +596 -0
  190. package/src/checkForUpdate.ts +368 -97
  191. package/src/error.ts +7 -0
  192. package/src/{fetchUpdateInfo.ts → fetchJSON.ts} +8 -15
  193. package/src/httpClient.spec.ts +132 -0
  194. package/src/httpClient.ts +261 -0
  195. package/src/index.spec.ts +110 -203
  196. package/src/index.ts +70 -93
  197. package/src/iosInterop.spec.ts +30 -0
  198. package/src/native.spec.ts +226 -10
  199. package/src/native.ts +385 -83
  200. package/src/notifyAppReadyAnalytics.boundary.spec.ts +234 -0
  201. package/src/notifyAppReadyAnalytics.spec.ts +243 -0
  202. package/src/notifyAppReadyAnalytics.test-utils.ts +31 -0
  203. package/src/notifyAppReadyAnalytics.transport.spec.ts +130 -0
  204. package/src/notifyAppReadyAnalytics.ts +194 -0
  205. package/src/releaseCatalogCache.spec.ts +282 -0
  206. package/src/releaseCatalogCache.ts +341 -0
  207. package/src/releaseCatalogCacheNativeContract.spec.ts +80 -0
  208. package/src/sdkVersion.ts +1 -1
  209. package/src/specs/NativeHotUpdater.ts +51 -5
  210. package/src/types.ts +0 -140
  211. package/src/wrap.spec.ts +251 -56
  212. package/src/wrap.tsx +23 -318
  213. package/src/wrap.types.ts +78 -0
  214. package/android/src/main/java/com/hotupdater/StringResourceUtils.kt +0 -45
  215. package/app.plugin.js +0 -1
  216. package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.h +0 -7
  217. package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.mm +0 -4
  218. package/lib/commonjs/DefaultResolver.js +0 -50
  219. package/lib/commonjs/DefaultResolver.js.map +0 -1
  220. package/lib/commonjs/DefaultResolver.spec.js +0 -144
  221. package/lib/commonjs/DefaultResolver.spec.js.map +0 -1
  222. package/lib/commonjs/fetchUpdateInfo.js.map +0 -1
  223. package/lib/module/DefaultResolver.js +0 -46
  224. package/lib/module/DefaultResolver.js.map +0 -1
  225. package/lib/module/DefaultResolver.spec.js +0 -144
  226. package/lib/module/DefaultResolver.spec.js.map +0 -1
  227. package/lib/module/fetchUpdateInfo.js.map +0 -1
  228. package/lib/typescript/commonjs/DefaultResolver.d.ts +0 -11
  229. package/lib/typescript/commonjs/DefaultResolver.d.ts.map +0 -1
  230. package/lib/typescript/commonjs/fetchUpdateInfo.d.ts +0 -7
  231. package/lib/typescript/commonjs/fetchUpdateInfo.d.ts.map +0 -1
  232. package/lib/typescript/module/DefaultResolver.d.ts +0 -11
  233. package/lib/typescript/module/DefaultResolver.d.ts.map +0 -1
  234. package/lib/typescript/module/fetchUpdateInfo.d.ts +0 -7
  235. package/lib/typescript/module/fetchUpdateInfo.d.ts.map +0 -1
  236. package/plugin/build/transformers.js +0 -314
  237. package/plugin/build/withHotUpdater.js +0 -228
  238. package/src/DefaultResolver.spec.ts +0 -193
  239. package/src/DefaultResolver.ts +0 -63
@@ -153,7 +153,9 @@ class BundleFileStorageServiceTest {
153
153
  assertFalse(selection.shouldRollbackOnCrash)
154
154
  assertFalse(stagingDir.exists())
155
155
  assertEquals("RECOVERED", report["status"])
156
- assertEquals(stagingDir.name, report["crashedBundleId"])
156
+ assertEquals(stagingDir.name, report["fromBundleId"])
157
+ assertEquals(stableDir.name, report["toBundleId"])
158
+ assertEquals("appVersion", report["updateStrategy"])
157
159
 
158
160
  val metadata = loadMetadata(rootDir)
159
161
  assertNotNull(metadata)
@@ -192,7 +194,9 @@ class BundleFileStorageServiceTest {
192
194
  assertFalse(selection.shouldRollbackOnCrash)
193
195
  assertFalse(stagingDir.exists())
194
196
  assertEquals("RECOVERED", report["status"])
195
- assertEquals(stagingDir.name, report["crashedBundleId"])
197
+ assertEquals(stagingDir.name, report["fromBundleId"])
198
+ assertEquals(HotUpdaterImpl.getMinBundleId(), report["toBundleId"])
199
+ assertEquals("appVersion", report["updateStrategy"])
196
200
  }
197
201
 
198
202
  @Test
@@ -244,6 +248,97 @@ class BundleFileStorageServiceTest {
244
248
  assertEquals(stagingDir.name, service.getBundleId())
245
249
  }
246
250
 
251
+ @Test
252
+ fun `markLaunchCompleted records update applied transition`() {
253
+ val rootDir = temporaryFolder.newFolder("update-applied-transition")
254
+ val service = createService(rootDir)
255
+
256
+ val stableDir = createBundleDir(rootDir, "stable-bundle")
257
+ writeFile(stableDir, "index.android.bundle")
258
+ writeManifest(stableDir, listOf("index.android.bundle"))
259
+
260
+ val stagingDir = createBundleDir(rootDir, "staging-bundle")
261
+ writeFile(stagingDir, "index.android.bundle")
262
+ writeManifest(stagingDir, listOf("index.android.bundle"))
263
+
264
+ writeMetadata(
265
+ rootDir,
266
+ BundleMetadata(
267
+ isolationKey = TEST_ISOLATION_KEY,
268
+ stableBundleId = stableDir.name,
269
+ stagingBundleId = stagingDir.name,
270
+ verificationPending = true,
271
+ ),
272
+ )
273
+
274
+ assertEquals(mapOf("status" to "PENDING"), service.notifyAppReady())
275
+
276
+ service.markLaunchCompleted(stagingDir.name)
277
+
278
+ assertEquals(
279
+ mapOf(
280
+ "status" to "UPDATE_APPLIED",
281
+ "fromBundleId" to stableDir.name,
282
+ "toBundleId" to stagingDir.name,
283
+ "updateStrategy" to "appVersion",
284
+ ),
285
+ service.notifyAppReady(),
286
+ )
287
+ }
288
+
289
+ @Test
290
+ fun `install identity persists across service instances and user clear keeps install id`() {
291
+ val rootDir = temporaryFolder.newFolder("install-identity")
292
+ val firstService = createService(rootDir)
293
+
294
+ val firstInstallId = firstService.getInstallId()
295
+ assertTrue(firstInstallId.isNotBlank())
296
+
297
+ firstService.setUser("user-123", "alice")
298
+ assertEquals(
299
+ InstallationIdentity(
300
+ installId = firstInstallId,
301
+ userId = "user-123",
302
+ username = "alice",
303
+ ),
304
+ loadInstallationIdentity(rootDir),
305
+ )
306
+
307
+ val restartedService = createService(rootDir)
308
+ assertEquals(firstInstallId, restartedService.getInstallId())
309
+
310
+ restartedService.setUser(null, null)
311
+ assertEquals(
312
+ InstallationIdentity(
313
+ installId = firstInstallId,
314
+ userId = null,
315
+ username = null,
316
+ ),
317
+ loadInstallationIdentity(rootDir),
318
+ )
319
+ }
320
+
321
+ @Test
322
+ fun `user update keeps cached install id when persisted identity becomes unreadable`() {
323
+ val rootDir = temporaryFolder.newFolder("install-identity-corruption")
324
+ val service = createService(rootDir)
325
+ val installId = service.getInstallId()
326
+ val identityFile = File(bundleStoreDir(rootDir), InstallationIdentity.IDENTITY_FILENAME)
327
+ identityFile.writeText("{")
328
+
329
+ service.setUser("user-123", "alice")
330
+
331
+ assertEquals(installId, service.getInstallId())
332
+ assertEquals(
333
+ InstallationIdentity(
334
+ installId = installId,
335
+ userId = "user-123",
336
+ username = "alice",
337
+ ),
338
+ loadInstallationIdentity(rootDir),
339
+ )
340
+ }
341
+
247
342
  @Test
248
343
  fun `manifest driven install is disabled before first OTA`() {
249
344
  val rootDir = temporaryFolder.newFolder("first-ota-manifest-disabled")
@@ -280,6 +375,164 @@ class BundleFileStorageServiceTest {
280
375
  assertFalse(invokeCanUseManifestDrivenInstall(service))
281
376
  }
282
377
 
378
+ @Test
379
+ fun `catalog high water rejects replay and survives channel reset`() =
380
+ runBlocking {
381
+ val rootDir = temporaryFolder.newFolder("release-high-water")
382
+ val service = createService(rootDir)
383
+
384
+ assertTrue(
385
+ service.acceptReleaseCatalog(
386
+ catalogId = "project-a",
387
+ scopeKey = "scope-production",
388
+ generation = 2,
389
+ catalogHash = "hash-2",
390
+ channel = "production",
391
+ selectionContextHash = "context-2",
392
+ ),
393
+ )
394
+ assertFalse(
395
+ service.acceptReleaseCatalog(
396
+ catalogId = "project-a",
397
+ scopeKey = "scope-production",
398
+ generation = 1,
399
+ catalogHash = "hash-1",
400
+ channel = "production",
401
+ selectionContextHash = "context-1",
402
+ ),
403
+ )
404
+ assertFalse(
405
+ service.acceptReleaseCatalog(
406
+ catalogId = "project-a",
407
+ scopeKey = "scope-production",
408
+ generation = 2,
409
+ catalogHash = "different-hash",
410
+ channel = "production",
411
+ selectionContextHash = "context-2",
412
+ ),
413
+ )
414
+
415
+ assertTrue(service.resetChannel())
416
+ val metadata = loadMetadata(rootDir)
417
+ assertEquals(
418
+ CatalogHighWater(generation = 2, catalogHash = "hash-2"),
419
+ metadata?.highestSeenCatalogs?.get("project-a|scope-production"),
420
+ )
421
+ }
422
+
423
+ @Test
424
+ fun `same bundle adoption refreshes its receipt without changing bytes`() =
425
+ runBlocking {
426
+ val rootDir = temporaryFolder.newFolder("same-bundle-adoption")
427
+ val service = createService(rootDir)
428
+ val bundleDir = createBundleDir(rootDir, "bundle-one")
429
+ writeFile(bundleDir, "index.android.bundle")
430
+ writeManifest(bundleDir, listOf("index.android.bundle"))
431
+ val oldSelection =
432
+ releaseSelection(
433
+ releaseId = "release-one",
434
+ bundleId = bundleDir.name,
435
+ generation = 1,
436
+ catalogHash = "hash-1",
437
+ selectionContextHash = "context-1",
438
+ )
439
+ writeMetadata(
440
+ rootDir,
441
+ BundleMetadata(
442
+ isolationKey = TEST_ISOLATION_KEY,
443
+ stagingBundleId = bundleDir.name,
444
+ stagingSelection = oldSelection,
445
+ ),
446
+ )
447
+ assertTrue(
448
+ service.acceptReleaseCatalog(
449
+ catalogId = "project-a",
450
+ scopeKey = "scope-production",
451
+ generation = 2,
452
+ catalogHash = "hash-2",
453
+ channel = "production",
454
+ selectionContextHash = "context-2",
455
+ ),
456
+ )
457
+ val newSelection =
458
+ releaseSelection(
459
+ releaseId = "release-two",
460
+ bundleId = bundleDir.name,
461
+ generation = 2,
462
+ catalogHash = "hash-2",
463
+ selectionContextHash = "context-2",
464
+ )
465
+
466
+ assertTrue(service.commitReleaseSelection(newSelection))
467
+
468
+ assertTrue(bundleDir.isDirectory)
469
+ assertEquals(newSelection, loadMetadata(rootDir)?.stagingSelection)
470
+ assertFalse(loadMetadata(rootDir)?.verificationPending ?: true)
471
+ }
472
+
473
+ @Test
474
+ fun `crash restores the complete stable receipt but retains newer high water`() {
475
+ val rootDir = temporaryFolder.newFolder("release-crash-ledger")
476
+ val service = createService(rootDir)
477
+ val stableDir = createBundleDir(rootDir, "bundle-one")
478
+ writeFile(stableDir, "index.android.bundle")
479
+ writeManifest(stableDir, listOf("index.android.bundle"))
480
+ val stagingDir = createBundleDir(rootDir, "bundle-two")
481
+ writeFile(stagingDir, "index.android.bundle")
482
+ writeManifest(stagingDir, listOf("index.android.bundle"))
483
+ val stableSelection =
484
+ releaseSelection("release-one", stableDir.name, 1, "hash-1", "context-1")
485
+ val stagingSelection =
486
+ releaseSelection("release-two", stagingDir.name, 2, "hash-2", "context-2")
487
+ writeMetadata(
488
+ rootDir,
489
+ BundleMetadata(
490
+ isolationKey = TEST_ISOLATION_KEY,
491
+ stableBundleId = stableDir.name,
492
+ stagingBundleId = stagingDir.name,
493
+ stableSelection = stableSelection,
494
+ stagingSelection = stagingSelection,
495
+ pendingUpdateStrategy = "appVersion",
496
+ pendingTransition =
497
+ PendingSelectionTransition(
498
+ fromReleaseId = stableSelection.releaseId,
499
+ fromBundleId = stableSelection.bundleId,
500
+ toReleaseId = stagingSelection.releaseId,
501
+ toBundleId = stagingSelection.bundleId,
502
+ ),
503
+ verificationPending = true,
504
+ highestSeenCatalogs =
505
+ mapOf(
506
+ "project-a|scope-production" to
507
+ CatalogHighWater(generation = 2, catalogHash = "hash-2"),
508
+ ),
509
+ currentSelectionContexts =
510
+ mapOf("project-a|scope-production" to "production\ncontext-2"),
511
+ ),
512
+ )
513
+
514
+ val launch =
515
+ service.prepareLaunch(
516
+ PendingCrashRecovery(
517
+ launchedBundleId = stagingDir.name,
518
+ shouldRollback = true,
519
+ ),
520
+ )
521
+ val report = service.notifyAppReady()
522
+ val metadata = loadMetadata(rootDir)
523
+
524
+ assertEquals(stableDir.name, launch.launchedBundleId)
525
+ assertEquals("RECOVERED", report["status"])
526
+ assertEquals("release-two", report["fromReleaseId"])
527
+ assertEquals("release-one", report["toReleaseId"])
528
+ assertEquals(stableSelection, metadata?.stagingSelection)
529
+ assertEquals(
530
+ CatalogHighWater(generation = 2, catalogHash = "hash-2"),
531
+ metadata?.highestSeenCatalogs?.get("project-a|scope-production"),
532
+ )
533
+ assertTrue(service.getCrashHistory().contains(stagingDir.name))
534
+ }
535
+
283
536
  @Test
284
537
  fun `manifest driven install moves blocking work off caller dispatcher`() {
285
538
  val rootDir = temporaryFolder.newFolder("manifest-install-dispatcher")
@@ -354,6 +607,26 @@ class BundleFileStorageServiceTest {
354
607
  DecompressService(),
355
608
  preferences,
356
609
  TEST_ISOLATION_KEY,
610
+ { "production" },
611
+ )
612
+
613
+ private fun releaseSelection(
614
+ releaseId: String,
615
+ bundleId: String,
616
+ generation: Long,
617
+ catalogHash: String,
618
+ selectionContextHash: String,
619
+ ): PersistedSelection =
620
+ PersistedSelection(
621
+ kind = "BUNDLE",
622
+ releaseId = releaseId,
623
+ bundleId = bundleId,
624
+ catalogId = "project-a",
625
+ scopeKey = "scope-production",
626
+ generation = generation,
627
+ catalogHash = catalogHash,
628
+ channel = "production",
629
+ selectionContextHash = selectionContextHash,
357
630
  )
358
631
 
359
632
  private fun createBundleDir(
@@ -393,6 +666,11 @@ class BundleFileStorageServiceTest {
393
666
  TEST_ISOLATION_KEY,
394
667
  )
395
668
 
669
+ private fun loadInstallationIdentity(rootDir: File): InstallationIdentity? =
670
+ InstallationIdentity.loadFromFile(
671
+ File(bundleStoreDir(rootDir), InstallationIdentity.IDENTITY_FILENAME),
672
+ )
673
+
396
674
  private fun writeFile(
397
675
  rootDir: File,
398
676
  relativePath: String,
@@ -107,6 +107,41 @@ class HotUpdaterImplTest {
107
107
  assertNull(impl.getBaseURL())
108
108
  }
109
109
 
110
+ @Test
111
+ fun `notifyAppReady reads the report without completing the current launch`() {
112
+ val storage = FakeBundleStorageService(bundleId = null)
113
+ val impl = allocateWithoutConstructor<HotUpdaterImpl>()
114
+ setField(impl, "bundleStorage", storage)
115
+ setCurrentLaunchSelection(
116
+ impl,
117
+ LaunchSelection(
118
+ bundleUrl = "file:///bundle-store/launched-bundle/index.android.bundle",
119
+ launchedBundleId = "launched-bundle",
120
+ shouldRollbackOnCrash = false,
121
+ ),
122
+ )
123
+
124
+ assertEquals(mapOf("status" to "UNCHANGED"), impl.notifyAppReady())
125
+ assertNull(storage.lastCompletedBundleId)
126
+ }
127
+
128
+ @Test
129
+ fun `getInstallId and setUser delegate to bundle storage`() {
130
+ val storage = FakeBundleStorageService(bundleId = null)
131
+ val impl = allocateWithoutConstructor<HotUpdaterImpl>()
132
+ setField(impl, "bundleStorage", storage)
133
+
134
+ assertEquals("test-install-id", impl.getInstallId())
135
+
136
+ impl.setUser("user-123", "alice")
137
+ assertEquals("user-123", storage.lastUserId)
138
+ assertEquals("alice", storage.lastUsername)
139
+
140
+ impl.setUser(null, null)
141
+ assertNull(storage.lastUserId)
142
+ assertNull(storage.lastUsername)
143
+ }
144
+
110
145
  private fun createImpl(
111
146
  storageBundleId: String?,
112
147
  storageManifest: Map<String, Any?> = emptyMap(),
@@ -162,6 +197,10 @@ class HotUpdaterImplTest {
162
197
  emptyMap(),
163
198
  private val launchedBundleBaseURLs: Map<String, String> = emptyMap(),
164
199
  ) : BundleStorageService {
200
+ var lastCompletedBundleId: String? = null
201
+ var lastUserId: String? = null
202
+ var lastUsername: String? = null
203
+
165
204
  override fun setBundleURL(localPath: String?): Boolean = true
166
205
 
167
206
  override fun getCachedBundleURL(): String? = null
@@ -185,9 +224,25 @@ class HotUpdaterImplTest {
185
224
  progressCallback: (UpdateProgressPayload) -> Unit,
186
225
  ) = Unit
187
226
 
188
- override fun markLaunchCompleted(currentBundleId: String?) = Unit
227
+ override fun markLaunchCompleted(currentBundleId: String?) {
228
+ lastCompletedBundleId = currentBundleId
229
+ }
230
+
231
+ override fun notifyAppReady(): Map<String, Any?> = mapOf("status" to "UNCHANGED")
232
+
233
+ override fun getInstallId(): String = "test-install-id"
189
234
 
190
- override fun notifyAppReady(): Map<String, Any?> = mapOf("status" to "STABLE")
235
+ override fun getUserId(): String? = lastUserId
236
+
237
+ override fun getUsername(): String? = lastUsername
238
+
239
+ override fun setUser(
240
+ userId: String?,
241
+ username: String?,
242
+ ) {
243
+ lastUserId = userId
244
+ lastUsername = username
245
+ }
191
246
 
192
247
  override fun getCrashHistory(): CrashedHistory = CrashedHistory()
193
248
 
@@ -0,0 +1,118 @@
1
+ package com.hotupdater
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertNull
6
+ import org.junit.Assert.assertTrue
7
+ import org.junit.Rule
8
+ import org.junit.Test
9
+ import org.junit.rules.TemporaryFolder
10
+ import java.io.File
11
+ import java.nio.file.Files
12
+ import java.nio.file.StandardCopyOption
13
+
14
+ class ReleaseCatalogCacheServiceTest {
15
+ @get:Rule
16
+ val temporaryFolder = TemporaryFolder()
17
+
18
+ @Test
19
+ fun `persists checksum verified entries without credential filenames`() {
20
+ val root = temporaryFolder.newFolder("catalog-cache")
21
+ val first = createService(root)
22
+ val partition = "https://updates.example.com|x-api-key=raw-secret"
23
+
24
+ assertTrue(first.set(partition, "validated-catalog"))
25
+ assertEquals("validated-catalog", createService(root).get(partition))
26
+
27
+ val entry = root.listFiles()!!.single()
28
+ assertTrue(entry.name.matches(Regex("[0-9a-f]{64}\\.catalog")))
29
+ assertFalse(entry.name.contains("raw-secret"))
30
+ assertFalse(entry.readText().contains("raw-secret"))
31
+
32
+ entry.appendText("corrupt")
33
+ assertNull(createService(root).get(partition))
34
+ assertFalse(entry.exists())
35
+ }
36
+
37
+ @Test
38
+ fun `failed atomic replacement preserves the previous entry`() {
39
+ val root = temporaryFolder.newFolder("interrupted-write")
40
+ val partition = "partition"
41
+ assertTrue(createService(root).set(partition, "previous"))
42
+
43
+ val interrupted =
44
+ ReleaseCatalogCacheService(
45
+ rootDirectory = root,
46
+ atomicReader = ::readForTest,
47
+ atomicWriter = { _, _ -> false },
48
+ atomicRemover = ::removeForTest,
49
+ )
50
+ assertFalse(interrupted.set(partition, "replacement"))
51
+ assertEquals("previous", createService(root).get(partition))
52
+ }
53
+
54
+ @Test
55
+ fun `evicts least recently used entries and rejects oversized payloads`() {
56
+ val root = temporaryFolder.newFolder("bounded-cache")
57
+ val service =
58
+ ReleaseCatalogCacheService(
59
+ rootDirectory = root,
60
+ maxEntries = 2,
61
+ maxEntryBytes = 16,
62
+ maxTotalBytes = 200,
63
+ atomicReader = ::readForTest,
64
+ atomicWriter = ::writeForTest,
65
+ atomicRemover = ::removeForTest,
66
+ )
67
+
68
+ assertTrue(service.set("one", "first"))
69
+ Thread.sleep(5)
70
+ assertTrue(service.set("two", "second"))
71
+ Thread.sleep(5)
72
+ assertTrue(service.set("three", "third"))
73
+
74
+ assertNull(service.get("one"))
75
+ assertEquals("second", service.get("two"))
76
+ assertEquals("third", service.get("three"))
77
+ assertFalse(service.set("two", "x".repeat(17)))
78
+ assertEquals("second", service.get("two"))
79
+ }
80
+
81
+ private fun createService(root: File): ReleaseCatalogCacheService =
82
+ ReleaseCatalogCacheService(
83
+ rootDirectory = root,
84
+ atomicReader = ::readForTest,
85
+ atomicWriter = ::writeForTest,
86
+ atomicRemover = ::removeForTest,
87
+ )
88
+
89
+ private fun readForTest(file: File): ByteArray? = if (file.isFile) file.readBytes() else null
90
+
91
+ private fun removeForTest(file: File): Boolean = !file.exists() || file.delete()
92
+
93
+ private fun writeForTest(
94
+ destination: File,
95
+ bytes: ByteArray,
96
+ ): Boolean =
97
+ try {
98
+ val temporary = File.createTempFile("entry", ".tmp", destination.parentFile)
99
+ temporary.writeBytes(bytes)
100
+ try {
101
+ Files.move(
102
+ temporary.toPath(),
103
+ destination.toPath(),
104
+ StandardCopyOption.ATOMIC_MOVE,
105
+ StandardCopyOption.REPLACE_EXISTING,
106
+ )
107
+ } catch (_: Exception) {
108
+ Files.move(
109
+ temporary.toPath(),
110
+ destination.toPath(),
111
+ StandardCopyOption.REPLACE_EXISTING,
112
+ )
113
+ }
114
+ true
115
+ } catch (_: Exception) {
116
+ false
117
+ }
118
+ }