@onekeyfe/react-native-range-downloader 3.0.67 → 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.
Files changed (44) hide show
  1. package/README.md +15 -0
  2. package/android/build.gradle +3 -0
  3. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt +179 -11
  4. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogic.kt +98 -0
  5. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +24 -25
  6. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloaderOcdsTest.kt +554 -0
  7. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FaultServer.kt +282 -0
  8. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/IsPermanentHttpStatusTest.kt +63 -0
  9. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/MonotonicProgressGateTest.kt +368 -0
  10. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/Ocds416ResumeTest.kt +272 -0
  11. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsBadTotalRejectTest.kt +147 -0
  12. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsMultipartRejectTest.kt +114 -0
  13. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsReadOnlyFsTest.kt +250 -0
  14. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsTransient5xxTest.kt +275 -0
  15. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogicTest.kt +124 -0
  16. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RunRegistrySingleFlightTest.kt +217 -0
  17. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SegmentArtifactSweepTest.kt +350 -0
  18. package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SmokeTest.kt +65 -0
  19. package/ios/RangeDownloadLogic.swift +187 -0
  20. package/ios/ReactNativeRangeDownloader.swift +669 -133
  21. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts +7 -1
  22. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts.map +1 -1
  23. package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.cpp +4 -0
  24. package/nitrogen/generated/android/c++/JRangeDownloadOutcome.hpp +6 -0
  25. package/nitrogen/generated/android/c++/JRangeDownloadParams.hpp +19 -3
  26. package/nitrogen/generated/android/c++/JRangeDownloadResult.hpp +9 -3
  27. package/nitrogen/generated/android/c++/JRangeFallbackKind.hpp +83 -0
  28. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadOutcome.kt +3 -1
  29. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadParams.kt +15 -3
  30. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadResult.kt +6 -3
  31. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeFallbackKind.kt +29 -0
  32. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.hpp +18 -0
  33. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Umbrella.hpp +3 -0
  34. package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.hpp +3 -0
  35. package/nitrogen/generated/ios/swift/RangeDownloadOutcome.swift +8 -0
  36. package/nitrogen/generated/ios/swift/RangeDownloadParams.swift +93 -1
  37. package/nitrogen/generated/ios/swift/RangeDownloadResult.swift +24 -1
  38. package/nitrogen/generated/ios/swift/RangeFallbackKind.swift +72 -0
  39. package/nitrogen/generated/shared/c++/RangeDownloadOutcome.hpp +9 -1
  40. package/nitrogen/generated/shared/c++/RangeDownloadParams.hpp +18 -2
  41. package/nitrogen/generated/shared/c++/RangeDownloadResult.hpp +9 -2
  42. package/nitrogen/generated/shared/c++/RangeFallbackKind.hpp +108 -0
  43. package/package.json +1 -1
  44. package/src/ReactNativeRangeDownloader.nitro.ts +36 -2
package/README.md CHANGED
@@ -39,6 +39,21 @@ try {
39
39
  }
