@onekeyfe/react-native-range-downloader 3.0.52 → 3.0.54

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 CHANGED
@@ -5,20 +5,38 @@ react-native-range-downloader
5
5
  ## Installation
6
6
 
7
7
  ```sh
8
- npm install react-native-range-downloader react-native-nitro-modules
8
+ npm install @onekeyfe/react-native-range-downloader react-native-nitro-modules
9
9
 
10
10
  > `react-native-nitro-modules` is required as this library relies on [Nitro Modules](https://nitro.margelo.com/).
11
11
  ```
12
12
 
13
13
  ## Usage
14
14
 
15
- ```js
16
- import { ReactNativeRangeDownloader } from 'react-native-range-downloader';
15
+ ```ts
16
+ import { ReactNativeRangeDownloader } from '@onekeyfe/react-native-range-downloader';
17
17
 
18
18
  // ...
19
19
 
20
- const result = await ReactNativeRangeDownloader.hello({ message: 'World' });
21
- console.log(result); // { success: true, data: 'Hello, World!' }
20
+ const taskId = 'chart-assets';
21
+ const destFilePath = `${ReactNativeRangeDownloader.getDownloadsDir()}/chart-assets.zip`;
22
+
23
+ const listenerId = ReactNativeRangeDownloader.addDownloadListener((event) => {
24
+ if (event.channel === 'chart' && event.taskId === taskId) {
25
+ console.log(event.type, event.progress, event.message);
26
+ }
27
+ });
28
+
29
+ try {
30
+ const result = await ReactNativeRangeDownloader.download({
31
+ channel: 'chart',
32
+ taskId,
33
+ url: 'https://example.com/chart-assets.zip',
34
+ destFilePath,
35
+ });
36
+ console.log(result.outcome, result.filePath, result.fallbackReason);
37
+ } finally {
38
+ ReactNativeRangeDownloader.removeDownloadListener(listenerId);
39
+ }
22
40
  ```
23
41
 
24
42
  ## Contributing
@@ -44,7 +44,35 @@ class ConcurrentRangeDownloader(
44
44
  /** Thrown internally when a segment proves concurrency can't be used. */
45
45
  private class FallbackException(message: String) : Exception(message)
46
46
 
47
- private class Part(val index: Int, val start: Long, val end: Long, @Volatile var done: Long) {
47
+ /**
48
+ * Cooperative-cancel handle the caller can register a download against. The
49
+ * adapter keeps these in a per-taskId registry so `cancel`/`discardArtifacts`
50
+ * can flip [aborted] and `shutdownNow()` the worker pool BEFORE deleting the
51
+ * `.partial`/`.progress`, so no in-flight worker resurrects a deleted file.
52
+ */
53
+ class CancelHandle {
54
+ val aborted = AtomicBoolean(false)
55
+
56
+ @Volatile
57
+ private var pool: java.util.concurrent.ExecutorService? = null
58
+
59
+ internal fun attach(pool: java.util.concurrent.ExecutorService) {
60
+ this.pool = pool
61
+ // If cancel() already raced in before the pool was attached, honor it.
62
+ if (aborted.get()) pool.shutdownNow()
63
+ }
64
+
65
+ /** Flip the abort flag and stop the worker pool. Idempotent. */
66
+ fun cancel() {
67
+ aborted.set(true)
68
+ pool?.shutdownNow()
69
+ }
70
+ }
71
+
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)
48
76
  val length: Long get() = end - start + 1
49
77
  }
50
78
 
@@ -59,6 +87,7 @@ class ConcurrentRangeDownloader(
59
87
  fun download(
60
88
  url: String,
61
89
  partialFilePath: String,
90
+ cancelHandle: CancelHandle? = null,
62
91
  onProgress: (transferred: Long, total: Long) -> Unit,
63
92
  ): Outcome {
64
93
  val partialFile = File(partialFilePath)
@@ -78,30 +107,41 @@ class ConcurrentRangeDownloader(
78
107
  }
79
108
  val total = probe.totalSize
80
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()
81
116
 
82
117
  partialFile.parentFile?.let { if (!it.exists()) it.mkdirs() }
83
118
  dropOrphanManifest(partialFile, manifestFile)
84
- val parts = loadOrInitManifest(manifestFile, partialFile, total, etag)
119
+ val parts = loadOrInitManifest(manifestFile, partialFile, total, etag, hasValidator)
85
120
 
86
- val transferred = AtomicLong(parts.sumOf { it.done })
121
+ val transferred = AtomicLong(parts.sumOf { it.done.get() })
87
122
  onProgress(transferred.get(), total)
88
123
 
89
- val aborted = AtomicBoolean(false)
124
+ // Share the abort flag with the cancel handle so an external cancel() is
125
+ // observed by the per-segment loops; default to a private flag otherwise.
126
+ val aborted = cancelHandle?.aborted ?: AtomicBoolean(false)
90
127
  val fallback = AtomicBoolean(false)
91
128
  val firstError = AtomicReference<Exception?>(null)
92
- val lastFlushed = LongArray(parts.size) { parts[it].done }
129
+ val lastFlushed = LongArray(parts.size) { parts[it].done.get() }
93
130
 
94
131
  val pool = Executors.newFixedThreadPool(minOf(segmentCount, parts.size))
132
+ cancelHandle?.attach(pool)
95
133
  try {
96
134
  val futures = parts.map { part ->
97
135
  pool.submit {
98
136
  try {
99
137
  downloadPart(url, etag, partialFile, part, aborted) { delta ->
100
138
  val t = transferred.addAndGet(delta)
101
- synchronized(lastFlushed) {
102
- if (part.done - lastFlushed[part.index] >= manifestFlushBytes) {
103
- lastFlushed[part.index] = part.done
104
- flushManifest(manifestFile, total, etag, parts)
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
+ }
105
145
  }
106
146
  }
107
147
  onProgress(t, total)
@@ -128,13 +168,23 @@ class ConcurrentRangeDownloader(
128
168
  }
129
169
  val err = firstError.get()
130
170
  if (err != null) {
131
- // Transient — persist progress so the next attempt resumes, then bubble up.
132
- flushManifest(manifestFile, total, etag, parts)
171
+ if (hasValidator) {
172
+ // Transient — persist 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
+ }
133
179
  throw err
134
180
  }
135
- val got = parts.sumOf { it.done }
181
+ val got = parts.sumOf { it.done.get() }
136
182
  if (got < total) {
137
- flushManifest(manifestFile, total, etag, parts)
183
+ if (hasValidator) {
184
+ flushManifest(manifestFile, total, etag, parts)
185
+ } else {
186
+ discard(partialFile, manifestFile)
187
+ }
138
188
  throw java.io.IOException("Concurrent download incomplete ($got/$total)")
139
189
  }
140
190
 
@@ -195,11 +245,14 @@ class ConcurrentRangeDownloader(
195
245
  partialFile: File,
196
246
  total: Long,
197
247
  etag: String?,
248
+ hasValidator: Boolean,
198
249
  ): List<Part> {
199
- if (manifestFile.exists() && partialFile.exists()) {
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()) {
200
253
  val parsed = parseManifest(manifestFile, total, etag, partialFile.length())
201
254
  if (parsed != null) {
202
- log("concurrent: resuming, transferred=${parsed.sumOf { it.done }}/$total")
255
+ log("concurrent: resuming, transferred=${parsed.sumOf { it.done.get() }}/$total")
203
256
  return parsed
204
257
  }
205
258
  }
@@ -215,7 +268,8 @@ class ConcurrentRangeDownloader(
215
268
  parts.add(Part(parts.size, start, end, 0))
216
269
  i += 1
217
270
  }
218
- writeManifest(manifestFile, total, etag, parts)
271
+ // Persist the manifest only when it can be safely resumed later.
272
+ if (hasValidator) writeManifest(manifestFile, total, etag, parts)
219
273
  return parts
220
274
  }
221
275
 
@@ -226,7 +280,7 @@ class ConcurrentRangeDownloader(
226
280
  sb.append(total).append('|').append(etag ?: "").append('\n')
227
281
  for (p in parts) {
228
282
  sb.append(p.index).append(',').append(p.start).append(',')
229
- .append(p.end).append(',').append(p.done).append('\n')
283
+ .append(p.end).append(',').append(p.done.get()).append('\n')
230
284
  }
231
285
  manifestFile.writeText(sb.toString())
232
286
  }
@@ -284,7 +338,7 @@ class ConcurrentRangeDownloader(
284
338
  var retry = 0
285
339
  while (true) {
286
340
  if (aborted.get()) throw java.io.IOException("aborted")
287
- val rangeStart = part.start + part.done
341
+ val rangeStart = part.start + part.done.get()
288
342
  if (rangeStart > part.end) return
289
343
  try {
290
344
  fetchSegment(url, etag, partialFile, part, rangeStart, aborted, onBytes)
@@ -330,7 +384,7 @@ class ConcurrentRangeDownloader(
330
384
  val read = input.read(buffer)
331
385
  if (read == -1) break
332
386
  raf.write(buffer, 0, read)
333
- part.done += read
387
+ part.done.addAndGet(read.toLong())
334
388
  onBytes(read.toLong())
335
389
  }
336
390
  }
@@ -6,6 +6,7 @@ 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
9
10
  import java.util.concurrent.CopyOnWriteArrayList
10
11
  import java.util.concurrent.atomic.AtomicLong
11
12
 
@@ -31,6 +32,15 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() {
31
32
  private val listeners = CopyOnWriteArrayList<Listener>()
32
33
  private val nextListenerId = AtomicLong(1)
33
34
 
35
+ // Active downloads keyed by "channel|taskId" so cancel/discardArtifacts can
36
+ // flip the abort flag + stop the worker pool BEFORE deleting files, instead of
37
+ // racing live workers that would resurrect a just-deleted .partial.
38
+ private val activeDownloads =
39
+ ConcurrentHashMap<String, ConcurrentRangeDownloader.CancelHandle>()
40
+
41
+ private fun runKey(channel: DownloadChannel, taskId: String): String =
42
+ "${channel.name}|$taskId"
43
+
34
44
  // HTTPS-only client: reject any redirect to a non-HTTPS hop. Mirrors the
35
45
  // existing react-native-bundle-update configuration verbatim.
36
46
  private val httpClient = okhttp3.OkHttpClient.Builder()
@@ -69,20 +79,29 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() {
69
79
 
70
80
  sendEvent(channel, taskId, type = "start")
71
81
 
82
+ val runKey = runKey(channel, taskId)
83
+ val cancelHandle = ConcurrentRangeDownloader.CancelHandle()
84
+ activeDownloads[runKey] = cancelHandle
85
+
72
86
  var lastProgress = -1
73
- val outcome = ConcurrentRangeDownloader(
74
- httpClient = httpClient,
75
- segmentCount = segmentCount,
76
- minConcurrentBytes = minConcurrentBytes,
77
- log = { msg -> OneKeyLog.info("RangeDownloader", msg) },
78
- ).download(downloadUrl, partialFilePath) { transferred, total ->
79
- if (total > 0) {
80
- val p = ((transferred * 100) / total).toInt().coerceIn(0, 100)
81
- if (p != lastProgress) {
82
- sendEvent(channel, taskId, type = "progress", progress = p)
83
- lastProgress = p
87
+ val outcome = try {
88
+ ConcurrentRangeDownloader(
89
+ httpClient = httpClient,
90
+ segmentCount = segmentCount,
91
+ minConcurrentBytes = minConcurrentBytes,
92
+ log = { msg -> OneKeyLog.info("RangeDownloader", msg) },
93
+ ).download(downloadUrl, partialFilePath, cancelHandle) { transferred, total ->
94
+ if (total > 0) {
95
+ val p = ((transferred * 100) / total).toInt().coerceIn(0, 100)
96
+ if (p != lastProgress) {
97
+ sendEvent(channel, taskId, type = "progress", progress = p)
98
+ lastProgress = p
99
+ }
84
100
  }
85
101
  }
102
+ } finally {
103
+ // Only deregister our own handle (a concurrent cancel may have replaced it).
104
+ activeDownloads.remove(runKey, cancelHandle)
86
105
  }
87
106
 
88
107
  if (outcome == ConcurrentRangeDownloader.Outcome.FALLBACK) {
@@ -99,9 +118,12 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() {
99
118
 
100
119
  // COMPLETED: `.partial` is fully on disk. Promote -> final, then run the
101
120
  // optional in-module SHA256 self-check (mirrors the source finalize path).
102
- if (destFile.exists()) destFile.delete()
103
- if (!File(partialFilePath).renameTo(destFile)) {
104
- OneKeyLog.error("RangeDownloader", "download: rename .partial -> final failed")
121
+ // Use an atomic move so a kill mid-finalize never leaves NEITHER file:
122
+ // the destination is replaced in one step, preserving the previous file on
123
+ // failure (vs. the old delete-then-rename, which had a window with both
124
+ // gone if the rename then failed).
125
+ if (!promoteAtomically(File(partialFilePath), destFile)) {
126
+ OneKeyLog.error("RangeDownloader", "download: promote .partial -> final failed")
105
127
  sendEvent(channel, taskId, type = "error", message = "Failed to finalize download")
106
128
  throw Exception("Failed to finalize download")
107
129
  }
@@ -132,6 +154,10 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() {
132
154
  destFilePath: String
133
155
  ): Promise<Unit> {
134
156
  return Promise.async {
157
+ // Cancel-then-delete: stop any live workers for this task before removing
158
+ // files, otherwise a still-running segment could re-create the .partial we
159
+ // just deleted.
160
+ cancelActive(channel, taskId)
135
161
  // Manifest first so it never outlives the partial it describes.
136
162
  File("$destFilePath.partial.progress").delete()
137
163
  File("$destFilePath.partial").delete()
@@ -140,6 +166,65 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() {
140
166
  }
141
167
  }
142
168
 
169
+ override fun cancel(
170
+ channel: DownloadChannel,
171
+ taskId: String,
172
+ destFilePath: String
173
+ ): Promise<Unit> {
174
+ return Promise.async {
175
+ // Stop workers first, then delete artifacts so nothing resurrects them.
176
+ cancelActive(channel, taskId)
177
+ File("$destFilePath.partial.progress").delete()
178
+ File("$destFilePath.partial").delete()
179
+ OneKeyLog.info("RangeDownloader", "cancel: channel=$channel taskId=$taskId")
180
+ Unit
181
+ }
182
+ }
183
+
184
+ // Flip the abort flag + shutdown the pool for an in-flight download (if any).
185
+ private fun cancelActive(channel: DownloadChannel, taskId: String) {
186
+ activeDownloads.remove(runKey(channel, taskId))?.cancel()
187
+ }
188
+
189
+ // Atomically replace [dest] with [src] so a kill mid-finalize never leaves
190
+ // NEITHER file. On API 26+ uses Files.move with ATOMIC_MOVE/REPLACE_EXISTING
191
+ // (single-step rename onto the destination). On older APIs (java.nio.file is
192
+ // API 26+) File.renameTo onto an existing dest is itself an atomic rename on a
193
+ // POSIX filesystem (the kernel replaces the inode in one step), which gives
194
+ // the same "old file preserved until the new one lands" guarantee.
195
+ private fun promoteAtomically(src: File, dest: File): Boolean {
196
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
197
+ try {
198
+ java.nio.file.Files.move(
199
+ src.toPath(), dest.toPath(),
200
+ java.nio.file.StandardCopyOption.ATOMIC_MOVE,
201
+ java.nio.file.StandardCopyOption.REPLACE_EXISTING,
202
+ )
203
+ return true
204
+ } catch (e: Exception) {
205
+ // ATOMIC_MOVE may be unsupported across the source/dest (e.g. different
206
+ // stores) — retry a plain replace, still single-step on one filesystem.
207
+ try {
208
+ java.nio.file.Files.move(
209
+ src.toPath(), dest.toPath(),
210
+ java.nio.file.StandardCopyOption.REPLACE_EXISTING,
211
+ )
212
+ return true
213
+ } catch (e2: Exception) {
214
+ OneKeyLog.error(
215
+ "RangeDownloader",
216
+ "promoteAtomically: Files.move failed (${e2.javaClass.simpleName}), falling back to renameTo",
217
+ )
218
+ }
219
+ }
220
+ }
221
+ // API < 26 (or Files.move unsupported): rename directly onto the dest. On a
222
+ // POSIX filesystem this replaces the destination atomically and keeps the old
223
+ // file until the rename lands. Do NOT pre-delete the dest — that reintroduces
224
+ // the both-files-gone window we are fixing.
225
+ return src.renameTo(dest)
226
+ }
227
+
143
228
  override fun addDownloadListener(callback: (event: RangeDownloadEvent) -> Unit): Double {
144
229
  val id = nextListenerId.getAndIncrement().toDouble()
145
230
  listeners.add(Listener(id, callback))
@@ -42,7 +42,23 @@ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec {
42
42
  destFilePath: String
43
43
  ) throws -> Promise<Void> {
44
44
  return Promise.async {
45
- RangeDownloader.shared.discardArtifacts(filePath: destFilePath)
45
+ // Cancel-then-delete: cancel any in-flight tasks for this run before
46
+ // removing files so a running task can't resurrect a deleted segment.
47
+ await RangeDownloader.shared.cancel(
48
+ channel: channel, taskId: taskId, filePath: destFilePath
49
+ )
50
+ }
51
+ }
52
+
53
+ func cancel(
54
+ channel: DownloadChannel,
55
+ taskId: String,
56
+ destFilePath: String
57
+ ) throws -> Promise<Void> {
58
+ return Promise.async {
59
+ await RangeDownloader.shared.cancel(
60
+ channel: channel, taskId: taskId, filePath: destFilePath
61
+ )
46
62
  }
47
63
  }
48
64
 
@@ -145,6 +161,12 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
145
161
  var continuation: CheckedContinuation<Void, Error>?
146
162
  var prevProgress = -1
147
163
  var fellBack = false
164
+ /// Set when a segment could not be stashed (move/size-check failure). Carries
165
+ /// the terminal error so didCompleteWithError finalizes instead of hanging.
166
+ var stashError: Error?
167
+ /// Whether a strong validator (ETag) was captured for this run. When false,
168
+ /// resumable `.segN` state must not be trusted across attempts.
169
+ var hasValidator = false
148
170
  let sessionIdentifier: String
149
171
 
150
172
  init(channel: DownloadChannel, taskId: String, filePath: String,
@@ -344,6 +366,7 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
344
366
  )
345
367
  state.totalSize = probe.total
346
368
  state.etag = probe.etag
369
+ state.hasValidator = (probe.etag?.isEmpty == false)
347
370
  state.ranges = Self.planRanges(total: probe.total, segments: segCount)
348
371
  state.segmentWritten = [Int64](repeating: 0, count: state.ranges.count)
349
372
 
@@ -353,6 +376,14 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
353
376
 
354
377
  let ranges = state.ranges
355
378
 
379
+ // Without a strong validator (ETag) we cannot pin stashed `.segN` to a
380
+ // specific server object via If-Range, so any leftover segments from a prior
381
+ // attempt are untrustworthy. Start fresh and only proceed on the resumable
382
+ // path when expectedSha256 will gate the assembled file (verified below).
383
+ if !state.hasValidator {
384
+ cleanupSegments(state: state, ranges: ranges)
385
+ }
386
+
356
387
  emit(channel: channel, taskId: taskId, type: "start", progress: 0, message: "")
357
388
 
358
389
  do {
@@ -461,6 +492,22 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
461
492
  return Int64(tail.trimmingCharacters(in: .whitespaces))
462
493
  }
463
494
 
495
+ /// Parses the start/end of a "bytes <start>-<end>/<total>" Content-Range.
496
+ static func parseContentRangeBounds(_ header: String) -> (start: Int64, end: Int64)? {
497
+ // Drop the leading "bytes " and the trailing "/<total>".
498
+ let trimmed = header.trimmingCharacters(in: .whitespaces)
499
+ guard let spaceIdx = trimmed.firstIndex(of: " ") else { return nil }
500
+ var rangePart = String(trimmed[trimmed.index(after: spaceIdx)...])
501
+ if let slash = rangePart.firstIndex(of: "/") {
502
+ rangePart = String(rangePart[..<slash])
503
+ }
504
+ let bounds = rangePart.split(separator: "-", maxSplits: 1).map { String($0) }
505
+ guard bounds.count == 2,
506
+ let start = Int64(bounds[0].trimmingCharacters(in: .whitespaces)),
507
+ let end = Int64(bounds[1].trimmingCharacters(in: .whitespaces)) else { return nil }
508
+ return (start, end)
509
+ }
510
+
464
511
  // MARK: - Task reconciliation (handles app relaunch)
465
512
 
466
513
  /// Ensures exactly one in-flight (or completed) artifact per missing segment:
@@ -563,15 +610,50 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
563
610
  didFinishDownloadingTo location: URL) {
564
611
  guard let desc = downloadTask.taskDescription,
565
612
  let (state, idx) = run(for: desc) else { return }
566
- // A 200 means the server ignored Range / the ETag changed — we cannot
567
- // safely assemble. Flag fallback; finalize happens in didCompleteWithError.
568
- if let http = downloadTask.response as? HTTPURLResponse, http.statusCode == 200 {
613
+ let ranges = lock.withLockValue { state.ranges }
614
+ guard idx < ranges.count else { return }
615
+ let range = ranges[idx]
616
+ let expectedLen = range.end - range.start + 1
617
+
618
+ // We require a 206 Partial Content that matches the requested byte range.
619
+ // Anything else — 200 (Range ignored / ETag changed) or an out-of-range
620
+ // Content-Range — means we cannot safely assemble this segment, so flag
621
+ // fallback; finalize happens in didCompleteWithError.
622
+ guard let http = downloadTask.response as? HTTPURLResponse else {
623
+ lock.lock(); state.fellBack = true; lock.unlock()
624
+ return
625
+ }
626
+ if http.statusCode != 206 {
627
+ lock.lock(); state.fellBack = true; lock.unlock()
628
+ return
629
+ }
630
+ // Verify the server's Content-Range start/end matches what we asked for so a
631
+ // stashed `.segN` can't be a slice of a different object/range.
632
+ if let cr = http.value(forHTTPHeaderField: "Content-Range") {
633
+ guard let parsed = Self.parseContentRangeBounds(cr),
634
+ parsed.start == range.start, parsed.end == range.end else {
635
+ lock.lock(); state.fellBack = true; lock.unlock()
636
+ return
637
+ }
638
+ } else {
639
+ // 206 without a Content-Range header is non-conforming — don't trust it.
569
640
  lock.lock(); state.fellBack = true; lock.unlock()
570
641
  return
571
642
  }
572
- // Move the segment into place atomically (temp file is deleted after return).
643
+
644
+ // Validate the downloaded body length BEFORE moving it into place: a
645
+ // truncated 206 must never be stashed as a valid segment.
573
646
  let dest = state.segPath(idx)
574
647
  do {
648
+ let attrs = try FileManager.default.attributesOfItem(atPath: location.path)
649
+ let size = attrs[.size] as? Int64
650
+ guard let size = size, size == expectedLen else {
651
+ throw NSError(domain: "RangeDownloader", code: -2, userInfo: [
652
+ NSLocalizedDescriptionKey:
653
+ "segment \(idx) truncated (got \(size.map(String.init) ?? "nil"), expected \(expectedLen))"
654
+ ])
655
+ }
656
+ // Move the segment into place (temp file is deleted after return).
575
657
  let dir = (dest as NSString).deletingLastPathComponent
576
658
  if !FileManager.default.fileExists(atPath: dir) {
577
659
  try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
@@ -583,6 +665,12 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
583
665
  } catch {
584
666
  OneKeyLog.error("RangeDownloader",
585
667
  "\(state.channel.stringValue)/\(state.taskId): failed to stash segment \(idx): \(error)")
668
+ // Record the failure so didCompleteWithError finalizes the run with this
669
+ // terminal error instead of waiting forever for a segment that will never
670
+ // appear.
671
+ lock.lock()
672
+ if state.stashError == nil { state.stashError = error }
673
+ lock.unlock()
586
674
  }
587
675
  }
588
676
 
@@ -621,10 +709,45 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
621
709
  ranges: ranges)
622
710
  return
623
711
  }
712
+ // A segment failed to stash (move/size-check failure in didFinishDownloadingTo).
713
+ // That segment will never appear, so finalize terminally instead of waiting.
714
+ if let stashErr = lock.withLockValue({ state.stashError }) {
715
+ finishContinuation(state: state, with: stashErr, ranges: ranges)
716
+ return
717
+ }
624
718
  if allSegmentsPresent(state: state, ranges: ranges) {
625
719
  finishContinuation(state: state, with: nil, ranges: ranges)
720
+ return
721
+ }
722
+ // This task finished error==nil but not all segments are present. If any
723
+ // tasks for THIS run are still in flight, wait for their completions.
724
+ // Otherwise nothing will ever resume the continuation — finalize with a
725
+ // descriptive terminal error so the JS promise resolves (as fallback).
726
+ let channel = state.channel
727
+ let taskId = state.taskId
728
+ session.getAllTasks { [weak self] tasks in
729
+ guard let self = self else { return }
730
+ let stillInFlight = tasks.contains { t in
731
+ guard let d = t.taskDescription,
732
+ let decoded = Self.decodeTaskDescription(d),
733
+ decoded.channel.stringValue == channel.stringValue,
734
+ decoded.taskId == taskId else { return false }
735
+ return t.state == .running || t.state == .suspended
736
+ }
737
+ if stillInFlight { return }
738
+ // Re-check under no-in-flight: a just-finished stash may have completed.
739
+ if self.allSegmentsPresent(state: state, ranges: ranges) {
740
+ self.finishContinuation(state: state, with: nil, ranges: ranges)
741
+ return
742
+ }
743
+ let missing = ranges.indices.first {
744
+ !FileManager.default.fileExists(atPath: state.segPath($0))
745
+ }
746
+ let reason = "segment \(missing.map(String.init) ?? "?") missing/truncated after completion"
747
+ self.finishContinuation(state: state,
748
+ with: FallbackError(reason: reason),
749
+ ranges: ranges)
626
750
  }
627
- // else: other segments still in flight; wait for their completions.
628
751
  }
629
752
 
630
753
  /// Called on the session delegate queue when all background events for this
@@ -675,9 +798,22 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
675
798
  userInfo: [NSLocalizedDescriptionKey: "cannot open partial for write"])
676
799
  }
677
800
  defer { try? out.close() }
801
+ // Stream each segment in fixed-size chunks so a full segment is never held
802
+ // in RAM (mirrors the streamed SHA256 backstop below).
803
+ let chunkSize = 1 * 1024 * 1024
678
804
  for idx in 0..<ranges.count {
679
- let segData = try Data(contentsOf: URL(fileURLWithPath: state.segPath(idx)))
680
- try out.write(contentsOf: segData)
805
+ let segPath = state.segPath(idx)
806
+ guard let inHandle = FileHandle(forReadingAtPath: segPath) else {
807
+ throw NSError(domain: "RangeDownloader", code: -3,
808
+ userInfo: [NSLocalizedDescriptionKey: "cannot open segment \(idx) for read"])
809
+ }
810
+ defer { try? inHandle.close() }
811
+ while try autoreleasepool(invoking: { () -> Bool in
812
+ let data = inHandle.readData(ofLength: chunkSize)
813
+ if data.isEmpty { return false }
814
+ try out.write(contentsOf: data)
815
+ return true
816
+ }) {}
681
817
  }
682
818
  try? out.close()
683
819
  if FileManager.default.fileExists(atPath: filePath) {
@@ -694,8 +830,36 @@ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
694
830
  try? FileManager.default.removeItem(atPath: "\(state.filePath).partial")
695
831
  }
696
832
 
833
+ /// Cancels any in-flight background tasks for (channel|taskId) and then
834
+ /// discards all segment artifacts. Cancel-then-delete prevents a still-running
835
+ /// `nsurlsessiond` task from resurrecting a `.segN` we just deleted.
836
+ public func cancel(channel: DownloadChannel, taskId: String,
837
+ filePath: String) async {
838
+ let session = session(forChannel: channel, segmentCount: Self.defaultSegmentCount)
839
+ // Cancel matching tasks first and wait for getAllTasks to return so the
840
+ // cancels have been issued before we touch the files.
841
+ await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
842
+ session.getAllTasks { tasks in
843
+ for t in tasks {
844
+ if let d = t.taskDescription,
845
+ let decoded = Self.decodeTaskDescription(d),
846
+ decoded.channel.stringValue == channel.stringValue,
847
+ decoded.taskId == taskId {
848
+ t.cancel()
849
+ }
850
+ }
851
+ cont.resume()
852
+ }
853
+ }
854
+ // Drop the run state so a late delegate callback can't re-stash a segment.
855
+ clearRun(key: Self.runKey(channel: channel, taskId: taskId))
856
+ discardArtifacts(filePath: filePath)
857
+ }
858
+
697
859
  /// Discards all segment artifacts (used by the caller when falling back to
698
- /// single-stream so the bare slot is clean).
860
+ /// single-stream so the bare slot is clean). Prefer `cancel(...)` when tasks
861
+ /// may still be in flight; this file-only delete is for the post-fallback
862
+ /// case where tasks have already been abandoned.
699
863
  public func discardArtifacts(filePath: String) {
700
864
  for idx in 0..<Self.defaultSegmentCount {
701
865
  try? FileManager.default.removeItem(atPath: "\(filePath).seg\(idx)")
@@ -28,6 +28,7 @@ export interface ReactNativeRangeDownloader extends HybridObject<{
28
28
  }> {
29
29
  download(params: RangeDownloadParams): Promise<RangeDownloadResult>;
30
30
  discardArtifacts(channel: DownloadChannel, taskId: string, destFilePath: string): Promise<void>;
31
+ cancel(channel: DownloadChannel, taskId: string, destFilePath: string): Promise<void>;
31
32
  addDownloadListener(callback: (event: RangeDownloadEvent) => void): number;
32
33
  removeDownloadListener(id: number): void;
33
34
  getDownloadsDir(): string;
@@ -1 +1 @@
1
- {"version":3,"file":"ReactNativeRangeDownloader.nitro.d.ts","sourceRoot":"","sources":["../../../src/ReactNativeRangeDownloader.nitro.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAM/D,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,KAAK,GAAG,OAAO,CAAC;AAEzD,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,eAAe,CAAC;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,MAAM,oBAAoB,GAC5B,WAAW,GACX,UAAU,CAAC;AAEf,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,oBAAoB,CAAC;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,eAAe,CAAC;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,0BACf,SAAQ,YAAY,CAAC;IAAE,GAAG,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,QAAQ,CAAA;CAAE,CAAC;IAIzD,QAAQ,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC;IAIpE,gBAAgB,CACd,OAAO,EAAE,eAAe,EACxB,MAAM,EAAE,MAAM,EACd,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,IAAI,CAAC,CAAC;IAGjB,mBAAmB,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,IAAI,GAAG,MAAM,CAAC;IAC3E,sBAAsB,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAKzC,eAAe,IAAI,MAAM,CAAC;CAC3B"}
1
+ {"version":3,"file":"ReactNativeRangeDownloader.nitro.d.ts","sourceRoot":"","sources":["../../../src/ReactNativeRangeDownloader.nitro.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAM/D,MAAM,MAAM,eAAe,GAAG,QAAQ,GAAG,KAAK,GAAG,OAAO,CAAC;AAEzD,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,eAAe,CAAC;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,MAAM,oBAAoB,GAC5B,WAAW,GACX,UAAU,CAAC;AAEf,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,oBAAoB,CAAC;IAC9B,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,eAAe,CAAC;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,0BACf,SAAQ,YAAY,CAAC;IAAE,GAAG,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,QAAQ,CAAA;CAAE,CAAC;IAIzD,QAAQ,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC;IAMpE,gBAAgB,CACd,OAAO,EAAE,eAAe,EACxB,MAAM,EAAE,MAAM,EACd,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,IAAI,CAAC,CAAC;IAKjB,MAAM,CACJ,OAAO,EAAE,eAAe,EACxB,MAAM,EAAE,MAAM,EACd,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,IAAI,CAAC,CAAC;IAGjB,mBAAmB,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,IAAI,GAAG,MAAM,CAAC;IAC3E,sBAAsB,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAKzC,eAAe,IAAI,MAAM,CAAC;CAC3B"}
@@ -99,6 +99,21 @@ namespace margelo::nitro::reactnativerangedownloader {
99
99
  return __promise;
100
100
  }();
101
101
  }
102
+ std::shared_ptr<Promise<void>> JHybridReactNativeRangeDownloaderSpec::cancel(DownloadChannel channel, const std::string& taskId, const std::string& destFilePath) {
103
+ static const auto method = javaClassStatic()->getMethod<jni::local_ref<JPromise::javaobject>(jni::alias_ref<JDownloadChannel> /* channel */, jni::alias_ref<jni::JString> /* taskId */, jni::alias_ref<jni::JString> /* destFilePath */)>("cancel");
104
+ auto __result = method(_javaPart, JDownloadChannel::fromCpp(channel), jni::make_jstring(taskId), jni::make_jstring(destFilePath));
105
+ return [&]() {
106
+ auto __promise = Promise<void>::create();
107
+ __result->cthis()->addOnResolvedListener([=](const jni::alias_ref<jni::JObject>& /* unit */) {
108
+ __promise->resolve();
109
+ });
110
+ __result->cthis()->addOnRejectedListener([=](const jni::alias_ref<jni::JThrowable>& __throwable) {
111
+ jni::JniException __jniError(__throwable);
112
+ __promise->reject(std::make_exception_ptr(__jniError));
113
+ });
114
+ return __promise;
115
+ }();
116
+ }
102
117
  double JHybridReactNativeRangeDownloaderSpec::addDownloadListener(const std::function<void(const RangeDownloadEvent& /* event */)>& callback) {
103
118
  static const auto method = javaClassStatic()->getMethod<double(jni::alias_ref<JFunc_void_RangeDownloadEvent::javaobject> /* callback */)>("addDownloadListener_cxx");
104
119
  auto __result = method(_javaPart, JFunc_void_RangeDownloadEvent_cxx::fromCpp(callback));
@@ -56,6 +56,7 @@ namespace margelo::nitro::reactnativerangedownloader {
56
56
  // Methods
57
57
  std::shared_ptr<Promise<RangeDownloadResult>> download(const RangeDownloadParams& params) override;
58
58
  std::shared_ptr<Promise<void>> discardArtifacts(DownloadChannel channel, const std::string& taskId, const std::string& destFilePath) override;
59
+ std::shared_ptr<Promise<void>> cancel(DownloadChannel channel, const std::string& taskId, const std::string& destFilePath) override;
59
60
  double addDownloadListener(const std::function<void(const RangeDownloadEvent& /* event */)>& callback) override;
60
61
  void removeDownloadListener(double id) override;
61
62
  std::string getDownloadsDir() override;
@@ -54,6 +54,10 @@ abstract class HybridReactNativeRangeDownloaderSpec: HybridObject() {
54
54
  @Keep
55
55
  abstract fun discardArtifacts(channel: DownloadChannel, taskId: String, destFilePath: String): Promise<Unit>
56
56
 
57
+ @DoNotStrip
58
+ @Keep
59
+ abstract fun cancel(channel: DownloadChannel, taskId: String, destFilePath: String): Promise<Unit>
60
+
57
61
  abstract fun addDownloadListener(callback: (event: RangeDownloadEvent) -> Unit): Double
58
62
 
59
63
  @DoNotStrip
@@ -93,6 +93,14 @@ namespace margelo::nitro::reactnativerangedownloader {
93
93
  auto __value = std::move(__result.value());
94
94
  return __value;
95
95
  }
96
+ inline std::shared_ptr<Promise<void>> cancel(DownloadChannel channel, const std::string& taskId, const std::string& destFilePath) override {
97
+ auto __result = _swiftPart.cancel(static_cast<int>(channel), taskId, destFilePath);
98
+ if (__result.hasError()) [[unlikely]] {
99
+ std::rethrow_exception(__result.error());
100
+ }
101
+ auto __value = std::move(__result.value());
102
+ return __value;
103
+ }
96
104
  inline double addDownloadListener(const std::function<void(const RangeDownloadEvent& /* event */)>& callback) override {
97
105
  auto __result = _swiftPart.addDownloadListener(callback);
98
106
  if (__result.hasError()) [[unlikely]] {
@@ -16,6 +16,7 @@ public protocol HybridReactNativeRangeDownloaderSpec_protocol: HybridObject {
16
16
  // Methods
17
17
  func download(params: RangeDownloadParams) throws -> Promise<RangeDownloadResult>
18
18
  func discardArtifacts(channel: DownloadChannel, taskId: String, destFilePath: String) throws -> Promise<Void>
19
+ func cancel(channel: DownloadChannel, taskId: String, destFilePath: String) throws -> Promise<Void>
19
20
  func addDownloadListener(callback: @escaping (_ event: RangeDownloadEvent) -> Void) throws -> Double
20
21
  func removeDownloadListener(id: Double) throws -> Void
21
22
  func getDownloadsDir() throws -> String
@@ -155,6 +155,25 @@ open class HybridReactNativeRangeDownloaderSpec_cxx {
155
155
  }
156
156
  }
157
157
 
158
+ @inline(__always)
159
+ public final func cancel(channel: Int32, taskId: std.string, destFilePath: std.string) -> bridge.Result_std__shared_ptr_Promise_void___ {
160
+ do {
161
+ let __result = try self.__implementation.cancel(channel: margelo.nitro.reactnativerangedownloader.DownloadChannel(rawValue: channel)!, taskId: String(taskId), destFilePath: String(destFilePath))
162
+ let __resultCpp = { () -> bridge.std__shared_ptr_Promise_void__ in
163
+ let __promise = bridge.create_std__shared_ptr_Promise_void__()
164
+ let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_void__(__promise)
165
+ __result
166
+ .then({ __result in __promiseHolder.resolve() })
167
+ .catch({ __error in __promiseHolder.reject(__error.toCpp()) })
168
+ return __promise
169
+ }()
170
+ return bridge.create_Result_std__shared_ptr_Promise_void___(__resultCpp)
171
+ } catch (let __error) {
172
+ let __exceptionPtr = __error.toCpp()
173
+ return bridge.create_Result_std__shared_ptr_Promise_void___(__exceptionPtr)
174
+ }
175
+ }
176
+
158
177
  @inline(__always)
159
178
  public final func addDownloadListener(callback: bridge.Func_void_RangeDownloadEvent) -> bridge.Result_double_ {
160
179
  do {
@@ -16,6 +16,7 @@ namespace margelo::nitro::reactnativerangedownloader {
16
16
  registerHybrids(this, [](Prototype& prototype) {
17
17
  prototype.registerHybridMethod("download", &HybridReactNativeRangeDownloaderSpec::download);
18
18
  prototype.registerHybridMethod("discardArtifacts", &HybridReactNativeRangeDownloaderSpec::discardArtifacts);
19
+ prototype.registerHybridMethod("cancel", &HybridReactNativeRangeDownloaderSpec::cancel);
19
20
  prototype.registerHybridMethod("addDownloadListener", &HybridReactNativeRangeDownloaderSpec::addDownloadListener);
20
21
  prototype.registerHybridMethod("removeDownloadListener", &HybridReactNativeRangeDownloaderSpec::removeDownloadListener);
21
22
  prototype.registerHybridMethod("getDownloadsDir", &HybridReactNativeRangeDownloaderSpec::getDownloadsDir);
@@ -63,6 +63,7 @@ namespace margelo::nitro::reactnativerangedownloader {
63
63
  // Methods
64
64
  virtual std::shared_ptr<Promise<RangeDownloadResult>> download(const RangeDownloadParams& params) = 0;
65
65
  virtual std::shared_ptr<Promise<void>> discardArtifacts(DownloadChannel channel, const std::string& taskId, const std::string& destFilePath) = 0;
66
+ virtual std::shared_ptr<Promise<void>> cancel(DownloadChannel channel, const std::string& taskId, const std::string& destFilePath) = 0;
66
67
  virtual double addDownloadListener(const std::function<void(const RangeDownloadEvent& /* event */)>& callback) = 0;
67
68
  virtual void removeDownloadListener(double id) = 0;
68
69
  virtual std::string getDownloadsDir() = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-range-downloader",
3
- "version": "3.0.52",
3
+ "version": "3.0.54",
4
4
  "description": "react-native-range-downloader",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -43,12 +43,23 @@ export interface ReactNativeRangeDownloader
43
43
 
44
44
  // Clean the concurrent artifacts (.segN / .partial / .progress) for one task.
45
45
  // Caller invokes before falling back to its own single-stream path.
46
+ // Cancel-then-delete: any in-flight tasks/workers for (channel,taskId) are
47
+ // cancelled before the files are removed so they cannot be resurrected.
46
48
  discardArtifacts(
47
49
  channel: DownloadChannel,
48
50
  taskId: string,
49
51
  destFilePath: string
50
52
  ): Promise<void>;
51
53
 
54
+ // Cancel an in-flight download for (channel,taskId) and remove its artifacts.
55
+ // iOS cancels the matching background URLSession tasks; Android flips the
56
+ // worker pool's abort flag and shuts it down — both before deleting files.
57
+ cancel(
58
+ channel: DownloadChannel,
59
+ taskId: string,
60
+ destFilePath: string
61
+ ): Promise<void>;
62
+
52
63
  // Event listening: all consumers share one listener registry and filter by channel.
53
64
  addDownloadListener(callback: (event: RangeDownloadEvent) => void): number;
54
65
  removeDownloadListener(id: number): void;