@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 OneKey
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # react-native-range-downloader
2
+
3
+ react-native-range-downloader
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ npm install react-native-range-downloader react-native-nitro-modules
9
+
10
+ > `react-native-nitro-modules` is required as this library relies on [Nitro Modules](https://nitro.margelo.com/).
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```js
16
+ import { ReactNativeRangeDownloader } from 'react-native-range-downloader';
17
+
18
+ // ...
19
+
20
+ const result = await ReactNativeRangeDownloader.hello({ message: 'World' });
21
+ console.log(result); // { success: true, data: 'Hello, World!' }
22
+ ```
23
+
24
+ ## Contributing
25
+
26
+ - [Development workflow](CONTRIBUTING.md#development-workflow)
27
+ - [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request)
28
+ - [Code of conduct](CODE_OF_CONDUCT.md)
29
+
30
+ ## License
31
+
32
+ MIT
33
+
34
+ ---
35
+
36
+ Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob)
@@ -0,0 +1,30 @@
1
+ require "json"
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = "ReactNativeRangeDownloader"
7
+ s.version = package["version"]
8
+ s.summary = package["description"]
9
+ s.homepage = package["homepage"]
10
+ s.license = package["license"]
11
+ s.authors = package["author"]
12
+
13
+ s.platforms = { :ios => min_ios_version_supported }
14
+ s.source = { :git => "https://github.com/OneKeyHQ/app-modules/react-native-range-downloader.git", :tag => "#{s.version}" }
15
+
16
+ s.source_files = [
17
+ "ios/**/*.{swift}",
18
+ "ios/**/*.{m,mm}",
19
+ "cpp/**/*.{hpp,cpp}",
20
+ ]
21
+
22
+ s.dependency 'React-jsi'
23
+ s.dependency 'React-callinvoker'
24
+ s.dependency 'ReactNativeNativeLogger'
25
+
26
+ load 'nitrogen/generated/ios/ReactNativeRangeDownloader+autolinking.rb'
27
+ add_nitrogen_files(s)
28
+
29
+ install_modules_dependencies(s)
30
+ end
@@ -0,0 +1,24 @@
1
+ project(reactnativerangedownloader)
2
+ cmake_minimum_required(VERSION 3.9.0)
3
+
4
+ set(PACKAGE_NAME reactnativerangedownloader)
5
+ set(CMAKE_VERBOSE_MAKEFILE ON)
6
+ set(CMAKE_CXX_STANDARD 20)
7
+
8
+ # Define C++ library and add all sources
9
+ add_library(${PACKAGE_NAME} SHARED src/main/cpp/cpp-adapter.cpp)
10
+
11
+ # Add Nitrogen specs :)
12
+ include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/reactnativerangedownloader+autolinking.cmake)
13
+
14
+ # Set up local includes
15
+ include_directories("src/main/cpp" "../cpp")
16
+
17
+ find_library(LOG_LIB log)
18
+
19
+ # Link all libraries together
20
+ target_link_libraries(
21
+ ${PACKAGE_NAME}
22
+ ${LOG_LIB}
23
+ android # <-- Android core
24
+ )
@@ -0,0 +1,132 @@
1
+ buildscript {
2
+ ext.getExtOrDefault = {name ->
3
+ return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['ReactNativeRangeDownloader_' + name]
4
+ }
5
+
6
+ repositories {
7
+ google()
8
+ mavenCentral()
9
+ }
10
+
11
+ dependencies {
12
+ classpath "com.android.tools.build:gradle:8.7.2"
13
+ // noinspection DifferentKotlinGradleVersion
14
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}"
15
+ }
16
+ }
17
+
18
+ def reactNativeArchitectures() {
19
+ def value = rootProject.getProperties().get("reactNativeArchitectures")
20
+ return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
21
+ }
22
+
23
+ apply plugin: "com.android.library"
24
+ apply plugin: "kotlin-android"
25
+ apply from: '../nitrogen/generated/android/reactnativerangedownloader+autolinking.gradle'
26
+
27
+ apply plugin: "com.facebook.react"
28
+
29
+ def getExtOrIntegerDefault(name) {
30
+ return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["ReactNativeRangeDownloader_" + name]).toInteger()
31
+ }
32
+
33
+ android {
34
+ namespace "com.margelo.nitro.reactnativerangedownloader"
35
+
36
+ compileSdkVersion getExtOrIntegerDefault("compileSdkVersion")
37
+
38
+ defaultConfig {
39
+ minSdkVersion getExtOrIntegerDefault("minSdkVersion")
40
+ targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
41
+
42
+ externalNativeBuild {
43
+ cmake {
44
+ cppFlags "-frtti -fexceptions -Wall -fstack-protector-all"
45
+ arguments "-DANDROID_STL=c++_shared", "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
46
+ abiFilters (*reactNativeArchitectures())
47
+
48
+ buildTypes {
49
+ debug {
50
+ cppFlags "-O1 -g"
51
+ }
52
+ release {
53
+ cppFlags "-O2"
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ externalNativeBuild {
61
+ cmake {
62
+ path "CMakeLists.txt"
63
+ }
64
+ }
65
+
66
+ packagingOptions {
67
+ excludes = [
68
+ "META-INF",
69
+ "META-INF/**",
70
+ "**/libc++_shared.so",
71
+ "**/libfbjni.so",
72
+ "**/libjsi.so",
73
+ "**/libfolly_json.so",
74
+ "**/libfolly_runtime.so",
75
+ "**/libglog.so",
76
+ "**/libhermes.so",
77
+ "**/libhermes-executor-debug.so",
78
+ "**/libhermes_executor.so",
79
+ "**/libreactnative.so",
80
+ "**/libreactnativejni.so",
81
+ "**/libturbomodulejsijni.so",
82
+ "**/libreact_nativemodule_core.so",
83
+ "**/libjscexecutor.so"
84
+ ]
85
+ }
86
+
87
+ buildFeatures {
88
+ buildConfig true
89
+ prefab true
90
+ }
91
+
92
+ buildTypes {
93
+ release {
94
+ minifyEnabled false
95
+ }
96
+ }
97
+
98
+ lintOptions {
99
+ disable "GradleCompatible"
100
+ }
101
+
102
+ compileOptions {
103
+ sourceCompatibility JavaVersion.VERSION_1_8
104
+ targetCompatibility JavaVersion.VERSION_1_8
105
+ }
106
+
107
+ sourceSets {
108
+ main {
109
+ java.srcDirs += [
110
+ "generated/java",
111
+ "generated/jni"
112
+ ]
113
+ }
114
+ }
115
+ }
116
+
117
+ repositories {
118
+ mavenCentral()
119
+ google()
120
+ }
121
+
122
+ def kotlin_version = getExtOrDefault("kotlinVersion")
123
+
124
+ dependencies {
125
+ implementation "com.facebook.react:react-android"
126
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
127
+ implementation project(":react-native-nitro-modules")
128
+
129
+ implementation project(":onekeyfe_react-native-native-logger")
130
+
131
+ implementation "com.squareup.okhttp3:okhttp:4.12.0"
132
+ }
@@ -0,0 +1,4 @@
1
+ ReactNativeRangeDownloader_kotlinVersion=1.9.25
2
+ ReactNativeRangeDownloader_compileSdkVersion=35
3
+ ReactNativeRangeDownloader_targetSdkVersion=35
4
+ ReactNativeRangeDownloader_minSdkVersion=24
@@ -0,0 +1 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,6 @@
1
+ #include <jni.h>
2
+ #include "reactnativerangedownloaderOnLoad.hpp"
3
+
4
+ extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) {
5
+ return margelo::nitro::reactnativerangedownloader::initialize(vm);
6
+ }
@@ -0,0 +1,340 @@
1
+ package com.margelo.nitro.reactnativerangedownloader
2
+
3
+ import okhttp3.OkHttpClient
4
+ import okhttp3.Request
5
+ import java.io.File
6
+ import java.io.RandomAccessFile
7
+ import java.util.concurrent.Executors
8
+ import java.util.concurrent.atomic.AtomicBoolean
9
+ import java.util.concurrent.atomic.AtomicLong
10
+ import java.util.concurrent.atomic.AtomicReference
11
+
12
+ /**
13
+ * Splits a Range-capable download into [segmentCount] byte ranges fetched in
14
+ * parallel, each written directly into its own offset of ONE pre-allocated
15
+ * `.partial` file (no merge pass, 1x disk). A sidecar `<partial>.progress`
16
+ * manifest records each segment's durably-written cursor so an interrupted
17
+ * download resumes by re-requesting only the unfinished tail of each segment.
18
+ *
19
+ * Mirrors the desktop DesktopApiBundleUpdate concurrent path. This class is
20
+ * intentionally free of Android/OneKey dependencies (logging is injected) so
21
+ * it can be unit/type-checked standalone; the whole-file SHA256 check the
22
+ * caller already performs after promotion is the final correctness backstop.
23
+ *
24
+ * Invariant: the manifest is only meaningful as metadata for an existing
25
+ * `.partial`. Either both exist (resume) or neither does (fresh) — any other
26
+ * combination is treated as "no resumable state".
27
+ */
28
+ class ConcurrentRangeDownloader(
29
+ private val httpClient: OkHttpClient,
30
+ private val segmentCount: Int = 8,
31
+ private val minConcurrentBytes: Long = 2L * 1024 * 1024,
32
+ private val maxPartRetry: Int = 3,
33
+ private val manifestFlushBytes: Long = 4L * 1024 * 1024,
34
+ private val log: (String) -> Unit = {},
35
+ ) {
36
+ enum class Outcome {
37
+ /** `.partial` is fully on disk; caller should promote (rename) + verify. */
38
+ COMPLETED,
39
+
40
+ /** Concurrency unusable — caller should use its single-stream path. */
41
+ FALLBACK,
42
+ }
43
+
44
+ /** Thrown internally when a segment proves concurrency can't be used. */
45
+ private class FallbackException(message: String) : Exception(message)
46
+
47
+ private class Part(val index: Int, val start: Long, val end: Long, @Volatile var done: Long) {
48
+ val length: Long get() = end - start + 1
49
+ }
50
+
51
+ private class Probe(val totalSize: Long, val etag: String?, val supportsRange: Boolean)
52
+
53
+ /**
54
+ * Fills [partialFilePath] completely with the resource at [url] using
55
+ * concurrent ranges. See [Outcome]. Throws on a transient/IO error after
56
+ * per-segment retries, leaving the partial + manifest in place so a later
57
+ * attempt resumes.
58
+ */
59
+ fun download(
60
+ url: String,
61
+ partialFilePath: String,
62
+ onProgress: (transferred: Long, total: Long) -> Unit,
63
+ ): Outcome {
64
+ val partialFile = File(partialFilePath)
65
+ val manifestFile = File("$partialFilePath.progress")
66
+
67
+ // A bare `.partial` with no manifest is a single-stream leftover; let
68
+ // the caller's single-stream path resume it instead of discarding it.
69
+ if (partialFile.exists() && !manifestFile.exists()) {
70
+ log("concurrent: single-stream partial present, deferring to single-stream")
71
+ return Outcome.FALLBACK
72
+ }
73
+
74
+ val probe = probe(url) ?: return Outcome.FALLBACK
75
+ if (!probe.supportsRange || probe.totalSize < minConcurrentBytes) {
76
+ log("concurrent: not eligible (supportsRange=${probe.supportsRange}, size=${probe.totalSize})")
77
+ return Outcome.FALLBACK
78
+ }
79
+ val total = probe.totalSize
80
+ val etag = probe.etag
81
+
82
+ partialFile.parentFile?.let { if (!it.exists()) it.mkdirs() }
83
+ dropOrphanManifest(partialFile, manifestFile)
84
+ val parts = loadOrInitManifest(manifestFile, partialFile, total, etag)
85
+
86
+ val transferred = AtomicLong(parts.sumOf { it.done })
87
+ onProgress(transferred.get(), total)
88
+
89
+ val aborted = AtomicBoolean(false)
90
+ val fallback = AtomicBoolean(false)
91
+ val firstError = AtomicReference<Exception?>(null)
92
+ val lastFlushed = LongArray(parts.size) { parts[it].done }
93
+
94
+ val pool = Executors.newFixedThreadPool(minOf(segmentCount, parts.size))
95
+ try {
96
+ val futures = parts.map { part ->
97
+ pool.submit {
98
+ try {
99
+ downloadPart(url, etag, partialFile, part, aborted) { delta ->
100
+ val t = transferred.addAndGet(delta)
101
+ synchronized(lastFlushed) {
102
+ if (part.done - lastFlushed[part.index] >= manifestFlushBytes) {
103
+ lastFlushed[part.index] = part.done
104
+ flushManifest(manifestFile, total, etag, parts)
105
+ }
106
+ }
107
+ onProgress(t, total)
108
+ }
109
+ } catch (e: FallbackException) {
110
+ fallback.set(true)
111
+ aborted.set(true)
112
+ firstError.compareAndSet(null, e)
113
+ } catch (e: Exception) {
114
+ aborted.set(true)
115
+ firstError.compareAndSet(null, e)
116
+ }
117
+ }
118
+ }
119
+ futures.forEach { it.get() }
120
+ } finally {
121
+ pool.shutdownNow()
122
+ }
123
+
124
+ if (fallback.get()) {
125
+ // Stale/unusable bytes — clear before the caller falls back.
126
+ discard(partialFile, manifestFile)
127
+ return Outcome.FALLBACK
128
+ }
129
+ val err = firstError.get()
130
+ if (err != null) {
131
+ // Transient — persist progress so the next attempt resumes, then bubble up.
132
+ flushManifest(manifestFile, total, etag, parts)
133
+ throw err
134
+ }
135
+ val got = parts.sumOf { it.done }
136
+ if (got < total) {
137
+ flushManifest(manifestFile, total, etag, parts)
138
+ throw java.io.IOException("Concurrent download incomplete ($got/$total)")
139
+ }
140
+
141
+ // Success: `.partial` is fully filled. The manifest's job is done and it
142
+ // must never outlive the `.partial` it describes (caller is about to
143
+ // promote it), so drop it now.
144
+ manifestFile.delete()
145
+ log("concurrent: completed ($total bytes)")
146
+ return Outcome.COMPLETED
147
+ }
148
+
149
+ // Single round-trip probe: a one-byte Range request that confirms Range
150
+ // support and captures total size + ETag. OkHttp follows redirects (the
151
+ // caller's client enforces HTTPS on each hop).
152
+ private fun probe(url: String): Probe? {
153
+ return try {
154
+ val req = Request.Builder().url(url).addHeader("Range", "bytes=0-0").build()
155
+ httpClient.newCall(req).execute().use { response ->
156
+ val etag = response.header("ETag")
157
+ when (response.code) {
158
+ 206 -> {
159
+ val total = response.header("Content-Range")
160
+ ?.let { Regex("""bytes \d+-\d+/(\d+)""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() }
161
+ if (total != null) Probe(total, etag, true) else Probe(0, etag, false)
162
+ }
163
+ 200 -> {
164
+ // Server ignored Range — single-stream only.
165
+ val len = response.body?.contentLength() ?: -1L
166
+ Probe(if (len > 0) len else 0, etag, false)
167
+ }
168
+ else -> null
169
+ }
170
+ }
171
+ } catch (e: Exception) {
172
+ log("concurrent: probe failed: ${e.javaClass.simpleName}")
173
+ null
174
+ }
175
+ }
176
+
177
+ private fun dropOrphanManifest(partialFile: File, manifestFile: File) {
178
+ if (!partialFile.exists() && manifestFile.exists()) {
179
+ log("concurrent: dropping orphan manifest")
180
+ manifestFile.delete()
181
+ }
182
+ }
183
+
184
+ private fun discard(partialFile: File, manifestFile: File) {
185
+ // Manifest first so it never outlives the partial it describes.
186
+ manifestFile.delete()
187
+ partialFile.delete()
188
+ }
189
+
190
+ // Resume from a manifest whose size/ETag still match, else (re)create a
191
+ // fresh pre-allocated partial + manifest. Manifest is removed before the
192
+ // partial is (re)created, and written only after the partial exists.
193
+ private fun loadOrInitManifest(
194
+ manifestFile: File,
195
+ partialFile: File,
196
+ total: Long,
197
+ etag: String?,
198
+ ): List<Part> {
199
+ if (manifestFile.exists() && partialFile.exists()) {
200
+ val parsed = parseManifest(manifestFile, total, etag, partialFile.length())
201
+ if (parsed != null) {
202
+ log("concurrent: resuming, transferred=${parsed.sumOf { it.done }}/$total")
203
+ return parsed
204
+ }
205
+ }
206
+ discard(partialFile, manifestFile)
207
+ RandomAccessFile(partialFile, "rw").use { it.setLength(total) }
208
+ val parts = ArrayList<Part>()
209
+ val chunk = (total + segmentCount - 1) / segmentCount
210
+ var i = 0
211
+ while (i < segmentCount) {
212
+ val start = i * chunk
213
+ if (start >= total) break
214
+ val end = minOf(start + chunk - 1, total - 1)
215
+ parts.add(Part(parts.size, start, end, 0))
216
+ i += 1
217
+ }
218
+ writeManifest(manifestFile, total, etag, parts)
219
+ return parts
220
+ }
221
+
222
+ // Manifest format (dependency-free, internal): line 0 "<size>|<etag>",
223
+ // then one "<index>,<start>,<end>,<done>" line per segment.
224
+ private fun writeManifest(manifestFile: File, total: Long, etag: String?, parts: List<Part>) {
225
+ val sb = StringBuilder()
226
+ sb.append(total).append('|').append(etag ?: "").append('\n')
227
+ for (p in parts) {
228
+ sb.append(p.index).append(',').append(p.start).append(',')
229
+ .append(p.end).append(',').append(p.done).append('\n')
230
+ }
231
+ manifestFile.writeText(sb.toString())
232
+ }
233
+
234
+ @Synchronized
235
+ private fun flushManifest(manifestFile: File, total: Long, etag: String?, parts: List<Part>) {
236
+ try {
237
+ writeManifest(manifestFile, total, etag, parts)
238
+ } catch (e: Exception) {
239
+ log("concurrent: manifest flush failed: ${e.javaClass.simpleName}")
240
+ }
241
+ }
242
+
243
+ private fun parseManifest(manifestFile: File, total: Long, etag: String?, partialSize: Long): List<Part>? {
244
+ return try {
245
+ val lines = manifestFile.readText().trim().split('\n')
246
+ if (lines.isEmpty()) return null
247
+ val head = lines[0].split('|')
248
+ val savedSize = head.getOrNull(0)?.toLongOrNull() ?: return null
249
+ val savedEtag = head.getOrNull(1)?.takeIf { it.isNotEmpty() }
250
+ // Object must be identical to what's on disk and on the CDN.
251
+ if (savedSize != total || partialSize != total) return null
252
+ if (etag != null && savedEtag != null && etag != savedEtag) return null
253
+ val parts = ArrayList<Part>()
254
+ for (idx in 1 until lines.size) {
255
+ val cols = lines[idx].split(',')
256
+ if (cols.size != 4) return null
257
+ val i = cols[0].toIntOrNull() ?: return null
258
+ val s = cols[1].toLongOrNull() ?: return null
259
+ val e = cols[2].toLongOrNull() ?: return null
260
+ var d = cols[3].toLongOrNull() ?: return null
261
+ val segLen = e - s + 1
262
+ if (d < 0) d = 0
263
+ if (d > segLen) d = segLen
264
+ parts.add(Part(i, s, e, d))
265
+ }
266
+ if (parts.isEmpty()) null else parts
267
+ } catch (e: Exception) {
268
+ log("concurrent: manifest parse failed: ${e.javaClass.simpleName}")
269
+ null
270
+ }
271
+ }
272
+
273
+ // Download [start+done, end] of [part] into its own RandomAccessFile handle
274
+ // (each segment gets its own fd so concurrent positioned writes don't race),
275
+ // resuming from part.done and retrying transient failures in place.
276
+ private fun downloadPart(
277
+ url: String,
278
+ etag: String?,
279
+ partialFile: File,
280
+ part: Part,
281
+ aborted: AtomicBoolean,
282
+ onBytes: (delta: Long) -> Unit,
283
+ ) {
284
+ var retry = 0
285
+ while (true) {
286
+ if (aborted.get()) throw java.io.IOException("aborted")
287
+ val rangeStart = part.start + part.done
288
+ if (rangeStart > part.end) return
289
+ try {
290
+ fetchSegment(url, etag, partialFile, part, rangeStart, aborted, onBytes)
291
+ return
292
+ } catch (e: FallbackException) {
293
+ throw e
294
+ } catch (e: Exception) {
295
+ if (aborted.get() || retry >= maxPartRetry) throw e
296
+ retry += 1
297
+ log("concurrent: segment ${part.index} retry $retry: ${e.javaClass.simpleName}")
298
+ }
299
+ }
300
+ }
301
+
302
+ private fun fetchSegment(
303
+ url: String,
304
+ etag: String?,
305
+ partialFile: File,
306
+ part: Part,
307
+ rangeStart: Long,
308
+ aborted: AtomicBoolean,
309
+ onBytes: (delta: Long) -> Unit,
310
+ ) {
311
+ val builder = Request.Builder().url(url)
312
+ .addHeader("Range", "bytes=$rangeStart-${part.end}")
313
+ // If-Range: a mismatched ETag makes the CDN reply 200 (full body)
314
+ // instead of 206, which we treat as a fallback signal.
315
+ if (etag != null) builder.addHeader("If-Range", etag)
316
+ httpClient.newCall(builder.build()).execute().use { response ->
317
+ if (response.code == 200) {
318
+ throw FallbackException("server returned 200 to a Range request")
319
+ }
320
+ if (response.code != 206) {
321
+ throw java.io.IOException("HTTP ${response.code}")
322
+ }
323
+ val body = response.body ?: throw java.io.IOException("Empty segment body")
324
+ RandomAccessFile(partialFile, "rw").use { raf ->
325
+ raf.seek(rangeStart)
326
+ body.byteStream().use { input ->
327
+ val buffer = ByteArray(8192)
328
+ while (true) {
329
+ if (aborted.get()) throw java.io.IOException("aborted")
330
+ val read = input.read(buffer)
331
+ if (read == -1) break
332
+ raf.write(buffer, 0, read)
333
+ part.done += read
334
+ onBytes(read.toLong())
335
+ }
336
+ }
337
+ }
338
+ }
339
+ }
340
+ }