@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.
package/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
459
|
-
|
|
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
|
|
1307
|
-
val
|
|
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
|
-
|
|
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
|
|
1317
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
1518
|
+
FileOutputStream(partialFilePath, appendMode).use { outputStream ->
|
|
1329
1519
|
val buffer = ByteArray(8192)
|
|
1330
1520
|
var bytesRead: Int
|
|
1331
1521
|
|
|
1332
|
-
var prevProgress =
|
|
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 (
|
|
1337
|
-
val progress = ((totalBytesRead * 100) /
|
|
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/$
|
|
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
|
|
1349
|
-
OneKeyLog.info(
|
|
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
|
-
|
|
1354
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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 {
|
|
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
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
-
|
|
1569
|
+
OneKeyLog.info("BundleUpdate", "downloadBundle: download finished, HTTP \(httpResponse.statusCode), moving to destination...")
|
|
1279
1570
|
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
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
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
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
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
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
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
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
|
-
|
|
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: \(
|
|
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)
|