@onekeyfe/react-native-app-update 3.0.53 → 3.0.55

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.
@@ -18,8 +18,14 @@ import java.util.concurrent.atomic.AtomicReference
18
18
  *
19
19
  * Mirrors the desktop DesktopApiBundleUpdate concurrent path. This class is
20
20
  * intentionally free of Android/OneKey dependencies (logging is injected) so
21
- * it can be unit/type-checked standalone; the whole-file SHA256 check the
22
- * caller already performs after promotion is the final correctness backstop.
21
+ * it can be unit/type-checked standalone.
22
+ *
23
+ * LOAD-BEARING: the caller's whole-file SHA256 + GPG verify performed AFTER
24
+ * promotion is the final correctness backstop for this concurrent path and must
25
+ * NEVER be skipped. Resume here is best-effort (ETag-gated; no ETag => fresh
26
+ * start), and concurrent stitching of ranges has no per-segment integrity
27
+ * check — a mismatched or mixed file is only ever caught by that downstream
28
+ * whole-file verify. Removing it would let a corrupt/mixed APK reach install.
23
29
  *
24
30
  * Invariant: the manifest is only meaningful as metadata for an existing
25
31
  * `.partial`. Either both exist (resume) or neither does (fresh) — any other
@@ -147,8 +153,13 @@ internal class ConcurrentRangeDownloader(
147
153
  }
148
154
 
149
155
  // Single round-trip probe: a one-byte Range request that confirms Range
