@onekeyfe/react-native-range-downloader 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.
- package/README.md +15 -0
- package/android/build.gradle +3 -0
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt +179 -11
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogic.kt +98 -0
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +24 -25
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloaderOcdsTest.kt +554 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/FaultServer.kt +282 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/IsPermanentHttpStatusTest.kt +63 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/MonotonicProgressGateTest.kt +368 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/Ocds416ResumeTest.kt +272 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsBadTotalRejectTest.kt +147 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsMultipartRejectTest.kt +114 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsReadOnlyFsTest.kt +250 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/OcdsTransient5xxTest.kt +275 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogicTest.kt +124 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/RunRegistrySingleFlightTest.kt +217 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SegmentArtifactSweepTest.kt +350 -0
- package/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SmokeTest.kt +65 -0
- package/ios/RangeDownloadLogic.swift +187 -0
- package/ios/ReactNativeRangeDownloader.swift +669 -133
- package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts +7 -1
- package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.cpp +4 -0
- package/nitrogen/generated/android/c++/JRangeDownloadOutcome.hpp +6 -0
- package/nitrogen/generated/android/c++/JRangeDownloadParams.hpp +19 -3
- package/nitrogen/generated/android/c++/JRangeDownloadResult.hpp +9 -3
- package/nitrogen/generated/android/c++/JRangeFallbackKind.hpp +83 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadOutcome.kt +3 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadParams.kt +15 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadResult.kt +6 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeFallbackKind.kt +29 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.hpp +18 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.hpp +3 -0
- package/nitrogen/generated/ios/swift/RangeDownloadOutcome.swift +8 -0
- package/nitrogen/generated/ios/swift/RangeDownloadParams.swift +93 -1
- package/nitrogen/generated/ios/swift/RangeDownloadResult.swift +24 -1
- package/nitrogen/generated/ios/swift/RangeFallbackKind.swift +72 -0
- package/nitrogen/generated/shared/c++/RangeDownloadOutcome.hpp +9 -1
- package/nitrogen/generated/shared/c++/RangeDownloadParams.hpp +18 -2
- package/nitrogen/generated/shared/c++/RangeDownloadResult.hpp +9 -2
- package/nitrogen/generated/shared/c++/RangeFallbackKind.hpp +108 -0
- package/package.json +1 -1
- 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)
|
package/android/build.gradle
CHANGED
|
@@ -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
|
-
/**
|
|
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?.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
423
|
-
// header
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/RangeDownloadLogic.kt
ADDED
|
@@ -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
|
-
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
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.
|
|
107
|
+
activeDownloads.finish(channel.name, taskId, cancelHandle)
|
|
113
108
|
}
|
|
114
109
|
|
|
115
110
|
if (outcome == ConcurrentRangeDownloader.Outcome.FALLBACK) {
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|