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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/android/proguard-rules.pro +2 -2
  2. package/android/src/main/java/com/hotupdater/BuiltInAssetResolver.kt +286 -0
  3. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +650 -636
  4. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +3 -0
  5. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +0 -13
  6. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +10 -14
  7. package/android/src/main/java/com/hotupdater/OkHttpDownloadService.kt +19 -0
  8. package/android/src/main/java/com/hotupdater/TarArchiveInputStream.kt +214 -375
  9. package/android/src/main/java/com/hotupdater/TarBrArchiveExtractor.kt +119 -0
  10. package/android/src/newarch/HotUpdater.kt +7 -9
  11. package/android/src/newarch/HotUpdaterModule.kt +40 -20
  12. package/android/src/oldarch/HotUpdater.kt +7 -9
  13. package/android/src/oldarch/HotUpdaterModule.kt +40 -20
  14. package/android/src/test/java/com/hotupdater/BundleFileStorageServiceTest.kt +1126 -74
  15. package/android/src/test/java/com/hotupdater/HotUpdaterImplTest.kt +4 -5
  16. package/android/src/test/java/com/hotupdater/TarBrArchiveExtractorTest.kt +206 -0
  17. package/ios/HotUpdater/Internal/BrotliFileDecompressor.swift +167 -0
  18. package/ios/HotUpdater/Internal/BuiltInAssetResolver.swift +196 -0
  19. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +773 -1016
  20. package/ios/HotUpdater/Internal/BundleMetadata.swift +5 -0
  21. package/ios/HotUpdater/Internal/FileUtilities.swift +64 -0
  22. package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +0 -1
  23. package/ios/HotUpdater/Internal/HotUpdater.mm +5 -27
  24. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +47 -31
  25. package/ios/HotUpdater/Internal/NotificationExtension.swift +0 -2
  26. package/ios/HotUpdater/Internal/TarArchiveExtractor.swift +182 -311
  27. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +13 -72
  28. package/lib/commonjs/checkForUpdate.js +7 -10
  29. package/lib/commonjs/checkForUpdate.js.map +1 -1
  30. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js +9 -4
  31. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js.map +1 -1
  32. package/lib/commonjs/error.js +4 -13
  33. package/lib/commonjs/error.js.map +1 -1
  34. package/lib/commonjs/fetchJSON.js +9 -2
  35. package/lib/commonjs/fetchJSON.js.map +1 -1
  36. package/lib/commonjs/httpClient.js +151 -39
  37. package/lib/commonjs/httpClient.js.map +1 -1
  38. package/lib/commonjs/httpClient.spec.js +247 -5
  39. package/lib/commonjs/httpClient.spec.js.map +1 -1
  40. package/lib/commonjs/index.js +15 -8
  41. package/lib/commonjs/index.js.map +1 -1
  42. package/lib/commonjs/index.spec.js +101 -0
  43. package/lib/commonjs/index.spec.js.map +1 -1
  44. package/lib/commonjs/native.js +7 -8
  45. package/lib/commonjs/native.js.map +1 -1
  46. package/lib/commonjs/native.spec.js +21 -16
  47. package/lib/commonjs/native.spec.js.map +1 -1
  48. package/lib/commonjs/notifyAppReadyInsights.boundary.spec.js +7 -0
  49. package/lib/commonjs/notifyAppReadyInsights.boundary.spec.js.map +1 -1
  50. package/lib/commonjs/notifyAppReadyInsights.js +4 -1
  51. package/lib/commonjs/notifyAppReadyInsights.js.map +1 -1
  52. package/lib/commonjs/notifyAppReadyInsights.spec.js +59 -0
  53. package/lib/commonjs/notifyAppReadyInsights.spec.js.map +1 -1
  54. package/lib/commonjs/notifyAppReadyInsights.transport.spec.js +55 -1
  55. package/lib/commonjs/notifyAppReadyInsights.transport.spec.js.map +1 -1
  56. package/lib/commonjs/sdkVersion.js +1 -1
  57. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  58. package/lib/commonjs/store.js +3 -10
  59. package/lib/commonjs/store.js.map +1 -1
  60. package/lib/commonjs/store.notification.spec.js +12 -4
  61. package/lib/commonjs/store.notification.spec.js.map +1 -1
  62. package/lib/commonjs/store.spec.js +2 -136
  63. package/lib/commonjs/store.spec.js.map +1 -1
  64. package/lib/commonjs/uuidv7.js +41 -0
  65. package/lib/commonjs/uuidv7.js.map +1 -0
  66. package/lib/commonjs/uuidv7.spec.js +30 -0
  67. package/lib/commonjs/uuidv7.spec.js.map +1 -0
  68. package/lib/commonjs/wrap.js +1 -3
  69. package/lib/commonjs/wrap.js.map +1 -1
  70. package/lib/commonjs/wrap.spec.js +7 -0
  71. package/lib/commonjs/wrap.spec.js.map +1 -1
  72. package/lib/module/checkForUpdate.js +7 -10
  73. package/lib/module/checkForUpdate.js.map +1 -1
  74. package/lib/module/checkForUpdate.releaseCatalog.spec.js +9 -4
  75. package/lib/module/checkForUpdate.releaseCatalog.spec.js.map +1 -1
  76. package/lib/module/error.js +4 -13
  77. package/lib/module/error.js.map +1 -1
  78. package/lib/module/fetchJSON.js +7 -1
  79. package/lib/module/fetchJSON.js.map +1 -1
  80. package/lib/module/httpClient.js +153 -41
  81. package/lib/module/httpClient.js.map +1 -1
  82. package/lib/module/httpClient.spec.js +248 -6
  83. package/lib/module/httpClient.spec.js.map +1 -1
  84. package/lib/module/index.js +15 -8
  85. package/lib/module/index.js.map +1 -1
  86. package/lib/module/index.spec.js +102 -1
  87. package/lib/module/index.spec.js.map +1 -1
  88. package/lib/module/native.js +7 -8
  89. package/lib/module/native.js.map +1 -1
  90. package/lib/module/native.spec.js +21 -16
  91. package/lib/module/native.spec.js.map +1 -1
  92. package/lib/module/notifyAppReadyInsights.boundary.spec.js +7 -0
  93. package/lib/module/notifyAppReadyInsights.boundary.spec.js.map +1 -1
  94. package/lib/module/notifyAppReadyInsights.js +5 -2
  95. package/lib/module/notifyAppReadyInsights.js.map +1 -1
  96. package/lib/module/notifyAppReadyInsights.spec.js +59 -0
  97. package/lib/module/notifyAppReadyInsights.spec.js.map +1 -1
  98. package/lib/module/notifyAppReadyInsights.transport.spec.js +55 -1
  99. package/lib/module/notifyAppReadyInsights.transport.spec.js.map +1 -1
  100. package/lib/module/sdkVersion.js +1 -1
  101. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  102. package/lib/module/store.js +3 -10
  103. package/lib/module/store.js.map +1 -1
  104. package/lib/module/store.notification.spec.js +12 -4
  105. package/lib/module/store.notification.spec.js.map +1 -1
  106. package/lib/module/store.spec.js +2 -136
  107. package/lib/module/store.spec.js.map +1 -1
  108. package/lib/module/uuidv7.js +36 -0
  109. package/lib/module/uuidv7.js.map +1 -0
  110. package/lib/module/uuidv7.spec.js +30 -0
  111. package/lib/module/uuidv7.spec.js.map +1 -0
  112. package/lib/module/wrap.js +1 -3
  113. package/lib/module/wrap.js.map +1 -1
  114. package/lib/module/wrap.spec.js +7 -0
  115. package/lib/module/wrap.spec.js.map +1 -1
  116. package/lib/typescript/commonjs/checkForUpdate.d.ts +1 -2
  117. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  118. package/lib/typescript/commonjs/error.d.ts +4 -13
  119. package/lib/typescript/commonjs/error.d.ts.map +1 -1
  120. package/lib/typescript/commonjs/fetchJSON.d.ts +4 -0
  121. package/lib/typescript/commonjs/fetchJSON.d.ts.map +1 -1
  122. package/lib/typescript/commonjs/httpClient.d.ts +6 -0
  123. package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
  124. package/lib/typescript/commonjs/index.d.ts +0 -2
  125. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  126. package/lib/typescript/commonjs/native.d.ts +1 -6
  127. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  128. package/lib/typescript/commonjs/notifyAppReadyInsights.d.ts +1 -1
  129. package/lib/typescript/commonjs/notifyAppReadyInsights.d.ts.map +1 -1
  130. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  131. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +8 -35
  132. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  133. package/lib/typescript/commonjs/store.d.ts +1 -3
  134. package/lib/typescript/commonjs/store.d.ts.map +1 -1
  135. package/lib/typescript/commonjs/uuidv7.d.ts +10 -0
  136. package/lib/typescript/commonjs/uuidv7.d.ts.map +1 -0
  137. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  138. package/lib/typescript/commonjs/wrap.types.d.ts +7 -4
  139. package/lib/typescript/commonjs/wrap.types.d.ts.map +1 -1
  140. package/lib/typescript/module/checkForUpdate.d.ts +1 -2
  141. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  142. package/lib/typescript/module/error.d.ts +4 -13
  143. package/lib/typescript/module/error.d.ts.map +1 -1
  144. package/lib/typescript/module/fetchJSON.d.ts +4 -0
  145. package/lib/typescript/module/fetchJSON.d.ts.map +1 -1
  146. package/lib/typescript/module/httpClient.d.ts +6 -0
  147. package/lib/typescript/module/httpClient.d.ts.map +1 -1
  148. package/lib/typescript/module/index.d.ts +0 -2
  149. package/lib/typescript/module/index.d.ts.map +1 -1
  150. package/lib/typescript/module/native.d.ts +1 -6
  151. package/lib/typescript/module/native.d.ts.map +1 -1
  152. package/lib/typescript/module/notifyAppReadyInsights.d.ts +1 -1
  153. package/lib/typescript/module/notifyAppReadyInsights.d.ts.map +1 -1
  154. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  155. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +8 -35
  156. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  157. package/lib/typescript/module/store.d.ts +1 -3
  158. package/lib/typescript/module/store.d.ts.map +1 -1
  159. package/lib/typescript/module/uuidv7.d.ts +10 -0
  160. package/lib/typescript/module/uuidv7.d.ts.map +1 -0
  161. package/lib/typescript/module/wrap.d.ts.map +1 -1
  162. package/lib/typescript/module/wrap.types.d.ts +7 -4
  163. package/lib/typescript/module/wrap.types.d.ts.map +1 -1
  164. package/package.json +3 -3
  165. package/src/checkForUpdate.releaseCatalog.spec.ts +10 -4
  166. package/src/checkForUpdate.ts +5 -11
  167. package/src/error.ts +4 -14
  168. package/src/fetchJSON.ts +12 -1
  169. package/src/httpClient.spec.ts +289 -8
  170. package/src/httpClient.ts +224 -67
  171. package/src/index.spec.ts +70 -1
  172. package/src/index.ts +18 -7
  173. package/src/native.spec.ts +23 -16
  174. package/src/native.ts +12 -24
  175. package/src/notifyAppReadyInsights.boundary.spec.ts +8 -0
  176. package/src/notifyAppReadyInsights.spec.ts +60 -0
  177. package/src/notifyAppReadyInsights.transport.spec.ts +66 -0
  178. package/src/notifyAppReadyInsights.ts +13 -1
  179. package/src/sdkVersion.ts +1 -1
  180. package/src/specs/NativeHotUpdater.ts +8 -36
  181. package/src/store.notification.spec.ts +13 -3
  182. package/src/store.spec.ts +0 -157
  183. package/src/store.ts +2 -23
  184. package/src/uuidv7.spec.ts +25 -0
  185. package/src/uuidv7.ts +38 -0
  186. package/src/wrap.spec.ts +8 -0
  187. package/src/wrap.tsx +0 -2
  188. package/src/wrap.types.ts +9 -4
  189. package/android/src/main/java/com/hotupdater/DecompressService.kt +0 -89
  190. package/android/src/main/java/com/hotupdater/DecompressionStrategy.kt +0 -26
  191. package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +0 -117
  192. package/android/src/main/java/com/hotupdater/TarGzDecompressionStrategy.kt +0 -117
  193. package/android/src/main/java/com/hotupdater/ZipDecompressionStrategy.kt +0 -192
  194. package/android/src/test/java/com/hotupdater/TarArchiveInputStreamTest.kt +0 -68
  195. package/ios/HotUpdater/Internal/ArchiveExtractionUtilities.swift +0 -178
  196. package/ios/HotUpdater/Internal/DecompressService.swift +0 -112
  197. package/ios/HotUpdater/Internal/DecompressionStrategy.swift +0 -22
  198. package/ios/HotUpdater/Internal/StreamingTarArchiveExtractor.swift +0 -370
  199. package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +0 -39
  200. package/ios/HotUpdater/Internal/TarGzDecompressionStrategy.swift +0 -59
  201. package/ios/HotUpdater/Internal/ZipArchiveExtractor.swift +0 -482
  202. package/ios/HotUpdater/Internal/ZipDecompressionStrategy.swift +0 -56
