@onekeyfe/react-native-range-downloader 3.0.53 → 3.0.55
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 +23 -5
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt +73 -19
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +99 -14
- package/ios/ReactNativeRangeDownloader.swift +173 -9
- package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts +1 -0
- package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.cpp +15 -0
- package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.hpp +1 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/HybridReactNativeRangeDownloaderSpec.kt +4 -0
- package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.hpp +8 -0
- package/nitrogen/generated/ios/swift/HybridReactNativeRangeDownloaderSpec.swift +1 -0
- package/nitrogen/generated/ios/swift/HybridReactNativeRangeDownloaderSpec_cxx.swift +19 -0
- package/nitrogen/generated/shared/c++/HybridReactNativeRangeDownloaderSpec.cpp +1 -0
- package/nitrogen/generated/shared/c++/HybridReactNativeRangeDownloaderSpec.hpp +1 -0
- package/package.json +1 -1
- package/src/ReactNativeRangeDownloader.nitro.ts +11 -0
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
|
-
```
|
|
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
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
lastFlushed[part.index]
|
|
104
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
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
|
|
680
|
-
|
|
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;
|
|
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
|
@@ -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;
|