@hot-updater/react-native 1.0.0-rc.17 → 1.0.0-rc.21

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 (240) hide show
  1. package/lib/commonjs/checkForUpdate.js +7 -9
  2. package/lib/commonjs/checkForUpdate.js.map +1 -1
  3. package/lib/commonjs/clientPlugin.js +7 -43
  4. package/lib/commonjs/clientPlugin.js.map +1 -1
  5. package/lib/commonjs/httpClient.js +7 -9
  6. package/lib/commonjs/httpClient.js.map +1 -1
  7. package/lib/commonjs/index.js +10 -0
  8. package/lib/commonjs/index.js.map +1 -1
  9. package/lib/commonjs/native.js +4 -4
  10. package/lib/commonjs/native.js.map +1 -1
  11. package/lib/commonjs/pluginHost.js +26 -190
  12. package/lib/commonjs/pluginHost.js.map +1 -1
  13. package/lib/commonjs/releaseCatalogCache.js +7 -59
  14. package/lib/commonjs/releaseCatalogCache.js.map +1 -1
  15. package/lib/commonjs/sdkVersion.js +1 -1
  16. package/lib/module/checkForUpdate.js +1 -3
  17. package/lib/module/checkForUpdate.js.map +1 -1
  18. package/lib/module/clientPlugin.js +1 -41
  19. package/lib/module/clientPlugin.js.map +1 -1
  20. package/lib/module/httpClient.js +1 -3
  21. package/lib/module/httpClient.js.map +1 -1
  22. package/lib/module/index.js +2 -0
  23. package/lib/module/index.js.map +1 -1
  24. package/lib/module/native.js +1 -1
  25. package/lib/module/native.js.map +1 -1
  26. package/lib/module/pluginHost.js +22 -187
  27. package/lib/module/pluginHost.js.map +1 -1
  28. package/lib/module/releaseCatalogCache.js +3 -54
  29. package/lib/module/releaseCatalogCache.js.map +1 -1
  30. package/lib/module/sdkVersion.js +1 -1
  31. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/clientPlugin.d.ts +1 -184
  33. package/lib/typescript/commonjs/clientPlugin.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/httpClient.d.ts +1 -2
  35. package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
  36. package/lib/typescript/commonjs/index.d.ts +1 -0
  37. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  38. package/lib/typescript/commonjs/native.d.ts +1 -1
  39. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  40. package/lib/typescript/commonjs/pluginHost.d.ts +3 -28
  41. package/lib/typescript/commonjs/pluginHost.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/releaseCatalogCache.d.ts +1 -13
  43. package/lib/typescript/commonjs/releaseCatalogCache.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  45. package/lib/typescript/commonjs/types.d.ts +1 -1
  46. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/wrap.types.d.ts +2 -2
  48. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  49. package/lib/typescript/module/clientPlugin.d.ts +1 -184
  50. package/lib/typescript/module/clientPlugin.d.ts.map +1 -1
  51. package/lib/typescript/module/httpClient.d.ts +1 -2
  52. package/lib/typescript/module/httpClient.d.ts.map +1 -1
  53. package/lib/typescript/module/index.d.ts +1 -0
  54. package/lib/typescript/module/index.d.ts.map +1 -1
  55. package/lib/typescript/module/native.d.ts +1 -1
  56. package/lib/typescript/module/native.d.ts.map +1 -1
  57. package/lib/typescript/module/pluginHost.d.ts +3 -28
  58. package/lib/typescript/module/pluginHost.d.ts.map +1 -1
  59. package/lib/typescript/module/releaseCatalogCache.d.ts +1 -13
  60. package/lib/typescript/module/releaseCatalogCache.d.ts.map +1 -1
  61. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  62. package/lib/typescript/module/types.d.ts +1 -1
  63. package/lib/typescript/module/types.d.ts.map +1 -1
  64. package/lib/typescript/module/wrap.types.d.ts +2 -2
  65. package/package.json +7 -16
  66. package/src/checkForUpdate.ts +4 -6
  67. package/src/clientPlugin.ts +17 -231
  68. package/src/httpClient.ts +4 -4
  69. package/src/index.ts +7 -0
  70. package/src/native.ts +1 -1
  71. package/src/pluginHost.ts +24 -241
  72. package/src/releaseCatalogCache.ts +7 -136
  73. package/src/sdkVersion.ts +1 -1
  74. package/src/types.ts +1 -1
  75. package/src/wrap.types.ts +2 -2
  76. package/android/src/test/java/com/hotupdater/BsdiffPatchTest.kt +0 -46
  77. package/android/src/test/java/com/hotupdater/BundleFileStorageServiceTest.kt +0 -2236
  78. package/android/src/test/java/com/hotupdater/HotUpdaterExceptionTest.kt +0 -220
  79. package/android/src/test/java/com/hotupdater/HotUpdaterImplTest.kt +0 -264
  80. package/android/src/test/java/com/hotupdater/HotUpdaterRecoveryStateTest.kt +0 -97
  81. package/android/src/test/java/com/hotupdater/InstallIdentityServiceTest.kt +0 -71
  82. package/android/src/test/java/com/hotupdater/KeyValueStorageServiceTest.kt +0 -106
  83. package/android/src/test/java/com/hotupdater/NativeConfigUtilsTest.kt +0 -20
  84. package/android/src/test/java/com/hotupdater/NoBackupContext.kt +0 -11
  85. package/android/src/test/java/com/hotupdater/OkHttpDownloadServiceTest.kt +0 -397
  86. package/android/src/test/java/com/hotupdater/PendingCrashRecoveryTest.kt +0 -44
  87. package/android/src/test/java/com/hotupdater/PreviousProcessExitTest.kt +0 -57
  88. package/android/src/test/java/com/hotupdater/RelativePathResolverTest.kt +0 -41
  89. package/android/src/test/java/com/hotupdater/ReleaseCatalogCacheServiceTest.kt +0 -118
  90. package/android/src/test/java/com/hotupdater/TarBrArchiveExtractorTest.kt +0 -206
  91. package/lib/commonjs/appReady.spec.js +0 -360
  92. package/lib/commonjs/appReady.spec.js.map +0 -1
  93. package/lib/commonjs/appReady.test-utils.js +0 -25
  94. package/lib/commonjs/appReady.test-utils.js.map +0 -1
  95. package/lib/commonjs/baseURL.js +0 -16
  96. package/lib/commonjs/baseURL.js.map +0 -1
  97. package/lib/commonjs/catalogCacheNative.spec.js +0 -31
  98. package/lib/commonjs/catalogCacheNative.spec.js.map +0 -1
  99. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js +0 -936
  100. package/lib/commonjs/checkForUpdate.releaseCatalog.spec.js.map +0 -1
  101. package/lib/commonjs/entryImportGraph.spec.js +0 -50
  102. package/lib/commonjs/entryImportGraph.spec.js.map +0 -1
  103. package/lib/commonjs/httpClient.spec.js +0 -171
  104. package/lib/commonjs/httpClient.spec.js.map +0 -1
  105. package/lib/commonjs/index.spec.js +0 -395
  106. package/lib/commonjs/index.spec.js.map +0 -1
  107. package/lib/commonjs/iosInterop.spec.js +0 -18
  108. package/lib/commonjs/iosInterop.spec.js.map +0 -1
  109. package/lib/commonjs/native.spec.js +0 -910
  110. package/lib/commonjs/native.spec.js.map +0 -1
  111. package/lib/commonjs/pluginHost.spec.js +0 -292
  112. package/lib/commonjs/pluginHost.spec.js.map +0 -1
  113. package/lib/commonjs/plugins/insights/eventId.js +0 -129
  114. package/lib/commonjs/plugins/insights/eventId.js.map +0 -1
  115. package/lib/commonjs/plugins/insights/eventId.spec.js +0 -47
  116. package/lib/commonjs/plugins/insights/eventId.spec.js.map +0 -1
  117. package/lib/commonjs/plugins/insights/index.js +0 -272
  118. package/lib/commonjs/plugins/insights/index.js.map +0 -1
  119. package/lib/commonjs/plugins/insights/insights.spec.js +0 -640
  120. package/lib/commonjs/plugins/insights/insights.spec.js.map +0 -1
  121. package/lib/commonjs/plugins/insights/sender.js +0 -128
  122. package/lib/commonjs/plugins/insights/sender.js.map +0 -1
  123. package/lib/commonjs/plugins/insights/sender.spec.js +0 -265
  124. package/lib/commonjs/plugins/insights/sender.spec.js.map +0 -1
  125. package/lib/commonjs/plugins/insights/state.js +0 -145
  126. package/lib/commonjs/plugins/insights/state.js.map +0 -1
  127. package/lib/commonjs/releaseCatalogCache.spec.js +0 -213
  128. package/lib/commonjs/releaseCatalogCache.spec.js.map +0 -1
  129. package/lib/commonjs/releaseCatalogCacheNativeContract.spec.js +0 -64
  130. package/lib/commonjs/releaseCatalogCacheNativeContract.spec.js.map +0 -1
  131. package/lib/commonjs/store.notification.spec.js +0 -84
  132. package/lib/commonjs/store.notification.spec.js.map +0 -1
  133. package/lib/commonjs/store.spec.js +0 -182
  134. package/lib/commonjs/store.spec.js.map +0 -1
  135. package/lib/commonjs/updateError.spec.js +0 -149
  136. package/lib/commonjs/updateError.spec.js.map +0 -1
  137. package/lib/commonjs/wrap.progress.spec.js +0 -195
  138. package/lib/commonjs/wrap.progress.spec.js.map +0 -1
  139. package/lib/commonjs/wrap.spec.js +0 -232
  140. package/lib/commonjs/wrap.spec.js.map +0 -1
  141. package/lib/module/appReady.spec.js +0 -359
  142. package/lib/module/appReady.spec.js.map +0 -1
  143. package/lib/module/appReady.test-utils.js +0 -19
  144. package/lib/module/appReady.test-utils.js.map +0 -1
  145. package/lib/module/baseURL.js +0 -11
  146. package/lib/module/baseURL.js.map +0 -1
  147. package/lib/module/catalogCacheNative.spec.js +0 -31
  148. package/lib/module/catalogCacheNative.spec.js.map +0 -1
  149. package/lib/module/checkForUpdate.releaseCatalog.spec.js +0 -935
  150. package/lib/module/checkForUpdate.releaseCatalog.spec.js.map +0 -1
  151. package/lib/module/entryImportGraph.spec.js +0 -50
  152. package/lib/module/entryImportGraph.spec.js.map +0 -1
  153. package/lib/module/httpClient.spec.js +0 -171
  154. package/lib/module/httpClient.spec.js.map +0 -1
  155. package/lib/module/index.spec.js +0 -394
  156. package/lib/module/index.spec.js.map +0 -1
  157. package/lib/module/iosInterop.spec.js +0 -18
  158. package/lib/module/iosInterop.spec.js.map +0 -1
  159. package/lib/module/native.spec.js +0 -909
  160. package/lib/module/native.spec.js.map +0 -1
  161. package/lib/module/pluginHost.spec.js +0 -291
  162. package/lib/module/pluginHost.spec.js.map +0 -1
  163. package/lib/module/plugins/insights/eventId.js +0 -122
  164. package/lib/module/plugins/insights/eventId.js.map +0 -1
  165. package/lib/module/plugins/insights/eventId.spec.js +0 -47
  166. package/lib/module/plugins/insights/eventId.spec.js.map +0 -1
  167. package/lib/module/plugins/insights/index.js +0 -268
  168. package/lib/module/plugins/insights/index.js.map +0 -1
  169. package/lib/module/plugins/insights/insights.spec.js +0 -639
  170. package/lib/module/plugins/insights/insights.spec.js.map +0 -1
  171. package/lib/module/plugins/insights/sender.js +0 -123
  172. package/lib/module/plugins/insights/sender.js.map +0 -1
  173. package/lib/module/plugins/insights/sender.spec.js +0 -265
  174. package/lib/module/plugins/insights/sender.spec.js.map +0 -1
  175. package/lib/module/plugins/insights/state.js +0 -139
  176. package/lib/module/plugins/insights/state.js.map +0 -1
  177. package/lib/module/releaseCatalogCache.spec.js +0 -213
  178. package/lib/module/releaseCatalogCache.spec.js.map +0 -1
  179. package/lib/module/releaseCatalogCacheNativeContract.spec.js +0 -64
  180. package/lib/module/releaseCatalogCacheNativeContract.spec.js.map +0 -1
  181. package/lib/module/store.notification.spec.js +0 -85
  182. package/lib/module/store.notification.spec.js.map +0 -1
  183. package/lib/module/store.spec.js +0 -181
  184. package/lib/module/store.spec.js.map +0 -1
  185. package/lib/module/updateError.spec.js +0 -149
  186. package/lib/module/updateError.spec.js.map +0 -1
  187. package/lib/module/wrap.progress.spec.js +0 -196
  188. package/lib/module/wrap.progress.spec.js.map +0 -1
  189. package/lib/module/wrap.spec.js +0 -233
  190. package/lib/module/wrap.spec.js.map +0 -1
  191. package/lib/typescript/commonjs/appReady.test-utils.d.ts +0 -9
  192. package/lib/typescript/commonjs/appReady.test-utils.d.ts.map +0 -1
  193. package/lib/typescript/commonjs/baseURL.d.ts +0 -4
  194. package/lib/typescript/commonjs/baseURL.d.ts.map +0 -1
  195. package/lib/typescript/commonjs/plugins/insights/eventId.d.ts +0 -18
  196. package/lib/typescript/commonjs/plugins/insights/eventId.d.ts.map +0 -1
  197. package/lib/typescript/commonjs/plugins/insights/index.d.ts +0 -40
  198. package/lib/typescript/commonjs/plugins/insights/index.d.ts.map +0 -1
  199. package/lib/typescript/commonjs/plugins/insights/sender.d.ts +0 -74
  200. package/lib/typescript/commonjs/plugins/insights/sender.d.ts.map +0 -1
  201. package/lib/typescript/commonjs/plugins/insights/state.d.ts +0 -46
  202. package/lib/typescript/commonjs/plugins/insights/state.d.ts.map +0 -1
  203. package/lib/typescript/module/appReady.test-utils.d.ts +0 -9
  204. package/lib/typescript/module/appReady.test-utils.d.ts.map +0 -1
  205. package/lib/typescript/module/baseURL.d.ts +0 -4
  206. package/lib/typescript/module/baseURL.d.ts.map +0 -1
  207. package/lib/typescript/module/plugins/insights/eventId.d.ts +0 -18
  208. package/lib/typescript/module/plugins/insights/eventId.d.ts.map +0 -1
  209. package/lib/typescript/module/plugins/insights/index.d.ts +0 -40
  210. package/lib/typescript/module/plugins/insights/index.d.ts.map +0 -1
  211. package/lib/typescript/module/plugins/insights/sender.d.ts +0 -74
  212. package/lib/typescript/module/plugins/insights/sender.d.ts.map +0 -1
  213. package/lib/typescript/module/plugins/insights/state.d.ts +0 -46
  214. package/lib/typescript/module/plugins/insights/state.d.ts.map +0 -1
  215. package/plugins/insights/package.json +0 -8
  216. package/src/appReady.spec.ts +0 -393
  217. package/src/appReady.test-utils.ts +0 -31
  218. package/src/baseURL.ts +0 -15
  219. package/src/catalogCacheNative.spec.ts +0 -63
  220. package/src/checkForUpdate.releaseCatalog.spec.ts +0 -966
  221. package/src/entryImportGraph.spec.ts +0 -65
  222. package/src/httpClient.spec.ts +0 -216
  223. package/src/index.spec.ts +0 -434
  224. package/src/iosInterop.spec.ts +0 -30
  225. package/src/native.spec.ts +0 -978
  226. package/src/pluginHost.spec.ts +0 -297
  227. package/src/plugins/insights/eventId.spec.ts +0 -60
  228. package/src/plugins/insights/eventId.ts +0 -171
  229. package/src/plugins/insights/index.ts +0 -367
  230. package/src/plugins/insights/insights.spec.ts +0 -732
  231. package/src/plugins/insights/sender.spec.ts +0 -287
  232. package/src/plugins/insights/sender.ts +0 -221
  233. package/src/plugins/insights/state.ts +0 -184
  234. package/src/releaseCatalogCache.spec.ts +0 -304
  235. package/src/releaseCatalogCacheNativeContract.spec.ts +0 -80
  236. package/src/store.notification.spec.ts +0 -102
  237. package/src/store.spec.ts +0 -215
  238. package/src/updateError.spec.ts +0 -144
  239. package/src/wrap.progress.spec.ts +0 -232
  240. package/src/wrap.spec.ts +0 -262
