@onekeyfe/react-native-range-downloader 3.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/ReactNativeRangeDownloader.podspec +30 -0
  4. package/android/CMakeLists.txt +24 -0
  5. package/android/build.gradle +132 -0
  6. package/android/gradle.properties +4 -0
  7. package/android/src/main/AndroidManifest.xml +1 -0
  8. package/android/src/main/cpp/cpp-adapter.cpp +6 -0
  9. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt +340 -0
  10. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +233 -0
  11. package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloaderPackage.kt +24 -0
  12. package/ios/ReactNativeRangeDownloader.swift +732 -0
  13. package/lib/module/ReactNativeRangeDownloader.nitro.js +4 -0
  14. package/lib/module/ReactNativeRangeDownloader.nitro.js.map +1 -0
  15. package/lib/module/index.js +15 -0
  16. package/lib/module/index.js.map +1 -0
  17. package/lib/module/package.json +1 -0
  18. package/lib/typescript/package.json +1 -0
  19. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts +35 -0
  20. package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts.map +1 -0
  21. package/lib/typescript/src/index.d.ts +9 -0
  22. package/lib/typescript/src/index.d.ts.map +1 -0
  23. package/nitro.json +17 -0
  24. package/nitrogen/generated/android/c++/JDownloadChannel.hpp +62 -0
  25. package/nitrogen/generated/android/c++/JFunc_void_RangeDownloadEvent.hpp +80 -0
  26. package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.cpp +117 -0
  27. package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.hpp +69 -0
  28. package/nitrogen/generated/android/c++/JRangeDownloadEvent.hpp +75 -0
  29. package/nitrogen/generated/android/c++/JRangeDownloadOutcome.hpp +59 -0
  30. package/nitrogen/generated/android/c++/JRangeDownloadParams.hpp +84 -0
  31. package/nitrogen/generated/android/c++/JRangeDownloadResult.hpp +68 -0
  32. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/DownloadChannel.kt +22 -0
  33. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/Func_void_RangeDownloadEvent.kt +80 -0
  34. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/HybridReactNativeRangeDownloaderSpec.kt +79 -0
  35. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadEvent.kt +50 -0
  36. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadOutcome.kt +21 -0
  37. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadParams.kt +56 -0
  38. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadResult.kt +44 -0
  39. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/reactnativerangedownloaderOnLoad.kt +35 -0
  40. package/nitrogen/generated/android/reactnativerangedownloader+autolinking.cmake +81 -0
  41. package/nitrogen/generated/android/reactnativerangedownloader+autolinking.gradle +27 -0
  42. package/nitrogen/generated/android/reactnativerangedownloaderOnLoad.cpp +46 -0
  43. package/nitrogen/generated/android/reactnativerangedownloaderOnLoad.hpp +25 -0
  44. package/nitrogen/generated/ios/ReactNativeRangeDownloader+autolinking.rb +60 -0
  45. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.cpp +65 -0
  46. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.hpp +246 -0
  47. package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Umbrella.hpp +62 -0
  48. package/nitrogen/generated/ios/ReactNativeRangeDownloaderAutolinking.mm +33 -0
  49. package/nitrogen/generated/ios/ReactNativeRangeDownloaderAutolinking.swift +25 -0
  50. package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.cpp +11 -0
  51. package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.hpp +123 -0
  52. package/nitrogen/generated/ios/swift/DownloadChannel.swift +44 -0
  53. package/nitrogen/generated/ios/swift/Func_void.swift +47 -0
  54. package/nitrogen/generated/ios/swift/Func_void_RangeDownloadEvent.swift +47 -0
  55. package/nitrogen/generated/ios/swift/Func_void_RangeDownloadResult.swift +47 -0
  56. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +47 -0
  57. package/nitrogen/generated/ios/swift/HybridReactNativeRangeDownloaderSpec.swift +60 -0
  58. package/nitrogen/generated/ios/swift/HybridReactNativeRangeDownloaderSpec_cxx.swift +197 -0
  59. package/nitrogen/generated/ios/swift/RangeDownloadEvent.swift +80 -0
  60. package/nitrogen/generated/ios/swift/RangeDownloadOutcome.swift +40 -0
  61. package/nitrogen/generated/ios/swift/RangeDownloadParams.swift +145 -0
  62. package/nitrogen/generated/ios/swift/RangeDownloadResult.swift +77 -0
  63. package/nitrogen/generated/shared/c++/DownloadChannel.hpp +80 -0
  64. package/nitrogen/generated/shared/c++/HybridReactNativeRangeDownloaderSpec.cpp +25 -0
  65. package/nitrogen/generated/shared/c++/HybridReactNativeRangeDownloaderSpec.hpp +79 -0
  66. package/nitrogen/generated/shared/c++/RangeDownloadEvent.hpp +93 -0
  67. package/nitrogen/generated/shared/c++/RangeDownloadOutcome.hpp +76 -0
  68. package/nitrogen/generated/shared/c++/RangeDownloadParams.hpp +102 -0
  69. package/nitrogen/generated/shared/c++/RangeDownloadResult.hpp +86 -0
  70. package/package.json +169 -0
  71. package/src/ReactNativeRangeDownloader.nitro.ts +60 -0
  72. package/src/index.tsx +20 -0
