@onekeyfe/react-native-app-update 3.0.180 → 6.7.0-alpha.310

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.
@@ -10,6 +10,7 @@ import androidx.core.app.ActivityCompat
10
10
  import androidx.core.app.NotificationCompat
11
11
  import androidx.core.app.NotificationManagerCompat
12
12
  import androidx.core.content.FileProvider
13
+ import androidx.core.content.pm.PackageInfoCompat
13
14
  import com.facebook.proguard.annotations.DoNotStrip
14
15
  import com.margelo.nitro.core.Promise
15
16
  import com.margelo.nitro.NitroModules
@@ -23,10 +24,12 @@ import java.io.FileInputStream
23
24
  import java.io.FileOutputStream
24
25
  import java.io.InputStreamReader
25
26
  import java.util.concurrent.CopyOnWriteArrayList
27
+ import java.util.concurrent.CountDownLatch
26
28
  import java.util.concurrent.TimeUnit
27
29
  import java.util.concurrent.atomic.AtomicBoolean
28
30
  import java.util.concurrent.atomic.AtomicInteger
29
31
  import java.util.concurrent.atomic.AtomicLong
32
+ import org.json.JSONObject
30
33
  import com.margelo.nitro.reactnativebundlecrypto.BundleCryptoCore
31
34
  // Shared 8-range concurrent downloader (segment-file model, no whole-file
32
35
  // pre-allocation). Previously app-update bundled its own private copy; it now
@@ -39,6 +42,49 @@ private data class Listener(
39
42
  val callback: (DownloadEvent) -> Unit
40
43
  )
41
44
 
45
+ private class AppUpdateError(
46
+ val code: String,
47
+ val step: String,
48
+ val httpStatus: Int? = null,
49
+ ) : Exception(buildString {
50
+ append("ONEKEY_UPDATE_ERROR:")
51
+ append(JSONObject().put("code", code).put("step", step).apply {
52
+ if (code == "HTTP_ERROR" && httpStatus != null && httpStatus in 100..599) {
53
+ put("httpStatus", httpStatus)
54
+ }
55
+ }.toString())
56
+ }) {
57
+ override fun printStackTrace(writer: java.io.PrintWriter) { writer.print(message.orEmpty()) }
58
+ override fun printStackTrace(stream: java.io.PrintStream) { stream.print(message.orEmpty()) }
59
+ }
60
+
61
+ private fun appUpdateError(error: Exception, step: String): AppUpdateError {
62
+ if (error is AppUpdateError) {
63
+ return if (error.step == step) error else AppUpdateError(error.code, step, error.httpStatus)
64
+ }
65
+ val code = when (error) {
66
+ is ConcurrentRangeDownloader.PermanentHttpException,
67
+ is ConcurrentRangeDownloader.TransientHttpException -> "HTTP_ERROR"
68
+ is java.net.SocketTimeoutException -> "TIMEOUT"
69
+ is java.net.UnknownHostException,
70
+ is java.net.ConnectException,
71
+ is java.net.ProtocolException,
72
+ is java.net.SocketException -> "NETWORK_ERROR"
73
+ is javax.net.ssl.SSLException -> "SECURITY_REJECTED"
74
+ is java.io.FileNotFoundException,
75
+ is java.nio.file.AccessDeniedException -> "STORAGE_ERROR"
76
+ is IllegalArgumentException -> "INVALID_ARGUMENT"
77
+ is SecurityException -> "SECURITY_REJECTED"
78
+ else -> "UNKNOWN"
79
+ }
80
+ val status = when (error) {
81
+ is ConcurrentRangeDownloader.PermanentHttpException -> error.code
82
+ is ConcurrentRangeDownloader.TransientHttpException -> error.code
83
+ else -> null
84
+ }
85
+ return AppUpdateError(code, step, status)
86
+ }
87
+
42
88
  @DoNotStrip
43
89
  class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
44
90
 
@@ -63,6 +109,10 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
63
109
  // react-native-bundle-update's activeDownloads cancel wiring.
64
110
  private val activeDownload =
65
111
  java.util.concurrent.atomic.AtomicReference<ConcurrentRangeDownloader.CancelHandle?>(null)
112
+ // The handle stops range workers; this latch also covers ASC and single-stream I/O.
113
+ private var activeDownloadFinished: CountDownLatch? = null
114
+ // Guarded by activeDownload; cleanup owns the slot until its file wipe ends.
115
+ private var clearingDownload = false
66
116
  // downloadThread removed: downloads use coroutine-based Promise.async, not raw threads
67
117
 
68
118
  private fun sendEvent(type: String, progress: Int = 0, message: String = "") {
@@ -76,6 +126,17 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
76
126
  }
77
127
  }
78
128
 