40
40
  ```
41
41
 
42
+ ## Specification
43
+
44
+ The behavior of the concurrent downloader is governed by a normative,
45
+ platform-agnostic standard that **all** implementations (iOS, Android, Desktop,
46
+ and any future platform) MUST conform to:
47
+
48
+ **→ [OneKey Concurrent Download Standard (OCDS)](./SPEC.md)**
49
+
50
+ Any change to download behavior on any platform must be checked against OCDS.
51
+ When an implementation and the standard disagree, the implementation is wrong.
52
+
53
+ How each platform (Node / Android / iOS) is verified against OCDS — and the
54
+ runnable verification code to re-run after any downloader change — lives in
55
+ **→ [`conformance/`](./conformance/README.md)**.
56
+
42
57
  ## Contributing
43
58
 
44
59
  - [Development workflow](CONTRIBUTING.md#development-workflow)
@@ -129,4 +129,7 @@ dependencies {
129
129
  implementation project(":onekeyfe_react-native-native-logger")
130
130
 
131
131
  implementation "com.squareup.okhttp3:okhttp:4.12.0"
132
+
133
+ testImplementation "junit:junit:4.13.2"
134
+ testImplementation "com.squareup.okhttp3:mockwebserver:4.12.0"
132
135
  }
@@ -49,6 +49,12 @@ class ConcurrentRangeDownloader(
49
49
  private val segmentCount: Int = 8,
50
50
  private val minConcurrentBytes: Long = 2L * 1024 * 1024,
51
51
  private val maxPartRetry: Int = 3,
52
+ // Per-segment retry backoff (OCDS §5.4). Doubles each attempt up to a cap,
53
+ // with full jitter so N segments do not retry in lockstep. A server
54
+ // `Retry-After` overrides these. Caller-tunable; defaults are config, not
55
+ // part of the standard.
56
+ private val retryBaseDelayMillis: Long = 500L,
57
+ private val retryMaxDelayMillis: Long = 8_000L,
52
58
  private val log: (String) -> Unit = {},
53
59
  ) {
54
60
  enum class Outcome {
@@ -62,6 +68,27 @@ class ConcurrentRangeDownloader(
62
68
  /** Thrown internally when a segment proves concurrency can't be used. */
63
69
  private class FallbackException(message: String) : Exception(message)
64
70
 
71
+ /**
72
+ * Thrown for an HTTP status that is permanently unrecoverable for this URL
73
+ * (auth/expired-signed-URL `401`/`403`, gone `404`/`410`, `501`/`505`, and
74
+ * any other non-retryable 4xx per OCDS §4's catch-all). Unlike a generic
75
+ * transient [java.io.IOException], this MUST bypass the per-segment retry
76
+ * loop ([downloadSegment]) — retrying a dead URL only wastes the attempt
77
+ * budget. The `HTTP <code>` message shape is preserved so the JS error
78
+ * taxonomy ([updateErrorTaxonomy.ts]) maps it to `HTTP_<code>`.
79
+ */
80
+ private class PermanentHttpException(val code: Int) :
81
+ Exception("HTTP $code")
82
+
83
+ /**
84
+ * Typed transient carrying an optional `Retry-After` delay (milliseconds)
85
+ * the server asked us to wait before retrying (`429`/`503`). The retry loop
86
+ * in [downloadSegment] prefers this over its computed backoff. Keeps the
87
+ * `HTTP <code>` message shape for the JS taxonomy.
88
+ */
89
+ private class TransientHttpException(val code: Int, val retryAfterMillis: Long?) :
90
+ java.io.IOException("HTTP $code")
91
+
65
92
  /**
66
93
  * Cooperative-cancel handle the caller can register a download against. The
67
94
  * adapter keeps these in a per-taskId registry so `cancel`/`discardArtifacts`
@@ -80,10 +107,30 @@ class ConcurrentRangeDownloader(
80
107
  if (aborted.get()) pool.shutdownNow()
81
108
  }
82
109
 
83
- /** Flip the abort flag and stop the worker pool. Idempotent. */
110
+ /**
111
+ * Flip the abort flag and stop the worker pool, then wait (bounded) for
112
+ * in-flight workers to actually terminate. `shutdownNow()` only
113
+ * *interrupts*; a worker blocked in a native `write()` may run a moment
114
+ * longer, so without this wait the caller's subsequent `.segN` delete
115
+ * could be resurrected by that straggler — the exact race §5.8 forbids.
116
+ * Idempotent.
117
+ */
84
118
  fun cancel() {
85
119
  aborted.set(true)
86
- pool?.shutdownNow()
120
+ pool?.let { p ->
121
+ p.shutdownNow()
122
+ try {
123
+ p.awaitTermination(AWAIT_TERMINATION_SECONDS, java.util.concurrent.TimeUnit.SECONDS)
124
+ } catch (e: InterruptedException) {
125
+ // Preserve the interrupt status; the bounded wait is best-effort.
126
+ Thread.currentThread().interrupt()
127
+ }
128
+ }
129
+ }
130
+
131
+ private companion object {
132
+ /** Bounded wait for worker termination on cancel (cancel-then-delete). */
133
+ const val AWAIT_TERMINATION_SECONDS = 3L
87
134
  }
88
135
  }
89
136
 
@@ -181,7 +228,7 @@ class ConcurrentRangeDownloader(
181
228
  val futures = pending.map { part ->
182
229
  pool.submit {
183
230
  try {
184
- downloadSegment(url, segFile(part.index), part, aborted) { delta ->
231
+ downloadSegment(url, segFile(part.index), part, total, aborted) { delta ->
185
232
  onProgress(transferred.addAndGet(delta), total)
186
233
  }
187
234
  } catch (e: FallbackException) {
@@ -318,6 +365,13 @@ class ConcurrentRangeDownloader(
318
365
  cursor = segEndInFinal
319
366
  segFile(part.index).delete()
320
367
  }
368
+ // Force the assembled `.partial` durable before the caller renames it
369
+ // to the final path (OCDS §5.2: assemble → durable flush → atomic
370
+ // rename). `out.flush()` above only pushes the JVM/libc buffers to the
371
+ // kernel; without this fsync a power loss between the rename and the
372
+ // kernel's writeback could promote a `.partial` whose tail bytes never
373
+ // reached the platter, yielding a final file that fails SHA256.
374
+ out.fd.sync()
321
375
  }
322
376
  if (partialFile.length() != total) {
323
377
  partialFile.delete()
@@ -333,6 +387,7 @@ class ConcurrentRangeDownloader(
333
387
  url: String,
334
388
  segFile: File,
335
389
  part: Part,
390
+ total: Long,
336
391
  aborted: AtomicBoolean,
337
392
  onBytes: (delta: Long) -> Unit,
338
393
  ) {
@@ -343,22 +398,60 @@ class ConcurrentRangeDownloader(
343
398
  if (have >= part.length) return
344
399
  val rangeStart = part.start + have
345
400
  try {
346
- fetchSegment(url, segFile, part, rangeStart, aborted, onBytes)
401
+ fetchSegment(url, segFile, part, total, rangeStart, aborted, onBytes)
347
402
  return
348
403
  } catch (e: FallbackException) {
349
404
  throw e
405
+ } catch (e: PermanentHttpException) {
406
+ // Dead URL (auth/gone/non-retryable) — retrying only burns the
407
+ // attempt budget. Surface immediately; the caller maps the
408
+ // `HTTP <code>` message to a permanent JS taxonomy bucket.
409
+ throw e
350
410
  } catch (e: Exception) {
351
411
  if (aborted.get() || retry >= maxPartRetry) throw e
352
412
  retry += 1
353
- log("concurrent: segment ${part.index} retry $retry: ${e.javaClass.simpleName}")
413
+ // Prefer the server's Retry-After; otherwise exponential backoff
414
+ // with full jitter so the 8 segments do not retry in lockstep.
415
+ val retryAfter = (e as? TransientHttpException)?.retryAfterMillis
416
+ val delay = retryAfter ?: computeBackoffMillis(retry)
417
+ log("concurrent: segment ${part.index} retry $retry in ${delay}ms: ${e.javaClass.simpleName}")
418
+ sleepAbortable(delay, aborted)
354
419
  }
355
420
  }
356
421
  }
357
422
 
423
+ // Exponential backoff (base * 2^(attempt-1), capped) with full jitter:
424
+ // a uniformly random delay in [0, ceiling]. Full jitter is what actually
425
+ // de-correlates the N segments — without it they would retry in lockstep.
426
+ private fun computeBackoffMillis(attempt: Int): Long {
427
+ val exp = retryBaseDelayMillis shl (attempt - 1).coerceIn(0, 16)
428
+ val ceiling = exp.coerceAtMost(retryMaxDelayMillis).coerceAtLeast(1L)
429
+ return (Math.random() * ceiling).toLong().coerceAtLeast(0L)
430
+ }
431
+
432
+ // Sleep in short slices so an external cancel() (which flips `aborted` and
433
+ // shutdownNow()s the pool) is observed promptly instead of after a multi-
434
+ // second backoff. Throws on abort so the loop bails immediately.
435
+ private fun sleepAbortable(totalMillis: Long, aborted: AtomicBoolean) {
436
+ var remaining = totalMillis
437
+ while (remaining > 0) {
438
+ if (aborted.get()) throw java.io.IOException("aborted")
439
+ val slice = minOf(remaining, 100L)
440
+ try {
441
+ Thread.sleep(slice)
442
+ } catch (e: InterruptedException) {
443
+ Thread.currentThread().interrupt()
444
+ throw java.io.IOException("aborted")
445
+ }
446
+ remaining -= slice
447
+ }
448
+ }
449
+
358
450
  private fun fetchSegment(
359
451
  url: String,
360
452
  segFile: File,
361
453
  part: Part,
454
+ total: Long,
362
455
  rangeStart: Long,
363
456
  aborted: AtomicBoolean,
364
457
  onBytes: (delta: Long) -> Unit,
@@ -374,7 +467,19 @@ class ConcurrentRangeDownloader(
374
467
  throw FallbackException("server returned 200 to a Range request")
375
468
  }
376
469
  if (response.code != 206) {
377
- throw java.io.IOException("HTTP ${response.code}")
470
+ // OCDS §4 classification. Anything that is not a usable 206 is
471
+ // either a permanently-dead URL (bypass retries) or a transient
472
+ // condition (retry with backoff). The `HTTP <code>` message shape
473
+ // is preserved on both so the JS taxonomy maps it to HTTP_<code>.
474
+ throw classifyHttpFailure(response)
475
+ }
476
+ // Reject a multipart/byteranges body: it carries range delimiters and
477
+ // (potentially) more than the single window we asked for, so streaming
478
+ // it raw into the segment file would splice in boundary bytes. This is
479
+ // not a usable single-range 206 — fall back to single-stream.
480
+ val contentType = response.header("Content-Type")?.lowercase()
481
+ if (contentType != null && contentType.startsWith("multipart/byteranges")) {
482
+ throw FallbackException("server returned multipart/byteranges to a single Range request")
378
483
  }
379
484
  // Verify the 206 covers exactly the slice we asked for. This guards
380
485
  // against a proxy/CDN returning a mis-aligned 206 (wrong window),
@@ -392,6 +497,17 @@ class ConcurrentRangeDownloader(
392
497
  "expected $rangeStart-${part.end}"
393
498
  )
394
499
  }
500
+ // The 206's `Content-Range` total (the `/<total>` tail) MUST agree
501
+ // with the probe total. A disagreeing concrete total means the object
502
+ // changed size behind us (different build) — that is permanent for
503
+ // this window; an unknown `*` total cannot be reconciled either. Both
504
+ // make concurrency unusable → fall back to single-stream.
505
+ val parsedTotal = bounds.third
506
+ if (parsedTotal == null || parsedTotal != total) {
507
+ throw FallbackException(
508
+ "Content-Range total mismatch: got ${parsedTotal ?: "*"}, expected $total"
509
+ )
510
+ }
395
511
  val body = response.body ?: throw java.io.IOException("Empty segment body")
396
512
  // Append the fetched tail to the segment file. Append mode keeps
397
513
  // resume correct: we only ever request the bytes not yet on disk.
@@ -419,12 +535,64 @@ class ConcurrentRangeDownloader(
419
535
  }
420
536
  }
421
537
 
422
- // Parse "bytes <start>-<end>/<total>" → (start, end). Returns null when the
423
- // header is absent-of-bounds (e.g. "bytes */1234") or otherwise unparseable.
424
- private fun parseContentRangeBounds(value: String): Pair<Long, Long>? {
425
- val m = Regex("""bytes\s+(\d+)-(\d+)/""").find(value) ?: return null
538
+ // Parse "bytes <start>-<end>/<total>" → (start, end, total). Returns null
539
+ // when the header lacks concrete bounds (e.g. "bytes */1234") or is otherwise
540
+ // unparseable. `total` is null when the total is the unknown `*` form
541
+ // ("bytes <start>-<end>/*"), which the caller treats as a disagreeing total.
542
+ private fun parseContentRangeBounds(value: String): Triple<Long, Long, Long?>? {
543
+ val m = Regex("""bytes\s+(\d+)-(\d+)/(\d+|\*)""").find(value) ?: return null
426
544
  val start = m.groupValues[1].toLongOrNull() ?: return null
427
545
  val end = m.groupValues[2].toLongOrNull() ?: return null
428
- return start to end
546
+ val total = m.groupValues[3].let { if (it == "*") null else it.toLongOrNull() }
547
+ return Triple(start, end, total)
548
+ }
549
+
550
+ // OCDS §4 status classifier for a non-206, non-200 status: returns a
551
+ // [PermanentHttpException] (bypasses the per-segment retry loop) or a
552
+ // [TransientHttpException] (retried with backoff/Retry-After). The pure
553
+ // status→class decision lives in the module-visible [isPermanentHttpStatus]
554
+ // top-level function so it can be unit-tested without an okhttp Response.
555
+ private fun classifyHttpFailure(response: okhttp3.Response): Exception {
556
+ val code = response.code
557
+ if (isPermanentHttpStatus(code)) return PermanentHttpException(code)
558
+ return TransientHttpException(code, parseRetryAfterMillis(response.header("Retry-After")))
559
+ }
560
+
561
+ // Parse a `Retry-After` header into milliseconds. Only the delta-seconds form
562
+ // is honored (the absolute HTTP-date form is rarely sent for 429/503 and not
563
+ // worth a date parser here); an unparseable/absent value yields null so the
564
+ // caller falls back to its computed backoff.
565
+ private fun parseRetryAfterMillis(value: String?): Long? {
566
+ val seconds = value?.trim()?.toLongOrNull() ?: return null
567
+ if (seconds < 0) return null
568
+ return (seconds * 1000L).coerceAtMost(retryMaxDelayMillis)
429
569
  }
430
570
  }
571
+
572
+ /**
573
+ * OCDS §4 HTTP-status classification. `true` = permanent (concurrency is
574
+ * fundamentally unusable for this object → discard artifacts, fall back to
575
+ * single-stream); `false` = transient (keep artifacts, retry). Pure and
576
+ * module-visible so it is unit-tested without constructing an okhttp Response.
577
+ * 200 (fallback) and 206 (proceed) are handled before a failure reaches the
578
+ * classifier, so they are not represented here.
579
+ *
580
+ * 401/403/404/410 → permanent (auth / expired signed URL / gone)
581
+ * 408/429 → transient (timeout / throttling)
582
+ * 416 → transient (resume; total size re-evaluated by the caller)
583
+ * other 4xx → permanent (catch-all default)
584
+ * 501/505 → permanent; other 5xx → transient (back off and retry)
585
+ * anything else → permanent (unknown → permanent, per §4)
586
+ */
587
+ internal fun isPermanentHttpStatus(code: Int): Boolean = when (code) {
588
+ 401, 403, 404, 410 -> true
589
+ 408, 429 -> false
590
+ // 416 MUST stay above `in 400..499` (when matches top-to-bottom; 416 is a
591
+ // member of that range). Treating 416 as permanent would discard resumable
592
+ // `.segN` bytes and restart from byte 0 — the §4 "most damaging mistake".
593
+ 416 -> false
594
+ in 400..499 -> true
595
+ 501, 505 -> true
596
+ in 500..599 -> false
597
+ else -> true
598
+ }
@@ -0,0 +1,98 @@
1
+ package com.margelo.nitro.reactnativerangedownloader
2
+
3
+ import java.io.File
4
+ import java.util.concurrent.ConcurrentHashMap
5
+
6
+ // Dependency-free RangeDownloader adapter logic.
7
+ //
8
+ // This file holds the DETERMINISTIC, dependency-light pieces of the Nitro
9
+ // adapter (ReactNativeRangeDownloader): the run-key derivation that pins
10
+ // single-flight semantics, the CAS gate that keeps progress monotonic and
11
+ // de-duped, and the per-segment artifact sweep. They were extracted VERBATIM
12
+ // (bodies unchanged) from `ReactNativeRangeDownloader.kt` so they can be
13
+ // compiled and unit-tested WITHOUT the NitroModules / OneKeyLog dependencies
14
+ // or the HybridObject JNI base class (which is device-only).
15
+ //
16
+ // `ReactNativeRangeDownloader` keeps owning the Nitro wiring, OkHttp client,
17
+ // Promise.async flows, sendEvent, SHA/promote — it only DELEGATES these three
18
+ // behaviors via `RangeDownloadLogic.<fn>`. Mirrors the iOS `RangeDownloadLogic`
19
+ // extraction. Everything here is pure Kotlin + java.io.File.
20
+ object RangeDownloadLogic {
21
+
22
+ // Single-flight key: active downloads are keyed by "channel|taskId" so
23
+ // cancel/discardArtifacts can flip the abort flag + stop the worker pool
24
+ // BEFORE deleting files, instead of racing live workers that would resurrect
25
+ // a just-deleted .partial. Channel is identified by its enum name.
26
+ fun runKey(channelName: String, taskId: String): String = "$channelName|$taskId"
27
+
28
+ // Single-flight registry of in-flight downloads keyed by "channel|taskId".
29
+ //
30
+ // Extracted VERBATIM from the adapter's inline `activeDownloads`
31
+ // ConcurrentHashMap usage so the keyed single-flight invariants can be unit
32
+ // tested WITHOUT the Nitro / JNI / OkHttp dependencies. The adapter delegates
33
+ // to one instance; behaviour is byte-for-byte the same:
34
+ // - [start] registers a fresh handle under the key, overwriting any prior
35
+ // one (dedup: `map[key] = handle`, adapter:87).
36
+ // - [finish] does an IDENTITY-checked `map.remove(key, handle)` so a
37
+ // concurrent [cancel] that already replaced the handle is NOT clobbered
38
+ // (adapter:112 invariant: "only deregister our own handle").
39
+ // - [cancel] atomically removes + `.cancel()`s the live handle if present
40
+ // (adapter:206).
41
+ //
42
+ // ConcurrentHashMap gives the atomic put / identity-remove / remove primitives;
43
+ // no extra lock is needed.
44
+ class RunRegistry {
45
+ private val active =
46
+ ConcurrentHashMap<String, ConcurrentRangeDownloader.CancelHandle>()
47
+
48
+ /** Live entry count — for assertions/diagnostics (no leaked keys on success/cancel). */
49
+ val size: Int get() = active.size
50
+
51
+ /** Register a fresh handle for [channelName]|[taskId], overwriting any prior. */
52
+ fun start(channelName: String, taskId: String): ConcurrentRangeDownloader.CancelHandle {
53
+ val handle = ConcurrentRangeDownloader.CancelHandle()
54
+ active[runKey(channelName, taskId)] = handle
55
+ return handle
56
+ }
57
+
58
+ /**
59
+ * Deregister [handle] for the key — but ONLY if it is still the live handle.
60
+ * A concurrent [cancel] that replaced it must not be clobbered.
61
+ */
62
+ fun finish(channelName: String, taskId: String, handle: ConcurrentRangeDownloader.CancelHandle) {
63
+ active.remove(runKey(channelName, taskId), handle)
64
+ }
65
+
66
+ /** Remove + cancel the live handle for the key, if any. No-op when absent. */
67
+ fun cancel(channelName: String, taskId: String) {
68
+ active.remove(runKey(channelName, taskId))?.cancel()
69
+ }
70
+ }
71
+
72
+ // CAS gate for progress events. The progress callback is invoked concurrently
73
+ // by the helper's worker threads; only the thread that advances the
74
+ // percentage to a strictly higher value should emit, which keeps progress
75
+ // monotonic and de-duped without a lock (this only affects event ordering,
76
+ // never file bytes).
77
+ //
78
+ // Returns the percentage to emit, or null when this transfer/total tuple does
79
+ // not advance past [previousProgress] (no event). Callers feed the result
80
+ // back as the new previous via their AtomicInteger CAS.
81
+ fun progressPercent(transferred: Long, total: Long): Int? {
82
+ if (total <= 0) return null
83
+ return ((transferred * 100) / total).toInt().coerceIn(0, 100)
84
+ }
85
+
86
+ // Delete every sibling artifact for [destFilePath]: all `<dest>.partial.seg<N>`
87
+ // segment files (matched by filename prefix, so any segmentCount is swept, not
88
+ // just the shipped default) plus the concatenated `<dest>.partial` itself.
89
+ // Glob by filename prefix so a future resume can't re-trust stale bytes (no
90
+ // `.progress` manifest exists anymore in the segmented model).
91
+ fun sweepPartialArtifacts(destFilePath: String) {
92
+ val partial = File("$destFilePath.partial")
93
+ partial.parentFile
94
+ ?.listFiles { f -> f.name.startsWith(partial.name + ".seg") }
95
+ ?.forEach { it.delete() }
96
+ partial.delete()
97
+ }
98
+ }
@@ -6,7 +6,6 @@ import com.margelo.nitro.core.Promise
6
6
  import com.margelo.nitro.nativelogger.OneKeyLog
7
7
  import java.io.File
8
8
  import java.security.MessageDigest
9
- import java.util.concurrent.ConcurrentHashMap
10
9
  import java.util.concurrent.CopyOnWriteArrayList
11
10
  import java.util.concurrent.atomic.AtomicLong
12
11
 
@@ -37,12 +36,10 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() {
37
36
 
38
37
  // Active downloads keyed by "channel|taskId" so cancel/discardArtifacts can
39
38
  // flip the abort flag + stop the worker pool BEFORE deleting files, instead of
40
- // racing live workers that would resurrect a just-deleted .partial.
41
- private val activeDownloads =
42
- ConcurrentHashMap<String, ConcurrentRangeDownloader.CancelHandle>()
43
-
44
- private fun runKey(channel: DownloadChannel, taskId: String): String =
45
- "${channel.name}|$taskId"
39
+ // racing live workers that would resurrect a just-deleted .partial. The keyed
40
+ // single-flight logic lives in RangeDownloadLogic.RunRegistry (dependency-free,
41
+ // unit-tested); this class only supplies the channel/taskId.
42
+ private val activeDownloads = RangeDownloadLogic.RunRegistry()
46
43
 
47
44
  // HTTPS-only client: reject any redirect to a non-HTTPS hop. Mirrors the
48
45
  // existing react-native-bundle-update configuration verbatim.
@@ -82,9 +79,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() {
82
79
 
83
80
  sendEvent(channel, taskId, type = "start")
84
81
 
85
- val runKey = runKey(channel, taskId)
86
- val cancelHandle = ConcurrentRangeDownloader.CancelHandle()
87
- activeDownloads[runKey] = cancelHandle
82
+ val cancelHandle = activeDownloads.start(channel.name, taskId)
88
83
 
89
84
  // The progress callback is invoked concurrently by the helper's worker
90
85
  // threads, so guard lastProgress with an AtomicInteger + CAS: only the
@@ -99,8 +94,8 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() {
99
94
  minConcurrentBytes = minConcurrentBytes,
100
95
  log = { msg -> OneKeyLog.info("RangeDownloader", msg) },
101
96
  ).download(downloadUrl, partialFilePath, cancelHandle) { transferred, total ->
102
- if (total > 0) {
103
- val p = ((transferred * 100) / total).toInt().coerceIn(0, 100)
97
+ val p = RangeDownloadLogic.progressPercent(transferred, total)
98
+ if (p != null) {
104
99
  val prev = lastProgress.get()
105
100
  if (p > prev && lastProgress.compareAndSet(prev, p)) {
106
101
  sendEvent(channel, taskId, type = "progress", progress = p)
@@ -109,18 +104,26 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() {
109
104
  }
110
105
  } finally {
111
106
  // Only deregister our own handle (a concurrent cancel may have replaced it).
112
- activeDownloads.remove(runKey, cancelHandle)
107
+ activeDownloads.finish(channel.name, taskId, cancelHandle)
113
108
  }
114
109
 
115
110
  if (outcome == ConcurrentRangeDownloader.Outcome.FALLBACK) {
116
- // Concurrency unusable. The helper has already cleaned up its own
117
- // artifacts where appropriate; the caller runs its single-stream path.
118
- OneKeyLog.info("RangeDownloader", "download: concurrent not used, returning fallback")
111
+ // OCDS §4 wire mapping (parity with the iOS shim). On Android the core
112
+ // helper splits the two §4 classes by RETURN vs THROW: a RESUMABLE
113
+ // (transient) interruption network drop / incomplete segment — is thrown
114
+ // and surfaces below as a promise rejection (the `.segN`/`.partial` are
115
+ // kept). `Outcome.FALLBACK` is therefore the PERMANENT class only: range
116
+ // unsupported / a 200 to a Range request / the object is too small / a
117
+ // single-stream leftover. The helper has already wiped its concurrent
118
+ // artifacts on this path, so it maps to `FALLBACKPERMANENT` with the
119
+ // `RANGEUNSUPPORTED` sub-kind, and the caller restarts single-stream.
120
+ OneKeyLog.info("RangeDownloader", "download: concurrent not used, returning permanent fallback")
119
121
  sendEvent(channel, taskId, type = "fallback", message = "concurrent unavailable")
120
122
  return@async RangeDownloadResult(
121
- outcome = RangeDownloadOutcome.FALLBACK,
123
+ outcome = RangeDownloadOutcome.FALLBACKPERMANENT,
122
124
  filePath = destFilePath,
123
125
  fallbackReason = "concurrent unavailable (range unsupported / 200 / too small / single-stream partial)",
126
+ fallbackKind = RangeFallbackKind.RANGEUNSUPPORTED,
124
127
  )
125
128
  }
126
129
 
@@ -152,6 +155,7 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() {
152
155
  outcome = RangeDownloadOutcome.COMPLETED,
153
156
  filePath = destFilePath,
154
157
  fallbackReason = null,
158
+ fallbackKind = null,
155
159
  )
156
160
  }
157
161
  }
@@ -194,19 +198,14 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() {
194
198
 
195
199
  // Flip the abort flag + shutdown the pool for an in-flight download (if any).
196
200
  private fun cancelActive(channel: DownloadChannel, taskId: String) {
197
- activeDownloads.remove(runKey(channel, taskId))?.cancel()
201
+ activeDownloads.cancel(channel.name, taskId)
198
202
  }
199
203
 
200
204
  // Delete every sibling artifact for [destFilePath]: all `<dest>.partial.seg<N>`
201
205
  // segment files (matched by filename prefix, so any segmentCount is swept, not
202
206
  // just the shipped default) plus the concatenated `<dest>.partial` itself.
203
- private fun sweepPartialArtifacts(destFilePath: String) {
204
- val partial = File("$destFilePath.partial")
205
- partial.parentFile
206
- ?.listFiles { f -> f.name.startsWith(partial.name + ".seg") }
207
- ?.forEach { it.delete() }
208
- partial.delete()
209
- }
207
+ private fun sweepPartialArtifacts(destFilePath: String) =
208
+ RangeDownloadLogic.sweepPartialArtifacts(destFilePath)
210
209
 
211
210
  // Atomically replace [dest] with [src] so a kill mid-finalize never leaves
212
211
  // NEITHER file. On API 26+ uses Files.move with ATOMIC_MOVE/REPLACE_EXISTING