@@ -2,6 +2,7 @@ package com.hotupdater
2
2
 
3
3
  import android.content.ContextWrapper
4
4
  import kotlinx.coroutines.asCoroutineDispatcher
5
+ import kotlinx.coroutines.delay
5
6
  import kotlinx.coroutines.runBlocking
6
7
  import org.json.JSONObject
7
8
  import org.junit.Assert.assertEquals
@@ -14,15 +15,80 @@ import org.junit.Test
14
15
  import org.junit.rules.TemporaryFolder
15
16
  import java.io.File
16
17
  import java.net.URL
18
+ import java.util.Base64
17
19
  import java.util.concurrent.CopyOnWriteArrayList
18
20
  import java.util.concurrent.Executors
19
- import java.util.zip.ZipEntry
20
- import java.util.zip.ZipOutputStream
21
+ import java.util.concurrent.atomic.AtomicInteger
21
22
 
22
23
  class BundleFileStorageServiceTest {
23
24
  @get:Rule
24
25
  val temporaryFolder = TemporaryFolder()
25
26
 
27
+ @Test
28
+ fun `unfinished staging launch rolls back to stable on cold start`() {
29
+ assertStagingLaunchAcrossColdStart(hasStableBundle = true)
30
+ }
31
+
32
+ @Test
33
+ fun `unfinished staging launch rolls back to built in on cold start`() {
34
+ assertStagingLaunchAcrossColdStart(hasStableBundle = false)
35
+ }
36
+
37
+ @Test
38
+ fun `completed staging launch remains trusted on cold start`() {
39
+ assertStagingLaunchAcrossColdStart(hasStableBundle = true, completesLaunch = true)
40
+ }
41
+
42
+ @Test
43
+ fun `completed first OTA launch remains trusted on cold start`() {
44
+ assertStagingLaunchAcrossColdStart(hasStableBundle = false, completesLaunch = true)
45
+ }
46
+
47
+ private fun assertStagingLaunchAcrossColdStart(
48
+ hasStableBundle: Boolean,
49
+ completesLaunch: Boolean = false,
50
+ ) {
51
+ val rootDir = temporaryFolder.newFolder()
52
+ val preferences = InMemoryPreferencesService()
53
+ val stableBundleId = if (hasStableBundle) "stable-bundle" else null
54
+ listOfNotNull(stableBundleId, "hung-bundle").forEach { bundleId ->
55
+ val directory = createBundleDir(rootDir, bundleId)
56
+ writeFile(directory, "index.android.bundle")
57
+ writeManifest(directory, listOf("index.android.bundle"))
58
+ }
59
+ writeMetadata(
60
+ rootDir,
61
+ BundleMetadata(
62
+ isolationKey = TEST_ISOLATION_KEY,
63
+ stableBundleId = stableBundleId,
64
+ stagingBundleId = "hung-bundle",
65
+ verificationPending = true,
66
+ ),
67
+ )
68
+ val firstProcess = createService(rootDir, preferences)
69
+ val firstLaunch = firstProcess.prepareLaunch(null)
70
+ assertEquals("hung-bundle", firstLaunch.launchedBundleId)
71
+ assertTrue(firstLaunch.shouldRollbackOnCrash)
72
+
73
+ assertEquals("hung-bundle", firstProcess.prepareLaunch(null).launchedBundleId)
74
+ assertFalse(firstProcess.getCrashHistory().contains("hung-bundle"))
75
+ if (completesLaunch) {
76
+ firstProcess.markLaunchCompleted("hung-bundle")
77
+ }
78
+
79
+ // Issue #1321: simulate process termination without a crash marker.
80
+ val secondProcess = createService(rootDir, preferences)
81
+ val nextLaunch = secondProcess.prepareLaunch(null)
82
+ assertEquals(if (completesLaunch) "hung-bundle" else stableBundleId, nextLaunch.launchedBundleId)
83
+ assertFalse(nextLaunch.shouldRollbackOnCrash)
84
+ assertEquals(!completesLaunch, secondProcess.getCrashHistory().contains("hung-bundle"))
85
+ if (!completesLaunch) {
86
+ assertEquals("RECOVERED", secondProcess.notifyAppReady()["status"])
87
+ }
88
+ val thirdProcess = createService(rootDir, preferences)
89
+ assertEquals(nextLaunch.launchedBundleId, thirdProcess.prepareLaunch(null).launchedBundleId)
90
+ }
91
+
26
92
  @Test
27
93
  fun `resolveBundleFile uses single manifest bundle at root`() {
28
94
  val rootDir = temporaryFolder.newFolder("root-manifest-bundle")
@@ -97,13 +163,13 @@ class BundleFileStorageServiceTest {
97
163
  }
98
164
 
99
165
  @Test
100
- fun `resolveBundleFile allows legacy root index without manifest`() {
166
+ fun `resolveBundleFile rejects root index without manifest`() {
101
167
  val rootDir = temporaryFolder.newFolder("legacy-root-index")
102
168
  val service = createService(rootDir)
103
169
  val bundleDir = createBundleDir(rootDir, "bundle-legacy")
104
- val fallbackBundleFile = writeFile(bundleDir, "index.android.bundle")
170
+ writeFile(bundleDir, "index.android.bundle")
105
171
 
106
- assertResolvedBundlePath(service, bundleDir, fallbackBundleFile)
172
+ assertNull(invokeResolveBundleFile(service, bundleDir))
107
173
  }
108
174
 
109
175
  @Test
@@ -340,40 +406,118 @@ class BundleFileStorageServiceTest {
340
406
  }
341
407
 
342
408
  @Test
343
- fun `manifest driven install is disabled before first OTA`() {
344
- val rootDir = temporaryFolder.newFolder("first-ota-manifest-disabled")
345
- val service = createService(rootDir)
346
-
347
- assertFalse(invokeCanUseManifestDrivenInstall(service))
348
- }
349
-
350
- @Test
351
- fun `manifest driven install is enabled for active OTA bundle with manifest`() {
352
- val rootDir = temporaryFolder.newFolder("active-ota-manifest-enabled")
353
- val preferences = InMemoryPreferencesService()
354
- val service = createService(rootDir, preferences)
355
- val activeDir = createBundleDir(rootDir, "active-bundle")
356
- val activeBundleFile = writeFile(activeDir, "index.android.bundle")
357
- writeManifest(activeDir, listOf("index.android.bundle"))
409
+ fun `manifest driven install reuses matching built in asset before first OTA`() =
410
+ runBlocking {
411
+ val rootDir = temporaryFolder.newFolder("first-ota-manifest-install")
412
+ val bundleContent = "target-bundle"
413
+ val imageContent = "target-image"
414
+ val assets =
415
+ linkedMapOf(
416
+ "index.android.bundle" to sha256(rootDir, bundleContent),
417
+ "assets/image.png" to sha256(rootDir, imageContent),
418
+ )
419
+ val manifest = manifestJson("target-bundle", assets)
420
+ val downloads =
421
+ MappingDownloadService(
422
+ mapOf(
423
+ "https://example.com/manifest.json" to manifest,
424
+ "https://example.com/index.android.bundle" to bundleContent,
425
+ ),
426
+ )
427
+ val service =
428
+ createService(
429
+ rootDir,
430
+ downloadService = downloads,
431
+ builtInAssetResolver =
432
+ MappingBuiltInAssetResolver(
433
+ mapOf("assets/image.png" to imageContent),
434
+ ),
435
+ )
358
436
 
359
- preferences.setItem("HotUpdaterBundleURL", activeBundleFile.absolutePath)
437
+ val progress = CopyOnWriteArrayList<UpdateProgressPayload>()
438
+ service.updateBundle(
439
+ bundleId = "target-bundle",
440
+ manifestUrl = "https://example.com/manifest.json",
441
+ manifestFileHash = sha256(rootDir, manifest),
442
+ assets =
443
+ assets.mapValues { (path, hash) ->
444
+ ChangedAssetDescriptor(
445
+ fileUrl = "https://example.com/$path",
446
+ fileHash = hash,
447
+ )
448
+ },
449
+ progressCallback = { progress.add(it) },
450
+ )
360
451
 
361
- assertTrue(invokeCanUseManifestDrivenInstall(service))
362
- }
452
+ assertEquals(1, progress.last().details?.totalFilesCount)
453
+ assertEquals(
454
+ listOf("index.android.bundle"),
455
+ progress
456
+ .last()
457
+ .details
458
+ ?.files
459
+ ?.map { it.path },
460
+ )
461
+ assertTrue(progress.any { it.progress in 0.15..0.2 && it.details?.totalFilesCount == 0 })
462
+ val targetDir = File(bundleStoreDir(rootDir), "target-bundle")
463
+ assertEquals(bundleContent, File(targetDir, "index.android.bundle").readText())
464
+ assertEquals(imageContent, File(targetDir, "assets/image.png").readText())
465
+ assertEquals("target-bundle", loadMetadata(rootDir)?.stagingBundleId)
466
+ assertEquals(
467
+ listOf(
468
+ "https://example.com/manifest.json",
469
+ "https://example.com/index.android.bundle",
470
+ ),
471
+ downloads.calls,
472
+ )
473
+ }
363
474
 
364
475
  @Test
365
- fun `manifest driven install rejects unsafe asset paths`() {
366
- val rootDir = temporaryFolder.newFolder("active-ota-unsafe-manifest")
367
- val preferences = InMemoryPreferencesService()
368
- val service = createService(rootDir, preferences)
369
- val activeDir = createBundleDir(rootDir, "active-bundle")
370
- val activeBundleFile = writeFile(activeDir, "index.android.bundle")
371
- writeManifest(activeDir, listOf("../active-bundle_evil/index.android.bundle"))
476
+ fun `manifest driven install downloads original when matching current asset is corrupt`() =
477
+ runBlocking {
478
+ val rootDir = temporaryFolder.newFolder("corrupt-current-asset")
479
+ val preferences = InMemoryPreferencesService()
480
+ val targetContent = "verified-target-bundle"
481
+ val targetHash = sha256(rootDir, targetContent)
482
+ val activeDir = createBundleDir(rootDir, "active-bundle")
483
+ val activeBundleFile = writeFile(activeDir, "index.android.bundle", "corrupt")
484
+ File(activeDir, "manifest.json").writeText(
485
+ manifestJson("active-bundle", mapOf("index.android.bundle" to targetHash)),
486
+ )
487
+ preferences.setItem("HotUpdaterBundleURL", activeBundleFile.absolutePath)
372
488
 
373
- preferences.setItem("HotUpdaterBundleURL", activeBundleFile.absolutePath)
489
+ val targetManifest =
490
+ manifestJson("target-bundle", mapOf("index.android.bundle" to targetHash))
491
+ val downloads =
492
+ MappingDownloadService(
493
+ mapOf(
494
+ "https://example.com/manifest.json" to targetManifest,
495
+ "https://example.com/index.android.bundle" to targetContent,
496
+ ),
497
+ )
498
+ val service = createService(rootDir, preferences, downloads)
374
499
 
375
- assertFalse(invokeCanUseManifestDrivenInstall(service))
376
- }
500
+ service.updateBundle(
501
+ bundleId = "target-bundle",
502
+ manifestUrl = "https://example.com/manifest.json",
503
+ manifestFileHash = sha256(rootDir, targetManifest),
504
+ assets =
505
+ mapOf(
506
+ "index.android.bundle" to
507
+ ChangedAssetDescriptor(
508
+ fileUrl = "https://example.com/index.android.bundle",
509
+ fileHash = targetHash,
510
+ ),
511
+ ),
512
+ progressCallback = {},
513
+ )
514
+
515
+ assertEquals(
516
+ targetContent,
517
+ File(bundleStoreDir(rootDir), "target-bundle/index.android.bundle").readText(),
518
+ )
519
+ assertTrue(downloads.calls.contains("https://example.com/index.android.bundle"))
520
+ }
377
521
 
378
522
  @Test
379
523
  fun `catalog high water rejects replay and survives channel reset`() =
@@ -554,11 +698,9 @@ class BundleFileStorageServiceTest {
554
698
  runCatching {
555
699
  service.updateBundle(
556
700
  bundleId = "target-bundle",
557
- fileUrl = "https://example.com/bundle.zip",
558
- fileHash = null,
559
701
  manifestUrl = "https://example.com/manifest.json",
560
702
  manifestFileHash = "manifest-hash",
561
- changedAssets = emptyMap(),
703
+ assets = emptyMap(),
562
704
  progressCallback = {},
563
705
  )
564
706
  }
@@ -575,39 +717,800 @@ class BundleFileStorageServiceTest {
575
717
  }
576
718
 
577
719
  @Test
578
- fun `zip decompression does not write sibling prefix traversal entries`() {
579
- val rootDir = temporaryFolder.newFolder("zip-sibling-prefix")
580
- val zipFile = File(rootDir, "bundle.zip")
581
- ZipOutputStream(zipFile.outputStream()).use { zip ->
582
- writeZipEntry(zip, "../bundle-temp_evil/escape.txt", "blocked")
583
- writeZipEntry(zip, "safe/kept.txt", "kept")
720
+ fun `reused bytes cannot bypass mismatched descriptor contracts`() =
721
+ runBlocking {
722
+ for (kind in listOf("missing", "mismatch", "extra", "missing-original")) {
723
+ val root = temporaryFolder.newFolder(kind)
724
+ val hash = sha256(root, "target")
725
+ val manifest = manifestJson("target", mapOf("index.android.bundle" to hash))
726
+ val downloads = MappingDownloadService(mapOf("https://example.com/manifest.json" to manifest))
727
+ val service =
728
+ createService(
729
+ root,
730
+ downloadService = downloads,
731
+ builtInAssetResolver = MappingBuiltInAssetResolver(mapOf("index.android.bundle" to "target")),
732
+ )
733
+ val descriptors = mutableMapOf("index.android.bundle" to ChangedAssetDescriptor("https://example.com/bundle", hash))
734
+ when (kind) {
735
+ "missing" -> {
736
+ descriptors.clear()
737
+ }
738
+
739
+ "mismatch" -> {
740
+ descriptors["index.android.bundle"] = ChangedAssetDescriptor("https://example.com/bundle", "wrong")
741
+ }
742
+
743
+ "extra" -> {
744
+ descriptors["extra.png"] = ChangedAssetDescriptor("https://example.com/extra", hash)
745
+ }
746
+
747
+ "missing-original" -> {
748
+ descriptors["index.android.bundle"] = ChangedAssetDescriptor(null, hash)
749
+ }
750
+ }
751
+ val result =
752
+ runCatching {
753
+ service.updateBundle(
754
+ "target",
755
+ "https://example.com/manifest.json",
756
+ sha256(root, manifest),
757
+ descriptors,
758
+ ) {}
759
+ }
760
+ assertTrue("Contract $kind must fail closed", result.isFailure)
761
+ assertNull(loadMetadata(root)?.stagingBundleId)
762
+ }
584
763
  }
585
764
 
586
- val destinationDir = File(rootDir, "bundle-temp")
587
- val extracted =
588
- ZipDecompressionStrategy().decompress(
589
- zipFile.absolutePath,
590
- destinationDir.absolutePath,
765
+ @Test
766
+ fun `cached target is repaired before activation`() =
767
+ runBlocking {
768
+ val root = temporaryFolder.newFolder("corrupt-cached-target")
769
+ val hash = sha256(root, "correct")
770
+ val manifest = manifestJson("target", mapOf("index.android.bundle" to hash))
771
+ val cached = createBundleDir(root, "target")
772
+ writeFile(cached, "manifest.json", manifest)
773
+ writeFile(cached, "index.android.bundle", "CORRUPT")
774
+ val downloads =
775
+ MappingDownloadService(
776
+ mapOf(
777
+ "https://example.com/manifest.json" to manifest,
778
+ "https://example.com/bundle" to "correct",
779
+ ),
780
+ )
781
+ val service = createService(root, downloadService = downloads, builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()))
782
+ service.updateBundle(
783
+ "target",
784
+ "https://example.com/manifest.json",
785
+ sha256(root, manifest),
786
+ mapOf("index.android.bundle" to ChangedAssetDescriptor("https://example.com/bundle", hash)),
591
787
  ) {}
788
+ assertEquals("correct", File(cached, "index.android.bundle").readText())
789
+ assertEquals("target", loadMetadata(root)?.stagingBundleId)
790
+ assertTrue(downloads.calls.contains("https://example.com/bundle"))
791
+ }
792
+
793
+ @Test
794
+ fun `failed final rename restores existing bundle`() =
795
+ runBlocking {
796
+ val root = temporaryFolder.newFolder("failed-final-rename")
797
+ val preferences = InMemoryPreferencesService()
798
+ val target = createCompleteBundle(root, "target", "old")
799
+ val oldBundleFile = File(target, "index.android.bundle")
800
+ preferences.setItem("HotUpdaterBundleURL", oldBundleFile.absolutePath)
801
+ writeMetadata(
802
+ root,
803
+ BundleMetadata(
804
+ isolationKey = TEST_ISOLATION_KEY,
805
+ stagingBundleId = "target",
806
+ ),
807
+ )
808
+ val manifest = manifestJson("target", mapOf("index.android.bundle" to sha256(root, "new")))
809
+ val downloads =
810
+ MappingDownloadService(
811
+ mapOf(
812
+ "https://example.com/manifest.json" to manifest,
813
+ "https://example.com/bundle" to "new",
814
+ ),
815
+ )
816
+ val service =
817
+ createService(
818
+ root,
819
+ preferences = preferences,
820
+ downloadService = downloads,
821
+ builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()),
822
+ directoryRenamer = { source, destination ->
823
+ if (source.name == "target.tmp" && destination.name == "target") {
824
+ false
825
+ } else {
826
+ source.renameTo(destination)
827
+ }
828
+ },
829
+ )
830
+
831
+ val result =
832
+ runCatching {
833
+ service.updateBundle(
834
+ "target",
835
+ "https://example.com/manifest.json",
836
+ sha256(root, manifest),
837
+ mapOf(
838
+ "index.android.bundle" to
839
+ ChangedAssetDescriptor("https://example.com/bundle", sha256(root, "new")),
840
+ ),
841
+ ) {}
842
+ }
843
+
844
+ assertTrue(result.isFailure)
845
+ assertEquals("old", oldBundleFile.readText())
846
+ assertFalse(File(bundleStoreDir(root), "target.install-backup").exists())
847
+ assertEquals(oldBundleFile.absolutePath, preferences.getItem("HotUpdaterBundleURL"))
848
+ assertEquals("target", loadMetadata(root)?.stagingBundleId)
849
+ }
850
+
851
+ @Test
852
+ fun `metadata write failure restores preference and existing bundle`() =
853
+ runBlocking {
854
+ val root = temporaryFolder.newFolder("failed-metadata-write")
855
+ val preferences = InMemoryPreferencesService()
856
+ val target = createCompleteBundle(root, "target", "old")
857
+ val oldBundleFile = File(target, "index.android.bundle")
858
+ preferences.setItem("HotUpdaterBundleURL", oldBundleFile.absolutePath)
859
+ val previousMetadata =
860
+ BundleMetadata(
861
+ isolationKey = TEST_ISOLATION_KEY,
862
+ stagingBundleId = "target",
863
+ )
864
+ writeMetadata(root, previousMetadata)
865
+ val newHash = sha256(root, "new")
866
+ val manifest = manifestJson("target", mapOf("index.android.bundle" to newHash))
867
+ val service =
868
+ createService(
869
+ root,
870
+ preferences = preferences,
871
+ downloadService =
872
+ MappingDownloadService(
873
+ mapOf(
874
+ "https://example.com/manifest.json" to manifest,
875
+ "https://example.com/bundle" to "new",
876
+ ),
877
+ ),
878
+ builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()),
879
+ metadataWriter = { _, _ -> false },
880
+ )
592
881
 
593
- assertTrue(extracted)
594
- assertTrue(File(destinationDir, "safe/kept.txt").isFile)
595
- assertFalse(File(rootDir, "bundle-temp_evil/escape.txt").exists())
882
+ val result =
883
+ runCatching {
884
+ service.updateBundle(
885
+ "target",
886
+ "https://example.com/manifest.json",
887
+ sha256(root, manifest),
888
+ mapOf(
889
+ "index.android.bundle" to ChangedAssetDescriptor("https://example.com/bundle", newHash),
890
+ ),
891
+ ) {}
892
+ }
893
+
894
+ assertTrue(result.isFailure)
895
+ assertEquals("old", oldBundleFile.readText())
896
+ assertFalse(File(bundleStoreDir(root), "target.install-backup").exists())
897
+ assertEquals(oldBundleFile.absolutePath, preferences.getItem("HotUpdaterBundleURL"))
898
+ assertEquals(previousMetadata.stagingBundleId, loadMetadata(root)?.stagingBundleId)
899
+ }
900
+
901
+ @Test
902
+ fun `preference write failure aborts before metadata commit and restores existing bundle`() =
903
+ runBlocking {
904
+ val root = temporaryFolder.newFolder("failed-preference-write")
905
+ val target = createCompleteBundle(root, "target", "old")
906
+ val oldBundleFile = File(target, "index.android.bundle")
907
+ var preferenceWrites = 0
908
+ val preferences =
909
+ InMemoryPreferencesService { key, _ ->
910
+ if (key == "HotUpdaterBundleURL") preferenceWrites++
911
+ preferenceWrites == 2
912
+ }
913
+ preferences.setItem("HotUpdaterBundleURL", oldBundleFile.absolutePath)
914
+ writeMetadata(
915
+ root,
916
+ BundleMetadata(
917
+ isolationKey = TEST_ISOLATION_KEY,
918
+ stagingBundleId = "target",
919
+ ),
920
+ )
921
+ val newHash = sha256(root, "new")
922
+ val manifest = manifestJson("target", mapOf("index.android.bundle" to newHash))
923
+ var metadataWrites = 0
924
+ val service =
925
+ createService(
926
+ root,
927
+ preferences = preferences,
928
+ downloadService =
929
+ MappingDownloadService(
930
+ mapOf(
931
+ "https://example.com/manifest.json" to manifest,
932
+ "https://example.com/bundle" to "new",
933
+ ),
934
+ ),
935
+ builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()),
936
+ metadataWriter = { metadata, file ->
937
+ metadataWrites++
938
+ metadata.saveToFile(file)
939
+ },
940
+ )
941
+
942
+ val result =
943
+ runCatching {
944
+ service.updateBundle(
945
+ "target",
946
+ "https://example.com/manifest.json",
947
+ sha256(root, manifest),
948
+ mapOf(
949
+ "index.android.bundle" to ChangedAssetDescriptor("https://example.com/bundle", newHash),
950
+ ),
951
+ ) {}
952
+ }
953
+
954
+ assertTrue(result.isFailure)
955
+ assertEquals(0, metadataWrites)
956
+ assertEquals("old", oldBundleFile.readText())
957
+ assertEquals(oldBundleFile.absolutePath, preferences.getItem("HotUpdaterBundleURL"))
958
+ }
959
+
960
+ @Test
961
+ fun `startup restores interrupted promotion backup when final is absent`() {
962
+ val root = temporaryFolder.newFolder("missing-final-recovery")
963
+ val backup = createCompleteBundle(root, "target.install-backup", "old", manifestBundleId = "target")
964
+
965
+ createService(root)
966
+
967
+ val restored = File(bundleStoreDir(root), "target")
968
+ assertEquals("old", File(restored, "index.android.bundle").readText())
969
+ assertFalse(backup.exists())
970
+ }
971
+
972
+ @Test
973
+ fun `startup keeps complete final when install backup also exists`() {
974
+ val root = temporaryFolder.newFolder("complete-final-recovery")
975
+ val final = createCompleteBundle(root, "target", "new")
976
+ val backup = createCompleteBundle(root, "target.install-backup", "old", manifestBundleId = "target")
977
+
978
+ createService(root)
979
+
980
+ assertEquals("new", File(final, "index.android.bundle").readText())
981
+ assertFalse(backup.exists())
596
982
  }
597
983
 
984
+ @Test
985
+ fun `retry reuses completed staging files and replaces partial files without stale assets`() =
986
+ runBlocking {
987
+ val root = temporaryFolder.newFolder("interrupted-retry")
988
+ val hashes = mapOf("index.android.bundle" to sha256(root, "correct"), "assets/image.png" to sha256(root, "image"))
989
+ val manifest = manifestJson("target", hashes)
990
+ val staging = createBundleDir(root, "target.tmp")
991
+ writeFile(staging, "index.android.bundle", "partial")
992
+ writeFile(staging, "assets/image.png", "image")
993
+ writeFile(staging, "assets/obsolete.png", "obsolete")
994
+ val downloads =
995
+ MappingDownloadService(
996
+ mapOf(
997
+ "https://example.com/manifest.json" to manifest,
998
+ "https://example.com/index.android.bundle" to "correct",
999
+ ),
1000
+ )
1001
+ val service = createService(root, downloadService = downloads, builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()))
1002
+ service.updateBundle(
1003
+ "target",
1004
+ "https://example.com/manifest.json",
1005
+ sha256(root, manifest),
1006
+ hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) },
1007
+ ) {}
1008
+ assertEquals(listOf("https://example.com/manifest.json", "https://example.com/index.android.bundle"), downloads.calls)
1009
+ val target = File(bundleStoreDir(root), "target")
1010
+ assertEquals("image", File(target, "assets/image.png").readText())
1011
+ assertEquals("correct", File(target, "index.android.bundle").readText())
1012
+ assertFalse(File(target, "assets/obsolete.png").exists())
1013
+ }
1014
+
1015
+ @Test
1016
+ fun `independent downloads overlap with at most four active requests`() =
1017
+ runBlocking {
1018
+ val root = temporaryFolder.newFolder("parallel-originals")
1019
+ val paths = listOf("index.android.bundle") + (0..8).map { "assets/$it.png" }
1020
+ val hash = sha256(root, "content")
1021
+ val manifest = manifestJson("target", paths.associateWith { hash })
1022
+ val mapping =
1023
+ MappingDownloadService(
1024
+ mapOf("https://example.com/manifest.json" to manifest) + paths.associate { "https://example.com/$it" to "content" },
1025
+ )
1026
+ val active = AtomicInteger()
1027
+ val maximum = AtomicInteger()
1028
+ val downloader =
1029
+ object : DownloadService {
1030
+ override suspend fun downloadFile(
1031
+ fileUrl: URL,
1032
+ destination: File,
1033
+ fileSizeCallback: ((Long) -> Unit)?,
1034
+ progressCallback: (DownloadProgress) -> Unit,
1035
+ ): DownloadResult {
1036
+ val isAsset = fileUrl.path != "/manifest.json"
1037
+ if (isAsset) {
1038
+ val count = active.incrementAndGet()
1039
+ maximum.updateAndGet { maxOf(it, count) }
1040
+ }
1041
+ try {
1042
+ delay(25)
1043
+ return mapping.downloadFile(fileUrl, destination, fileSizeCallback, progressCallback)
1044
+ } finally {
1045
+ if (isAsset) active.decrementAndGet()
1046
+ }
1047
+ }
1048
+ }
1049
+ val service = createService(root, downloadService = downloader, builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()))
1050
+ val progress = CopyOnWriteArrayList<UpdateProgressPayload>()
1051
+ service.updateBundle(
1052
+ "target",
1053
+ "https://example.com/manifest.json",
1054
+ sha256(root, manifest),
1055
+ paths.associateWith { ChangedAssetDescriptor("https://example.com/$it", hash) },
1056
+ progressCallback = progress::add,
1057
+ )
1058
+ assertTrue("Expected concurrent requests, observed ${maximum.get()}", maximum.get() > 1)
1059
+ assertTrue("Concurrency must be bounded", maximum.get() <= 4)
1060
+ assertEquals(paths.size, progress.last().details?.completedFilesCount)
1061
+ }
1062
+
1063
+ @Test
1064
+ fun `archive installs as one actual network file at equal planned bytes`() =
1065
+ runBlocking {
1066
+ val root = temporaryFolder.newFolder("archive-selected")
1067
+ val archiveBytes = Base64.getDecoder().decode(TarBrArchiveExtractorTest.VALID)
1068
+ val contents = mapOf("assets/image.png" to "image", "index.android.bundle" to "bundle")
1069
+ val hashes = contents.mapValues { sha256(root, it.value) }
1070
+ val manifest =
1071
+ manifestJson(
1072
+ bundleId = "target",
1073
+ assets = hashes,
1074
+ byteSizes =
1075
+ contents.mapValues {
1076
+ it.value
1077
+ .toByteArray()
1078
+ .size
1079
+ .toLong()
1080
+ },
1081
+ downloadByteSizes = mapOf("assets/image.png" to 50, "index.android.bundle" to 55),
1082
+ archive = archiveJson(root, archiveBytes, tarByteSize = 3072),
1083
+ )
1084
+ val downloads =
1085
+ BinaryMappingDownloadService(
1086
+ mapOf(
1087
+ MANIFEST_URL to manifest.toByteArray(),
1088
+ ARCHIVE_URL to archiveBytes,
1089
+ ),
1090
+ )
1091
+ val service = createService(root, downloadService = downloads, builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()))
1092
+ val progress = CopyOnWriteArrayList<UpdateProgressPayload>()
1093
+
1094
+ service.updateBundle(
1095
+ bundleId = "target",
1096
+ manifestUrl = MANIFEST_URL,
1097
+ manifestFileHash = sha256(root, manifest),
1098
+ assets = hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) },
1099
+ archiveUrl = ARCHIVE_URL,
1100
+ progressCallback = progress::add,
1101
+ )
1102
+
1103
+ assertEquals(listOf(MANIFEST_URL, ARCHIVE_URL), downloads.calls)
1104
+ assertEquals(
1105
+ listOf("bundle.tar.br"),
1106
+ progress
1107
+ .last()
1108
+ .details
1109
+ ?.files
1110
+ ?.map { it.path },
1111
+ )
1112
+ assertEquals(
1113
+ archiveBytes.size.toLong(),
1114
+ progress
1115
+ .last()
1116
+ .details
1117
+ ?.files
1118
+ ?.single()
1119
+ ?.downloadedBytes,
1120
+ )
1121
+ val installed = File(bundleStoreDir(root), "target")
1122
+ assertEquals("bundle", File(installed, "index.android.bundle").readText())
1123
+ assertEquals("image", File(installed, "assets/image.png").readText())
1124
+ val installedManifest = JSONObject(File(installed, "manifest.json").readText())
1125
+ assertEquals(archiveBytes.size.toLong(), installedManifest.getJSONObject("archive").getLong("downloadByteSize"))
1126
+ assertEquals(6, installedManifest.getJSONObject("assets").getJSONObject("index.android.bundle").getLong("byteSize"))
1127
+ }
1128
+
1129
+ @Test
1130
+ fun `full network plan accepts only bounded TAR framing overhead`() =
1131
+ runBlocking {
1132
+ val contents = mapOf("assets/image.png" to "image", "index.android.bundle" to "bundle")
1133
+ val logicalBytes = contents.values.sumOf { it.toByteArray().size.toLong() }
1134
+ val archiveBytes = Base64.getDecoder().decode(TarBrArchiveExtractorTest.VALID)
1135
+ val individualBytes = archiveBytes.size.toLong() - 1
1136
+ val downloadByteSizes =
1137
+ mapOf(
1138
+ "assets/image.png" to individualBytes / 2,
1139
+ "index.android.bundle" to individualBytes - individualBytes / 2,
1140
+ )
1141
+
1142
+ val selectedRoot = temporaryFolder.newFolder("archive-framing-selected")
1143
+ val selectedHashes = contents.mapValues { sha256(selectedRoot, it.value) }
1144
+ val selectedManifest =
1145
+ manifestJson(
1146
+ bundleId = "target",
1147
+ assets = selectedHashes,
1148
+ byteSizes =
1149
+ contents.mapValues {
1150
+ it.value
1151
+ .toByteArray()
1152
+ .size
1153
+ .toLong()
1154
+ },
1155
+ downloadByteSizes = downloadByteSizes,
1156
+ archive = archiveJson(selectedRoot, archiveBytes, tarByteSize = 3072),
1157
+ )
1158
+ val selectedDownloads =
1159
+ BinaryMappingDownloadService(
1160
+ mapOf(MANIFEST_URL to selectedManifest.toByteArray(), ARCHIVE_URL to archiveBytes),
1161
+ )
1162
+ createService(
1163
+ selectedRoot,
1164
+ downloadService = selectedDownloads,
1165
+ builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()),
1166
+ ).updateBundle(
1167
+ bundleId = "target",
1168
+ manifestUrl = MANIFEST_URL,
1169
+ manifestFileHash = sha256(selectedRoot, selectedManifest),
1170
+ assets = selectedHashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) },
1171
+ archiveUrl = ARCHIVE_URL,
1172
+ progressCallback = {},
1173
+ )
1174
+ assertEquals(listOf(MANIFEST_URL, ARCHIVE_URL), selectedDownloads.calls)
1175
+
1176
+ val declinedRoot = temporaryFolder.newFolder("archive-framing-declined")
1177
+ val declinedHashes = contents.mapValues { sha256(declinedRoot, it.value) }
1178
+ val minimumTarBytes = 1024L
1179
+ val firstByteOutsideBound = individualBytes + (minimumTarBytes - logicalBytes) + 1
1180
+ val declinedManifest =
1181
+ manifestJson(
1182
+ bundleId = "target",
1183
+ assets = declinedHashes,
1184
+ byteSizes =
1185
+ contents.mapValues {
1186
+ it.value
1187
+ .toByteArray()
1188
+ .size
1189
+ .toLong()
1190
+ },
1191
+ downloadByteSizes = downloadByteSizes,
1192
+ archive =
1193
+ archiveJson(declinedRoot, archiveBytes, tarByteSize = minimumTarBytes)
1194
+ .put("downloadByteSize", firstByteOutsideBound),
1195
+ )
1196
+ val declinedDownloads =
1197
+ BinaryMappingDownloadService(
1198
+ mapOf(MANIFEST_URL to declinedManifest.toByteArray()) +
1199
+ contents.mapKeys { "https://example.com/${it.key}" }.mapValues { it.value.toByteArray() },
1200
+ )
1201
+ createService(
1202
+ declinedRoot,
1203
+ downloadService = declinedDownloads,
1204
+ builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()),
1205
+ ).updateBundle(
1206
+ bundleId = "target",
1207
+ manifestUrl = MANIFEST_URL,
1208
+ manifestFileHash = sha256(declinedRoot, declinedManifest),
1209
+ assets = declinedHashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) },
1210
+ archiveUrl = ARCHIVE_URL,
1211
+ progressCallback = {},
1212
+ )
1213
+ assertFalse(declinedDownloads.calls.contains(ARCHIVE_URL))
1214
+ }
1215
+
1216
+ @Test
1217
+ fun `corrupt archive is discarded before exactly one ordinary file pass`() =
1218
+ runBlocking {
1219
+ val root = temporaryFolder.newFolder("archive-fallback")
1220
+ val archiveBytes = Base64.getDecoder().decode(TarBrArchiveExtractorTest.VALID)
1221
+ val corruptArchive = archiveBytes.clone().apply { this[lastIndex] = (this[lastIndex].toInt() xor 1).toByte() }
1222
+ val contents = mapOf("assets/image.png" to "image", "index.android.bundle" to "bundle")
1223
+ val hashes = contents.mapValues { sha256(root, it.value) }
1224
+ val manifest =
1225
+ manifestJson(
1226
+ bundleId = "target",
1227
+ assets = hashes,
1228
+ byteSizes =
1229
+ contents.mapValues {
1230
+ it.value
1231
+ .toByteArray()
1232
+ .size
1233
+ .toLong()
1234
+ },
1235
+ downloadByteSizes = contents.mapValues { 100L },
1236
+ archive = archiveJson(root, archiveBytes, tarByteSize = 3072),
1237
+ )
1238
+ val downloads =
1239
+ BinaryMappingDownloadService(
1240
+ mapOf(MANIFEST_URL to manifest.toByteArray(), ARCHIVE_URL to corruptArchive) +
1241
+ contents.mapKeys { "https://example.com/${it.key}" }.mapValues { it.value.toByteArray() },
1242
+ )
1243
+ val service = createService(root, downloadService = downloads, builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()))
1244
+ val progress = CopyOnWriteArrayList<UpdateProgressPayload>()
1245
+
1246
+ service.updateBundle(
1247
+ bundleId = "target",
1248
+ manifestUrl = MANIFEST_URL,
1249
+ manifestFileHash = sha256(root, manifest),
1250
+ assets = hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) },
1251
+ archiveUrl = ARCHIVE_URL,
1252
+ progressCallback = progress::add,
1253
+ )
1254
+
1255
+ assertEquals(1, downloads.calls.count { it == ARCHIVE_URL })
1256
+ assertEquals(1, downloads.calls.count { it == "https://example.com/index.android.bundle" })
1257
+ assertEquals(1, downloads.calls.count { it == "https://example.com/assets/image.png" })
1258
+ assertEquals(
1259
+ hashes.keys.sorted(),
1260
+ progress
1261
+ .last()
1262
+ .details
1263
+ ?.files
1264
+ ?.map { it.path },
1265
+ )
1266
+ assertFalse(File(bundleStoreDir(root), "target.archive.tmp").exists())
1267
+ assertFalse(File(bundleStoreDir(root), "target.local.tmp").exists())
1268
+ }
1269
+
1270
+ @Test
1271
+ fun `partial local reuse keeps strict cost comparison with two network files`() =
1272
+ runBlocking {
1273
+ val root = temporaryFolder.newFolder("archive-one-remaining")
1274
+ val archiveBytes = Base64.getDecoder().decode(TarBrArchiveExtractorTest.VALID)
1275
+ val contents =
1276
+ mapOf(
1277
+ "assets/image.png" to "image",
1278
+ "assets/other.png" to "other",
1279
+ "index.android.bundle" to "bundle",
1280
+ )
1281
+ val hashes = contents.mapValues { sha256(root, it.value) }
1282
+ val manifest =
1283
+ manifestJson(
1284
+ bundleId = "target",
1285
+ assets = hashes,
1286
+ byteSizes =
1287
+ contents.mapValues {
1288
+ it.value
1289
+ .toByteArray()
1290
+ .size
1291
+ .toLong()
1292
+ },
1293
+ downloadByteSizes = contents.mapValues { 1L },
1294
+ archive = archiveJson(root, archiveBytes, tarByteSize = 3072),
1295
+ )
1296
+ val bundleUrl = "https://example.com/index.android.bundle"
1297
+ val otherUrl = "https://example.com/assets/other.png"
1298
+ val downloads =
1299
+ BinaryMappingDownloadService(
1300
+ mapOf(
1301
+ MANIFEST_URL to manifest.toByteArray(),
1302
+ bundleUrl to "bundle".toByteArray(),
1303
+ otherUrl to "other".toByteArray(),
1304
+ ),
1305
+ )
1306
+ val service =
1307
+ createService(
1308
+ rootDir = root,
1309
+ downloadService = downloads,
1310
+ builtInAssetResolver = MappingBuiltInAssetResolver(mapOf("assets/image.png" to "image")),
1311
+ )
1312
+
1313
+ service.updateBundle(
1314
+ bundleId = "target",
1315
+ manifestUrl = MANIFEST_URL,
1316
+ manifestFileHash = sha256(root, manifest),
1317
+ assets = hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) },
1318
+ archiveUrl = ARCHIVE_URL,
1319
+ progressCallback = {},
1320
+ )
1321
+
1322
+ assertFalse(downloads.calls.contains(ARCHIVE_URL))
1323
+ assertEquals(1, downloads.calls.count { it == bundleUrl })
1324
+ assertEquals(1, downloads.calls.count { it == otherUrl })
1325
+ }
1326
+
1327
+ @Test
1328
+ fun `archive selection declines incomplete expensive and overflowing cost plans`() =
1329
+ runBlocking {
1330
+ data class CostCase(
1331
+ val name: String,
1332
+ val downloadSizes: Map<String, Long>,
1333
+ val archiveSize: Long,
1334
+ val tarSize: Long = 3072,
1335
+ val logicalSizes: (Map<String, String>) -> Map<String, Long> = { contents ->
1336
+ contents.mapValues {
1337
+ it.value
1338
+ .toByteArray()
1339
+ .size
1340
+ .toLong()
1341
+ }
1342
+ },
1343
+ val descriptors: (Map<String, String>) -> Map<String, ChangedAssetDescriptor>,
1344
+ )
1345
+
1346
+ val cases =
1347
+ listOf(
1348
+ CostCase("missing", mapOf("index.android.bundle" to 100), 1) { hashes ->
1349
+ hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) }
1350
+ },
1351
+ CostCase("larger", mapOf("index.android.bundle" to 50, "assets/image.png" to 50), 4000) { hashes ->
1352
+ hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) }
1353
+ },
1354
+ CostCase("zero-archive", mapOf("index.android.bundle" to 100, "assets/image.png" to 100), 0) { hashes ->
1355
+ hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) }
1356
+ },
1357
+ CostCase("short-tar", mapOf("index.android.bundle" to 100, "assets/image.png" to 100), 1, tarSize = 1023) { hashes ->
1358
+ hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) }
1359
+ },
1360
+ CostCase(
1361
+ "negative-framing",
1362
+ mapOf("index.android.bundle" to 100, "assets/image.png" to 100),
1363
+ 1,
1364
+ tarSize = 1024,
1365
+ logicalSizes = { contents -> contents.mapValues { 600L } },
1366
+ ) { hashes ->
1367
+ hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) }
1368
+ },
1369
+ CostCase(
1370
+ "logical-overflow",
1371
+ mapOf("index.android.bundle" to 100, "assets/image.png" to 100),
1372
+ 1,
1373
+ tarSize = 9_007_199_254_740_991,
1374
+ logicalSizes = {
1375
+ mapOf(
1376
+ "index.android.bundle" to 9_007_199_254_740_991,
1377
+ "assets/image.png" to 1,
1378
+ )
1379
+ },
1380
+ ) { hashes ->
1381
+ hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) }
1382
+ },
1383
+ CostCase(
1384
+ "framed-limit-overflow",
1385
+ mapOf("index.android.bundle" to 9_007_199_254_740_991, "assets/image.png" to 0),
1386
+ 1,
1387
+ ) { hashes ->
1388
+ hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) }
1389
+ },
1390
+ CostCase("unknown-patch", mapOf("index.android.bundle" to 100, "assets/image.png" to 100), 1) { hashes ->
1391
+ hashes.mapValues { (path, hash) ->
1392
+ ChangedAssetDescriptor(
1393
+ fileUrl = "https://example.com/$path",
1394
+ fileHash = hash,
1395
+ patch =
1396
+ if (path == "index.android.bundle") {
1397
+ BsdiffPatchDescriptor("bsdiff", "base", "a", "b", "https://example.com/patch")
1398
+ } else {
1399
+ null
1400
+ },
1401
+ )
1402
+ }
1403
+ },
1404
+ CostCase("known-patch", mapOf("index.android.bundle" to 100, "assets/image.png" to 1), 3) { hashes ->
1405
+ hashes.mapValues { (path, hash) ->
1406
+ ChangedAssetDescriptor(
1407
+ fileUrl = "https://example.com/$path",
1408
+ fileHash = hash,
1409
+ patch =
1410
+ if (path == "index.android.bundle") {
1411
+ BsdiffPatchDescriptor("bsdiff", "base", "a", "b", "https://example.com/patch", byteSize = 1)
1412
+ } else {
1413
+ null
1414
+ },
1415
+ )
1416
+ }
1417
+ },
1418
+ CostCase("negative-patch", mapOf("index.android.bundle" to 100, "assets/image.png" to 100), 1) { hashes ->
1419
+ hashes.mapValues { (path, hash) ->
1420
+ ChangedAssetDescriptor(
1421
+ fileUrl = "https://example.com/$path",
1422
+ fileHash = hash,
1423
+ patch =
1424
+ if (path == "index.android.bundle") {
1425
+ BsdiffPatchDescriptor("bsdiff", "base", "a", "b", "https://example.com/patch", byteSize = -1)
1426
+ } else {
1427
+ null
1428
+ },
1429
+ )
1430
+ }
1431
+ },
1432
+ CostCase("overflowing-patch", mapOf("index.android.bundle" to 100, "assets/image.png" to 100), 1) { hashes ->
1433
+ hashes.mapValues { (path, hash) ->
1434
+ ChangedAssetDescriptor(
1435
+ fileUrl = "https://example.com/$path",
1436
+ fileHash = hash,
1437
+ patch =
1438
+ if (path == "index.android.bundle") {
1439
+ BsdiffPatchDescriptor(
1440
+ "bsdiff",
1441
+ "base",
1442
+ "a",
1443
+ "b",
1444
+ "https://example.com/patch",
1445
+ byteSize = Long.MAX_VALUE,
1446
+ )
1447
+ } else {
1448
+ null
1449
+ },
1450
+ )
1451
+ }
1452
+ },
1453
+ CostCase(
1454
+ "overflow",
1455
+ mapOf("index.android.bundle" to 9_007_199_254_740_991, "assets/image.png" to 1),
1456
+ 1,
1457
+ ) { hashes ->
1458
+ hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) }
1459
+ },
1460
+ )
1461
+
1462
+ for (case in cases) {
1463
+ val root = temporaryFolder.newFolder("archive-decline-${case.name}")
1464
+ val archiveBytes = Base64.getDecoder().decode(TarBrArchiveExtractorTest.VALID)
1465
+ val contents = mapOf("assets/image.png" to "image", "index.android.bundle" to "bundle")
1466
+ val hashes = contents.mapValues { sha256(root, it.value) }
1467
+ val manifest =
1468
+ manifestJson(
1469
+ bundleId = "target",
1470
+ assets = hashes,
1471
+ byteSizes = case.logicalSizes(contents),
1472
+ downloadByteSizes = case.downloadSizes,
1473
+ archive = archiveJson(root, archiveBytes, tarByteSize = case.tarSize).put("downloadByteSize", case.archiveSize),
1474
+ )
1475
+ val downloads =
1476
+ BinaryMappingDownloadService(
1477
+ mapOf(MANIFEST_URL to manifest.toByteArray()) +
1478
+ contents.mapKeys { "https://example.com/${it.key}" }.mapValues { it.value.toByteArray() },
1479
+ )
1480
+ val service =
1481
+ createService(root, downloadService = downloads, builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()))
1482
+
1483
+ service.updateBundle(
1484
+ bundleId = "target",
1485
+ manifestUrl = MANIFEST_URL,
1486
+ manifestFileHash = sha256(root, manifest),
1487
+ assets = case.descriptors(hashes),
1488
+ archiveUrl = ARCHIVE_URL,
1489
+ progressCallback = {},
1490
+ )
1491
+
1492
+ assertFalse("${case.name} must decline archive", downloads.calls.contains(ARCHIVE_URL))
1493
+ }
1494
+ }
1495
+
598
1496
  private fun createService(
599
1497
  rootDir: File,
600
1498
  preferences: InMemoryPreferencesService = InMemoryPreferencesService(),
601
1499
  downloadService: DownloadService = UnusedDownloadService,
1500
+ builtInAssetResolver: BuiltInAssetResolver? = null,
1501
+ metadataWriter: (BundleMetadata, File) -> Boolean = { metadata, file -> metadata.saveToFile(file) },
1502
+ directoryRenamer: (File, File) -> Boolean = { source, destination -> source.renameTo(destination) },
602
1503
  ): BundleFileStorageService =
