@onekeyfe/react-native-range-downloader 3.0.65 → 3.0.67

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.
@@ -3,7 +3,8 @@ package com.margelo.nitro.reactnativerangedownloader
3
3
  import okhttp3.OkHttpClient
4
4
  import okhttp3.Request
5
5
  import java.io.File
6
- import java.io.RandomAccessFile
6
+ import java.io.FileInputStream
7
+ import java.io.FileOutputStream
7
8
  import java.util.concurrent.Executors
8
9
  import java.util.concurrent.atomic.AtomicBoolean
9
10
  import java.util.concurrent.atomic.AtomicLong
@@ -11,26 +12,43 @@ import java.util.concurrent.atomic.AtomicReference
11
12
 
12
13
  /**
13
14
  * Splits a Range-capable download into [segmentCount] byte ranges fetched in
14
- * parallel, each written directly into its own offset of ONE pre-allocated
15
- * `.partial` file (no merge pass, 1x disk). A sidecar `<partial>.progress`
16
- * manifest records each segment's durably-written cursor so an interrupted
17
- * download resumes by re-requesting only the unfinished tail of each segment.
15
+ * parallel, each STREAMED INTO ITS OWN sibling file `<partial>.segN` with plain
16
+ * sequential `FileOutputStream` appends (O_WRONLY). Once every segment file is
17
+ * fully present, the segments are concatenated in order into the `.partial`
18
+ * (and freed as they are consumed, so the peak footprint stays ~1x the file
19
+ * plus one segment). Mirrors the iOS RangeDownloader segment-file model.
18
20
  *
19
- * Mirrors the desktop DesktopApiBundleUpdate concurrent path. This class is
20
- * intentionally free of Android/OneKey dependencies (logging is injected) so
21
- * it can be unit/type-checked standalone; the whole-file SHA256 check the
22
- * caller already performs after promotion is the final correctness backstop.
21
+ * Why segment files instead of one pre-allocated `.partial` with positioned
22
+ * writes: the previous design pre-allocated the full size up front via
23
+ * `RandomAccessFile(partial, "rw").setLength(total)`. That O_RDWR open +
24
+ * large reservation fails with EROFS/ENOSPC on near-full f2fs devices (and any
25
+ * storage that rejects a large up-front reservation), aborting the WHOLE
26
+ * download before a byte is fetched. This design only ever does plain O_WRONLY
27
+ * sequential writes (segment fetch + concat) and O_RDONLY reads — the same I/O
28
+ * shape as the caller's proven single-stream path — so it grows incrementally
29
+ * up to the real space limit instead of reserving everything at once.
23
30
  *
24
- * Invariant: the manifest is only meaningful as metadata for an existing
25
- * `.partial`. Either both exist (resume) or neither does (fresh) any other
26
- * combination is treated as "no resumable state".
31
+ * Resume across kill/suspend is simply "which `<partial>.segN` already exist
32
+ * and how big" plus "how far the `.partial` concat already committed": a
33
+ * full-sized segment is kept; a short one resumes from its current length via
34
+ * `Range`; a segment whose extent is already inside the committed `.partial`
35
+ * prefix is done even if its `.segN` was deleted mid-concat. Object identity is
36
+ * intentionally NOT pinned (no ETag/If-Range) — resume is unconditional, and a
37
+ * mid-flight object swap that slips through is caught by the caller's whole-file
38
+ * SHA256 + GPG verify after promotion, which then drives a clean full
39
+ * re-download (concat deletes every `.segN` on success). That verify is the sole
40
+ * correctness backstop. The only validator-free safety nets kept inline are: a
41
+ * 200 to a Range request → FallbackException, an over-long/oversized segment →
42
+ * discard, and a per-segment Content-Range bounds check against mis-aligned 206s.
43
+ *
44
+ * This class is intentionally free of Android/OneKey dependencies (logging is
45
+ * injected) so it can be unit/type-checked standalone.
27
46
  */
