@onekeyfe/react-native-bundle-update 3.0.28 → 3.0.30

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.
@@ -404,7 +404,34 @@ object BundleUpdateStoreAndroid {
404
404
  }
405
405
  }
406
406
 
407
+ private val lastSHA256Failure = ThreadLocal<String?>()
408
+
409
+ /**
410
+ * Subtype of the most recent calculateSHA256 failure on this thread, or
411
+ * null if the last call succeeded. Surfaces the specific reason —
412
+ * FILE_NOT_FOUND / FILE_TRUNCATED / OOM / IO_<class> /
413
+ * UNEXPECTED_<class> — so analytics can split the previously opaque
414
+ * "Failed to calculate SHA256" bucket (mixpanel: 91.3 percent of
415
+ * verifyPackage failures) into actionable categories.
416
+ *
417
+ * Note: 0-byte files are NOT treated as a failure. They hash to the
418
+ * well-known empty-content SHA-256 and the caller's expected/actual
419
+ * comparison handles legitimate vs. corrupt-empty cases. Rejecting
420
+ * empty files here would make any OTA bundle that legitimately
421
+ * contains a 0-byte file (touched marker, blank locale fallback)
422
+ * fail validateAllFilesInDir / validateWebEmbedSha256 / launch entry
423
+ * verification — all of which share this calculator.
424
+ */
425
+ fun lastSHA256FailureReason(): String? = lastSHA256Failure.get()
426
+
407
427
  fun calculateSHA256(filePath: String): String? {
428
+ lastSHA256Failure.set(null)
429
+ val file = File(filePath)
430
+ if (!file.exists()) {
431
+ lastSHA256Failure.set("FILE_NOT_FOUND")
432
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: file not found: $filePath")
433
+ return null
434
+ }
408
435
  return try {
409
436
  val digest = MessageDigest.getInstance("SHA-256")
410
437
  BufferedInputStream(FileInputStream(filePath)).use { bis ->
@@ -415,8 +442,29 @@ object BundleUpdateStoreAndroid {
415
442
  }
416
443
  }
417
444
  bytesToHex(digest.digest())
445
+ } catch (e: java.io.FileNotFoundException) {
446
+ lastSHA256Failure.set("FILE_DISAPPEARED")
447
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: file disappeared during read: ${e.message}")
448
+ null
449
+ } catch (e: java.io.EOFException) {
450
+ lastSHA256Failure.set("FILE_TRUNCATED")
451
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: truncated file: ${e.message}")
452
+ null
453
+ } catch (e: SecurityException) {
454
+ lastSHA256Failure.set("PERMISSION_DENIED")
455
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: permission denied: ${e.message}")
456
+ null
457
+ } catch (e: OutOfMemoryError) {
458
+ lastSHA256Failure.set("OOM")
459
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: OutOfMemoryError on ${file.length()} bytes")
460
+ null
461
+ } catch (e: java.io.IOException) {
462
+ lastSHA256Failure.set("IO_${e.javaClass.simpleName}")
463
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: ${e.javaClass.simpleName}: ${e.message}")
464
+ null
418
465
  } catch (e: Exception) {
419
- OneKeyLog.error("BundleUpdate", "Error calculating SHA256: ${e.message}")
466
+ lastSHA256Failure.set("UNEXPECTED_${e.javaClass.simpleName}")
467
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: ${e.javaClass.simpleName}: ${e.message}")
420
468
  null
421
469
  }
422
470
  }
@@ -455,8 +503,13 @@ object BundleUpdateStoreAndroid {
455
503
  }
456
504
  }
457
505
  } catch (e: Exception) {
458
- OneKeyLog.error("BundleUpdate", "Error parsing metadata JSON: ${e.message}")
459
- throw Exception("Failed to parse metadata.json: ${e.message}")
506
+ // org.json's exception messages occasionally embed file paths or
507
+ // partial JSON content. Keep the rich detail in OneKeyLog (local
508
+ // only), but throw a class-tag-only message so the JS analytics
509
+ // layer cannot reflect arbitrary inner content. Mirrors the
510
+ // SHA256_<reason>/IO_<class> convention used elsewhere.
511
+ OneKeyLog.error("BundleUpdate", "Error parsing metadata JSON: ${e.javaClass.simpleName}: ${e.message}")
512
+ throw Exception("Failed to parse metadata.json: IO_${e.javaClass.simpleName}")
460
513
  }
461
514
  if (metadata.isEmpty()) {
462
515
  throw Exception("metadata.json is empty or contains no file entries")
@@ -1201,6 +1254,35 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1201
1254
  }
1202
1255
  }
1203
1256
 
