@onekeyfe/react-native-app-update 3.0.66 → 3.0.68
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/build.gradle +5 -0
- package/android/src/main/java/com/margelo/nitro/reactnativeappupdate/AppUpdateLogic.kt +85 -0
- package/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt +105 -35
- package/android/src/test/java/com/margelo/nitro/reactnativeappupdate/AppUpdateContentRangeTest.kt +164 -0
- package/android/src/test/java/com/margelo/nitro/reactnativeappupdate/AppUpdateLogicSegmentTest.kt +88 -0
- package/package.json +1 -1
package/android/build.gradle
CHANGED
|
@@ -153,4 +153,9 @@ dependencies {
|
|
|
153
153
|
|
|
154
154
|
// MMKV for reading DevSettings (compileOnly: provided by the host app via react-native-mmkv)
|
|
155
155
|
compileOnly "io.github.zhongwuzw:mmkv:2.2.4"
|
|
156
|
+
|
|
157
|
+
// Pure-logic unit tests (AppUpdateLogic) run on the local JVM via
|
|
158
|
+
// :onekeyfe_react-native-app-update:testDebugUnitTest. No Robolectric: the
|
|
159
|
+
// extracted logic is dependency-free (no Context / Nitro / OkHttp).
|
|
160
|
+
testImplementation "junit:junit:4.13.2"
|
|
156
161
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
package com.margelo.nitro.reactnativeappupdate
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dependency-free pure logic extracted from ReactNativeAppUpdate.
|
|
5
|
+
*
|
|
6
|
+
* This object intentionally pulls in NO Android / Nitro / OkHttp / File-I/O
|
|
7
|
+
* dependencies: it holds only constants and pure string-derivation helpers so
|
|
8
|
+
* the values can be unit-tested under plain JVM JUnit (the adapter itself
|
|
9
|
+
* cannot, because it needs a live Context / FileProvider / network).
|
|
10
|
+
*/
|
|
11
|
+
object AppUpdateLogic {
|
|
12
|
+
// Must match ConcurrentRangeDownloader's default segmentCount: the
|
|
13
|
+
// concurrent downloader writes sibling segment files
|
|
14
|
+
// "<partial>.seg0".."<partial>.seg${N-1}", and Phase 2 below scans this
|
|
15
|
+
// range to detect an in-flight concurrent download.
|
|
16
|
+
const val CONCURRENT_SEGMENT_COUNT = 8
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Derive the sibling segment-file name the concurrent downloader writes for
|
|
20
|
+
* a given partial path and segment index, i.e. "<partial>.seg<index>".
|
|
21
|
+
* This MUST match exactly what ConcurrentRangeDownloader writes.
|
|
22
|
+
*/
|
|
23
|
+
fun segmentFileName(partialFilePath: String, index: Int): String =
|
|
24
|
+
"$partialFilePath.seg$index"
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* All [CONCURRENT_SEGMENT_COUNT] segment-file names for a given partial
|
|
28
|
+
* path, ordered seg0..seg${N-1}.
|
|
29
|
+
*/
|
|
30
|
+
fun segmentFileNames(partialFilePath: String): List<String> =
|
|
31
|
+
(0 until CONCURRENT_SEGMENT_COUNT).map { segmentFileName(partialFilePath, it) }
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse the total size out of a 416 (Range Not Satisfiable)
|
|
35
|
+
* `Content-Range: bytes * /<total>` header. Returns null when the header is
|
|
36
|
+
* absent or is not the "unsatisfiable range" form (e.g. a normal
|
|
37
|
+
* `bytes start-end/total`).
|
|
38
|
+
*
|
|
39
|
+
* Regex body copied VERBATIM from ReactNativeAppUpdate.downloadAPK (the 416
|
|
40
|
+
* branch); wrapped here with no behavior change so the parse step is pure
|
|
41
|
+
* and unit-testable. The adapter's `total == partialBytes` comparison stays
|
|
42
|
+
* in the adapter (it needs runtime `partialBytes`); see [is416Complete].
|
|
43
|
+
*/
|
|
44
|
+
fun parse416Total(contentRange: String?): Long? =
|
|
45
|
+
contentRange
|
|
46
|
+
?.let { Regex("""bytes\s+\*\s*/\s*(\d+)""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() }
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse a 206 `Content-Range: bytes start-end/total` header into
|
|
50
|
+
* (start, end, total). `total` is null when the server sends `*`
|
|
51
|
+
* (unknown total). Returns null when the header is absent or malformed.
|
|
52
|
+
*
|
|
53
|
+
* `rangeRegex` body copied VERBATIM from ReactNativeAppUpdate.downloadAPK
|
|
54
|
+
* (the 206 sanity-check). The adapter's `start != partialBytes`
|
|
55
|
+
* CDN-misalignment guard stays in the adapter; see [is206StartAligned].
|
|
56
|
+
*/
|
|
57
|
+
fun parse206ContentRange(contentRange: String?): Triple<Long?, Long?, Long?>? {
|
|
58
|
+
val match = contentRange
|
|
59
|
+
?.let { Regex("""bytes\s+(\d+)\s*-\s*(\d+)\s*/\s*(\d+|\*)""").find(it) }
|
|
60
|
+
?: return null
|
|
61
|
+
val start = match.groupValues.getOrNull(1)?.toLongOrNull()
|
|
62
|
+
val end = match.groupValues.getOrNull(2)?.toLongOrNull()
|
|
63
|
+
// group 3 is either digits or literal "*"; "*" → unknown total → null.
|
|
64
|
+
val total = match.groupValues.getOrNull(3)?.toLongOrNull()
|
|
65
|
+
return Triple(start, end, total)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 416 recovery predicate: the server-reported total exactly equals the
|
|
70
|
+
* bytes we already have on disk, meaning our `.partial` IS the whole APK
|
|
71
|
+
* and just needs verify + rename rather than a wipe. Pure mirror of the
|
|
72
|
+
* adapter's `totalFromHeader != null && totalFromHeader == partialBytes`.
|
|
73
|
+
*/
|
|
74
|
+
fun is416Complete(total: Long?, partialBytes: Long): Boolean =
|
|
75
|
+
total != null && total == partialBytes
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 206 start-alignment guard: the server's body starts exactly where we
|
|
79
|
+
* asked it to resume from. When false (CDN/proxy rewrote the range), the
|
|
80
|
+
* 206 body is a mis-aligned slice and must NOT be appended. Pure mirror of
|
|
81
|
+
* the adapter's `rangeStart != null && rangeStart == partialBytes` check.
|
|
82
|
+
*/
|
|
83
|
+
fun is206StartAligned(start: Long?, partialBytes: Long): Boolean =
|
|
84
|
+
start != null && start == partialBytes
|
|
85
|
+
}
|
package/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt
CHANGED
|
@@ -46,16 +46,23 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
46
46
|
private const val CHANNEL_ID = "updateApp"
|
|
47
47
|
private const val NOTIFICATION_ID = 1
|
|
48
48
|
|
|
49
|
-
//
|
|
50
|
-
// concurrent downloader writes sibling segment
|
|
51
|
-
// "<partial>.seg0".."<partial>.seg${N-1}", and Phase 2 below
|
|
52
|
-
// range to detect an in-flight concurrent download.
|
|
53
|
-
private const val CONCURRENT_SEGMENT_COUNT =
|
|
49
|
+
// Segment-count + segment-file naming live in AppUpdateLogic (pure,
|
|
50
|
+
// unit-testable). The concurrent downloader writes sibling segment
|
|
51
|
+
// files "<partial>.seg0".."<partial>.seg${N-1}", and Phase 2 below
|
|
52
|
+
// scans this range to detect an in-flight concurrent download.
|
|
53
|
+
private const val CONCURRENT_SEGMENT_COUNT = AppUpdateLogic.CONCURRENT_SEGMENT_COUNT
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
private val listeners = CopyOnWriteArrayList<Listener>()
|
|
57
57
|
private val nextListenerId = AtomicLong(1)
|
|
58
58
|
private val isDownloading = AtomicBoolean(false)
|
|
59
|
+
// The in-flight concurrent download's cancel handle, so clearCache can stop
|
|
60
|
+
// its workers (shutdownNow + awaitTermination) BEFORE deleting .segN files.
|
|
61
|
+
// Without this a still-running worker resurrects a just-deleted segment / writes
|
|
62
|
+
// to a deleted FD — the cancel-then-delete race OCDS §5.8 forbids. Mirrors
|
|
63
|
+
// react-native-bundle-update's activeDownloads cancel wiring.
|
|
64
|
+
private val activeDownload =
|
|
65
|
+
java.util.concurrent.atomic.AtomicReference<ConcurrentRangeDownloader.CancelHandle?>(null)
|
|
59
66
|
// downloadThread removed: downloads use coroutine-based Promise.async, not raw threads
|
|
60
67
|
|
|
61
68
|
private fun sendEvent(type: String, progress: Int = 0, message: String = "") {
|
|
@@ -289,9 +296,22 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
289
296
|
OneKeyLog.warn("AppUpdate", "verifyExistingApk: SHA256 mismatch, expected=${expectedSha256.take(16)}..., got=${actualSha256.take(16)}...")
|
|
290
297
|
ApkVerifyOutcome.HashMismatch
|
|
291
298
|
}
|
|
292
|
-
} catch (e:
|
|
293
|
-
|
|
299
|
+
} catch (e: java.io.IOException) {
|
|
300
|
+
// Offline / disk-I/O transient (ASC fetch or APK read): we genuinely
|
|
301
|
+
// cannot decide validity right now, so preserve the bytes and defer to
|
|
302
|
+
// the next online retry (Indeterminate → caller rolls back to .partial).
|
|
303
|
+
OneKeyLog.warn("AppUpdate", "verifyExistingApk: transient I/O failure, indeterminate: ${e.javaClass.simpleName}: ${e.message}")
|
|
294
304
|
ApkVerifyOutcome.Indeterminate
|
|
305
|
+
} catch (e: Exception) {
|
|
306
|
+
// A non-I/O failure (corrupt local state, path/security rejection,
|
|
307
|
+
// runtime fault) is NOT a "can't tell because offline" case — it is a
|
|
308
|
+
// genuine local error. Do NOT collapse it into a benign Deferred verify:
|
|
309
|
+
// downloadAPK's catch suppresses ApkVerificationDeferredException from
|
|
310
|
+
// `update/error`, which would silently hide this real failure and keep
|
|
311
|
+
// retrying a permanently-bad file until the JS budget is spent. Rethrow
|
|
312
|
+
// so it surfaces to the caller as an error.
|
|
313
|
+
OneKeyLog.error("AppUpdate", "verifyExistingApk: non-I/O failure, surfacing: ${e.javaClass.simpleName}: ${e.message}")
|
|
314
|
+
throw e
|
|
295
315
|
}
|
|
296
316
|
}
|
|
297
317
|
|
|
@@ -440,6 +460,9 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
440
460
|
throw Exception("Download already in progress")
|
|
441
461
|
}
|
|
442
462
|
|
|
463
|
+
// Hoisted so the catch can tell an intentional clearCache-cancel from a
|
|
464
|
+
// real failure (a cancel must NOT surface as an `update/error`).
|
|
465
|
+
var cancelHandle: ConcurrentRangeDownloader.CancelHandle? = null
|
|
443
466
|
try {
|
|
444
467
|
val url = params.downloadUrl
|
|
445
468
|
val filePath = filePathFromUrl(url)
|
|
@@ -510,7 +533,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
510
533
|
// concurrent run could otherwise be picked up below and reused —
|
|
511
534
|
// wipe them so this restarts cleanly from byte zero.
|
|
512
535
|
if (partialFile.exists()) partialFile.delete()
|
|
513
|
-
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(
|
|
536
|
+
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(AppUpdateLogic.segmentFileName(partialFilePath, i)).delete()
|
|
514
537
|
}
|
|
515
538
|
expectedSize > 0 && existingSize < expectedSize -> {
|
|
516
539
|
OneKeyLog.info("AppUpdate", "downloadAPK: existing APK smaller than expected, promoting to .partial for resume")
|
|
@@ -522,7 +545,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
522
545
|
// concat committed-cursor, risking a mixed file. With no .segN,
|
|
523
546
|
// Phase 2 takes the single-stream size-based path and Range-resumes
|
|
524
547
|
// from the partial's length.
|
|
525
|
-
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(
|
|
548
|
+
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(AppUpdateLogic.segmentFileName(partialFilePath, i)).delete()
|
|
526
549
|
if (!downloadedFile.renameTo(partialFile)) {
|
|
527
550
|
OneKeyLog.warn("AppUpdate", "downloadAPK: rename to .partial failed, deleting stale final")
|
|
528
551
|
downloadedFile.delete()
|
|
@@ -542,7 +565,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
542
565
|
// Final was stale → start from byte zero. Wipe any
|
|
543
566
|
// sibling .segN left by an earlier concurrent run so
|
|
544
567
|
// it can't be mistaken for trustworthy in-flight bytes.
|
|
545
|
-
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(
|
|
568
|
+
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(AppUpdateLogic.segmentFileName(partialFilePath, i)).delete()
|
|
546
569
|
}
|
|
547
570
|
ApkVerifyOutcome.Indeterminate -> {
|
|
548
571
|
// ASC could not be fetched (offline) or could
|
|
@@ -573,7 +596,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
573
596
|
// path must match exactly what ConcurrentRangeDownloader writes:
|
|
574
597
|
// File("$partialFilePath.seg$i").
|
|
575
598
|
val hasConcurrentSegments =
|
|
576
|
-
(0 until CONCURRENT_SEGMENT_COUNT).any { buildFile(
|
|
599
|
+
(0 until CONCURRENT_SEGMENT_COUNT).any { buildFile(AppUpdateLogic.segmentFileName(partialFilePath, it)).exists() }
|
|
577
600
|
var partialBytes = 0L
|
|
578
601
|
if (partialFile.exists() && !hasConcurrentSegments) {
|
|
579
602
|
val partialSize = partialFile.length()
|
|
@@ -608,7 +631,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
608
631
|
OneKeyLog.warn("AppUpdate", "downloadAPK: stale partial (>expected $partialSize/$expectedSize), discarding")
|
|
609
632
|
partialFile.delete()
|
|
610
633
|
// Partial bytes are untrustworthy → restart from zero; drop any sibling .segN too.
|
|
611
|
-
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(
|
|
634
|
+
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(AppUpdateLogic.segmentFileName(partialFilePath, i)).delete()
|
|
612
635
|
}
|
|
613
636
|
partialSize > 0 -> {
|
|
614
637
|
partialBytes = partialSize
|
|
@@ -643,6 +666,10 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
643
666
|
// the thread that actually moved the percent forward proceeds.
|
|
644
667
|
val concurrentProgress = AtomicInteger(-1)
|
|
645
668
|
val notifyLock = Any()
|
|
669
|
+
// Wire a cancel handle so clearCache can stop these workers
|
|
670
|
+
// before deleting .segN (OCDS §5.8). Cleared in the finally below.
|
|
671
|
+
cancelHandle = ConcurrentRangeDownloader.CancelHandle()
|
|
672
|
+
activeDownload.set(cancelHandle)
|
|
646
673
|
val concurrentOutcome = ConcurrentRangeDownloader(
|
|
647
674
|
httpClient = concurrentClient,
|
|
648
675
|
log = { msg -> OneKeyLog.info("AppUpdate", msg) },
|
|
@@ -653,7 +680,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
653
680
|
// files to the read-only root fs → EROFS. Resolve it to
|
|
654
681
|
// the real apks dir (filesDir/apks) first, exactly like
|
|
655
682
|
// react-native-bundle-update passes an absolute path.
|
|
656
|
-
).download(url, partialFile.absolutePath) { transferred, total ->
|
|
683
|
+
).download(url, partialFile.absolutePath, cancelHandle) { transferred, total ->
|
|
657
684
|
if (total > 0) {
|
|
658
685
|
val p = ((transferred * 100) / total).toInt().coerceIn(0, 100)
|
|
659
686
|
// Only the thread that advances the percent emits; a
|
|
@@ -724,9 +751,8 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
724
751
|
if (response.code == 416) {
|
|
725
752
|
val contentRange = response.header("Content-Range")
|
|
726
753
|
response.close()
|
|
727
|
-
val totalFromHeader = contentRange
|
|
728
|
-
|
|
729
|
-
if (totalFromHeader != null && totalFromHeader == partialBytes && partialFile.exists()) {
|
|
754
|
+
val totalFromHeader = AppUpdateLogic.parse416Total(contentRange)
|
|
755
|
+
if (AppUpdateLogic.is416Complete(totalFromHeader, partialBytes) && partialFile.exists()) {
|
|
730
756
|
OneKeyLog.info("AppUpdate", "downloadAPK: HTTP 416 with total=$totalFromHeader matches partial, attempting promote+verify")
|
|
731
757
|
when (tryPromoteAndVerify(url, filePath, partialFile, downloadedFile)) {
|
|
732
758
|
PromoteOutcome.Valid -> {
|
|
@@ -743,7 +769,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
743
769
|
OneKeyLog.warn("AppUpdate", "downloadAPK: 416 recovery hash mismatch — server build changed mid-download")
|
|
744
770
|
if (partialFile.exists()) partialFile.delete()
|
|
745
771
|
// Server build changed → these bytes are worthless; drop sibling .segN too.
|
|
746
|
-
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(
|
|
772
|
+
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(AppUpdateLogic.segmentFileName(partialFilePath, i)).delete()
|
|
747
773
|
throw java.io.IOException("Server build changed mid-download (size matches but hash differs)")
|
|
748
774
|
}
|
|
749
775
|
PromoteOutcome.Deferred -> {
|
|
@@ -761,7 +787,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
761
787
|
OneKeyLog.warn("AppUpdate", "downloadAPK: HTTP 416 (range not satisfiable), discarding partial and failing attempt")
|
|
762
788
|
if (partialFile.exists()) partialFile.delete()
|
|
763
789
|
// Partial discarded as unusable → drop sibling .segN too so the next attempt starts clean.
|
|
764
|
-
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(
|
|
790
|
+
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(AppUpdateLogic.segmentFileName(partialFilePath, i)).delete()
|
|
765
791
|
throw Exception("HTTP 416 (range not satisfiable)")
|
|
766
792
|
}
|
|
767
793
|
|
|
@@ -781,7 +807,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
781
807
|
OneKeyLog.warn("AppUpdate", "downloadAPK: requested Range but server returned 200, restarting from scratch")
|
|
782
808
|
if (partialFile.exists()) partialFile.delete()
|
|
783
809
|
// Restarting from byte zero → drop any sibling .segN too.
|
|
784
|
-
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(
|
|
810
|
+
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(AppUpdateLogic.segmentFileName(partialFilePath, i)).delete()
|
|
785
811
|
partialBytes = 0L
|
|
786
812
|
}
|
|
787
813
|
|
|
@@ -791,13 +817,14 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
791
817
|
// be appending mis-aligned bytes and only catching it later
|
|
792
818
|
// at the SHA step — with the partial now corrupted. Demote to
|
|
793
819
|
// a full restart instead.
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
820
|
+
// Parse the 206 Content-Range via the unit-tested helper (was an
|
|
821
|
+
// inline duplicate of AppUpdateLogic.parse206ContentRange's regex).
|
|
822
|
+
val parsed206 = if (serverWillResume) {
|
|
823
|
+
AppUpdateLogic.parse206ContentRange(response.header("Content-Range"))
|
|
797
824
|
} else null
|
|
798
825
|
if (serverWillResume) {
|
|
799
|
-
val rangeStart =
|
|
800
|
-
if (rangeStart
|
|
826
|
+
val rangeStart = parsed206?.first
|
|
827
|
+
if (!AppUpdateLogic.is206StartAligned(rangeStart, partialBytes)) {
|
|
801
828
|
// This 206 body is a slice starting at the wrong offset (CDN bug /
|
|
802
829
|
// proxy rewrite). It is NOT a full file, so we must not consume it as
|
|
803
830
|
// one — doing so would write mis-aligned bytes (caught only later at
|
|
@@ -809,7 +836,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
809
836
|
val contentRangeHeader = response.header("Content-Range")
|
|
810
837
|
OneKeyLog.warn("AppUpdate", "downloadAPK: 206 Content-Range start mismatch (header='$contentRangeHeader', requested=$partialBytes); discarding partial and retrying from scratch")
|
|
811
838
|
if (partialFile.exists()) partialFile.delete()
|
|
812
|
-
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(
|
|
839
|
+
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(AppUpdateLogic.segmentFileName(partialFilePath, i)).delete()
|
|
813
840
|
response.close()
|
|
814
841
|
throw java.io.IOException("206 Content-Range start mismatch (header='$contentRangeHeader', requested=$partialBytes); discarded partial, retry from scratch")
|
|
815
842
|
}
|
|
@@ -821,7 +848,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
821
848
|
}
|
|
822
849
|
val contentLength = body.contentLength()
|
|
823
850
|
val totalSize: Long = if (serverWillResume) {
|
|
824
|
-
val parsedTotal =
|
|
851
|
+
val parsedTotal = parsed206?.third
|
|
825
852
|
parsedTotal ?: (partialBytes + contentLength.coerceAtLeast(0L))
|
|
826
853
|
} else {
|
|
827
854
|
if (contentLength > 0) contentLength else expectedSize
|
|
@@ -898,10 +925,37 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
898
925
|
notifyManager.notify(NOTIFICATION_ID, builder.build())
|
|
899
926
|
}
|
|
900
927
|
} catch (e: Exception) {
|
|
901
|
-
|
|
902
|
-
|
|
928
|
+
// Two conditions reach this catch that are NOT download failures and
|
|
929
|
+
// must therefore be suppressed from `update/error`:
|
|
930
|
+
//
|
|
931
|
+
// 1. A clearCache/clearApkCache cancel aborts the core workers,
|
|
932
|
+
// which surfaces here as an exception — intentional, not a
|
|
933
|
+
// failure (mirrors react-native-bundle-update).
|
|
934
|
+
// 2. A deferred verification (ApkVerificationDeferredException):
|
|
935
|
+
// the detached ASC was temporarily unreachable, so we couldn't
|
|
936
|
+
// decide whether the on-disk bytes are still the right APK. The
|
|
937
|
+
// bytes are preserved (rolled back to .partial) for the next
|
|
938
|
+
// online retry; this is a "retry later", not a download error.
|
|
939
|
+
// Detect it by TYPE, never by message string — the message is a
|
|
940
|
+
// constant today but type-matching can't silently rot.
|
|
941
|
+
//
|
|
942
|
+
// Both still rethrow so the JS Promise rejects (the caller learns the
|
|
943
|
+
// attempt didn't complete); only the spurious `update/error` event is
|
|
944
|
+
// withheld. For the deferred case we also downgrade the log to info so
|
|
945
|
+
// logs don't read like a failure.
|
|
946
|
+
val intentionallyCancelled = cancelHandle?.aborted?.get() == true
|
|
947
|
+
val verificationDeferred = e is ApkVerificationDeferredException
|
|
948
|
+
if (verificationDeferred) {
|
|
949
|
+
OneKeyLog.info("AppUpdate", "downloadAPK: verification deferred (ASC unavailable); bytes preserved for next online retry: ${e.message}")
|
|
950
|
+
} else {
|
|
951
|
+
OneKeyLog.error("AppUpdate", "downloadAPK: failed: ${e.javaClass.simpleName}: ${e.message}")
|
|
952
|
+
}
|
|
953
|
+
if (!intentionallyCancelled && !verificationDeferred) {
|
|
954
|
+
sendEvent("update/error", message = "${e.javaClass.simpleName}: ${e.message}")
|
|
955
|
+
}
|
|
903
956
|
throw e
|
|
904
957
|
} finally {
|
|
958
|
+
activeDownload.set(null)
|
|
905
959
|
isDownloading.set(false)
|
|
906
960
|
}
|
|
907
961
|
}
|
|
@@ -1089,7 +1143,18 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
1089
1143
|
OneKeyLog.info("AppUpdate", "verifyAPK: package name matches installed app")
|
|
1090
1144
|
}
|
|
1091
1145
|
|
|
1092
|
-
// Verify APK signing certificate matches the installed app
|
|
1146
|
+
// Verify APK signing certificate matches the installed app.
|
|
1147
|
+
//
|
|
1148
|
+
// This cross-check is defense-in-depth ONLY: by the time verifyAPK
|
|
1149
|
+
// runs, verifyASC has already proven the bytes on disk are the
|
|
1150
|
+
// authentic OneKey APK via GPG + SHA-256, and Android's own
|
|
1151
|
+
// PackageInstaller re-verifies the signing certificate at install
|
|
1152
|
+
// time. So a genuine MISMATCH is still hard-failed, but when the
|
|
1153
|
+
// platform can't read the archive's signers (null) we just log it
|
|
1154
|
+
// and silently skip — on some OEM ROMs (Huawei/EMUI Android 9–10)
|
|
1155
|
+
// and for large APKs getPackageArchiveInfo returns null signers
|
|
1156
|
+
// even for a perfectly valid APK, and blocking on that bricks the
|
|
1157
|
+
// update for those users.
|
|
1093
1158
|
OneKeyLog.info("AppUpdate", "verifyAPK: verifying APK signing certificate (API level=${Build.VERSION.SDK_INT})...")
|
|
1094
1159
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
1095
1160
|
val apkInfo = pm.getPackageArchiveInfo(file.absolutePath, PackageManager.GET_SIGNING_CERTIFICATES)
|
|
@@ -1097,9 +1162,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
1097
1162
|
val apkSigners = apkInfo?.signingInfo?.apkContentsSigners
|
|
1098
1163
|
val installedSigners = installedInfo?.signingInfo?.apkContentsSigners
|
|
1099
1164
|
if (apkSigners == null || installedSigners == null) {
|
|
1100
|
-
OneKeyLog.
|
|
1101
|
-
if (!debugBuild) throw Exception("SIGNATURE_UNAVAILABLE")
|
|
1102
|
-
OneKeyLog.warn("AppUpdate", "verifyAPK: DEBUG build — ignoring unavailable signatures")
|
|
1165
|
+
OneKeyLog.info("AppUpdate", "verifyAPK: signing info unavailable (apkSigners=${apkSigners != null}, installedSigners=${installedSigners != null}), skipping signing certificate check")
|
|
1103
1166
|
} else {
|
|
1104
1167
|
OneKeyLog.info("AppUpdate", "verifyAPK: APK signers count=${apkSigners.size}, installed signers count=${installedSigners.size}")
|
|
1105
1168
|
if (apkSigners.toSet() != installedSigners.toSet()) {
|
|
@@ -1118,9 +1181,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
1118
1181
|
val apkSignatures = apkInfo?.signatures
|
|
1119
1182
|
val installedSignatures = installedInfo?.signatures
|
|
1120
1183
|
if (apkSignatures == null || installedSignatures == null) {
|
|
1121
|
-
OneKeyLog.
|
|
1122
|
-
if (!debugBuild) throw Exception("SIGNATURE_UNAVAILABLE")
|
|
1123
|
-
OneKeyLog.warn("AppUpdate", "verifyAPK: DEBUG build — ignoring unavailable signatures")
|
|
1184
|
+
OneKeyLog.info("AppUpdate", "verifyAPK: legacy signatures unavailable (apkSignatures=${apkSignatures != null}, installedSignatures=${installedSignatures != null}), skipping signing certificate check")
|
|
1124
1185
|
} else {
|
|
1125
1186
|
OneKeyLog.info("AppUpdate", "verifyAPK: APK signatures count=${apkSignatures.size}, installed signatures count=${installedSignatures.size}")
|
|
1126
1187
|
if (apkSignatures.toSet() != installedSignatures.toSet()) {
|
|
@@ -1270,6 +1331,15 @@ n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ
|
|
|
1270
1331
|
* log lines so the two callers stay distinguishable in logcat.
|
|
1271
1332
|
*/
|
|
1272
1333
|
private fun wipeApkCacheFiles(tag: String, protectedPaths: Set<String> = emptySet()) {
|
|
1334
|
+
// OCDS §5.8: stop any in-flight concurrent download (shutdownNow +
|
|
1335
|
+
// awaitTermination) BEFORE deleting its .segN, so a still-running worker
|
|
1336
|
+
// cannot resurrect a just-deleted segment or write to a deleted FD. Only
|
|
1337
|
+
// the two external cleanup paths (clearCache / clearApkCache) call this;
|
|
1338
|
+
// it is never invoked mid-download.
|
|
1339
|
+
activeDownload.getAndSet(null)?.let {
|
|
1340
|
+
OneKeyLog.info("AppUpdate", "$tag: cancelling in-flight download before wipe")
|
|
1341
|
+
it.cancel()
|
|
1342
|
+
}
|
|
1273
1343
|
val context = NitroModules.applicationContext
|
|
1274
1344
|
if (context == null) {
|
|
1275
1345
|
OneKeyLog.warn("AppUpdate", "$tag: application context unavailable, skipping file cleanup")
|
package/android/src/test/java/com/margelo/nitro/reactnativeappupdate/AppUpdateContentRangeTest.kt
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
package com.margelo.nitro.reactnativeappupdate
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.assertEquals
|
|
4
|
+
import org.junit.Assert.assertFalse
|
|
5
|
+
import org.junit.Assert.assertNull
|
|
6
|
+
import org.junit.Assert.assertTrue
|
|
7
|
+
import org.junit.Test
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pure-JVM coverage for the "Content-Range / 416 header parsing" unit extracted
|
|
11
|
+
* out of ReactNativeAppUpdate.downloadAPK into [AppUpdateLogic].
|
|
12
|
+
*
|
|
13
|
+
* These exercise the REAL extracted code (regex literals copied character-for-
|
|
14
|
+
* character from the adapter — the 416 `bytes * /<total>` branch and the 206
|
|
15
|
+
* `bytes start-end/total` sanity check), never a re-implementation. The adapter
|
|
16
|
+
* keeps all File I/O / response.close / throw; only the pure string-parse and
|
|
17
|
+
* the two comparison predicates moved here, so this guards the parse step is
|
|
18
|
+
* byte-identical and the CDN-misalignment / 416-complete guards stay total.
|
|
19
|
+
*/
|
|
20
|
+
class AppUpdateContentRangeTest {
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// parse416Total — `Content-Range: bytes * /<total>` (the 416 branch, line 742)
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
@Test
|
|
27
|
+
fun parse416Total_extractsTotalFromUnsatisfiableRange() {
|
|
28
|
+
assertEquals(12345L, AppUpdateLogic.parse416Total("bytes */12345"))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Test
|
|
32
|
+
fun parse416Total_isTolerantOfWhitespaceAroundStarAndSlash() {
|
|
33
|
+
// The verbatim regex is `bytes\s+\*\s*/\s*(\d+)`, so spaces around `*` and
|
|
34
|
+
// `/` are all allowed.
|
|
35
|
+
assertEquals(99L, AppUpdateLogic.parse416Total("bytes * / 99"))
|
|
36
|
+
assertEquals(99L, AppUpdateLogic.parse416Total("bytes * /99"))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@Test
|
|
40
|
+
fun parse416Total_rejectsNormalContentRange() {
|
|
41
|
+
// A satisfiable `bytes 0-1/2` is NOT the unsatisfiable form → no total.
|
|
42
|
+
assertNull(AppUpdateLogic.parse416Total("bytes 0-1/2"))
|
|
43
|
+
assertNull(AppUpdateLogic.parse416Total("bytes 100-199/500"))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@Test
|
|
47
|
+
fun parse416Total_nullAndGarbageYieldNull() {
|
|
48
|
+
assertNull(AppUpdateLogic.parse416Total(null))
|
|
49
|
+
assertNull(AppUpdateLogic.parse416Total(""))
|
|
50
|
+
assertNull(AppUpdateLogic.parse416Total("garbage"))
|
|
51
|
+
assertNull(AppUpdateLogic.parse416Total("bytes */")) // no digits after slash
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// parse206ContentRange — `bytes start-end/total` (the 206 sanity check, l.808)
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
@Test
|
|
59
|
+
fun parse206ContentRange_parsesStartEndTotal() {
|
|
60
|
+
assertEquals(
|
|
61
|
+
Triple(100L, 199L, 500L),
|
|
62
|
+
AppUpdateLogic.parse206ContentRange("bytes 100-199/500"),
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@Test
|
|
67
|
+
fun parse206ContentRange_starTotalYieldsNullTotal() {
|
|
68
|
+
// `total` is `*` (server doesn't know the full length) → total parses null,
|
|
69
|
+
// but start/end are still recovered.
|
|
70
|
+
assertEquals(
|
|
71
|
+
Triple(0L, 99L, null),
|
|
72
|
+
AppUpdateLogic.parse206ContentRange("bytes 0-99/*"),
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@Test
|
|
77
|
+
fun parse206ContentRange_isTolerantOfWhitespace() {
|
|
78
|
+
// Verbatim regex `bytes\s+(\d+)\s*-\s*(\d+)\s*/\s*(\d+|\*)`.
|
|
79
|
+
assertEquals(
|
|
80
|
+
Triple(100L, 199L, 500L),
|
|
81
|
+
AppUpdateLogic.parse206ContentRange("bytes 100 - 199 / 500"),
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@Test
|
|
86
|
+
fun parse206ContentRange_garbageAndNullYieldNull() {
|
|
87
|
+
assertNull(AppUpdateLogic.parse206ContentRange("garbage"))
|
|
88
|
+
assertNull(AppUpdateLogic.parse206ContentRange(null))
|
|
89
|
+
assertNull(AppUpdateLogic.parse206ContentRange(""))
|
|
90
|
+
// The 416 unsatisfiable form is not a valid 206 range → no match.
|
|
91
|
+
assertNull(AppUpdateLogic.parse206ContentRange("bytes */12345"))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// is416Complete — the 416-recovery predicate (adapter comparison, line 743)
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
@Test
|
|
99
|
+
fun is416Complete_trueOnlyWhenTotalEqualsPartial() {
|
|
100
|
+
// Server says the whole APK is exactly what we already have → recover, don't wipe.
|
|
101
|
+
assertTrue(AppUpdateLogic.is416Complete(99L, 99L))
|
|
102
|
+
// Sizes differ → partial is stale/corrupt → not complete.
|
|
103
|
+
assertFalse(AppUpdateLogic.is416Complete(99L, 50L))
|
|
104
|
+
assertFalse(AppUpdateLogic.is416Complete(50L, 99L))
|
|
105
|
+
// No parseable total → cannot claim completeness.
|
|
106
|
+
assertFalse(AppUpdateLogic.is416Complete(null, 0L))
|
|
107
|
+
assertFalse(AppUpdateLogic.is416Complete(null, 99L))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// is206StartAligned — the CDN-misalignment guard (adapter check, lines 814)
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
@Test
|
|
115
|
+
fun is206StartAligned_trueOnlyWhenStartEqualsPartial() {
|
|
116
|
+
// 206 body starts exactly where we asked to resume → safe to append.
|
|
117
|
+
assertTrue(AppUpdateLogic.is206StartAligned(100L, 100L))
|
|
118
|
+
// Start != requested offset (CDN/proxy rewrote the range) → mis-aligned slice.
|
|
119
|
+
assertFalse(AppUpdateLogic.is206StartAligned(100L, 50L))
|
|
120
|
+
assertFalse(AppUpdateLogic.is206StartAligned(50L, 100L))
|
|
121
|
+
// Missing/unparseable start → demote to full restart, never append.
|
|
122
|
+
assertFalse(AppUpdateLogic.is206StartAligned(null, 0L))
|
|
123
|
+
assertFalse(AppUpdateLogic.is206StartAligned(null, 100L))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// End-to-end: parse → predicate, mirroring how the adapter chains them.
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
@Test
|
|
131
|
+
fun parse416ThenIs416Complete_matchesAdapterRecoveryDecision() {
|
|
132
|
+
// (a) total == partialBytes → recover.
|
|
133
|
+
val total = AppUpdateLogic.parse416Total("bytes */2048")
|
|
134
|
+
assertTrue(AppUpdateLogic.is416Complete(total, 2048L))
|
|
135
|
+
// (b) anything else → wipe.
|
|
136
|
+
assertFalse(AppUpdateLogic.is416Complete(total, 1024L))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@Test
|
|
140
|
+
fun parse206ThenIs206StartAligned_matchesAdapterStartCheck() {
|
|
141
|
+
val (start, _, _) = AppUpdateLogic.parse206ContentRange("bytes 4096-8191/8192")!!
|
|
142
|
+
assertTrue(AppUpdateLogic.is206StartAligned(start, 4096L))
|
|
143
|
+
assertFalse(AppUpdateLogic.is206StartAligned(start, 0L))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Sibling-segment invariant: segmentCount == 8 (the concurrent downloader's
|
|
148
|
+
// default that the 416/206 cleanup loops scan). Asserted over the REAL const.
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
@Test
|
|
152
|
+
fun concurrentSegmentCountIsEight() {
|
|
153
|
+
assertEquals(8, AppUpdateLogic.CONCURRENT_SEGMENT_COUNT)
|
|
154
|
+
assertEquals(8, AppUpdateLogic.segmentFileNames("/tmp/app.apk.partial").size)
|
|
155
|
+
assertEquals(
|
|
156
|
+
"/tmp/app.apk.partial.seg0",
|
|
157
|
+
AppUpdateLogic.segmentFileNames("/tmp/app.apk.partial").first(),
|
|
158
|
+
)
|
|
159
|
+
assertEquals(
|
|
160
|
+
"/tmp/app.apk.partial.seg7",
|
|
161
|
+
AppUpdateLogic.segmentFileNames("/tmp/app.apk.partial").last(),
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
}
|
package/android/src/test/java/com/margelo/nitro/reactnativeappupdate/AppUpdateLogicSegmentTest.kt
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
package com.margelo.nitro.reactnativeappupdate
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.assertEquals
|
|
4
|
+
import org.junit.Assert.assertTrue
|
|
5
|
+
import org.junit.Test
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pin tests for the extracted pure unit:
|
|
9
|
+
* "CONCURRENT_SEGMENT_COUNT constant (segment-count + segment-path derivation)".
|
|
10
|
+
*
|
|
11
|
+
* These assert directly against the REAL extracted code in [AppUpdateLogic]
|
|
12
|
+
* (never a re-implementation): the byte-identical-move constant and the
|
|
13
|
+
* faithful capture of the inlined "<partial>.seg<i>" string template that the
|
|
14
|
+
* adapter previously duplicated across 6 sites.
|
|
15
|
+
*/
|
|
16
|
+
class AppUpdateLogicSegmentTest {
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Pins the must-equal-core-default invariant.
|
|
20
|
+
*
|
|
21
|
+
* COUPLING NOTE: [AppUpdateLogic.CONCURRENT_SEGMENT_COUNT] must equal the
|
|
22
|
+
* shared `ConcurrentRangeDownloader`'s default `segmentCount = 8`. That core
|
|
23
|
+
* default is a PRIVATE constructor parameter default, not a public constant,
|
|
24
|
+
* so this test cannot reference it directly — it can only pin the documented
|
|
25
|
+
* magic number 8. If the core default ever changes, this hard-coded 8 is the
|
|
26
|
+
* tripwire that forces a human to re-sync both sides (otherwise the Phase-2
|
|
27
|
+
* scan would miss in-flight segment files written past index 7, or scan a
|
|
28
|
+
* dead range).
|
|
29
|
+
*/
|
|
30
|
+
@Test
|
|
31
|
+
fun concurrentSegmentCountEqualsCoreDefaultEight() {
|
|
32
|
+
assertEquals(8, AppUpdateLogic.CONCURRENT_SEGMENT_COUNT)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** segmentFileName is the verbatim "<partial>.seg<index>" template. */
|
|
36
|
+
@Test
|
|
37
|
+
fun segmentFileNameAppendsDotSegIndex() {
|
|
38
|
+
assertEquals(
|
|
39
|
+
"x.apk.partial.seg0",
|
|
40
|
+
AppUpdateLogic.segmentFileName("x.apk.partial", 0),
|
|
41
|
+
)
|
|
42
|
+
assertEquals(
|
|
43
|
+
"x.apk.partial.seg7",
|
|
44
|
+
AppUpdateLogic.segmentFileName("x.apk.partial", 7),
|
|
45
|
+
)
|
|
46
|
+
// The helper is a dumb string template: it does not validate the index, so
|
|
47
|
+
// any integer flows straight through (mirrors the inlined call sites).
|
|
48
|
+
assertEquals(
|
|
49
|
+
"x.apk.partial.seg42",
|
|
50
|
+
AppUpdateLogic.segmentFileName("x.apk.partial", 42),
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** segmentFileNames returns exactly CONCURRENT_SEGMENT_COUNT names, seg0..seg7. */
|
|
55
|
+
@Test
|
|
56
|
+
fun segmentFileNamesYieldsEightOrderedNamesSeg0ToSeg7() {
|
|
57
|
+
val names = AppUpdateLogic.segmentFileNames("x.apk.partial")
|
|
58
|
+
|
|
59
|
+
assertEquals(AppUpdateLogic.CONCURRENT_SEGMENT_COUNT, names.size)
|
|
60
|
+
assertEquals(8, names.size)
|
|
61
|
+
assertEquals("x.apk.partial.seg0", names.first())
|
|
62
|
+
assertEquals("x.apk.partial.seg7", names.last())
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The generated names are distinct and strictly ordered seg0..seg${N-1},
|
|
67
|
+
* each equal to the single-name helper for the same index. This guards the
|
|
68
|
+
* Phase-2 scan range: a duplicate or out-of-order name would make the
|
|
69
|
+
* in-flight-download detection scan the wrong sibling files.
|
|
70
|
+
*/
|
|
71
|
+
@Test
|
|
72
|
+
fun segmentFileNamesAreDistinctAndOrderedAndMatchSingleHelper() {
|
|
73
|
+
val partial = "/data/user/0/app/files/update.apk.partial"
|
|
74
|
+
val names = AppUpdateLogic.segmentFileNames(partial)
|
|
75
|
+
|
|
76
|
+
// distinct
|
|
77
|
+
assertEquals(names.size, names.toSet().size)
|
|
78
|
+
|
|
79
|
+
// ordered seg0..seg${N-1}, each consistent with segmentFileName(index)
|
|
80
|
+
for (i in 0 until AppUpdateLogic.CONCURRENT_SEGMENT_COUNT) {
|
|
81
|
+
assertEquals(AppUpdateLogic.segmentFileName(partial, i), names[i])
|
|
82
|
+
assertTrue(
|
|
83
|
+
"name at $i must end with .seg$i",
|
|
84
|
+
names[i].endsWith(".seg$i"),
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|