603
1504
  BundleFileStorageService(
604
- ContextWrapper(null),
605
- TestFileSystemService(rootDir),
606
- downloadService,
607
- DecompressService(),
608
- preferences,
609
- TEST_ISOLATION_KEY,
610
- { "production" },
1505
+ context = ContextWrapper(null),
1506
+ fileSystem = TestFileSystemService(rootDir),
1507
+ downloadService = downloadService,
1508
+ preferences = preferences,
1509
+ isolationKey = TEST_ISOLATION_KEY,
1510
+ defaultChannelProvider = { "production" },
1511
+ builtInAssetResolver = builtInAssetResolver,
1512
+ metadataWriter = metadataWriter,
1513
+ directoryRenamer = directoryRenamer,
611
1514
  )
612
1515
 
613
1516
  private fun releaseSelection(
@@ -634,6 +1537,23 @@ class BundleFileStorageServiceTest {
634
1537
  bundleId: String,
635
1538
  ): File = File(bundleStoreDir(rootDir), bundleId).apply { mkdirs() }
636
1539
 
1540
+ private fun createCompleteBundle(
1541
+ rootDir: File,
1542
+ directoryName: String,
1543
+ content: String,
1544
+ manifestBundleId: String = directoryName,
1545
+ ): File {
1546
+ val directory = createBundleDir(rootDir, directoryName)
1547
+ writeFile(directory, "index.android.bundle", content)
1548
+ File(directory, "manifest.json").writeText(
1549
+ manifestJson(
1550
+ manifestBundleId,
1551
+ mapOf("index.android.bundle" to sha256(rootDir, content)),
1552
+ ),
1553
+ )
1554
+ return directory
1555
+ }
1556
+
637
1557
  private fun writeManifest(
638
1558
  bundleDir: File,
639
1559
  assetPaths: List<String>,
@@ -681,16 +1601,6 @@ class BundleFileStorageServiceTest {
681
1601
  writeText(content)
682
1602
  }
683
1603
 
684
- private fun writeZipEntry(
685
- zip: ZipOutputStream,
686
- path: String,
687
- content: String,
688
- ) {
689
- zip.putNextEntry(ZipEntry(path))
690
- zip.write(content.toByteArray())
691
- zip.closeEntry()
692
- }
693
-
694
1604
  private fun bundleStoreDir(rootDir: File): File = File(rootDir, "bundle-store").apply { mkdirs() }
695
1605
 
696
1606
  private fun invokeResolveBundleFile(
@@ -707,12 +1617,6 @@ class BundleFileStorageServiceTest {
707
1617
  return method.invoke(service, bundleDir, bundleDir.name) as File?
708
1618
  }
709
1619
 
710
- private fun invokeCanUseManifestDrivenInstall(service: BundleFileStorageService): Boolean {
711
- val method = BundleFileStorageService::class.java.getDeclaredMethod("canUseManifestDrivenInstall")
712
- method.isAccessible = true
713
- return method.invoke(service) as Boolean
714
- }
715
-
716
1620
  private fun assertResolvedBundlePath(
717
1621
  service: BundleFileStorageService,
718
1622
  bundleDir: File,
@@ -753,7 +1657,9 @@ class BundleFileStorageServiceTest {
753
1657
  override fun getInternalFilesDir(): File = internalFilesDir
754
1658
  }
755
1659
 
756
- private class InMemoryPreferencesService : PreferencesService {
1660
+ private class InMemoryPreferencesService(
1661
+ private val shouldFailWrite: (String, String?) -> Boolean = { _, _ -> false },
1662
+ ) : PreferencesService {
757
1663
  private val values = mutableMapOf<String, String?>()
758
1664
 
759
1665
  override fun getItem(key: String): String? = values[key]
@@ -762,6 +1668,9 @@ class BundleFileStorageServiceTest {
762
1668
  key: String,
763
1669
  value: String?,
764
1670
  ) {
1671
+ if (shouldFailWrite(key, value)) {
1672
+ throw IllegalStateException("Injected preference write failure")
1673
+ }
765
1674
  if (value == null) {
766
1675
  values.remove(key)
767
1676
  } else {
@@ -793,7 +1702,150 @@ class BundleFileStorageServiceTest {
793
1702
  }
794
1703
  }
795
1704
 
1705
+ private class MappingDownloadService(
1706
+ private val contents: Map<String, String>,
1707
+ ) : DownloadService {
1708
+ val calls = CopyOnWriteArrayList<String>()
1709
+
1710
+ override suspend fun downloadFile(
1711
+ fileUrl: URL,
1712
+ destination: File,
1713
+ fileSizeCallback: ((Long) -> Unit)?,
1714
+ progressCallback: (DownloadProgress) -> Unit,
1715
+ ): DownloadResult {
1716
+ calls += fileUrl.toString()
1717
+ val content =
1718
+ contents[fileUrl.toString()]
1719
+ ?: return DownloadResult.Error(IllegalArgumentException("Unexpected URL: $fileUrl"))
1720
+ destination.parentFile?.mkdirs()
1721
+ destination.writeText(content)
1722
+ fileSizeCallback?.invoke(destination.length())
1723
+ progressCallback(DownloadProgress(1.0, destination.length(), destination.length()))
1724
+ return DownloadResult.Success(destination)
1725
+ }
1726
+ }
1727
+
1728
+ private class BinaryMappingDownloadService(
1729
+ private val contents: Map<String, ByteArray>,
1730
+ ) : DownloadService {
1731
+ val calls = CopyOnWriteArrayList<String>()
1732
+
1733
+ override suspend fun downloadFile(
1734
+ fileUrl: URL,
1735
+ destination: File,
1736
+ fileSizeCallback: ((Long) -> Unit)?,
1737
+ progressCallback: (DownloadProgress) -> Unit,
1738
+ ): DownloadResult = download(fileUrl, destination, fileSizeCallback, progressCallback)
1739
+
1740
+ override suspend fun downloadFileOnce(
1741
+ fileUrl: URL,
1742
+ destination: File,
1743
+ fileSizeCallback: ((Long) -> Unit)?,
1744
+ progressCallback: (DownloadProgress) -> Unit,
1745
+ ): DownloadResult = download(fileUrl, destination, fileSizeCallback, progressCallback)
1746
+
1747
+ private fun download(
1748
+ fileUrl: URL,
1749
+ destination: File,
1750
+ fileSizeCallback: ((Long) -> Unit)?,
1751
+ progressCallback: (DownloadProgress) -> Unit,
1752
+ ): DownloadResult {
1753
+ calls += fileUrl.toString()
1754
+ val bytes = contents[fileUrl.toString()] ?: return DownloadResult.Error(IllegalArgumentException("Unexpected URL: $fileUrl"))
1755
+ destination.parentFile?.mkdirs()
1756
+ destination.writeBytes(bytes)
1757
+ fileSizeCallback?.invoke(bytes.size.toLong())
1758
+ progressCallback(DownloadProgress(1.0, bytes.size.toLong(), bytes.size.toLong()))
1759
+ return DownloadResult.Success(destination)
1760
+ }
1761
+ }
1762
+
1763
+ private class MappingBuiltInAssetResolver(
1764
+ private val contents: Map<String, String>,
1765
+ ) : BuiltInAssetResolver {
1766
+ override fun copyIfMatches(
1767
+ assetPath: String,
1768
+ expectedHash: String,
1769
+ destination: File,
1770
+ ): Boolean {
1771
+ val content = contents[assetPath] ?: return false
1772
+ destination.parentFile?.mkdirs()
1773
+ destination.writeText(content)
1774
+ if (!HashUtils.verifyHash(destination, expectedHash)) {
1775
+ destination.delete()
1776
+ return false
1777
+ }
1778
+ return true
1779
+ }
1780
+ }
1781
+
1782
+ private fun manifestJson(
1783
+ bundleId: String,
1784
+ assets: Map<String, String>,
1785
+ byteSizes: Map<String, Long> = emptyMap(),
1786
+ downloadByteSizes: Map<String, Long> = emptyMap(),
1787
+ archive: JSONObject? = null,
1788
+ ): String =
1789
+ JSONObject()
1790
+ .put("bundleId", bundleId)
1791
+ .put(
1792
+ "assets",
1793
+ JSONObject().apply {
1794
+ assets.forEach { (path, hash) ->
1795
+ put(
1796
+ path,
1797
+ JSONObject()
1798
+ .put("fileHash", hash)
1799
+ .apply {
1800
+ byteSizes[path]?.let { put("byteSize", it) }
1801
+ downloadByteSizes[path]?.let { put("downloadByteSize", it) }
1802
+ },
1803
+ )
1804
+ }
1805
+ },
1806
+ ).apply {
1807
+ archive?.let { put("archive", it) }
1808
+ }.toString()
1809
+
1810
+ private fun archiveJson(
1811
+ rootDir: File,
1812
+ bytes: ByteArray,
1813
+ tarByteSize: Long,
1814
+ ): JSONObject =
1815
+ JSONObject()
1816
+ .put("downloadFileHash", sha256(rootDir, bytes))
1817
+ .put("downloadByteSize", bytes.size)
1818
+ .put("tarByteSize", tarByteSize)
1819
+
1820
+ private fun sha256(
1821
+ rootDir: File,
1822
+ content: String,
1823
+ ): String {
1824
+ val file = File.createTempFile("hash-", null, rootDir)
1825
+ return try {
1826
+ file.writeText(content)
1827
+ HashUtils.calculateSHA256(file)
1828
+ } finally {
1829
+ file.delete()
1830
+ }
1831
+ }
1832
+
1833
+ private fun sha256(
1834
+ rootDir: File,
1835
+ bytes: ByteArray,
1836
+ ): String {
1837
+ val file = File.createTempFile("hash-", null, rootDir)
1838
+ return try {
1839
+ file.writeBytes(bytes)
1840
+ HashUtils.calculateSHA256(file)
1841
+ } finally {
1842
+ file.delete()
1843
+ }
1844
+ }
1845
+
796
1846
  companion object {
797
1847
  private const val TEST_ISOLATION_KEY = "test-isolation-key"
1848
+ private const val MANIFEST_URL = "https://example.com/manifest.json"
1849
+ private const val ARCHIVE_URL = "https://example.com/bundle.tar.br"
798
1850
  }
799
1851
  }