@onekeyfe/react-native-app-update 3.0.36 → 3.0.38

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.
@@ -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
- * Check if an existing APK is valid by verifying GPG signature and SHA256 against the ASC file.
313
- * Downloads ASC if not present. Returns true if APK is valid.
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 fun tryVerifyExistingApk(url: String, filePath: String, apkFile: File): Boolean {
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", "tryVerifyExistingApk: ASC not found, downloading from $ascUrl")
323
- if (downloadAscFile(ascUrl, ascFile) == null) {
324
- OneKeyLog.warn("AppUpdate", "tryVerifyExistingApk: ASC download failed")
325
- return false
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", "tryVerifyExistingApk: ASC downloaded to ${ascFile.absolutePath}")
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
- OneKeyLog.warn("AppUpdate", "tryVerifyExistingApk: GPG verification or SHA256 extraction failed")
333
- return false
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", "tryVerifyExistingApk: computing SHA256 of existing APK (size=${apkFile.length()})...")
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", "tryVerifyExistingApk: SHA256 matches, APK is valid")
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.warn("AppUpdate", "tryVerifyExistingApk: SHA256 mismatch, expected=${expectedSha256.take(16)}..., got=${actualSha256.take(16)}...")
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.warn("AppUpdate", "tryVerifyExistingApk: failed: ${e.javaClass.simpleName}: ${e.message}")
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
- OneKeyLog.info("AppUpdate", "downloadAPK: existing APK found (size=${downloadedFile.length()}), verifying...")
423
- if (tryVerifyExistingApk(url, filePath, downloadedFile)) {
424
- OneKeyLog.info("AppUpdate", "downloadAPK: existing APK is valid, skipping download")
425
- sendEvent("update/downloaded")
426
- return@async
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 request = Request.Builder().url(url).build()
439
- val response = client.newCall(request).execute()
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 body = response.body ?: throw Exception("Empty response body")
448
- val contentLength = if (fileSize > 0) fileSize else body.contentLength()
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
- var totalBytesRead = 0L
455
- val bufferSize = 8 * 1024L
456
- sendEvent("update/start")
457
- var prevProgress = 0
458
-
459
- try {
460
- while (true) {
461
- val bytesRead = source.read(sinkBuffer, bufferSize)
462
- if (bytesRead == -1L) break
463
- sink.emit()
464
- totalBytesRead += bytesRead
465
- if (contentLength > 0) {
466
- val progress = ((totalBytesRead * 100) / contentLength).toInt()
467
- if (prevProgress != progress) {
468
- sendEvent("update/downloading", progress = progress)
469
- OneKeyLog.info("AppUpdate", "download progress: $progress%")
470
- builder.setProgress(100, progress, false)
471
- if (ActivityCompat.checkSelfPermission(
472
- context, android.Manifest.permission.POST_NOTIFICATIONS
473
- ) == PackageManager.PERMISSION_GRANTED
474
- ) {
475
- notifyManager.notify(NOTIFICATION_ID, builder.build())
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
- } finally {
482
- sink.flush()
483
- sink.close()
484
- source.close()
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")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-app-update",
3
- "version": "3.0.36",
3
+ "version": "3.0.38",
4
4
  "description": "react-native-app-update",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",