@@ -1,2236 +0,0 @@
1
- package com.hotupdater
2
-
3
- import android.content.ContextWrapper
4
- import kotlinx.coroutines.asCoroutineDispatcher
5
- import kotlinx.coroutines.delay
6
- import kotlinx.coroutines.runBlocking
7
- import org.json.JSONObject
8
- import org.junit.Assert.assertEquals
9
- import org.junit.Assert.assertFalse
10
- import org.junit.Assert.assertNotNull
11
- import org.junit.Assert.assertNull
12
- import org.junit.Assert.assertTrue
13
- import org.junit.Rule
14
- import org.junit.Test
15
- import org.junit.rules.TemporaryFolder
16
- import java.io.File
17
- import java.io.IOException
18
- import java.net.ConnectException
19
- import java.net.SocketTimeoutException
20
- import java.net.URL
21
- import java.net.UnknownHostException
22
- import java.util.Base64
23
- import java.util.concurrent.CopyOnWriteArrayList
24
- import java.util.concurrent.Executors
25
- import java.util.concurrent.atomic.AtomicInteger
26
- import javax.net.ssl.SSLHandshakeException
27
-
28
- class BundleFileStorageServiceTest {
29
- @get:Rule
30
- val temporaryFolder = TemporaryFolder()
31
-
32
- @Test
33
- fun `unfinished staging launch rolls back to stable on cold start`() {
34
- assertStagingLaunchAcrossColdStart(hasStableBundle = true)
35
- }
36
-
37
- @Test
38
- fun `unfinished staging launch rolls back to built in on cold start`() {
39
- assertStagingLaunchAcrossColdStart(hasStableBundle = false)
40
- }
41
-
42
- @Test
43
- fun `completed staging launch remains trusted on cold start`() {
44
- assertStagingLaunchAcrossColdStart(hasStableBundle = true, completesLaunch = true)
45
- }
46
-
47
- @Test
48
- fun `completed first OTA launch remains trusted on cold start`() {
49
- assertStagingLaunchAcrossColdStart(hasStableBundle = false, completesLaunch = true)
50
- }
51
-
52
- private fun assertStagingLaunchAcrossColdStart(
53
- hasStableBundle: Boolean,
54
- completesLaunch: Boolean = false,
55
- ) {
56
- val rootDir = temporaryFolder.newFolder()
57
- val preferences = InMemoryPreferencesService()
58
- val stableBundleId = if (hasStableBundle) "stable-bundle" else null
59
- listOfNotNull(stableBundleId, "hung-bundle").forEach { bundleId ->
60
- val directory = createBundleDir(rootDir, bundleId)
61
- writeFile(directory, "index.android.bundle")
62
- writeManifest(directory, listOf("index.android.bundle"))
63
- }
64
- writeMetadata(
65
- rootDir,
66
- BundleMetadata(
67
- isolationKey = TEST_ISOLATION_KEY,
68
- stableBundleId = stableBundleId,
69
- stagingBundleId = "hung-bundle",
70
- verificationPending = true,
71
- ),
72
- )
73
- val firstProcess = createService(rootDir, preferences)
74
- val firstLaunch = firstProcess.prepareLaunch(null)
75
- assertEquals("hung-bundle", firstLaunch.launchedBundleId)
76
- assertTrue(firstLaunch.shouldRollbackOnCrash)
77
-
78
- assertEquals("hung-bundle", firstProcess.prepareLaunch(null).launchedBundleId)
79
- assertFalse(firstProcess.getCrashHistory().contains("hung-bundle"))
80
- if (completesLaunch) {
81
- firstProcess.markLaunchCompleted("hung-bundle")
82
- }
83
-
84
- // Issue #1321: simulate process termination without a crash marker.
85
- val secondProcess = createService(rootDir, preferences)
86
- val nextLaunch = secondProcess.prepareLaunch(null)
87
- assertEquals(if (completesLaunch) "hung-bundle" else stableBundleId, nextLaunch.launchedBundleId)
88
- assertFalse(nextLaunch.shouldRollbackOnCrash)
89
- assertEquals(!completesLaunch, secondProcess.getCrashHistory().contains("hung-bundle"))
90
- if (!completesLaunch) {
91
- assertEquals("RECOVERED", secondProcess.notifyAppReady()["status"])
92
- }
93
- val thirdProcess = createService(rootDir, preferences)
94
- assertEquals(nextLaunch.launchedBundleId, thirdProcess.prepareLaunch(null).launchedBundleId)
95
- }
96
-
97
- @Test
98
- fun `resolveBundleFile uses single manifest bundle at root`() {
99
- val rootDir = temporaryFolder.newFolder("root-manifest-bundle")
100
- val service = createService(rootDir)
101
- val bundleDir = createBundleDir(rootDir, "bundle-root")
102
- val expectedBundleFile = writeFile(bundleDir, "foo.android.bundle")
103
-
104
- writeManifest(bundleDir, listOf("foo.android.bundle"))
105
-
106
- assertResolvedBundlePath(service, bundleDir, expectedBundleFile)
107
- }
108
-
109
- @Test
110
- fun `resolveBundleFile uses single nested manifest bundle`() {
111
- val rootDir = temporaryFolder.newFolder("nested-manifest-bundle")
112
- val service = createService(rootDir)
113
- val bundleDir = createBundleDir(rootDir, "bundle-nested")
114
- val expectedBundleFile = writeFile(bundleDir, "dist/foo.android.bundle")
115
-
116
- writeManifest(bundleDir, listOf("dist/foo.android.bundle"))
117
-
118
- assertResolvedBundlePath(service, bundleDir, expectedBundleFile)
119
- }
120
-
121
- @Test
122
- fun `resolveBundleFile rejects a manifest with missing assets even when root index exists`() {
123
- val rootDir = temporaryFolder.newFolder("no-android-candidate")
124
- val service = createService(rootDir)
125
- val bundleDir = createBundleDir(rootDir, "bundle-no-candidate")
126
- writeFile(bundleDir, "index.android.bundle")
127
-
128
- writeManifest(bundleDir, listOf("index.ios.bundle", "assets/image.png"))
129
-
130
- assertNull(invokeResolveBundleFile(service, bundleDir))
131
- }
132
-
133
- @Test
134
- fun `resolveBundleFile rejects a manifest with multiple android bundle candidates`() {
135
- val rootDir = temporaryFolder.newFolder("multiple-android-candidates")
136
- val service = createService(rootDir)
137
- val bundleDir = createBundleDir(rootDir, "bundle-multiple-candidates")
138
- writeFile(bundleDir, "index.android.bundle")
139
-
140
- writeFile(bundleDir, "foo.android.bundle")
141
- writeFile(bundleDir, "dist/bar.android.bundle")
142
- writeManifest(bundleDir, listOf("foo.android.bundle", "dist/bar.android.bundle"))
143
-
144
- assertNull(invokeResolveBundleFile(service, bundleDir))
145
- }
146
-
147
- @Test
148
- fun `resolveBundleFile returns null when manifest escapes root and no fallback exists`() {
149
- val rootDir = temporaryFolder.newFolder("escaped-manifest-path")
150
- val service = createService(rootDir)
151
- val bundleDir = createBundleDir(rootDir, "bundle-escaped-path")
152
-
153
- writeFile(bundleStoreDir(rootDir), "outside.android.bundle")
154
- writeManifest(bundleDir, listOf("../outside.android.bundle"))
155
-
156
- assertNull(invokeResolveBundleFile(service, bundleDir))
157
- }
158
-
159
- @Test
160
- fun `resolveBundleFile returns null when manifest target is missing and no fallback exists`() {
161
- val rootDir = temporaryFolder.newFolder("missing-manifest-target")
162
- val service = createService(rootDir)
163
- val bundleDir = createBundleDir(rootDir, "bundle-missing-target")
164
-
165
- writeManifest(bundleDir, listOf("dist/missing.android.bundle"))
166
-
167
- assertNull(invokeResolveBundleFile(service, bundleDir))
168
- }
169
-
170
- @Test
171
- fun `resolveBundleFile rejects root index without manifest`() {
172
- val rootDir = temporaryFolder.newFolder("legacy-root-index")
173
- val service = createService(rootDir)
174
- val bundleDir = createBundleDir(rootDir, "bundle-legacy")
175
- writeFile(bundleDir, "index.android.bundle")
176
-
177
- assertNull(invokeResolveBundleFile(service, bundleDir))
178
- }
179
-
180
- @Test
181
- fun `resolveBundleFile returns null when manifest and root index are both missing`() {
182
- val rootDir = temporaryFolder.newFolder("missing-everything")
183
- val service = createService(rootDir)
184
- val bundleDir = createBundleDir(rootDir, "bundle-invalid")
185
-
186
- assertNull(invokeResolveBundleFile(service, bundleDir))
187
- }
188
-
189
- @Test
190
- fun `prepareLaunch rolls back invalid staging and selects stable bundle`() {
191
- val rootDir = temporaryFolder.newFolder("rollback-to-stable")
192
- val preferences = InMemoryPreferencesService()
193
- val service = createService(rootDir, preferences)
194
-
195
- val stagingDir = createBundleDir(rootDir, "staging-bundle")
196
- writeFile(stagingDir, "dist/staging.android.bundle")
197
- writeManifest(
198
- stagingDir,
199
- listOf(
200
- "dist/staging.android.bundle",
201
- "assets/missing.png",
202
- ),
203
- )
204
-
205
- val stableDir = createBundleDir(rootDir, "stable-bundle")
206
- val stableBundleFile = writeFile(stableDir, "dist/stable.android.bundle")
207
- writeManifest(stableDir, listOf("dist/stable.android.bundle"))
208
-
209
- writeMetadata(
210
- rootDir,
211
- BundleMetadata(
212
- isolationKey = TEST_ISOLATION_KEY,
213
- stableBundleId = stableDir.name,
214
- stagingBundleId = stagingDir.name,
215
- verificationPending = true,
216
- ),
217
- )
218
-
219
- val selection = service.prepareLaunch(null)
220
- val report = service.notifyAppReady()
221
-
222
- assertEquals(stableBundleFile.absolutePath, selection.bundleUrl)
223
- assertEquals(stableDir.name, selection.launchedBundleId)
224
- assertFalse(selection.shouldRollbackOnCrash)
225
- assertFalse(stagingDir.exists())
226
- assertEquals("RECOVERED", report["status"])
227
- assertEquals(stagingDir.name, report["fromBundleId"])
228
- assertEquals(stableDir.name, report["toBundleId"])
229
- assertEquals("appVersion", report["updateStrategy"])
230
-
231
- val metadata = loadMetadata(rootDir)
232
- assertNotNull(metadata)
233
- assertEquals(stableDir.name, metadata?.stagingBundleId)
234
- assertNull(metadata?.stableBundleId)
235
- assertFalse(metadata?.verificationPending ?: true)
236
- assertEquals(stableBundleFile.absolutePath, preferences.getItem("HotUpdaterBundleURL"))
237
- }
238
-
239
- @Test
240
- fun `prepareLaunch falls back to built in bundle when staging and stable are both invalid`() {
241
- val rootDir = temporaryFolder.newFolder("fallback-to-built-in")
242
- val service = createService(rootDir)
243
-
244
- val stagingDir = createBundleDir(rootDir, "staging-bundle")
245
- writeManifest(stagingDir, listOf("dist/missing.android.bundle"))
246
-
247
- val stableDir = createBundleDir(rootDir, "stable-bundle")
248
- writeManifest(stableDir, listOf("../outside.android.bundle"))
249
-
250
- writeMetadata(
251
- rootDir,
252
- BundleMetadata(
253
- isolationKey = TEST_ISOLATION_KEY,
254
- stableBundleId = stableDir.name,
255
- stagingBundleId = stagingDir.name,
256
- verificationPending = true,
257
- ),
258
- )
259
-
260
- val selection = service.prepareLaunch(null)
261
- val report = service.notifyAppReady()
262
-
263
- assertEquals("assets://index.android.bundle", selection.bundleUrl)
264
- assertNull(selection.launchedBundleId)
265
- assertFalse(selection.shouldRollbackOnCrash)
266
- assertFalse(stagingDir.exists())
267
- assertEquals("RECOVERED", report["status"])
268
- assertEquals(stagingDir.name, report["fromBundleId"])
269
- assertEquals(HotUpdaterImpl.getMinBundleId(), report["toBundleId"])
270
- assertEquals("appVersion", report["updateStrategy"])
271
- }
272
-
273
- @Test
274
- fun `getBundleId falls back to built in while staging verification is pending`() {
275
- val rootDir = temporaryFolder.newFolder("pending-staging-built-in")
276
- val service = createService(rootDir)
277
-
278
- val stagingDir = createBundleDir(rootDir, "staging-bundle")
279
- writeFile(stagingDir, "index.android.bundle")
280
- writeManifest(stagingDir, listOf("index.android.bundle"))
281
-
282
- writeMetadata(
283
- rootDir,
284
- BundleMetadata(
285
- isolationKey = TEST_ISOLATION_KEY,
286
- stableBundleId = null,
287
- stagingBundleId = stagingDir.name,
288
- verificationPending = true,
289
- ),
290
- )
291
-
292
- assertNull(service.getBundleId())
293
- assertEquals("", service.getBaseURL())
294
- assertTrue(service.getManifest().isEmpty())
295
- }
296
-
297
- @Test
298
- fun `getBundleId returns launched staging bundle while verification is pending`() {
299
- val rootDir = temporaryFolder.newFolder("pending-staging-active")
300
- val preferences = InMemoryPreferencesService()
301
- val service = createService(rootDir, preferences)
302
-
303
- val stagingDir = createBundleDir(rootDir, "staging-bundle")
304
- val stagingBundleFile = writeFile(stagingDir, "index.android.bundle")
305
- writeManifest(stagingDir, listOf("index.android.bundle"))
306
-
307
- writeMetadata(
308
- rootDir,
309
- BundleMetadata(
310
- isolationKey = TEST_ISOLATION_KEY,
311
- stableBundleId = null,
312
- stagingBundleId = stagingDir.name,
313
- verificationPending = true,
314
- ),
315
- )
316
-
317
- preferences.setItem("HotUpdaterBundleURL", stagingBundleFile.absolutePath)
318
-
319
- assertEquals(stagingDir.name, service.getBundleId())
320
- }
321
-
322
- @Test
323
- fun `markLaunchCompleted records update applied transition`() {
324
- val rootDir = temporaryFolder.newFolder("update-applied-transition")
325
- val service = createService(rootDir)
326
-
327
- val stableDir = createBundleDir(rootDir, "stable-bundle")
328
- writeFile(stableDir, "index.android.bundle")
329
- writeManifest(stableDir, listOf("index.android.bundle"))
330
-
331
- val stagingDir = createBundleDir(rootDir, "staging-bundle")
332
- writeFile(stagingDir, "index.android.bundle")
333
- writeManifest(stagingDir, listOf("index.android.bundle"))
334
-
335
- writeMetadata(
336
- rootDir,
337
- BundleMetadata(
338
- isolationKey = TEST_ISOLATION_KEY,
339
- stableBundleId = stableDir.name,
340
- stagingBundleId = stagingDir.name,
341
- verificationPending = true,
342
- ),
343
- )
344
-
345
- assertEquals(mapOf("status" to "PENDING"), service.notifyAppReady())
346
-
347
- service.markLaunchCompleted(stagingDir.name)
348
-
349
- assertEquals(
350
- mapOf(
351
- "status" to "UPDATE_APPLIED",
352
- "fromBundleId" to stableDir.name,
353
- "toBundleId" to stagingDir.name,
354
- "updateStrategy" to "appVersion",
355
- ),
356
- service.notifyAppReady(),
357
- )
358
- }
359
-
360
- @Test
361
- fun `manifest driven install reuses matching built in asset before first OTA`() =
362
- runBlocking {
363
- val rootDir = temporaryFolder.newFolder("first-ota-manifest-install")
364
- val bundleContent = "target-bundle"
365
- val imageContent = "target-image"
366
- val assets =
367
- linkedMapOf(
368
- "index.android.bundle" to sha256(rootDir, bundleContent),
369
- "assets/image.png" to sha256(rootDir, imageContent),
370
- )
371
- val manifest = manifestJson("target-bundle", assets)
372
- val downloads =
373
- MappingDownloadService(
374
- mapOf(
375
- "https://example.com/manifest.json" to manifest,
376
- "https://example.com/index.android.bundle" to bundleContent,
377
- ),
378
- )
379
- val service =
380
- createService(
381
- rootDir,
382
- downloadService = downloads,
383
- builtInAssetResolver =
384
- MappingBuiltInAssetResolver(
385
- mapOf("assets/image.png" to imageContent),
386
- ),
387
- )
388
-
389
- val progress = CopyOnWriteArrayList<UpdateProgressPayload>()
390
- service.updateBundle(
391
- bundleId = "target-bundle",
392
- manifestUrl = "https://example.com/manifest.json",
393
- manifestFileHash = sha256(rootDir, manifest),
394
- assets =
395
- assets.mapValues { (path, hash) ->
396
- ChangedAssetDescriptor(
397
- fileUrl = "https://example.com/$path",
398
- fileHash = hash,
399
- )
400
- },
401
- progressCallback = { progress.add(it) },
402
- )
403
-
404
- assertEquals(1, progress.last().details?.totalFilesCount)
405
- assertEquals(
406
- listOf("index.android.bundle"),
407
- progress
408
- .last()
409
- .details
410
- ?.files
411
- ?.map { it.path },
412
- )
413
- assertTrue(progress.any { it.progress in 0.15..0.2 && it.details?.totalFilesCount == 0 })
414
- val targetDir = File(bundleStoreDir(rootDir), "target-bundle")
415
- assertEquals(bundleContent, File(targetDir, "index.android.bundle").readText())
416
- assertEquals(imageContent, File(targetDir, "assets/image.png").readText())
417
- assertEquals("target-bundle", loadMetadata(rootDir)?.stagingBundleId)
418
- assertEquals(
419
- listOf(
420
- "https://example.com/manifest.json",
421
- "https://example.com/index.android.bundle",
422
- ),
423
- downloads.calls,
424
- )
425
- }
426
-
427
- @Test
428
- fun `manifest driven install downloads original when matching current asset is corrupt`() =
429
- runBlocking {
430
- val rootDir = temporaryFolder.newFolder("corrupt-current-asset")
431
- val preferences = InMemoryPreferencesService()
432
- val targetContent = "verified-target-bundle"
433
- val targetHash = sha256(rootDir, targetContent)
434
- val activeDir = createBundleDir(rootDir, "active-bundle")
435
- val activeBundleFile = writeFile(activeDir, "index.android.bundle", "corrupt")
436
- File(activeDir, "manifest.json").writeText(
437
- manifestJson("active-bundle", mapOf("index.android.bundle" to targetHash)),
438
- )
439
- preferences.setItem("HotUpdaterBundleURL", activeBundleFile.absolutePath)
440
-
441
- val targetManifest =
442
- manifestJson("target-bundle", mapOf("index.android.bundle" to targetHash))
443
- val downloads =
444
- MappingDownloadService(
445
- mapOf(
446
- "https://example.com/manifest.json" to targetManifest,
447
- "https://example.com/index.android.bundle" to targetContent,
448
- ),
449
- )
450
- val service = createService(rootDir, preferences, downloads)
451
-
452
- service.updateBundle(
453
- bundleId = "target-bundle",
454
- manifestUrl = "https://example.com/manifest.json",
455
- manifestFileHash = sha256(rootDir, targetManifest),
456
- assets =
457
- mapOf(
458
- "index.android.bundle" to
459
- ChangedAssetDescriptor(
460
- fileUrl = "https://example.com/index.android.bundle",
461
- fileHash = targetHash,
462
- ),
463
- ),
464
- progressCallback = {},
465
- )
466
-
467
- assertEquals(
468
- targetContent,
469
- File(bundleStoreDir(rootDir), "target-bundle/index.android.bundle").readText(),
470
- )
471
- assertTrue(downloads.calls.contains("https://example.com/index.android.bundle"))
472
- }
473
-
474
- @Test
475
- fun `catalog high water rejects replay and survives channel reset`() =
476
- runBlocking {
477
- val rootDir = temporaryFolder.newFolder("release-high-water")
478
- val service = createService(rootDir)
479
-
480
- assertTrue(
481
- service.acceptReleaseCatalog(
482
- catalogId = "project-a",
483
- scopeKey = "scope-production",
484
- generation = 2,
485
- catalogHash = "hash-2",
486
- channel = "production",
487
- selectionContextHash = "context-2",
488
- ),
489
- )
490
- assertFalse(
491
- service.acceptReleaseCatalog(
492
- catalogId = "project-a",
493
- scopeKey = "scope-production",
494
- generation = 1,
495
- catalogHash = "hash-1",
496
- channel = "production",
497
- selectionContextHash = "context-1",
498
- ),
499
- )
500
- assertFalse(
501
- service.acceptReleaseCatalog(
502
- catalogId = "project-a",
503
- scopeKey = "scope-production",
504
- generation = 2,
505
- catalogHash = "different-hash",
506
- channel = "production",
507
- selectionContextHash = "context-2",
508
- ),
509
- )
510
-
511
- assertTrue(service.resetChannel())
512
- val metadata = loadMetadata(rootDir)
513
- assertEquals(
514
- CatalogHighWater(generation = 2, catalogHash = "hash-2"),
515
- metadata?.highestSeenCatalogs?.get("project-a|scope-production"),
516
- )
517
- }
518
-
519
- @Test
520
- fun `same bundle adoption refreshes its receipt without changing bytes`() =
521
- runBlocking {
522
- val rootDir = temporaryFolder.newFolder("same-bundle-adoption")
523
- val service = createService(rootDir)
524
- val bundleDir = createBundleDir(rootDir, "bundle-one")
525
- writeFile(bundleDir, "index.android.bundle")
526
- writeManifest(bundleDir, listOf("index.android.bundle"))
527
- val oldSelection =
528
- releaseSelection(
529
- releaseId = "release-one",
530
- bundleId = bundleDir.name,
531
- generation = 1,
532
- catalogHash = "hash-1",
533
- selectionContextHash = "context-1",
534
- )
535
- writeMetadata(
536
- rootDir,
537
- BundleMetadata(
538
- isolationKey = TEST_ISOLATION_KEY,
539
- stagingBundleId = bundleDir.name,
540
- stagingSelection = oldSelection,
541
- ),
542
- )
543
- assertTrue(
544
- service.acceptReleaseCatalog(
545
- catalogId = "project-a",
546
- scopeKey = "scope-production",
547
- generation = 2,
548
- catalogHash = "hash-2",
549
- channel = "production",
550
- selectionContextHash = "context-2",
551
- ),
552
- )
553
- val newSelection =
554
- releaseSelection(
555
- releaseId = "release-two",
556
- bundleId = bundleDir.name,
557
- generation = 2,
558
- catalogHash = "hash-2",
559
- selectionContextHash = "context-2",
560
- )
561
-
562
- assertTrue(service.commitReleaseSelection(newSelection))
563
-
564
- assertTrue(bundleDir.isDirectory)
565
- assertEquals(newSelection, loadMetadata(rootDir)?.stagingSelection)
566
- assertFalse(loadMetadata(rootDir)?.verificationPending ?: true)
567
- }
568
-
569
- @Test
570
- fun `crash restores the complete stable receipt but retains newer high water`() {
571
- val rootDir = temporaryFolder.newFolder("release-crash-ledger")
572
- val service = createService(rootDir)
573
- val stableDir = createBundleDir(rootDir, "bundle-one")
574
- writeFile(stableDir, "index.android.bundle")
575
- writeManifest(stableDir, listOf("index.android.bundle"))
576
- val stagingDir = createBundleDir(rootDir, "bundle-two")
577
- writeFile(stagingDir, "index.android.bundle")
578
- writeManifest(stagingDir, listOf("index.android.bundle"))
579
- val stableSelection =
580
- releaseSelection("release-one", stableDir.name, 1, "hash-1", "context-1")
581
- val stagingSelection =
582
- releaseSelection("release-two", stagingDir.name, 2, "hash-2", "context-2")
583
- writeMetadata(
584
- rootDir,
585
- BundleMetadata(
586
- isolationKey = TEST_ISOLATION_KEY,
587
- stableBundleId = stableDir.name,
588
- stagingBundleId = stagingDir.name,
589
- stableSelection = stableSelection,
590
- stagingSelection = stagingSelection,
591
- pendingUpdateStrategy = "appVersion",
592
- pendingTransition =
593
- PendingSelectionTransition(
594
- fromReleaseId = stableSelection.releaseId,
595
- fromBundleId = stableSelection.bundleId,
596
- toReleaseId = stagingSelection.releaseId,
597
- toBundleId = stagingSelection.bundleId,
598
- ),
599
- verificationPending = true,
600
- highestSeenCatalogs =
601
- mapOf(
602
- "project-a|scope-production" to
603
- CatalogHighWater(generation = 2, catalogHash = "hash-2"),
604
- ),
605
- currentSelectionContexts =
606
- mapOf("project-a|scope-production" to "production\ncontext-2"),
607
- ),
608
- )
609
-
610
- val launch =
611
- service.prepareLaunch(
612
- PendingCrashRecovery(
613
- launchedBundleId = stagingDir.name,
614
- shouldRollback = true,
615
- ),
616
- )
617
- val report = service.notifyAppReady()
618
- val metadata = loadMetadata(rootDir)
619
-
620
- assertEquals(stableDir.name, launch.launchedBundleId)
621
- assertEquals("RECOVERED", report["status"])
622
- assertEquals("release-two", report["fromReleaseId"])
623
- assertEquals("release-one", report["toReleaseId"])
624
- assertEquals(stableSelection, metadata?.stagingSelection)
625
- assertEquals(
626
- CatalogHighWater(generation = 2, catalogHash = "hash-2"),
627
- metadata?.highestSeenCatalogs?.get("project-a|scope-production"),
628
- )
629
- assertTrue(service.getCrashHistory().contains(stagingDir.name))
630
- }
631
-
632
- @Test
633
- fun `manifest driven install moves blocking work off caller dispatcher`() {
634
- val rootDir = temporaryFolder.newFolder("manifest-install-dispatcher")
635
- val preferences = InMemoryPreferencesService()
636
- val downloadService = RecordingFailedDownloadService()
637
- val service = createService(rootDir, preferences, downloadService)
638
- val activeDir = createBundleDir(rootDir, "active-bundle")
639
- val activeBundleFile = writeFile(activeDir, "index.android.bundle")
640
- writeManifest(activeDir, listOf("index.android.bundle"))
641
-
642
- preferences.setItem("HotUpdaterBundleURL", activeBundleFile.absolutePath)
643
-
644
- Executors
645
- .newSingleThreadExecutor { runnable -> Thread(runnable, "manifest-caller") }
646
- .asCoroutineDispatcher()
647
- .use { callerDispatcher ->
648
- runBlocking(callerDispatcher) {
649
- val result =
650
- runCatching {
651
- service.updateBundle(
652
- bundleId = "target-bundle",
653
- manifestUrl = "https://example.com/manifest.json",
654
- manifestFileHash = "manifest-hash",
655
- assets = emptyMap(),
656
- progressCallback = {},
657
- )
658
- }
659
- assertTrue(result.exceptionOrNull() is HotUpdaterException)
660
- }
661
- }
662
-
663
- val manifestCall = downloadService.calls.first()
664
- assertEquals("https://example.com/manifest.json", manifestCall.first)
665
- assertFalse(
666
- "Manifest install ran on the caller dispatcher: ${manifestCall.second}",
667
- manifestCall.second.contains("manifest-caller"),
668
- )
669
- }
670
-
671
- @Test
672
- fun `reused bytes cannot bypass mismatched descriptor contracts`() =
673
- runBlocking {
674
- for (kind in listOf("missing", "mismatch", "extra", "missing-original")) {
675
- val root = temporaryFolder.newFolder(kind)
676
- val hash = sha256(root, "target")
677
- val manifest = manifestJson("target", mapOf("index.android.bundle" to hash))
678
- val downloads = MappingDownloadService(mapOf("https://example.com/manifest.json" to manifest))
679
- val service =
680
- createService(
681
- root,
682
- downloadService = downloads,
683
- builtInAssetResolver = MappingBuiltInAssetResolver(mapOf("index.android.bundle" to "target")),
684
- )
685
- val descriptors = mutableMapOf("index.android.bundle" to ChangedAssetDescriptor("https://example.com/bundle", hash))
686
- when (kind) {
687
- "missing" -> {
688
- descriptors.clear()
689
- }
690
-
691
- "mismatch" -> {
692
- descriptors["index.android.bundle"] = ChangedAssetDescriptor("https://example.com/bundle", "wrong")
693
- }
694
-
695
- "extra" -> {
696
- descriptors["extra.png"] = ChangedAssetDescriptor("https://example.com/extra", hash)
697
- }
698
-
699
- "missing-original" -> {
700
- descriptors["index.android.bundle"] = ChangedAssetDescriptor(null, hash)
701
- }
702
- }
703
- val result =
704
- runCatching {
705
- service.updateBundle(
706
- "target",
707
- "https://example.com/manifest.json",
708
- sha256(root, manifest),
709
- descriptors,
710
- ) {}
711
- }
712
- assertTrue("Contract $kind must fail closed", result.isFailure)
713
- assertNull(loadMetadata(root)?.stagingBundleId)
714
- }
715
- }
716
-
717
- @Test
718
- fun `cached target is repaired before activation`() =
719
- runBlocking {
720
- val root = temporaryFolder.newFolder("corrupt-cached-target")
721
- val hash = sha256(root, "correct")
722
- val manifest = manifestJson("target", mapOf("index.android.bundle" to hash))
723
- val cached = createBundleDir(root, "target")
724
- writeFile(cached, "manifest.json", manifest)
725
- writeFile(cached, "index.android.bundle", "CORRUPT")
726
- val downloads =
727
- MappingDownloadService(
728
- mapOf(
729
- "https://example.com/manifest.json" to manifest,
730
- "https://example.com/bundle" to "correct",
731
- ),
732
- )
733
- val service = createService(root, downloadService = downloads, builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()))
734
- service.updateBundle(
735
- "target",
736
- "https://example.com/manifest.json",
737
- sha256(root, manifest),
738
- mapOf("index.android.bundle" to ChangedAssetDescriptor("https://example.com/bundle", hash)),
739
- ) {}
740
- assertEquals("correct", File(cached, "index.android.bundle").readText())
741
- assertEquals("target", loadMetadata(root)?.stagingBundleId)
742
- assertTrue(downloads.calls.contains("https://example.com/bundle"))
743
- }
744
-
745
- @Test
746
- fun `failed final rename restores existing bundle`() =
747
- runBlocking {
748
- val root = temporaryFolder.newFolder("failed-final-rename")
749
- val preferences = InMemoryPreferencesService()
750
- val target = createCompleteBundle(root, "target", "old")
751
- val oldBundleFile = File(target, "index.android.bundle")
752
- preferences.setItem("HotUpdaterBundleURL", oldBundleFile.absolutePath)
753
- writeMetadata(
754
- root,
755
- BundleMetadata(
756
- isolationKey = TEST_ISOLATION_KEY,
757
- stagingBundleId = "target",
758
- ),
759
- )
760
- val manifest = manifestJson("target", mapOf("index.android.bundle" to sha256(root, "new")))
761
- val downloads =
762
- MappingDownloadService(
763
- mapOf(
764
- "https://example.com/manifest.json" to manifest,
765
- "https://example.com/bundle" to "new",
766
- ),
767
- )
768
- val service =
769
- createService(
770
- root,
771
- preferences = preferences,
772
- downloadService = downloads,
773
- builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()),
774
- directoryRenamer = { source, destination ->
775
- if (source.name == "target.tmp" && destination.name == "target") {
776
- false
777
- } else {
778
- source.renameTo(destination)
779
- }
780
- },
781
- )
782
-
783
- val result =
784
- runCatching {
785
- service.updateBundle(
786
- "target",
787
- "https://example.com/manifest.json",
788
- sha256(root, manifest),
789
- mapOf(
790
- "index.android.bundle" to
791
- ChangedAssetDescriptor("https://example.com/bundle", sha256(root, "new")),
792
- ),
793
- ) {}
794
- }
795
-
796
- assertUpdateFailure(
797
- result.exceptionOrNull(),
798
- "MOVE_OPERATION_FAILED",
799
- UpdateFailure.install(UpdateFailureReason.STORAGE),
800
- )
801
- assertEquals("old", oldBundleFile.readText())
802
- assertFalse(File(bundleStoreDir(root), "target.install-backup").exists())
803
- assertEquals(oldBundleFile.absolutePath, preferences.getItem("HotUpdaterBundleURL"))
804
- assertEquals("target", loadMetadata(root)?.stagingBundleId)
805
- }
806
-
807
- @Test
808
- fun `metadata write failure restores preference and existing bundle`() =
809
- runBlocking {
810
- val root = temporaryFolder.newFolder("failed-metadata-write")
811
- val preferences = InMemoryPreferencesService()
812
- val target = createCompleteBundle(root, "target", "old")
813
- val oldBundleFile = File(target, "index.android.bundle")
814
- preferences.setItem("HotUpdaterBundleURL", oldBundleFile.absolutePath)
815
- val previousMetadata =
816
- BundleMetadata(
817
- isolationKey = TEST_ISOLATION_KEY,
818
- stagingBundleId = "target",
819
- )
820
- writeMetadata(root, previousMetadata)
821
- val newHash = sha256(root, "new")
822
- val manifest = manifestJson("target", mapOf("index.android.bundle" to newHash))
823
- val service =
824
- createService(
825
- root,
826
- preferences = preferences,
827
- downloadService =
828
- MappingDownloadService(
829
- mapOf(
830
- "https://example.com/manifest.json" to manifest,
831
- "https://example.com/bundle" to "new",
832
- ),
833
- ),
834
- builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()),
835
- metadataWriter = { _, _ -> false },
836
- )
837
-
838
- val result =
839
- runCatching {
840
- service.updateBundle(
841
- "target",
842
- "https://example.com/manifest.json",
843
- sha256(root, manifest),
844
- mapOf(
845
- "index.android.bundle" to ChangedAssetDescriptor("https://example.com/bundle", newHash),
846
- ),
847
- ) {}
848
- }
849
-
850
- assertUpdateFailure(
851
- result.exceptionOrNull(),
852
- "UNKNOWN_ERROR",
853
- UpdateFailure.install(UpdateFailureReason.STORAGE),
854
- )
855
- assertEquals("old", oldBundleFile.readText())
856
- assertFalse(File(bundleStoreDir(root), "target.install-backup").exists())
857
- assertEquals(oldBundleFile.absolutePath, preferences.getItem("HotUpdaterBundleURL"))
858
- assertEquals(previousMetadata.stagingBundleId, loadMetadata(root)?.stagingBundleId)
859
- }
860
-
861
- @Test
862
- fun `preference write failure aborts before metadata commit and restores existing bundle`() =
863
- runBlocking {
864
- val root = temporaryFolder.newFolder("failed-preference-write")
865
- val target = createCompleteBundle(root, "target", "old")
866
- val oldBundleFile = File(target, "index.android.bundle")
867
- var preferenceWrites = 0
868
- val preferences =
869
- InMemoryPreferencesService { key, _ ->
870
- if (key == "HotUpdaterBundleURL") preferenceWrites++
871
- preferenceWrites == 2
872
- }
873
- preferences.setItem("HotUpdaterBundleURL", oldBundleFile.absolutePath)
874
- writeMetadata(
875
- root,
876
- BundleMetadata(
877
- isolationKey = TEST_ISOLATION_KEY,
878
- stagingBundleId = "target",
879
- ),
880
- )
881
- val newHash = sha256(root, "new")
882
- val manifest = manifestJson("target", mapOf("index.android.bundle" to newHash))
883
- var metadataWrites = 0
884
- val service =
885
- createService(
886
- root,
887
- preferences = preferences,
888
- downloadService =
889
- MappingDownloadService(
890
- mapOf(
891
- "https://example.com/manifest.json" to manifest,
892
- "https://example.com/bundle" to "new",
893
- ),
894
- ),
895
- builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()),
896
- metadataWriter = { metadata, file ->
897
- metadataWrites++
898
- metadata.saveToFile(file)
899
- },
900
- )
901
-
902
- val result =
903
- runCatching {
904
- service.updateBundle(
905
- "target",
906
- "https://example.com/manifest.json",
907
- sha256(root, manifest),
908
- mapOf(
909
- "index.android.bundle" to ChangedAssetDescriptor("https://example.com/bundle", newHash),
910
- ),
911
- ) {}
912
- }
913
-
914
- assertUpdateFailure(
915
- result.exceptionOrNull(),
916
- "UNKNOWN_ERROR",
917
- UpdateFailure.install(UpdateFailureReason.STORAGE),
918
- )
919
- assertEquals(0, metadataWrites)
920
- assertEquals("old", oldBundleFile.readText())
921
- assertEquals(oldBundleFile.absolutePath, preferences.getItem("HotUpdaterBundleURL"))
922
- }
923
-
924
- @Test
925
- fun `startup restores interrupted promotion backup when final is absent`() {
926
- val root = temporaryFolder.newFolder("missing-final-recovery")
927
- val backup = createCompleteBundle(root, "target.install-backup", "old", manifestBundleId = "target")
928
-
929
- createService(root)
930
-
931
- val restored = File(bundleStoreDir(root), "target")
932
- assertEquals("old", File(restored, "index.android.bundle").readText())
933
- assertFalse(backup.exists())
934
- }
935
-
936
- @Test
937
- fun `startup keeps complete final when install backup also exists`() {
938
- val root = temporaryFolder.newFolder("complete-final-recovery")
939
- val final = createCompleteBundle(root, "target", "new")
940
- val backup = createCompleteBundle(root, "target.install-backup", "old", manifestBundleId = "target")
941
-
942
- createService(root)
943
-
944
- assertEquals("new", File(final, "index.android.bundle").readText())
945
- assertFalse(backup.exists())
946
- }
947
-
948
- @Test
949
- fun `retry reuses completed staging files and replaces partial files without stale assets`() =
950
- runBlocking {
951
- val root = temporaryFolder.newFolder("interrupted-retry")
952
- val hashes = mapOf("index.android.bundle" to sha256(root, "correct"), "assets/image.png" to sha256(root, "image"))
953
- val manifest = manifestJson("target", hashes)
954
- val staging = createBundleDir(root, "target.tmp")
955
- writeFile(staging, "index.android.bundle", "partial")
956
- writeFile(staging, "assets/image.png", "image")
957
- writeFile(staging, "assets/obsolete.png", "obsolete")
958
- val downloads =
959
- MappingDownloadService(
960
- mapOf(
961
- "https://example.com/manifest.json" to manifest,
962
- "https://example.com/index.android.bundle" to "correct",
963
- ),
964
- )
965
- val service = createService(root, downloadService = downloads, builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()))
966
- service.updateBundle(
967
- "target",
968
- "https://example.com/manifest.json",
969
- sha256(root, manifest),
970
- hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) },
971
- ) {}
972
- assertEquals(listOf("https://example.com/manifest.json", "https://example.com/index.android.bundle"), downloads.calls)
973
- val target = File(bundleStoreDir(root), "target")
974
- assertEquals("image", File(target, "assets/image.png").readText())
975
- assertEquals("correct", File(target, "index.android.bundle").readText())
976
- assertFalse(File(target, "assets/obsolete.png").exists())
977
- }
978
-
979
- @Test
980
- fun `independent downloads overlap with at most four active requests`() =
981
- runBlocking {
982
- val root = temporaryFolder.newFolder("parallel-originals")
983
- val paths = listOf("index.android.bundle") + (0..8).map { "assets/$it.png" }
984
- val hash = sha256(root, "content")
985
- val manifest = manifestJson("target", paths.associateWith { hash })
986
- val mapping =
987
- MappingDownloadService(
988
- mapOf("https://example.com/manifest.json" to manifest) + paths.associate { "https://example.com/$it" to "content" },
989
- )
990
- val active = AtomicInteger()
991
- val maximum = AtomicInteger()
992
- val downloader =
993
- object : DownloadService {
994
- override suspend fun downloadFile(
995
- fileUrl: URL,
996
- destination: File,
997
- fileSizeCallback: ((Long) -> Unit)?,
998
- progressCallback: (DownloadProgress) -> Unit,
999
- ): DownloadResult {
1000
- val isAsset = fileUrl.path != "/manifest.json"
1001
- if (isAsset) {
1002
- val count = active.incrementAndGet()
1003
- maximum.updateAndGet { maxOf(it, count) }
1004
- }
1005
- try {
1006
- delay(25)
1007
- return mapping.downloadFile(fileUrl, destination, fileSizeCallback, progressCallback)
1008
- } finally {
1009
- if (isAsset) active.decrementAndGet()
1010
- }
1011
- }
1012
- }
1013
- val service = createService(root, downloadService = downloader, builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()))
1014
- val progress = CopyOnWriteArrayList<UpdateProgressPayload>()
1015
- service.updateBundle(
1016
- "target",
1017
- "https://example.com/manifest.json",
1018
- sha256(root, manifest),
1019
- paths.associateWith { ChangedAssetDescriptor("https://example.com/$it", hash) },
1020
- progressCallback = progress::add,
1021
- )
1022
- assertTrue("Expected concurrent requests, observed ${maximum.get()}", maximum.get() > 1)
1023
- assertTrue("Concurrency must be bounded", maximum.get() <= 4)
1024
- assertEquals(paths.size, progress.last().details?.completedFilesCount)
1025
- }
1026
-
1027
- @Test
1028
- fun `archive installs as one actual network file at equal planned bytes`() =
1029
- runBlocking {
1030
- val root = temporaryFolder.newFolder("archive-selected")
1031
- val archiveBytes = Base64.getDecoder().decode(TarBrArchiveExtractorTest.VALID)
1032
- val contents = mapOf("assets/image.png" to "image", "index.android.bundle" to "bundle")
1033
- val hashes = contents.mapValues { sha256(root, it.value) }
1034
- val manifest =
1035
- manifestJson(
1036
- bundleId = "target",
1037
- assets = hashes,
1038
- byteSizes =
1039
- contents.mapValues {
1040
- it.value
1041
- .toByteArray()
1042
- .size
1043
- .toLong()
1044
- },
1045
- downloadByteSizes = mapOf("assets/image.png" to 50, "index.android.bundle" to 55),
1046
- archive = archiveJson(root, archiveBytes, tarByteSize = 3072),
1047
- )
1048
- val downloads =
1049
- BinaryMappingDownloadService(
1050
- mapOf(
1051
- MANIFEST_URL to manifest.toByteArray(),
1052
- ARCHIVE_URL to archiveBytes,
1053
- ),
1054
- )
1055
- val service = createService(root, downloadService = downloads, builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()))
1056
- val progress = CopyOnWriteArrayList<UpdateProgressPayload>()
1057
-
1058
- service.updateBundle(
1059
- bundleId = "target",
1060
- manifestUrl = MANIFEST_URL,
1061
- manifestFileHash = sha256(root, manifest),
1062
- assets = hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) },
1063
- archiveUrl = ARCHIVE_URL,
1064
- progressCallback = progress::add,
1065
- )
1066
-
1067
- assertEquals(listOf(MANIFEST_URL, ARCHIVE_URL), downloads.calls)
1068
- assertEquals(
1069
- listOf("bundle.tar.br"),
1070
- progress
1071
- .last()
1072
- .details
1073
- ?.files
1074
- ?.map { it.path },
1075
- )
1076
- assertEquals(
1077
- archiveBytes.size.toLong(),
1078
- progress
1079
- .last()
1080
- .details
1081
- ?.files
1082
- ?.single()
1083
- ?.downloadedBytes,
1084
- )
1085
- val installed = File(bundleStoreDir(root), "target")
1086
- assertEquals("bundle", File(installed, "index.android.bundle").readText())
1087
- assertEquals("image", File(installed, "assets/image.png").readText())
1088
- val installedManifest = JSONObject(File(installed, "manifest.json").readText())
1089
- assertEquals(archiveBytes.size.toLong(), installedManifest.getJSONObject("archive").getLong("downloadByteSize"))
1090
- assertEquals(6, installedManifest.getJSONObject("assets").getJSONObject("index.android.bundle").getLong("byteSize"))
1091
- }
1092
-
1093
- @Test
1094
- fun `full network plan accepts only bounded TAR framing overhead`() =
1095
- runBlocking {
1096
- val contents = mapOf("assets/image.png" to "image", "index.android.bundle" to "bundle")
1097
- val logicalBytes = contents.values.sumOf { it.toByteArray().size.toLong() }
1098
- val archiveBytes = Base64.getDecoder().decode(TarBrArchiveExtractorTest.VALID)
1099
- val individualBytes = archiveBytes.size.toLong() - 1
1100
- val downloadByteSizes =
1101
- mapOf(
1102
- "assets/image.png" to individualBytes / 2,
1103
- "index.android.bundle" to individualBytes - individualBytes / 2,
1104
- )
1105
-
1106
- val selectedRoot = temporaryFolder.newFolder("archive-framing-selected")
1107
- val selectedHashes = contents.mapValues { sha256(selectedRoot, it.value) }
1108
- val selectedManifest =
1109
- manifestJson(
1110
- bundleId = "target",
1111
- assets = selectedHashes,
1112
- byteSizes =
1113
- contents.mapValues {
1114
- it.value
1115
- .toByteArray()
1116
- .size
1117
- .toLong()
1118
- },
1119
- downloadByteSizes = downloadByteSizes,
1120
- archive = archiveJson(selectedRoot, archiveBytes, tarByteSize = 3072),
1121
- )
1122
- val selectedDownloads =
1123
- BinaryMappingDownloadService(
1124
- mapOf(MANIFEST_URL to selectedManifest.toByteArray(), ARCHIVE_URL to archiveBytes),
1125
- )
1126
- createService(
1127
- selectedRoot,
1128
- downloadService = selectedDownloads,
1129
- builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()),
1130
- ).updateBundle(
1131
- bundleId = "target",
1132
- manifestUrl = MANIFEST_URL,
1133
- manifestFileHash = sha256(selectedRoot, selectedManifest),
1134
- assets = selectedHashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) },
1135
- archiveUrl = ARCHIVE_URL,
1136
- progressCallback = {},
1137
- )
1138
- assertEquals(listOf(MANIFEST_URL, ARCHIVE_URL), selectedDownloads.calls)
1139
-
1140
- val declinedRoot = temporaryFolder.newFolder("archive-framing-declined")
1141
- val declinedHashes = contents.mapValues { sha256(declinedRoot, it.value) }
1142
- val minimumTarBytes = 1024L
1143
- val firstByteOutsideBound = individualBytes + (minimumTarBytes - logicalBytes) + 1
1144
- val declinedManifest =
1145
- manifestJson(
1146
- bundleId = "target",
1147
- assets = declinedHashes,
1148
- byteSizes =
1149
- contents.mapValues {
1150
- it.value
1151
- .toByteArray()
1152
- .size
1153
- .toLong()
1154
- },
1155
- downloadByteSizes = downloadByteSizes,
1156
- archive =
1157
- archiveJson(declinedRoot, archiveBytes, tarByteSize = minimumTarBytes)
1158
- .put("downloadByteSize", firstByteOutsideBound),
1159
- )
1160
- val declinedDownloads =
1161
- BinaryMappingDownloadService(
1162
- mapOf(MANIFEST_URL to declinedManifest.toByteArray()) +
1163
- contents.mapKeys { "https://example.com/${it.key}" }.mapValues { it.value.toByteArray() },
1164
- )
1165
- createService(
1166
- declinedRoot,
1167
- downloadService = declinedDownloads,
1168
- builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()),
1169
- ).updateBundle(
1170
- bundleId = "target",
1171
- manifestUrl = MANIFEST_URL,
1172
- manifestFileHash = sha256(declinedRoot, declinedManifest),
1173
- assets = declinedHashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) },
1174
- archiveUrl = ARCHIVE_URL,
1175
- progressCallback = {},
1176
- )
1177
- assertFalse(declinedDownloads.calls.contains(ARCHIVE_URL))
1178
- }
1179
-
1180
- @Test
1181
- fun `corrupt archive is discarded before exactly one ordinary file pass`() =
1182
- runBlocking {
1183
- val root = temporaryFolder.newFolder("archive-fallback")
1184
- val archiveBytes = Base64.getDecoder().decode(TarBrArchiveExtractorTest.VALID)
1185
- val corruptArchive = archiveBytes.clone().apply { this[lastIndex] = (this[lastIndex].toInt() xor 1).toByte() }
1186
- val contents = mapOf("assets/image.png" to "image", "index.android.bundle" to "bundle")
1187
- val hashes = contents.mapValues { sha256(root, it.value) }
1188
- val manifest =
1189
- manifestJson(
1190
- bundleId = "target",
1191
- assets = hashes,
1192
- byteSizes =
1193
- contents.mapValues {
1194
- it.value
1195
- .toByteArray()
1196
- .size
1197
- .toLong()
1198
- },
1199
- downloadByteSizes = contents.mapValues { 100L },
1200
- archive = archiveJson(root, archiveBytes, tarByteSize = 3072),
1201
- )
1202
- val downloads =
1203
- BinaryMappingDownloadService(
1204
- mapOf(MANIFEST_URL to manifest.toByteArray(), ARCHIVE_URL to corruptArchive) +
1205
- contents.mapKeys { "https://example.com/${it.key}" }.mapValues { it.value.toByteArray() },
1206
- )
1207
- val service = createService(root, downloadService = downloads, builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()))
1208
- val progress = CopyOnWriteArrayList<UpdateProgressPayload>()
1209
-
1210
- service.updateBundle(
1211
- bundleId = "target",
1212
- manifestUrl = MANIFEST_URL,
1213
- manifestFileHash = sha256(root, manifest),
1214
- assets = hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) },
1215
- archiveUrl = ARCHIVE_URL,
1216
- progressCallback = progress::add,
1217
- )
1218
-
1219
- assertEquals(1, downloads.calls.count { it == ARCHIVE_URL })
1220
- assertEquals(1, downloads.calls.count { it == "https://example.com/index.android.bundle" })
1221
- assertEquals(1, downloads.calls.count { it == "https://example.com/assets/image.png" })
1222
- assertEquals(
1223
- hashes.keys.sorted(),
1224
- progress
1225
- .last()
1226
- .details
1227
- ?.files
1228
- ?.map { it.path },
1229
- )
1230
- assertFalse(File(bundleStoreDir(root), "target.archive.tmp").exists())
1231
- assertFalse(File(bundleStoreDir(root), "target.local.tmp").exists())
1232
- }
1233
-
1234
- @Test
1235
- fun `partial local reuse keeps strict cost comparison with two network files`() =
1236
- runBlocking {
1237
- val root = temporaryFolder.newFolder("archive-one-remaining")
1238
- val archiveBytes = Base64.getDecoder().decode(TarBrArchiveExtractorTest.VALID)
1239
- val contents =
1240
- mapOf(
1241
- "assets/image.png" to "image",
1242
- "assets/other.png" to "other",
1243
- "index.android.bundle" to "bundle",
1244
- )
1245
- val hashes = contents.mapValues { sha256(root, it.value) }
1246
- val manifest =
1247
- manifestJson(
1248
- bundleId = "target",
1249
- assets = hashes,
1250
- byteSizes =
1251
- contents.mapValues {
1252
- it.value
1253
- .toByteArray()
1254
- .size
1255
- .toLong()
1256
- },
1257
- downloadByteSizes = contents.mapValues { 1L },
1258
- archive = archiveJson(root, archiveBytes, tarByteSize = 3072),
1259
- )
1260
- val bundleUrl = "https://example.com/index.android.bundle"
1261
- val otherUrl = "https://example.com/assets/other.png"
1262
- val downloads =
1263
- BinaryMappingDownloadService(
1264
- mapOf(
1265
- MANIFEST_URL to manifest.toByteArray(),
1266
- bundleUrl to "bundle".toByteArray(),
1267
- otherUrl to "other".toByteArray(),
1268
- ),
1269
- )
1270
- val service =
1271
- createService(
1272
- rootDir = root,
1273
- downloadService = downloads,
1274
- builtInAssetResolver = MappingBuiltInAssetResolver(mapOf("assets/image.png" to "image")),
1275
- )
1276
-
1277
- service.updateBundle(
1278
- bundleId = "target",
1279
- manifestUrl = MANIFEST_URL,
1280
- manifestFileHash = sha256(root, manifest),
1281
- assets = hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) },
1282
- archiveUrl = ARCHIVE_URL,
1283
- progressCallback = {},
1284
- )
1285
-
1286
- assertFalse(downloads.calls.contains(ARCHIVE_URL))
1287
- assertEquals(1, downloads.calls.count { it == bundleUrl })
1288
- assertEquals(1, downloads.calls.count { it == otherUrl })
1289
- }
1290
-
1291
- @Test
1292
- fun `archive selection declines incomplete expensive and overflowing cost plans`() =
1293
- runBlocking {
1294
- data class CostCase(
1295
- val name: String,
1296
- val downloadSizes: Map<String, Long>,
1297
- val archiveSize: Long,
1298
- val tarSize: Long = 3072,
1299
- val logicalSizes: (Map<String, String>) -> Map<String, Long> = { contents ->
1300
- contents.mapValues {
1301
- it.value
1302
- .toByteArray()
1303
- .size
1304
- .toLong()
1305
- }
1306
- },
1307
- val descriptors: (Map<String, String>) -> Map<String, ChangedAssetDescriptor>,
1308
- )
1309
-
1310
- val cases =
1311
- listOf(
1312
- CostCase("missing", mapOf("index.android.bundle" to 100), 1) { hashes ->
1313
- hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) }
1314
- },
1315
- CostCase("larger", mapOf("index.android.bundle" to 50, "assets/image.png" to 50), 4000) { hashes ->
1316
- hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) }
1317
- },
1318
- CostCase("zero-archive", mapOf("index.android.bundle" to 100, "assets/image.png" to 100), 0) { hashes ->
1319
- hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) }
1320
- },
1321
- CostCase("short-tar", mapOf("index.android.bundle" to 100, "assets/image.png" to 100), 1, tarSize = 1023) { hashes ->
1322
- hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) }
1323
- },
1324
- CostCase(
1325
- "negative-framing",
1326
- mapOf("index.android.bundle" to 100, "assets/image.png" to 100),
1327
- 1,
1328
- tarSize = 1024,
1329
- logicalSizes = { contents -> contents.mapValues { 600L } },
1330
- ) { hashes ->
1331
- hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) }
1332
- },
1333
- CostCase(
1334
- "logical-overflow",
1335
- mapOf("index.android.bundle" to 100, "assets/image.png" to 100),
1336
- 1,
1337
- tarSize = 9_007_199_254_740_991,
1338
- logicalSizes = {
1339
- mapOf(
1340
- "index.android.bundle" to 9_007_199_254_740_991,
1341
- "assets/image.png" to 1,
1342
- )
1343
- },
1344
- ) { hashes ->
1345
- hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) }
1346
- },
1347
- CostCase(
1348
- "framed-limit-overflow",
1349
- mapOf("index.android.bundle" to 9_007_199_254_740_991, "assets/image.png" to 0),
1350
- 1,
1351
- ) { hashes ->
1352
- hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) }
1353
- },
1354
- CostCase("unknown-patch", mapOf("index.android.bundle" to 100, "assets/image.png" to 100), 1) { hashes ->
1355
- hashes.mapValues { (path, hash) ->
1356
- ChangedAssetDescriptor(
1357
- fileUrl = "https://example.com/$path",
1358
- fileHash = hash,
1359
- patch =
1360
- if (path == "index.android.bundle") {
1361
- BsdiffPatchDescriptor("bsdiff", "base", "a", "b", "https://example.com/patch")
1362
- } else {
1363
- null
1364
- },
1365
- )
1366
- }
1367
- },
1368
- CostCase("known-patch", mapOf("index.android.bundle" to 100, "assets/image.png" to 1), 3) { hashes ->
1369
- hashes.mapValues { (path, hash) ->
1370
- ChangedAssetDescriptor(
1371
- fileUrl = "https://example.com/$path",
1372
- fileHash = hash,
1373
- patch =
1374
- if (path == "index.android.bundle") {
1375
- BsdiffPatchDescriptor("bsdiff", "base", "a", "b", "https://example.com/patch", byteSize = 1)
1376
- } else {
1377
- null
1378
- },
1379
- )
1380
- }
1381
- },
1382
- CostCase("negative-patch", mapOf("index.android.bundle" to 100, "assets/image.png" to 100), 1) { hashes ->
1383
- hashes.mapValues { (path, hash) ->
1384
- ChangedAssetDescriptor(
1385
- fileUrl = "https://example.com/$path",
1386
- fileHash = hash,
1387
- patch =
1388
- if (path == "index.android.bundle") {
1389
- BsdiffPatchDescriptor("bsdiff", "base", "a", "b", "https://example.com/patch", byteSize = -1)
1390
- } else {
1391
- null
1392
- },
1393
- )
1394
- }
1395
- },
1396
- CostCase("overflowing-patch", mapOf("index.android.bundle" to 100, "assets/image.png" to 100), 1) { hashes ->
1397
- hashes.mapValues { (path, hash) ->
1398
- ChangedAssetDescriptor(
1399
- fileUrl = "https://example.com/$path",
1400
- fileHash = hash,
1401
- patch =
1402
- if (path == "index.android.bundle") {
1403
- BsdiffPatchDescriptor(
1404
- "bsdiff",
1405
- "base",
1406
- "a",
1407
- "b",
1408
- "https://example.com/patch",
1409
- byteSize = Long.MAX_VALUE,
1410
- )
1411
- } else {
1412
- null
1413
- },
1414
- )
1415
- }
1416
- },
1417
- CostCase(
1418
- "overflow",
1419
- mapOf("index.android.bundle" to 9_007_199_254_740_991, "assets/image.png" to 1),
1420
- 1,
1421
- ) { hashes ->
1422
- hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) }
1423
- },
1424
- )
1425
-
1426
- for (case in cases) {
1427
- val root = temporaryFolder.newFolder("archive-decline-${case.name}")
1428
- val archiveBytes = Base64.getDecoder().decode(TarBrArchiveExtractorTest.VALID)
1429
- val contents = mapOf("assets/image.png" to "image", "index.android.bundle" to "bundle")
1430
- val hashes = contents.mapValues { sha256(root, it.value) }
1431
- val manifest =
1432
- manifestJson(
1433
- bundleId = "target",
1434
- assets = hashes,
1435
- byteSizes = case.logicalSizes(contents),
1436
- downloadByteSizes = case.downloadSizes,
1437
- archive = archiveJson(root, archiveBytes, tarByteSize = case.tarSize).put("downloadByteSize", case.archiveSize),
1438
- )
1439
- val downloads =
1440
- BinaryMappingDownloadService(
1441
- mapOf(MANIFEST_URL to manifest.toByteArray()) +
1442
- contents.mapKeys { "https://example.com/${it.key}" }.mapValues { it.value.toByteArray() },
1443
- )
1444
- val service =
1445
- createService(root, downloadService = downloads, builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()))
1446
-
1447
- service.updateBundle(
1448
- bundleId = "target",
1449
- manifestUrl = MANIFEST_URL,
1450
- manifestFileHash = sha256(root, manifest),
1451
- assets = case.descriptors(hashes),
1452
- archiveUrl = ARCHIVE_URL,
1453
- progressCallback = {},
1454
- )
1455
-
1456
- assertFalse("${case.name} must decline archive", downloads.calls.contains(ARCHIVE_URL))
1457
- }
1458
- }
1459
-
1460
- @Test
1461
- fun `download failures of the manifest or a file report network http or storage`() =
1462
- runBlocking {
1463
- val network = UpdateFailure.download(UpdateFailureReason.NETWORK)
1464
- val cases =
1465
- listOf(
1466
- Triple(HttpStatusException(404, "Not Found"), "DOWNLOAD_FAILED", UpdateFailure.http(404)),
1467
- Triple(
1468
- HttpStatusException(403, "Forbidden", originCode = "AccessDenied"),
1469
- "DOWNLOAD_FAILED",
1470
- UpdateFailure.http(403, originCode = "AccessDenied"),
1471
- ),
1472
- Triple(
1473
- UnknownHostException("example.com"),
1474
- "DOWNLOAD_FAILED",
1475
- network.copy(transport = UpdateFailureTransport.DNS),
1476
- ),
1477
- Triple(
1478
- SocketTimeoutException("timeout"),
1479
- "DOWNLOAD_FAILED",
1480
- network.copy(transport = UpdateFailureTransport.TIMEOUT),
1481
- ),
1482
- Triple(
1483
- ConnectException("Connection refused"),
1484
- "DOWNLOAD_FAILED",
1485
- network.copy(transport = UpdateFailureTransport.CONNECTION),
1486
- ),
1487
- Triple(
1488
- SSLHandshakeException("Chain validation failed"),
1489
- "DOWNLOAD_FAILED",
1490
- network.copy(transport = UpdateFailureTransport.TLS),
1491
- ),
1492
- Triple(IncompleteDownloadException(expectedSize = 10, actualSize = 4), "INCOMPLETE_DOWNLOAD", network),
1493
- Triple(
1494
- LocalStorageException(IOException("No space left on device")),
1495
- "DOWNLOAD_FAILED",
1496
- UpdateFailure.install(UpdateFailureReason.STORAGE),
1497
- ),
1498
- )
1499
- val resources = mapOf(MANIFEST_URL to UpdateFailureResource.MANIFEST, BUNDLE_URL to UpdateFailureResource.FILE)
1500
-
1501
- cases.forEachIndexed { index, (error, code, failure) ->
1502
- resources.forEach { (failingUrl, resource) ->
1503
- val root = temporaryFolder.newFolder("download-failure-$index-${resource.value}")
1504
- val result = runOneFileUpdate(root, failures = mapOf(failingUrl to error))
1505
-
1506
- assertUpdateFailure(result, code, failure.copy(resource = resource))
1507
- assertNull(loadMetadata(root)?.stagingBundleId)
1508
- }
1509
- }
1510
- }
1511
-
1512
- @Test
1513
- fun `verification failures report hash mismatch signature or invalid response`() =
1514
- runBlocking {
1515
- val hashMismatch = UpdateFailure.download(UpdateFailureReason.HASH_MISMATCH)
1516
- val hashMismatchRoot = temporaryFolder.newFolder("manifest-hash-mismatch")
1517
- assertUpdateFailure(
1518
- runOneFileUpdate(hashMismatchRoot, manifestFileHash = sha256(hashMismatchRoot, "another manifest")),
1519
- "SIGNATURE_VERIFICATION_FAILED",
1520
- hashMismatch.copy(resource = UpdateFailureResource.MANIFEST),
1521
- )
1522
-
1523
- assertUpdateFailure(
1524
- runOneFileUpdate(temporaryFolder.newFolder("file-hash-mismatch"), servedFile = "tampered".toByteArray()),
1525
- "SIGNATURE_VERIFICATION_FAILED",
1526
- hashMismatch.copy(resource = UpdateFailureResource.FILE),
1527
- )
1528
-
1529
- HotUpdaterConfig.publicKey = "configured-public-key"
1530
- try {
1531
- assertUpdateFailure(
1532
- runOneFileUpdate(temporaryFolder.newFolder("unsigned-with-public-key")),
1533
- "SIGNATURE_VERIFICATION_FAILED",
1534
- UpdateFailure.download(UpdateFailureReason.SIGNATURE).copy(resource = UpdateFailureResource.MANIFEST),
1535
- )
1536
- } finally {
1537
- HotUpdaterConfig.publicKey = null
1538
- }
1539
-
1540
- val invalidManifest =
1541
- UpdateFailure.download(UpdateFailureReason.INVALID_RESPONSE).copy(resource = UpdateFailureResource.MANIFEST)
1542
- val otherBundleRoot = temporaryFolder.newFolder("manifest-for-other-bundle")
1543
- assertUpdateFailure(
1544
- runOneFileUpdate(
1545
- otherBundleRoot,
1546
- manifest = manifestJson("other-bundle", mapOf("index.android.bundle" to sha256(otherBundleRoot, BUNDLE_CONTENT))),
1547
- ),
1548
- "INVALID_BUNDLE",
1549
- invalidManifest,
1550
- )
1551
- assertUpdateFailure(
1552
- runOneFileUpdate(temporaryFolder.newFolder("unparsable-manifest"), manifest = "not json"),
1553
- "INVALID_BUNDLE",
1554
- invalidManifest,
1555
- )
1556
- assertUpdateFailure(
1557
- runOneFileUpdate(temporaryFolder.newFolder("missing-platform-bundle"), assetPath = "assets/image.png"),
1558
- "INVALID_BUNDLE",
1559
- invalidManifest,
1560
- )
1561
- val malformedUrlRoot = temporaryFolder.newFolder("malformed-file-url")
1562
- assertUpdateFailure(
1563
- runOneFileUpdate(
1564
- malformedUrlRoot,
1565
- descriptor = ChangedAssetDescriptor("not a url", sha256(malformedUrlRoot, BUNDLE_CONTENT)),
1566
- ),
1567
- "UNKNOWN_ERROR",
1568
- invalidManifest.copy(resource = UpdateFailureResource.FILE),
1569
- )
1570
- }
1571
-
1572
- @Test
1573
- fun `install failures report extract or storage`() =
1574
- runBlocking {
1575
- val brotliRoot = temporaryFolder.newFolder("corrupt-brotli-file")
1576
- val truncatedBrotli = Base64.getDecoder().decode(TarBrArchiveExtractorTest.VALID).let { it.copyOf(it.size / 2) }
1577
- assertUpdateFailure(
1578
- runOneFileUpdate(
1579
- brotliRoot,
1580
- descriptor = ChangedAssetDescriptor(BUNDLE_URL, sha256(brotliRoot, BUNDLE_CONTENT), fileCompression = "br"),
1581
- servedFile = truncatedBrotli,
1582
- ),
1583
- "DOWNLOAD_FAILED",
1584
- UpdateFailure.install(UpdateFailureReason.EXTRACT).copy(resource = UpdateFailureResource.FILE),
1585
- )
1586
-
1587
- val stagingRoot = temporaryFolder.newFolder("staging-directory-blocked")
1588
- File(bundleStoreDir(stagingRoot), "target.tmp").writeText("a file where the staging directory belongs")
1589
- assertUpdateFailure(
1590
- runOneFileUpdate(stagingRoot),
1591
- "DIRECTORY_CREATION_FAILED",
1592
- UpdateFailure.install(UpdateFailureReason.STORAGE),
1593
- )
1594
- }
1595
-
1596
- @Test
1597
- fun `unanticipated failures report unknown in the stage they happened in`() =
1598
- runBlocking {
1599
- val downloadRoot = temporaryFolder.newFolder("unknown-download-failure")
1600
- val downloadResult =
1601
- runCatching {
1602
- createService(downloadRoot).updateBundle("target", MANIFEST_URL, "hash", emptyMap()) {}
1603
- }.exceptionOrNull()
1604
- assertUpdateFailure(
1605
- downloadResult,
1606
- "UNKNOWN_ERROR",
1607
- UpdateFailure.download(UpdateFailureReason.UNKNOWN).copy(resource = UpdateFailureResource.MANIFEST),
1608
- )
1609
- assertEquals("downloadFile should not be called in these tests", downloadResult?.message)
1610
-
1611
- val installResult =
1612
- runOneFileUpdate(
1613
- temporaryFolder.newFolder("unknown-install-failure"),
1614
- directoryRenamer = { source, destination ->
1615
- if (source.name == "target.tmp") throw SecurityException("Promotion denied")
1616
- source.renameTo(destination)
1617
- },
1618
- )
1619
- assertUpdateFailure(installResult, "UNKNOWN_ERROR", UpdateFailure.install(UpdateFailureReason.UNKNOWN))
1620
- assertEquals("Promotion denied", installResult?.message)
1621
- }
1622
-
1623
- @Test
1624
- fun `a bundle built from changed files reports manifest delivery`() =
1625
- runBlocking {
1626
- val root = temporaryFolder.newFolder("manifest-delivery")
1627
- val hash = sha256(root, BUNDLE_CONTENT)
1628
- val manifest = manifestJson("target", mapOf("index.android.bundle" to hash))
1629
- val downloads =
1630
- BinaryMappingDownloadService(mapOf(MANIFEST_URL to manifest.toByteArray(), BUNDLE_URL to BUNDLE_CONTENT.toByteArray()))
1631
- val service = createService(root, downloadService = downloads, builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()))
1632
- val assets = mapOf("index.android.bundle" to ChangedAssetDescriptor(BUNDLE_URL, hash))
1633
-
1634
- val downloaded = service.updateBundle("target", MANIFEST_URL, sha256(root, manifest), assets) {}
1635
- // Installing it again reuses the staged file, so nothing is downloaded.
1636
- val reused = service.updateBundle("target", MANIFEST_URL, sha256(root, manifest), assets) {}
1637
-
1638
- assertEquals(UpdateBundleResult(BundleDelivery.MANIFEST, patchFallback = false), downloaded)
1639
- assertEquals(UpdateBundleResult(BundleDelivery.MANIFEST, patchFallback = false), reused)
1640
- assertEquals(1, downloads.calls.count { it == BUNDLE_URL })
1641
- }
1642
-
1643
- @Test
1644
- fun `a bundle installed from the archive reports archive delivery`() =
1645
- runBlocking {
1646
- val root = temporaryFolder.newFolder("archive-delivery")
1647
- val archiveBytes = Base64.getDecoder().decode(TarBrArchiveExtractorTest.VALID)
1648
- val contents = mapOf("assets/image.png" to "image", "index.android.bundle" to "bundle")
1649
- val hashes = contents.mapValues { sha256(root, it.value) }
1650
- val manifest =
1651
- manifestJson(
1652
- bundleId = "target",
1653
- assets = hashes,
1654
- byteSizes = contents.mapValues { it.value.length.toLong() },
1655
- downloadByteSizes = mapOf("assets/image.png" to 50, "index.android.bundle" to 55),
1656
- archive = archiveJson(root, archiveBytes, tarByteSize = 3072),
1657
- )
1658
- val downloads =
1659
- BinaryMappingDownloadService(mapOf(MANIFEST_URL to manifest.toByteArray(), ARCHIVE_URL to archiveBytes))
1660
-
1661
- val result =
1662
- createService(root, downloadService = downloads, builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()))
1663
- .updateBundle(
1664
- bundleId = "target",
1665
- manifestUrl = MANIFEST_URL,
1666
- manifestFileHash = sha256(root, manifest),
1667
- assets = hashes.mapValues { (path, hash) -> ChangedAssetDescriptor("https://example.com/$path", hash) },
1668
- archiveUrl = ARCHIVE_URL,
1669
- progressCallback = {},
1670
- )
1671
-
1672
- assertEquals(UpdateBundleResult(BundleDelivery.ARCHIVE, patchFallback = false), result)
1673
- assertEquals(listOf(MANIFEST_URL, ARCHIVE_URL), downloads.calls)
1674
- }
1675
-
1676
- @Test
1677
- fun `a file produced by a patch reports patch delivery`() =
1678
- runBlocking {
1679
- val patchUpdate = PatchUpdate(temporaryFolder.newFolder("patch-delivery"))
1680
-
1681
- val result = patchUpdate.run(servedPatch = patchUpdate.patchBytes)
1682
-
1683
- assertEquals(UpdateBundleResult(BundleDelivery.PATCH, patchFallback = false), result.getOrThrow())
1684
- assertEquals(listOf(MANIFEST_URL, PATCH_URL), patchUpdate.downloads.calls)
1685
- assertEquals(PATCHED_CONTENT, File(bundleStoreDir(patchUpdate.root), "target/index.android.bundle").readText())
1686
- }
1687
-
1688
- @Test
1689
- fun `a patch that cannot produce its file falls back to the file`() =
1690
- runBlocking {
1691
- val corruptPatch = PatchUpdate(temporaryFolder.newFolder("patch-fallback-corrupt"))
1692
- assertEquals(
1693
- UpdateBundleResult(BundleDelivery.MANIFEST, patchFallback = true),
1694
- corruptPatch.run(servedPatch = "not a bsdiff patch".toByteArray()).getOrThrow(),
1695
- )
1696
- assertEquals(listOf(MANIFEST_URL, PATCH_URL, BUNDLE_URL), corruptPatch.downloads.calls)
1697
-
1698
- val unreachablePatch = PatchUpdate(temporaryFolder.newFolder("patch-fallback-http"))
1699
- assertEquals(
1700
- UpdateBundleResult(BundleDelivery.MANIFEST, patchFallback = true),
1701
- unreachablePatch.run(servedPatch = null).getOrThrow(),
1702
- )
1703
-
1704
- val corruptBase = PatchUpdate(temporaryFolder.newFolder("patch-fallback-base"))
1705
- File(bundleStoreDir(corruptBase.root), "base-bundle/index.android.bundle").writeText("corrupt base")
1706
- assertEquals(
1707
- UpdateBundleResult(BundleDelivery.MANIFEST, patchFallback = true),
1708
- corruptBase.run(servedPatch = corruptBase.patchBytes).getOrThrow(),
1709
- )
1710
- assertFalse(corruptBase.downloads.calls.contains(PATCH_URL))
1711
-
1712
- // A patch for a bundle other than the running one is never attempted.
1713
- val otherBase = PatchUpdate(temporaryFolder.newFolder("patch-for-other-base"))
1714
- assertEquals(
1715
- UpdateBundleResult(BundleDelivery.MANIFEST, patchFallback = false),
1716
- otherBase.run(servedPatch = otherBase.patchBytes, patchBaseBundleId = "other-bundle").getOrThrow(),
1717
- )
1718
- assertEquals(listOf(MANIFEST_URL, BUNDLE_URL), otherBase.downloads.calls)
1719
- }
1720
-
1721
- /**
1722
- * Bundle "target" whose index.android.bundle can be produced by a bsdiff
1723
- * patch against the running bundle "base-bundle", or downloaded whole.
1724
- */
1725
- private inner class PatchUpdate(
1726
- val root: File,
1727
- ) {
1728
- val patchBytes: ByteArray = Base64.getDecoder().decode(BsdiffPatchTest.BSDIFF_PATCH_FIXTURE_BASE64)
1729
- private val preferences = InMemoryPreferencesService()
1730
- lateinit var downloads: BinaryMappingDownloadService
1731
-
1732
- init {
1733
- val base = createBundleDir(root, "base-bundle")
1734
- val baseFile = writeFile(base, "index.android.bundle", BASE_CONTENT)
1735
- File(base, "manifest.json").writeText(
1736
- manifestJson("base-bundle", mapOf("index.android.bundle" to sha256(root, BASE_CONTENT))),
1737
- )
1738
- writeMetadata(root, BundleMetadata(isolationKey = TEST_ISOLATION_KEY, stagingBundleId = "base-bundle"))
1739
- preferences.setItem("HotUpdaterBundleURL", baseFile.absolutePath)
1740
- }
1741
-
1742
- /** Serves [servedPatch] with its hash, or a 404 for the patch when it is null. */
1743
- suspend fun run(
1744
- servedPatch: ByteArray?,
1745
- patchBaseBundleId: String = "base-bundle",
1746
- ): Result<UpdateBundleResult> {
1747
- val targetHash = sha256(root, PATCHED_CONTENT)
1748
- val manifest = manifestJson("target", mapOf("index.android.bundle" to targetHash))
1749
- downloads =
1750
- BinaryMappingDownloadService(
1751
- buildMap {
1752
- put(MANIFEST_URL, manifest.toByteArray())
1753
- put(BUNDLE_URL, PATCHED_CONTENT.toByteArray())
1754
- servedPatch?.let { put(PATCH_URL, it) }
1755
- },
1756
- failures = if (servedPatch == null) mapOf(PATCH_URL to HttpStatusException(404, "Not Found")) else emptyMap(),
1757
- )
1758
- val service =
1759
- createService(
1760
- root,
1761
- preferences = preferences,
1762
- downloadService = downloads,
1763
- builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()),
1764
- )
1765
- val descriptor =
1766
- ChangedAssetDescriptor(
1767
- fileUrl = BUNDLE_URL,
1768
- fileHash = targetHash,
1769
- patch =
1770
- BsdiffPatchDescriptor(
1771
- algorithm = "bsdiff",
1772
- baseBundleId = patchBaseBundleId,
1773
- baseFileHash = sha256(root, BASE_CONTENT),
1774
- patchFileHash = sha256(root, servedPatch ?: ByteArray(0)),
1775
- patchUrl = PATCH_URL,
1776
- ),
1777
- )
1778
- return runCatching {
1779
- service.updateBundle("target", MANIFEST_URL, sha256(root, manifest), mapOf("index.android.bundle" to descriptor)) {}
1780
- }
1781
- }
1782
- }
1783
-
1784
- @Test
1785
- fun `rejections that are not update failures carry no classification`() =
1786
- runBlocking {
1787
- val crashedRoot = temporaryFolder.newFolder("crashed-bundle")
1788
- assertTrue(
1789
- CrashedHistory(mutableListOf(CrashedBundleEntry(bundleId = "target", crashedAt = 1)))
1790
- .saveToFile(File(bundleStoreDir(crashedRoot), CrashedHistory.CRASHED_HISTORY_FILENAME)),
1791
- )
1792
- assertUpdateFailure(
1793
- runOneFileUpdate(crashedRoot),
1794
- "BUNDLE_IN_CRASHED_HISTORY",
1795
- null,
1796
- )
1797
-
1798
- val staleRoot = temporaryFolder.newFolder("stale-selection")
1799
- val hash = sha256(staleRoot, BUNDLE_CONTENT)
1800
- val manifest = manifestJson("target", mapOf("index.android.bundle" to hash))
1801
- val served =
1802
- BinaryMappingDownloadService(
1803
- mapOf(MANIFEST_URL to manifest.toByteArray(), BUNDLE_URL to BUNDLE_CONTENT.toByteArray()),
1804
- )
1805
- lateinit var service: BundleFileStorageService
1806
- val downloads =
1807
- object : DownloadService {
1808
- override suspend fun downloadFile(
1809
- fileUrl: URL,
1810
- destination: File,
1811
- fileSizeCallback: ((Long) -> Unit)?,
1812
- progressCallback: (DownloadProgress) -> Unit,
1813
- ): DownloadResult {
1814
- if (fileUrl.toString() == BUNDLE_URL) {
1815
- // A newer catalog is accepted while the selected bundle downloads.
1816
- service.acceptReleaseCatalog("project-a", "scope-production", 2, "hash-2", "production", "context-2")
1817
- }
1818
- return served.downloadFile(fileUrl, destination, fileSizeCallback, progressCallback)
1819
- }
1820
- }
1821
- service = createService(staleRoot, downloadService = downloads, builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()))
1822
- assertTrue(service.acceptReleaseCatalog("project-a", "scope-production", 1, "hash-1", "production", "context-1"))
1823
- assertTrue(service.stageReleaseSelection(releaseSelection("release-one", "target", 1, "hash-1", "context-1")))
1824
-
1825
- val staleResult =
1826
- runCatching {
1827
- service.updateBundle(
1828
- "target",
1829
- MANIFEST_URL,
1830
- sha256(staleRoot, manifest),
1831
- mapOf("index.android.bundle" to ChangedAssetDescriptor(BUNDLE_URL, hash)),
1832
- ) {}
1833
- }.exceptionOrNull()
1834
-
1835
- assertTrue("Expected a stale selection, got $staleResult", staleResult is StaleReleaseSelectionException)
1836
- assertNull(loadMetadata(staleRoot)?.stagingBundleId)
1837
- }
1838
-
1839
- /** Installs bundle "target" with one file, returning the failure, if any. */
1840
- private suspend fun runOneFileUpdate(
1841
- root: File,
1842
- assetPath: String = "index.android.bundle",
1843
- manifest: String = manifestJson("target", mapOf(assetPath to sha256(root, BUNDLE_CONTENT))),
1844
- manifestFileHash: String = sha256(root, manifest),
1845
- descriptor: ChangedAssetDescriptor = ChangedAssetDescriptor(BUNDLE_URL, sha256(root, BUNDLE_CONTENT)),
1846
- servedFile: ByteArray = BUNDLE_CONTENT.toByteArray(),
1847
- failures: Map<String, Exception> = emptyMap(),
1848
- directoryRenamer: (File, File) -> Boolean = { source, destination -> source.renameTo(destination) },
1849
- ): Throwable? {
1850
- val service =
1851
- createService(
1852
- root,
1853
- downloadService =
1854
- BinaryMappingDownloadService(
1855
- mapOf(MANIFEST_URL to manifest.toByteArray(), BUNDLE_URL to servedFile),
1856
- failures,
1857
- ),
1858
- builtInAssetResolver = MappingBuiltInAssetResolver(emptyMap()),
1859
- directoryRenamer = directoryRenamer,
1860
- )
1861
- return runCatching {
1862
- service.updateBundle("target", MANIFEST_URL, manifestFileHash, mapOf(assetPath to descriptor)) {}
1863
- }.exceptionOrNull()
1864
- }
1865
-
1866
- private fun assertUpdateFailure(
1867
- error: Throwable?,
1868
- code: String,
1869
- failure: UpdateFailure?,
1870
- ) {
1871
- assertTrue("Expected HotUpdaterException, got $error", error is HotUpdaterException)
1872
- val updateError = error as HotUpdaterException
1873
- assertEquals(code, updateError.code)
1874
- assertEquals(failure, updateError.failure)
1875
- }
1876
-
1877
- private fun createService(
1878
- rootDir: File,
1879
- preferences: InMemoryPreferencesService = InMemoryPreferencesService(),
1880
- downloadService: DownloadService = UnusedDownloadService,
1881
- builtInAssetResolver: BuiltInAssetResolver? = null,
1882
- metadataWriter: (BundleMetadata, File) -> Boolean = { metadata, file -> metadata.saveToFile(file) },
1883
- directoryRenamer: (File, File) -> Boolean = { source, destination -> source.renameTo(destination) },
1884
- ): BundleFileStorageService =
1885
- BundleFileStorageService(
1886
- context = ContextWrapper(null),
1887
- fileSystem = TestFileSystemService(rootDir),
1888
- downloadService = downloadService,
1889
- preferences = preferences,
1890
- isolationKey = TEST_ISOLATION_KEY,
1891
- defaultChannelProvider = { "production" },
1892
- builtInAssetResolver = builtInAssetResolver,
1893
- metadataWriter = metadataWriter,
1894
- directoryRenamer = directoryRenamer,
1895
- )
1896
-
1897
- private fun releaseSelection(
1898
- releaseId: String,
1899
- bundleId: String,
1900
- generation: Long,
1901
- catalogHash: String,
1902
- selectionContextHash: String,
1903
- ): PersistedSelection =
1904
- PersistedSelection(
1905
- kind = "BUNDLE",
1906
- releaseId = releaseId,
1907
- bundleId = bundleId,
1908
- catalogId = "project-a",
1909
- scopeKey = "scope-production",
1910
- generation = generation,
1911
- catalogHash = catalogHash,
1912
- channel = "production",
1913
- selectionContextHash = selectionContextHash,
1914
- )
1915
-
1916
- private fun createBundleDir(
1917
- rootDir: File,
1918
- bundleId: String,
1919
- ): File = File(bundleStoreDir(rootDir), bundleId).apply { mkdirs() }
1920
-
1921
- private fun createCompleteBundle(
1922
- rootDir: File,
1923
- directoryName: String,
1924
- content: String,
1925
- manifestBundleId: String = directoryName,
1926
- ): File {
1927
- val directory = createBundleDir(rootDir, directoryName)
1928
- writeFile(directory, "index.android.bundle", content)
1929
- File(directory, "manifest.json").writeText(
1930
- manifestJson(
1931
- manifestBundleId,
1932
- mapOf("index.android.bundle" to sha256(rootDir, content)),
1933
- ),
1934
- )
1935
- return directory
1936
- }
1937
-
1938
- private fun writeManifest(
1939
- bundleDir: File,
1940
- assetPaths: List<String>,
1941
- ) {
1942
- val assets =
1943
- JSONObject().apply {
1944
- assetPaths.forEach { assetPath ->
1945
- put(assetPath, JSONObject().put("fileHash", "$assetPath-hash"))
1946
- }
1947
- }
1948
-
1949
- File(bundleDir, "manifest.json").writeText(
1950
- JSONObject()
1951
- .put("bundleId", bundleDir.name)
1952
- .put("assets", assets)
1953
- .toString(),
1954
- )
1955
- }
1956
-
1957
- private fun writeMetadata(
1958
- rootDir: File,
1959
- metadata: BundleMetadata,
1960
- ) {
1961
- assertTrue(metadata.saveToFile(File(bundleStoreDir(rootDir), BundleMetadata.METADATA_FILENAME)))
1962
- }
1963
-
1964
- private fun loadMetadata(rootDir: File): BundleMetadata? =
1965
- BundleMetadata.loadFromFile(
1966
- File(bundleStoreDir(rootDir), BundleMetadata.METADATA_FILENAME),
1967
- TEST_ISOLATION_KEY,
1968
- )
1969
-
1970
- private fun writeFile(
1971
- rootDir: File,
1972
- relativePath: String,
1973
- content: String = "bundle-content",
1974
- ): File =
1975
- File(rootDir, relativePath).apply {
1976
- parentFile?.mkdirs()
1977
- writeText(content)
1978
- }
1979
-
1980
- private fun bundleStoreDir(rootDir: File): File = File(rootDir, "bundle-store").apply { mkdirs() }
1981
-
1982
- private fun invokeResolveBundleFile(
1983
- service: BundleFileStorageService,
1984
- bundleDir: File,
1985
- ): File? {
1986
- val method =
1987
- BundleFileStorageService::class.java.getDeclaredMethod(
1988
- "resolveBundleFile",
1989
- File::class.java,
1990
- String::class.java,
1991
- )
1992
- method.isAccessible = true
1993
- return method.invoke(service, bundleDir, bundleDir.name) as File?
1994
- }
1995
-
1996
- private fun assertResolvedBundlePath(
1997
- service: BundleFileStorageService,
1998
- bundleDir: File,
1999
- expected: File,
2000
- ) {
2001
- val resolved = invokeResolveBundleFile(service, bundleDir)
2002
-
2003
- assertNotNull(resolved)
2004
- assertEquals(expected.canonicalFile.absolutePath, resolved?.canonicalFile?.absolutePath)
2005
- }
2006
-
2007
- private class TestFileSystemService(
2008
- private val internalFilesDir: File,
2009
- ) : FileSystemService {
2010
- override fun fileExists(path: String): Boolean = File(path).exists()
2011
-
2012
- override fun createDirectory(path: String): Boolean = File(path).mkdirs()
2013
-
2014
- override fun removeItem(path: String): Boolean = File(path).deleteRecursively()
2015
-
2016
- override fun moveItem(
2017
- sourcePath: String,
2018
- destinationPath: String,
2019
- ): Boolean = File(sourcePath).renameTo(File(destinationPath))
2020
-
2021
- override fun copyItem(
2022
- sourcePath: String,
2023
- destinationPath: String,
2024
- ): Boolean =
2025
- try {
2026
- File(sourcePath).copyRecursively(File(destinationPath), overwrite = true)
2027
- } catch (_: Exception) {
2028
- false
2029
- }
2030
-
2031
- override fun contentsOfDirectory(path: String): List<String> = File(path).list()?.toList() ?: emptyList()
2032
-
2033
- override fun getInternalFilesDir(): File = internalFilesDir
2034
- }
2035
-
2036
- private class InMemoryPreferencesService(
2037
- private val shouldFailWrite: (String, String?) -> Boolean = { _, _ -> false },
2038
- ) : PreferencesService {
2039
- private val values = mutableMapOf<String, String?>()
2040
-
2041
- override fun getItem(key: String): String? = values[key]
2042
-
2043
- override fun setItem(
2044
- key: String,
2045
- value: String?,
2046
- ) {
2047
- if (shouldFailWrite(key, value)) {
2048
- throw IllegalStateException("Injected preference write failure")
2049
- }
2050
- if (value == null) {
2051
- values.remove(key)
2052
- } else {
2053
- values[key] = value
2054
- }
2055
- }
2056
- }
2057
-
2058
- private object UnusedDownloadService : DownloadService {
2059
- override suspend fun downloadFile(
2060
- fileUrl: URL,
2061
- destination: File,
2062
- fileSizeCallback: ((Long) -> Unit)?,
2063
- progressCallback: (DownloadProgress) -> Unit,
2064
- ): DownloadResult = error("downloadFile should not be called in these tests")
2065
- }
2066
-
2067
- private class RecordingFailedDownloadService : DownloadService {
2068
- val calls = CopyOnWriteArrayList<Pair<String, String>>()
2069
-
2070
- override suspend fun downloadFile(
2071
- fileUrl: URL,
2072
- destination: File,
2073
- fileSizeCallback: ((Long) -> Unit)?,
2074
- progressCallback: (DownloadProgress) -> Unit,
2075
- ): DownloadResult {
2076
- calls += fileUrl.toString() to Thread.currentThread().name
2077
- return DownloadResult.Error(IllegalStateException("expected download failure"))
2078
- }
2079
- }
2080
-
2081
- private class MappingDownloadService(
2082
- private val contents: Map<String, String>,
2083
- ) : DownloadService {
2084
- val calls = CopyOnWriteArrayList<String>()
2085
-
2086
- override suspend fun downloadFile(
2087
- fileUrl: URL,
2088
- destination: File,
2089
- fileSizeCallback: ((Long) -> Unit)?,
2090
- progressCallback: (DownloadProgress) -> Unit,
2091
- ): DownloadResult {
2092
- calls += fileUrl.toString()
2093
- val content =
2094
- contents[fileUrl.toString()]
2095
- ?: return DownloadResult.Error(IllegalArgumentException("Unexpected URL: $fileUrl"))
2096
- destination.parentFile?.mkdirs()
2097
- destination.writeText(content)
2098
- fileSizeCallback?.invoke(destination.length())
2099
- progressCallback(DownloadProgress(1.0, destination.length(), destination.length()))
2100
- return DownloadResult.Success(destination)
2101
- }
2102
- }
2103
-
2104
- private class BinaryMappingDownloadService(
2105
- private val contents: Map<String, ByteArray>,
2106
- private val failures: Map<String, Exception> = emptyMap(),
2107
- ) : DownloadService {
2108
- val calls = CopyOnWriteArrayList<String>()
2109
-
2110
- override suspend fun downloadFile(
2111
- fileUrl: URL,
2112
- destination: File,
2113
- fileSizeCallback: ((Long) -> Unit)?,
2114
- progressCallback: (DownloadProgress) -> Unit,
2115
- ): DownloadResult = download(fileUrl, destination, fileSizeCallback, progressCallback)
2116
-
2117
- override suspend fun downloadFileOnce(
2118
- fileUrl: URL,
2119
- destination: File,
2120
- fileSizeCallback: ((Long) -> Unit)?,
2121
- progressCallback: (DownloadProgress) -> Unit,
2122
- ): DownloadResult = download(fileUrl, destination, fileSizeCallback, progressCallback)
2123
-
2124
- private fun download(
2125
- fileUrl: URL,
2126
- destination: File,
2127
- fileSizeCallback: ((Long) -> Unit)?,
2128
- progressCallback: (DownloadProgress) -> Unit,
2129
- ): DownloadResult {
2130
- calls += fileUrl.toString()
2131
- failures[fileUrl.toString()]?.let { return DownloadResult.Error(it) }
2132
- val bytes = contents[fileUrl.toString()] ?: return DownloadResult.Error(IllegalArgumentException("Unexpected URL: $fileUrl"))
2133
- destination.parentFile?.mkdirs()
2134
- destination.writeBytes(bytes)
2135
- fileSizeCallback?.invoke(bytes.size.toLong())
2136
- progressCallback(DownloadProgress(1.0, bytes.size.toLong(), bytes.size.toLong()))
2137
- return DownloadResult.Success(destination)
2138
- }
2139
- }
2140
-
2141
- private class MappingBuiltInAssetResolver(
2142
- private val contents: Map<String, String>,
2143
- ) : BuiltInAssetResolver {
2144
- override fun copyIfMatches(
2145
- assetPath: String,
2146
- expectedHash: String,
2147
- destination: File,
2148
- ): Boolean {
2149
- val content = contents[assetPath] ?: return false
2150
- destination.parentFile?.mkdirs()
2151
- destination.writeText(content)
2152
- if (!HashUtils.verifyHash(destination, expectedHash)) {
2153
- destination.delete()
2154
- return false
2155
- }
2156
- return true
2157
- }
2158
- }
2159
-
2160
- private fun manifestJson(
2161
- bundleId: String,
2162
- assets: Map<String, String>,
2163
- byteSizes: Map<String, Long> = emptyMap(),
2164
- downloadByteSizes: Map<String, Long> = emptyMap(),
2165
- archive: JSONObject? = null,
2166
- ): String =
2167
- JSONObject()
2168
- .put("bundleId", bundleId)
2169
- .put(
2170
- "assets",
2171
- JSONObject().apply {
2172
- assets.forEach { (path, hash) ->
2173
- put(
2174
- path,
2175
- JSONObject()
2176
- .put("fileHash", hash)
2177
- .apply {
2178
- byteSizes[path]?.let { put("byteSize", it) }
2179
- downloadByteSizes[path]?.let { put("downloadByteSize", it) }
2180
- },
2181
- )
2182
- }
2183
- },
2184
- ).apply {
2185
- archive?.let { put("archive", it) }
2186
- }.toString()
2187
-
2188
- private fun archiveJson(
2189
- rootDir: File,
2190
- bytes: ByteArray,
2191
- tarByteSize: Long,
2192
- ): JSONObject =
2193
- JSONObject()
2194
- .put("downloadFileHash", sha256(rootDir, bytes))
2195
- .put("downloadByteSize", bytes.size)
2196
- .put("tarByteSize", tarByteSize)
2197
-
2198
- private fun sha256(
2199
- rootDir: File,
2200
- content: String,
2201
- ): String {
2202
- val file = File.createTempFile("hash-", null, rootDir)
2203
- return try {
2204
- file.writeText(content)
2205
- HashUtils.calculateSHA256(file)
2206
- } finally {
2207
- file.delete()
2208
- }
2209
- }
2210
-
2211
- private fun sha256(
2212
- rootDir: File,
2213
- bytes: ByteArray,
2214
- ): String {
2215
- val file = File.createTempFile("hash-", null, rootDir)
2216
- return try {
2217
- file.writeBytes(bytes)
2218
- HashUtils.calculateSHA256(file)
2219
- } finally {
2220
- file.delete()
2221
- }
2222
- }
2223
-
2224
- companion object {
2225
- private const val TEST_ISOLATION_KEY = "test-isolation-key"
2226
- private const val MANIFEST_URL = "https://example.com/manifest.json"
2227
- private const val ARCHIVE_URL = "https://example.com/bundle.tar.br"
2228
- private const val BUNDLE_URL = "https://example.com/index.android.bundle"
2229
- private const val BUNDLE_CONTENT = "bundle"
2230
- private const val PATCH_URL = "https://example.com/index.android.bundle.bsdiff"
2231
-
2232
- // The base and output of BsdiffPatchTest.BSDIFF_PATCH_FIXTURE_BASE64.
2233
- private const val BASE_CONTENT = "console.log(\"base bundle\");\n"
2234
- private const val PATCHED_CONTENT = "console.log(\"patched bundle\");\n"
2235
- }
2236
- }