@onekeyfe/react-native-app-update 3.0.64 → 3.0.66

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.
@@ -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"
@@ -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
- private fun getApkCacheDir(): File {
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.cacheDir, "apks")
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(getApkCacheDir(), stripped)
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
- val body = response.body ?: return null
155
- BufferedReader(InputStreamReader(body.byteStream())).use { reader ->
156
- var line: String?
157
- while (reader.readLine().also { line = it } != null) {
158
- content.append(line).append("\n")
159
- if (content.length > maxAscSize) return null
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
- // A concurrent (multi-range) partial is pre-allocated to the FULL
524
- // size up front (RandomAccessFile.setLength(total)) and tracks the
525
- // real, durably-written cursor only in its sidecar
526
- // "<partial>.progress" manifest the .partial itself is zero-filled
527
- // past the real data. The size-based classification below is blind
528
- // to that: it would see partialSize == expectedSize and try to
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 own the file: it resumes from the manifest (or
534
- // returns FALLBACK and hands the bytes back to single-stream).
535
- // The path must match exactly what ConcurrentRangeDownloader writes:
536
- // File("$partialFilePath.progress").
537
- val hasConcurrentManifest = buildFile("$partialFilePath.progress").exists()
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() && !hasConcurrentManifest) {
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
- ).download(url, partialFilePath) { transferred, total ->
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
- OneKeyLog.warn("AppUpdate", "downloadAPK: 206 Content-Range start mismatch (header='${response.header("Content-Range")}', requested=$partialBytes); treating as full restart")
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
- partialBytes = 0L
750
- serverWillResume = false
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
- val response = client.newCall(request).execute()
870
-
871
- if (!response.isSuccessful) {
872
- OneKeyLog.error("AppUpdate", "downloadASC: HTTP error, statusCode=${response.code}")
873
- throw Exception(response.code.toString())
874
- }
875
-
876
- OneKeyLog.info("AppUpdate", "downloadASC: HTTP 200, reading ASC content...")
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
- val content = StringBuilder()
879
- val maxAscSize = 10 * 1024 // 10 KB max for ASC files
880
- val body = response.body ?: throw Exception("Empty ASC response body")
881
- BufferedReader(InputStreamReader(body.byteStream())).use { reader ->
882
- var line: String?
883
- while (reader.readLine().also { line = it } != null) {
884
- content.append(line).append("\n")
885
- if (content.length > maxAscSize) {
886
- OneKeyLog.error("AppUpdate", "downloadASC: ASC file exceeds max size ($maxAscSize bytes)")
887
- throw Exception("ASC file exceeds maximum allowed size")
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
- val ascContent = content.toString()
893
- if (ascContent.isEmpty()) {
894
- OneKeyLog.error("AppUpdate", "downloadASC: ASC content is empty")
895
- throw Exception("Empty ASC file")
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
- val apkDir = File(context.cacheDir, "apks")
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-app-update",
3
- "version": "3.0.64",
3
+ "version": "3.0.66",
4
4
  "description": "react-native-app-update",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -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
- }