1257
+ /**
1258
+ * Returns a low-cardinality, path-free tag describing why a download
1259
+ * failed. Used in the JS-facing `update/error` event so listeners
1260
+ * never observe Android's `/data/data/<pkg>/...` or `/data/user/<u>/<pkg>/...`
1261
+ * paths that FileNotFoundException / IOException embed in `e.message`.
1262
+ *
1263
+ * Recognized payloads (same shape extractUpdateErrorCode parses):
1264
+ * - "Bundle SHA256 verification failed: <REASON>" → preserved verbatim;
1265
+ * JS extractor maps to SHA256_<REASON>.
1266
+ * - "HTTP <code>" / "HTTP 416 ..." → preserved verbatim; maps to HTTP_<code>.
1267
+ * - "Already downloading" / "Invalid version string format" /
1268
+ * "Bundle download URL must use HTTPS" → preserved verbatim; the
1269
+ * hooks unrecoverable-list matches them by exact substring.
1270
+ * - Anything else → "IO_<exceptionClassName>" so the JS extractor
1271
+ * splits the bucket on exception class without leaking the message.
1272
+ */
1273
+ private fun sanitizeErrorMessageForEvent(e: Exception): String {
1274
+ val msg = e.message ?: return "IO_${e.javaClass.simpleName}"
1275
+ if (msg.startsWith("Bundle SHA256 verification failed:")) return msg
1276
+ if (msg.startsWith("HTTP ")) return msg
1277
+ if (msg == "Already downloading" ||
1278
+ msg == "Invalid version string format" ||
1279
+ msg == "Bundle download URL must use HTTPS" ||
1280
+ msg == "Empty response body" ||
1281
+ msg == "Failed to finalize download"
1282
+ ) return msg
1283
+ return "IO_${e.javaClass.simpleName}"
1284
+ }
1285
+
1204
1286
  override fun addDownloadListener(callback: (BundleDownloadEvent) -> Unit): Double {
1205
1287
  val id = nextListenerId.getAndIncrement().toDouble()
1206
1288
  listeners.add(BundleListener(id, callback))
@@ -1274,6 +1356,11 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1274
1356
 
1275
1357
  val fileName = "$appVersion-$bundleVersion.zip"
1276
1358
  val filePath = File(BundleUpdateStoreAndroid.getDownloadBundleDir(context), fileName).absolutePath
1359
+ // Resume support: download to <filename>.partial; rename to <filename>
1360
+ // only after the full transfer + SHA256 verify pass. Mirrors the
1361
+ // Desktop convention so a corrupt completion can never poison the
1362
+ // "exists at filePath -> already valid" cache check above.
1363
+ val partialFilePath = "$filePath.partial"
1277
1364
 
1278
1365
  val result = BundleDownloadResult(
1279
1366
  downloadedFile = filePath,
@@ -1286,6 +1373,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1286
1373
  OneKeyLog.info("BundleUpdate", "downloadBundle: filePath=$filePath")
1287
1374
 
1288
1375
  val downloadedFile = File(filePath)
1376
+ val partialFile = File(partialFilePath)
1289
1377
  if (downloadedFile.exists()) {
1290
1378
  OneKeyLog.info("BundleUpdate", "downloadBundle: file already exists, verifying SHA256...")
1291
1379
  if (verifyBundleSHA256(filePath, sha256)) {
@@ -1297,47 +1385,149 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1297
1385
  } else {
1298
1386
  OneKeyLog.warn("BundleUpdate", "downloadBundle: existing file SHA256 mismatch, re-downloading")
1299
1387
  downloadedFile.delete()
1388
+ // Stale completed file invalidates any partial too.
1389
+ if (partialFile.exists()) partialFile.delete()
1390
+ }
1391
+ }
1392
+
1393
+ // Resume: if a partial from a previous run exists and is smaller
1394
+ // than the expected size, send `Range: bytes=<offset>-` so the
1395
+ // server fills in the rest. If the partial is exactly the
1396
+ // expected size it's a process-killed-just-before-rename case
1397
+ // (full body on disk but never promoted): try SHA verify
1398
+ // before discarding so we save a full re-download.
1399
+ val expectedSize = if (params.fileSize > 0) params.fileSize.toLong() else 0L
1400
+ var partialBytes = 0L
1401
+ if (partialFile.exists()) {
1402
+ val partialSize = partialFile.length()
1403
+ when {
1404
+ expectedSize > 0 && partialSize == expectedSize -> {
1405
+ OneKeyLog.info("BundleUpdate", "downloadBundle: partial matches expected size ($partialSize), trying promote+verify")
1406
+ if (downloadedFile.exists()) downloadedFile.delete()
1407
+ if (partialFile.renameTo(downloadedFile) && verifyBundleSHA256(filePath, sha256)) {
1408
+ OneKeyLog.info("BundleUpdate", "downloadBundle: recovered crashed-before-rename bundle, skipping download")
1409
+ isDownloading.set(false)
1410
+ Thread.sleep(1000)
1411
+ sendEvent("update/complete")
1412
+ return@async result
1413
+ } else {
1414
+ OneKeyLog.warn("BundleUpdate", "downloadBundle: promote+verify failed, discarding both files")
1415
+ if (downloadedFile.exists()) downloadedFile.delete()
1416
+ // partialFile is gone (renamed); nothing to delete
1417
+ }
1418
+ }
1419
+ expectedSize > 0 && partialSize > expectedSize -> {
1420
+ OneKeyLog.warn("BundleUpdate", "downloadBundle: stale partial (>expected), discarding: $partialSize/$expectedSize")
1421
+ partialFile.delete()
1422
+ }
1423
+ partialSize > 0 -> {
1424
+ partialBytes = partialSize
1425
+ OneKeyLog.info("BundleUpdate", "downloadBundle: resuming from $partialBytes bytes (expected=$expectedSize)")
1426
+ }
1427
+ else -> partialFile.delete()
1300
1428
  }
1301
1429
  }
1302
1430
 
1303
1431
  sendEvent("update/start")
1304
- OneKeyLog.info("BundleUpdate", "downloadBundle: starting download...")
1432
+ OneKeyLog.info("BundleUpdate", "downloadBundle: starting download (resume=${partialBytes > 0})...")
1433
+
1434
+ val requestBuilder = Request.Builder().url(downloadUrl)
1435
+ if (partialBytes > 0) {
1436
+ requestBuilder.addHeader("Range", "bytes=$partialBytes-")
1437
+ }
1438
+ val response = httpClient.newCall(requestBuilder.build()).execute()
1439
+
1440
+ // 416 Range Not Satisfiable: server says our offset is past the
1441
+ // file length. Two sub-cases distinguishable from
1442
+ // `Content-Range: bytes */<total>`:
1443
+ // (a) total == partialBytes → file is exactly complete on
1444
+ // server; our partial is the whole bundle and just
1445
+ // needs SHA verify + rename. Recover instead of wipe.
1446
+ // (b) anything else → partial is corrupt or bundle changed.
1447
+ // Wipe and bubble up.
1448
+ if (response.code == 416) {
1449
+ val contentRange = response.header("Content-Range")
1450
+ response.close()
1451
+ val totalFromHeader = contentRange
1452
+ ?.let { Regex("""bytes\s+\*\s*/\s*(\d+)""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() }
1453
+ if (totalFromHeader != null && totalFromHeader == partialBytes && partialFile.exists()) {
1454
+ OneKeyLog.info("BundleUpdate", "downloadBundle: HTTP 416 with total==$totalFromHeader matches partial, attempting promote+verify")
1455
+ if (downloadedFile.exists()) downloadedFile.delete()
1456
+ if (partialFile.renameTo(downloadedFile) && verifyBundleSHA256(filePath, sha256)) {
1457
+ OneKeyLog.info("BundleUpdate", "downloadBundle: 416 recovery succeeded, skipping download")
1458
+ sendEvent("update/complete")
1459
+ return@async result
1460
+ }
1461
+ OneKeyLog.warn("BundleUpdate", "downloadBundle: 416 recovery failed verify, discarding")
1462
+ if (downloadedFile.exists()) downloadedFile.delete()
1463
+ }
1464
+ OneKeyLog.warn("BundleUpdate", "downloadBundle: HTTP 416 (range not satisfiable), discarding partial and failing this attempt")
1465
+ if (partialFile.exists()) partialFile.delete()
1466
+ // Don't pre-emit update/error here; the outer catch is the
1467
+ // single source of error events. sanitizeErrorMessageForEvent
1468
+ // recognizes "HTTP " prefix and forwards this string verbatim.
1469
+ throw Exception("HTTP 416 (range not satisfiable)")
1470
+ }
1305
1471
 
1306
- val request = Request.Builder().url(downloadUrl).build()
1307
- val response = httpClient.newCall(request).execute()
1472
+ val expectsResume = partialBytes > 0
1473
+ val isPartialResponse = response.code == 206
1308
1474
 
1309
- if (!response.isSuccessful) {
1475
+ if (!response.isSuccessful || (response.code != 200 && response.code != 206)) {
1310
1476
  OneKeyLog.error("BundleUpdate", "downloadBundle: HTTP error, statusCode=${response.code}")
1311
- sendEvent("update/error", message = "HTTP ${response.code}")
1477
+ response.close()
1478
+ // outer catch is the single source of update/error events.
1312
1479
  throw Exception("HTTP ${response.code}")
1313
1480
  }
1314
1481
 
1482
+ // Server can ignore `Range` and return the full body with 200; in
1483
+ // that case our partial is meaningless — restart fresh.
1484
+ if (expectsResume && !isPartialResponse) {
1485
+ OneKeyLog.warn("BundleUpdate", "downloadBundle: requested Range but server returned 200, restarting from scratch")
1486
+ if (partialFile.exists()) partialFile.delete()
1487
+ partialBytes = 0L
1488
+ }
1489
+
1315
1490
  val body = response.body ?: throw Exception("Empty response body")
1316
- val fileSize = if (params.fileSize > 0) params.fileSize.toLong() else body.contentLength()
1317
- OneKeyLog.info("BundleUpdate", "downloadBundle: HTTP 200, contentLength=$fileSize, downloading...")
1491
+ val contentLength = body.contentLength()
1492
+ // Total size of the whole resource (not the slice). On 206 prefer
1493
+ // Content-Range's "/total" tail; fall back to partial+contentLength.
1494
+ val totalSize: Long = if (isPartialResponse) {
1495
+ val contentRange = response.header("Content-Range")
1496
+ val parsedTotal = contentRange
1497
+ ?.let { Regex("""bytes \d+-\d+/(\d+)""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() }
1498
+ parsedTotal ?: (partialBytes + contentLength.coerceAtLeast(0L))
1499
+ } else {
1500
+ if (contentLength > 0) contentLength else expectedSize
1501
+ }
1502
+ OneKeyLog.info(
1503
+ "BundleUpdate",
1504
+ "downloadBundle: HTTP ${response.code}, contentLength=$contentLength, totalSize=$totalSize, partialBytes=$partialBytes, downloading..."
1505
+ )
1318
1506
 
1319
1507
  // Ensure parent directory exists before writing
1320
- val parentDir = File(filePath).parentFile
1508
+ val parentDir = File(partialFilePath).parentFile
1321
1509
  if (parentDir != null && !parentDir.exists()) {
1322
1510
  parentDir.mkdirs()
1323
1511
  OneKeyLog.info("BundleUpdate", "downloadBundle: created parent directory: ${parentDir.absolutePath}")
1324
1512
  }
1325
1513
 
1326
- var totalBytesRead = 0L
1514
+ // Append iff server granted us a 206 partial; otherwise overwrite.
1515
+ val appendMode = isPartialResponse
1516
+ var totalBytesRead = if (isPartialResponse) partialBytes else 0L
1327
1517
  body.byteStream().use { inputStream ->
1328
- FileOutputStream(filePath).use { outputStream ->
1518
+ FileOutputStream(partialFilePath, appendMode).use { outputStream ->
1329
1519
  val buffer = ByteArray(8192)
1330
1520
  var bytesRead: Int
1331
1521
 
1332
- var prevProgress = 0
1522
+ var prevProgress = -1
1333
1523
  while (inputStream.read(buffer).also { bytesRead = it } != -1) {
1334
1524
  outputStream.write(buffer, 0, bytesRead)
1335
1525
  totalBytesRead += bytesRead
1336
- if (fileSize > 0) {
1337
- val progress = ((totalBytesRead * 100) / fileSize).toInt()
1526
+ if (totalSize > 0) {
1527
+ val progress = ((totalBytesRead * 100) / totalSize).toInt().coerceIn(0, 100)
1338
1528
  if (progress != prevProgress) {
1339
1529
  sendEvent("update/downloading", progress = progress)
1340
- OneKeyLog.info("BundleUpdate", "download progress: $progress% ($totalBytesRead/$fileSize)")
1530
+ OneKeyLog.info("BundleUpdate", "download progress: $progress% ($totalBytesRead/$totalSize)")
1341
1531
  prevProgress = progress
1342
1532
  }
1343
1533
  }
@@ -1345,21 +1535,48 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1345
1535
  }
1346
1536
  }
1347
1537
 
1348
- val downloadedFileAfter = File(filePath)
1349
- OneKeyLog.info("BundleUpdate", "downloadBundle: download finished, totalBytesRead=$totalBytesRead, fileExists=${downloadedFileAfter.exists()}, fileSize=${if (downloadedFileAfter.exists()) downloadedFileAfter.length() else -1}, verifying SHA256...")
1538
+ val partialAfter = File(partialFilePath)
1539
+ OneKeyLog.info(
1540
+ "BundleUpdate",
1541
+ "downloadBundle: download finished, totalBytesRead=$totalBytesRead, partialExists=${partialAfter.exists()}, partialSize=${if (partialAfter.exists()) partialAfter.length() else -1}, finalizing..."
1542
+ )
1543
+
1544
+ // Promote .partial to final ONLY after the full transfer; renaming
1545
+ // first means a SHA256 mismatch leaves no half-baked filePath that
1546
+ // the next call would mistake for a cached good bundle.
1547
+ if (downloadedFile.exists()) downloadedFile.delete()
1548
+ if (!partialAfter.renameTo(downloadedFile)) {
1549
+ OneKeyLog.error("BundleUpdate", "downloadBundle: rename .partial -> final failed")
1550
+ // outer catch is the single source of update/error events;
1551
+ // "Failed to finalize download" is in the verbatim allowlist.
1552
+ throw Exception("Failed to finalize download")
1553
+ }
1554
+
1555
+ OneKeyLog.info("BundleUpdate", "downloadBundle: verifying SHA256...")
1350
1556
  if (!verifyBundleSHA256(filePath, sha256)) {
1557
+ val reason = BundleUpdateStoreAndroid.lastSHA256FailureReason() ?: "MISMATCH"
1351
1558
  File(filePath).delete()
1352
- OneKeyLog.error("BundleUpdate", "downloadBundle: SHA256 verification failed after download")
1353
- sendEvent("update/error", message = "Bundle signature verification failed")
1354
- throw Exception("Bundle signature verification failed")
1559
+ OneKeyLog.error("BundleUpdate", "downloadBundle: SHA256 verification failed after download, reason=$reason")
1560
+ // outer catch emits the verbatim "Bundle SHA256 verification
1561
+ // failed: <REASON>" payload (recognized by sanitize/JS).
1562
+ throw Exception("Bundle SHA256 verification failed: $reason")
1355
1563
  }
1356
1564
 
1357
1565
  sendEvent("update/complete")
1358
1566
  OneKeyLog.info("BundleUpdate", "downloadBundle: completed successfully, appVersion=$appVersion, bundleVersion=$bundleVersion")
1359
1567
  result
1360
1568
  } catch (e: Exception) {
1569
+ // Keep the rich detail in OneKeyLog (local-only). The JS
1570
+ // event channel must NOT carry e.message verbatim — Android
1571
+ // FileNotFoundException etc. embed the full /data/user/.../
1572
+ // path including the package identifier, and downstream
1573
+ // listeners would forward that into analytics. Emit only the
1574
+ // sanitized tag; mirrors the iOS sendEvent payload at
1575
+ // ReactNativeBundleUpdate.swift's "update/error" sites and
1576
+ // matches the verbatim guarantees documented on
1577
+ // sanitizeErrorMessageForEvent.
1361
1578
  OneKeyLog.error("BundleUpdate", "downloadBundle: failed: ${e.javaClass.simpleName}: ${e.message}")
1362
- sendEvent("update/error", message = "${e.javaClass.simpleName}: ${e.message}")
1579
+ sendEvent("update/error", message = sanitizeErrorMessageForEvent(e))
1363
1580
  throw e
1364
1581
  } finally {
1365
1582
  isDownloading.set(false)
@@ -1367,10 +1584,18 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1367
1584
  }
1368
1585
  }
1369
1586
 
1587
+ /**
1588
+ * Returns true on hash match. On false, callers may inspect
1589
+ * BundleUpdateStoreAndroid.lastSHA256FailureReason() to distinguish a
1590
+ * computation failure (FILE_TRUNCATED / OOM / IO_*) from a clean hash
1591
+ * mismatch (reason == null).
1592
+ */
1370
1593
  private fun verifyBundleSHA256(bundlePath: String, sha256: String): Boolean {
1371
1594
  val calculated = BundleUpdateStoreAndroid.calculateSHA256(bundlePath)
1372
1595
  if (calculated == null) {
1373
- OneKeyLog.error("BundleUpdate", "verifyBundleSHA256: failed to calculate SHA256 for: $bundlePath")
1596
+ val reason = BundleUpdateStoreAndroid.lastSHA256FailureReason() ?: "UNKNOWN"
1597
+ val fileSize = try { File(bundlePath).length() } catch (_: Exception) { -1L }
1598
+ OneKeyLog.error("BundleUpdate", "verifyBundleSHA256: failed to calculate SHA256 for: $bundlePath, reason=$reason, fileSize=$fileSize")
1374
1599
  return false
1375
1600
  }
1376
1601
  val isValid = BundleUpdateStoreAndroid.secureCompare(calculated, sha256)
@@ -1412,8 +1637,13 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() {
1412
1637
  if (!skipGPG) {
1413
1638
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: verifying SHA256 of downloaded file...")
1414
1639
  if (!verifyBundleSHA256(filePath, sha256)) {
1415
- OneKeyLog.error("BundleUpdate", "verifyBundleASC: SHA256 verification failed for file=$filePath")
1416
- throw Exception("Bundle signature verification failed")
1640
+ // Promote the SHA256 subtype (FILE_TRUNCATED / OOM /
1641
+ // IO_<class> / MISMATCH) into the thrown message so
1642
+ // extractUpdateErrorCode in the JS layer can split
1643
+ // this bucket the same way the download stage does.
1644
+ val reason = BundleUpdateStoreAndroid.lastSHA256FailureReason() ?: "MISMATCH"
1645
+ OneKeyLog.error("BundleUpdate", "verifyBundleASC: SHA256 verification failed for file=$filePath, reason=$reason")
1646
+ throw Exception("Bundle SHA256 verification failed: $reason")
1417
1647
  }
1418
1648
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: SHA256 verified OK")
1419
1649
  } else {
@@ -5,6 +5,7 @@ import CommonCrypto
5
5
  import Gopenpgp
6
6
  import SSZipArchive
7
7
  import MMKV
8
+ import UIKit
8
9
 
9
10
  // OneKey GPG public key for signature verification
10
11
  private let GPG_PUBLIC_KEY = """
@@ -312,23 +313,96 @@ public class BundleUpdateStore: NSObject {
312
313
  return true
313
314
  }
314
315
 
316
+ /// Subtype of the most recent calculateSHA256 failure on this thread, or
317
+ /// nil if the last call succeeded. Surfaces FILE_NOT_FOUND /
318
+ /// FILE_DISAPPEARED / IO_<NSError code> / UNEXPECTED so analytics can
319
+ /// split the previously opaque "Failed to calculate SHA256" bucket
320
+ /// (mixpanel: 91.3% of verifyPackage failures are Android; iOS shares
321
+ /// the calculator and inherits the same blind spot for its 14 ASC +
322
+ /// 2 verifyPackage Promise-destroyed cases).
323
+ ///
324
+ /// Note: 0-byte files are NOT treated as a failure. They hash to the
325
+ /// well-known empty-content SHA256 and the caller's expected/actual
326
+ /// comparison handles legitimate vs. corrupt-empty cases. Rejecting
327
+ /// empty files here would make any OTA bundle that legitimately
328
+ /// contains a 0-byte file (touched marker, blank locale fallback)
329
+ /// fail validateAllFilesInDir / validateWebEmbedSha256 / launch entry
330
+ /// verification — all of which share this calculator.
331
+ public static func lastSHA256FailureReason() -> String? {
332
+ return Thread.current.threadDictionary[kSHA256FailureKey] as? String
333
+ }
334
+ private static let kSHA256FailureKey = "so.onekey.bundleupdate.sha256.failure"
335
+ private static func setSHA256Failure(_ reason: String?) {
336
+ if let reason = reason {
337
+ Thread.current.threadDictionary[kSHA256FailureKey] = reason
338
+ } else {
339
+ Thread.current.threadDictionary.removeObject(forKey: kSHA256FailureKey)
340
+ }
341
+ }
342
+
315
343
  public static func calculateSHA256(_ filePath: String) -> String? {
316
- guard let fileHandle = FileHandle(forReadingAtPath: filePath) else { return nil }
344
+ setSHA256Failure(nil)
345
+ let fm = FileManager.default
346
+ if !fm.fileExists(atPath: filePath) {
347
+ setSHA256Failure("FILE_NOT_FOUND")
348
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: file not found: \(filePath)")
349
+ return nil
350
+ }
351
+ guard let fileHandle = FileHandle(forReadingAtPath: filePath) else {
352
+ setSHA256Failure("FILE_DISAPPEARED")
353
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: open failed (file disappeared between stat and open): \(filePath)")
354
+ return nil
355
+ }
317
356
  defer { fileHandle.closeFile() }
318
357
 
319
- var context = CC_SHA256_CTX()
320
- CC_SHA256_Init(&context)
321
- while autoreleasepool(invoking: {
322
- let data = fileHandle.readData(ofLength: 8192)
323
- if data.count > 0 {
324
- data.withUnsafeBytes { CC_SHA256_Update(&context, $0.baseAddress, CC_LONG(data.count)) }
325
- return true
326
- }
327
- return false
328
- }) {}
329
- var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
330
- CC_SHA256_Final(&hash, &context)
331
- return hash.map { String(format: "%02x", $0) }.joined()
358
+ do {
359
+ var context = CC_SHA256_CTX()
360
+ CC_SHA256_Init(&context)
361
+ var threwError: Error?
362
+ // Wrap reads in try/catch via NSException bridge: FileHandle.readData
363
+ // can raise on read failure (NSFileHandleOperationException);
364
+ // ObjCRuntime catches those when bridged through NSObject methods.
365
+ while autoreleasepool(invoking: {
366
+ do {
367
+ let data = try Self.safeRead(fileHandle: fileHandle, length: 8192)
368
+ if data.count > 0 {
369
+ data.withUnsafeBytes { CC_SHA256_Update(&context, $0.baseAddress, CC_LONG(data.count)) }
370
+ return true
371
+ }
372
+ return false
373
+ } catch {
374
+ threwError = error
375
+ return false
376
+ }
377
+ }) {}
378
+ if let err = threwError {
379
+ let nsErr = err as NSError
380
+ // Keep the failure tag low-cardinality (`IO_<code>`) so it
381
+ // matches the doc on lastSHA256FailureReason and stays under
382
+ // the analytics bucket cap. The full `domain code description`
383
+ // detail is still logged below for local debugging.
384
+ setSHA256Failure("IO_\(nsErr.code)")
385
+ OneKeyLog.error("BundleUpdate", "calculateSHA256: read failed: \(nsErr.domain) \(nsErr.code) \(nsErr.localizedDescription)")
386
+ return nil
387
+ }
388
+ var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
389
+ CC_SHA256_Final(&hash, &context)
390
+ return hash.map { String(format: "%02x", $0) }.joined()
391
+ }
392
+ }
393
+
394
+ /// Raises throwing wrapper around FileHandle.read(upToCount:) so disk
395
+ /// I/O failures (truncated file, unmounted volume) become catchable Swift
396
+ /// errors rather than NSFileHandleOperationException.
397
+ private static func safeRead(fileHandle: FileHandle, length: Int) throws -> Data {
398
+ if #available(iOS 13.4, macOS 10.15.4, *) {
399
+ return try fileHandle.read(upToCount: length) ?? Data()
400
+ } else {
401
+ // Pre-iOS 13.4 fallback: classic readData(ofLength:) does not
402
+ // throw, but raises NSException; we cannot bridge that here so
403
+ // accept the legacy behavior on these old OS versions only.
404
+ return fileHandle.readData(ofLength: length)
405
+ }
332
406
  }
333
407
 
334
408
  public static func getNativeVersion() -> String? {
@@ -1036,6 +1110,10 @@ private class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
1036
1110
  /// Continuation to bridge delegate callbacks → async/await
1037
1111
  private var continuation: CheckedContinuation<(URL, URLResponse), Error>?
1038
1112
  private var tempFileURL: URL?
1113
+ /// Resume data captured from the most recent failure so the caller can
1114
+ /// persist it for the next attempt. Populated in didCompleteWithError
1115
+ /// when the system supplies NSURLSessionDownloadTaskResumeData.
1116
+ private(set) var lastResumeData: Data?
1039
1117
  private let lock = NSLock()
1040
1118
 
1041
1119
  func setContinuation(_ cont: CheckedContinuation<(URL, URLResponse), Error>) {
@@ -1079,10 +1157,23 @@ private class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
1079
1157
  lock.unlock()
1080
1158
 
1081
1159
  if let error = error {
1160
+ // iOS attaches partial download bytes via userInfo so the next
1161
+ // attempt can finish from the cut point instead of byte 0. Mixpanel
1162
+ // shows ~5,940 of our failures (NSURL -1005 / -1001) carry ~11KB of
1163
+ // resume data each — previously discarded.
1164
+ let nsError = error as NSError
1165
+ if let resumeData = nsError.userInfo[NSURLSessionDownloadTaskResumeData] as? Data, resumeData.count > 0 {
1166
+ self.lastResumeData = resumeData
1167
+ OneKeyLog.info("BundleUpdate", "download error captured resumeData: \(resumeData.count) bytes")
1168
+ } else {
1169
+ self.lastResumeData = nil
1170
+ }
1082
1171
  cont?.resume(throwing: error)
1083
1172
  } else if let tempURL = tempFileURL, let response = task.response {
1173
+ self.lastResumeData = nil
1084
1174
  cont?.resume(returning: (tempURL, response))
1085
1175
  } else {
1176
+ self.lastResumeData = nil
1086
1177
  cont?.resume(throwing: NSError(domain: "BundleUpdate", code: -1,
1087
1178
  userInfo: [NSLocalizedDescriptionKey: "Download completed without file"]))
1088
1179
  }
@@ -1105,6 +1196,7 @@ private class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
1105
1196
  tempFileURL = nil
1106
1197
  onProgress = nil
1107
1198
  prevProgress = -1
1199
+ lastResumeData = nil
1108
1200
  }
1109
1201
  }
1110
1202
 
@@ -1134,9 +1226,132 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1134
1226
  return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
1135
1227
  }
1136
1228
 
1229
+ /// Path of the bundle being downloaded right now (mirrors filePath in
1230
+ /// downloadBundle). Set on entry, cleared on exit. Single value because
1231
+ /// isDownloading enforces at most one in-flight download per process.
1232
+ /// Read by the background-snapshot handler so it knows where to drop the
1233
+ /// `.resume` sidecar.
1234
+ private var activeDownloadFilePath: String?
1235
+ private var didEnterBackgroundObserver: NSObjectProtocol?
1236
+
1137
1237
  override init() {
1138
1238
  super.init()
1139
1239
  urlSession = createURLSession()
1240
+ registerBackgroundSnapshotObserver()
1241
+ }
1242
+
1243
+ deinit {
1244
+ if let token = didEnterBackgroundObserver {
1245
+ NotificationCenter.default.removeObserver(token)
1246
+ }
1247
+ // URLSession retains its delegate strongly until invalidated.
1248
+ // Without this call the session (and its DownloadDelegate) would
1249
+ // leak past module deallocation — relevant in dev hot-reload and
1250
+ // any future test harness that spins up multiple module instances.
1251
+ urlSession?.invalidateAndCancel()
1252
+ }
1253
+
1254
+ /// On iOS, force-quit (user swipes the app off the App Switcher) cannot
1255
+ /// fire `URLSession`'s `didCompleteWithError` — SIGKILL leaves no time
1256
+ /// for callbacks. The kill is, however, *always* preceded by the app
1257
+ /// transitioning to the background. We hook that transition and kick
1258
+ /// off `cancel(byProducingResumeData:)` for any in-flight downloads so
1259
+ /// the resume blob lands on disk before the app can be terminated.
1260
+ /// Memory-pressure kills follow the same chain (the OS only reaps
1261
+ /// backgrounded apps under memory pressure), so this also covers OOM
1262
+ /// termination.
1263
+ ///
1264
+ /// Both `URLSession.getAllTasks(_:)` and `cancel(byProducingResumeData:)`
1265
+ /// deliver their results *asynchronously* via a closure — the resume
1266
+ /// data does not pop out synchronously. We wrap the work in a
1267
+ /// `beginBackgroundTask` window that extends the ~5s of guaranteed
1268
+ /// background runtime to ~30s so the closures actually have time to
1269
+ /// fire and write the few-KB blob before suspension. Persistence is
1270
+ /// best-effort: if iOS reaps us before the writer closure runs (very
1271
+ /// short background windows, expiration), the next launch simply
1272
+ /// re-downloads from scratch — a resume miss is correct, just slower.
1273
+ private func registerBackgroundSnapshotObserver() {
1274
+ didEnterBackgroundObserver = NotificationCenter.default.addObserver(
1275
+ forName: UIApplication.didEnterBackgroundNotification,
1276
+ object: nil,
1277
+ queue: .main
1278
+ ) { [weak self] _ in
1279
+ self?.snapshotResumeDataForBackgrounding()
1280
+ }
1281
+ }
1282
+
1283
+ private func snapshotResumeDataForBackgrounding() {
1284
+ guard let session = self.urlSession else { return }
1285
+ // Read both isDownloading AND activeDownloadFilePath inside the
1286
+ // same stateQueue.sync block so a concurrent downloadBundle entry
1287
+ // (which writes filePath BEFORE flipping isDownloading) cannot
1288
+ // produce a torn read where isDownloading=true but filePath=nil.
1289
+ let snapshot: (Bool, String?) = self.stateQueue.sync {
1290
+ (self.isDownloading, self.activeDownloadFilePath)
1291
+ }
1292
+ let (stillDownloading, snapshotPath) = snapshot
1293
+ guard stillDownloading, let filePath = snapshotPath else { return }
1294
+
1295
+ let resumeDataPath = "\(filePath).resume"
1296
+ let bgTaskName = "BundleUpdateResumeSnapshot"
1297
+
1298
+ // bgTaskId is mutated from three escaping closures: the
1299
+ // beginBackgroundTask expiration handler (called on main),
1300
+ // session.getAllTasks's completion (NOT guaranteed to be main),
1301
+ // and group.notify on .main. Wrap reads/writes in a tiny holder
1302
+ // serialized through a dedicated queue so we cannot double-end
1303
+ // the background task or leak it. `endOnce` guarantees endBackgroundTask
1304
+ // is invoked exactly once across whichever closure reaches it first.
1305
+ final class BgTaskHolder {
1306
+ private let q = DispatchQueue(label: "so.onekey.bundleupdate.bgtask")
1307
+ private var id: UIBackgroundTaskIdentifier = .invalid
1308
+ func set(_ newId: UIBackgroundTaskIdentifier) {
1309
+ q.sync { id = newId }
1310
+ }
1311
+ func endOnce() {
1312
+ q.sync {
1313
+ if id != .invalid {
1314
+ UIApplication.shared.endBackgroundTask(id)
1315
+ id = .invalid
1316
+ }
1317
+ }
1318
+ }
1319
+ }
1320
+ let bgTask = BgTaskHolder()
1321
+ let started = UIApplication.shared.beginBackgroundTask(withName: bgTaskName) {
1322
+ // Expiration handler — system is reclaiming us. Best-effort end.
1323
+ bgTask.endOnce()
1324
+ }
1325
+ bgTask.set(started)
1326
+ OneKeyLog.info("BundleUpdate", "didEnterBackground: snapshotting resumeData for \(filePath)")
1327
+
1328
+ session.getAllTasks { tasks in
1329
+ let group = DispatchGroup()
1330
+ for task in tasks {
1331
+ guard let dl = task as? URLSessionDownloadTask, dl.state == .running else { continue }
1332
+ group.enter()
1333
+ dl.cancel(byProducingResumeData: { data in
1334
+ if let data = data, data.count > 0 {
1335
+ do {
1336
+ let dir = (resumeDataPath as NSString).deletingLastPathComponent
1337
+ if !FileManager.default.fileExists(atPath: dir) {
1338
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
1339
+ }
1340
+ try data.write(to: URL(fileURLWithPath: resumeDataPath), options: .atomic)
1341
+ OneKeyLog.info("BundleUpdate", "didEnterBackground: persisted resumeData (\(data.count) bytes) at \(resumeDataPath)")
1342
+ } catch {
1343
+ OneKeyLog.warn("BundleUpdate", "didEnterBackground: failed to persist resumeData: \(error)")
1344
+ }
1345
+ } else {
1346
+ OneKeyLog.info("BundleUpdate", "didEnterBackground: cancel produced no resumeData (task may have just completed)")
1347
+ }
1348
+ group.leave()
1349
+ })
1350
+ }
1351
+ group.notify(queue: .main) {
1352
+ bgTask.endOnce()
1353
+ }
1354
+ }
1140
1355
  }
1141
1356
 
1142
1357
  private func sendEvent(type: String, progress: Int = 0, message: String = "") {
@@ -1181,7 +1396,16 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1181
1396
  OneKeyLog.warn("BundleUpdate", "downloadBundle: rejected, already downloading")
1182
1397
  throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Already downloading"])
1183
1398
  }
1184
- defer { self.stateQueue.sync { self.isDownloading = false } }
1399
+ defer {
1400
+ // Clear isDownloading + the snapshot anchor under the same
1401
+ // queue so didEnterBackground's paired read can't observe
1402
+ // a half-cleared state (isDownloading=true while
1403
+ // activeDownloadFilePath=nil, or vice versa).
1404
+ self.stateQueue.sync {
1405
+ self.isDownloading = false
1406
+ self.activeDownloadFilePath = nil
1407
+ }
1408
+ }
1185
1409
 
1186
1410
  let appVersion = params.latestVersion
1187
1411
  let bundleVersion = params.bundleVersion
@@ -1202,6 +1426,10 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1202
1426
 
1203
1427
  let fileName = "\(appVersion)-\(bundleVersion).zip"
1204
1428
  let filePath = (BundleUpdateStore.downloadBundleDir() as NSString).appendingPathComponent(fileName)
1429
+ // Persisted resume blob from a previous failed attempt. Lives next
1430
+ // to the bundle so it shares fate (delete-with-bundle) without
1431
+ // polluting the bundle dir's cache lookup.
1432
+ let resumeDataPath = "\(filePath).resume"
1205
1433
 
1206
1434
  let result = BundleDownloadResult(
1207
1435
  downloadedFile: filePath,
@@ -1218,6 +1446,8 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1218
1446
  OneKeyLog.info("BundleUpdate", "downloadBundle: file already exists, verifying SHA256...")
1219
1447
  if self.verifyBundleSHA256(filePath, sha256: sha256) {
1220
1448
  OneKeyLog.info("BundleUpdate", "downloadBundle: existing file SHA256 valid, skipping download")
1449
+ // A valid completed bundle invalidates any stale resume blob.
1450
+ try? FileManager.default.removeItem(atPath: resumeDataPath)
1221
1451
  DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
1222
1452
  self?.sendEvent(type: "update/complete")
1223
1453
  }
@@ -1225,6 +1455,8 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1225
1455
  } else {
1226
1456
  OneKeyLog.warn("BundleUpdate", "downloadBundle: existing file SHA256 mismatch, re-downloading")
1227
1457
  try? FileManager.default.removeItem(atPath: filePath)
1458
+ // Hash-failed bundle means the resume blob is also poisoned.
1459
+ try? FileManager.default.removeItem(atPath: resumeDataPath)
1228
1460
  }
1229
1461
  }
1230
1462
 
@@ -1239,8 +1471,17 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1239
1471
  throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "URLSession not initialized"])
1240
1472
  }
1241
1473
 
1474
+ // Resume blob from a prior failed attempt; iOS rebuilds the byte
1475
+ // offset internally so we never need a Range header on this path.
1476
+ let persistedResumeData: Data? = {
1477
+ guard FileManager.default.fileExists(atPath: resumeDataPath),
1478
+ let data = try? Data(contentsOf: URL(fileURLWithPath: resumeDataPath)),
1479
+ data.count > 0 else { return nil }
1480
+ return data
1481
+ }()
1482
+
1242
1483
  self.sendEvent(type: "update/start")
1243
- OneKeyLog.info("BundleUpdate", "downloadBundle: starting download...")
1484
+ OneKeyLog.info("BundleUpdate", "downloadBundle: starting download (resumeBytes=\(persistedResumeData?.count ?? 0))...")
1244
1485
 
1245
1486
  let request = URLRequest(url: url)
1246
1487
 
@@ -1254,51 +1495,103 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1254
1495
  self?.sendEvent(type: "update/downloading", progress: progress)
1255
1496
  }
1256
1497
 
1257
- let (tempURL, response) = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(URL, URLResponse), Error>) in
1258
- delegate.setContinuation(continuation)
1259
- let task = session.downloadTask(with: request)
1260
- task.resume()
1261
- }
1498
+ // Anchor for the background-snapshot handler. Set BEFORE
1499
+ // task.resume() so a foreground→background transition that
1500
+ // races the very first bytes still finds a path to write the
1501
+ // resume blob to. Pair the write with the same stateQueue the
1502
+ // snapshot reader uses, so isDownloading=true and a non-nil
1503
+ // activeDownloadFilePath always go together.
1504
+ self.stateQueue.sync { self.activeDownloadFilePath = filePath }
1262
1505
 
1263
- // Verify HTTPS was maintained (no HTTP redirect)
1264
- if let httpResponse = response as? HTTPURLResponse,
1265
- let responseUrl = httpResponse.url,
1266
- responseUrl.scheme?.lowercased() != "https" {
1267
- OneKeyLog.error("BundleUpdate", "downloadBundle: redirected to non-HTTPS URL: \(responseUrl)")
1268
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Download was redirected to non-HTTPS URL"])
1269
- }
1506
+ let downloadOutcome: Result<(URL, URLResponse), Error>
1507
+ do {
1508
+ let value = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(URL, URLResponse), Error>) in
1509
+ delegate.setContinuation(continuation)
1510
+ let task: URLSessionDownloadTask
1511
+ if let resumeData = persistedResumeData {
1512
+ task = session.downloadTask(withResumeData: resumeData)
1513
+ } else {
1514
+ task = session.downloadTask(with: request)
1515
+ }
1516
+ task.resume()
1517
+ }
1518
+ downloadOutcome = .success(value)
1519
+ } catch {
1520
+ downloadOutcome = .failure(error)
1521
+ }
1522
+
1523
+ switch downloadOutcome {
1524
+ case .failure(let error):
1525
+ // Persist the freshly captured resume blob for the next call.
1526
+ // If iOS gave us nothing usable, clear any stale blob so a
1527
+ // future attempt isn't held back by an unresumable cut point.
1528
+ if let resumeData = delegate.lastResumeData {
1529
+ do {
1530
+ let dir = (resumeDataPath as NSString).deletingLastPathComponent
1531
+ if !FileManager.default.fileExists(atPath: dir) {
1532
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
1533
+ }
1534
+ try resumeData.write(to: URL(fileURLWithPath: resumeDataPath), options: .atomic)
1535
+ OneKeyLog.info("BundleUpdate", "downloadBundle: persisted resumeData (\(resumeData.count) bytes) at \(resumeDataPath)")
1536
+ } catch {
1537
+ OneKeyLog.warn("BundleUpdate", "downloadBundle: failed to persist resumeData: \(error)")
1538
+ }
1539
+ } else {
1540
+ try? FileManager.default.removeItem(atPath: resumeDataPath)
1541
+ }
1542
+ let nsError = error as NSError
1543
+ OneKeyLog.error("BundleUpdate", "downloadBundle: download failed: \(nsError.domain) \(nsError.code) \(nsError.localizedDescription)")
1544
+ self.sendEvent(type: "update/error", message: "\(nsError.domain) \(nsError.code)")
1545
+ throw error
1546
+
1547
+ case .success(let (tempURL, response)):
1548
+ // Successful completion ⇒ no longer need the resume blob.
1549
+ try? FileManager.default.removeItem(atPath: resumeDataPath)
1550
+
1551
+ // Verify HTTPS was maintained (no HTTP redirect)
1552
+ if let httpResponse = response as? HTTPURLResponse,
1553
+ let responseUrl = httpResponse.url,
1554
+ responseUrl.scheme?.lowercased() != "https" {
1555
+ OneKeyLog.error("BundleUpdate", "downloadBundle: redirected to non-HTTPS URL: \(responseUrl)")
1556
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Download was redirected to non-HTTPS URL"])
1557
+ }
1270
1558
 
1271
- guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
1272
- let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
1273
- OneKeyLog.error("BundleUpdate", "downloadBundle: HTTP error, statusCode=\(statusCode)")
1274
- self.sendEvent(type: "update/error", message: "HTTP error \(statusCode)")
1275
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Download failed with HTTP \(statusCode)"])
1276
- }
1559
+ // 206 is acceptable when the OS finished a Range-resumed task on
1560
+ // our behalf; everything else non-200 is a real error.
1561
+ guard let httpResponse = response as? HTTPURLResponse,
1562
+ httpResponse.statusCode == 200 || httpResponse.statusCode == 206 else {
1563
+ let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
1564
+ OneKeyLog.error("BundleUpdate", "downloadBundle: HTTP error, statusCode=\(statusCode)")
1565
+ self.sendEvent(type: "update/error", message: "HTTP error \(statusCode)")
1566
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Download failed with HTTP \(statusCode)"])
1567
+ }
1277
1568
 
1278
- OneKeyLog.info("BundleUpdate", "downloadBundle: download finished, HTTP 200, moving to destination...")
1569
+ OneKeyLog.info("BundleUpdate", "downloadBundle: download finished, HTTP \(httpResponse.statusCode), moving to destination...")
1279
1570
 
1280
- // Move downloaded file to destination
1281
- let destDir = (filePath as NSString).deletingLastPathComponent
1282
- if !FileManager.default.fileExists(atPath: destDir) {
1283
- try FileManager.default.createDirectory(atPath: destDir, withIntermediateDirectories: true)
1284
- }
1285
- if FileManager.default.fileExists(atPath: filePath) {
1286
- try FileManager.default.removeItem(atPath: filePath)
1287
- }
1288
- try FileManager.default.moveItem(at: tempURL, to: URL(fileURLWithPath: filePath))
1571
+ // Move downloaded file to destination
1572
+ let destDir = (filePath as NSString).deletingLastPathComponent
1573
+ if !FileManager.default.fileExists(atPath: destDir) {
1574
+ try FileManager.default.createDirectory(atPath: destDir, withIntermediateDirectories: true)
1575
+ }
1576
+ if FileManager.default.fileExists(atPath: filePath) {
1577
+ try FileManager.default.removeItem(atPath: filePath)
1578
+ }
1579
+ try FileManager.default.moveItem(at: tempURL, to: URL(fileURLWithPath: filePath))
1289
1580
 
1290
- // Verify SHA256
1291
- OneKeyLog.info("BundleUpdate", "downloadBundle: verifying SHA256...")
1292
- if !self.verifyBundleSHA256(filePath, sha256: sha256) {
1293
- try? FileManager.default.removeItem(atPath: filePath)
1294
- OneKeyLog.error("BundleUpdate", "downloadBundle: SHA256 verification failed after download")
1295
- self.sendEvent(type: "update/error", message: "Bundle signature verification failed")
1296
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle signature verification failed"])
1297
- }
1581
+ // Verify SHA256
1582
+ OneKeyLog.info("BundleUpdate", "downloadBundle: verifying SHA256...")
1583
+ if !self.verifyBundleSHA256(filePath, sha256: sha256) {
1584
+ let reason = BundleUpdateStore.lastSHA256FailureReason() ?? "MISMATCH"
1585
+ try? FileManager.default.removeItem(atPath: filePath)
1586
+ OneKeyLog.error("BundleUpdate", "downloadBundle: SHA256 verification failed after download, reason=\(reason)")
1587
+ self.sendEvent(type: "update/error", message: "SHA256_\(reason)")
1588
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle SHA256 verification failed: \(reason)"])
1589
+ }
1298
1590
 
1299
- self.sendEvent(type: "update/complete")
1300
- OneKeyLog.info("BundleUpdate", "downloadBundle: completed successfully, appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
1301
- return result
1591
+ self.sendEvent(type: "update/complete")
1592
+ OneKeyLog.info("BundleUpdate", "downloadBundle: completed successfully, appVersion=\(appVersion), bundleVersion=\(bundleVersion)")
1593
+ return result
1594
+ }
1302
1595
  }
1303
1596
  }
1304
1597
 
@@ -1347,10 +1640,16 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1347
1640
 
1348
1641
  if !skipGPG {
1349
1642
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: verifying SHA256 of downloaded file...")
1350
- guard let calculated = BundleUpdateStore.calculateSHA256(filePath),
1351
- calculated.secureCompare(sha256) else {
1352
- OneKeyLog.error("BundleUpdate", "verifyBundleASC: SHA256 verification failed for file=\(filePath)")
1353
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle signature verification failed"])
1643
+ let calculated = BundleUpdateStore.calculateSHA256(filePath)
1644
+ let isValid = calculated != nil && calculated!.secureCompare(sha256)
1645
+ if !isValid {
1646
+ // Promote the SHA256 subtype (FILE_TRUNCATED / OOM /
1647
+ // IO_<class> / MISMATCH) into the thrown message so
1648
+ // analytics' extractUpdateErrorCode can split this
1649
+ // bucket the same way the download stage already does.
1650
+ let reason = BundleUpdateStore.lastSHA256FailureReason() ?? "MISMATCH"
1651
+ OneKeyLog.error("BundleUpdate", "verifyBundleASC: SHA256 verification failed for file=\(filePath), reason=\(reason)")
1652
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle SHA256 verification failed: \(reason)"])
1354
1653
  }
1355
1654
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: SHA256 verified OK")
1356
1655
  } else {
@@ -1377,9 +1676,19 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec {
1377
1676
  try SSZipArchive.unzipFile(atPath: filePath, toDestination: destination, overwrite: true, password: nil)
1378
1677
  OneKeyLog.info("BundleUpdate", "verifyBundleASC: extraction completed")
1379
1678
  } catch {
1380
- OneKeyLog.error("BundleUpdate", "verifyBundleASC: unzip failed: \(error.localizedDescription)")
1679
+ // SSZipArchive's NSError.localizedDescription often embeds the
1680
+ // failing file path which on iOS includes the install UUID
1681
+ // (/var/mobile/Containers/Data/Application/<UUID>/...). Keep
1682
+ // the rich detail in OneKeyLog (local-only), but expose only
1683
+ // a code-shaped tag in the thrown message so the JS-side
1684
+ // analytics layer can never reflect that path back to the
1685
+ // server. Subtypes intentionally low-cardinality so
1686
+ // extractUpdateErrorCode picks them up cleanly:
1687
+ // IO_<NSError code>
1688
+ let nsErr = error as NSError
1689
+ OneKeyLog.error("BundleUpdate", "verifyBundleASC: unzip failed: domain=\(nsErr.domain) code=\(nsErr.code) desc=\(nsErr.localizedDescription)")
1381
1690
  try? FileManager.default.removeItem(atPath: destination)
1382
- throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to unzip bundle: \(error.localizedDescription)"])
1691
+ throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to unzip bundle: IO_\(nsErr.code)"])
1383
1692
  }
1384
1693
 
1385
1694
  // Validate extracted paths (symlinks, path traversal)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-bundle-update",
3
- "version": "3.0.28",
3
+ "version": "3.0.30",
4
4
  "description": "react-native-bundle-update",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",