@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.
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
@@ -0,0 +1,350 @@
1
+ package com.margelo.nitro.reactnativerangedownloader
2
+
3
+ import org.junit.After
4
+ import org.junit.Assert.assertEquals
5
+ import org.junit.Assert.assertFalse
6
+ import org.junit.Assert.assertNotEquals
7
+ import org.junit.Assert.assertNull
8
+ import org.junit.Assert.assertTrue
9
+ import org.junit.Before
10
+ import org.junit.Test
11
+ import java.io.File
12
+ import java.util.concurrent.ConcurrentHashMap
13
+ import java.util.concurrent.CountDownLatch
14
+ import java.util.concurrent.Executors
15
+ import java.util.concurrent.TimeUnit
16
+ import java.util.concurrent.atomic.AtomicInteger
17
+
18
+ /**
19
+ * Unit coverage for the extracted "segmentArtifactPaths + sweepPartialArtifacts
20
+ * (.segN glob / delete)" behavior plus the two adapter invariants it ships with
21
+ * (single-flight run-key, monotonic/de-duped progress gate).
22
+ *
23
+ * IMPORTANT — drives the REAL extracted code, never re-implements it:
24
+ * - The Android extraction lives in [RangeDownloadLogic] (the dependency-free
25
+ * pieces of `ReactNativeRangeDownloader`). On Android the path computation and
26
+ * the delete side-effect are NOT two separate public functions — the verbatim
27
+ * glob predicate `f.name.startsWith(partial.name + ".seg")` and the
28
+ * `.forEach { it.delete() }` live INSIDE [RangeDownloadLogic.sweepPartialArtifacts]
29
+ * (RangeDownloadLogic.kt:46-52, mirroring adapter:209-213). So the glob is
30
+ * asserted the only way it is observable here: by which sibling files survive
31
+ * vs. vanish on disk after the REAL sweep runs. We deliberately do not
32
+ * reproduce the predicate in the test.
33
+ * - The "RunRegistry / single-flight" key is [RangeDownloadLogic.runKey]; the
34
+ * `activeDownloads` map that dedupes by it is a `ConcurrentHashMap` in the
35
+ * adapter, so the single-flight / cancel-removes-only-matching / no-co-exist
36
+ * semantics are exercised against the REAL key over that same map type.
37
+ * - The "MonotonicProgressGate" is [RangeDownloadLogic.progressPercent] feeding
38
+ * the adapter's verbatim `p > prev && compareAndSet` loop
39
+ * (ReactNativeRangeDownloader.kt:102-108). We drive the REAL percent fn and the
40
+ * REAL `AtomicInteger` CAS, never a hand-rolled monotone helper.
41
+ *
42
+ * Pure JVM JUnit4 — no Nitro / HybridObject / Context — matching the established
43
+ * convention in this module (see RangeDownloadLogicTest / IsPermanentHttpStatusTest).
44
+ */
45
+ class SegmentArtifactSweepTest {
46
+
47
+ private lateinit var tmpDir: File
48
+
49
+ @Before
50
+ fun setUp() {
51
+ tmpDir = File.createTempFile("crd-sweep", "").let {
52
+ it.delete(); it.mkdirs(); it
53
+ }
54
+ }
55
+
56
+ @After
57
+ fun tearDown() {
58
+ tmpDir.deleteRecursively()
59
+ }
60
+
61
+ // --- helpers -------------------------------------------------------------
62
+
63
+ private fun destPath(name: String = "asset.bin") = File(tmpDir, name).absolutePath
64
+ private fun partial(dest: String) = File("$dest.partial")
65
+ private fun seg(dest: String, index: Int) = File("$dest.partial.seg$index")
66
+
67
+ // ------------------------------------------------------------------------
68
+ // segmentArtifactPaths + sweepPartialArtifacts (.segN glob / delete)
69
+ // ------------------------------------------------------------------------
70
+
71
+ // Idea 1: <dest>.partial.seg0..seg11 (custom count > shipped 8) + <dest>.partial
72
+ // + an unrelated sibling → the sweep removes exactly the 12 seg files and the
73
+ // .partial, and leaves the unrelated sibling (proving the glob set = every
74
+ // per-segment file regardless of segmentCount, PLUS the .partial, and nothing
75
+ // else).
76
+ @Test
77
+ fun sweepGlobsEverySegRegardlessOfCountAndThePartialButNotUnrelated() {
78
+ val dest = destPath()
79
+ val p = partial(dest).apply { writeText("concatenated") }
80
+ // 12 > the shipped default of 8 — the prefix glob must catch all of them.
81
+ val segs = (0 until 12).map { seg(dest, it).apply { writeText("s$it") } }
82
+ // A sibling that shares the <dest> stem but is NOT a .partial / .segN artifact.
83
+ val unrelated = File(tmpDir, "asset.bin.keep").apply { writeText("keep") }
84
+ // A sibling that does not share the stem at all.
85
+ val foreign = File(tmpDir, "other.txt").apply { writeText("other") }
86
+
87
+ RangeDownloadLogic.sweepPartialArtifacts(dest)
88
+
89
+ assertFalse(".partial must be deleted", p.exists())
90
+ segs.forEach { assertFalse("${it.name} must be deleted", it.exists()) }
91
+ assertTrue("unrelated <dest>.keep sibling must survive", unrelated.exists())
92
+ assertTrue("foreign sibling must survive", foreign.exists())
93
+ }
94
+
95
+ // Idea 2: no parent dir / parentFile resolvable to null → the safe-call `?.`
96
+ // on listFiles means the seg-glob phase is skipped, and the bare `partial.delete()`
97
+ // still runs without throwing (mirrors the `?.listFiles` null-guard). We assert
98
+ // on the observable contract: no crash, and the call is a no-op for a path whose
99
+ // parent holds nothing.
100
+ @Test
101
+ fun sweepDoesNotCrashWhenNoSegArtifactsExist() {
102
+ // A real path with a real parent dir but zero artifacts: the listFiles glob
103
+ // returns an empty array and partial.delete() is a no-op — no throw.
104
+ val dest = destPath("nothing-here.bin")
105
+ RangeDownloadLogic.sweepPartialArtifacts(dest)
106
+ assertFalse(partial(dest).exists())
107
+
108
+ // A relative bare filename ("foo.bin") makes File("foo.bin.partial").parentFile
109
+ // null; the `?.` short-circuits the glob and only the .partial delete is
110
+ // attempted. Must not throw (the load-bearing null-safety assertion).
111
+ RangeDownloadLogic.sweepPartialArtifacts("crd-sweep-nonexistent-bare-name.bin")
112
+ }
113
+
114
+ // Idea 3: the sweep deletes all .segN and the .partial; the final <dest> and
115
+ // unrelated siblings are untouched (the sweep never touches the promoted final).
116
+ @Test
117
+ fun sweepLeavesFinalDestAndSiblingsUntouched() {
118
+ val dest = destPath()
119
+ val finalFile = File(dest).apply { writeText("final promoted bytes") }
120
+ val p = partial(dest).apply { writeText("p") }
121
+ val segs = (0 until 4).map { seg(dest, it).apply { writeText("$it") } }
122
+ val sibling = File(tmpDir, "asset.bin.metadata").apply { writeText("m") }
123
+
124
+ RangeDownloadLogic.sweepPartialArtifacts(dest)
125
+
126
+ assertFalse(".partial must be gone", p.exists())
127
+ segs.forEach { assertFalse("${it.name} must be gone", it.exists()) }
128
+ assertTrue("the final <dest> must NOT be swept", finalFile.exists())
129
+ assertEquals("final <dest> content must be intact", "final promoted bytes", finalFile.readText())
130
+ assertTrue("unrelated sibling must survive", sibling.exists())
131
+ }
132
+
133
+ // Idea 4: prefix-boundary — a file named "<dest>.partial.segment-notes" still
134
+ // matches because the predicate is prefix-only (startsWith ".partial.seg"), not
135
+ // "<dest>.partial.seg<digits>". This documents (behavior-preserving) that today's
136
+ // glob would sweep it too.
137
+ @Test
138
+ fun sweepPrefixIsBoundaryLooseAndAlsoMatchesSegmentNotes() {
139
+ val dest = destPath()
140
+ partial(dest).writeText("p")
141
+ val numericSeg = seg(dest, 0).apply { writeText("0") }
142
+ // Shares the "<partial>.seg" prefix but is not a numeric segment.
143
+ val prefixLookalike = File("$dest.partial.segment-notes").apply { writeText("notes") }
144
+ // Does NOT start with "<basename>.partial.seg" (the stem is "...partialX...")
145
+ // → must survive, proving the match is anchored on that exact prefix.
146
+ val nearMiss = File("$dest.partialX.seg0").apply { writeText("x") }
147
+
148
+ RangeDownloadLogic.sweepPartialArtifacts(dest)
149
+
150
+ assertFalse("numeric .seg0 must be swept", numericSeg.exists())
151
+ assertFalse(
152
+ "prefix-only predicate also sweeps '<partial>.segment-notes' (behavior-preserving)",
153
+ prefixLookalike.exists(),
154
+ )
155
+ assertTrue("a sibling not sharing the '.partial.seg' prefix must survive", nearMiss.exists())
156
+ }
157
+
158
+ // Idea 5: idempotent — sweeping twice (artifacts already gone) does not throw and
159
+ // is a no-op.
160
+ @Test
161
+ fun sweepIsIdempotentAndSafeToRepeat() {
162
+ val dest = destPath()
163
+ partial(dest).writeText("p")
164
+ (0 until 3).forEach { seg(dest, it).writeText("$it") }
165
+
166
+ RangeDownloadLogic.sweepPartialArtifacts(dest)
167
+ // Everything is already gone; a second sweep must be a clean no-op.
168
+ RangeDownloadLogic.sweepPartialArtifacts(dest)
169
+ RangeDownloadLogic.sweepPartialArtifacts(dest)
170
+
171
+ assertFalse(partial(dest).exists())
172
+ (0 until 3).forEach { assertFalse(seg(dest, it).exists()) }
173
+ }
174
+
175
+ // ------------------------------------------------------------------------
176
+ // RunRegistry / single-flight (RangeDownloadLogic.runKey over the adapter's
177
+ // ConcurrentHashMap<String, CancelHandle> activeDownloads map).
178
+ // ------------------------------------------------------------------------
179
+
180
+ // runKey is stable + collision-free across channel/taskId.
181
+ @Test
182
+ fun runKeyIsStableAndCollisionFreeAcrossChannelAndTask() {
183
+ // Stable: same inputs → same key, every call.
184
+ assertEquals(
185
+ RangeDownloadLogic.runKey("MAIN", "task-1"),
186
+ RangeDownloadLogic.runKey("MAIN", "task-1"),
187
+ )
188
+ assertEquals("MAIN|task-1", RangeDownloadLogic.runKey("MAIN", "task-1"))
189
+ // Collision-free across channel.
190
+ assertNotEquals(
191
+ RangeDownloadLogic.runKey("MAIN", "t"),
192
+ RangeDownloadLogic.runKey("OTHER", "t"),
193
+ )
194
+ // Collision-free across taskId.
195
+ assertNotEquals(
196
+ RangeDownloadLogic.runKey("MAIN", "a"),
197
+ RangeDownloadLogic.runKey("MAIN", "b"),
198
+ )
199
+ }
200
+
201
+ // A second register for the same key dedupes (the map holds a single handle per
202
+ // key); cancel/discard removes only the matching key; concurrent registers for
203
+ // distinct keys co-exist while the same key never does.
204
+ @Test
205
+ fun singleFlightRegistryDedupesPerKeyAndRemovesOnlyTheMatchingKey() {
206
+ // Mirror the adapter's registry type exactly (ReactNativeRangeDownloader.kt:41-42).
207
+ val active = ConcurrentHashMap<String, ConcurrentRangeDownloader.CancelHandle>()
208
+
209
+ val keyA = RangeDownloadLogic.runKey("MAIN", "task-A")
210
+ val keyB = RangeDownloadLogic.runKey("OTHER", "task-A")
211
+
212
+ val h1 = ConcurrentRangeDownloader.CancelHandle()
213
+ val h2 = ConcurrentRangeDownloader.CancelHandle()
214
+ active[keyA] = h1
215
+ // A second register for the SAME key replaces (does not co-exist) — only ever
216
+ // one live handle per "channel|taskId".
217
+ active[keyA] = h2
218
+ assertEquals("same-key register must not co-exist; map holds exactly one", 1, active.keys.count { it == keyA })
219
+ assertEquals("the latest handle wins for the same key", h2, active[keyA])
220
+
221
+ // A distinct key (different channel, same taskId) co-exists.
222
+ val hB = ConcurrentRangeDownloader.CancelHandle()
223
+ active[keyB] = hB
224
+ assertEquals("distinct keys co-exist", 2, active.size)
225
+
226
+ // cancel/discard for keyA removes ONLY keyA, leaving keyB intact.
227
+ active.remove(keyA)
228
+ assertNull("cancelled key must be gone", active[keyA])
229
+ assertEquals("only the matching key is removed", hB, active[keyB])
230
+ assertEquals(1, active.size)
231
+ }
232
+
233
+ // The adapter deregisters with the value-checked remove(key, value) so a
234
+ // concurrent cancel that already replaced the handle is NOT clobbered
235
+ // (ReactNativeRangeDownloader.kt:112). Drive that exact 2-arg remove contract.
236
+ @Test
237
+ fun valueCheckedRemoveOnlyDeregistersOwnHandle() {
238
+ val active = ConcurrentHashMap<String, ConcurrentRangeDownloader.CancelHandle>()
239
+ val key = RangeDownloadLogic.runKey("MAIN", "task-X")
240
+ val mine = ConcurrentRangeDownloader.CancelHandle()
241
+ active[key] = mine
242
+ // A concurrent cancel replaced our handle with a newer one.
243
+ val newer = ConcurrentRangeDownloader.CancelHandle()
244
+ active[key] = newer
245
+ // Our finally-block remove(key, mine) must be a no-op because the value moved on.
246
+ assertFalse("value-checked remove must not evict the replacement", active.remove(key, mine))
247
+ assertEquals("the newer handle survives the stale deregister", newer, active[key])
248
+ // The owner of `newer` removes its own handle successfully.
249
+ assertTrue(active.remove(key, newer))
250
+ assertNull(active[key])
251
+ }
252
+
253
+ // ------------------------------------------------------------------------
254
+ // MonotonicProgressGate (RangeDownloadLogic.progressPercent + the adapter's
255
+ // verbatim AtomicInteger `p > prev && compareAndSet` loop).
256
+ // ------------------------------------------------------------------------
257
+
258
+ // progressPercent floors, clamps to [0,100], and returns null for a non-positive
259
+ // total (the gate's input contract).
260
+ @Test
261
+ fun progressPercentFloorsClampsAndNullsNonPositiveTotal() {
262
+ assertEquals(0, RangeDownloadLogic.progressPercent(0, 100))
263
+ assertEquals(33, RangeDownloadLogic.progressPercent(1, 3)) // floor, never rounds up
264
+ assertEquals(100, RangeDownloadLogic.progressPercent(100, 100))
265
+ assertEquals(100, RangeDownloadLogic.progressPercent(150, 100)) // clamp overshoot
266
+ assertNull(RangeDownloadLogic.progressPercent(10, 0))
267
+ assertNull(RangeDownloadLogic.progressPercent(10, -1))
268
+ }
269
+
270
+ // Progress only advances (CAS), never emits below the prior max even with
271
+ // out-of-order callbacks, and equal values do not re-emit. Single-threaded
272
+ // reproduction of the adapter's gate loop.
273
+ @Test
274
+ fun progressGateIsMonotonicAndDeduped() {
275
+ val last = AtomicInteger(-1)
276
+ val emitted = mutableListOf<Int>()
277
+ // total fixed at 100; transferred arrives out of order with repeats + a regress.
278
+ for (transferred in listOf(10L, 10L, 25L, 25L, 20L, 50L, 50L, 49L, 100L, 100L)) {
279
+ val p = RangeDownloadLogic.progressPercent(transferred, 100) ?: continue
280
+ val prev = last.get()
281
+ if (p > prev && last.compareAndSet(prev, p)) {
282
+ emitted.add(p)
283
+ }
284
+ }
285
+ // 20 (<25) and 49 (<50) never emit; repeats are de-duped; sequence is monotone.
286
+ assertEquals(listOf(10, 25, 50, 100), emitted)
287
+ }
288
+
289
+ // Under genuine concurrency the CAS gate still never emits a value below the
290
+ // running max, and emits each percentage at most once. Many threads race the
291
+ // same gate driven by the REAL progressPercent + AtomicInteger CAS.
292
+ @Test
293
+ fun progressGateNeverGoesBackwardUnderConcurrency() {
294
+ val last = AtomicInteger(-1)
295
+ val emissions = java.util.concurrent.ConcurrentLinkedQueue<Int>()
296
+ val total = 1_000L
297
+ val threads = 16
298
+ val pool = Executors.newFixedThreadPool(threads)
299
+ val start = CountDownLatch(1)
300
+ val done = CountDownLatch(threads)
301
+ val regress = AtomicInteger(0)
302
+
303
+ repeat(threads) { t ->
304
+ pool.submit {
305
+ start.await()
306
+ // Each thread fires the full 0..1000 transferred sweep (heavy overlap), so
307
+ // the gate sees the same percentages racing from many threads at once.
308
+ for (transferred in 0L..total) {
309
+ val p = RangeDownloadLogic.progressPercent(transferred, total) ?: continue
310
+ while (true) {
311
+ val prev = last.get()
312
+ if (p <= prev) break // not an advance → no emit (de-dup / monotone)
313
+ if (last.compareAndSet(prev, p)) {
314
+ emissions.add(p)
315
+ break
316
+ }
317
+ }
318
+ // A reader must NEVER observe the shared published max regress.
319
+ if (prevMaxRegressed(last)) regress.incrementAndGet()
320
+ }
321
+ done.countDown()
322
+ }
323
+ }
324
+ start.countDown()
325
+ assertTrue("workers must finish", done.await(20, TimeUnit.SECONDS))
326
+ pool.shutdownNow()
327
+
328
+ // The published max ends at exactly 100 and never regressed.
329
+ assertEquals("gate must settle at 100%", 100, last.get())
330
+ assertEquals("published max must never regress", 0, regress.get())
331
+ // Each emitted percentage is unique (CAS de-dup) and the stream is sorted
332
+ // ascending (monotone): emissions are appended only on a successful advance.
333
+ val list = emissions.toList()
334
+ assertEquals("every emission must be unique (no re-emit of an equal value)", list.toSet().size, list.size)
335
+ assertEquals("emissions must be monotone non-decreasing", list.sorted(), list)
336
+ assertTrue("the terminal 100% must be emitted exactly once", list.count { it == 100 } == 1)
337
+ }
338
+
339
+ // Tracks the running max in a thread-confined way to detect any regression of the
340
+ // shared published value. Returns true if the AtomicInteger's value ever dropped
341
+ // below a previously observed value on this thread.
342
+ private val perThreadSeenMax = ThreadLocal.withInitial { -1 }
343
+ private fun prevMaxRegressed(last: AtomicInteger): Boolean {
344
+ val now = last.get()
345
+ val seen = perThreadSeenMax.get()
346
+ if (now < seen) return true
347
+ if (now > seen) perThreadSeenMax.set(now)
348
+ return false
349
+ }
350
+ }
@@ -0,0 +1,65 @@
1
+ package com.margelo.nitro.reactnativerangedownloader
2
+
3
+ import okhttp3.mockwebserver.MockWebServer
4
+ import org.junit.After
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Assert.assertTrue
7
+ import org.junit.Before
8
+ import org.junit.Test
9
+ import java.io.File
10
+
11
+ /**
12
+ * Harness smoke test: confirms the [RangeFaultServer] dispatcher + MockWebServer
13
+ * + a real [ConcurrentRangeDownloader] over a plain OkHttpClient compile and run
14
+ * a clean full download. A green run here proves the JVM test harness works end
15
+ * to end (8 parallel range requests served concurrently, assembled `.partial`
16
+ * byte-identical to the source).
17
+ */
18
+ class SmokeTest {
19
+
20
+ private lateinit var server: MockWebServer
21
+ private lateinit var tmpDir: File
22
+
23
+ // 3 MiB > minConcurrentBytes (2 MiB) so the core uses the concurrent path.
24
+ private val content: ByteArray = ByteArray(3 * 1024 * 1024) { (it % 251).toByte() }
25
+
26
+ @Before
27
+ fun setUp() {
28
+ server = MockWebServer()
29
+ server.dispatcher = RangeFaultServer(content).dispatcher
30
+ server.start()
31
+ tmpDir = File.createTempFile("crd-smoke", "").let {
32
+ it.delete(); it.mkdirs(); it
33
+ }
34
+ }
35
+
36
+ @After
37
+ fun tearDown() {
38
+ server.shutdown()
39
+ tmpDir.deleteRecursively()
40
+ }
41
+
42
+ @Test
43
+ fun fullDownloadCompletesWithMatchingSha() {
44
+ val partial = File(tmpDir, "asset.bin.partial").absolutePath
45
+ val outcome = newDownloader().download(
46
+ url = server.url("/asset.bin").toString(),
47
+ partialFilePath = partial,
48
+ onProgress = { _, _ -> },
49
+ )
50
+
51
+ assertEquals(ConcurrentRangeDownloader.Outcome.COMPLETED, outcome)
52
+ val partialFile = File(partial)
53
+ assertTrue("partial should exist", partialFile.exists())
54
+ assertEquals(content.size.toLong(), partialFile.length())
55
+ assertEquals(
56
+ "assembled .partial SHA must match source",
57
+ sha256(content),
58
+ sha256(partialFile),
59
+ )
60
+ // Every `.segN` is consumed by concatenate() on success.
61
+ for (i in 0 until 8) {
62
+ assertTrue(".seg$i should be gone", !File("$partial.seg$i").exists())
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,187 @@
1
+ import Foundation
2
+ import CommonCrypto
3
+
4
+ // MARK: - Dependency-free RangeDownloader logic (OCDS §4 / §5)
5
+ //
6
+ // This file holds the DETERMINISTIC, dependency-light pieces of the range
7
+ // downloader: HTTP-status classification, range planning, Content-Range parsing,
8
+ // Retry-After / backoff math, and the SHA-256 integrity hash. They were extracted
9
+ // VERBATIM (bodies unchanged) from `ReactNativeRangeDownloader.swift` so they can
10
+ // be compiled and unit-tested WITHOUT the NitroModules / ReactNativeNativeLogger
11
+ // dependencies or the background `URLSession` (which is device-only).
12
+ //
13
+ // `RangeDownloader` keeps using them via `RangeDownloadLogic.<fn>`. The Nitro wire
14
+ // projections (`wireOutcome` / `wireKind`) stay in the main module file because
15
+ // they depend on the codegen enums; everything here is pure Swift + Foundation +
16
+ // CommonCrypto.
17
+
18
+ // MARK: - Typed failure model (OCDS §4)
19
+ //
20
+ // The IN-PROCESS core returns this Swift-native typed class to its in-process
21
+ // caller (BundleUpdate); the Nitro shim maps it onto the regenerated wire enum
22
+ // `RangeDownloadOutcome` (completed | fallbackTransient | fallbackPermanent) so
23
+ // the failure class crosses the JS boundary as an EXPLICIT value, never inferred
24
+ // from incidental on-disk side effects (which §4 forbids). The core enum is a
25
+ // SEPARATE type from the generated wire `RangeFallbackKind` (same case set) so
26
+ // the core can carry an extra `failureClass` projection without the module-scope
27
+ // name colliding with the codegen typealias; `wireOutcome` / `wireKind` (in the
28
+ // main module file) do the 1:1 translation onto the generated enums.
29
+ public enum RangeDownloadClass: Equatable {
30
+ case completed
31
+ /// Resumable interruption — keep `.segN`, the concurrent path may resume.
32
+ case fallbackTransient
33
+ /// Concurrency fundamentally unusable for this object — segments discarded.
34
+ case fallbackPermanent
35
+
36
+ var isFallback: Bool { self != .completed }
37
+ }
38
+
39
+ /// Typed sub-classification of a fallback (in-process mirror of the generated
40
+ /// wire `RangeFallbackKind`). Used by the caller/analytics to know WHY without
41
+ /// parsing the reason string, and by the core to drive keep-vs-discard. Named
42
+ /// distinctly from the codegen `RangeFallbackKind` typealias to avoid a
43
+ /// module-scope name clash; `wireKind` (in the main module file) translates onto
44
+ /// the wire enum.
45
+ public enum RangeFallbackClass: String {
46
+ case serverIgnoredRange
47
+ case rangeUnsupported
48
+ case authExpired
49
+ case notFound
50
+ case redirectRejected
51
+ case checksumMismatch
52
+ case multipartOrBadTotal
53
+ case transientNetwork
54
+ case throttled
55
+ case budgetExhausted
56
+
57
+ /// The §4 recovery class implied by this kind.
58
+ var failureClass: RangeDownloadClass {
59
+ switch self {
60
+ case .transientNetwork, .throttled, .budgetExhausted:
61
+ return .fallbackTransient
62
+ case .serverIgnoredRange, .rangeUnsupported, .authExpired, .notFound,
63
+ .redirectRejected, .checksumMismatch, .multipartOrBadTotal:
64
+ return .fallbackPermanent
65
+ }
66
+ }
67
+ }
68
+
69
+ // MARK: - Pure logic namespace
70
+
71
+ /// Dependency-free static logic for the range downloader. Bodies are a verbatim
72
+ /// move from `RangeDownloader`; callsites there now call `RangeDownloadLogic.<fn>`.
73
+ public enum RangeDownloadLogic {
74
+
75
+ // §5.4 backoff knobs (moved here because only `backoffDelay` consumes them).
76
+ static let retryBaseDelaySeconds: Double = 1.0
77
+ static let retryMaxDelaySeconds: Double = 30.0
78
+
79
+ // MARK: - Range planning / probing
80
+
81
+ static func planRanges(total: Int64, segments: Int) -> [(start: Int64, end: Int64)] {
82
+ var out: [(Int64, Int64)] = []
83
+ let chunk = (total + Int64(segments) - 1) / Int64(segments)
84
+ var i = 0
85
+ while i < segments {
86
+ let start = Int64(i) * chunk
87
+ if start >= total { break }
88
+ let end = min(start + chunk - 1, total - 1)
89
+ out.append((start, end))
90
+ i += 1
91
+ }
92
+ return out
93
+ }
94
+
95
+ // MARK: - HTTP status classification (OCDS §4 table + catch-all)
96
+
97
+ /// Maps an HTTP status on a Range request to a §4 fallback kind. Used by the
98
+ /// download-finish delegate to decide keep-vs-discard. Status 206 is handled by
99
+ /// the caller (validated, not a fallback); 200 is `serverIgnoredRange`.
100
+ static func classifyStatus(_ status: Int) -> RangeFallbackClass {
101
+ switch status {
102
+ case 200:
103
+ return .serverIgnoredRange
104
+ case 416:
105
+ // §4: 416 to a resume request → Transient (re-evaluate size, keep segments).
106
+ return .transientNetwork
107
+ case 401, 403:
108
+ return .authExpired
109
+ case 404, 410:
110
+ return .notFound
111
+ case 408, 429:
112
+ return .throttled
113
+ case 501, 505:
114
+ // Explicit Permanent carve-outs from the 5xx → Transient default.
115
+ return .rangeUnsupported
116
+ case 500...599:
117
+ return .throttled
118
+ case 400...499:
119
+ // Default 4xx → Permanent (408/429 handled above).
120
+ return .rangeUnsupported
121
+ default:
122
+ // Anything else / unknown → Permanent per the §4 catch-all.
123
+ return .rangeUnsupported
124
+ }
125
+ }
126
+
127
+ /// Parses a `Retry-After` header value (delta-seconds form only; HTTP-date form
128
+ /// is treated as absent). Returns nil when missing/unparseable.
129
+ static func parseRetryAfterSeconds(_ value: String?) -> Double? {
130
+ guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty,
131
+ let seconds = Double(value), seconds >= 0 else { return nil }
132
+ return seconds
133
+ }
134
+
135
+ static func parseContentRangeTotal(_ header: String) -> Int64? {
136
+ // "bytes 0-0/65226095"
137
+ guard let slash = header.lastIndex(of: "/") else { return nil }
138
+ let tail = header[header.index(after: slash)...]
139
+ return Int64(tail.trimmingCharacters(in: .whitespaces))
140
+ }
141
+
142
+ /// Parses the start/end of a "bytes <start>-<end>/<total>" Content-Range.
143
+ static func parseContentRangeBounds(_ header: String) -> (start: Int64, end: Int64)? {
144
+ // Drop the leading "bytes " and the trailing "/<total>".
145
+ let trimmed = header.trimmingCharacters(in: .whitespaces)
146
+ guard let spaceIdx = trimmed.firstIndex(of: " ") else { return nil }
147
+ var rangePart = String(trimmed[trimmed.index(after: spaceIdx)...])
148
+ if let slash = rangePart.firstIndex(of: "/") {
149
+ rangePart = String(rangePart[..<slash])
150
+ }
151
+ let bounds = rangePart.split(separator: "-", maxSplits: 1).map { String($0) }
152
+ guard bounds.count == 2,
153
+ let start = Int64(bounds[0].trimmingCharacters(in: .whitespaces)),
154
+ let end = Int64(bounds[1].trimmingCharacters(in: .whitespaces)) else { return nil }
155
+ return (start, end)
156
+ }
157
+
158
+ /// §5.4: exponential backoff base*2^(attempt-1), capped, with full jitter so N
159
+ /// segments don't retry in lockstep. A server `Retry-After` overrides it.
160
+ static func backoffDelay(attempt: Int, retryAfter: Double?) -> Double {
161
+ if let retryAfter = retryAfter { return min(retryAfter, retryMaxDelaySeconds * 2) }
162
+ let exp = retryBaseDelaySeconds * pow(2.0, Double(max(0, attempt - 1)))
163
+ let capped = min(exp, retryMaxDelaySeconds)
164
+ // Full jitter in [0, capped].
165
+ return Double.random(in: 0...capped)
166
+ }
167
+
168
+ // MARK: - Integrity (§5.5)
169
+
170
+ static func calculateSHA256(_ filePath: String) -> String? {
171
+ let fm = FileManager.default
172
+ guard fm.fileExists(atPath: filePath),
173
+ let fileHandle = FileHandle(forReadingAtPath: filePath) else { return nil }
174
+ defer { try? fileHandle.close() }
175
+ var context = CC_SHA256_CTX()
176
+ CC_SHA256_Init(&context)
177
+ while autoreleasepool(invoking: { () -> Bool in
178
+ let data = fileHandle.readData(ofLength: 8192)
179
+ if data.isEmpty { return false }
180
+ data.withUnsafeBytes { CC_SHA256_Update(&context, $0.baseAddress, CC_LONG(data.count)) }
181
+ return true
182
+ }) {}
183
+ var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
184
+ CC_SHA256_Final(&hash, &context)
185
+ return hash.map { String(format: "%02x", $0) }.joined()
186
+ }
187
+ }