@@ -0,0 +1,732 @@
1
+ import Foundation
2
+ import CommonCrypto
3
+ import NitroModules
4
+ import ReactNativeNativeLogger
5
+
6
+ // MARK: - Nitro HybridObject entry point
7
+ //
8
+ // Thin Nitro shim over `RangeDownloader.shared`. The heavy lifting — concurrent
9
+ // background range download, segment stash/concatenate, resume — is migrated
10
+ // almost verbatim from react-native-bundle-update's ConcurrentBundleDownloader.
11
+ // The only structural changes vs. that origin are:
12
+ // - single global run state → a `runs` table keyed by "channel|taskId", so
13
+ // different channels (apk, chart, bundle) can download concurrently;
14
+ // - one hardcoded background session identifier → one session per channel;
15
+ // - `onProgress` closure → a listener registry broadcasting
16
+ // `RangeDownloadEvent`s (multi-consumer);
17
+ // - `throws FallbackError` → returns `RangeDownloadResult(.fallback, …)`.
18
+ class ReactNativeRangeDownloader: HybridReactNativeRangeDownloaderSpec {
19
+
20
+ func download(params: RangeDownloadParams) throws -> Promise<RangeDownloadResult> {
21
+ return Promise.async {
22
+ let (outcome, filePath, fallbackReason) = await RangeDownloader.shared.download(
23
+ channel: params.channel,
24
+ taskId: params.taskId,
25
+ urlString: params.url,
26
+ filePath: params.destFilePath,
27
+ expectedSha256: params.expectedSha256,
28
+ segmentCount: params.segmentCount.map { Int($0) },
29
+ minConcurrentBytes: params.minConcurrentBytes.map { Int64($0) }
30
+ )
31
+ return RangeDownloadResult(
32
+ outcome: outcome,
33
+ filePath: filePath,
34
+ fallbackReason: fallbackReason
35
+ )
36
+ }
37
+ }
38
+
39
+ func discardArtifacts(
40
+ channel: DownloadChannel,
41
+ taskId: String,
42
+ destFilePath: String
43
+ ) throws -> Promise<Void> {
44
+ return Promise.async {
45
+ RangeDownloader.shared.discardArtifacts(filePath: destFilePath)
46
+ }
47
+ }
48
+
49
+ func addDownloadListener(
50
+ callback: @escaping (_ event: RangeDownloadEvent) -> Void
51
+ ) throws -> Double {
52
+ return Double(RangeDownloader.shared.addListener(callback))
53
+ }
54
+
55
+ func removeDownloadListener(id: Double) throws {
56
+ RangeDownloader.shared.removeListener(Int(id))
57
+ }
58
+
59
+ // App Caches directory — an app-owned, writable absolute path resolved at
60
+ // runtime (no hardcoded sandbox path).
61
+ func getDownloadsDir() throws -> String {
62
+ if let caches = FileManager.default.urls(
63
+ for: .cachesDirectory, in: .userDomainMask
64
+ ).first {
65
+ return caches.path
66
+ }
67
+ return NSTemporaryDirectory()
68
+ }
69
+ }
70
+
71
+ // MARK: - RangeDownloader (migrated core)
72
+
73
+ /// Concurrent + background multi-range downloader for iOS.
74
+ ///
75
+ /// Each channel owns its own background `URLSession` (identifier
76
+ /// "so.onekey.rangedownloader.bg.<channel>"). A background session carries
77
+ /// [segmentCount] download tasks, each requesting a byte Range of the file. A
78
+ /// background session satisfies BOTH requirements at once:
79
+ /// - concurrency: the range tasks run in parallel (foreground = full speed);
80
+ /// - background: `nsurlsessiond` keeps transferring while the app is
81
+ /// suspended and even relaunches the app to deliver completion events.
82
+ /// It needs NO new entitlement.
83
+ ///
84
+ /// Because background download tasks deliver a whole file per task at
85
+ /// completion (not an incremental stream), each segment is downloaded to its
86
+ /// own `<file>.segN` and the segments are concatenated in order once all are
87
+ /// present. A segment file is therefore atomic — fully present or absent — so
88
+ /// resume across app suspension/kill is just "which `.segN` already exist".
89
+ ///
90
+ /// Unlike the single-run origin, this downloader keeps a `runs` table keyed by
91
+ /// "channel|taskId" so multiple channels can download at once. Each running
92
+ /// task's `taskDescription` encodes "channel|taskId|segIndex" so a delegate
93
+ /// callback can locate both the run and the segment.
94
+ public final class RangeDownloader: NSObject, URLSessionDownloadDelegate {
95
+
96
+ public static let shared = RangeDownloader()
97
+
98
+ /// Posted by the AppDelegate from
99
+ /// application(_:handleEventsForBackgroundURLSession:completionHandler:).
100
+ /// userInfo: ["identifier": String, "completionHandler": () -> Void].
101
+ /// (The AppDelegate posts this generic name for every background URLSession.)
102
+ static let backgroundEventsNotification =
103
+ Notification.Name("RangeDownloaderBackgroundEvents")
104
+
105
+ /// Our per-channel session identifier prefix. Identifiers not carrying this
106
+ /// prefix (and not the legacy one) are ignored so we never steal another
107
+ /// module's background session events.
108
+ private static let sessionIdentifierPrefix = "so.onekey.rangedownloader.bg."
109
+ /// Legacy bundle-update identifier prefix, recognized during the transition
110
+ /// so events queued by an older build still route here.
111
+ private static let legacySessionIdentifierPrefix = "so.onekey.bundleupdate.concurrent.bg"
112
+
113
+ private static let defaultSegmentCount = 8
114
+ private static let defaultMinConcurrentBytes: Int64 = 2 * 1024 * 1024
115
+
116
+ private let lock = NSLock()
117
+
118
+ /// Active-run state keyed by "channel|taskId". Replaces the origin's single
119
+ /// global run state to support concurrent channels.
120
+ private var runs: [String: RunState] = [:]
121
+
122
+ /// Lazy per-channel background sessions, cached by identifier.
123
+ private var sessions: [String: URLSession] = [:]
124
+
125
+ /// Listener registry. The Nitro layer registers JS callbacks here; events are
126
+ /// broadcast to all of them (replaces the origin's single `onProgress`).
127
+ private var listeners: [Int: (RangeDownloadEvent) -> Void] = [:]
128
+ private var nextListenerId = 1
129
+
130
+ /// Stored by the AppDelegate's handleEventsForBackgroundURLSession (via the
131
+ /// notification), keyed by session identifier, so we can call each back once
132
+ /// all its queued background events have been delivered.
133
+ private var backgroundCompletionHandlers: [String: () -> Void] = [:]
134
+
135
+ // Per-run mutable state.
136
+ private final class RunState {
137
+ let channel: DownloadChannel
138
+ let taskId: String
139
+ let filePath: String
140
+ let segmentCount: Int
141
+ var totalSize: Int64 = 0
142
+ var etag: String?
143
+ var ranges: [(start: Int64, end: Int64)] = []
144
+ var segmentWritten: [Int64] = [] // progress estimate per segment
145
+ var continuation: CheckedContinuation<Void, Error>?
146
+ var prevProgress = -1
147
+ var fellBack = false
148
+ let sessionIdentifier: String
149
+
150
+ init(channel: DownloadChannel, taskId: String, filePath: String,
151
+ segmentCount: Int, sessionIdentifier: String) {
152
+ self.channel = channel
153
+ self.taskId = taskId
154
+ self.filePath = filePath
155
+ self.segmentCount = segmentCount
156
+ self.sessionIdentifier = sessionIdentifier
157
+ }
158
+
159
+ func segPath(_ index: Int) -> String { "\(filePath).seg\(index)" }
160
+ }
161
+
162
+ override init() {
163
+ super.init()
164
+ NotificationCenter.default.addObserver(
165
+ self,
166
+ selector: #selector(handleBackgroundEventsNotification(_:)),
167
+ name: Self.backgroundEventsNotification,
168
+ object: nil
169
+ )
170
+ }
171
+
172
+ // MARK: - Identifier <-> channel
173
+
174
+ private static func sessionIdentifier(for channel: DownloadChannel) -> String {
175
+ return "\(sessionIdentifierPrefix)\(channel.stringValue)"
176
+ }
177
+
178
+ /// Reverse-resolves a channel from a session identifier; nil if the
179
+ /// identifier doesn't belong to this module (so we don't preempt it).
180
+ private static func channel(forIdentifier identifier: String) -> DownloadChannel? {
181
+ if identifier.hasPrefix(sessionIdentifierPrefix) {
182
+ let raw = String(identifier.dropFirst(sessionIdentifierPrefix.count))
183
+ return DownloadChannel(fromString: raw)
184
+ }
185
+ // Legacy bundle-update identifier maps to the bundle channel during the
186
+ // transition.
187
+ if identifier == legacySessionIdentifierPrefix
188
+ || identifier.hasPrefix(legacySessionIdentifierPrefix) {
189
+ return .bundle
190
+ }
191
+ return nil
192
+ }
193
+
194
+ @objc private func handleBackgroundEventsNotification(_ note: Notification) {
195
+ guard let identifier = note.userInfo?["identifier"] as? String,
196
+ Self.channel(forIdentifier: identifier) != nil else { return }
197
+ // Re-create the session with this delegate so queued completion events are
198
+ // delivered here on a background relaunch.
199
+ _ = session(forIdentifier: identifier)
200
+ if let handler = note.userInfo?["completionHandler"] as? () -> Void {
201
+ lock.lock()
202
+ backgroundCompletionHandlers[identifier] = handler
203
+ lock.unlock()
204
+ }
205
+ }
206
+
207
+ // MARK: - Session cache
208
+
209
+ private func session(forChannel channel: DownloadChannel, segmentCount: Int) -> URLSession {
210
+ return session(forIdentifier: Self.sessionIdentifier(for: channel),
211
+ segmentCount: segmentCount)
212
+ }
213
+
214
+ private func session(forIdentifier identifier: String,
215
+ segmentCount: Int = RangeDownloader.defaultSegmentCount) -> URLSession {
216
+ lock.lock()
217
+ if let existing = sessions[identifier] {
218
+ lock.unlock()
219
+ return existing
220
+ }
221
+ lock.unlock()
222
+ let cfg = URLSessionConfiguration.background(withIdentifier: identifier)
223
+ cfg.tlsMinimumSupportedProtocolVersion = .TLSv12
224
+ cfg.isDiscretionary = false
225
+ cfg.sessionSendsLaunchEvents = true
226
+ cfg.httpMaximumConnectionsPerHost = segmentCount
227
+ let created = URLSession(configuration: cfg, delegate: self, delegateQueue: nil)
228
+ lock.lock()
229
+ // Another thread may have raced us; prefer the first-stored session.
230
+ if let existing = sessions[identifier] {
231
+ lock.unlock()
232
+ created.invalidateAndCancel()
233
+ return existing
234
+ }
235
+ sessions[identifier] = created
236
+ lock.unlock()
237
+ return created
238
+ }
239
+
240
+ // MARK: - Listeners
241
+
242
+ public func addListener(_ callback: @escaping (RangeDownloadEvent) -> Void) -> Int {
243
+ lock.lock(); defer { lock.unlock() }
244
+ let id = nextListenerId
245
+ nextListenerId += 1
246
+ listeners[id] = callback
247
+ return id
248
+ }
249
+
250
+ public func removeListener(_ id: Int) {
251
+ lock.lock(); defer { lock.unlock() }
252
+ listeners.removeValue(forKey: id)
253
+ }
254
+
255
+ private func emit(channel: DownloadChannel, taskId: String, type: String,
256
+ progress: Double, message: String) {
257
+ let snapshot: [(RangeDownloadEvent) -> Void] = lock.withLockValue {
258
+ Array(self.listeners.values)
259
+ }
260
+ guard !snapshot.isEmpty else { return }
261
+ let event = RangeDownloadEvent(
262
+ channel: channel, taskId: taskId, type: type, progress: progress, message: message
263
+ )
264
+ for cb in snapshot { cb(event) }
265
+ }
266
+
267
+ // MARK: - Run table helpers
268
+
269
+ private static func runKey(channel: DownloadChannel, taskId: String) -> String {
270
+ return "\(channel.stringValue)|\(taskId)"
271
+ }
272
+
273
+ /// taskDescription codec: "channel|taskId|segIndex".
274
+ private static func encodeTaskDescription(channel: DownloadChannel, taskId: String,
275
+ segIndex: Int) -> String {
276
+ return "\(channel.stringValue)|\(taskId)|\(segIndex)"
277
+ }
278
+
279
+ private static func decodeTaskDescription(_ desc: String)
280
+ -> (channel: DownloadChannel, taskId: String, segIndex: Int)? {
281
+ // taskId may itself contain '|'? Keep it conservative: split into exactly 3
282
+ // by taking the first and last fields, joining the middle as taskId.
283
+ let parts = desc.components(separatedBy: "|")
284
+ guard parts.count >= 3,
285
+ let channel = DownloadChannel(fromString: parts[0]),
286
+ let segIndex = Int(parts[parts.count - 1]) else { return nil }
287
+ let taskId = parts[1..<(parts.count - 1)].joined(separator: "|")
288
+ return (channel, taskId, segIndex)
289
+ }
290
+
291
+ private func run(forKey key: String) -> RunState? {
292
+ lock.withLockValue { self.runs[key] }
293
+ }
294
+
295
+ private func run(for desc: String) -> (run: RunState, segIndex: Int)? {
296
+ guard let decoded = Self.decodeTaskDescription(desc) else { return nil }
297
+ let key = Self.runKey(channel: decoded.channel, taskId: decoded.taskId)
298
+ guard let r = run(forKey: key) else { return nil }
299
+ return (r, decoded.segIndex)
300
+ }
301
+
302
+ // MARK: - Public entry
303
+
304
+ /// Downloads [urlString] into [filePath] using concurrent background ranges.
305
+ /// Returns `(.completed, filePath, nil)` on success, or `(.fallback, filePath,
306
+ /// reason)` when the caller should use its single-stream path. Transient
307
+ /// network errors are also reported as `.fallback` with the error reason (the
308
+ /// `.segN` files are kept for the next attempt).
309
+ public func download(
310
+ channel: DownloadChannel,
311
+ taskId: String,
312
+ urlString: String,
313
+ filePath: String,
314
+ expectedSha256: String?,
315
+ segmentCount: Int?,
316
+ minConcurrentBytes: Int64?
317
+ ) async -> (RangeDownloadOutcome, String, String?) {
318
+ let segCount = max(1, segmentCount ?? Self.defaultSegmentCount)
319
+ let minBytes = minConcurrentBytes ?? Self.defaultMinConcurrentBytes
320
+ let key = Self.runKey(channel: channel, taskId: taskId)
321
+
322
+ guard let url = URL(string: urlString) else {
323
+ return (.fallback, filePath, "invalid url")
324
+ }
325
+ // HTTPS-only: background URLSession + transport hardening.
326
+ guard urlString.hasPrefix("https://") else {
327
+ return (.fallback, filePath, "url must use https")
328
+ }
329
+
330
+ let probe: ProbeResult
331
+ do {
332
+ probe = try await self.probe(url: url)
333
+ } catch {
334
+ return (.fallback, filePath, "probe failed: \(error.localizedDescription)")
335
+ }
336
+ guard probe.supportsRange, probe.total >= minBytes else {
337
+ return (.fallback, filePath, "range unsupported or file too small")
338
+ }
339
+
340
+ let state = RunState(
341
+ channel: channel, taskId: taskId, filePath: filePath,
342
+ segmentCount: segCount,
343
+ sessionIdentifier: Self.sessionIdentifier(for: channel)
344
+ )
345
+ state.totalSize = probe.total
346
+ state.etag = probe.etag
347
+ state.ranges = Self.planRanges(total: probe.total, segments: segCount)
348
+ state.segmentWritten = [Int64](repeating: 0, count: state.ranges.count)
349
+
350
+ lock.lock()
351
+ runs[key] = state
352
+ lock.unlock()
353
+
354
+ let ranges = state.ranges
355
+
356
+ emit(channel: channel, taskId: taskId, type: "start", progress: 0, message: "")
357
+
358
+ do {
359
+ // If every segment is already on disk (resume after suspension/kill), skip
360
+ // straight to concatenation.
361
+ if allSegmentsPresent(state: state, ranges: ranges) {
362
+ try concatenateAndFinish(state: state, ranges: ranges)
363
+ } else {
364
+ try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
365
+ self.lock.lock()
366
+ state.continuation = cont
367
+ self.lock.unlock()
368
+ reconcileAndStartTasks(state: state, url: url, ranges: ranges)
369
+ }
370
+ }
371
+ } catch let fb as FallbackError {
372
+ clearRun(key: key)
373
+ emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: fb.reason)
374
+ return (.fallback, filePath, fb.reason)
375
+ } catch {
376
+ // Transient error → ask caller to fall back (segments retained for retry).
377
+ clearRun(key: key)
378
+ let reason = error.localizedDescription
379
+ emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: reason)
380
+ return (.fallback, filePath, reason)
381
+ }
382
+
383
+ clearRun(key: key)
384
+
385
+ // Optional immediate SHA256 self-check backstop. When omitted, the caller
386
+ // verifies after the fact.
387
+ if let expected = expectedSha256, !expected.isEmpty {
388
+ let actual = Self.calculateSHA256(filePath)
389
+ if actual?.lowercased() != expected.lowercased() {
390
+ try? FileManager.default.removeItem(atPath: filePath)
391
+ let reason = "sha256 mismatch (expected \(expected), got \(actual ?? "nil"))"
392
+ OneKeyLog.error("RangeDownloader", "\(channel.stringValue)/\(taskId): \(reason)")
393
+ emit(channel: channel, taskId: taskId, type: "fallback", progress: 0, message: reason)
394
+ return (.fallback, filePath, reason)
395
+ }
396
+ }
397
+
398
+ emit(channel: channel, taskId: taskId, type: "complete", progress: 100, message: "")
399
+ return (.completed, filePath, nil)
400
+ }
401
+
402
+ private func clearRun(key: String) {
403
+ lock.lock(); runs.removeValue(forKey: key); lock.unlock()
404
+ }
405
+
406
+ // MARK: - Range planning / probing
407
+
408
+ static func planRanges(total: Int64, segments: Int) -> [(start: Int64, end: Int64)] {
409
+ var out: [(Int64, Int64)] = []
410
+ let chunk = (total + Int64(segments) - 1) / Int64(segments)
411
+ var i = 0
412
+ while i < segments {
413
+ let start = Int64(i) * chunk
414
+ if start >= total { break }
415
+ let end = min(start + chunk - 1, total - 1)
416
+ out.append((start, end))
417
+ i += 1
418
+ }
419
+ return out
420
+ }
421
+
422
+ private struct ProbeResult { let total: Int64; let etag: String?; let supportsRange: Bool }
423
+
424
+ struct FallbackError: Error { let reason: String }
425
+
426
+ /// One-byte Range request on a default (foreground) session to learn total
427
+ /// size + ETag + Range support. Background sessions can't do data tasks, so
428
+ /// the probe uses an ephemeral session.
429
+ private func probe(url: URL) async throws -> ProbeResult {
430
+ var req = URLRequest(url: url)
431
+ req.setValue("bytes=0-0", forHTTPHeaderField: "Range")
432
+ let cfg = URLSessionConfiguration.ephemeral
433
+ cfg.tlsMinimumSupportedProtocolVersion = .TLSv12
434
+ let probeSession = URLSession(configuration: cfg)
435
+ defer { probeSession.finishTasksAndInvalidate() }
436
+
437
+ return try await withCheckedThrowingContinuation { cont in
438
+ let task = probeSession.dataTask(with: req) { _, response, error in
439
+ if let error = error { cont.resume(throwing: error); return }
440
+ guard let http = response as? HTTPURLResponse else {
441
+ cont.resume(throwing: FallbackError(reason: "no http response")); return
442
+ }
443
+ let etag = http.value(forHTTPHeaderField: "ETag")
444
+ if http.statusCode == 206,
445
+ let cr = http.value(forHTTPHeaderField: "Content-Range"),
446
+ let total = Self.parseContentRangeTotal(cr) {
447
+ cont.resume(returning: ProbeResult(total: total, etag: etag, supportsRange: true))
448
+ } else {
449
+ // 200 (Range ignored) or anything else → single-stream.
450
+ cont.resume(returning: ProbeResult(total: 0, etag: etag, supportsRange: false))
451
+ }
452
+ }
453
+ task.resume()
454
+ }
455
+ }
456
+
457
+ static func parseContentRangeTotal(_ header: String) -> Int64? {
458
+ // "bytes 0-0/65226095"
459
+ guard let slash = header.lastIndex(of: "/") else { return nil }
460
+ let tail = header[header.index(after: slash)...]
461
+ return Int64(tail.trimmingCharacters(in: .whitespaces))
462
+ }
463
+
464
+ // MARK: - Task reconciliation (handles app relaunch)
465
+
466
+ /// Ensures exactly one in-flight (or completed) artifact per missing segment:
467
+ /// segments already on disk as `.segN` are skipped; segments that already
468
+ /// have a running task in the (recreated) background session for THIS run are
469
+ /// left alone; the rest get a fresh Range download task.
470
+ private func reconcileAndStartTasks(state: RunState, url: URL,
471
+ ranges: [(start: Int64, end: Int64)]) {
472
+ let session = session(forChannel: state.channel, segmentCount: state.segmentCount)
473
+ session.getAllTasks { [weak self] tasks in
474
+ guard let self = self else { return }
475
+ var liveIndexes = Set<Int>()
476
+ for t in tasks {
477
+ guard let desc = t.taskDescription,
478
+ let decoded = Self.decodeTaskDescription(desc) else {
479
+ // Unrecognized task description on our channel session — leave it.
480
+ continue
481
+ }
482
+ // Only adopt tasks belonging to THIS run (same channel|taskId). Stale
483
+ // tasks from a DIFFERENT taskId on the same channel session that point
484
+ // at a different URL are cancelled so their segment indexes can't
485
+ // collide with this run's.
486
+ let belongsToThisRun =
487
+ decoded.channel.stringValue == state.channel.stringValue
488
+ && decoded.taskId == state.taskId
489
+ if belongsToThisRun {
490
+ if let turl = t.originalRequest?.url,
491
+ turl.absoluteString == url.absoluteString,
492
+ t.state == .running || t.state == .suspended {
493
+ liveIndexes.insert(decoded.segIndex)
494
+ } else {
495
+ // Same run id but different URL (etag/url changed) — drop it.
496
+ t.cancel()
497
+ }
498
+ }
499
+ // Tasks for other taskIds on the same channel are left running so
500
+ // concurrent downloads in the same channel are not disturbed.
501
+ }
502
+ for (idx, range) in ranges.enumerated() {
503
+ if FileManager.default.fileExists(atPath: state.segPath(idx)) { continue }
504
+ if liveIndexes.contains(idx) { continue }
505
+ var req = URLRequest(url: url)
506
+ req.setValue("bytes=\(range.start)-\(range.end)", forHTTPHeaderField: "Range")
507
+ if let etag = state.etag { req.setValue(etag, forHTTPHeaderField: "If-Range") }
508
+ let task = session.downloadTask(with: req)
509
+ task.taskDescription = Self.encodeTaskDescription(
510
+ channel: state.channel, taskId: state.taskId, segIndex: idx
511
+ )
512
+ task.resume()
513
+ }
514
+ // It's possible every segment was already present but the early
515
+ // allSegmentsPresent check raced a just-finished task; re-check.
516
+ if self.allSegmentsPresent(state: state, ranges: ranges) {
517
+ self.finishContinuation(state: state, with: nil, ranges: ranges)
518
+ }
519
+ }
520
+ }
521
+
522
+ private func allSegmentsPresent(state: RunState,
523
+ ranges: [(start: Int64, end: Int64)]) -> Bool {
524
+ for (idx, range) in ranges.enumerated() {
525
+ let expected = range.end - range.start + 1
526
+ let p = state.segPath(idx)
527
+ guard FileManager.default.fileExists(atPath: p),
528
+ let attrs = try? FileManager.default.attributesOfItem(atPath: p),
529
+ let size = attrs[.size] as? Int64, size == expected else {
530
+ return false
531
+ }
532
+ }
533
+ return true
534
+ }
535
+
536
+ // MARK: - URLSessionDownloadDelegate
537
+
538
+ public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
539
+ didWriteData bytesWritten: Int64, totalBytesWritten: Int64,
540
+ totalBytesExpectedToWrite: Int64) {
541
+ guard let desc = downloadTask.taskDescription,
542
+ let (state, idx) = run(for: desc) else { return }
543
+ lock.lock()
544
+ if idx < state.segmentWritten.count { state.segmentWritten[idx] = totalBytesWritten }
545
+ let sum = state.segmentWritten.reduce(0, +)
546
+ let total = state.totalSize
547
+ var emit = false
548
+ if total > 0 {
549
+ let p = Int((sum * 100) / total)
550
+ if p != state.prevProgress { state.prevProgress = p; emit = true }
551
+ }
552
+ let progressValue = total > 0 ? Int((sum * 100) / total) : 0
553
+ let channel = state.channel
554
+ let taskId = state.taskId
555
+ lock.unlock()
556
+ if emit {
557
+ self.emit(channel: channel, taskId: taskId, type: "progress",
558
+ progress: Double(progressValue), message: "")
559
+ }
560
+ }
561
+
562
+ public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
563
+ didFinishDownloadingTo location: URL) {
564
+ guard let desc = downloadTask.taskDescription,
565
+ 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 {
569
+ lock.lock(); state.fellBack = true; lock.unlock()
570
+ return
571
+ }
572
+ // Move the segment into place atomically (temp file is deleted after return).
573
+ let dest = state.segPath(idx)
574
+ do {
575
+ let dir = (dest as NSString).deletingLastPathComponent
576
+ if !FileManager.default.fileExists(atPath: dir) {
577
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
578
+ }
579
+ if FileManager.default.fileExists(atPath: dest) {
580
+ try FileManager.default.removeItem(atPath: dest)
581
+ }
582
+ try FileManager.default.moveItem(at: location, to: URL(fileURLWithPath: dest))
583
+ } catch {
584
+ OneKeyLog.error("RangeDownloader",
585
+ "\(state.channel.stringValue)/\(state.taskId): failed to stash segment \(idx): \(error)")
586
+ }
587
+ }
588
+
589
+ public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
590
+ guard let desc = task.taskDescription,
591
+ let (state, _) = run(for: desc) else { return }
592
+ let ranges = lock.withLockValue { state.ranges }
593
+ if let error = error {
594
+ // Background ignores user-cancels (e.g. our own fallback cancel below) —
595
+ // surface only genuine give-ups.
596
+ let nsErr = error as NSError
597
+ if nsErr.domain == NSURLErrorDomain && nsErr.code == NSURLErrorCancelled {
598
+ return
599
+ }
600
+ finishContinuation(state: state, with: error, ranges: ranges)
601
+ return
602
+ }
603
+ if lock.withLockValue({ state.fellBack }) {
604
+ // Abandon the other in-flight segment tasks for THIS run so they don't
605
+ // keep downloading after we've decided to fall back to single-stream.
606
+ let channel = state.channel
607
+ let taskId = state.taskId
608
+ session.getAllTasks { tasks in
609
+ for t in tasks {
610
+ if let d = t.taskDescription,
611
+ let decoded = Self.decodeTaskDescription(d),
612
+ decoded.channel.stringValue == channel.stringValue,
613
+ decoded.taskId == taskId {
614
+ t.cancel()
615
+ }
616
+ }
617
+ }
618
+ cleanupSegments(state: state, ranges: ranges)
619
+ finishContinuation(state: state,
620
+ with: FallbackError(reason: "server returned 200 to a Range request"),
621
+ ranges: ranges)
622
+ return
623
+ }
624
+ if allSegmentsPresent(state: state, ranges: ranges) {
625
+ finishContinuation(state: state, with: nil, ranges: ranges)
626
+ }
627
+ // else: other segments still in flight; wait for their completions.
628
+ }
629
+
630
+ /// Called on the session delegate queue when all background events for this
631
+ /// session have been delivered after a background relaunch.
632
+ public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
633
+ let identifier = session.configuration.identifier
634
+ DispatchQueue.main.async { [weak self] in
635
+ guard let self = self else { return }
636
+ var handler: (() -> Void)?
637
+ if let id = identifier {
638
+ self.lock.lock()
639
+ handler = self.backgroundCompletionHandlers.removeValue(forKey: id)
640
+ self.lock.unlock()
641
+ }
642
+ handler?()
643
+ }
644
+ }
645
+
646
+ // MARK: - Finalize
647
+
648
+ private func finishContinuation(state: RunState, with error: Error?,
649
+ ranges: [(start: Int64, end: Int64)]) {
650
+ let cont: CheckedContinuation<Void, Error>? = lock.withLockValue {
651
+ let c = state.continuation
652
+ state.continuation = nil
653
+ return c
654
+ }
655
+ guard let cont = cont else { return }
656
+ if let error = error { cont.resume(throwing: error); return }
657
+ do {
658
+ try concatenateAndFinish(state: state, ranges: ranges)
659
+ cont.resume(returning: ())
660
+ } catch {
661
+ cont.resume(throwing: error)
662
+ }
663
+ }
664
+
665
+ private func concatenateAndFinish(state: RunState,
666
+ ranges: [(start: Int64, end: Int64)]) throws {
667
+ let filePath = state.filePath
668
+ let partial = "\(filePath).partial"
669
+ if FileManager.default.fileExists(atPath: partial) {
670
+ try FileManager.default.removeItem(atPath: partial)
671
+ }
672
+ FileManager.default.createFile(atPath: partial, contents: nil)
673
+ guard let out = FileHandle(forWritingAtPath: partial) else {
674
+ throw NSError(domain: "RangeDownloader", code: -1,
675
+ userInfo: [NSLocalizedDescriptionKey: "cannot open partial for write"])
676
+ }
677
+ defer { try? out.close() }
678
+ for idx in 0..<ranges.count {
679
+ let segData = try Data(contentsOf: URL(fileURLWithPath: state.segPath(idx)))
680
+ try out.write(contentsOf: segData)
681
+ }
682
+ try? out.close()
683
+ if FileManager.default.fileExists(atPath: filePath) {
684
+ try FileManager.default.removeItem(atPath: filePath)
685
+ }
686
+ try FileManager.default.moveItem(atPath: partial, toPath: filePath)
687
+ cleanupSegments(state: state, ranges: ranges)
688
+ }
689
+
690
+ private func cleanupSegments(state: RunState, ranges: [(start: Int64, end: Int64)]) {
691
+ for idx in 0..<ranges.count {
692
+ try? FileManager.default.removeItem(atPath: state.segPath(idx))
693
+ }
694
+ try? FileManager.default.removeItem(atPath: "\(state.filePath).partial")
695
+ }
696
+
697
+ /// Discards all segment artifacts (used by the caller when falling back to
698
+ /// single-stream so the bare slot is clean).
699
+ public func discardArtifacts(filePath: String) {
700
+ for idx in 0..<Self.defaultSegmentCount {
701
+ try? FileManager.default.removeItem(atPath: "\(filePath).seg\(idx)")
702
+ }
703
+ try? FileManager.default.removeItem(atPath: "\(filePath).partial")
704
+ }
705
+
706
+ // MARK: - SHA256 (streaming backstop)
707
+
708
+ static func calculateSHA256(_ filePath: String) -> String? {
709
+ let fm = FileManager.default
710
+ guard fm.fileExists(atPath: filePath),
711
+ let fileHandle = FileHandle(forReadingAtPath: filePath) else { return nil }
712
+ defer { try? fileHandle.close() }
713
+ var context = CC_SHA256_CTX()
714
+ CC_SHA256_Init(&context)
715
+ while autoreleasepool(invoking: { () -> Bool in
716
+ let data = fileHandle.readData(ofLength: 8192)
717
+ if data.isEmpty { return false }
718
+ data.withUnsafeBytes { CC_SHA256_Update(&context, $0.baseAddress, CC_LONG(data.count)) }
719
+ return true
720
+ }) {}
721
+ var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
722
+ CC_SHA256_Final(&hash, &context)
723
+ return hash.map { String(format: "%02x", $0) }.joined()
724
+ }
725
+ }
726
+
727
+ private extension NSLock {
728
+ func withLockValue<T>(_ body: () -> T) -> T {
729
+ lock(); defer { unlock() }
730
+ return body()
731
+ }
732
+ }