150
- // support and captures total size + ETag. OkHttp follows redirects (the
151
- // caller's client enforces HTTPS on each hop).
156
+ // support and captures total size + ETag. The caller's OkHttp client has
157
+ // redirects DISABLED (followRedirects(false)/followSslRedirects(false)), so
158
+ // a 3xx is not a 200/206 and probe() returns null -> Outcome.FALLBACK; the
159
+ // single-stream path (also redirect-disabled) then handles it.
160
+ // TODO: if APK origins ever start issuing redirects, decide deliberately
161
+ // whether to enable followSslRedirects(true) (HTTPS-only) here; until then
162
+ // redirecting origins simply fall back to single-stream.
152
163
  private fun probe(url: String): Probe? {
153
164
  return try {
154
165
  val req = Request.Builder().url(url).addHeader("Range", "bytes=0-0").build()
@@ -249,7 +260,18 @@ internal class ConcurrentRangeDownloader(
249
260
  val savedEtag = head.getOrNull(1)?.takeIf { it.isNotEmpty() }
250
261
  // Object must be identical to what's on disk and on the CDN.
251
262
  if (savedSize != total || partialSize != total) return null
252
- if (etag != null && savedEtag != null && etag != savedEtag) return null
263
+ // Without an ETag we have no strong validator that the bytes already
264
+ // on disk belong to the SAME build as the one the CDN is serving now.
265
+ // A same-size-but-different build (common during a staged rollout)
266
+ // would otherwise resume by stitching old + new bytes into one file.
267
+ // The downstream whole-file SHA256 + GPG verify WOULD reject that
268
+ // mixed file, but only after we've wasted the whole download. So treat
269
+ // an ETag-less manifest as untrustworthy across restarts: bail out
270
+ // here and let the caller start fresh.
271
+ // (If a weaker validator is ever wanted, persist+compare Last-Modified
272
+ // instead of dropping outright.)
273
+ if (etag == null || savedEtag == null) return null
274
+ if (etag != savedEtag) return null
253
275
  val parts = ArrayList<Part>()
254
276
  for (idx in 1 until lines.size) {
255
277
  val cols = lines[idx].split(',')
@@ -25,6 +25,7 @@ import java.io.InputStreamReader
25
25
  import java.util.concurrent.CopyOnWriteArrayList
26
26
  import java.util.concurrent.TimeUnit
27
27
  import java.util.concurrent.atomic.AtomicBoolean
28
+ import java.util.concurrent.atomic.AtomicInteger
28
29
  import java.util.concurrent.atomic.AtomicLong
29
30
  import com.margelo.nitro.reactnativebundlecrypto.BundleCryptoCore
30
31
 
@@ -518,8 +519,24 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
518
519
  }
519
520
 
520
521
  // Phase 2 — pick up an in-flight partial.
522
+ //
523
+ // A concurrent (multi-range) partial is pre-allocated to the FULL
524
+ // size up front (RandomAccessFile.setLength(total)) and tracks the
525
+ // real, durably-written cursor only in its sidecar
526
+ // "<partial>.progress" manifest — the .partial itself is zero-filled
527
+ // past the real data. The size-based classification below is blind
528
+ // to that: it would see partialSize == expectedSize and try to
529
+ // promote+SHA-verify a mostly-zeroed file, hit HashMismatch, and
530
+ // DELETE it — nuking every interrupted concurrent download back to
531
+ // byte 0. So when the manifest exists, skip the size-based
532
+ // promote/discard branches entirely and let the concurrent
533
+ // downloader below own the file: it resumes from the manifest (or
534
+ // returns FALLBACK and hands the bytes back to single-stream).
535
+ // The path must match exactly what ConcurrentRangeDownloader writes:
536
+ // File("$partialFilePath.progress").
537
+ val hasConcurrentManifest = buildFile("$partialFilePath.progress").exists()
521
538
  var partialBytes = 0L
522
- if (partialFile.exists()) {
539
+ if (partialFile.exists() && !hasConcurrentManifest) {
523
540
  val partialSize = partialFile.length()
524
541
  when {
525
542
  expectedSize > 0 && partialSize == expectedSize -> {
@@ -575,23 +592,36 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
575
592
  .followRedirects(false)
576
593
  .followSslRedirects(false)
577
594
  .build()
578
- var concurrentProgress = -1
595
+ // onProgress is invoked concurrently from all 8 worker
596
+ // threads. concurrentProgress must be an AtomicInteger so the
597
+ // "did the percent advance?" check is race-free, and the
598
+ // NotificationCompat.Builder (shared, NOT thread-safe) plus the
599
+ // notify() call must be guarded by a single lock so two threads
600
+ // never mutate/build it at once. We emit at most once per
601
+ // percent step: getAndSet returns the previous value, and only
602
+ // the thread that actually moved the percent forward proceeds.
603
+ val concurrentProgress = AtomicInteger(-1)
604
+ val notifyLock = Any()
579
605
  val concurrentOutcome = ConcurrentRangeDownloader(
580
606
  httpClient = concurrentClient,
581
607
  log = { msg -> OneKeyLog.info("AppUpdate", msg) },
582
608
  ).download(url, partialFilePath) { transferred, total ->
583
609
  if (total > 0) {
584
610
  val p = ((transferred * 100) / total).toInt().coerceIn(0, 100)
585
- if (p != concurrentProgress) {
611
+ // Only the thread that advances the percent emits; a
612
+ // stale/lower p (out-of-order delivery) is dropped.
613
+ val prev = concurrentProgress.get()
614
+ if (p > prev && concurrentProgress.compareAndSet(prev, p)) {
586
615
  sendEvent("update/downloading", progress = p)
587
- builder.setProgress(100, p, false)
588
- if (ActivityCompat.checkSelfPermission(
589
- context, android.Manifest.permission.POST_NOTIFICATIONS
590
- ) == PackageManager.PERMISSION_GRANTED
591
- ) {
592
- notifyManager.notify(NOTIFICATION_ID, builder.build())
616
+ synchronized(notifyLock) {
617
+ builder.setProgress(100, p, false)
618
+ if (ActivityCompat.checkSelfPermission(
619
+ context, android.Manifest.permission.POST_NOTIFICATIONS
620
+ ) == PackageManager.PERMISSION_GRANTED
621
+ ) {
622
+ notifyManager.notify(NOTIFICATION_ID, builder.build())
623
+ }
593
624
  }
594
- concurrentProgress = p
595
625
  }
596
626
  }
597
627
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-app-update",
3
- "version": "3.0.53",
3
+ "version": "3.0.55",
4
4
  "description": "react-native-app-update",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",