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