@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.
- package/LICENSE +21 -0
- package/README.md +36 -0
- package/ReactNativeRangeDownloader.podspec +30 -0
- package/android/CMakeLists.txt +24 -0
- package/android/build.gradle +132 -0
- package/android/gradle.properties +4 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/cpp/cpp-adapter.cpp +6 -0
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt +340 -0
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +233 -0
- package/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloaderPackage.kt +24 -0
- package/ios/ReactNativeRangeDownloader.swift +732 -0
- package/lib/module/ReactNativeRangeDownloader.nitro.js +4 -0
- package/lib/module/ReactNativeRangeDownloader.nitro.js.map +1 -0
- package/lib/module/index.js +15 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts +35 -0
- package/lib/typescript/src/ReactNativeRangeDownloader.nitro.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +9 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/nitro.json +17 -0
- package/nitrogen/generated/android/c++/JDownloadChannel.hpp +62 -0
- package/nitrogen/generated/android/c++/JFunc_void_RangeDownloadEvent.hpp +80 -0
- package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.cpp +117 -0
- package/nitrogen/generated/android/c++/JHybridReactNativeRangeDownloaderSpec.hpp +69 -0
- package/nitrogen/generated/android/c++/JRangeDownloadEvent.hpp +75 -0
- package/nitrogen/generated/android/c++/JRangeDownloadOutcome.hpp +59 -0
- package/nitrogen/generated/android/c++/JRangeDownloadParams.hpp +84 -0
- package/nitrogen/generated/android/c++/JRangeDownloadResult.hpp +68 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/DownloadChannel.kt +22 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/Func_void_RangeDownloadEvent.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/HybridReactNativeRangeDownloaderSpec.kt +79 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadEvent.kt +50 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadOutcome.kt +21 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadParams.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/RangeDownloadResult.kt +44 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativerangedownloader/reactnativerangedownloaderOnLoad.kt +35 -0
- package/nitrogen/generated/android/reactnativerangedownloader+autolinking.cmake +81 -0
- package/nitrogen/generated/android/reactnativerangedownloader+autolinking.gradle +27 -0
- package/nitrogen/generated/android/reactnativerangedownloaderOnLoad.cpp +46 -0
- package/nitrogen/generated/android/reactnativerangedownloaderOnLoad.hpp +25 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloader+autolinking.rb +60 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.cpp +65 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Bridge.hpp +246 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloader-Swift-Cxx-Umbrella.hpp +62 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloaderAutolinking.mm +33 -0
- package/nitrogen/generated/ios/ReactNativeRangeDownloaderAutolinking.swift +25 -0
- package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridReactNativeRangeDownloaderSpecSwift.hpp +123 -0
- package/nitrogen/generated/ios/swift/DownloadChannel.swift +44 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +47 -0
- package/nitrogen/generated/ios/swift/Func_void_RangeDownloadEvent.swift +47 -0
- package/nitrogen/generated/ios/swift/Func_void_RangeDownloadResult.swift +47 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +47 -0
- package/nitrogen/generated/ios/swift/HybridReactNativeRangeDownloaderSpec.swift +60 -0
- package/nitrogen/generated/ios/swift/HybridReactNativeRangeDownloaderSpec_cxx.swift +197 -0
- package/nitrogen/generated/ios/swift/RangeDownloadEvent.swift +80 -0
- package/nitrogen/generated/ios/swift/RangeDownloadOutcome.swift +40 -0
- package/nitrogen/generated/ios/swift/RangeDownloadParams.swift +145 -0
- package/nitrogen/generated/ios/swift/RangeDownloadResult.swift +77 -0
- package/nitrogen/generated/shared/c++/DownloadChannel.hpp +80 -0
- package/nitrogen/generated/shared/c++/HybridReactNativeRangeDownloaderSpec.cpp +25 -0
- package/nitrogen/generated/shared/c++/HybridReactNativeRangeDownloaderSpec.hpp +79 -0
- package/nitrogen/generated/shared/c++/RangeDownloadEvent.hpp +93 -0
- package/nitrogen/generated/shared/c++/RangeDownloadOutcome.hpp +76 -0
- package/nitrogen/generated/shared/c++/RangeDownloadParams.hpp +102 -0
- package/nitrogen/generated/shared/c++/RangeDownloadResult.hpp +86 -0
- package/package.json +169 -0
- package/src/ReactNativeRangeDownloader.nitro.ts +60 -0
- 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 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
|
@@ -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
|
+
}
|