@onekeyfe/react-native-bundle-update 3.0.67 → 3.0.69
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/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt
CHANGED
|
@@ -18,8 +18,9 @@ import java.io.FileOutputStream
|
|
|
18
18
|
import java.nio.file.Files
|
|
19
19
|
import java.nio.file.Path
|
|
20
20
|
import java.nio.file.Paths
|
|
21
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
21
22
|
import java.util.concurrent.CopyOnWriteArrayList
|
|
22
|
-
import java.util.concurrent.
|
|
23
|
+
import java.util.concurrent.TimeUnit
|
|
23
24
|
import java.util.concurrent.atomic.AtomicInteger
|
|
24
25
|
import java.util.concurrent.atomic.AtomicLong
|
|
25
26
|
import java.util.zip.ZipEntry
|
|
@@ -1094,8 +1095,27 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
|
|
|
1094
1095
|
|
|
1095
1096
|
private val listeners = CopyOnWriteArrayList<BundleListener>()
|
|
1096
1097
|
private val nextListenerId = AtomicLong(1)
|
|
1097
|
-
|
|
1098
|
+
|
|
1099
|
+
// Per-destination single-flight (OCDS §5.8): one in-flight run per final
|
|
1100
|
+
// bundle path, keyed by `filePath`. `putIfAbsent` makes a second
|
|
1101
|
+
// downloadBundle for the SAME dest fail fast ("Already downloading"), while
|
|
1102
|
+
// different dests now run concurrently (the old global boolean serialized
|
|
1103
|
+
// everything). The value is the live CancelHandle so clearDownload/clearBundle
|
|
1104
|
+
// can stop the 8 workers BEFORE deleting the directory (cancel-then-delete),
|
|
1105
|
+
// preventing a worker from re-creating a just-deleted `.segN`. Being an
|
|
1106
|
+
// in-memory map, a crashed run leaves no stale lock — the entry is gone on
|
|
1107
|
+
// relaunch, giving inherent stale-lock recovery (OCDS §5.8).
|
|
1108
|
+
private val activeDownloads =
|
|
1109
|
+
ConcurrentHashMap<String, ConcurrentRangeDownloader.CancelHandle>()
|
|
1098
1110
|
private val httpClient = OkHttpClient.Builder()
|
|
1111
|
+
// OCDS §5.4 timeouts. `readTimeout` is the primary inter-byte stall
|
|
1112
|
+
// window: OkHttp raises SocketTimeoutException when no bytes arrive within
|
|
1113
|
+
// it, which the segment loop classifies transient and retries. We do NOT
|
|
1114
|
+
// set callTimeout — it bounds the WHOLE call and would cut a legitimately
|
|
1115
|
+
// slow large segment mid-stream; the overall run deadline is owned by the
|
|
1116
|
+
// shared-JS retry budget (ServiceAppUpdate), not this socket layer.
|
|
1117
|
+
.connectTimeout(30, TimeUnit.SECONDS)
|
|
1118
|
+
.readTimeout(60, TimeUnit.SECONDS)
|
|
1099
1119
|
.addNetworkInterceptor { chain ->
|
|
1100
1120
|
val req = chain.request()
|
|
1101
1121
|
if (!req.url.isHttps) {
|
|
@@ -1191,10 +1211,13 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
|
|
|
1191
1211
|
|
|
1192
1212
|
override fun downloadBundle(params: BundleDownloadParams): Promise<BundleDownloadResult> {
|
|
1193
1213
|
return Promise.async {
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1214
|
+
// Per-dest single-flight is acquired below once `filePath` is known.
|
|
1215
|
+
// Input validation (version/HTTPS) runs FIRST and is intentionally
|
|
1216
|
+
// NOT gated by the lock — a malformed request must fail the same way
|
|
1217
|
+
// whether or not a download is in flight, and must never register a
|
|
1218
|
+
// lock entry it then has to unwind.
|
|
1219
|
+
var acquiredKey: String? = null
|
|
1220
|
+
var cancelHandle: ConcurrentRangeDownloader.CancelHandle? = null
|
|
1198
1221
|
|
|
1199
1222
|
try {
|
|
1200
1223
|
val context = getContext()
|
|
@@ -1224,6 +1247,19 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
|
|
|
1224
1247
|
// "exists at filePath -> already valid" cache check above.
|
|
1225
1248
|
val partialFilePath = "$filePath.partial"
|
|
1226
1249
|
|
|
1250
|
+
// OCDS §5.8 per-destination single-flight. Register a CancelHandle for
|
|
1251
|
+
// THIS dest; a second downloadBundle for the same `filePath` fails fast
|
|
1252
|
+
// ("Already downloading"). Different dests are allowed to run
|
|
1253
|
+
// concurrently. The handle is passed to the concurrent downloader so
|
|
1254
|
+
// clearDownload/clearBundle can stop its workers before deleting files.
|
|
1255
|
+
val handle = ConcurrentRangeDownloader.CancelHandle()
|
|
1256
|
+
if (activeDownloads.putIfAbsent(filePath, handle) != null) {
|
|
1257
|
+
OneKeyLog.warn("BundleUpdate", "downloadBundle: rejected, already downloading $filePath")
|
|
1258
|
+
throw Exception("Already downloading")
|
|
1259
|
+
}
|
|
1260
|
+
acquiredKey = filePath
|
|
1261
|
+
cancelHandle = handle
|
|
1262
|
+
|
|
1227
1263
|
val result = BundleDownloadResult(
|
|
1228
1264
|
downloadedFile = filePath,
|
|
1229
1265
|
downloadUrl = downloadUrl,
|
|
@@ -1247,12 +1283,12 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
|
|
|
1247
1283
|
if (partialFile.exists()) partialFile.delete()
|
|
1248
1284
|
File("$partialFilePath.progress").delete()
|
|
1249
1285
|
for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete()
|
|
1250
|
-
// Keep
|
|
1251
|
-
//
|
|
1252
|
-
//
|
|
1253
|
-
//
|
|
1286
|
+
// Keep the per-dest lock held across the skip delay below.
|
|
1287
|
+
// The `finally` removes the lock entry AFTER this return runs,
|
|
1288
|
+
// so the lock spans the whole sleep — closing the ~1s window
|
|
1289
|
+
// where a second downloadBundle for this dest could otherwise
|
|
1290
|
+
// pass the single-flight guard and run concurrently.
|
|
1254
1291
|
Thread.sleep(1000)
|
|
1255
|
-
isDownloading.set(false)
|
|
1256
1292
|
sendEvent("update/complete")
|
|
1257
1293
|
return@async result
|
|
1258
1294
|
} else {
|
|
@@ -1284,7 +1320,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
|
|
|
1284
1320
|
val concurrentOutcome = ConcurrentRangeDownloader(
|
|
1285
1321
|
httpClient = httpClient,
|
|
1286
1322
|
log = { msg -> OneKeyLog.info("BundleUpdate", msg) },
|
|
1287
|
-
).download(downloadUrl, partialFilePath) { transferred, total ->
|
|
1323
|
+
).download(downloadUrl, partialFilePath, cancelHandle) { transferred, total ->
|
|
1288
1324
|
if (total > 0) {
|
|
1289
1325
|
val p = ((transferred * 100) / total).toInt().coerceIn(0, 100)
|
|
1290
1326
|
val prev = concurrentProgress.get()
|
|
@@ -1338,7 +1374,9 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
|
|
|
1338
1374
|
if (downloadedFile.exists()) downloadedFile.delete()
|
|
1339
1375
|
if (partialFile.renameTo(downloadedFile) && verifyBundleSHA256(filePath, sha256)) {
|
|
1340
1376
|
OneKeyLog.info("BundleUpdate", "downloadBundle: recovered crashed-before-rename bundle, skipping download")
|
|
1341
|
-
|
|
1377
|
+
// The `finally` removes the per-dest lock after this
|
|
1378
|
+
// return; the lock therefore spans the skip delay,
|
|
1379
|
+
// same as the existing-file cache-hit path above.
|
|
1342
1380
|
Thread.sleep(1000)
|
|
1343
1381
|
sendEvent("update/complete")
|
|
1344
1382
|
return@async result
|
|
@@ -1545,7 +1583,26 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
|
|
|
1545
1583
|
// sanitizeErrorMessageForEvent.
|
|
1546
1584
|
OneKeyLog.error("BundleUpdate", "downloadBundle: failed: ${e.javaClass.simpleName}: ${e.message}")
|
|
1547
1585
|
val sanitized = sanitizeErrorMessageForEvent(e)
|
|
1548
|
-
|
|
1586
|
+
// An INTENTIONAL cancel (clearDownload/clearBundle flipped THIS
|
|
1587
|
+
// run's CancelHandle, which aborts the workers → they throw
|
|
1588
|
+
// IOException("aborted")) is NOT a download failure — the caller
|
|
1589
|
+
// deliberately tore the run down. Before the cancel-then-delete
|
|
1590
|
+
// path existed, clearing never aborted an in-flight run, so no
|
|
1591
|
+
// `update/error` was emitted; preserve that. Detect it off the
|
|
1592
|
+
// handle's `aborted` flag rather than the message, because
|
|
1593
|
+
// sanitizeErrorMessageForEvent collapses "aborted" to the generic
|
|
1594
|
+
// "IO_IOException" tag and would be indistinguishable from a real
|
|
1595
|
+
// I/O failure.
|
|
1596
|
+
val intentionallyCancelled = cancelHandle?.aborted?.get() == true
|
|
1597
|
+
// A duplicate-dest single-flight rejection ("Already downloading")
|
|
1598
|
+
// is NOT a failure of any in-flight download — it means another
|
|
1599
|
+
// run already owns this dest. The original global-boolean guard
|
|
1600
|
+
// threw this BEFORE the try, so no `update/error` was emitted;
|
|
1601
|
+
// preserve that so listeners don't observe a spurious error for the
|
|
1602
|
+
// rejected caller while the real download is still progressing.
|
|
1603
|
+
if (sanitized != "Already downloading" && !intentionallyCancelled) {
|
|
1604
|
+
sendEvent("update/error", message = sanitized)
|
|
1605
|
+
}
|
|
1549
1606
|
// Rethrow with the same sanitized message so the Promise
|
|
1550
1607
|
// rejection surfacing to JS carries no /data/user/<u>/<pkg>/
|
|
1551
1608
|
// paths. Without this rewrap, FileNotFoundException etc.
|
|
@@ -1555,7 +1612,16 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
|
|
|
1555
1612
|
// native crash reporter) still sees the full chain.
|
|
1556
1613
|
throw Exception(sanitized, e)
|
|
1557
1614
|
} finally {
|
|
1558
|
-
|
|
1615
|
+
// Release THIS run's per-dest single-flight lock. Keyed remove
|
|
1616
|
+
// (only if we actually acquired it — input-validation failures
|
|
1617
|
+
// throw before acquisition, leaving acquiredKey null). The
|
|
1618
|
+
// value-checked remove avoids deleting an entry a later run for
|
|
1619
|
+
// the same dest may have re-registered after a concurrent cancel.
|
|
1620
|
+
val key = acquiredKey
|
|
1621
|
+
val handle = cancelHandle
|
|
1622
|
+
if (key != null && handle != null) {
|
|
1623
|
+
activeDownloads.remove(key, handle)
|
|
1624
|
+
}
|
|
1559
1625
|
}
|
|
1560
1626
|
}
|
|
1561
1627
|
}
|
|
@@ -1784,10 +1850,28 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
|
|
|
1784
1850
|
}
|
|
1785
1851
|
}
|
|
1786
1852
|
|
|
1853
|
+
// OCDS §5.8 cancel-then-delete. Stop every in-flight concurrent run's worker
|
|
1854
|
+
// pool (flip its abort flag + shutdownNow) BEFORE the caller deletes the
|
|
1855
|
+
// download directory. Without this, a live segment worker could re-create a
|
|
1856
|
+
// `.segN` we just deleted (CRD streams into `.segN` via append). Removing each
|
|
1857
|
+
// entry here also makes the dest immediately re-acquirable. The in-flight
|
|
1858
|
+
// download's own `finally` does a value-checked remove, so racing it is safe.
|
|
1859
|
+
private fun cancelAllActiveDownloads() {
|
|
1860
|
+
val keys = activeDownloads.keys.toList()
|
|
1861
|
+
for (key in keys) {
|
|
1862
|
+
activeDownloads.remove(key)?.let {
|
|
1863
|
+
OneKeyLog.info("BundleUpdate", "cancelling in-flight download: $key")
|
|
1864
|
+
it.cancel()
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1787
1869
|
override fun clearDownload(): Promise<Unit> {
|
|
1788
1870
|
return Promise.async {
|
|
1789
1871
|
OneKeyLog.info("BundleUpdate", "clearDownload: clearing download directory...")
|
|
1790
1872
|
val context = getContext()
|
|
1873
|
+
// Cancel-then-delete: stop live workers before removing files.
|
|
1874
|
+
cancelAllActiveDownloads()
|
|
1791
1875
|
val downloadDir = File(BundleUpdateStoreAndroid.getDownloadBundleDir(context))
|
|
1792
1876
|
if (downloadDir.exists()) {
|
|
1793
1877
|
BundleUpdateStoreAndroid.deleteDir(downloadDir)
|
|
@@ -1795,7 +1879,6 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
|
|
|
1795
1879
|
} else {
|
|
1796
1880
|
OneKeyLog.info("BundleUpdate", "clearDownload: download directory does not exist, skipping")
|
|
1797
1881
|
}
|
|
1798
|
-
isDownloading.set(false)
|
|
1799
1882
|
OneKeyLog.info("BundleUpdate", "clearDownload: completed")
|
|
1800
1883
|
}
|
|
1801
1884
|
}
|
|
@@ -1805,6 +1888,8 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
|
|
|
1805
1888
|
return Promise.async {
|
|
1806
1889
|
OneKeyLog.info("BundleUpdate", "clearBundle: clearing download and bundle directories...")
|
|
1807
1890
|
val context = getContext()
|
|
1891
|
+
// Cancel-then-delete: stop live workers before removing files.
|
|
1892
|
+
cancelAllActiveDownloads()
|
|
1808
1893
|
// Clear download directory
|
|
1809
1894
|
val downloadDir = File(BundleUpdateStoreAndroid.getDownloadBundleDir(context))
|
|
1810
1895
|
if (downloadDir.exists()) {
|
|
@@ -1817,7 +1902,6 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
|
|
|
1817
1902
|
BundleUpdateStoreAndroid.deleteDir(bundleDir)
|
|
1818
1903
|
OneKeyLog.info("BundleUpdate", "clearBundle: bundle directory deleted")
|
|
1819
1904
|
}
|
|
1820
|
-
isDownloading.set(false)
|
|
1821
1905
|
OneKeyLog.info("BundleUpdate", "clearBundle: completed")
|
|
1822
1906
|
}
|
|
1823
1907
|
}
|
|
@@ -985,6 +985,11 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
|
|
|
985
985
|
/// Read by the background-snapshot handler so it knows where to drop the
|
|
986
986
|
/// `.resume` sidecar.
|
|
987
987
|
private var activeDownloadFilePath: String?
|
|
988
|
+
/// OCDS §5.7: the highest progress percent already reported to JS for the
|
|
989
|
+
/// in-flight download. Used to floor single-stream progress so the bar never
|
|
990
|
+
/// moves backward at the concurrent→single-stream seam. Guarded by
|
|
991
|
+
/// `stateQueue`. Reset to 0 only on a genuine restart (permanent fallback).
|
|
992
|
+
private var lastReportedProgress: Int = 0
|
|
988
993
|
private var didEnterBackgroundObserver: NSObjectProtocol?
|
|
989
994
|
|
|
990
995
|
override init() {
|
|
@@ -1228,7 +1233,13 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
|
|
|
1228
1233
|
// below; the range downloader keeps its `.segN` files for the next
|
|
1229
1234
|
// attempt (its background session resumes them by taskId).
|
|
1230
1235
|
self.sendEvent(type: "update/start")
|
|
1231
|
-
|
|
1236
|
+
// §5.7: a fresh downloadBundle starts the progress floor at 0; the
|
|
1237
|
+
// concurrent path raises it as bytes land (below), and a transient
|
|
1238
|
+
// single-stream fallback keeps it.
|
|
1239
|
+
self.stateQueue.sync {
|
|
1240
|
+
self.activeDownloadFilePath = filePath
|
|
1241
|
+
self.lastReportedProgress = 0
|
|
1242
|
+
}
|
|
1232
1243
|
|
|
1233
1244
|
// Stable, unique task id for this bundle. Same value across retry
|
|
1234
1245
|
// attempts so the range downloader can resume its `.segN` files, and
|
|
@@ -1241,31 +1252,88 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
|
|
|
1241
1252
|
// "fallback" are surfaced by this method's own sendEvent calls and
|
|
1242
1253
|
// the download outcome below, so we don't double-emit them here.
|
|
1243
1254
|
let progressListenerId = RangeDownloader.shared.addListener { [weak self] event in
|
|
1244
|
-
guard
|
|
1255
|
+
guard let self = self,
|
|
1256
|
+
event.channel.stringValue == DownloadChannel.bundle.stringValue,
|
|
1245
1257
|
event.taskId == rangeTaskId,
|
|
1246
1258
|
event.type == "progress" else { return }
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1259
|
+
// §5.7: record the concurrent floor so a later single-stream
|
|
1260
|
+
// transient fallback resumes the bar from here, not from 0.
|
|
1261
|
+
let floored: Int = self.stateQueue.sync {
|
|
1262
|
+
let clamped = max(self.lastReportedProgress, Int(event.progress))
|
|
1263
|
+
self.lastReportedProgress = clamped
|
|
1264
|
+
return clamped
|
|
1265
|
+
}
|
|
1266
|
+
self.sendEvent(type: "update/downloading", progress: floored)
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// A `.fallback` outcome covers two very different situations whose
|
|
1270
|
+
// recovery must NOT be the same:
|
|
1271
|
+
// • Permanent — Range unsupported / file too small / server 200 /
|
|
1272
|
+
// SHA mismatch. The downloader has already cleaned its `.segN`
|
|
1273
|
+
// segments, so none survive; single-stream is the only way.
|
|
1274
|
+
// • Transient — a network drop or app suspend interrupted a segment
|
|
1275
|
+
// mid-flight ("segment N missing/truncated"). The downloader
|
|
1276
|
+
// RETAINS the segments that DID complete; the next concurrent
|
|
1277
|
+
// attempt resumes only the missing one(s).
|
|
1278
|
+
// This method used to `discardArtifacts` on EVERY fallback and restart
|
|
1279
|
+
// single-stream from byte 0, so any background/lock/network blip threw
|
|
1280
|
+
// away tens of MB and the user saw progress reset to 0. We now retry
|
|
1281
|
+
// the concurrent path while resumable segments survive, and fall back
|
|
1282
|
+
// to single-stream only as a last resort — WITHOUT deleting those
|
|
1283
|
+
// segments, so a later attempt can still resume them.
|
|
1284
|
+
let maxConcurrentAttempts = 3
|
|
1285
|
+
var concurrentAttempt = 0
|
|
1286
|
+
// OCDS §4: consume the EXPLICIT typed class from the in-process core
|
|
1287
|
+
// (`RangeDownloadClass`) instead of inferring transient-vs-permanent
|
|
1288
|
+
// from whether `.segN` happens to survive on disk. The wire enum stays
|
|
1289
|
+
// `completed | fallback` until nitrogen regen; we call the in-process
|
|
1290
|
+
// `download(...)` directly, so we get the precise class here.
|
|
1291
|
+
var rangeClass: RangeDownloadClass = .fallbackPermanent
|
|
1292
|
+
var rangeReason: String?
|
|
1293
|
+
while true {
|
|
1294
|
+
concurrentAttempt += 1
|
|
1295
|
+
(rangeClass, _, rangeReason, _) = await RangeDownloader.shared.download(
|
|
1296
|
+
channel: .bundle,
|
|
1297
|
+
taskId: rangeTaskId,
|
|
1298
|
+
urlString: downloadUrl,
|
|
1299
|
+
filePath: filePath,
|
|
1300
|
+
// SHA256 is verified by this module right after assembly (below),
|
|
1301
|
+
// so we don't double-hash inside the range downloader.
|
|
1302
|
+
expectedSha256: nil,
|
|
1303
|
+
segmentCount: nil,
|
|
1304
|
+
minConcurrentBytes: nil
|
|
1305
|
+
)
|
|
1306
|
+
if rangeClass == .completed {
|
|
1307
|
+
break // completed
|
|
1308
|
+
}
|
|
1309
|
+
// §4: a Transient class is resumable — retry the concurrent path.
|
|
1310
|
+
// The core keeps `.segN`; the next call resumes only what's missing.
|
|
1311
|
+
if rangeClass == .fallbackTransient,
|
|
1312
|
+
concurrentAttempt < maxConcurrentAttempts {
|
|
1313
|
+
OneKeyLog.info("BundleUpdate", "downloadBundle: concurrent transient fallback (\(rangeReason ?? "")), resuming surviving segments (attempt \(concurrentAttempt)/\(maxConcurrentAttempts))")
|
|
1314
|
+
// Brief backoff so a flapping network / suspend settles before
|
|
1315
|
+
// we re-probe and resume the missing segment(s).
|
|
1316
|
+
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
|
1317
|
+
continue
|
|
1318
|
+
}
|
|
1319
|
+
break // permanent, or transient retries exhausted
|
|
1320
|
+
}
|
|
1261
1321
|
RangeDownloader.shared.removeListener(progressListenerId)
|
|
1262
1322
|
|
|
1263
|
-
if
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1323
|
+
if rangeClass != .completed {
|
|
1324
|
+
if rangeClass == .fallbackTransient {
|
|
1325
|
+
// §4 Transient that outlived our concurrent retries: KEEP the
|
|
1326
|
+
// segments so the next downloadBundle call's concurrent path can
|
|
1327
|
+
// resume them. Single-stream is just this call's safety net, and
|
|
1328
|
+
// its progress is floored (below) so the bar never drops to 0.
|
|
1329
|
+
OneKeyLog.info("BundleUpdate", "downloadBundle: concurrent transient fallback persists (\(rangeReason ?? "")), using single-stream (segments preserved for resume)")
|
|
1330
|
+
} else {
|
|
1331
|
+
// §4 Permanent (segments already cleaned by the downloader):
|
|
1332
|
+
// clear any residue and hand off to single-stream from a clean
|
|
1333
|
+
// slot; this is a genuine restart, so the progress floor resets.
|
|
1334
|
+
OneKeyLog.info("BundleUpdate", "downloadBundle: concurrent permanent fallback (\(rangeReason ?? "")), using single-stream")
|
|
1335
|
+
RangeDownloader.shared.discardArtifacts(filePath: filePath)
|
|
1336
|
+
}
|
|
1269
1337
|
// fall through to the single-stream path below
|
|
1270
1338
|
} else {
|
|
1271
1339
|
OneKeyLog.info("BundleUpdate", "downloadBundle: concurrent finished, verifying SHA256...")
|
|
@@ -1312,8 +1380,24 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
|
|
|
1312
1380
|
throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Download delegate not initialized"])
|
|
1313
1381
|
}
|
|
1314
1382
|
delegate.reset()
|
|
1383
|
+
// OCDS §5.7: reported progress is monotonic non-decreasing within a
|
|
1384
|
+
// run, and resets only on a GENUINE restart. The single-stream
|
|
1385
|
+
// delegate emits its own 0→100, which would jump the bar backward at
|
|
1386
|
+
// the concurrent→single-stream seam. Floor it: a transient fallback
|
|
1387
|
+
// KEEPS the floor (the concurrent path already reported ~N%), a
|
|
1388
|
+
// permanent fallback is a genuine restart so the floor resets to 0.
|
|
1389
|
+
let progressFloor = (rangeClass == .fallbackTransient)
|
|
1390
|
+
? self.stateQueue.sync { self.lastReportedProgress }
|
|
1391
|
+
: 0
|
|
1392
|
+
self.stateQueue.sync { self.lastReportedProgress = progressFloor }
|
|
1315
1393
|
delegate.onProgress = { [weak self] progress in
|
|
1316
|
-
self
|
|
1394
|
+
guard let self = self else { return }
|
|
1395
|
+
let floored: Int = self.stateQueue.sync {
|
|
1396
|
+
let clamped = max(self.lastReportedProgress, progress)
|
|
1397
|
+
self.lastReportedProgress = clamped
|
|
1398
|
+
return clamped
|
|
1399
|
+
}
|
|
1400
|
+
self.sendEvent(type: "update/downloading", progress: floored)
|
|
1317
1401
|
}
|
|
1318
1402
|
|
|
1319
1403
|
// Anchor for the background-snapshot handler. Set BEFORE
|
|
@@ -1421,6 +1505,11 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
|
|
|
1421
1505
|
throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle SHA256 verification failed: \(reason)"])
|
|
1422
1506
|
}
|
|
1423
1507
|
|
|
1508
|
+
// A verified single-stream file is authoritative — drop any
|
|
1509
|
+
// concurrent `.segN` segments we intentionally kept across a
|
|
1510
|
+
// transient fallback so they don't linger as orphans.
|
|
1511
|
+
RangeDownloader.shared.discardArtifacts(filePath: filePath)
|
|
1512
|
+
|
|
1424
1513
|
self.sendEvent(type: "update/complete")
|
|
1425
1514
|
OneKeyLog.info("BundleUpdate", "downloadBundle: completed successfully, appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
|
|
1426
1515
|
return result
|