@onekeyfe/react-native-app-update 3.0.67 → 3.0.69

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.
@@ -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
+ }
@@ -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
- // Must match ConcurrentRangeDownloader's default segmentCount: the
50
- // concurrent downloader writes sibling segment files
51
- // "<partial>.seg0".."<partial>.seg${N-1}", and Phase 2 below scans this
52
- // range to detect an in-flight concurrent download.
53
- private const val CONCURRENT_SEGMENT_COUNT = 8
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: Exception) {
293
- OneKeyLog.warn("AppUpdate", "verifyExistingApk: unexpected failure: ${e.javaClass.simpleName}: ${e.message}")
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("$partialFilePath.seg$i").delete()
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("$partialFilePath.seg$i").delete()
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("$partialFilePath.seg$i").delete()
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("$partialFilePath.seg$it").exists() }
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("$partialFilePath.seg$i").delete()
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
- ?.let { Regex("""bytes\s+\*\s*/\s*(\d+)""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() }
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("$partialFilePath.seg$i").delete()
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("$partialFilePath.seg$i").delete()
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("$partialFilePath.seg$i").delete()
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
- val rangeRegex = Regex("""bytes\s+(\d+)\s*-\s*(\d+)\s*/\s*(\d+|\*)""")
795
- val rangeMatch = if (serverWillResume) {
796
- response.header("Content-Range")?.let { rangeRegex.find(it) }
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 = rangeMatch?.groupValues?.getOrNull(1)?.toLongOrNull()
800
- if (rangeStart == null || rangeStart != partialBytes) {
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("$partialFilePath.seg$i").delete()
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 = rangeMatch?.groupValues?.getOrNull(3)?.toLongOrNull()
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
- OneKeyLog.error("AppUpdate", "downloadAPK: failed: ${e.javaClass.simpleName}: ${e.message}")
902
- sendEvent("update/error", message = "${e.javaClass.simpleName}: ${e.message}")
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.error("AppUpdate", "verifyAPK: signing info unavailable (apkSigners=${apkSigners != null}, installedSigners=${installedSigners != null})")
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.error("AppUpdate", "verifyAPK: legacy signatures unavailable")
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")
@@ -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
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-app-update",
3
- "version": "3.0.67",
3
+ "version": "3.0.69",
4
4
  "description": "react-native-app-update",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",