@onekeyfe/react-native-app-update 6.7.0-1 → 6.7.0-alpha.311

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