@onekeyfe/react-native-app-update 3.0.65 → 3.0.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +5 -0
- package/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt +123 -54
- package/android/src/main/res/xml/app_update_file_paths.xml +4 -0
- package/package.json +1 -1
- package/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ConcurrentRangeDownloader.kt +0 -362
package/android/build.gradle
CHANGED
|
@@ -142,6 +142,11 @@ dependencies {
|
|
|
142
142
|
// own GPG public key + BouncyCastle verify implementation.
|
|
143
143
|
implementation project(":onekeyfe_react-native-bundle-crypto")
|
|
144
144
|
|
|
145
|
+
// Shared 8-range concurrent downloader (segment-file model). app-update no
|
|
146
|
+
// longer carries its own copy of ConcurrentRangeDownloader — it consumes the
|
|
147
|
+
// single shared implementation here, same as react-native-bundle-update.
|
|
148
|
+
implementation project(":onekeyfe_react-native-range-downloader")
|
|
149
|
+
|
|
145
150
|
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
|
146
151
|
implementation "com.squareup.okio:okio:3.9.0"
|
|
147
152
|
implementation "androidx.core:core-ktx:1.15.0"
|
package/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt
CHANGED
|
@@ -28,6 +28,11 @@ import java.util.concurrent.atomic.AtomicBoolean
|
|
|
28
28
|
import java.util.concurrent.atomic.AtomicInteger
|
|
29
29
|
import java.util.concurrent.atomic.AtomicLong
|
|
30
30
|
import com.margelo.nitro.reactnativebundlecrypto.BundleCryptoCore
|
|
31
|
+
// Shared 8-range concurrent downloader (segment-file model, no whole-file
|
|
32
|
+
// pre-allocation). Previously app-update bundled its own private copy; it now
|
|
33
|
+
// consumes the single shared implementation in react-native-range-downloader
|
|
34
|
+
// so a fix lands once for both APK and JS-bundle downloads.
|
|
35
|
+
import com.margelo.nitro.reactnativerangedownloader.ConcurrentRangeDownloader
|
|
31
36
|
|
|
32
37
|
private data class Listener(
|
|
33
38
|
val id: Double,
|
|
@@ -40,6 +45,12 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
40
45
|
companion object {
|
|
41
46
|
private const val CHANNEL_ID = "updateApp"
|
|
42
47
|
private const val NOTIFICATION_ID = 1
|
|
48
|
+
|
|
49
|
+
// Must match ConcurrentRangeDownloader's default segmentCount: the
|
|
50
|
+
// concurrent downloader writes sibling segment files
|
|
51
|
+
// "<partial>.seg0".."<partial>.seg${N-1}", and Phase 2 below scans this
|
|
52
|
+
// range to detect an in-flight concurrent download.
|
|
53
|
+
private const val CONCURRENT_SEGMENT_COUNT = 8
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
private val listeners = CopyOnWriteArrayList<Listener>()
|
|
@@ -68,10 +79,21 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
68
79
|
listeners.removeAll { it.id == id }
|
|
69
80
|
}
|
|
70
81
|
|
|
71
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Directory for downloaded APK artifacts (.apk / .partial / .segN / .asc).
|
|
84
|
+
*
|
|
85
|
+
* Uses filesDir, NOT cacheDir: cacheDir is system-reclaimable — under
|
|
86
|
+
* storage pressure Android can purge it or make it unwritable mid-download,
|
|
87
|
+
* surfacing as EROFS/ENOSPC when opening a new segment file. filesDir is
|
|
88
|
+
* persistent app data and is the same location react-native-bundle-update
|
|
89
|
+
* downloads to. It is still installable: the APK is handed to the system
|
|
90
|
+
* installer via FileProvider, whose <paths> exposes this dir
|
|
91
|
+
* (see res/xml/app_update_file_paths.xml: <files-path name="apks_files" .../>).
|
|
92
|
+
*/
|
|
93
|
+
private fun getApkDownloadDir(): File {
|
|
72
94
|
val context = NitroModules.applicationContext
|
|
73
95
|
?: throw SecurityException("Application context unavailable")
|
|
74
|
-
val apkDir = File(context.
|
|
96
|
+
val apkDir = File(context.filesDir, "apks")
|
|
75
97
|
if (!apkDir.exists()) apkDir.mkdirs()
|
|
76
98
|
return apkDir
|
|
77
99
|
}
|
|
@@ -97,7 +119,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
97
119
|
val file = if (stripped.startsWith("/")) {
|
|
98
120
|
File(stripped)
|
|
99
121
|
} else {
|
|
100
|
-
File(
|
|
122
|
+
File(getApkDownloadDir(), stripped)
|
|
101
123
|
}
|
|
102
124
|
|
|
103
125
|
// Validate the resolved path is within the app's cache or files directory
|
|
@@ -147,16 +169,17 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
147
169
|
.followSslRedirects(false)
|
|
148
170
|
.build()
|
|
149
171
|
val request = Request.Builder().url(ascUrl).build()
|
|
150
|
-
val response = client.newCall(request).execute()
|
|
151
|
-
if (!response.isSuccessful) return null
|
|
152
172
|
val content = StringBuilder()
|
|
153
173
|
val maxAscSize = 10 * 1024
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
174
|
+
client.newCall(request).execute().use { response ->
|
|
175
|
+
if (!response.isSuccessful) return null
|
|
176
|
+
val body = response.body ?: return null
|
|
177
|
+
BufferedReader(InputStreamReader(body.byteStream())).use { reader ->
|
|
178
|
+
var line: String?
|
|
179
|
+
while (reader.readLine().also { line = it } != null) {
|
|
180
|
+
content.append(line).append("\n")
|
|
181
|
+
if (content.length > maxAscSize) return null
|
|
182
|
+
}
|
|
160
183
|
}
|
|
161
184
|
}
|
|
162
185
|
val ascContent = content.toString()
|
|
@@ -482,10 +505,24 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
482
505
|
expectedSize > 0 && existingSize > expectedSize -> {
|
|
483
506
|
OneKeyLog.warn("AppUpdate", "downloadAPK: existing APK larger than expected, deleting")
|
|
484
507
|
downloadedFile.delete()
|
|
508
|
+
// Oversized final means local state is untrustworthy. A stale
|
|
509
|
+
// single-stream .partial and any sibling .segN left by an earlier
|
|
510
|
+
// concurrent run could otherwise be picked up below and reused —
|
|
511
|
+
// wipe them so this restarts cleanly from byte zero.
|
|
512
|
+
if (partialFile.exists()) partialFile.delete()
|
|
513
|
+
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete()
|
|
485
514
|
}
|
|
486
515
|
expectedSize > 0 && existingSize < expectedSize -> {
|
|
487
516
|
OneKeyLog.info("AppUpdate", "downloadAPK: existing APK smaller than expected, promoting to .partial for resume")
|
|
488
517
|
if (partialFile.exists()) partialFile.delete()
|
|
518
|
+
// Drop any sibling .segN BEFORE the promotion. The promoted final
|
|
519
|
+
// must become a clean single-stream resume cursor: if .segN
|
|
520
|
+
// survived, Phase 2's hasConcurrentSegments check would fire and
|
|
521
|
+
// the concurrent downloader would treat this legacy .partial as a
|
|
522
|
+
// concat committed-cursor, risking a mixed file. With no .segN,
|
|
523
|
+
// Phase 2 takes the single-stream size-based path and Range-resumes
|
|
524
|
+
// from the partial's length.
|
|
525
|
+
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete()
|
|
489
526
|
if (!downloadedFile.renameTo(partialFile)) {
|
|
490
527
|
OneKeyLog.warn("AppUpdate", "downloadAPK: rename to .partial failed, deleting stale final")
|
|
491
528
|
downloadedFile.delete()
|
|
@@ -502,6 +539,10 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
502
539
|
OneKeyLog.info("AppUpdate", "downloadAPK: existing APK hash mismatch, deleting and re-downloading")
|
|
503
540
|
downloadedFile.delete()
|
|
504
541
|
if (partialFile.exists()) partialFile.delete()
|
|
542
|
+
// Final was stale → start from byte zero. Wipe any
|
|
543
|
+
// sibling .segN left by an earlier concurrent run so
|
|
544
|
+
// it can't be mistaken for trustworthy in-flight bytes.
|
|
545
|
+
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete()
|
|
505
546
|
}
|
|
506
547
|
ApkVerifyOutcome.Indeterminate -> {
|
|
507
548
|
// ASC could not be fetched (offline) or could
|
|
@@ -520,23 +561,21 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
520
561
|
|
|
521
562
|
// Phase 2 — pick up an in-flight partial.
|
|
522
563
|
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
// promote+SHA-verify a mostly-zeroed file, hit HashMismatch, and
|
|
530
|
-
// DELETE it — nuking every interrupted concurrent download back to
|
|
531
|
-
// byte 0. So when the manifest exists, skip the size-based
|
|
564
|
+
// The concurrent (multi-range) downloader stores in-flight bytes in
|
|
565
|
+
// sibling segment files "<partial>.seg0".."<partial>.segN" — NOT in
|
|
566
|
+
// the .partial (the .partial only ever holds real, fully-assembled
|
|
567
|
+
// bytes, written by the concurrent downloader's final concat or by
|
|
568
|
+
// the single-stream path). When any segment file exists, an
|
|
569
|
+
// interrupted concurrent download owns the slot: skip the size-based
|
|
532
570
|
// promote/discard branches entirely and let the concurrent
|
|
533
|
-
// downloader below
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
// File("$partialFilePath.
|
|
537
|
-
val
|
|
571
|
+
// downloader below resume (it picks up each .segN, or returns
|
|
572
|
+
// FALLBACK and hands the bytes back to single-stream). The segment
|
|
573
|
+
// path must match exactly what ConcurrentRangeDownloader writes:
|
|
574
|
+
// File("$partialFilePath.seg$i").
|
|
575
|
+
val hasConcurrentSegments =
|
|
576
|
+
(0 until CONCURRENT_SEGMENT_COUNT).any { buildFile("$partialFilePath.seg$it").exists() }
|
|
538
577
|
var partialBytes = 0L
|
|
539
|
-
if (partialFile.exists() && !
|
|
578
|
+
if (partialFile.exists() && !hasConcurrentSegments) {
|
|
540
579
|
val partialSize = partialFile.length()
|
|
541
580
|
when {
|
|
542
581
|
expectedSize > 0 && partialSize == expectedSize -> {
|
|
@@ -568,6 +607,8 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
568
607
|
expectedSize > 0 && partialSize > expectedSize -> {
|
|
569
608
|
OneKeyLog.warn("AppUpdate", "downloadAPK: stale partial (>expected $partialSize/$expectedSize), discarding")
|
|
570
609
|
partialFile.delete()
|
|
610
|
+
// Partial bytes are untrustworthy → restart from zero; drop any sibling .segN too.
|
|
611
|
+
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete()
|
|
571
612
|
}
|
|
572
613
|
partialSize > 0 -> {
|
|
573
614
|
partialBytes = partialSize
|
|
@@ -605,7 +646,14 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
605
646
|
val concurrentOutcome = ConcurrentRangeDownloader(
|
|
606
647
|
httpClient = concurrentClient,
|
|
607
648
|
log = { msg -> OneKeyLog.info("AppUpdate", msg) },
|
|
608
|
-
|
|
649
|
+
// MUST be the ABSOLUTE path. partialFilePath is just a
|
|
650
|
+
// file NAME ("<apk>.partial"); the downloader does
|
|
651
|
+
// File(path) on it, which resolves a relative name
|
|
652
|
+
// against the process CWD ("/") and writes the segment
|
|
653
|
+
// files to the read-only root fs → EROFS. Resolve it to
|
|
654
|
+
// the real apks dir (filesDir/apks) first, exactly like
|
|
655
|
+
// react-native-bundle-update passes an absolute path.
|
|
656
|
+
).download(url, partialFile.absolutePath) { transferred, total ->
|
|
609
657
|
if (total > 0) {
|
|
610
658
|
val p = ((transferred * 100) / total).toInt().coerceIn(0, 100)
|
|
611
659
|
// Only the thread that advances the percent emits; a
|
|
@@ -694,6 +742,8 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
694
742
|
// anyone reading the error.
|
|
695
743
|
OneKeyLog.warn("AppUpdate", "downloadAPK: 416 recovery hash mismatch — server build changed mid-download")
|
|
696
744
|
if (partialFile.exists()) partialFile.delete()
|
|
745
|
+
// Server build changed → these bytes are worthless; drop sibling .segN too.
|
|
746
|
+
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete()
|
|
697
747
|
throw java.io.IOException("Server build changed mid-download (size matches but hash differs)")
|
|
698
748
|
}
|
|
699
749
|
PromoteOutcome.Deferred -> {
|
|
@@ -710,6 +760,8 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
710
760
|
}
|
|
711
761
|
OneKeyLog.warn("AppUpdate", "downloadAPK: HTTP 416 (range not satisfiable), discarding partial and failing attempt")
|
|
712
762
|
if (partialFile.exists()) partialFile.delete()
|
|
763
|
+
// Partial discarded as unusable → drop sibling .segN too so the next attempt starts clean.
|
|
764
|
+
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete()
|
|
713
765
|
throw Exception("HTTP 416 (range not satisfiable)")
|
|
714
766
|
}
|
|
715
767
|
|
|
@@ -728,6 +780,8 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
728
780
|
if (expectsResume && !serverWillResume) {
|
|
729
781
|
OneKeyLog.warn("AppUpdate", "downloadAPK: requested Range but server returned 200, restarting from scratch")
|
|
730
782
|
if (partialFile.exists()) partialFile.delete()
|
|
783
|
+
// Restarting from byte zero → drop any sibling .segN too.
|
|
784
|
+
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete()
|
|
731
785
|
partialBytes = 0L
|
|
732
786
|
}
|
|
733
787
|
|
|
@@ -744,10 +798,20 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
744
798
|
if (serverWillResume) {
|
|
745
799
|
val rangeStart = rangeMatch?.groupValues?.getOrNull(1)?.toLongOrNull()
|
|
746
800
|
if (rangeStart == null || rangeStart != partialBytes) {
|
|
747
|
-
|
|
801
|
+
// This 206 body is a slice starting at the wrong offset (CDN bug /
|
|
802
|
+
// proxy rewrite). It is NOT a full file, so we must not consume it as
|
|
803
|
+
// one — doing so would write mis-aligned bytes (caught only later at
|
|
804
|
+
// SHA/GPG, after a bogus "downloaded" event). Wipe the partial + any
|
|
805
|
+
// .segN so the next attempt starts clean from byte zero, close the
|
|
806
|
+
// bad response, and throw a retryable error. The outer
|
|
807
|
+
// catch(Exception) emits update/error and rethrows; finally resets
|
|
808
|
+
// isDownloading, so this won't wedge.
|
|
809
|
+
val contentRangeHeader = response.header("Content-Range")
|
|
810
|
+
OneKeyLog.warn("AppUpdate", "downloadAPK: 206 Content-Range start mismatch (header='$contentRangeHeader', requested=$partialBytes); discarding partial and retrying from scratch")
|
|
748
811
|
if (partialFile.exists()) partialFile.delete()
|
|
749
|
-
|
|
750
|
-
|
|
812
|
+
for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete()
|
|
813
|
+
response.close()
|
|
814
|
+
throw java.io.IOException("206 Content-Range start mismatch (header='$contentRangeHeader', requested=$partialBytes); discarded partial, retry from scratch")
|
|
751
815
|
}
|
|
752
816
|
}
|
|
753
817
|
|
|
@@ -866,33 +930,37 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
866
930
|
.followSslRedirects(false)
|
|
867
931
|
.build()
|
|
868
932
|
val request = Request.Builder().url(ascFileUrl).build()
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
933
|
+
// Wrap in .use so the Response (and its connection) is released on every
|
|
934
|
+
// exit path — including the !isSuccessful / empty-body / oversize throws,
|
|
935
|
+
// which previously leaked the connection back to the pool unclosed.
|
|
936
|
+
val ascContent = client.newCall(request).execute().use { response ->
|
|
937
|
+
if (!response.isSuccessful) {
|
|
938
|
+
OneKeyLog.error("AppUpdate", "downloadASC: HTTP error, statusCode=${response.code}")
|
|
939
|
+
throw Exception(response.code.toString())
|
|
940
|
+
}
|
|
877
941
|
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
942
|
+
OneKeyLog.info("AppUpdate", "downloadASC: HTTP 200, reading ASC content...")
|
|
943
|
+
|
|
944
|
+
val content = StringBuilder()
|
|
945
|
+
val maxAscSize = 10 * 1024 // 10 KB max for ASC files
|
|
946
|
+
val body = response.body ?: throw Exception("Empty ASC response body")
|
|
947
|
+
BufferedReader(InputStreamReader(body.byteStream())).use { reader ->
|
|
948
|
+
var line: String?
|
|
949
|
+
while (reader.readLine().also { line = it } != null) {
|
|
950
|
+
content.append(line).append("\n")
|
|
951
|
+
if (content.length > maxAscSize) {
|
|
952
|
+
OneKeyLog.error("AppUpdate", "downloadASC: ASC file exceeds max size ($maxAscSize bytes)")
|
|
953
|
+
throw Exception("ASC file exceeds maximum allowed size")
|
|
954
|
+
}
|
|
888
955
|
}
|
|
889
956
|
}
|
|
890
|
-
}
|
|
891
957
|
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
958
|
+
val parsed = content.toString()
|
|
959
|
+
if (parsed.isEmpty()) {
|
|
960
|
+
OneKeyLog.error("AppUpdate", "downloadASC: ASC content is empty")
|
|
961
|
+
throw Exception("Empty ASC file")
|
|
962
|
+
}
|
|
963
|
+
parsed
|
|
896
964
|
}
|
|
897
965
|
|
|
898
966
|
OneKeyLog.info("AppUpdate", "downloadASC: ASC content size=${ascContent.length} bytes")
|
|
@@ -1207,7 +1275,8 @@ n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ
|
|
|
1207
1275
|
OneKeyLog.warn("AppUpdate", "$tag: application context unavailable, skipping file cleanup")
|
|
1208
1276
|
return
|
|
1209
1277
|
}
|
|
1210
|
-
|
|
1278
|
+
// Must match getApkDownloadDir() (filesDir/apks).
|
|
1279
|
+
val apkDir = File(context.filesDir, "apks")
|
|
1211
1280
|
if (!apkDir.exists()) {
|
|
1212
1281
|
OneKeyLog.info("AppUpdate", "$tag: apks cache directory does not exist, nothing to clean")
|
|
1213
1282
|
return
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
<?xml version="1.0" encoding="utf-8"?>
|
|
2
2
|
<paths>
|
|
3
|
+
<!-- Primary APK download location (filesDir/apks). MUST match getApkDownloadDir(). -->
|
|
4
|
+
<files-path name="apks_files" path="apks/" />
|
|
5
|
+
<!-- Legacy location: an APK already downloaded into cacheDir/apks by an older
|
|
6
|
+
build can still be exposed to the installer during the transition. -->
|
|
3
7
|
<cache-path name="apks" path="apks/" />
|
|
4
8
|
</paths>
|
package/package.json
CHANGED
package/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ConcurrentRangeDownloader.kt
DELETED
|
@@ -1,362 +0,0 @@
|
|
|
1
|
-
package com.margelo.nitro.reactnativeappupdate
|
|
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.
|
|
22
|
-
*
|
|
23
|
-
* LOAD-BEARING: the caller's whole-file SHA256 + GPG verify performed AFTER
|
|
24
|
-
* promotion is the final correctness backstop for this concurrent path and must
|
|
25
|
-
* NEVER be skipped. Resume here is best-effort (ETag-gated; no ETag => fresh
|
|
26
|
-
* start), and concurrent stitching of ranges has no per-segment integrity
|
|
27
|
-
* check — a mismatched or mixed file is only ever caught by that downstream
|
|
28
|
-
* whole-file verify. Removing it would let a corrupt/mixed APK reach install.
|
|
29
|
-
*
|
|
30
|
-
* Invariant: the manifest is only meaningful as metadata for an existing
|
|
31
|
-
* `.partial`. Either both exist (resume) or neither does (fresh) — any other
|
|
32
|
-
* combination is treated as "no resumable state".
|
|
33
|
-
*/
|
|
34
|
-
internal class ConcurrentRangeDownloader(
|
|
35
|
-
private val httpClient: OkHttpClient,
|
|
36
|
-
private val segmentCount: Int = 8,
|
|
37
|
-
private val minConcurrentBytes: Long = 2L * 1024 * 1024,
|
|
38
|
-
private val maxPartRetry: Int = 3,
|
|
39
|
-
private val manifestFlushBytes: Long = 4L * 1024 * 1024,
|
|
40
|
-
private val log: (String) -> Unit = {},
|
|
41
|
-
) {
|
|
42
|
-
enum class Outcome {
|
|
43
|
-
/** `.partial` is fully on disk; caller should promote (rename) + verify. */
|
|
44
|
-
COMPLETED,
|
|
45
|
-
|
|
46
|
-
/** Concurrency unusable — caller should use its single-stream path. */
|
|
47
|
-
FALLBACK,
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Thrown internally when a segment proves concurrency can't be used. */
|
|
51
|
-
private class FallbackException(message: String) : Exception(message)
|
|
52
|
-
|
|
53
|
-
private class Part(val index: Int, val start: Long, val end: Long, @Volatile var done: Long) {
|
|
54
|
-
val length: Long get() = end - start + 1
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
private class Probe(val totalSize: Long, val etag: String?, val supportsRange: Boolean)
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Fills [partialFilePath] completely with the resource at [url] using
|
|
61
|
-
* concurrent ranges. See [Outcome]. Throws on a transient/IO error after
|
|
62
|
-
* per-segment retries, leaving the partial + manifest in place so a later
|
|
63
|
-
* attempt resumes.
|
|
64
|
-
*/
|
|
65
|
-
fun download(
|
|
66
|
-
url: String,
|
|
67
|
-
partialFilePath: String,
|
|
68
|
-
onProgress: (transferred: Long, total: Long) -> Unit,
|
|
69
|
-
): Outcome {
|
|
70
|
-
val partialFile = File(partialFilePath)
|
|
71
|
-
val manifestFile = File("$partialFilePath.progress")
|
|
72
|
-
|
|
73
|
-
// A bare `.partial` with no manifest is a single-stream leftover; let
|
|
74
|
-
// the caller's single-stream path resume it instead of discarding it.
|
|
75
|
-
if (partialFile.exists() && !manifestFile.exists()) {
|
|
76
|
-
log("concurrent: single-stream partial present, deferring to single-stream")
|
|
77
|
-
return Outcome.FALLBACK
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
val probe = probe(url) ?: return Outcome.FALLBACK
|
|
81
|
-
if (!probe.supportsRange || probe.totalSize < minConcurrentBytes) {
|
|
82
|
-
log("concurrent: not eligible (supportsRange=${probe.supportsRange}, size=${probe.totalSize})")
|
|
83
|
-
return Outcome.FALLBACK
|
|
84
|
-
}
|
|
85
|
-
val total = probe.totalSize
|
|
86
|
-
val etag = probe.etag
|
|
87
|
-
|
|
88
|
-
partialFile.parentFile?.let { if (!it.exists()) it.mkdirs() }
|
|
89
|
-
dropOrphanManifest(partialFile, manifestFile)
|
|
90
|
-
val parts = loadOrInitManifest(manifestFile, partialFile, total, etag)
|
|
91
|
-
|
|
92
|
-
val transferred = AtomicLong(parts.sumOf { it.done })
|
|
93
|
-
onProgress(transferred.get(), total)
|
|
94
|
-
|
|
95
|
-
val aborted = AtomicBoolean(false)
|
|
96
|
-
val fallback = AtomicBoolean(false)
|
|
97
|
-
val firstError = AtomicReference<Exception?>(null)
|
|
98
|
-
val lastFlushed = LongArray(parts.size) { parts[it].done }
|
|
99
|
-
|
|
100
|
-
val pool = Executors.newFixedThreadPool(minOf(segmentCount, parts.size))
|
|
101
|
-
try {
|
|
102
|
-
val futures = parts.map { part ->
|
|
103
|
-
pool.submit {
|
|
104
|
-
try {
|
|
105
|
-
downloadPart(url, etag, partialFile, part, aborted) { delta ->
|
|
106
|
-
val t = transferred.addAndGet(delta)
|
|
107
|
-
synchronized(lastFlushed) {
|
|
108
|
-
if (part.done - lastFlushed[part.index] >= manifestFlushBytes) {
|
|
109
|
-
lastFlushed[part.index] = part.done
|
|
110
|
-
flushManifest(manifestFile, total, etag, parts)
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
onProgress(t, total)
|
|
114
|
-
}
|
|
115
|
-
} catch (e: FallbackException) {
|
|
116
|
-
fallback.set(true)
|
|
117
|
-
aborted.set(true)
|
|
118
|
-
firstError.compareAndSet(null, e)
|
|
119
|
-
} catch (e: Exception) {
|
|
120
|
-
aborted.set(true)
|
|
121
|
-
firstError.compareAndSet(null, e)
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
futures.forEach { it.get() }
|
|
126
|
-
} finally {
|
|
127
|
-
pool.shutdownNow()
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
if (fallback.get()) {
|
|
131
|
-
// Stale/unusable bytes — clear before the caller falls back.
|
|
132
|
-
discard(partialFile, manifestFile)
|
|
133
|
-
return Outcome.FALLBACK
|
|
134
|
-
}
|
|
135
|
-
val err = firstError.get()
|
|
136
|
-
if (err != null) {
|
|
137
|
-
// Transient — persist progress so the next attempt resumes, then bubble up.
|
|
138
|
-
flushManifest(manifestFile, total, etag, parts)
|
|
139
|
-
throw err
|
|
140
|
-
}
|
|
141
|
-
val got = parts.sumOf { it.done }
|
|
142
|
-
if (got < total) {
|
|
143
|
-
flushManifest(manifestFile, total, etag, parts)
|
|
144
|
-
throw java.io.IOException("Concurrent download incomplete ($got/$total)")
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Success: `.partial` is fully filled. The manifest's job is done and it
|
|
148
|
-
// must never outlive the `.partial` it describes (caller is about to
|
|
149
|
-
// promote it), so drop it now.
|
|
150
|
-
manifestFile.delete()
|
|
151
|
-
log("concurrent: completed ($total bytes)")
|
|
152
|
-
return Outcome.COMPLETED
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Single round-trip probe: a one-byte Range request that confirms Range
|
|
156
|
-
// support and captures total size + ETag. The caller's OkHttp client has
|
|
157
|
-
// redirects DISABLED (followRedirects(false)/followSslRedirects(false)), so
|
|
158
|
-
// a 3xx is not a 200/206 and probe() returns null -> Outcome.FALLBACK; the
|
|
159
|
-
// single-stream path (also redirect-disabled) then handles it.
|
|
160
|
-
// TODO: if APK origins ever start issuing redirects, decide deliberately
|
|
161
|
-
// whether to enable followSslRedirects(true) (HTTPS-only) here; until then
|
|
162
|
-
// redirecting origins simply fall back to single-stream.
|
|
163
|
-
private fun probe(url: String): Probe? {
|
|
164
|
-
return try {
|
|
165
|
-
val req = Request.Builder().url(url).addHeader("Range", "bytes=0-0").build()
|
|
166
|
-
httpClient.newCall(req).execute().use { response ->
|
|
167
|
-
val etag = response.header("ETag")
|
|
168
|
-
when (response.code) {
|
|
169
|
-
206 -> {
|
|
170
|
-
val total = response.header("Content-Range")
|
|
171
|
-
?.let { Regex("""bytes \d+-\d+/(\d+)""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() }
|
|
172
|
-
if (total != null) Probe(total, etag, true) else Probe(0, etag, false)
|
|
173
|
-
}
|
|
174
|
-
200 -> {
|
|
175
|
-
// Server ignored Range — single-stream only.
|
|
176
|
-
val len = response.body?.contentLength() ?: -1L
|
|
177
|
-
Probe(if (len > 0) len else 0, etag, false)
|
|
178
|
-
}
|
|
179
|
-
else -> null
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
} catch (e: Exception) {
|
|
183
|
-
log("concurrent: probe failed: ${e.javaClass.simpleName}")
|
|
184
|
-
null
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
private fun dropOrphanManifest(partialFile: File, manifestFile: File) {
|
|
189
|
-
if (!partialFile.exists() && manifestFile.exists()) {
|
|
190
|
-
log("concurrent: dropping orphan manifest")
|
|
191
|
-
manifestFile.delete()
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
private fun discard(partialFile: File, manifestFile: File) {
|
|
196
|
-
// Manifest first so it never outlives the partial it describes.
|
|
197
|
-
manifestFile.delete()
|
|
198
|
-
partialFile.delete()
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Resume from a manifest whose size/ETag still match, else (re)create a
|
|
202
|
-
// fresh pre-allocated partial + manifest. Manifest is removed before the
|
|
203
|
-
// partial is (re)created, and written only after the partial exists.
|
|
204
|
-
private fun loadOrInitManifest(
|
|
205
|
-
manifestFile: File,
|
|
206
|
-
partialFile: File,
|
|
207
|
-
total: Long,
|
|
208
|
-
etag: String?,
|
|
209
|
-
): List<Part> {
|
|
210
|
-
if (manifestFile.exists() && partialFile.exists()) {
|
|
211
|
-
val parsed = parseManifest(manifestFile, total, etag, partialFile.length())
|
|
212
|
-
if (parsed != null) {
|
|
213
|
-
log("concurrent: resuming, transferred=${parsed.sumOf { it.done }}/$total")
|
|
214
|
-
return parsed
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
discard(partialFile, manifestFile)
|
|
218
|
-
RandomAccessFile(partialFile, "rw").use { it.setLength(total) }
|
|
219
|
-
val parts = ArrayList<Part>()
|
|
220
|
-
val chunk = (total + segmentCount - 1) / segmentCount
|
|
221
|
-
var i = 0
|
|
222
|
-
while (i < segmentCount) {
|
|
223
|
-
val start = i * chunk
|
|
224
|
-
if (start >= total) break
|
|
225
|
-
val end = minOf(start + chunk - 1, total - 1)
|
|
226
|
-
parts.add(Part(parts.size, start, end, 0))
|
|
227
|
-
i += 1
|
|
228
|
-
}
|
|
229
|
-
writeManifest(manifestFile, total, etag, parts)
|
|
230
|
-
return parts
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
// Manifest format (dependency-free, internal): line 0 "<size>|<etag>",
|
|
234
|
-
// then one "<index>,<start>,<end>,<done>" line per segment.
|
|
235
|
-
private fun writeManifest(manifestFile: File, total: Long, etag: String?, parts: List<Part>) {
|
|
236
|
-
val sb = StringBuilder()
|
|
237
|
-
sb.append(total).append('|').append(etag ?: "").append('\n')
|
|
238
|
-
for (p in parts) {
|
|
239
|
-
sb.append(p.index).append(',').append(p.start).append(',')
|
|
240
|
-
.append(p.end).append(',').append(p.done).append('\n')
|
|
241
|
-
}
|
|
242
|
-
manifestFile.writeText(sb.toString())
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
@Synchronized
|
|
246
|
-
private fun flushManifest(manifestFile: File, total: Long, etag: String?, parts: List<Part>) {
|
|
247
|
-
try {
|
|
248
|
-
writeManifest(manifestFile, total, etag, parts)
|
|
249
|
-
} catch (e: Exception) {
|
|
250
|
-
log("concurrent: manifest flush failed: ${e.javaClass.simpleName}")
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
private fun parseManifest(manifestFile: File, total: Long, etag: String?, partialSize: Long): List<Part>? {
|
|
255
|
-
return try {
|
|
256
|
-
val lines = manifestFile.readText().trim().split('\n')
|
|
257
|
-
if (lines.isEmpty()) return null
|
|
258
|
-
val head = lines[0].split('|')
|
|
259
|
-
val savedSize = head.getOrNull(0)?.toLongOrNull() ?: return null
|
|
260
|
-
val savedEtag = head.getOrNull(1)?.takeIf { it.isNotEmpty() }
|
|
261
|
-
// Object must be identical to what's on disk and on the CDN.
|
|
262
|
-
if (savedSize != total || partialSize != total) return null
|
|
263
|
-
// Without an ETag we have no strong validator that the bytes already
|
|
264
|
-
// on disk belong to the SAME build as the one the CDN is serving now.
|
|
265
|
-
// A same-size-but-different build (common during a staged rollout)
|
|
266
|
-
// would otherwise resume by stitching old + new bytes into one file.
|
|
267
|
-
// The downstream whole-file SHA256 + GPG verify WOULD reject that
|
|
268
|
-
// mixed file, but only after we've wasted the whole download. So treat
|
|
269
|
-
// an ETag-less manifest as untrustworthy across restarts: bail out
|
|
270
|
-
// here and let the caller start fresh.
|
|
271
|
-
// (If a weaker validator is ever wanted, persist+compare Last-Modified
|
|
272
|
-
// instead of dropping outright.)
|
|
273
|
-
if (etag == null || savedEtag == null) return null
|
|
274
|
-
if (etag != savedEtag) return null
|
|
275
|
-
val parts = ArrayList<Part>()
|
|
276
|
-
for (idx in 1 until lines.size) {
|
|
277
|
-
val cols = lines[idx].split(',')
|
|
278
|
-
if (cols.size != 4) return null
|
|
279
|
-
val i = cols[0].toIntOrNull() ?: return null
|
|
280
|
-
val s = cols[1].toLongOrNull() ?: return null
|
|
281
|
-
val e = cols[2].toLongOrNull() ?: return null
|
|
282
|
-
var d = cols[3].toLongOrNull() ?: return null
|
|
283
|
-
val segLen = e - s + 1
|
|
284
|
-
if (d < 0) d = 0
|
|
285
|
-
if (d > segLen) d = segLen
|
|
286
|
-
parts.add(Part(i, s, e, d))
|
|
287
|
-
}
|
|
288
|
-
if (parts.isEmpty()) null else parts
|
|
289
|
-
} catch (e: Exception) {
|
|
290
|
-
log("concurrent: manifest parse failed: ${e.javaClass.simpleName}")
|
|
291
|
-
null
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// Download [start+done, end] of [part] into its own RandomAccessFile handle
|
|
296
|
-
// (each segment gets its own fd so concurrent positioned writes don't race),
|
|
297
|
-
// resuming from part.done and retrying transient failures in place.
|
|
298
|
-
private fun downloadPart(
|
|
299
|
-
url: String,
|
|
300
|
-
etag: String?,
|
|
301
|
-
partialFile: File,
|
|
302
|
-
part: Part,
|
|
303
|
-
aborted: AtomicBoolean,
|
|
304
|
-
onBytes: (delta: Long) -> Unit,
|
|
305
|
-
) {
|
|
306
|
-
var retry = 0
|
|
307
|
-
while (true) {
|
|
308
|
-
if (aborted.get()) throw java.io.IOException("aborted")
|
|
309
|
-
val rangeStart = part.start + part.done
|
|
310
|
-
if (rangeStart > part.end) return
|
|
311
|
-
try {
|
|
312
|
-
fetchSegment(url, etag, partialFile, part, rangeStart, aborted, onBytes)
|
|
313
|
-
return
|
|
314
|
-
} catch (e: FallbackException) {
|
|
315
|
-
throw e
|
|
316
|
-
} catch (e: Exception) {
|
|
317
|
-
if (aborted.get() || retry >= maxPartRetry) throw e
|
|
318
|
-
retry += 1
|
|
319
|
-
log("concurrent: segment ${part.index} retry $retry: ${e.javaClass.simpleName}")
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
private fun fetchSegment(
|
|
325
|
-
url: String,
|
|
326
|
-
etag: String?,
|
|
327
|
-
partialFile: File,
|
|
328
|
-
part: Part,
|
|
329
|
-
rangeStart: Long,
|
|
330
|
-
aborted: AtomicBoolean,
|
|
331
|
-
onBytes: (delta: Long) -> Unit,
|
|
332
|
-
) {
|
|
333
|
-
val builder = Request.Builder().url(url)
|
|
334
|
-
.addHeader("Range", "bytes=$rangeStart-${part.end}")
|
|
335
|
-
// If-Range: a mismatched ETag makes the CDN reply 200 (full body)
|
|
336
|
-
// instead of 206, which we treat as a fallback signal.
|
|
337
|
-
if (etag != null) builder.addHeader("If-Range", etag)
|
|
338
|
-
httpClient.newCall(builder.build()).execute().use { response ->
|
|
339
|
-
if (response.code == 200) {
|
|
340
|
-
throw FallbackException("server returned 200 to a Range request")
|
|
341
|
-
}
|
|
342
|
-
if (response.code != 206) {
|
|
343
|
-
throw java.io.IOException("HTTP ${response.code}")
|
|
344
|
-
}
|
|
345
|
-
val body = response.body ?: throw java.io.IOException("Empty segment body")
|
|
346
|
-
RandomAccessFile(partialFile, "rw").use { raf ->
|
|
347
|
-
raf.seek(rangeStart)
|
|
348
|
-
body.byteStream().use { input ->
|
|
349
|
-
val buffer = ByteArray(8192)
|
|
350
|
-
while (true) {
|
|
351
|
-
if (aborted.get()) throw java.io.IOException("aborted")
|
|
352
|
-
val read = input.read(buffer)
|
|
353
|
-
if (read == -1) break
|
|
354
|
-
raf.write(buffer, 0, read)
|
|
355
|
-
part.done += read
|
|
356
|
-
onBytes(read.toLong())
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
}
|