@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.
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
85
|
-
*
|
|
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
|
|
109
|
+
val segFile: (Int) -> File = { index -> File("$partialFilePath.seg$index") }
|
|
95
110
|
|
|
96
|
-
// A bare `.partial` with
|
|
97
|
-
// the caller's single-stream path resume it instead of
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
+
wipeArtifacts(partialFile, segFile)
|
|
167
206
|
return Outcome.FALLBACK
|
|
168
207
|
}
|
|
169
208
|
val err = firstError.get()
|
|
170
209
|
if (err != null) {
|
|
171
|
-
|
|
172
|
-
|
|
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
|
+
// resumes — resume is unconditional now (no validator gate).
|
|
179
212
|
throw err
|
|
180
213
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
//
|
|
192
|
-
|
|
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
|
|
201
|
-
// caller's
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
//
|
|
328
|
-
//
|
|
329
|
-
// resuming from
|
|
330
|
-
|
|
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
|
-
|
|
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
|
|
342
|
-
if (
|
|
342
|
+
val have = segFile.length()
|
|
343
|
+
if (have >= part.length) return
|
|
344
|
+
val rangeStart = part.start + have
|
|
343
345
|
try {
|
|
344
|
-
fetchSegment(url,
|
|
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
|
-
|
|
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
|
|
366
|
+
val request = Request.Builder().url(url)
|
|
366
367
|
.addHeader("Range", "bytes=$rangeStart-${part.end}")
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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:
|
|
13
|
+
// P1: Nitro adapter for the Android concurrent multi-range downloader.
|
|
14
14
|
//
|
|
15
|
-
// The core algorithm
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// .
|
|
21
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|