@onekeyfe/react-native-app-update 3.0.36 → 3.0.37
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/reactnativeappupdate/ReactNativeAppUpdate.kt
CHANGED
|
@@ -17,8 +17,6 @@ import com.margelo.nitro.nativelogger.OneKeyLog
|
|
|
17
17
|
import com.tencent.mmkv.MMKV
|
|
18
18
|
import okhttp3.OkHttpClient
|
|
19
19
|
import okhttp3.Request
|
|
20
|
-
import okio.buffer
|
|
21
|
-
import okio.sink
|
|
22
20
|
import java.io.BufferedInputStream
|
|
23
21
|
import java.io.BufferedReader
|
|
24
22
|
import java.io.File
|
|
@@ -309,46 +307,182 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
309
307
|
}
|
|
310
308
|
|
|
311
309
|
/**
|
|
312
|
-
*
|
|
313
|
-
*
|
|
310
|
+
* Outcome of verifying an existing APK against its detached SHA256SUMS.asc.
|
|
311
|
+
* Three states matter: a clean pass, a real hash mismatch (file is stale and
|
|
312
|
+
* must be discarded), and "we can't tell" — typically because the ASC was
|
|
313
|
+
* unfetchable (no network) or the local ASC is unparseable. The previous
|
|
314
|
+
* Boolean collapsed Indeterminate into "invalid" and the caller deleted a
|
|
315
|
+
* perfectly-good partial download on every transient network blip.
|
|
314
316
|
*/
|
|
315
|
-
private
|
|
317
|
+
private sealed class ApkVerifyOutcome {
|
|
318
|
+
object Valid : ApkVerifyOutcome()
|
|
319
|
+
object HashMismatch : ApkVerifyOutcome()
|
|
320
|
+
object Indeterminate : ApkVerifyOutcome()
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Check the apkFile against its detached SHA256SUMS.asc and return a
|
|
325
|
+
* tri-state outcome. Callers must NOT delete the APK on Indeterminate —
|
|
326
|
+
* the bytes on disk may still be the right ones; the next online retry can
|
|
327
|
+
* decide. Downloads ASC if not present.
|
|
328
|
+
*/
|
|
329
|
+
private fun verifyExistingApk(url: String, filePath: String, apkFile: File): ApkVerifyOutcome {
|
|
316
330
|
return try {
|
|
317
331
|
val ascFilePath = "$filePath.SHA256SUMS.asc"
|
|
318
332
|
val ascFile = buildFile(ascFilePath)
|
|
319
333
|
|
|
320
334
|
if (!ascFile.exists()) {
|
|
321
335
|
val ascUrl = "$url.SHA256SUMS.asc"
|
|
322
|
-
OneKeyLog.info("AppUpdate", "
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
336
|
+
OneKeyLog.info("AppUpdate", "verifyExistingApk: ASC not found, downloading from $ascUrl")
|
|
337
|
+
val downloaded = try {
|
|
338
|
+
downloadAscFile(ascUrl, ascFile)
|
|
339
|
+
} catch (e: Exception) {
|
|
340
|
+
// OkHttp throws IOException family on offline / DNS / connection-reset.
|
|
341
|
+
// Treat as Indeterminate so caller preserves the partial.
|
|
342
|
+
OneKeyLog.warn("AppUpdate", "verifyExistingApk: ASC download threw ${e.javaClass.simpleName}: ${e.message}")
|
|
343
|
+
null
|
|
344
|
+
}
|
|
345
|
+
if (downloaded == null) {
|
|
346
|
+
OneKeyLog.warn("AppUpdate", "verifyExistingApk: ASC unavailable, indeterminate")
|
|
347
|
+
return ApkVerifyOutcome.Indeterminate
|
|
326
348
|
}
|
|
327
|
-
OneKeyLog.info("AppUpdate", "
|
|
349
|
+
OneKeyLog.info("AppUpdate", "verifyExistingApk: ASC downloaded to ${ascFile.absolutePath}")
|
|
328
350
|
}
|
|
329
351
|
|
|
330
352
|
val expectedSha256 = verifyAscAndExtractSha256(ascFile)
|
|
331
353
|
if (expectedSha256 == null) {
|
|
332
|
-
|
|
333
|
-
|
|
354
|
+
// Local ASC failed parsing/GPG. Could be a corrupted cache from
|
|
355
|
+
// an earlier interrupted write — drop it so the next round
|
|
356
|
+
// re-downloads cleanly. Don't condemn the APK on this alone.
|
|
357
|
+
OneKeyLog.warn("AppUpdate", "verifyExistingApk: GPG verification or SHA256 extraction failed, discarding local ASC")
|
|
358
|
+
ascFile.delete()
|
|
359
|
+
return ApkVerifyOutcome.Indeterminate
|
|
334
360
|
}
|
|
335
361
|
|
|
336
|
-
OneKeyLog.info("AppUpdate", "
|
|
362
|
+
OneKeyLog.info("AppUpdate", "verifyExistingApk: computing SHA256 of existing APK (size=${apkFile.length()})...")
|
|
337
363
|
val actualSha256 = computeSha256(apkFile)
|
|
338
364
|
|
|
339
365
|
if (secureCompare(actualSha256, expectedSha256)) {
|
|
340
|
-
OneKeyLog.info("AppUpdate", "
|
|
366
|
+
OneKeyLog.info("AppUpdate", "verifyExistingApk: SHA256 matches, APK is valid")
|
|
367
|
+
ApkVerifyOutcome.Valid
|
|
368
|
+
} else {
|
|
369
|
+
OneKeyLog.warn("AppUpdate", "verifyExistingApk: SHA256 mismatch, expected=${expectedSha256.take(16)}..., got=${actualSha256.take(16)}...")
|
|
370
|
+
ApkVerifyOutcome.HashMismatch
|
|
371
|
+
}
|
|
372
|
+
} catch (e: Exception) {
|
|
373
|
+
OneKeyLog.warn("AppUpdate", "verifyExistingApk: unexpected failure: ${e.javaClass.simpleName}: ${e.message}")
|
|
374
|
+
ApkVerifyOutcome.Indeterminate
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Thrown when we cannot decide whether the bytes on disk are still
|
|
380
|
+
* the right APK (typically: ASC unreachable due to offline). Extends
|
|
381
|
+
* IOException so existing IOException-aware callers still match it,
|
|
382
|
+
* but a dedicated type lets JS branch on `IOException`/class name
|
|
383
|
+
* instead of substring-matching the message — which would silently
|
|
384
|
+
* rot the moment we tweak the wording.
|
|
385
|
+
*/
|
|
386
|
+
private class ApkVerificationDeferredException :
|
|
387
|
+
java.io.IOException(DEFERRED_VERIFICATION_MESSAGE) {
|
|
388
|
+
companion object {
|
|
389
|
+
const val DEFERRED_VERIFICATION_MESSAGE = "APK verification deferred: ASC unavailable"
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Move bytes from the final path back into the .partial slot. Tries
|
|
395
|
+
* an atomic rename first; on the rare same-fs rename failure, falls
|
|
396
|
+
* back to a stream copy so a transient FS hiccup cannot destroy the
|
|
397
|
+
* one and only copy of an already-downloaded payload (the exact
|
|
398
|
+
* regression this PR is trying to avoid). Returns true if the bytes
|
|
399
|
+
* are at partialFile by the time we return.
|
|
400
|
+
*/
|
|
401
|
+
private fun rollbackFinalToPartial(downloadedFile: File, partialFile: File): Boolean {
|
|
402
|
+
if (!downloadedFile.exists()) return false
|
|
403
|
+
if (partialFile.exists()) partialFile.delete()
|
|
404
|
+
if (downloadedFile.renameTo(partialFile)) return true
|
|
405
|
+
OneKeyLog.warn("AppUpdate", "rollbackFinalToPartial: rename failed, falling back to byte copy (size=${downloadedFile.length()})")
|
|
406
|
+
return try {
|
|
407
|
+
FileInputStream(downloadedFile).use { input ->
|
|
408
|
+
FileOutputStream(partialFile).use { output ->
|
|
409
|
+
val buffer = ByteArray(8192)
|
|
410
|
+
var n: Int
|
|
411
|
+
while (input.read(buffer).also { n = it } != -1) {
|
|
412
|
+
output.write(buffer, 0, n)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
val ok = partialFile.length() == downloadedFile.length()
|
|
417
|
+
if (ok) {
|
|
418
|
+
downloadedFile.delete()
|
|
419
|
+
OneKeyLog.info("AppUpdate", "rollbackFinalToPartial: byte copy fallback succeeded (size=${partialFile.length()})")
|
|
341
420
|
true
|
|
342
421
|
} else {
|
|
343
|
-
OneKeyLog.
|
|
422
|
+
OneKeyLog.error("AppUpdate", "rollbackFinalToPartial: byte copy size mismatch (partial=${partialFile.length()}, final=${downloadedFile.length()}), discarding copy")
|
|
423
|
+
partialFile.delete()
|
|
344
424
|
false
|
|
345
425
|
}
|
|
346
426
|
} catch (e: Exception) {
|
|
347
|
-
OneKeyLog.
|
|
427
|
+
OneKeyLog.error("AppUpdate", "rollbackFinalToPartial: byte copy fallback failed: ${e.javaClass.simpleName}: ${e.message}")
|
|
428
|
+
if (partialFile.exists()) partialFile.delete()
|
|
348
429
|
false
|
|
349
430
|
}
|
|
350
431
|
}
|
|
351
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Outcome of promoting a fully-sized .partial to the final path and
|
|
435
|
+
* running the GPG/SHA verifier against it.
|
|
436
|
+
*/
|
|
437
|
+
private sealed class PromoteOutcome {
|
|
438
|
+
object Valid : PromoteOutcome()
|
|
439
|
+
object HashMismatch : PromoteOutcome()
|
|
440
|
+
object Deferred : PromoteOutcome() // verifier Indeterminate; bytes restored to .partial
|
|
441
|
+
object RenameFailed : PromoteOutcome() // promotion rename failed; bytes preserved at .partial
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Promote .partial -> final, run the verifier, and dispatch the
|
|
446
|
+
* tri-state outcome. On Indeterminate the bytes are rolled back into
|
|
447
|
+
* .partial (with copy fallback if rename fails), so callers can
|
|
448
|
+
* retry later. On promotion-rename failure the .partial is kept
|
|
449
|
+
* intact so Phase 3 can Range-resume on the next pass.
|
|
450
|
+
*
|
|
451
|
+
* This consolidates the previously three-times-duplicated
|
|
452
|
+
* promote+verify+dispatch logic in downloadAPK. Any future tweak to
|
|
453
|
+
* promotion semantics happens once here, not in three drift-prone
|
|
454
|
+
* sites.
|
|
455
|
+
*/
|
|
456
|
+
private fun tryPromoteAndVerify(
|
|
457
|
+
url: String,
|
|
458
|
+
filePath: String,
|
|
459
|
+
partialFile: File,
|
|
460
|
+
downloadedFile: File
|
|
461
|
+
): PromoteOutcome {
|
|
462
|
+
if (downloadedFile.exists()) downloadedFile.delete()
|
|
463
|
+
if (!partialFile.renameTo(downloadedFile)) {
|
|
464
|
+
OneKeyLog.warn("AppUpdate", "tryPromoteAndVerify: rename .partial -> final failed, preserving .partial for Range resume")
|
|
465
|
+
// renameTo is atomic on same fs: either partial moved to
|
|
466
|
+
// final or it didn't. On failure the bytes are still at
|
|
467
|
+
// partialFile; leave them so Phase 3 can Range-resume.
|
|
468
|
+
if (downloadedFile.exists()) downloadedFile.delete()
|
|
469
|
+
return PromoteOutcome.RenameFailed
|
|
470
|
+
}
|
|
471
|
+
return when (verifyExistingApk(url, filePath, downloadedFile)) {
|
|
472
|
+
ApkVerifyOutcome.Valid -> PromoteOutcome.Valid
|
|
473
|
+
ApkVerifyOutcome.HashMismatch -> {
|
|
474
|
+
downloadedFile.delete()
|
|
475
|
+
PromoteOutcome.HashMismatch
|
|
476
|
+
}
|
|
477
|
+
ApkVerifyOutcome.Indeterminate -> {
|
|
478
|
+
if (!rollbackFinalToPartial(downloadedFile, partialFile)) {
|
|
479
|
+
OneKeyLog.error("AppUpdate", "tryPromoteAndVerify: rollback failed for both rename and byte copy; bytes lost")
|
|
480
|
+
}
|
|
481
|
+
PromoteOutcome.Deferred
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
352
486
|
private fun isDebuggable(): Boolean {
|
|
353
487
|
val context = NitroModules.applicationContext ?: return false
|
|
354
488
|
return (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
|
@@ -417,71 +551,287 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
417
551
|
throw Exception("Download URL must use HTTPS")
|
|
418
552
|
}
|
|
419
553
|
|
|
554
|
+
// Resume model (mirrors react-native-bundle-update's downloadBundle):
|
|
555
|
+
// * Bytes in flight live in <filePath>.partial.
|
|
556
|
+
// * The "final" filePath only ever holds a fully transferred APK
|
|
557
|
+
// (verified or about-to-be-verified). This keeps the
|
|
558
|
+
// "exists at filePath -> already valid" cache check below
|
|
559
|
+
// immune to the previous bug where a half-baked APK at the
|
|
560
|
+
// final path looked complete to clearCache / installAPK callers.
|
|
561
|
+
val partialFilePath = "$filePath.partial"
|
|
420
562
|
val downloadedFile = buildFile(filePath)
|
|
563
|
+
val partialFile = buildFile(partialFilePath)
|
|
564
|
+
val expectedSize = if (fileSize > 0) fileSize else 0L
|
|
565
|
+
|
|
566
|
+
// Phase 1 — adopt anything already at the final path.
|
|
567
|
+
// Old builds (pre-resume) wrote partial bytes here directly, so
|
|
568
|
+
// a small file at filePath is most likely a stalled download
|
|
569
|
+
// from an earlier app version: promote it to .partial so we can
|
|
570
|
+
// Range-resume instead of starting over. A correctly-sized file
|
|
571
|
+
// goes through the tri-state verifier and only gets deleted on
|
|
572
|
+
// a real hash mismatch — never on an unfetchable ASC.
|
|
573
|
+
//
|
|
574
|
+
// Caveat: when expectedSize == 0 (caller didn't pass fileSize)
|
|
575
|
+
// we cannot distinguish a complete cached APK from a pre-resume
|
|
576
|
+
// half-baked one — both flow into the verifier, and a stale
|
|
577
|
+
// half-baked file that happens to be online will hit
|
|
578
|
+
// HashMismatch and get deleted (correct, but loses the bytes).
|
|
579
|
+
// Always pass fileSize from JS to unlock the size-based
|
|
580
|
+
// pre-resume migration path.
|
|
421
581
|
if (downloadedFile.exists()) {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
582
|
+
val existingSize = downloadedFile.length()
|
|
583
|
+
OneKeyLog.info("AppUpdate", "downloadAPK: existing APK at final path (size=$existingSize, expected=$expectedSize)")
|
|
584
|
+
when {
|
|
585
|
+
expectedSize > 0 && existingSize > expectedSize -> {
|
|
586
|
+
OneKeyLog.warn("AppUpdate", "downloadAPK: existing APK larger than expected, deleting")
|
|
587
|
+
downloadedFile.delete()
|
|
588
|
+
}
|
|
589
|
+
expectedSize > 0 && existingSize < expectedSize -> {
|
|
590
|
+
OneKeyLog.info("AppUpdate", "downloadAPK: existing APK smaller than expected, promoting to .partial for resume")
|
|
591
|
+
if (partialFile.exists()) partialFile.delete()
|
|
592
|
+
if (!downloadedFile.renameTo(partialFile)) {
|
|
593
|
+
OneKeyLog.warn("AppUpdate", "downloadAPK: rename to .partial failed, deleting stale final")
|
|
594
|
+
downloadedFile.delete()
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
else -> {
|
|
598
|
+
when (verifyExistingApk(url, filePath, downloadedFile)) {
|
|
599
|
+
ApkVerifyOutcome.Valid -> {
|
|
600
|
+
OneKeyLog.info("AppUpdate", "downloadAPK: existing APK is valid, skipping download")
|
|
601
|
+
sendEvent("update/downloaded")
|
|
602
|
+
return@async
|
|
603
|
+
}
|
|
604
|
+
ApkVerifyOutcome.HashMismatch -> {
|
|
605
|
+
OneKeyLog.info("AppUpdate", "downloadAPK: existing APK hash mismatch, deleting and re-downloading")
|
|
606
|
+
downloadedFile.delete()
|
|
607
|
+
if (partialFile.exists()) partialFile.delete()
|
|
608
|
+
}
|
|
609
|
+
ApkVerifyOutcome.Indeterminate -> {
|
|
610
|
+
// ASC could not be fetched (offline) or could
|
|
611
|
+
// not be parsed. The on-disk APK might still
|
|
612
|
+
// be the right one — surface a transient
|
|
613
|
+
// error so the JS retry layer waits for
|
|
614
|
+
// network and re-runs verify, instead of
|
|
615
|
+
// wiping the bytes preemptively.
|
|
616
|
+
OneKeyLog.warn("AppUpdate", "downloadAPK: cannot verify existing APK (ASC unavailable); preserving file and aborting this attempt")
|
|
617
|
+
throw ApkVerificationDeferredException()
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Phase 2 — pick up an in-flight partial.
|
|
625
|
+
var partialBytes = 0L
|
|
626
|
+
if (partialFile.exists()) {
|
|
627
|
+
val partialSize = partialFile.length()
|
|
628
|
+
when {
|
|
629
|
+
expectedSize > 0 && partialSize == expectedSize -> {
|
|
630
|
+
// Full body on disk but the previous run was killed
|
|
631
|
+
// before promotion. Try promote + verify before
|
|
632
|
+
// re-downloading.
|
|
633
|
+
OneKeyLog.info("AppUpdate", "downloadAPK: partial matches expected size ($partialSize), trying promote+verify")
|
|
634
|
+
when (tryPromoteAndVerify(url, filePath, partialFile, downloadedFile)) {
|
|
635
|
+
PromoteOutcome.Valid -> {
|
|
636
|
+
OneKeyLog.info("AppUpdate", "downloadAPK: recovered crashed-before-rename APK, skipping download")
|
|
637
|
+
sendEvent("update/downloaded")
|
|
638
|
+
return@async
|
|
639
|
+
}
|
|
640
|
+
PromoteOutcome.HashMismatch -> {
|
|
641
|
+
OneKeyLog.warn("AppUpdate", "downloadAPK: promoted partial failed hash check, discarding")
|
|
642
|
+
// Helper already deleted final; partial slot empty.
|
|
643
|
+
// Fall through: Phase 3 fetches from byte zero.
|
|
644
|
+
}
|
|
645
|
+
PromoteOutcome.Deferred -> {
|
|
646
|
+
throw ApkVerificationDeferredException()
|
|
647
|
+
}
|
|
648
|
+
PromoteOutcome.RenameFailed -> {
|
|
649
|
+
// Bytes preserved at .partial: Phase 3 will Range-resume.
|
|
650
|
+
OneKeyLog.warn("AppUpdate", "downloadAPK: promote rename failed, will Range-resume from .partial")
|
|
651
|
+
partialBytes = partialFile.length()
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
expectedSize > 0 && partialSize > expectedSize -> {
|
|
656
|
+
OneKeyLog.warn("AppUpdate", "downloadAPK: stale partial (>expected $partialSize/$expectedSize), discarding")
|
|
657
|
+
partialFile.delete()
|
|
658
|
+
}
|
|
659
|
+
partialSize > 0 -> {
|
|
660
|
+
partialBytes = partialSize
|
|
661
|
+
OneKeyLog.info("AppUpdate", "downloadAPK: resuming from $partialBytes bytes (expected=$expectedSize)")
|
|
662
|
+
}
|
|
663
|
+
else -> partialFile.delete()
|
|
427
664
|
}
|
|
428
|
-
OneKeyLog.info("AppUpdate", "downloadAPK: existing APK invalid, deleting and re-downloading...")
|
|
429
|
-
downloadedFile.delete()
|
|
430
665
|
}
|
|
431
666
|
|
|
667
|
+
// Phase 3 — fetch (with Range header iff resuming).
|
|
432
668
|
val client = OkHttpClient.Builder()
|
|
433
669
|
.connectTimeout(10, TimeUnit.SECONDS)
|
|
434
670
|
.readTimeout(60, TimeUnit.SECONDS)
|
|
435
671
|
.followRedirects(false)
|
|
436
672
|
.followSslRedirects(false)
|
|
437
673
|
.build()
|
|
438
|
-
val
|
|
439
|
-
|
|
674
|
+
val requestBuilder = Request.Builder().url(url)
|
|
675
|
+
if (partialBytes > 0) {
|
|
676
|
+
requestBuilder.addHeader("Range", "bytes=$partialBytes-")
|
|
677
|
+
}
|
|
678
|
+
sendEvent("update/start")
|
|
679
|
+
OneKeyLog.info("AppUpdate", "downloadAPK: starting download (resume=${partialBytes > 0})...")
|
|
680
|
+
|
|
681
|
+
val response = client.newCall(requestBuilder.build()).execute()
|
|
682
|
+
|
|
683
|
+
// 416 Range Not Satisfiable: server says our offset is past the
|
|
684
|
+
// file length. Two sub-cases distinguishable from
|
|
685
|
+
// `Content-Range: bytes */<total>`:
|
|
686
|
+
// (a) total == partialBytes → file is exactly complete on
|
|
687
|
+
// server; our partial IS the whole APK and just needs
|
|
688
|
+
// SHA verify + rename. Recover instead of wipe.
|
|
689
|
+
// (b) anything else → partial is corrupt or build changed.
|
|
690
|
+
// Wipe and bubble up.
|
|
691
|
+
if (response.code == 416) {
|
|
692
|
+
val contentRange = response.header("Content-Range")
|
|
693
|
+
response.close()
|
|
694
|
+
val totalFromHeader = contentRange
|
|
695
|
+
?.let { Regex("""bytes\s+\*\s*/\s*(\d+)""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() }
|
|
696
|
+
if (totalFromHeader != null && totalFromHeader == partialBytes && partialFile.exists()) {
|
|
697
|
+
OneKeyLog.info("AppUpdate", "downloadAPK: HTTP 416 with total=$totalFromHeader matches partial, attempting promote+verify")
|
|
698
|
+
when (tryPromoteAndVerify(url, filePath, partialFile, downloadedFile)) {
|
|
699
|
+
PromoteOutcome.Valid -> {
|
|
700
|
+
OneKeyLog.info("AppUpdate", "downloadAPK: 416 recovery succeeded, skipping download")
|
|
701
|
+
sendEvent("update/downloaded")
|
|
702
|
+
return@async
|
|
703
|
+
}
|
|
704
|
+
PromoteOutcome.HashMismatch -> {
|
|
705
|
+
// Server says "you have it all" AND sizes match,
|
|
706
|
+
// but SHA doesn't: the upstream build was
|
|
707
|
+
// replaced after we started downloading. That's
|
|
708
|
+
// the actual failure — raw "HTTP 416" misleads
|
|
709
|
+
// anyone reading the error.
|
|
710
|
+
OneKeyLog.warn("AppUpdate", "downloadAPK: 416 recovery hash mismatch — server build changed mid-download")
|
|
711
|
+
if (partialFile.exists()) partialFile.delete()
|
|
712
|
+
throw java.io.IOException("Server build changed mid-download (size matches but hash differs)")
|
|
713
|
+
}
|
|
714
|
+
PromoteOutcome.Deferred -> {
|
|
715
|
+
throw ApkVerificationDeferredException()
|
|
716
|
+
}
|
|
717
|
+
PromoteOutcome.RenameFailed -> {
|
|
718
|
+
// Bytes still at .partial; surface a transient
|
|
719
|
+
// error so caller retries (and we'll try the
|
|
720
|
+
// promotion again next pass).
|
|
721
|
+
OneKeyLog.warn("AppUpdate", "downloadAPK: 416 recovery rename failed, retry later")
|
|
722
|
+
throw java.io.IOException("Failed to finalize 416 recovery (rename failed)")
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
OneKeyLog.warn("AppUpdate", "downloadAPK: HTTP 416 (range not satisfiable), discarding partial and failing attempt")
|
|
727
|
+
if (partialFile.exists()) partialFile.delete()
|
|
728
|
+
throw Exception("HTTP 416 (range not satisfiable)")
|
|
729
|
+
}
|
|
440
730
|
|
|
441
|
-
if (!response.isSuccessful) {
|
|
731
|
+
if (!response.isSuccessful || (response.code != 200 && response.code != 206)) {
|
|
442
732
|
OneKeyLog.error("AppUpdate", "downloadAPK: HTTP error, statusCode=${response.code}")
|
|
733
|
+
response.close()
|
|
443
734
|
sendEvent("update/error", message = response.code.toString())
|
|
444
735
|
throw Exception(response.code.toString())
|
|
445
736
|
}
|
|
446
737
|
|
|
447
|
-
val
|
|
448
|
-
|
|
449
|
-
OneKeyLog.info("AppUpdate", "downloadAPK: HTTP 200, contentLength=$contentLength, starting download...")
|
|
450
|
-
val source = body.source()
|
|
451
|
-
val sink = downloadedFile.sink().buffer()
|
|
452
|
-
val sinkBuffer = sink.buffer
|
|
738
|
+
val expectsResume = partialBytes > 0
|
|
739
|
+
var serverWillResume = response.code == 206
|
|
453
740
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
741
|
+
// Server may legally ignore Range and reply 200 with the full
|
|
742
|
+
// body. Drop the stale partial and restart from byte zero.
|
|
743
|
+
if (expectsResume && !serverWillResume) {
|
|
744
|
+
OneKeyLog.warn("AppUpdate", "downloadAPK: requested Range but server returned 200, restarting from scratch")
|
|
745
|
+
if (partialFile.exists()) partialFile.delete()
|
|
746
|
+
partialBytes = 0L
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// 206 sanity check: the server MUST tell us where its body
|
|
750
|
+
// starts. If `Content-Range: bytes start-end/total` is missing
|
|
751
|
+
// or `start != partialBytes` (CDN bug / proxy rewrite), we'd
|
|
752
|
+
// be appending mis-aligned bytes and only catching it later
|
|
753
|
+
// at the SHA step — with the partial now corrupted. Demote to
|
|
754
|
+
// a full restart instead.
|
|
755
|
+
val rangeRegex = Regex("""bytes\s+(\d+)\s*-\s*(\d+)\s*/\s*(\d+|\*)""")
|
|
756
|
+
val rangeMatch = if (serverWillResume) {
|
|
757
|
+
response.header("Content-Range")?.let { rangeRegex.find(it) }
|
|
758
|
+
} else null
|
|
759
|
+
if (serverWillResume) {
|
|
760
|
+
val rangeStart = rangeMatch?.groupValues?.getOrNull(1)?.toLongOrNull()
|
|
761
|
+
if (rangeStart == null || rangeStart != partialBytes) {
|
|
762
|
+
OneKeyLog.warn("AppUpdate", "downloadAPK: 206 Content-Range start mismatch (header='${response.header("Content-Range")}', requested=$partialBytes); treating as full restart")
|
|
763
|
+
if (partialFile.exists()) partialFile.delete()
|
|
764
|
+
partialBytes = 0L
|
|
765
|
+
serverWillResume = false
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
val body = response.body ?: run {
|
|
770
|
+
response.close()
|
|
771
|
+
throw Exception("Empty response body")
|
|
772
|
+
}
|
|
773
|
+
val contentLength = body.contentLength()
|
|
774
|
+
val totalSize: Long = if (serverWillResume) {
|
|
775
|
+
val parsedTotal = rangeMatch?.groupValues?.getOrNull(3)?.toLongOrNull()
|
|
776
|
+
parsedTotal ?: (partialBytes + contentLength.coerceAtLeast(0L))
|
|
777
|
+
} else {
|
|
778
|
+
if (contentLength > 0) contentLength else expectedSize
|
|
779
|
+
}
|
|
780
|
+
val isPartialResponse = serverWillResume
|
|
781
|
+
OneKeyLog.info("AppUpdate", "downloadAPK: HTTP ${response.code}, contentLength=$contentLength, totalSize=$totalSize, partialBytes=$partialBytes, downloading...")
|
|
782
|
+
|
|
783
|
+
val parentDir = partialFile.parentFile
|
|
784
|
+
if (parentDir != null && !parentDir.exists()) {
|
|
785
|
+
parentDir.mkdirs()
|
|
786
|
+
OneKeyLog.info("AppUpdate", "downloadAPK: created parent directory: ${parentDir.absolutePath}")
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Append iff server granted us a 206. On a 200 (full body
|
|
790
|
+
// restart) we truncate the partial file.
|
|
791
|
+
val appendMode = isPartialResponse
|
|
792
|
+
var totalBytesRead = if (isPartialResponse) partialBytes else 0L
|
|
793
|
+
// -1 sentinel (vs the old 0) so 0% emits exactly once on a
|
|
794
|
+
// fresh start. Listeners just set state from event.progress,
|
|
795
|
+
// so this is a benign improvement (no double-fire on resume —
|
|
796
|
+
// a resumed download's first event is already >0%).
|
|
797
|
+
var prevProgress = -1
|
|
798
|
+
|
|
799
|
+
body.byteStream().use { inputStream ->
|
|
800
|
+
FileOutputStream(partialFile.absolutePath, appendMode).use { outputStream ->
|
|
801
|
+
val buffer = ByteArray(8192)
|
|
802
|
+
var bytesRead: Int
|
|
803
|
+
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
|
|
804
|
+
outputStream.write(buffer, 0, bytesRead)
|
|
805
|
+
totalBytesRead += bytesRead
|
|
806
|
+
if (totalSize > 0) {
|
|
807
|
+
val progress = ((totalBytesRead * 100) / totalSize).toInt().coerceIn(0, 100)
|
|
808
|
+
if (progress != prevProgress) {
|
|
809
|
+
sendEvent("update/downloading", progress = progress)
|
|
810
|
+
OneKeyLog.info("AppUpdate", "download progress: $progress% ($totalBytesRead/$totalSize)")
|
|
811
|
+
builder.setProgress(100, progress, false)
|
|
812
|
+
if (ActivityCompat.checkSelfPermission(
|
|
813
|
+
context, android.Manifest.permission.POST_NOTIFICATIONS
|
|
814
|
+
) == PackageManager.PERMISSION_GRANTED
|
|
815
|
+
) {
|
|
816
|
+
notifyManager.notify(NOTIFICATION_ID, builder.build())
|
|
817
|
+
}
|
|
818
|
+
prevProgress = progress
|
|
476
819
|
}
|
|
477
|
-
prevProgress = progress
|
|
478
820
|
}
|
|
479
821
|
}
|
|
480
822
|
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
OneKeyLog.info("AppUpdate", "downloadAPK: download finished, totalBytesRead=$totalBytesRead, finalizing...")
|
|
826
|
+
|
|
827
|
+
// Promote .partial -> final ONLY after the full transfer. Doing
|
|
828
|
+
// it before would mean a SHA mismatch leaves a half-baked
|
|
829
|
+
// filePath that the next call would mistake for a cached good
|
|
830
|
+
// APK.
|
|
831
|
+
if (downloadedFile.exists()) downloadedFile.delete()
|
|
832
|
+
if (!partialFile.renameTo(downloadedFile)) {
|
|
833
|
+
OneKeyLog.error("AppUpdate", "downloadAPK: rename .partial -> final failed")
|
|
834
|
+
throw Exception("Failed to finalize download")
|
|
485
835
|
}
|
|
486
836
|
|
|
487
837
|
OneKeyLog.info("AppUpdate", "Download completed")
|