129
+ private fun <T> updatePromise(step: String, work: suspend () -> T): Promise<T> {
130
+ return Promise.async {
131
+ try {
132
+ work()
133
+ } catch (e: Exception) {
134
+ OneKeyLog.error("AppUpdate", "$step failed: ${e.javaClass.simpleName}: ${e.message}")
135
+ throw if (e is ApkVerificationDeferredException) appUpdateError(e.reason, step) else appUpdateError(e, step)
136
+ }
137
+ }
138
+ }
139
+
79
140
  override fun addDownloadListener(callback: (DownloadEvent) -> Unit): Double {
80
141
  val id = nextListenerId.getAndIncrement().toDouble()
81
142
  listeners.add(Listener(id, callback))
@@ -119,37 +180,45 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
119
180
 
120
181
  private fun buildFile(path: String): File {
121
182
  val stripped = path.removePrefix("file:///")
122
- val context = NitroModules.applicationContext
183
+ NitroModules.applicationContext
123
184
  ?: throw SecurityException("Application context unavailable, cannot validate file path")
124
185
 
125
- // Resolve relative paths against cacheDir/apk/
186
+ val apkDirFile = getApkDownloadDir()
126
187
  val file = if (stripped.startsWith("/")) {
127
188
  File(stripped)
128
189
  } else {
129
- File(getApkDownloadDir(), stripped)
190
+ File(apkDirFile, stripped)
130
191
  }
131
192
 
132
- // Validate the resolved path is within the app's cache or files directory
193
+ // Validate the resolved path is strictly within the APK download directory (filesDir/apks)
133
194
  val canonicalPath = file.canonicalPath
134
- val cacheDir = context.cacheDir.canonicalPath
135
- val filesDir = context.filesDir.canonicalPath
136
- val externalCacheDir = context.externalCacheDir?.canonicalPath
137
- val externalFilesDir = context.getExternalFilesDir(null)?.canonicalPath
138
- val allowed = canonicalPath.startsWith(cacheDir + File.separator) || canonicalPath == cacheDir ||
139
- canonicalPath.startsWith(filesDir + File.separator) || canonicalPath == filesDir ||
140
- (externalCacheDir != null && (canonicalPath.startsWith(externalCacheDir + File.separator) || canonicalPath == externalCacheDir)) ||
141
- (externalFilesDir != null && (canonicalPath.startsWith(externalFilesDir + File.separator) || canonicalPath == externalFilesDir))
195
+ val apkDir = apkDirFile.canonicalPath
196
+ val allowed = canonicalPath.startsWith(apkDir + File.separator) || canonicalPath == apkDir
142
197
  if (!allowed) {
143
- OneKeyLog.error("AppUpdate", "buildFile: path outside allowed directories, " +
144
- "input=$path, resolved=$canonicalPath, " +
145
- "cacheDir=$cacheDir, filesDir=$filesDir, " +
146
- "externalCacheDir=$externalCacheDir, externalFilesDir=$externalFilesDir")
198
+ OneKeyLog.error("AppUpdate", "buildFile: path outside allowed apk directory, " +
199
+ "input=$path, resolved=$canonicalPath, apkDir=$apkDir")
147
200
  throw SecurityException("Path outside allowed directories: $canonicalPath")
148
201
  }
149
202
  OneKeyLog.debug("AppUpdate", "buildFile: input=$path, resolved=$canonicalPath")
150
203
  return file
151
204
  }
152
205
 
206
+ private fun buildHttpsOnlyHttpClient(readTimeoutSeconds: Long): OkHttpClient {
207
+ return OkHttpClient.Builder()
208
+ .connectTimeout(10, TimeUnit.SECONDS)
209
+ .readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
210
+ .followRedirects(true)
211
+ .followSslRedirects(true)
212
+ .addNetworkInterceptor { chain ->
213
+ val req = chain.request()
214
+ if (!req.url.isHttps) {
215
+ throw SecurityException("Redirect to non-HTTPS URL is not allowed: ${req.url}")
216
+ }
217
+ chain.proceed(req)
218
+ }
219
+ .build()
220
+ }
221
+
153
222
  /**
154
223
  * Thin delegate over the shared BundleCryptoCore.sha256OfFile. The streaming
155
224
  * MessageDigest implementation now lives in react-native-bundle-crypto.
@@ -161,7 +230,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
161
230
  private fun computeSha256(file: File): String {
162
231
  val result = BundleCryptoCore.sha256OfFile(file.absolutePath)
163
232
  return result.sha256
164
- ?: throw java.io.IOException("SHA256 computation failed: ${result.failureReason}")
233
+ ?: throw AppUpdateError("STORAGE_ERROR", "verifyPackage")
165
234
  }
166
235
 
167
236
  /**
@@ -169,30 +238,38 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
169
238
  * Returns the saved file, or null on failure.
170
239
  */
171
240
  private fun downloadAscFile(ascUrl: String, ascFile: File): File? {
172
- val client = OkHttpClient.Builder()
173
- .connectTimeout(10, TimeUnit.SECONDS)
174
- .readTimeout(30, TimeUnit.SECONDS)
175
- .followRedirects(false)
176
- .followSslRedirects(false)
177
- .build()
241
+ val client = buildHttpsOnlyHttpClient(30)
178
242
  val request = Request.Builder().url(ascUrl).build()
179
243
  val content = StringBuilder()
180
244
  val maxAscSize = 10 * 1024
245
+ try {
181
246
  client.newCall(request).execute().use { response ->
182
- if (!response.isSuccessful) return null
183
- val body = response.body ?: return null
184
- BufferedReader(InputStreamReader(body.byteStream())).use { reader ->
185
- var line: String?
186
- while (reader.readLine().also { line = it } != null) {
187
- content.append(line).append("\n")
188
- if (content.length > maxAscSize) return null
247
+ if (!response.isSuccessful) throw AppUpdateError("HTTP_ERROR", "downloadASC", response.code)
248
+ val body = response.body ?: throw AppUpdateError("NETWORK_ERROR", "downloadASC")
249
+ InputStreamReader(body.byteStream(), Charsets.UTF_8).use { reader ->
250
+ val buf = CharArray(1024)
251
+ var n: Int
252
+ while (reader.read(buf).also { n = it } != -1) {
253
+ if (content.length + n > maxAscSize) throw AppUpdateError("SECURITY_REJECTED", "downloadASC")
254
+ content.append(buf, 0, n)
189
255
  }
190
256
  }
191
257
  }
258
+ } catch (e: java.io.IOException) {
259
+ OneKeyLog.error("AppUpdate", "downloadAscFile: network read failed: ${e.javaClass.simpleName}: ${e.message}")
260
+ throw appUpdateError(e, "downloadASC").let {
261
+ if (it.code == "UNKNOWN") AppUpdateError("NETWORK_ERROR", "downloadASC") else it
262
+ }
263
+ }
192
264
  val ascContent = content.toString()
193
- if (ascContent.isEmpty()) return null
265
+ if (ascContent.isEmpty()) throw AppUpdateError("INTEGRITY_FAILED", "downloadASC")
194
266
  ascFile.parentFile?.mkdirs()
195
- FileOutputStream(ascFile).use { fos -> fos.write(ascContent.toByteArray()) }
267
+ try {
268
+ FileOutputStream(ascFile).use { fos -> fos.write(ascContent.toByteArray(Charsets.UTF_8)) }
269
+ } catch (e: java.io.IOException) {
270
+ OneKeyLog.error("AppUpdate", "downloadAscFile: disk write failed: ${e.javaClass.simpleName}: ${e.message}")
271
+ throw AppUpdateError("STORAGE_ERROR", "downloadASC")
272
+ }
196
273
  return ascFile
197
274
  }
198
275
 
@@ -209,10 +286,13 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
209
286
  * public key now live there as the single source of truth. Behavior is
210
287
  * preserved: any non-valid result (NOT_PGP_SIGNED_MESSAGE / INVALID_FORMAT /
211
288
  * NO_SIGNATURE / PUBKEY_NOT_FOUND / SIGNATURE_INVALID / SHA256_TOKEN_INVALID
212
- * / ERROR_*) maps to null.
289
+ * / FILENAME_MISMATCH / ERROR_*) maps to null.
213
290
  */
214
- private fun verifyAscContentAndExtractSha256(ascContent: String): String? {
215
- val result = BundleCryptoCore.verifyDetachedAsc(ascContent)
291
+ private fun verifyAscContentAndExtractSha256(
292
+ ascContent: String,
293
+ expectedFileName: String? = null,
294
+ ): String? {
295
+ val result = BundleCryptoCore.verifyDetachedAsc(ascContent, expectedFileName)
216
296
  if (!result.valid) {
217
297
  OneKeyLog.error("AppUpdate", "ASC verification failed: ${result.reason}")
218
298
  return null
@@ -220,8 +300,11 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
220
300
  return result.sha256
221
301
  }
222
302
 
223
- private fun verifyAscAndExtractSha256(ascFile: File): String? {
224
- return verifyAscContentAndExtractSha256(ascFile.readText())
303
+ private fun verifyAscAndExtractSha256(
304
+ ascFile: File,
305
+ expectedFileName: String? = null,
306
+ ): String? {
307
+ return verifyAscContentAndExtractSha256(ascFile.readText(), expectedFileName)
225
308
  }
226
309
 
227
310
  /**
@@ -244,7 +327,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
244
327
  private sealed class ApkVerifyOutcome {
245
328
  object Valid : ApkVerifyOutcome()
246
329
  object HashMismatch : ApkVerifyOutcome()
247
- object Indeterminate : ApkVerifyOutcome()
330
+ data class Indeterminate(val reason: AppUpdateError) : ApkVerifyOutcome()
248
331
  }
249
332
 
250
333
  /**
@@ -261,47 +344,40 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
261
344
  if (!ascFile.exists()) {
262
345
  val ascUrl = "$url.SHA256SUMS.asc"
263
346
  OneKeyLog.info("AppUpdate", "verifyExistingApk: ASC not found, downloading from $ascUrl")
264
- val downloaded = try {
265
- downloadAscFile(ascUrl, ascFile)
266
- } catch (e: Exception) {
267
- // OkHttp throws IOException family on offline / DNS / connection-reset.
268
- // Treat as Indeterminate so caller preserves the partial.
269
- OneKeyLog.warn("AppUpdate", "verifyExistingApk: ASC download threw ${e.javaClass.simpleName}: ${e.message}")
270
- null
271
- }
272
- if (downloaded == null) {
273
- OneKeyLog.warn("AppUpdate", "verifyExistingApk: ASC unavailable, indeterminate")
274
- return ApkVerifyOutcome.Indeterminate
275
- }
347
+ val downloaded = downloadAscFile(ascUrl, ascFile)
276
348
  OneKeyLog.info("AppUpdate", "verifyExistingApk: ASC downloaded to ${ascFile.absolutePath}")
277
349
  }
278
350
 
279
- val expectedSha256 = verifyAscAndExtractSha256(ascFile)
351
+ val expectedSha256 = verifyAscAndExtractSha256(ascFile, apkFile.name)
280
352
  if (expectedSha256 == null) {
281
353
  // Local ASC failed parsing/GPG. Could be a corrupted cache from
282
354
  // an earlier interrupted write — drop it so the next round
283
355
  // re-downloads cleanly. Don't condemn the APK on this alone.
284
356
  OneKeyLog.warn("AppUpdate", "verifyExistingApk: GPG verification or SHA256 extraction failed, discarding local ASC")
285
357
  ascFile.delete()
286
- return ApkVerifyOutcome.Indeterminate
358
+ throw AppUpdateError("INTEGRITY_FAILED", "verifyASC")
287
359
  }
288
360
 
289
361
  OneKeyLog.info("AppUpdate", "verifyExistingApk: computing SHA256 of existing APK (size=${apkFile.length()})...")
290
362
  val actualSha256 = computeSha256(apkFile)
291
363
 
292
364
  if (secureCompare(actualSha256, expectedSha256)) {
365
+ ascVerifiedFiles[apkFile.canonicalPath] = actualSha256.lowercase()
293
366
  OneKeyLog.info("AppUpdate", "verifyExistingApk: SHA256 matches, APK is valid")
294
367
  ApkVerifyOutcome.Valid
295
368
  } else {
296
369
  OneKeyLog.warn("AppUpdate", "verifyExistingApk: SHA256 mismatch, expected=${expectedSha256.take(16)}..., got=${actualSha256.take(16)}...")
297
370
  ApkVerifyOutcome.HashMismatch
298
371
  }
372
+ } catch (e: AppUpdateError) {
373
+ OneKeyLog.warn("AppUpdate", "verifyExistingApk: verification unavailable: ${e.code}")
374
+ ApkVerifyOutcome.Indeterminate(e)
299
375
  } catch (e: java.io.IOException) {
300
376
  // Offline / disk-I/O transient (ASC fetch or APK read): we genuinely
301
377
  // cannot decide validity right now, so preserve the bytes and defer to
302
378
  // the next online retry (Indeterminate → caller rolls back to .partial).
303
379
  OneKeyLog.warn("AppUpdate", "verifyExistingApk: transient I/O failure, indeterminate: ${e.javaClass.simpleName}: ${e.message}")
304
- ApkVerifyOutcome.Indeterminate
380
+ ApkVerifyOutcome.Indeterminate(AppUpdateError("STORAGE_ERROR", "download"))
305
381
  } catch (e: Exception) {
306
382
  // A non-I/O failure (corrupt local state, path/security rejection,
307
383
  // runtime fault) is NOT a "can't tell because offline" case — it is a
@@ -323,7 +399,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
323
399
  * instead of substring-matching the message — which would silently
324
400
  * rot the moment we tweak the wording.
325
401
  */
326
- private class ApkVerificationDeferredException :
402
+ private class ApkVerificationDeferredException(val reason: AppUpdateError) :
327
403
  java.io.IOException(DEFERRED_VERIFICATION_MESSAGE) {
328
404
  companion object {
329
405
  const val DEFERRED_VERIFICATION_MESSAGE = "APK verification deferred: ASC unavailable"
@@ -377,7 +453,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
377
453
  private sealed class PromoteOutcome {
378
454
  object Valid : PromoteOutcome()
379
455
  object HashMismatch : PromoteOutcome()
380
- object Deferred : PromoteOutcome() // verifier Indeterminate; bytes restored to .partial
456
+ data class Deferred(val reason: AppUpdateError) : PromoteOutcome() // bytes restored to .partial
381
457
  object RenameFailed : PromoteOutcome() // promotion rename failed; bytes preserved at .partial
382
458
  }
383
459
 
@@ -408,17 +484,18 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
408
484
  if (downloadedFile.exists()) downloadedFile.delete()
409
485
  return PromoteOutcome.RenameFailed
410
486
  }
411
- return when (verifyExistingApk(url, filePath, downloadedFile)) {
487
+ val verification = verifyExistingApk(url, filePath, downloadedFile)
488
+ return when (verification) {
412
489
  ApkVerifyOutcome.Valid -> PromoteOutcome.Valid
413
490
  ApkVerifyOutcome.HashMismatch -> {
414
491
  downloadedFile.delete()
415
492
  PromoteOutcome.HashMismatch
416
493
  }
417
- ApkVerifyOutcome.Indeterminate -> {
494
+ is ApkVerifyOutcome.Indeterminate -> {
418
495
  if (!rollbackFinalToPartial(downloadedFile, partialFile)) {
419
496
  OneKeyLog.error("AppUpdate", "tryPromoteAndVerify: rollback failed for both rename and byte copy; bytes lost")
420
497
  }
421
- PromoteOutcome.Deferred
498
+ PromoteOutcome.Deferred(verification.reason)
422
499
  }
423
500
  }
424
501
  }
@@ -454,15 +531,21 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
454
531
  }
455
532
 
456
533
  override fun downloadAPK(params: AppUpdateDownloadParams): Promise<Unit> {
457
- return Promise.async {
458
- if (isDownloading.getAndSet(true)) {
459
- OneKeyLog.warn("AppUpdate", "downloadAPK: rejected, already downloading")
460
- throw Exception("Download already in progress")
534
+ return updatePromise("download") {
535
+ val cancelHandle = ConcurrentRangeDownloader.CancelHandle()
536
+ val finished = CountDownLatch(1)
537
+ synchronized(activeDownload) {
538
+ if (isDownloading.get() || clearingDownload) {
539
+ OneKeyLog.warn("AppUpdate", "downloadAPK: rejected, already downloading")
540
+ throw AppUpdateError("INVALID_ARGUMENT", "download")
541
+ }
542
+ isDownloading.set(true)
543
+ activeDownload.set(cancelHandle)
544
+ activeDownloadFinished = finished
545
+ }
546
+ fun ensureNotCancelled() {
547
+ if (cancelHandle.aborted.get()) throw AppUpdateError("CANCELLED", "download")
461
548
  }
462
-
463
- // Hoisted so the catch can tell an intentional clearCache-cancel from a
464
- // real failure (a cancel must NOT surface as an `update/error`).
465
- var cancelHandle: ConcurrentRangeDownloader.CancelHandle? = null
466
549
  try {
467
550
  val url = params.downloadUrl
468
551
  val filePath = filePathFromUrl(url)
@@ -472,7 +555,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
472
555
  OneKeyLog.info("AppUpdate", "downloadAPK: url=$url, filePath=$filePath, fileSize=$fileSize")
473
556
 
474
557
  val context = NitroModules.applicationContext
475
- ?: throw Exception("Application context unavailable")
558
+ ?: throw AppUpdateError("UNKNOWN", "download")
476
559
 
477
560
  val notifyManager = NotificationManagerCompat.from(context)
478
561
  val builder = NotificationCompat.Builder(context, CHANNEL_ID)
@@ -491,7 +574,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
491
574
 
492
575
  if (!url.startsWith("https://")) {
493
576
  OneKeyLog.error("AppUpdate", "downloadAPK: URL is not HTTPS: $url")
494
- throw Exception("Download URL must use HTTPS")
577
+ throw AppUpdateError("SECURITY_REJECTED", "download")
495
578
  }
496
579
 
497
580
  // Resume model (mirrors react-native-bundle-update's downloadBundle):
@@ -552,11 +635,13 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
552
635
  }
553
636
  }
554
637
  else -> {
555
- when (verifyExistingApk(url, filePath, downloadedFile)) {
638
+ val verification = verifyExistingApk(url, filePath, downloadedFile)
639
+ when (verification) {
556
640
  ApkVerifyOutcome.Valid -> {
641
+ ensureNotCancelled()
557
642
  OneKeyLog.info("AppUpdate", "downloadAPK: existing APK is valid, skipping download")
558
643
  sendEvent("update/downloaded")
559
- return@async
644
+ return@updatePromise
560
645
  }
561
646
  ApkVerifyOutcome.HashMismatch -> {
562
647
  OneKeyLog.info("AppUpdate", "downloadAPK: existing APK hash mismatch, deleting and re-downloading")
@@ -567,7 +652,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
567
652
  // it can't be mistaken for trustworthy in-flight bytes.
568
653
  for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(AppUpdateLogic.segmentFileName(partialFilePath, i)).delete()
569
654
  }
570
- ApkVerifyOutcome.Indeterminate -> {
655
+ is ApkVerifyOutcome.Indeterminate -> {
571
656
  // ASC could not be fetched (offline) or could
572
657
  // not be parsed. The on-disk APK might still
573
658
  // be the right one — surface a transient
@@ -575,7 +660,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
575
660
  // network and re-runs verify, instead of
576
661
  // wiping the bytes preemptively.
577
662
  OneKeyLog.warn("AppUpdate", "downloadAPK: cannot verify existing APK (ASC unavailable); preserving file and aborting this attempt")
578
- throw ApkVerificationDeferredException()
663
+ throw ApkVerificationDeferredException(verification.reason)
579
664
  }
580
665
  }
581
666
  }
@@ -606,19 +691,21 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
606
691
  // before promotion. Try promote + verify before
607
692
  // re-downloading.
608
693
  OneKeyLog.info("AppUpdate", "downloadAPK: partial matches expected size ($partialSize), trying promote+verify")
609
- when (tryPromoteAndVerify(url, filePath, partialFile, downloadedFile)) {
694
+ val promotion = tryPromoteAndVerify(url, filePath, partialFile, downloadedFile)
695
+ when (promotion) {
610
696
  PromoteOutcome.Valid -> {
697
+ ensureNotCancelled()
611
698
  OneKeyLog.info("AppUpdate", "downloadAPK: recovered crashed-before-rename APK, skipping download")
612
699
  sendEvent("update/downloaded")
613
- return@async
700
+ return@updatePromise
614
701
  }
615
702
  PromoteOutcome.HashMismatch -> {
616
703
  OneKeyLog.warn("AppUpdate", "downloadAPK: promoted partial failed hash check, discarding")
617
704
  // Helper already deleted final; partial slot empty.
618
705
  // Fall through: Phase 3 fetches from byte zero.
619
706
  }
620
- PromoteOutcome.Deferred -> {
621
- throw ApkVerificationDeferredException()
707
+ is PromoteOutcome.Deferred -> {
708
+ throw ApkVerificationDeferredException(promotion.reason)
622
709
  }
623
710
  PromoteOutcome.RenameFailed -> {
624
711
  // Bytes preserved at .partial: Phase 3 will Range-resume.
@@ -642,6 +729,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
642
729
  }
643
730
 
644
731
  // === Concurrent multi-range download (P2) ===
732
+ ensureNotCancelled()
645
733
  // Try the 8-range concurrent path first. On COMPLETED the
646
734
  // .partial is fully on disk, so we promote + emit downloaded
647
735
  // here (SHA/GPG verification still happens later via verifyAPK,
@@ -650,12 +738,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
650
738
  // outer catch (the downloader keeps its partial + manifest).
651
739
  sendEvent("update/start")
652
740
  run {
653
- val concurrentClient = OkHttpClient.Builder()
654
- .connectTimeout(10, TimeUnit.SECONDS)
655
- .readTimeout(60, TimeUnit.SECONDS)
656
- .followRedirects(false)
657
- .followSslRedirects(false)
658
- .build()
741
+ val concurrentClient = buildHttpsOnlyHttpClient(60)
659
742
  // onProgress is invoked concurrently from all 8 worker
660
743
  // threads. concurrentProgress must be an AtomicInteger so the
661
744
  // "did the percent advance?" check is race-free, and the
@@ -666,10 +749,6 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
666
749
  // the thread that actually moved the percent forward proceeds.
667
750
  val concurrentProgress = AtomicInteger(-1)
668
751
  val notifyLock = Any()
669
- // Wire a cancel handle so clearCache can stop these workers
670
- // before deleting .segN (OCDS §5.8). Cleared in the finally below.
671
- cancelHandle = ConcurrentRangeDownloader.CancelHandle()
672
- activeDownload.set(cancelHandle)
673
752
  val concurrentOutcome = ConcurrentRangeDownloader(
674
753
  httpClient = concurrentClient,
675
754
  log = { msg -> OneKeyLog.info("AppUpdate", msg) },
@@ -681,6 +760,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
681
760
  // the real apks dir (filesDir/apks) first, exactly like
682
761
  // react-native-bundle-update passes an absolute path.
683
762
  ).download(url, partialFile.absolutePath, cancelHandle) { transferred, total ->
763
+ if (cancelHandle.aborted.get()) return@download
684
764
  if (total > 0) {
685
765
  val p = ((transferred * 100) / total).toInt().coerceIn(0, 100)
686
766
  // Only the thread that advances the percent emits; a
@@ -700,13 +780,15 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
700
780
  }
701
781
  }
702
782
  }
783
+ ensureNotCancelled()
703
784
  if (concurrentOutcome == ConcurrentRangeDownloader.Outcome.COMPLETED) {
704
785
  if (downloadedFile.exists()) downloadedFile.delete()
705
786
  if (!partialFile.renameTo(downloadedFile)) {
706
787
  OneKeyLog.error("AppUpdate", "downloadAPK: concurrent rename .partial -> final failed")
707
- throw Exception("Failed to finalize download")
788
+ throw AppUpdateError("STORAGE_ERROR", "download")
708
789
  }
709
790
  OneKeyLog.info("AppUpdate", "downloadAPK: concurrent download completed")
791
+ ensureNotCancelled()
710
792
  sendEvent("update/downloaded")
711
793
  notifyManager.cancel(NOTIFICATION_ID)
712
794
  builder.setContentText("")
@@ -719,18 +801,13 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
719
801
  ) {
720
802
  notifyManager.notify(NOTIFICATION_ID, builder.build())
721
803
  }
722
- return@async
804
+ return@updatePromise
723
805
  }
724
806
  OneKeyLog.info("AppUpdate", "downloadAPK: concurrent not used, falling back to single-stream")
725
807
  }
726
808
 
727
809
  // Phase 3 — fetch (with Range header iff resuming).
728
- val client = OkHttpClient.Builder()
729
- .connectTimeout(10, TimeUnit.SECONDS)
730
- .readTimeout(60, TimeUnit.SECONDS)
731
- .followRedirects(false)
732
- .followSslRedirects(false)
733
- .build()
810
+ val client = buildHttpsOnlyHttpClient(60)
734
811
  val requestBuilder = Request.Builder().url(url)
735
812
  if (partialBytes > 0) {
736
813
  requestBuilder.addHeader("Range", "bytes=$partialBytes-")
@@ -739,6 +816,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
739
816
  OneKeyLog.info("AppUpdate", "downloadAPK: starting single-stream download (resume=${partialBytes > 0})...")
740
817
 
741
818
  val response = client.newCall(requestBuilder.build()).execute()
819
+ ensureNotCancelled()
742
820
 
743
821
  // 416 Range Not Satisfiable: server says our offset is past the
744
822
  // file length. Two sub-cases distinguishable from
@@ -754,11 +832,13 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
754
832
  val totalFromHeader = AppUpdateLogic.parse416Total(contentRange)
755
833
  if (AppUpdateLogic.is416Complete(totalFromHeader, partialBytes) && partialFile.exists()) {
756
834
  OneKeyLog.info("AppUpdate", "downloadAPK: HTTP 416 with total=$totalFromHeader matches partial, attempting promote+verify")
757
- when (tryPromoteAndVerify(url, filePath, partialFile, downloadedFile)) {
835
+ val promotion = tryPromoteAndVerify(url, filePath, partialFile, downloadedFile)
836
+ when (promotion) {
758
837
  PromoteOutcome.Valid -> {
838
+ ensureNotCancelled()
759
839
  OneKeyLog.info("AppUpdate", "downloadAPK: 416 recovery succeeded, skipping download")
760
840
  sendEvent("update/downloaded")
761
- return@async
841
+ return@updatePromise
762
842
  }
763
843
  PromoteOutcome.HashMismatch -> {
764
844
  // Server says "you have it all" AND sizes match,
@@ -770,17 +850,17 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
770
850
  if (partialFile.exists()) partialFile.delete()
771
851
  // Server build changed → these bytes are worthless; drop sibling .segN too.
772
852
  for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(AppUpdateLogic.segmentFileName(partialFilePath, i)).delete()
773
- throw java.io.IOException("Server build changed mid-download (size matches but hash differs)")
853
+ throw AppUpdateError("INTEGRITY_FAILED", "download")
774
854
  }
775
- PromoteOutcome.Deferred -> {
776
- throw ApkVerificationDeferredException()
855
+ is PromoteOutcome.Deferred -> {
856
+ throw ApkVerificationDeferredException(promotion.reason)
777
857
  }
778
858
  PromoteOutcome.RenameFailed -> {
779
859
  // Bytes still at .partial; surface a transient
780
860
  // error so caller retries (and we'll try the
781
861
  // promotion again next pass).
782
862
  OneKeyLog.warn("AppUpdate", "downloadAPK: 416 recovery rename failed, retry later")
783
- throw java.io.IOException("Failed to finalize 416 recovery (rename failed)")
863
+ throw AppUpdateError("STORAGE_ERROR", "download")
784
864
  }
785
865
  }
786
866
  }
@@ -788,14 +868,13 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
788
868
  if (partialFile.exists()) partialFile.delete()
789
869
  // Partial discarded as unusable → drop sibling .segN too so the next attempt starts clean.
790
870
  for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(AppUpdateLogic.segmentFileName(partialFilePath, i)).delete()
791
- throw Exception("HTTP 416 (range not satisfiable)")
871
+ throw AppUpdateError("HTTP_ERROR", "download", 416)
792
872
  }
793
873
 
794
874
  if (!response.isSuccessful || (response.code != 200 && response.code != 206)) {
795
875
  OneKeyLog.error("AppUpdate", "downloadAPK: HTTP error, statusCode=${response.code}")
796
876
  response.close()
797
- sendEvent("update/error", message = response.code.toString())
798
- throw Exception(response.code.toString())
877
+ throw AppUpdateError("HTTP_ERROR", "download", response.code)
799
878
  }
800
879
 
801
880
  val expectsResume = partialBytes > 0
@@ -838,13 +917,13 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
838
917
  if (partialFile.exists()) partialFile.delete()
839
918
  for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile(AppUpdateLogic.segmentFileName(partialFilePath, i)).delete()
840
919
  response.close()
841
- throw java.io.IOException("206 Content-Range start mismatch (header='$contentRangeHeader', requested=$partialBytes); discarded partial, retry from scratch")
920
+ throw AppUpdateError("INTEGRITY_FAILED", "download")
842
921
  }
843
922
  }
844
923
 
845
924
  val body = response.body ?: run {
846
925
  response.close()
847
- throw Exception("Empty response body")
926
+ throw AppUpdateError("NETWORK_ERROR", "download")
848
927
  }
849
928
  val contentLength = body.contentLength()
850
929
  val totalSize: Long = if (serverWillResume) {
@@ -872,13 +951,18 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
872
951
  // a resumed download's first event is already >0%).
873
952
  var prevProgress = -1
874
953
 
954
+ val maxApkSize = 512L * 1024 * 1024
875
955
  body.byteStream().use { inputStream ->
876
956
  FileOutputStream(partialFile.absolutePath, appendMode).use { outputStream ->
877
957
  val buffer = ByteArray(8192)
878
958
  var bytesRead: Int
879
959
  while (inputStream.read(buffer).also { bytesRead = it } != -1) {
880
- outputStream.write(buffer, 0, bytesRead)
960
+ ensureNotCancelled()
881
961
  totalBytesRead += bytesRead
962
+ if (totalBytesRead > maxApkSize) {
963
+ throw AppUpdateError("SECURITY_REJECTED", "download")
964
+ }
965
+ outputStream.write(buffer, 0, bytesRead)
882
966
  if (totalSize > 0) {
883
967
  val progress = ((totalBytesRead * 100) / totalSize).toInt().coerceIn(0, 100)
884
968
  if (progress != prevProgress) {
@@ -899,6 +983,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
899
983
  }
900
984
 
901
985
  OneKeyLog.info("AppUpdate", "downloadAPK: download finished, totalBytesRead=$totalBytesRead, finalizing...")
986
+ ensureNotCancelled()
902
987
 
903
988
  // Promote .partial -> final ONLY after the full transfer. Doing
904
989
  // it before would mean a SHA mismatch leaves a half-baked
@@ -907,10 +992,11 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
907
992
  if (downloadedFile.exists()) downloadedFile.delete()
908
993
  if (!partialFile.renameTo(downloadedFile)) {
909
994
  OneKeyLog.error("AppUpdate", "downloadAPK: rename .partial -> final failed")
910
- throw Exception("Failed to finalize download")
995
+ throw AppUpdateError("STORAGE_ERROR", "download")
911
996
  }
912
997
 
913
998
  OneKeyLog.info("AppUpdate", "Download completed")
999
+ ensureNotCancelled()
914
1000
  sendEvent("update/downloaded")
915
1001
 
916
1002
  notifyManager.cancel(NOTIFICATION_ID)
@@ -943,26 +1029,40 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
943
1029
  // attempt didn't complete); only the spurious `update/error` event is
944
1030
  // withheld. For the deferred case we also downgrade the log to info so
945
1031
  // logs don't read like a failure.
946
- val intentionallyCancelled = cancelHandle?.aborted?.get() == true
1032
+ val intentionallyCancelled = cancelHandle.aborted.get()
947
1033
  val verificationDeferred = e is ApkVerificationDeferredException
948
1034
  if (verificationDeferred) {
949
1035
  OneKeyLog.info("AppUpdate", "downloadAPK: verification deferred (ASC unavailable); bytes preserved for next online retry: ${e.message}")
950
1036
  } else {
951
1037
  OneKeyLog.error("AppUpdate", "downloadAPK: failed: ${e.javaClass.simpleName}: ${e.message}")
952
1038
  }
953
- if (!intentionallyCancelled && !verificationDeferred) {
954
- sendEvent("update/error", message = "${e.javaClass.simpleName}: ${e.message}")
1039
+ val updateError = if (intentionallyCancelled) {
1040
+ AppUpdateError("CANCELLED", "download")
1041
+ } else if (e is ApkVerificationDeferredException) {
1042
+ appUpdateError(e.reason, "download")
1043
+ } else {
1044
+ appUpdateError(e, "download")
1045
+ }
1046
+ val retryableDeferral = verificationDeferred && updateError.code in
1047
+ setOf("NETWORK_ERROR", "TIMEOUT", "HTTP_ERROR")
1048
+ if (!intentionallyCancelled && !retryableDeferral) {
1049
+ sendEvent("update/error", message = updateError.message.orEmpty())
955
1050
  }
956
- throw e
1051
+ throw updateError
957
1052
  } finally {
958
- activeDownload.set(null)
959
- isDownloading.set(false)
1053
+ finished.countDown()
1054
+ synchronized(activeDownload) {
1055
+ if (!clearingDownload && activeDownload.compareAndSet(cancelHandle, null)) {
1056
+ activeDownloadFinished = null
1057
+ isDownloading.set(false)
1058
+ }
1059
+ }
960
1060
  }
961
1061
  }
962
1062
  }
963
1063
 
964
1064
  override fun downloadASC(params: AppUpdateFileParams): Promise<Unit> {
965
- return Promise.async {
1065
+ return updatePromise("downloadASC") {
966
1066
  val url = params.downloadUrl
967
1067
  val filePath = filePathFromUrl(url)
968
1068
 
@@ -970,19 +1070,14 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
970
1070
 
971
1071
  if (!url.startsWith("https://")) {
972
1072
  OneKeyLog.error("AppUpdate", "downloadASC: URL is not HTTPS: $url")
973
- throw Exception("Download URL must use HTTPS")
1073
+ throw AppUpdateError("SECURITY_REJECTED", "downloadASC")
974
1074
  }
975
1075
 
976
1076
  val ascFileUrl = "$url.SHA256SUMS.asc"
977
1077
  val ascFilePath = "$filePath.SHA256SUMS.asc"
978
1078
  OneKeyLog.info("AppUpdate", "downloadASC: ascFileUrl=$ascFileUrl")
979
1079
 
980
- val client = OkHttpClient.Builder()
981
- .connectTimeout(10, TimeUnit.SECONDS)
982
- .readTimeout(30, TimeUnit.SECONDS)
983
- .followRedirects(false)
984
- .followSslRedirects(false)
985
- .build()
1080
+ val client = buildHttpsOnlyHttpClient(30)
986
1081
  val request = Request.Builder().url(ascFileUrl).build()
987
1082
  // Wrap in .use so the Response (and its connection) is released on every
988
1083
  // exit path — including the !isSuccessful / empty-body / oversize throws,
@@ -990,29 +1085,30 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
990
1085
  val ascContent = client.newCall(request).execute().use { response ->
991
1086
  if (!response.isSuccessful) {
992
1087
  OneKeyLog.error("AppUpdate", "downloadASC: HTTP error, statusCode=${response.code}")
993
- throw Exception(response.code.toString())
1088
+ throw AppUpdateError("HTTP_ERROR", "downloadASC", response.code)
994
1089
  }
995
1090
 
996
1091
  OneKeyLog.info("AppUpdate", "downloadASC: HTTP 200, reading ASC content...")
997
1092
 
998
1093
  val content = StringBuilder()
999
1094
  val maxAscSize = 10 * 1024 // 10 KB max for ASC files
1000
- val body = response.body ?: throw Exception("Empty ASC response body")
1001
- BufferedReader(InputStreamReader(body.byteStream())).use { reader ->
1002
- var line: String?
1003
- while (reader.readLine().also { line = it } != null) {
1004
- content.append(line).append("\n")
1005
- if (content.length > maxAscSize) {
1095
+ val body = response.body ?: throw AppUpdateError("NETWORK_ERROR", "downloadASC")
1096
+ InputStreamReader(body.byteStream(), Charsets.UTF_8).use { reader ->
1097
+ val buf = CharArray(1024)
1098
+ var n: Int
1099
+ while (reader.read(buf).also { n = it } != -1) {
1100
+ if (content.length + n > maxAscSize) {
1006
1101
  OneKeyLog.error("AppUpdate", "downloadASC: ASC file exceeds max size ($maxAscSize bytes)")
1007
- throw Exception("ASC file exceeds maximum allowed size")
1102
+ throw AppUpdateError("SECURITY_REJECTED", "downloadASC")
1008
1103
  }
1104
+ content.append(buf, 0, n)
1009
1105
  }
1010
1106
  }
1011
1107
 
1012
1108
  val parsed = content.toString()
1013
1109
  if (parsed.isEmpty()) {
1014
1110
  OneKeyLog.error("AppUpdate", "downloadASC: ASC content is empty")
1015
- throw Exception("Empty ASC file")
1111
+ throw AppUpdateError("INTEGRITY_FAILED", "downloadASC")
1016
1112
  }
1017
1113
  parsed
1018
1114
  }
@@ -1025,24 +1121,29 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
1025
1121
  ascFile.delete()
1026
1122
  }
1027
1123
  FileOutputStream(ascFile).use { fos ->
1028
- fos.write(ascContent.toByteArray())
1124
+ fos.write(ascContent.toByteArray(Charsets.UTF_8))
1029
1125
  }
1030
1126
  OneKeyLog.info("AppUpdate", "downloadASC: saved ASC file to $ascFilePath")
1031
1127
  }
1032
1128
  }
1033
1129
 
1034
1130
  override fun verifyASC(params: AppUpdateFileParams): Promise<Unit> {
1035
- return Promise.async {
1131
+ return updatePromise("verifyASC") {
1036
1132
  val filePath = filePathFromUrl(params.downloadUrl)
1037
1133
  OneKeyLog.info("AppUpdate", "verifyASC: filePath=$filePath")
1038
1134
 
1135
+ val apkFile = buildFile(filePath)
1136
+
1039
1137
  // Skip GPG verification only when BuildConfig allows it AND both DevSettings and skip-GPG toggle are enabled
1040
1138
  val devSettings = if (BuildConfig.ALLOW_SKIP_GPG_VERIFICATION) isDevSettingsEnabled() else false
1041
1139
  val skipGPGToggle = if (BuildConfig.ALLOW_SKIP_GPG_VERIFICATION) isSkipGPGEnabled() else false
1042
1140
  OneKeyLog.info("AppUpdate", "verifyASC: GPG check: devSettings=$devSettings, skipGPGToggle=$skipGPGToggle")
1043
1141
  if (BuildConfig.ALLOW_SKIP_GPG_VERIFICATION && devSettings && skipGPGToggle) {
1044
1142
  OneKeyLog.warn("AppUpdate", "verifyASC: GPG verification skipped (DevSettings + skip-GPG enabled)")
1045
- return@async
1143
+ if (apkFile.exists()) {
1144
+ ascVerifiedFiles[apkFile.canonicalPath] = computeSha256(apkFile).lowercase()
1145
+ }
1146
+ return@updatePromise
1046
1147
  }
1047
1148
 
1048
1149
  try {
@@ -1051,7 +1152,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
1051
1152
  val ascFile = buildFile(ascFilePath)
1052
1153
  if (!ascFile.exists()) {
1053
1154
  OneKeyLog.error("AppUpdate", "verifyASC: ASC file not found at $ascFilePath")
1054
- throw Exception("ASC file not found")
1155
+ throw AppUpdateError("PACKAGE_MISSING", "verifyASC")
1055
1156
  }
1056
1157
 
1057
1158
  val ascContent = ascFile.readText()
@@ -1065,24 +1166,23 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
1065
1166
  // throw-on-failure contract; the reason taxonomy
1066
1167
  // (NOT_PGP_SIGNED_MESSAGE / INVALID_FORMAT / NO_SIGNATURE /
1067
1168
  // PUBKEY_NOT_FOUND / SIGNATURE_INVALID / SHA256_TOKEN_INVALID /
1068
- // ERROR_*) is logged for diagnostics.
1169
+ // FILENAME_MISMATCH / ERROR_*) is logged for diagnostics.
1069
1170
  OneKeyLog.info("AppUpdate", "verifyASC: verifying detached ASC signature via BundleCryptoCore...")
1070
- val ascResult = BundleCryptoCore.verifyDetachedAsc(ascContent)
1171
+ val ascResult = BundleCryptoCore.verifyDetachedAsc(ascContent, apkFile.name)
1071
1172
  // Capture in a local val so Kotlin can smart-cast to non-null below
1072
1173
  // (sha256 is a cross-module data-class property, which is not
1073
1174
  // smart-castable directly).
1074
1175
  val sha256 = ascResult.sha256
1075
1176
  if (!ascResult.valid || sha256 == null) {
1076
1177
  OneKeyLog.error("AppUpdate", "verifyASC: ASC verification FAILED: ${ascResult.reason}")
1077
- throw Exception("ASC signature verification failed: ${ascResult.reason}")
1178
+ throw AppUpdateError("INTEGRITY_FAILED", "verifyASC")
1078
1179
  }
1079
1180
  OneKeyLog.info("AppUpdate", "verifyASC: ASC verified OK, extracted SHA256=${sha256.take(16)}...")
1080
1181
 
1081
1182
  // Verify APK file SHA256
1082
- val apkFile = buildFile(filePath)
1083
1183
  if (!apkFile.exists()) {
1084
1184
  OneKeyLog.error("AppUpdate", "verifyASC: APK file not found at $filePath")
1085
- throw Exception("APK file not found: $filePath")
1185
+ throw AppUpdateError("PACKAGE_MISSING", "verifyASC")
1086
1186
  }
1087
1187
 
1088
1188
  OneKeyLog.info("AppUpdate", "verifyASC: computing SHA256 of APK file (size=${apkFile.length()} bytes)...")
@@ -1091,9 +1191,10 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
1091
1191
 
1092
1192
  if (!secureCompare(fileSha256, sha256)) {
1093
1193
  OneKeyLog.error("AppUpdate", "verifyASC: SHA256 MISMATCH — expected=${sha256.take(16)}..., got=${fileSha256.take(16)}...")
1094
- throw Exception("SHA256 mismatch for APK file")
1194
+ throw AppUpdateError("INTEGRITY_FAILED", "verifyASC")
1095
1195
  }
1096
1196
 
1197
+ ascVerifiedFiles[apkFile.canonicalPath] = fileSha256.lowercase()
1097
1198
  OneKeyLog.info("AppUpdate", "verifyASC: GPG signature + SHA256 verification passed")
1098
1199
 
1099
1200
  } catch (e: Exception) {
@@ -1105,22 +1206,23 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
1105
1206
 
1106
1207
  // Track verified files with their SHA-256 hash to prevent TOCTOU attacks
1107
1208
  private data class VerifiedFile(val canonicalPath: String, val sha256: String)
1209
+ private val ascVerifiedFiles = java.util.concurrent.ConcurrentHashMap<String, String>()
1108
1210
  private val verifiedFiles = java.util.Collections.synchronizedMap(mutableMapOf<String, String>())
1109
1211
 
1110
1212
  override fun verifyAPK(params: AppUpdateFileParams): Promise<Unit> {
1111
- return Promise.async {
1213
+ return updatePromise("verifyPackage") {
1112
1214
  val filePath = filePathFromUrl(params.downloadUrl)
1113
1215
  OneKeyLog.info("AppUpdate", "verifyAPK: filePath=$filePath")
1114
1216
 
1115
1217
  val file = buildFile(filePath)
1116
1218
  if (!file.exists()) {
1117
1219
  OneKeyLog.error("AppUpdate", "verifyAPK: APK file not found at $filePath")
1118
- throw Exception("NOT_FOUND_PACKAGE")
1220
+ throw AppUpdateError("PACKAGE_MISSING", "verifyPackage")
1119
1221
  }
1120
1222
  OneKeyLog.info("AppUpdate", "verifyAPK: APK file found, size=${file.length()} bytes")
1121
1223
 
1122
1224
  val context = NitroModules.applicationContext
1123
- ?: throw Exception("Application context unavailable")
1225
+ ?: throw AppUpdateError("UNKNOWN", "verifyPackage")
1124
1226
  val pm = context.packageManager
1125
1227
 
1126
1228
  // Check package name
@@ -1128,21 +1230,40 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
1128
1230
  val info = pm.getPackageArchiveInfo(file.absolutePath, 0)
1129
1231
  if (info?.packageName == null) {
1130
1232
  OneKeyLog.error("AppUpdate", "verifyAPK: failed to parse APK package info (INVALID_PACKAGE)")
1131
- throw Exception("INVALID_PACKAGE")
1233
+ throw AppUpdateError("INCOMPATIBLE", "verifyPackage")
1132
1234
  }
1133
1235
  OneKeyLog.info("AppUpdate", "verifyAPK: APK packageName=${info.packageName}, versionName=${info.versionName}, versionCode=${if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode else @Suppress("DEPRECATION") info.versionCode}")
1134
1236
 
1135
1237
  val debugBuild = isDebuggable()
1238
+ val skipGPG = BuildConfig.ALLOW_SKIP_GPG_VERIFICATION && isDevSettingsEnabled() && isSkipGPGEnabled()
1239
+
1240
+ // Compute SHA-256 hash of the file for TOCTOU and ASC-binding verification
1241
+ OneKeyLog.info("AppUpdate", "verifyAPK: computing SHA-256 hash for TOCTOU protection...")
1242
+ val fileHash = computeSha256(file).lowercase()
1243
+ val ascVerified = ascVerifiedFiles[file.canonicalPath] == fileHash
1244
+ if (!debugBuild && !skipGPG && !ascVerified) {
1245
+ OneKeyLog.error("AppUpdate", "verifyAPK: APK has not passed ASC verification for current hash")
1246
+ throw AppUpdateError("INTEGRITY_FAILED", "verifyPackage")
1247
+ }
1136
1248
 
1137
1249
  // Check package name
1138
1250
  if (info.packageName != context.packageName) {
1139
1251
  OneKeyLog.error("AppUpdate", "verifyAPK: package name mismatch — APK=${info.packageName}, installed=${context.packageName}")
1140
- if (!debugBuild) throw Exception("PACKAGE_NAME_MISMATCH")
1252
+ if (!debugBuild) throw AppUpdateError("INCOMPATIBLE", "verifyPackage")
1141
1253
  OneKeyLog.warn("AppUpdate", "verifyAPK: DEBUG build — ignoring package name mismatch")
1142
1254
  } else {
1143
1255
  OneKeyLog.info("AppUpdate", "verifyAPK: package name matches installed app")
1144
1256
  }
1145
1257
 
1258
+ // Anti-downgrade check: reject APKs with a lower versionCode than the installed app
1259
+ val currentPackageInfo = pm.getPackageInfo(context.packageName, 0)
1260
+ val apkVersionCode = PackageInfoCompat.getLongVersionCode(info)
1261
+ val installedVersionCode = PackageInfoCompat.getLongVersionCode(currentPackageInfo)
1262
+ if (!debugBuild && apkVersionCode < installedVersionCode) {
1263
+ OneKeyLog.error("AppUpdate", "verifyAPK: downgrade rejected — apkVersionCode=$apkVersionCode < installedVersionCode=$installedVersionCode")
1264
+ throw AppUpdateError("INCOMPATIBLE", "verifyPackage")
1265
+ }
1266
+
1146
1267
  // Verify APK signing certificate matches the installed app.
1147
1268
  //
1148
1269
  // This cross-check is defense-in-depth ONLY: by the time verifyAPK
@@ -1162,12 +1283,15 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
1162
1283
  val apkSigners = apkInfo?.signingInfo?.apkContentsSigners
1163
1284
  val installedSigners = installedInfo?.signingInfo?.apkContentsSigners
1164
1285
  if (apkSigners == null || installedSigners == null) {
1165
- OneKeyLog.info("AppUpdate", "verifyAPK: signing info unavailable (apkSigners=${apkSigners != null}, installedSigners=${installedSigners != null}), skipping signing certificate check")
1286
+ if (!debugBuild && !skipGPG && !ascVerified) {
1287
+ throw AppUpdateError("INTEGRITY_FAILED", "verifyPackage")
1288
+ }
1289
+ OneKeyLog.info("AppUpdate", "verifyAPK: signing info unavailable (apkSigners=${apkSigners != null}, installedSigners=${installedSigners != null}), relying on verified ASC signature")
1166
1290
  } else {
1167
1291
  OneKeyLog.info("AppUpdate", "verifyAPK: APK signers count=${apkSigners.size}, installed signers count=${installedSigners.size}")
1168
1292
  if (apkSigners.toSet() != installedSigners.toSet()) {
1169
1293
  OneKeyLog.error("AppUpdate", "verifyAPK: signing certificate MISMATCH")
1170
- if (!debugBuild) throw Exception("SIGNATURE_MISMATCH")
1294
+ if (!debugBuild) throw AppUpdateError("INTEGRITY_FAILED", "verifyPackage")
1171
1295
  OneKeyLog.warn("AppUpdate", "verifyAPK: DEBUG build — ignoring certificate mismatch")
1172
1296
  } else {
1173
1297
  OneKeyLog.info("AppUpdate", "verifyAPK: signing certificate verified OK")
@@ -1181,12 +1305,15 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
1181
1305
  val apkSignatures = apkInfo?.signatures
1182
1306
  val installedSignatures = installedInfo?.signatures
1183
1307
  if (apkSignatures == null || installedSignatures == null) {
1184
- OneKeyLog.info("AppUpdate", "verifyAPK: legacy signatures unavailable (apkSignatures=${apkSignatures != null}, installedSignatures=${installedSignatures != null}), skipping signing certificate check")
1308
+ if (!debugBuild && !skipGPG && !ascVerified) {
1309
+ throw AppUpdateError("INTEGRITY_FAILED", "verifyPackage")
1310
+ }
1311
+ OneKeyLog.info("AppUpdate", "verifyAPK: legacy signatures unavailable (apkSignatures=${apkSignatures != null}, installedSignatures=${installedSignatures != null}), relying on verified ASC signature")
1185
1312
  } else {
1186
1313
  OneKeyLog.info("AppUpdate", "verifyAPK: APK signatures count=${apkSignatures.size}, installed signatures count=${installedSignatures.size}")
1187
1314
  if (apkSignatures.toSet() != installedSignatures.toSet()) {
1188
1315
  OneKeyLog.error("AppUpdate", "verifyAPK: legacy signing certificate MISMATCH")
1189
- if (!debugBuild) throw Exception("SIGNATURE_MISMATCH")
1316
+ if (!debugBuild) throw AppUpdateError("INTEGRITY_FAILED", "verifyPackage")
1190
1317
  OneKeyLog.warn("AppUpdate", "verifyAPK: DEBUG build — ignoring certificate mismatch")
1191
1318
  } else {
1192
1319
  OneKeyLog.info("AppUpdate", "verifyAPK: signing certificate verified OK")
@@ -1194,44 +1321,45 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
1194
1321
  }
1195
1322
  }
1196
1323
 
1197
- // Compute SHA-256 hash of the verified file for TOCTOU protection
1198
- OneKeyLog.info("AppUpdate", "verifyAPK: computing SHA-256 hash for TOCTOU protection...")
1199
- val fileHash = computeSha256(file)
1200
1324
  verifiedFiles[file.canonicalPath] = fileHash
1201
1325
  OneKeyLog.info("AppUpdate", "verifyAPK: APK verified successfully, hash=${fileHash.take(16)}..., stored for install verification")
1202
1326
  }
1203
1327
  }
1204
1328
 
1205
1329
  override fun installAPK(params: AppUpdateFileParams): Promise<Unit> {
1206
- return Promise.async {
1330
+ return updatePromise("install") {
1207
1331
  val filePath = filePathFromUrl(params.downloadUrl)
1208
1332
  OneKeyLog.info("AppUpdate", "installAPK: filePath=$filePath")
1209
1333
 
1210
1334
  val context = NitroModules.applicationContext
1211
- ?: throw Exception("Application context unavailable")
1335
+ ?: throw AppUpdateError("UNKNOWN", "install")
1212
1336
  val file = buildFile(filePath)
1213
1337
  if (!file.exists()) {
1214
1338
  OneKeyLog.error("AppUpdate", "installAPK: APK file not found at $filePath")
1215
- throw Exception("NOT_FOUND_PACKAGE")
1339
+ throw AppUpdateError("PACKAGE_MISSING", "install")
1216
1340
  }
1217
1341
  OneKeyLog.info("AppUpdate", "installAPK: APK file found, size=${file.length()} bytes")
1218
1342
 
1219
- // Ensure verifyAPK was called before installation
1343
+ // Ensure verifyAPK was called before installation. Keep the entry in
1344
+ // verifiedFiles so user-cancelled system installer prompts or
1345
+ // "Install unknown apps" permission round-trips can retry installAPK.
1220
1346
  val debugBuild = isDebuggable()
1221
- val expectedHash = verifiedFiles.remove(file.canonicalPath)
1347
+ val expectedHash = verifiedFiles[file.canonicalPath]
1222
1348
  if (expectedHash == null) {
1223
1349
  OneKeyLog.error("AppUpdate", "installAPK: APK was not verified before installation (no hash in verifiedFiles)")
1224
- if (!debugBuild) throw Exception("APK must be verified before installation")
1350
+ if (!debugBuild) throw AppUpdateError("INTEGRITY_FAILED", "install")
1225
1351
  OneKeyLog.warn("AppUpdate", "installAPK: DEBUG build — ignoring missing verification")
1226
1352
  }
1227
1353
 
1228
1354
  // Re-verify file hash to prevent TOCTOU attacks (file swapped after verification)
1229
1355
  if (expectedHash != null) {
1230
1356
  OneKeyLog.info("AppUpdate", "installAPK: expected hash=${expectedHash.take(16)}..., re-verifying TOCTOU...")
1231
- val currentHash = computeSha256(file)
1357
+ val currentHash = computeSha256(file).lowercase()
1232
1358
  if (currentHash != expectedHash) {
1359
+ verifiedFiles.remove(file.canonicalPath)
1360
+ ascVerifiedFiles.remove(file.canonicalPath)
1233
1361
  OneKeyLog.error("AppUpdate", "installAPK: TOCTOU check FAILED — file was modified after verification (current=${currentHash.take(16)}..., expected=${expectedHash.take(16)}...)")
1234
- if (!debugBuild) throw Exception("APK file was modified after verification")
1362
+ if (!debugBuild) throw AppUpdateError("INTEGRITY_FAILED", "install")
1235
1363
  OneKeyLog.warn("AppUpdate", "installAPK: DEBUG build — ignoring TOCTOU mismatch")
1236
1364
  } else {
1237
1365
  OneKeyLog.info("AppUpdate", "installAPK: TOCTOU check passed, hash matches")
@@ -1262,7 +1390,12 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
1262
1390
  intent.setDataAndType(Uri.fromFile(file), "application/vnd.android.package-archive")
1263
1391
  OneKeyLog.info("AppUpdate", "installAPK: using file URI")
1264
1392
  }
1265
- context.startActivity(intent)
1393
+ try {
1394
+ context.startActivity(intent)
1395
+ } catch (e: Exception) {
1396
+ OneKeyLog.error("AppUpdate", "installAPK: installer launch failed: ${e.javaClass.simpleName}: ${e.message}")
1397
+ throw AppUpdateError("INSTALL_FAILED", "install")
1398
+ }
1266
1399
  OneKeyLog.info("AppUpdate", "installAPK: install intent launched, awaiting user confirmation")
1267
1400
  }
1268
1401
  }
@@ -1336,7 +1469,7 @@ n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ
1336
1469
  // cannot resurrect a just-deleted segment or write to a deleted FD. Only
1337
1470
  // the two external cleanup paths (clearCache / clearApkCache) call this;
1338
1471
  // it is never invoked mid-download.
1339
- activeDownload.getAndSet(null)?.let {
1472
+ activeDownload.get()?.let {
1340
1473
  OneKeyLog.info("AppUpdate", "$tag: cancelling in-flight download before wipe")
1341
1474
  it.cancel()
1342
1475
  }
@@ -1378,20 +1511,58 @@ n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ
1378
1511
  }
1379
1512
 
1380
1513
  override fun clearCache(): Promise<Unit> {
1381
- return Promise.async {
1514
+ return updatePromise("cleanup") {
1382
1515
  OneKeyLog.info("AppUpdate", "clearCache: starting cleanup...")
1383
- isDownloading.set(false)
1384
- val verifiedCount = verifiedFiles.size
1385
- verifiedFiles.clear()
1386
- OneKeyLog.info("AppUpdate", "clearCache: reset download state, cleared $verifiedCount verified file entries")
1387
-
1388
- // Clean up downloaded APK and ASC files from cacheDir/apks/ directory
1389
- wipeApkCacheFiles("clearCache")
1516
+ val (handle, finished) = synchronized(activeDownload) {
1517
+ if (clearingDownload) throw AppUpdateError("UNKNOWN", "cleanup")
1518
+ clearingDownload = true
1519
+ val current = activeDownload.get()
1520
+ current?.aborted?.set(true)
1521
+ current to activeDownloadFinished
1522
+ }
1523
+ try {
1524
+ handle?.cancel()
1525
+ if (finished != null) {
1526
+ // Keep the running slot occupied until old disk writes have ended.
1527
+ val completed = try {
1528
+ finished.await(65, TimeUnit.SECONDS)
1529
+ } catch (e: InterruptedException) {
1530
+ Thread.currentThread().interrupt()
1531
+ false
1532
+ }
1533
+ if (!completed) throw AppUpdateError("UNKNOWN", "cleanup")
1534
+ }
1535
+ synchronized(activeDownload) {
1536
+ if (activeDownload.get() !== handle || activeDownloadFinished !== finished) {
1537
+ throw AppUpdateError("UNKNOWN", "cleanup")
1538
+ }
1539
+ val verifiedCount = verifiedFiles.size
1540
+ verifiedFiles.clear()
1541
+ ascVerifiedFiles.clear()
1542
+ OneKeyLog.info("AppUpdate", "clearCache: reset download state, cleared $verifiedCount verified file entries")
1543
+ wipeApkCacheFiles("clearCache")
1544
+ activeDownload.set(null)
1545
+ activeDownloadFinished = null
1546
+ isDownloading.set(false)
1547
+ }
1548
+ } finally {
1549
+ synchronized(activeDownload) {
1550
+ clearingDownload = false
1551
+ // The writer may have completed while cleanup held its slot.
1552
+ if (finished != null && finished.count == 0L &&
1553
+ activeDownload.get() === handle && activeDownloadFinished === finished
1554
+ ) {
1555
+ activeDownload.set(null)
1556
+ activeDownloadFinished = null
1557
+ isDownloading.set(false)
1558
+ }
1559
+ }
1560
+ }
1390
1561
  }
1391
1562
  }
1392
1563
 
1393
1564
  override fun clearApkCache(): Promise<Unit> {
1394
- return Promise.async {
1565
+ return updatePromise("cleanup") {
1395
1566
  // Wipe downloaded APK artifacts from cacheDir/apks/ only. The JS
1396
1567
  // layer gates on app-update status before calling, but that read is
1397
1568
  // not atomic with this delete, so guard natively too:
@@ -1401,16 +1572,18 @@ n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ
1401
1572
  // verifiedFiles) so an open installer keeps a readable file.
1402
1573
  // Deliberately do NOT touch isDownloading / verifiedFiles state here
1403
1574
  // (that's clearCache's job). Missing dir/files are tolerated.
1404
- if (isDownloading.get()) {
1405
- OneKeyLog.info("AppUpdate", "clearApkCache: download in progress, skipping APK cache cleanup")
1406
- return@async
1575
+ synchronized(activeDownload) {
1576
+ if (isDownloading.get() || clearingDownload) {
1577
+ OneKeyLog.info("AppUpdate", "clearApkCache: download in progress, skipping APK cache cleanup")
1578
+ return@updatePromise
1579
+ }
1580
+ val protectedPaths = synchronized(verifiedFiles) { verifiedFiles.keys.toSet() }
1581
+ OneKeyLog.info(
1582
+ "AppUpdate",
1583
+ "clearApkCache: starting stale APK cache cleanup (protecting ${protectedPaths.size} verified file(s))...",
1584
+ )
1585
+ wipeApkCacheFiles("clearApkCache", protectedPaths)
1407
1586
  }
1408
- val protectedPaths = synchronized(verifiedFiles) { verifiedFiles.keys.toSet() }
1409
- OneKeyLog.info(
1410
- "AppUpdate",
1411
- "clearApkCache: starting stale APK cache cleanup (protecting ${protectedPaths.size} verified file(s))...",
1412
- )
1413
- wipeApkCacheFiles("clearApkCache", protectedPaths)
1414
1587
  }
1415
1588
  }
1416
1589
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-app-update",
3
- "version": "3.0.180",
3
+ "version": "6.7.0-alpha.310",
4
4
  "description": "Android APK download, signature verification and installation for OneKey",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",