28
47
  class ConcurrentRangeDownloader(
29
48
  private val httpClient: OkHttpClient,
30
49
  private val segmentCount: Int = 8,
31
50
  private val minConcurrentBytes: Long = 2L * 1024 * 1024,
32
51
  private val maxPartRetry: Int = 3,
33
- private val manifestFlushBytes: Long = 4L * 1024 * 1024,
34
52
  private val log: (String) -> Unit = {},
35
53
  ) {
36
54
  enum class Outcome {
@@ -48,7 +66,7 @@ class ConcurrentRangeDownloader(
48
66
  * Cooperative-cancel handle the caller can register a download against. The
49
67
  * adapter keeps these in a per-taskId registry so `cancel`/`discardArtifacts`
50
68
  * can flip [aborted] and `shutdownNow()` the worker pool BEFORE deleting the
51
- * `.partial`/`.progress`, so no in-flight worker resurrects a deleted file.
69
+ * segment files, so no in-flight worker resurrects a deleted file.
52
70
  */
53
71
  class CancelHandle {
54
72
  val aborted = AtomicBoolean(false)
@@ -69,20 +87,17 @@ class ConcurrentRangeDownloader(
69
87
  }
70
88
  }
71
89
 
72
- private class Part(val index: Int, val start: Long, val end: Long, done: Long) {
73
- // AtomicLong so manifest snapshots read a consistent value even if the
74
- // owning thread ever changes (cross-thread reads in flushManifest).
75
- val done = AtomicLong(done)
90
+ private class Part(val index: Int, val start: Long, val end: Long) {
76
91
  val length: Long get() = end - start + 1
77
92
  }
78
93
 
79
- private class Probe(val totalSize: Long, val etag: String?, val supportsRange: Boolean)
94
+ private class Probe(val totalSize: Long, val supportsRange: Boolean)
80
95
 
81
96
  /**
82
97
  * Fills [partialFilePath] completely with the resource at [url] using
83
98
  * concurrent ranges. See [Outcome]. Throws on a transient/IO error after
84
- * per-segment retries, leaving the partial + manifest in place so a later
85
- * attempt resumes.
99
+ * per-segment retries, leaving the segment files in place so a later attempt
100
+ * resumes.
86
101
  */
87
102
  fun download(
88
103
  url: String,
@@ -91,11 +106,12 @@ class ConcurrentRangeDownloader(
91
106
  onProgress: (transferred: Long, total: Long) -> Unit,
92
107
  ): Outcome {
93
108
  val partialFile = File(partialFilePath)
94
- val manifestFile = File("$partialFilePath.progress")
109
+ val segFile: (Int) -> File = { index -> File("$partialFilePath.seg$index") }
95
110
 
96
- // A bare `.partial` with no manifest is a single-stream leftover; let
97
- // the caller's single-stream path resume it instead of discarding it.
98
- if (partialFile.exists() && !manifestFile.exists()) {
111
+ // A bare `.partial` with NO segment files is a single-stream leftover;
112
+ // let the caller's single-stream path resume it instead of touching it.
113
+ val anyLeftoverSeg = (0 until segmentCount).any { segFile(it).exists() }
114
+ if (partialFile.exists() && !anyLeftoverSeg) {
99
115
  log("concurrent: single-stream partial present, deferring to single-stream")
100
116
  return Outcome.FALLBACK
101
117
  }
@@ -106,19 +122,46 @@ class ConcurrentRangeDownloader(
106
122
  return Outcome.FALLBACK
107
123
  }
108
124
  val total = probe.totalSize
109
- val etag = probe.etag
110
- // A strong validator (ETag) is what lets If-Range pin a resumed range to
111
- // the exact object the partial was started against. Without it we cannot
112
- // safely trust or persist `.partial`/`.progress` across attempts, so we
113
- // start fresh and skip manifest persistence (the caller's whole-file
114
- // SHA256 check after promotion remains the final correctness backstop).
115
- val hasValidator = !etag.isNullOrEmpty()
116
125
 
117
126
  partialFile.parentFile?.let { if (!it.exists()) it.mkdirs() }
118
- dropOrphanManifest(partialFile, manifestFile)
119
- val parts = loadOrInitManifest(manifestFile, partialFile, total, etag, hasValidator)
120
127
 
121
- val transferred = AtomicLong(parts.sumOf { it.done.get() })
128
+ val parts = planRanges(total)
129
+
130
+ // Resume is unconditional: object identity is NOT pinned (no ETag) — any
131
+ // mid-flight object swap that survives this far is caught by the caller's
132
+ // whole-file SHA256/GPG verify, which then drives a clean full re-download
133
+ // (concat deletes every `.segN` on success, so the retry starts fresh).
134
+ // We therefore never wipe `.segN` for "no/changed validator" reasons.
135
+
136
+ // Discard any leftover segment that can't belong to this plan (wrong
137
+ // length = different object/range, or an index beyond the plan). This is
138
+ // a pure size check, independent of any validator.
139
+ for (i in 0 until segmentCount) {
140
+ val f = segFile(i)
141
+ if (!f.exists()) continue
142
+ val expected = parts.getOrNull(i)?.length
143
+ if (expected == null || f.length() > expected) {
144
+ log("concurrent: discarding stale/oversized segment $i")
145
+ f.delete()
146
+ }
147
+ }
148
+
149
+ // `.partial`'s current length is the committed-concat cursor: every byte
150
+ // below it has already been appended into `.partial` and its source
151
+ // `.segN` may have been deleted by an interrupted concat. Such prefix
152
+ // segments are DONE — they must not be re-fetched (that would waste the
153
+ // network and break the "~1x + one segment" footprint target).
154
+ val committedBytes = if (partialFile.exists()) partialFile.length() else 0L
155
+ // A segment is "committed" when `.partial` already covers its full extent.
156
+ val isCommitted: (Part) -> Boolean = { committedBytes >= it.start + it.length }
157
+
158
+ // Progress baseline: committed bytes already in `.partial`, plus the
159
+ // current length of every not-yet-committed segment file (committed
160
+ // segments are already accounted for by `committedBytes`, so adding their
161
+ // `.segN` length — if it still exists — would double-count).
162
+ val transferred = AtomicLong(
163
+ committedBytes + parts.filterNot(isCommitted).sumOf { segFile(it.index).length() }
164
+ )
122
165
  onProgress(transferred.get(), total)
123
166
 
124
167
  // Share the abort flag with the cancel handle so an external cancel() is
@@ -126,94 +169,97 @@ class ConcurrentRangeDownloader(
126
169
  val aborted = cancelHandle?.aborted ?: AtomicBoolean(false)
127
170
  val fallback = AtomicBoolean(false)
128
171
  val firstError = AtomicReference<Exception?>(null)
129
- val lastFlushed = LongArray(parts.size) { parts[it].done.get() }
130
172
 
131
- val pool = Executors.newFixedThreadPool(minOf(segmentCount, parts.size))
132
- cancelHandle?.attach(pool)
133
- try {
134
- val futures = parts.map { part ->
135
- pool.submit {
136
- try {
137
- downloadPart(url, etag, partialFile, part, aborted) { delta ->
138
- val t = transferred.addAndGet(delta)
139
- if (hasValidator) {
140
- synchronized(lastFlushed) {
141
- if (part.done.get() - lastFlushed[part.index] >= manifestFlushBytes) {
142
- lastFlushed[part.index] = part.done.get()
143
- flushManifest(manifestFile, total, etag, parts)
144
- }
145
- }
173
+ // Only segments not yet fully on disk AND not already committed into
174
+ // `.partial` need fetching. A committed prefix segment whose `.segN` was
175
+ // deleted by an interrupted concat must NOT be treated as missing.
176
+ val pending = parts.filterNot(isCommitted).filter { segFile(it.index).length() < it.length }
177
+ if (pending.isNotEmpty()) {
178
+ val pool = Executors.newFixedThreadPool(minOf(segmentCount, pending.size))
179
+ cancelHandle?.attach(pool)
180
+ try {
181
+ val futures = pending.map { part ->
182
+ pool.submit {
183
+ try {
184
+ downloadSegment(url, segFile(part.index), part, aborted) { delta ->
185
+ onProgress(transferred.addAndGet(delta), total)
146
186
  }
147
- onProgress(t, total)
187
+ } catch (e: FallbackException) {
188
+ fallback.set(true)
189
+ aborted.set(true)
190
+ firstError.compareAndSet(null, e)
191
+ } catch (e: Exception) {
192
+ aborted.set(true)
193
+ firstError.compareAndSet(null, e)
148
194
  }
149
- } catch (e: FallbackException) {
150
- fallback.set(true)
151
- aborted.set(true)
152
- firstError.compareAndSet(null, e)
153
- } catch (e: Exception) {
154
- aborted.set(true)
155
- firstError.compareAndSet(null, e)
156
195
  }
157
196
  }
197
+ futures.forEach { it.get() }
198
+ } finally {
199
+ pool.shutdownNow()
158
200
  }
159
- futures.forEach { it.get() }
160
- } finally {
161
- pool.shutdownNow()
162
201
  }
163
202
 
164
203
  if (fallback.get()) {
165
204
  // Stale/unusable bytes — clear before the caller falls back.
166
- discard(partialFile, manifestFile)
205
+ wipeArtifacts(partialFile, segFile)
167
206
  return Outcome.FALLBACK
168
207
  }
169
208
  val err = firstError.get()
170
209
  if (err != null) {
171
- if (hasValidator) {
172
- // Transientpersist progress so the next attempt resumes, then bubble up.
173
- flushManifest(manifestFile, total, etag, parts)
174
- } else {
175
- // No validator: resume state is untrustworthy, so don't persist
176
- // it — discard and let the next attempt start clean.
177
- discard(partialFile, manifestFile)
178
- }
210
+ // Transient. Always keep the segment files so the next attempt
211
+ // resumesresume is unconditional now (no validator gate).
179
212
  throw err
180
213
  }
181
- val got = parts.sumOf { it.done.get() }
182
- if (got < total) {
183
- if (hasValidator) {
184
- flushManifest(manifestFile, total, etag, parts)
185
- } else {
186
- discard(partialFile, manifestFile)
187
- }
188
- throw java.io.IOException("Concurrent download incomplete ($got/$total)")
214
+ // A committed prefix segment is complete even though its `.segN` is gone;
215
+ // only not-yet-committed segments must have a full-length `.segN`.
216
+ val incomplete = parts.filterNot(isCommitted)
217
+ .firstOrNull { segFile(it.index).length() != it.length }
218
+ if (incomplete != null) {
219
+ // Keep the segment files for the next attempt to resume.
220
+ throw java.io.IOException("Concurrent download incomplete (segment ${incomplete.index})")
189
221
  }
190
222
 
191
- // Success: `.partial` is fully filled. The manifest's job is done and it
192
- // must never outlive the `.partial` it describes (caller is about to
193
- // promote it), so drop it now.
194
- manifestFile.delete()
223
+ // All segments complete assemble the `.partial`.
224
+ concatenate(partialFile, parts, segFile, total)
195
225
  log("concurrent: completed ($total bytes)")
196
226
  return Outcome.COMPLETED
197
227
  }
198
228
 
229
+ private fun planRanges(total: Long): List<Part> {
230
+ val parts = ArrayList<Part>()
231
+ val chunk = (total + segmentCount - 1) / segmentCount
232
+ var i = 0
233
+ while (i < segmentCount) {
234
+ val start = i * chunk
235
+ if (start >= total) break
236
+ val end = minOf(start + chunk - 1, total - 1)
237
+ parts.add(Part(parts.size, start, end))
238
+ i += 1
239
+ }
240
+ return parts
241
+ }
242
+
199
243
  // Single round-trip probe: a one-byte Range request that confirms Range
200
- // support and captures total size + ETag. OkHttp follows redirects (the
201
- // caller's client enforces HTTPS on each hop).
244
+ // support and captures total size. Object identity is intentionally NOT
245
+ // validated here (no ETag/If-Range) — the caller's whole-file SHA256 + GPG
246
+ // verify after promotion is the sole correctness backstop, so resume is
247
+ // always allowed. OkHttp follows redirects (the caller's client enforces
248
+ // HTTPS on each hop).
202
249
  private fun probe(url: String): Probe? {
203
250
  return try {
204
251
  val req = Request.Builder().url(url).addHeader("Range", "bytes=0-0").build()
205
252
  httpClient.newCall(req).execute().use { response ->
206
- val etag = response.header("ETag")
207
253
  when (response.code) {
208
254
  206 -> {
209
255
  val total = response.header("Content-Range")
210
256
  ?.let { Regex("""bytes \d+-\d+/(\d+)""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() }
211
- if (total != null) Probe(total, etag, true) else Probe(0, etag, false)
257
+ if (total != null) Probe(total, true) else Probe(0, false)
212
258
  }
213
259
  200 -> {
214
260
  // Server ignored Range — single-stream only.
215
261
  val len = response.body?.contentLength() ?: -1L
216
- Probe(if (len > 0) len else 0, etag, false)
262
+ Probe(if (len > 0) len else 0, false)
217
263
  }
218
264
  else -> null
219
265
  }
@@ -224,113 +270,68 @@ class ConcurrentRangeDownloader(
224
270
  }
225
271
  }
226
272
 
227
- private fun dropOrphanManifest(partialFile: File, manifestFile: File) {
228
- if (!partialFile.exists() && manifestFile.exists()) {
229
- log("concurrent: dropping orphan manifest")
230
- manifestFile.delete()
231
- }
232
- }
233
-
234
- private fun discard(partialFile: File, manifestFile: File) {
235
- // Manifest first so it never outlives the partial it describes.
236
- manifestFile.delete()
273
+ private fun wipeArtifacts(partialFile: File, segFile: (Int) -> File) {
237
274
  partialFile.delete()
275
+ for (i in 0 until segmentCount) segFile(i).delete()
238
276
  }
239
277
 
240
- // Resume from a manifest whose size/ETag still match, else (re)create a
241
- // fresh pre-allocated partial + manifest. Manifest is removed before the
242
- // partial is (re)created, and written only after the partial exists.
243
- private fun loadOrInitManifest(
244
- manifestFile: File,
278
+ // Concatenate the completed segment files, in order, into the `.partial`.
279
+ // Append-mode + the `.partial`'s current length as the resume cursor make
280
+ // this idempotent and crash-safe: an interrupted concat resumes where it
281
+ // left off, and each segment is deleted only after it has been fully
282
+ // appended, so the peak footprint stays ~1x the file plus one segment
283
+ // (critical on near-full devices — a 2x "all segs + full copy" peak would
284
+ // re-introduce the out-of-space failure this design exists to avoid).
285
+ private fun concatenate(
245
286
  partialFile: File,
287
+ parts: List<Part>,
288
+ segFile: (Int) -> File,
246
289
  total: Long,
247
- etag: String?,
248
- hasValidator: Boolean,
249
- ): List<Part> {
250
- // Only trust an existing manifest when a strong validator pins it to the
251
- // server object; otherwise always start fresh.
252
- if (hasValidator && manifestFile.exists() && partialFile.exists()) {
253
- val parsed = parseManifest(manifestFile, total, etag, partialFile.length())
254
- if (parsed != null) {
255
- log("concurrent: resuming, transferred=${parsed.sumOf { it.done.get() }}/$total")
256
- return parsed
257
- }
258
- }
259
- discard(partialFile, manifestFile)
260
- RandomAccessFile(partialFile, "rw").use { it.setLength(total) }
261
- val parts = ArrayList<Part>()
262
- val chunk = (total + segmentCount - 1) / segmentCount
263
- var i = 0
264
- while (i < segmentCount) {
265
- val start = i * chunk
266
- if (start >= total) break
267
- val end = minOf(start + chunk - 1, total - 1)
268
- parts.add(Part(parts.size, start, end, 0))
269
- i += 1
270
- }
271
- // Persist the manifest only when it can be safely resumed later.
272
- if (hasValidator) writeManifest(manifestFile, total, etag, parts)
273
- return parts
274
- }
275
-
276
- // Manifest format (dependency-free, internal): line 0 "<size>|<etag>",
277
- // then one "<index>,<start>,<end>,<done>" line per segment.
278
- private fun writeManifest(manifestFile: File, total: Long, etag: String?, parts: List<Part>) {
279
- val sb = StringBuilder()
280
- sb.append(total).append('|').append(etag ?: "").append('\n')
281
- for (p in parts) {
282
- sb.append(p.index).append(',').append(p.start).append(',')
283
- .append(p.end).append(',').append(p.done.get()).append('\n')
284
- }
285
- manifestFile.writeText(sb.toString())
286
- }
287
-
288
- @Synchronized
289
- private fun flushManifest(manifestFile: File, total: Long, etag: String?, parts: List<Part>) {
290
- try {
291
- writeManifest(manifestFile, total, etag, parts)
292
- } catch (e: Exception) {
293
- log("concurrent: manifest flush failed: ${e.javaClass.simpleName}")
290
+ ) {
291
+ var written = if (partialFile.exists()) partialFile.length() else 0L
292
+ if (written > total) {
293
+ // Corrupt/over-long prior concat restart clean.
294
+ partialFile.delete()
295
+ written = 0L
294
296
  }
295
- }
296
-
297
- private fun parseManifest(manifestFile: File, total: Long, etag: String?, partialSize: Long): List<Part>? {
298
- return try {
299
- val lines = manifestFile.readText().trim().split('\n')
300
- if (lines.isEmpty()) return null
301
- val head = lines[0].split('|')
302
- val savedSize = head.getOrNull(0)?.toLongOrNull() ?: return null
303
- val savedEtag = head.getOrNull(1)?.takeIf { it.isNotEmpty() }
304
- // Object must be identical to what's on disk and on the CDN.
305
- if (savedSize != total || partialSize != total) return null
306
- if (etag != null && savedEtag != null && etag != savedEtag) return null
307
- val parts = ArrayList<Part>()
308
- for (idx in 1 until lines.size) {
309
- val cols = lines[idx].split(',')
310
- if (cols.size != 4) return null
311
- val i = cols[0].toIntOrNull() ?: return null
312
- val s = cols[1].toLongOrNull() ?: return null
313
- val e = cols[2].toLongOrNull() ?: return null
314
- var d = cols[3].toLongOrNull() ?: return null
315
- val segLen = e - s + 1
316
- if (d < 0) d = 0
317
- if (d > segLen) d = segLen
318
- parts.add(Part(i, s, e, d))
297
+ FileOutputStream(partialFile, /* append = */ true).use { out ->
298
+ var cursor = 0L
299
+ for (part in parts) {
300
+ val segEndInFinal = cursor + part.length
301
+ if (written < segEndInFinal) {
302
+ val seg = segFile(part.index)
303
+ // Skip the prefix of this segment that a prior interrupted
304
+ // concat already appended (append always writes at EOF).
305
+ val skip = (written - cursor).coerceAtLeast(0L)
306
+ FileInputStream(seg).use { input ->
307
+ var toSkip = skip
308
+ while (toSkip > 0) {
309
+ val s = input.skip(toSkip)
310
+ if (s <= 0) break
311
+ toSkip -= s
312
+ }
313
+ input.copyTo(out)
314
+ }
315
+ out.flush()
316
+ written = segEndInFinal
317
+ }
318
+ cursor = segEndInFinal
319
+ segFile(part.index).delete()
319
320
  }
320
- if (parts.isEmpty()) null else parts
321
- } catch (e: Exception) {
322
- log("concurrent: manifest parse failed: ${e.javaClass.simpleName}")
323
- null
321
+ }
322
+ if (partialFile.length() != total) {
323
+ partialFile.delete()
324
+ throw java.io.IOException("Concat size mismatch (${partialFile.length()}/$total)")
324
325
  }
325
326
  }
326
327
 
327
- // Download [start+done, end] of [part] into its own RandomAccessFile handle
328
- // (each segment gets its own fd so concurrent positioned writes don't race),
329
- // resuming from part.done and retrying transient failures in place.
330
- private fun downloadPart(
328
+ // Fetch [start+have, end] of [part] into its OWN segment file via plain
329
+ // sequential O_WRONLY appends (no positioned writes, no pre-allocation),
330
+ // resuming from the segment file's current length and retrying transient
331
+ // failures in place.
332
+ private fun downloadSegment(
331
333
  url: String,
332
- etag: String?,
333
- partialFile: File,
334
+ segFile: File,
334
335
  part: Part,
335
336
  aborted: AtomicBoolean,
336
337
  onBytes: (delta: Long) -> Unit,
@@ -338,10 +339,11 @@ class ConcurrentRangeDownloader(
338
339
  var retry = 0
339
340
  while (true) {
340
341
  if (aborted.get()) throw java.io.IOException("aborted")
341
- val rangeStart = part.start + part.done.get()
342
- if (rangeStart > part.end) return
342
+ val have = segFile.length()
343
+ if (have >= part.length) return
344
+ val rangeStart = part.start + have
343
345
  try {
344
- fetchSegment(url, etag, partialFile, part, rangeStart, aborted, onBytes)
346
+ fetchSegment(url, segFile, part, rangeStart, aborted, onBytes)
345
347
  return
346
348
  } catch (e: FallbackException) {
347
349
  throw e
@@ -355,40 +357,74 @@ class ConcurrentRangeDownloader(
355
357
 
356
358
  private fun fetchSegment(
357
359
  url: String,
358
- etag: String?,
359
- partialFile: File,
360
+ segFile: File,
360
361
  part: Part,
361
362
  rangeStart: Long,
362
363
  aborted: AtomicBoolean,
363
364
  onBytes: (delta: Long) -> Unit,
364
365
  ) {
365
- val builder = Request.Builder().url(url)
366
+ val request = Request.Builder().url(url)
366
367
  .addHeader("Range", "bytes=$rangeStart-${part.end}")
367
- // If-Range: a mismatched ETag makes the CDN reply 200 (full body)
368
- // instead of 206, which we treat as a fallback signal.
369
- if (etag != null) builder.addHeader("If-Range", etag)
370
- httpClient.newCall(builder.build()).execute().use { response ->
368
+ .build()
369
+ httpClient.newCall(request).execute().use { response ->
370
+ // A 200 (full body) to a Range request is the one validator-free
371
+ // safety net we keep: appending a from-zero body onto a partially
372
+ // filled segment would corrupt it, so bail to the single-stream path.
371
373
  if (response.code == 200) {
372
374
  throw FallbackException("server returned 200 to a Range request")
373
375
  }
374
376
  if (response.code != 206) {
375
377
  throw java.io.IOException("HTTP ${response.code}")
376
378
  }
379
+ // Verify the 206 covers exactly the slice we asked for. This guards
380
+ // against a proxy/CDN returning a mis-aligned 206 (wrong window),
381
+ // which would otherwise silently corrupt the assembled file. A
382
+ // missing/mismatched Content-Range is treated as transient (retry).
383
+ // It canNOT detect an object swapped behind an identical window —
384
+ // that is the caller's whole-file SHA256/GPG verify's job.
385
+ val contentRange = response.header("Content-Range")
386
+ ?: throw java.io.IOException("206 without Content-Range")
387
+ val bounds = parseContentRangeBounds(contentRange)
388
+ ?: throw java.io.IOException("unparseable Content-Range: $contentRange")
389
+ if (bounds.first != rangeStart || bounds.second != part.end) {
390
+ throw java.io.IOException(
391
+ "Content-Range mismatch: got ${bounds.first}-${bounds.second}, " +
392
+ "expected $rangeStart-${part.end}"
393
+ )
394
+ }
377
395
  val body = response.body ?: throw java.io.IOException("Empty segment body")
378
- RandomAccessFile(partialFile, "rw").use { raf ->
379
- raf.seek(rangeStart)
396
+ // Append the fetched tail to the segment file. Append mode keeps
397
+ // resume correct: we only ever request the bytes not yet on disk.
398
+ FileOutputStream(segFile, /* append = */ true).use { out ->
380
399
  body.byteStream().use { input ->
381
400
  val buffer = ByteArray(8192)
382
401
  while (true) {
383
402
  if (aborted.get()) throw java.io.IOException("aborted")
384
403
  val read = input.read(buffer)
385
404
  if (read == -1) break
386
- raf.write(buffer, 0, read)
387
- part.done.addAndGet(read.toLong())
405
+ out.write(buffer, 0, read)
388
406
  onBytes(read.toLong())
389
407
  }
390
408
  }
391
409
  }
392
410
  }
411
+ // A 206 can still over-deliver (server ignored our end bound). A segment
412
+ // longer than its planned length is unusable — drop it so the next
413
+ // attempt re-fetches cleanly rather than concatenating misaligned bytes.
414
+ if (segFile.length() > part.length) {
415
+ segFile.delete()
416
+ throw java.io.IOException(
417
+ "Segment ${part.index} overran (${segFile.length()}/${part.length})"
418
+ )
419
+ }
420
+ }
421
+
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
426
+ val start = m.groupValues[1].toLongOrNull() ?: return null
427
+ val end = m.groupValues[2].toLongOrNull() ?: return null
428
+ return start to end
393
429
  }
394
430
  }
@@ -10,20 +10,23 @@ import java.util.concurrent.ConcurrentHashMap
10
10
  import java.util.concurrent.CopyOnWriteArrayList
11
11
  import java.util.concurrent.atomic.AtomicLong
12
12
 
13
- // P1: faithful migration of the Android concurrent multi-range downloader.
13
+ // P1: Nitro adapter for the Android concurrent multi-range downloader.
14
14
  //
15
- // The core algorithm (.partial preallocation + .progress manifest resume +
16
- // 8-segment thread pool + If-Range/200 fallback) lives unchanged in the
17
- // in-module ConcurrentRangeDownloader helper, copied byte-for-byte from
18
- // react-native-bundle-update. This class is the Nitro adapter: it builds the
19
- // HTTPS-only OkHttpClient, drives the helper, finalizes on COMPLETED (promote
20
- // .partial -> dest + optional SHA256 self-check), and bridges progress to the
21
- // shared listener registry as RangeDownloadEvent (tagged with channel/taskId).
15
+ // The core algorithm lives in the in-module ConcurrentRangeDownloader helper:
16
+ // each of the N segments streams into its own sibling file
17
+ // `<dest>.partial.seg0` .. `<dest>.partial.segN-1` (no whole-file preallocation,
18
+ // no `.progress` manifest), and on success the segments are concatenated in
19
+ // order into `<dest>.partial`. Resume re-uses whatever bytes each `.segN`
20
+ // already holds. An 8-segment thread pool plus a 200-to-a-Range fallback round
21
+ // it out (object identity is not pinned — no ETag/If-Range; the optional
22
+ // whole-file SHA256 self-check below is the correctness backstop). This class
23
+ // builds the HTTPS-only OkHttpClient, drives the helper,
24
+ // finalizes on COMPLETED (promote .partial -> dest + optional SHA256
25
+ // self-check), and bridges progress to the shared listener registry as
26
+ // RangeDownloadEvent (tagged with channel/taskId).
22
27
  //
23
28
  // Android has no background-session concept: `channel` is only an event label
24
- // + artifact-directory tag and does not change the download mechanism. The
25
- // on-disk format (.partial + .progress) is kept exactly as shipped so existing
26
- // interrupted downloads resume cleanly.
29
+ // + artifact-directory tag and does not change the download mechanism.
27
30
  @DoNotStrip
28
31
  class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() {
29
32
 
@@ -83,7 +86,12 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() {
83
86
  val cancelHandle = ConcurrentRangeDownloader.CancelHandle()
84
87
  activeDownloads[runKey] = cancelHandle
85
88
 
86
- var lastProgress = -1
89
+ // The progress callback is invoked concurrently by the helper's worker
90
+ // threads, so guard lastProgress with an AtomicInteger + CAS: only the
91
+ // thread that advances the percentage to a strictly higher value wins the
92
+ // CAS and emits the event, which keeps progress monotonic and de-duped
93
+ // without a lock (this only affects event ordering, never file bytes).
94
+ val lastProgress = java.util.concurrent.atomic.AtomicInteger(-1)
87
95
  val outcome = try {
88
96
  ConcurrentRangeDownloader(
89
97
  httpClient = httpClient,
@@ -93,9 +101,9 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() {
93
101
  ).download(downloadUrl, partialFilePath, cancelHandle) { transferred, total ->
94
102
  if (total > 0) {
95
103
  val p = ((transferred * 100) / total).toInt().coerceIn(0, 100)
96
- if (p != lastProgress) {
104
+ val prev = lastProgress.get()
105
+ if (p > prev && lastProgress.compareAndSet(prev, p)) {
97
106
  sendEvent(channel, taskId, type = "progress", progress = p)
98
- lastProgress = p
99
107
  }
100
108
  }
101
109
  }
@@ -158,9 +166,11 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() {
158
166
  // files, otherwise a still-running segment could re-create the .partial we
159
167
  // just deleted.
160
168
  cancelActive(channel, taskId)
161
- // Manifest first so it never outlives the partial it describes.
162
- File("$destFilePath.partial.progress").delete()
163
- File("$destFilePath.partial").delete()
169
+ // Sweep the per-segment `.segN` files plus the concatenated `.partial` so a
170
+ // future resume can't re-trust stale bytes (no `.progress` manifest exists
171
+ // anymore in the segmented model). Glob by filename prefix so any custom
172
+ // segmentCount is fully cleared, not just the shipped default of 8.
173
+ sweepPartialArtifacts(destFilePath)
164
174
  OneKeyLog.info("RangeDownloader", "discardArtifacts: channel=$channel taskId=$taskId")
165
175
  Unit
166
176
  }
@@ -174,8 +184,9 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() {
174
184
  return Promise.async {
175
185
  // Stop workers first, then delete artifacts so nothing resurrects them.
176
186
  cancelActive(channel, taskId)
177
- File("$destFilePath.partial.progress").delete()
178
- File("$destFilePath.partial").delete()
187
+ // Same segmented-artifact sweep as discardArtifacts: glob every per-segment
188
+ // `.segN` file by prefix plus the concatenated `.partial`.
189
+ sweepPartialArtifacts(destFilePath)
179
190
  OneKeyLog.info("RangeDownloader", "cancel: channel=$channel taskId=$taskId")
180
191
  Unit
181
192
  }
@@ -186,6 +197,17 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() {
186
197
  activeDownloads.remove(runKey(channel, taskId))?.cancel()
187
198
  }
188
199
 
200
+ // Delete every sibling artifact for [destFilePath]: all `<dest>.partial.seg<N>`
201
+ // segment files (matched by filename prefix, so any segmentCount is swept, not
202
+ // 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
+ }
210
+
189
211
  // Atomically replace [dest] with [src] so a kill mid-finalize never leaves
190
212
  // NEITHER file. On API 26+ uses Files.move with ATOMIC_MOVE/REPLACE_EXISTING
191
213
  // (single-step rename onto the destination). On older APIs (java.nio.file is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-range-downloader",
3
- "version": "3.0.65",
3
+ "version": "3.0.67",
4
4
  "description": "react-native-range-downloader",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",