@onekeyfe/react-native-app-update 1.1.21

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/ReactNativeAppUpdate.podspec +30 -0
  4. package/android/CMakeLists.txt +24 -0
  5. package/android/build.gradle +141 -0
  6. package/android/gradle.properties +4 -0
  7. package/android/src/main/AndroidManifest.xml +17 -0
  8. package/android/src/main/cpp/cpp-adapter.cpp +6 -0
  9. package/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt +819 -0
  10. package/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdatePackage.kt +24 -0
  11. package/android/src/main/res/xml/app_update_file_paths.xml +4 -0
  12. package/ios/ReactNativeAppUpdate.swift +41 -0
  13. package/lib/module/ReactNativeAppUpdate.nitro.js +4 -0
  14. package/lib/module/ReactNativeAppUpdate.nitro.js.map +1 -0
  15. package/lib/module/index.js +6 -0
  16. package/lib/module/index.js.map +1 -0
  17. package/lib/module/package.json +1 -0
  18. package/lib/typescript/package.json +1 -0
  19. package/lib/typescript/src/ReactNativeAppUpdate.nitro.d.ts +28 -0
  20. package/lib/typescript/src/ReactNativeAppUpdate.nitro.d.ts.map +1 -0
  21. package/lib/typescript/src/index.d.ts +4 -0
  22. package/lib/typescript/src/index.d.ts.map +1 -0
  23. package/nitro.json +17 -0
  24. package/nitrogen/generated/android/c++/JAppUpdateDownloadParams.hpp +65 -0
  25. package/nitrogen/generated/android/c++/JAppUpdateFileParams.hpp +57 -0
  26. package/nitrogen/generated/android/c++/JDownloadEvent.hpp +65 -0
  27. package/nitrogen/generated/android/c++/JFunc_void_DownloadEvent.hpp +78 -0
  28. package/nitrogen/generated/android/c++/JHybridReactNativeAppUpdateSpec.cpp +162 -0
  29. package/nitrogen/generated/android/c++/JHybridReactNativeAppUpdateSpec.hpp +72 -0
  30. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeappupdate/AppUpdateDownloadParams.kt +44 -0
  31. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeappupdate/AppUpdateFileParams.kt +38 -0
  32. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeappupdate/DownloadEvent.kt +44 -0
  33. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeappupdate/Func_void_DownloadEvent.kt +80 -0
  34. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeappupdate/HybridReactNativeAppUpdateSpec.kt +91 -0
  35. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativeappupdate/reactnativeappupdateOnLoad.kt +35 -0
  36. package/nitrogen/generated/android/reactnativeappupdate+autolinking.cmake +81 -0
  37. package/nitrogen/generated/android/reactnativeappupdate+autolinking.gradle +27 -0
  38. package/nitrogen/generated/android/reactnativeappupdateOnLoad.cpp +46 -0
  39. package/nitrogen/generated/android/reactnativeappupdateOnLoad.hpp +25 -0
  40. package/nitrogen/generated/ios/ReactNativeAppUpdate+autolinking.rb +60 -0
  41. package/nitrogen/generated/ios/ReactNativeAppUpdate-Swift-Cxx-Bridge.cpp +57 -0
  42. package/nitrogen/generated/ios/ReactNativeAppUpdate-Swift-Cxx-Bridge.hpp +154 -0
  43. package/nitrogen/generated/ios/ReactNativeAppUpdate-Swift-Cxx-Umbrella.hpp +55 -0
  44. package/nitrogen/generated/ios/ReactNativeAppUpdateAutolinking.mm +33 -0
  45. package/nitrogen/generated/ios/ReactNativeAppUpdateAutolinking.swift +25 -0
  46. package/nitrogen/generated/ios/c++/HybridReactNativeAppUpdateSpecSwift.cpp +11 -0
  47. package/nitrogen/generated/ios/c++/HybridReactNativeAppUpdateSpecSwift.hpp +140 -0
  48. package/nitrogen/generated/ios/swift/AppUpdateDownloadParams.swift +58 -0
  49. package/nitrogen/generated/ios/swift/AppUpdateFileParams.swift +36 -0
  50. package/nitrogen/generated/ios/swift/DownloadEvent.swift +58 -0
  51. package/nitrogen/generated/ios/swift/Func_void.swift +47 -0
  52. package/nitrogen/generated/ios/swift/Func_void_DownloadEvent.swift +47 -0
  53. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +47 -0
  54. package/nitrogen/generated/ios/swift/HybridReactNativeAppUpdateSpec.swift +63 -0
  55. package/nitrogen/generated/ios/swift/HybridReactNativeAppUpdateSpec_cxx.swift +261 -0
  56. package/nitrogen/generated/shared/c++/AppUpdateDownloadParams.hpp +83 -0
  57. package/nitrogen/generated/shared/c++/AppUpdateFileParams.hpp +75 -0
  58. package/nitrogen/generated/shared/c++/DownloadEvent.hpp +83 -0
  59. package/nitrogen/generated/shared/c++/HybridReactNativeAppUpdateSpec.cpp +28 -0
  60. package/nitrogen/generated/shared/c++/HybridReactNativeAppUpdateSpec.hpp +78 -0
  61. package/package.json +169 -0
  62. package/src/ReactNativeAppUpdate.nitro.ts +30 -0
  63. package/src/index.tsx +8 -0
@@ -0,0 +1,819 @@
1
+ package com.margelo.nitro.reactnativeappupdate
2
+
3
+ import android.app.NotificationChannel
4
+ import android.app.NotificationManager
5
+ import android.content.Intent
6
+ import android.content.pm.PackageManager
7
+ import android.net.Uri
8
+ import android.os.Build
9
+ import androidx.core.app.ActivityCompat
10
+ import androidx.core.app.NotificationCompat
11
+ import androidx.core.app.NotificationManagerCompat
12
+ import androidx.core.content.FileProvider
13
+ import com.facebook.proguard.annotations.DoNotStrip
14
+ import com.margelo.nitro.core.Promise
15
+ import com.margelo.nitro.NitroModules
16
+ import com.margelo.nitro.nativelogger.OneKeyLog
17
+ import com.tencent.mmkv.MMKV
18
+ import okhttp3.OkHttpClient
19
+ import okhttp3.Request
20
+ import okio.buffer
21
+ import okio.sink
22
+ import java.io.BufferedInputStream
23
+ import java.io.BufferedReader
24
+ import java.io.File
25
+ import java.io.FileInputStream
26
+ import java.io.FileOutputStream
27
+ import java.io.InputStreamReader
28
+ import java.security.MessageDigest
29
+ import java.util.concurrent.CopyOnWriteArrayList
30
+ import java.util.concurrent.TimeUnit
31
+ import java.util.concurrent.atomic.AtomicBoolean
32
+ import java.util.concurrent.atomic.AtomicLong
33
+ import org.bouncycastle.openpgp.PGPPublicKeyRingCollection
34
+ import org.bouncycastle.openpgp.PGPSignatureList
35
+ import org.bouncycastle.openpgp.PGPUtil
36
+ import org.bouncycastle.openpgp.jcajce.JcaPGPObjectFactory
37
+ import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator
38
+ import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentVerifierBuilderProvider
39
+ import org.bouncycastle.jce.provider.BouncyCastleProvider
40
+
41
+ // OneKey GPG public key for signature verification
42
+ private const val GPG_PUBLIC_KEY = """-----BEGIN PGP PUBLIC KEY BLOCK-----
43
+
44
+ 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
91
+ P2Q5dClenjjjVA==
92
+ =F0np
93
+ -----END PGP PUBLIC KEY BLOCK-----"""
94
+
95
+ private data class Listener(
96
+ val id: Double,
97
+ val callback: (DownloadEvent) -> Unit
98
+ )
99
+
100
+ @DoNotStrip
101
+ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
102
+
103
+ companion object {
104
+ private const val CHANNEL_ID = "updateApp"
105
+ private const val NOTIFICATION_ID = 1
106
+ // Use our own BouncyCastle provider instance to avoid Android's stripped-down built-in "BC"
107
+ private val bcProvider = BouncyCastleProvider()
108
+ }
109
+
110
+ private val listeners = CopyOnWriteArrayList<Listener>()
111
+ private val nextListenerId = AtomicLong(1)
112
+ private val isDownloading = AtomicBoolean(false)
113
+ // downloadThread removed: downloads use coroutine-based Promise.async, not raw threads
114
+
115
+ private fun sendEvent(type: String, progress: Int = 0, message: String = "") {
116
+ val event = DownloadEvent(type = type, progress = progress.toDouble(), message = message)
117
+ for (listener in listeners) {
118
+ try {
119
+ listener.callback(event)
120
+ } catch (e: Exception) {
121
+ OneKeyLog.error("AppUpdate", "Error sending event: ${e.message}")
122
+ }
123
+ }
124
+ }
125
+
126
+ override fun addDownloadListener(callback: (DownloadEvent) -> Unit): Double {
127
+ val id = nextListenerId.getAndIncrement().toDouble()
128
+ listeners.add(Listener(id, callback))
129
+ return id
130
+ }
131
+
132
+ override fun removeDownloadListener(id: Double) {
133
+ listeners.removeAll { it.id == id }
134
+ }
135
+
136
+ private fun getApkCacheDir(): File {
137
+ val context = NitroModules.applicationContext
138
+ ?: throw SecurityException("Application context unavailable")
139
+ val apkDir = File(context.cacheDir, "apks")
140
+ if (!apkDir.exists()) apkDir.mkdirs()
141
+ return apkDir
142
+ }
143
+
144
+ /**
145
+ * Derive a local file name from a download URL by extracting the last path segment.
146
+ * e.g. "https://example.com/path/to/app-1.0.apk" -> "app-1.0.apk"
147
+ */
148
+ private fun filePathFromUrl(url: String): String {
149
+ val path = Uri.parse(url).lastPathSegment
150
+ if (path.isNullOrBlank()) {
151
+ throw Exception("Cannot derive file name from URL: $url")
152
+ }
153
+ return path
154
+ }
155
+
156
+ private fun buildFile(path: String): File {
157
+ val stripped = path.removePrefix("file:///")
158
+ val context = NitroModules.applicationContext
159
+ ?: throw SecurityException("Application context unavailable, cannot validate file path")
160
+
161
+ // Resolve relative paths against cacheDir/apk/
162
+ val file = if (stripped.startsWith("/")) {
163
+ File(stripped)
164
+ } else {
165
+ File(getApkCacheDir(), stripped)
166
+ }
167
+
168
+ // Validate the resolved path is within the app's cache or files directory
169
+ val canonicalPath = file.canonicalPath
170
+ val cacheDir = context.cacheDir.canonicalPath
171
+ val filesDir = context.filesDir.canonicalPath
172
+ val externalCacheDir = context.externalCacheDir?.canonicalPath
173
+ val externalFilesDir = context.getExternalFilesDir(null)?.canonicalPath
174
+ val allowed = canonicalPath.startsWith(cacheDir + File.separator) || canonicalPath == cacheDir ||
175
+ canonicalPath.startsWith(filesDir + File.separator) || canonicalPath == filesDir ||
176
+ (externalCacheDir != null && (canonicalPath.startsWith(externalCacheDir + File.separator) || canonicalPath == externalCacheDir)) ||
177
+ (externalFilesDir != null && (canonicalPath.startsWith(externalFilesDir + File.separator) || canonicalPath == externalFilesDir))
178
+ if (!allowed) {
179
+ OneKeyLog.error("AppUpdate", "buildFile: path outside allowed directories, " +
180
+ "input=$path, resolved=$canonicalPath, " +
181
+ "cacheDir=$cacheDir, filesDir=$filesDir, " +
182
+ "externalCacheDir=$externalCacheDir, externalFilesDir=$externalFilesDir")
183
+ throw SecurityException("Path outside allowed directories: $canonicalPath")
184
+ }
185
+ OneKeyLog.debug("AppUpdate", "buildFile: input=$path, resolved=$canonicalPath")
186
+ return file
187
+ }
188
+
189
+ private fun bytesToHex(bytes: ByteArray): String {
190
+ return bytes.joinToString("") { "%02x".format(it) }
191
+ }
192
+
193
+ private fun computeSha256(file: File): String {
194
+ val digest = MessageDigest.getInstance("SHA-256")
195
+ BufferedInputStream(FileInputStream(file)).use { bis ->
196
+ val buffer = ByteArray(8192)
197
+ var count: Int
198
+ while (bis.read(buffer).also { count = it } > 0) {
199
+ digest.update(buffer, 0, count)
200
+ }
201
+ }
202
+ return bytesToHex(digest.digest())
203
+ }
204
+
205
+ /**
206
+ * Extract SHA256 hash from a PGP cleartext signed ASC file.
207
+ * Returns null if the file is invalid or cannot be parsed.
208
+ */
209
+ private fun extractSha256FromAsc(ascFile: File): String? {
210
+ val ascContent = ascFile.readText()
211
+ if (!ascContent.contains("-----BEGIN PGP SIGNED MESSAGE-----")) return null
212
+ val lines = ascContent.lines()
213
+ val hashHeaderIdx = lines.indexOfFirst { it.startsWith("Hash:") }
214
+ val sigStartIdx = lines.indexOfFirst { it == "-----BEGIN PGP SIGNATURE-----" }
215
+ if (hashHeaderIdx < 0 || sigStartIdx < 0) return null
216
+ val bodyLines = lines.subList(hashHeaderIdx + 2, sigStartIdx)
217
+ val sha256 = bodyLines.joinToString("\n").trim().split("\\s+".toRegex())[0].lowercase()
218
+ if (sha256.length != 64 || !sha256.all { it in '0'..'9' || it in 'a'..'f' }) return null
219
+ return sha256
220
+ }
221
+
222
+ /**
223
+ * Download ASC file for the given URL and save to the given path.
224
+ * Returns the saved file, or null on failure.
225
+ */
226
+ private fun downloadAscFile(ascUrl: String, ascFile: File): File? {
227
+ val client = OkHttpClient.Builder()
228
+ .connectTimeout(10, TimeUnit.SECONDS)
229
+ .readTimeout(30, TimeUnit.SECONDS)
230
+ .followRedirects(false)
231
+ .followSslRedirects(false)
232
+ .build()
233
+ val request = Request.Builder().url(ascUrl).build()
234
+ val response = client.newCall(request).execute()
235
+ if (!response.isSuccessful) return null
236
+ val content = StringBuilder()
237
+ val maxAscSize = 10 * 1024
238
+ val body = response.body ?: return null
239
+ BufferedReader(InputStreamReader(body.byteStream())).use { reader ->
240
+ var line: String?
241
+ while (reader.readLine().also { line = it } != null) {
242
+ content.append(line).append("\n")
243
+ if (content.length > maxAscSize) return null
244
+ }
245
+ }
246
+ val ascContent = content.toString()
247
+ if (ascContent.isEmpty()) return null
248
+ ascFile.parentFile?.mkdirs()
249
+ FileOutputStream(ascFile).use { fos -> fos.write(ascContent.toByteArray()) }
250
+ return ascFile
251
+ }
252
+
253
+ /**
254
+ * Check if an existing APK is valid by verifying its SHA256 against the ASC file.
255
+ * Downloads ASC if not present. Returns true if APK is valid.
256
+ */
257
+ private fun tryVerifyExistingApk(url: String, filePath: String, apkFile: File): Boolean {
258
+ return try {
259
+ val ascFilePath = "$filePath.SHA256SUMS.asc"
260
+ val ascFile = buildFile(ascFilePath)
261
+
262
+ if (!ascFile.exists()) {
263
+ val ascUrl = "$url.SHA256SUMS.asc"
264
+ OneKeyLog.info("AppUpdate", "tryVerifyExistingApk: ASC not found, downloading from $ascUrl")
265
+ if (downloadAscFile(ascUrl, ascFile) == null) {
266
+ OneKeyLog.warn("AppUpdate", "tryVerifyExistingApk: ASC download failed")
267
+ return false
268
+ }
269
+ OneKeyLog.info("AppUpdate", "tryVerifyExistingApk: ASC downloaded to ${ascFile.absolutePath}")
270
+ }
271
+
272
+ val expectedSha256 = extractSha256FromAsc(ascFile)
273
+ if (expectedSha256 == null) {
274
+ OneKeyLog.warn("AppUpdate", "tryVerifyExistingApk: failed to extract SHA256 from ASC")
275
+ return false
276
+ }
277
+
278
+ OneKeyLog.info("AppUpdate", "tryVerifyExistingApk: computing SHA256 of existing APK (size=${apkFile.length()})...")
279
+ val actualSha256 = computeSha256(apkFile)
280
+
281
+ if (actualSha256 == expectedSha256) {
282
+ OneKeyLog.info("AppUpdate", "tryVerifyExistingApk: SHA256 matches, APK is valid")
283
+ true
284
+ } else {
285
+ OneKeyLog.warn("AppUpdate", "tryVerifyExistingApk: SHA256 mismatch, expected=${expectedSha256.take(16)}..., got=${actualSha256.take(16)}...")
286
+ false
287
+ }
288
+ } catch (e: Exception) {
289
+ OneKeyLog.warn("AppUpdate", "tryVerifyExistingApk: failed: ${e.javaClass.simpleName}: ${e.message}")
290
+ false
291
+ }
292
+ }
293
+
294
+ private fun isDebuggable(): Boolean {
295
+ val context = NitroModules.applicationContext ?: return false
296
+ return (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
297
+ }
298
+
299
+ /** Returns true if OneKey developer mode (DevSettings) is enabled via MMKV storage. */
300
+ private fun isDevSettingsEnabled(): Boolean {
301
+ return try {
302
+ val context = NitroModules.applicationContext ?: return false
303
+ MMKV.initialize(context)
304
+ val mmkv = MMKV.mmkvWithID("onekey-app-setting") ?: return false
305
+ mmkv.decodeBool("onekey_developer_mode_enabled", false)
306
+ } catch (e: Exception) {
307
+ false
308
+ }
309
+ }
310
+
311
+ override fun downloadAPK(params: AppUpdateDownloadParams): Promise<Unit> {
312
+ return Promise.async {
313
+ if (isDownloading.getAndSet(true)) {
314
+ OneKeyLog.warn("AppUpdate", "downloadAPK: rejected, already downloading")
315
+ throw Exception("Download already in progress")
316
+ }
317
+
318
+ try {
319
+ val url = params.downloadUrl
320
+ val filePath = filePathFromUrl(url)
321
+ val notificationTitle = params.notificationTitle
322
+ val fileSize = params.fileSize.toLong()
323
+
324
+ OneKeyLog.info("AppUpdate", "downloadAPK: url=$url, filePath=$filePath, fileSize=$fileSize")
325
+
326
+ val context = NitroModules.applicationContext
327
+ ?: throw Exception("Application context unavailable")
328
+
329
+ val notifyManager = NotificationManagerCompat.from(context)
330
+ val builder = NotificationCompat.Builder(context, CHANNEL_ID)
331
+ .setContentTitle(notificationTitle)
332
+ .setContentText("")
333
+ .setOngoing(true)
334
+ .setPriority(NotificationCompat.PRIORITY_LOW)
335
+ .setSmallIcon(android.R.drawable.stat_sys_download)
336
+
337
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
338
+ val channel = NotificationChannel(
339
+ CHANNEL_ID, "updateApp", NotificationManager.IMPORTANCE_DEFAULT
340
+ )
341
+ notifyManager.createNotificationChannel(channel)
342
+ }
343
+
344
+ if (!url.startsWith("https://")) {
345
+ OneKeyLog.error("AppUpdate", "downloadAPK: URL is not HTTPS: $url")
346
+ throw Exception("Download URL must use HTTPS")
347
+ }
348
+
349
+ val downloadedFile = buildFile(filePath)
350
+ if (downloadedFile.exists()) {
351
+ OneKeyLog.info("AppUpdate", "downloadAPK: existing APK found (size=${downloadedFile.length()}), verifying...")
352
+ if (tryVerifyExistingApk(url, filePath, downloadedFile)) {
353
+ OneKeyLog.info("AppUpdate", "downloadAPK: existing APK is valid, skipping download")
354
+ sendEvent("update/downloaded")
355
+ return@async
356
+ }
357
+ OneKeyLog.info("AppUpdate", "downloadAPK: existing APK invalid, deleting and re-downloading...")
358
+ downloadedFile.delete()
359
+ }
360
+
361
+ val client = OkHttpClient.Builder()
362
+ .connectTimeout(10, TimeUnit.SECONDS)
363
+ .readTimeout(60, TimeUnit.SECONDS)
364
+ .followRedirects(false)
365
+ .followSslRedirects(false)
366
+ .build()
367
+ val request = Request.Builder().url(url).build()
368
+ val response = client.newCall(request).execute()
369
+
370
+ if (!response.isSuccessful) {
371
+ OneKeyLog.error("AppUpdate", "downloadAPK: HTTP error, statusCode=${response.code}")
372
+ sendEvent("update/error", message = response.code.toString())
373
+ throw Exception(response.code.toString())
374
+ }
375
+
376
+ val body = response.body ?: throw Exception("Empty response body")
377
+ val contentLength = if (fileSize > 0) fileSize else body.contentLength()
378
+ OneKeyLog.info("AppUpdate", "downloadAPK: HTTP 200, contentLength=$contentLength, starting download...")
379
+ val source = body.source()
380
+ val sink = downloadedFile.sink().buffer()
381
+ val sinkBuffer = sink.buffer
382
+
383
+ var totalBytesRead = 0L
384
+ val bufferSize = 8 * 1024L
385
+ sendEvent("update/start")
386
+ var prevProgress = 0
387
+
388
+ try {
389
+ while (true) {
390
+ val bytesRead = source.read(sinkBuffer, bufferSize)
391
+ if (bytesRead == -1L) break
392
+ sink.emit()
393
+ totalBytesRead += bytesRead
394
+ if (contentLength > 0) {
395
+ val progress = ((totalBytesRead * 100) / contentLength).toInt()
396
+ if (prevProgress != progress) {
397
+ sendEvent("update/downloading", progress = progress)
398
+ OneKeyLog.info("AppUpdate", "download progress: $progress%")
399
+ builder.setProgress(100, progress, false)
400
+ if (ActivityCompat.checkSelfPermission(
401
+ context, android.Manifest.permission.POST_NOTIFICATIONS
402
+ ) == PackageManager.PERMISSION_GRANTED
403
+ ) {
404
+ notifyManager.notify(NOTIFICATION_ID, builder.build())
405
+ }
406
+ prevProgress = progress
407
+ }
408
+ }
409
+ }
410
+ } finally {
411
+ sink.flush()
412
+ sink.close()
413
+ source.close()
414
+ }
415
+
416
+ OneKeyLog.info("AppUpdate", "Download completed")
417
+ sendEvent("update/downloaded")
418
+
419
+ notifyManager.cancel(NOTIFICATION_ID)
420
+ builder.setContentText("")
421
+ .setProgress(0, 0, false)
422
+ .setOngoing(false)
423
+ .setAutoCancel(true)
424
+ if (ActivityCompat.checkSelfPermission(
425
+ context, android.Manifest.permission.POST_NOTIFICATIONS
426
+ ) == PackageManager.PERMISSION_GRANTED
427
+ ) {
428
+ notifyManager.notify(NOTIFICATION_ID, builder.build())
429
+ }
430
+ } catch (e: Exception) {
431
+ OneKeyLog.error("AppUpdate", "downloadAPK: failed: ${e.javaClass.simpleName}: ${e.message}")
432
+ sendEvent("update/error", message = "${e.javaClass.simpleName}: ${e.message}")
433
+ throw e
434
+ } finally {
435
+ isDownloading.set(false)
436
+ }
437
+ }
438
+ }
439
+
440
+ override fun downloadASC(params: AppUpdateFileParams): Promise<Unit> {
441
+ return Promise.async {
442
+ val url = params.downloadUrl
443
+ val filePath = filePathFromUrl(url)
444
+
445
+ OneKeyLog.info("AppUpdate", "downloadASC: url=$url, filePath=$filePath")
446
+
447
+ if (!url.startsWith("https://")) {
448
+ OneKeyLog.error("AppUpdate", "downloadASC: URL is not HTTPS: $url")
449
+ throw Exception("Download URL must use HTTPS")
450
+ }
451
+
452
+ val ascFileUrl = "$url.SHA256SUMS.asc"
453
+ val ascFilePath = "$filePath.SHA256SUMS.asc"
454
+ OneKeyLog.info("AppUpdate", "downloadASC: ascFileUrl=$ascFileUrl")
455
+
456
+ val client = OkHttpClient.Builder()
457
+ .connectTimeout(10, TimeUnit.SECONDS)
458
+ .readTimeout(30, TimeUnit.SECONDS)
459
+ .followRedirects(false)
460
+ .followSslRedirects(false)
461
+ .build()
462
+ val request = Request.Builder().url(ascFileUrl).build()
463
+ val response = client.newCall(request).execute()
464
+
465
+ if (!response.isSuccessful) {
466
+ OneKeyLog.error("AppUpdate", "downloadASC: HTTP error, statusCode=${response.code}")
467
+ throw Exception(response.code.toString())
468
+ }
469
+
470
+ OneKeyLog.info("AppUpdate", "downloadASC: HTTP 200, reading ASC content...")
471
+
472
+ val content = StringBuilder()
473
+ val maxAscSize = 10 * 1024 // 10 KB max for ASC files
474
+ val body = response.body ?: throw Exception("Empty ASC response body")
475
+ BufferedReader(InputStreamReader(body.byteStream())).use { reader ->
476
+ var line: String?
477
+ while (reader.readLine().also { line = it } != null) {
478
+ content.append(line).append("\n")
479
+ if (content.length > maxAscSize) {
480
+ OneKeyLog.error("AppUpdate", "downloadASC: ASC file exceeds max size ($maxAscSize bytes)")
481
+ throw Exception("ASC file exceeds maximum allowed size")
482
+ }
483
+ }
484
+ }
485
+
486
+ val ascContent = content.toString()
487
+ if (ascContent.isEmpty()) {
488
+ OneKeyLog.error("AppUpdate", "downloadASC: ASC content is empty")
489
+ throw Exception("Empty ASC file")
490
+ }
491
+
492
+ OneKeyLog.info("AppUpdate", "downloadASC: ASC content size=${ascContent.length} bytes")
493
+
494
+ val ascFile = buildFile(ascFilePath)
495
+ if (ascFile.exists()) {
496
+ OneKeyLog.info("AppUpdate", "downloadASC: existing ASC file found, deleting...")
497
+ ascFile.delete()
498
+ }
499
+ FileOutputStream(ascFile).use { fos ->
500
+ fos.write(ascContent.toByteArray())
501
+ }
502
+ OneKeyLog.info("AppUpdate", "downloadASC: saved ASC file to $ascFilePath")
503
+ }
504
+ }
505
+
506
+ override fun verifyASC(params: AppUpdateFileParams): Promise<Unit> {
507
+ return Promise.async {
508
+ val filePath = filePathFromUrl(params.downloadUrl)
509
+ OneKeyLog.info("AppUpdate", "verifyASC: filePath=$filePath")
510
+
511
+ // Skip GPG verification when DevSettings (developer mode) is enabled
512
+ if (isDevSettingsEnabled()) {
513
+ OneKeyLog.warn("AppUpdate", "verifyASC: GPG verification skipped (DevSettings enabled)")
514
+ return@async
515
+ }
516
+
517
+ try {
518
+
519
+ val ascFilePath = "$filePath.SHA256SUMS.asc"
520
+ val ascFile = buildFile(ascFilePath)
521
+ if (!ascFile.exists()) {
522
+ OneKeyLog.error("AppUpdate", "verifyASC: ASC file not found at $ascFilePath")
523
+ throw Exception("ASC file not found")
524
+ }
525
+
526
+ val ascContent = ascFile.readText()
527
+ OneKeyLog.info("AppUpdate", "verifyASC: ASC file loaded, size=${ascContent.length} bytes")
528
+
529
+ if (!ascContent.contains("-----BEGIN PGP SIGNED MESSAGE-----")) {
530
+ OneKeyLog.error("AppUpdate", "verifyASC: ASC file missing PGP signed message header")
531
+ throw Exception("ASC file does not contain a PGP signed message")
532
+ }
533
+
534
+ // Parse the cleartext signed message
535
+ val lines = ascContent.lines()
536
+ val hashHeaderIdx = lines.indexOfFirst { it.startsWith("Hash:") }
537
+ val sigStartIdx = lines.indexOfFirst { it == "-----BEGIN PGP SIGNATURE-----" }
538
+ val sigEndIdx = lines.indexOfFirst { it == "-----END PGP SIGNATURE-----" }
539
+
540
+ if (hashHeaderIdx < 0 || sigStartIdx < 0 || sigEndIdx < 0) {
541
+ OneKeyLog.error("AppUpdate", "verifyASC: invalid cleartext format (hashHeader=$hashHeaderIdx, sigStart=$sigStartIdx, sigEnd=$sigEndIdx)")
542
+ throw Exception("Invalid PGP cleartext signed message format")
543
+ }
544
+
545
+ OneKeyLog.info("AppUpdate", "verifyASC: parsed cleartext message structure OK")
546
+
547
+ val bodyStartIdx = hashHeaderIdx + 2
548
+ val bodyLines = lines.subList(bodyStartIdx, sigStartIdx)
549
+ val cleartextBody = bodyLines.joinToString("\r\n").trimEnd()
550
+
551
+ val sigBlock = lines.subList(sigStartIdx, sigEndIdx + 1).joinToString("\n")
552
+
553
+ // Parse signature
554
+ OneKeyLog.info("AppUpdate", "verifyASC: parsing PGP signature...")
555
+ val sigInputStream = PGPUtil.getDecoderStream(sigBlock.byteInputStream())
556
+ val sigFactory = JcaPGPObjectFactory(sigInputStream)
557
+ val signatureList = sigFactory.nextObject()
558
+
559
+ if (signatureList !is PGPSignatureList || signatureList.isEmpty) {
560
+ OneKeyLog.error("AppUpdate", "verifyASC: no PGP signature found in ASC file")
561
+ throw Exception("No PGP signature found in ASC file")
562
+ }
563
+
564
+ val pgpSignature = signatureList[0]
565
+ val keyId = pgpSignature.keyID
566
+ OneKeyLog.info("AppUpdate", "verifyASC: signature keyID=${java.lang.Long.toHexString(keyId).uppercase()}")
567
+
568
+ // Parse public key
569
+ OneKeyLog.info("AppUpdate", "verifyASC: loading GPG public key...")
570
+ val pubKeyStream = PGPUtil.getDecoderStream(GPG_PUBLIC_KEY.byteInputStream())
571
+ val pgpPubKeyRingCollection = PGPPublicKeyRingCollection(pubKeyStream, JcaKeyFingerprintCalculator())
572
+ val publicKey = pgpPubKeyRingCollection.getPublicKey(keyId)
573
+ if (publicKey == null) {
574
+ OneKeyLog.error("AppUpdate", "verifyASC: GPG public key not found for keyID=${java.lang.Long.toHexString(keyId).uppercase()}")
575
+ throw Exception("GPG public key not found for signature verification")
576
+ }
577
+ OneKeyLog.info("AppUpdate", "verifyASC: public key matched, verifying signature...")
578
+
579
+ // Verify signature
580
+ pgpSignature.init(JcaPGPContentVerifierBuilderProvider().setProvider(bcProvider), publicKey)
581
+
582
+ val unescapedLines = cleartextBody.lines().map { line ->
583
+ if (line.startsWith("- ")) line.substring(2) else line
584
+ }
585
+ val dataToVerify = unescapedLines.joinToString("\r\n").toByteArray(Charsets.UTF_8)
586
+ pgpSignature.update(dataToVerify)
587
+
588
+ if (!pgpSignature.verify()) {
589
+ OneKeyLog.error("AppUpdate", "verifyASC: GPG signature verification FAILED")
590
+ throw Exception("GPG signature verification failed for ASC file")
591
+ }
592
+ OneKeyLog.info("AppUpdate", "verifyASC: GPG signature verified OK")
593
+
594
+ // Extract SHA256 from cleartext (format: "<sha256hash> <filename>\n" or just "<sha256hash>")
595
+ val sha256 = cleartextBody.trim().split("\\s+".toRegex())[0].lowercase()
596
+ OneKeyLog.info("AppUpdate", "verifyASC: extracted SHA256=${sha256.take(16)}...")
597
+
598
+ if (sha256.length != 64 || !sha256.all { it in '0'..'9' || it in 'a'..'f' }) {
599
+ OneKeyLog.error("AppUpdate", "verifyASC: invalid SHA256 hash format (length=${sha256.length})")
600
+ throw Exception("Invalid SHA256 hash format in ASC file")
601
+ }
602
+
603
+ // Verify APK file SHA256
604
+ val apkFile = buildFile(filePath)
605
+ if (!apkFile.exists()) {
606
+ OneKeyLog.error("AppUpdate", "verifyASC: APK file not found at $filePath")
607
+ throw Exception("APK file not found: $filePath")
608
+ }
609
+
610
+ OneKeyLog.info("AppUpdate", "verifyASC: computing SHA256 of APK file (size=${apkFile.length()} bytes)...")
611
+ val fileSha256 = computeSha256(apkFile)
612
+ OneKeyLog.info("AppUpdate", "verifyASC: APK SHA256=${fileSha256.take(16)}...")
613
+
614
+ if (fileSha256 != sha256) {
615
+ OneKeyLog.error("AppUpdate", "verifyASC: SHA256 MISMATCH — expected=${sha256.take(16)}..., got=${fileSha256.take(16)}...")
616
+ throw Exception("SHA256 mismatch for APK file")
617
+ }
618
+
619
+ OneKeyLog.info("AppUpdate", "verifyASC: GPG signature + SHA256 verification passed")
620
+
621
+ } catch (e: Exception) {
622
+ OneKeyLog.error("AppUpdate", "verifyASC: failed: ${e.javaClass.simpleName}: ${e.message}")
623
+ throw e
624
+ }
625
+ }
626
+ }
627
+
628
+ // Track verified files with their SHA-256 hash to prevent TOCTOU attacks
629
+ private data class VerifiedFile(val canonicalPath: String, val sha256: String)
630
+ private val verifiedFiles = java.util.Collections.synchronizedMap(mutableMapOf<String, String>())
631
+
632
+ override fun verifyAPK(params: AppUpdateFileParams): Promise<Unit> {
633
+ return Promise.async {
634
+ val filePath = filePathFromUrl(params.downloadUrl)
635
+ OneKeyLog.info("AppUpdate", "verifyAPK: filePath=$filePath")
636
+
637
+ val file = buildFile(filePath)
638
+ if (!file.exists()) {
639
+ OneKeyLog.error("AppUpdate", "verifyAPK: APK file not found at $filePath")
640
+ throw Exception("NOT_FOUND_PACKAGE")
641
+ }
642
+ OneKeyLog.info("AppUpdate", "verifyAPK: APK file found, size=${file.length()} bytes")
643
+
644
+ val context = NitroModules.applicationContext
645
+ ?: throw Exception("Application context unavailable")
646
+ val pm = context.packageManager
647
+
648
+ // Check package name
649
+ OneKeyLog.info("AppUpdate", "verifyAPK: parsing APK package info...")
650
+ val info = pm.getPackageArchiveInfo(file.absolutePath, 0)
651
+ if (info?.packageName == null) {
652
+ OneKeyLog.error("AppUpdate", "verifyAPK: failed to parse APK package info (INVALID_PACKAGE)")
653
+ throw Exception("INVALID_PACKAGE")
654
+ }
655
+ 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}")
656
+
657
+ val debugBuild = isDebuggable()
658
+
659
+ // Check package name
660
+ if (info.packageName != context.packageName) {
661
+ OneKeyLog.error("AppUpdate", "verifyAPK: package name mismatch — APK=${info.packageName}, installed=${context.packageName}")
662
+ if (!debugBuild) throw Exception("PACKAGE_NAME_MISMATCH")
663
+ OneKeyLog.warn("AppUpdate", "verifyAPK: DEBUG build — ignoring package name mismatch")
664
+ } else {
665
+ OneKeyLog.info("AppUpdate", "verifyAPK: package name matches installed app")
666
+ }
667
+
668
+ // Verify APK signing certificate matches the installed app
669
+ OneKeyLog.info("AppUpdate", "verifyAPK: verifying APK signing certificate (API level=${Build.VERSION.SDK_INT})...")
670
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
671
+ val apkInfo = pm.getPackageArchiveInfo(file.absolutePath, PackageManager.GET_SIGNING_CERTIFICATES)
672
+ val installedInfo = pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES)
673
+ val apkSigners = apkInfo?.signingInfo?.apkContentsSigners
674
+ val installedSigners = installedInfo?.signingInfo?.apkContentsSigners
675
+ if (apkSigners == null || installedSigners == null) {
676
+ OneKeyLog.error("AppUpdate", "verifyAPK: signing info unavailable (apkSigners=${apkSigners != null}, installedSigners=${installedSigners != null})")
677
+ if (!debugBuild) throw Exception("SIGNATURE_UNAVAILABLE")
678
+ OneKeyLog.warn("AppUpdate", "verifyAPK: DEBUG build — ignoring unavailable signatures")
679
+ } else {
680
+ OneKeyLog.info("AppUpdate", "verifyAPK: APK signers count=${apkSigners.size}, installed signers count=${installedSigners.size}")
681
+ if (apkSigners.toSet() != installedSigners.toSet()) {
682
+ OneKeyLog.error("AppUpdate", "verifyAPK: signing certificate MISMATCH")
683
+ if (!debugBuild) throw Exception("SIGNATURE_MISMATCH")
684
+ OneKeyLog.warn("AppUpdate", "verifyAPK: DEBUG build — ignoring certificate mismatch")
685
+ } else {
686
+ OneKeyLog.info("AppUpdate", "verifyAPK: signing certificate verified OK")
687
+ }
688
+ }
689
+ } else {
690
+ @Suppress("DEPRECATION")
691
+ val apkInfo = pm.getPackageArchiveInfo(file.absolutePath, PackageManager.GET_SIGNATURES)
692
+ @Suppress("DEPRECATION")
693
+ val installedInfo = pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES)
694
+ val apkSignatures = apkInfo?.signatures
695
+ val installedSignatures = installedInfo?.signatures
696
+ if (apkSignatures == null || installedSignatures == null) {
697
+ OneKeyLog.error("AppUpdate", "verifyAPK: legacy signatures unavailable")
698
+ if (!debugBuild) throw Exception("SIGNATURE_UNAVAILABLE")
699
+ OneKeyLog.warn("AppUpdate", "verifyAPK: DEBUG build — ignoring unavailable signatures")
700
+ } else {
701
+ OneKeyLog.info("AppUpdate", "verifyAPK: APK signatures count=${apkSignatures.size}, installed signatures count=${installedSignatures.size}")
702
+ if (apkSignatures.toSet() != installedSignatures.toSet()) {
703
+ OneKeyLog.error("AppUpdate", "verifyAPK: legacy signing certificate MISMATCH")
704
+ if (!debugBuild) throw Exception("SIGNATURE_MISMATCH")
705
+ OneKeyLog.warn("AppUpdate", "verifyAPK: DEBUG build — ignoring certificate mismatch")
706
+ } else {
707
+ OneKeyLog.info("AppUpdate", "verifyAPK: signing certificate verified OK")
708
+ }
709
+ }
710
+ }
711
+
712
+ // Compute SHA-256 hash of the verified file for TOCTOU protection
713
+ OneKeyLog.info("AppUpdate", "verifyAPK: computing SHA-256 hash for TOCTOU protection...")
714
+ val fileHash = computeSha256(file)
715
+ verifiedFiles[file.canonicalPath] = fileHash
716
+ OneKeyLog.info("AppUpdate", "verifyAPK: APK verified successfully, hash=${fileHash.take(16)}..., stored for install verification")
717
+ }
718
+ }
719
+
720
+ override fun installAPK(params: AppUpdateFileParams): Promise<Unit> {
721
+ return Promise.async {
722
+ val filePath = filePathFromUrl(params.downloadUrl)
723
+ OneKeyLog.info("AppUpdate", "installAPK: filePath=$filePath")
724
+
725
+ val context = NitroModules.applicationContext
726
+ ?: throw Exception("Application context unavailable")
727
+ val file = buildFile(filePath)
728
+ if (!file.exists()) {
729
+ OneKeyLog.error("AppUpdate", "installAPK: APK file not found at $filePath")
730
+ throw Exception("NOT_FOUND_PACKAGE")
731
+ }
732
+ OneKeyLog.info("AppUpdate", "installAPK: APK file found, size=${file.length()} bytes")
733
+
734
+ // Ensure verifyAPK was called before installation
735
+ val debugBuild = isDebuggable()
736
+ val expectedHash = verifiedFiles.remove(file.canonicalPath)
737
+ if (expectedHash == null) {
738
+ OneKeyLog.error("AppUpdate", "installAPK: APK was not verified before installation (no hash in verifiedFiles)")
739
+ if (!debugBuild) throw Exception("APK must be verified before installation")
740
+ OneKeyLog.warn("AppUpdate", "installAPK: DEBUG build — ignoring missing verification")
741
+ }
742
+
743
+ // Re-verify file hash to prevent TOCTOU attacks (file swapped after verification)
744
+ if (expectedHash != null) {
745
+ OneKeyLog.info("AppUpdate", "installAPK: expected hash=${expectedHash.take(16)}..., re-verifying TOCTOU...")
746
+ val currentHash = computeSha256(file)
747
+ if (currentHash != expectedHash) {
748
+ OneKeyLog.error("AppUpdate", "installAPK: TOCTOU check FAILED — file was modified after verification (current=${currentHash.take(16)}..., expected=${expectedHash.take(16)}...)")
749
+ if (!debugBuild) throw Exception("APK file was modified after verification")
750
+ OneKeyLog.warn("AppUpdate", "installAPK: DEBUG build — ignoring TOCTOU mismatch")
751
+ } else {
752
+ OneKeyLog.info("AppUpdate", "installAPK: TOCTOU check passed, hash matches")
753
+ }
754
+ }
755
+
756
+ // Parse APK info for logging
757
+ val pm = context.packageManager
758
+ val apkInfo = pm.getPackageArchiveInfo(file.absolutePath, 0)
759
+ if (apkInfo != null) {
760
+ val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) apkInfo.longVersionCode else @Suppress("DEPRECATION") apkInfo.versionCode.toLong()
761
+ OneKeyLog.info("AppUpdate", "installAPK: APK package=${apkInfo.packageName}, versionName=${apkInfo.versionName}, versionCode=$versionCode")
762
+ }
763
+
764
+ OneKeyLog.info("AppUpdate", "installAPK: creating install intent (API level=${Build.VERSION.SDK_INT})...")
765
+ val intent = Intent(Intent.ACTION_VIEW)
766
+ intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
767
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
768
+ val apkUri = FileProvider.getUriForFile(
769
+ context,
770
+ "${context.packageName}.fileprovider",
771
+ file
772
+ )
773
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
774
+ intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
775
+ OneKeyLog.info("AppUpdate", "installAPK: using FileProvider URI=$apkUri")
776
+ } else {
777
+ intent.setDataAndType(Uri.fromFile(file), "application/vnd.android.package-archive")
778
+ OneKeyLog.info("AppUpdate", "installAPK: using file URI")
779
+ }
780
+ context.startActivity(intent)
781
+ OneKeyLog.info("AppUpdate", "installAPK: install intent launched, awaiting user confirmation")
782
+ }
783
+ }
784
+
785
+ override fun clearCache(): Promise<Unit> {
786
+ return Promise.async {
787
+ OneKeyLog.info("AppUpdate", "clearCache: starting cleanup...")
788
+ isDownloading.set(false)
789
+ val verifiedCount = verifiedFiles.size
790
+ verifiedFiles.clear()
791
+ OneKeyLog.info("AppUpdate", "clearCache: reset download state, cleared $verifiedCount verified file entries")
792
+
793
+ // Clean up downloaded APK and ASC files from cacheDir/apks/ directory
794
+ val context = NitroModules.applicationContext
795
+ if (context != null) {
796
+ val apkDir = File(context.cacheDir, "apks")
797
+ if (apkDir.exists()) {
798
+ val filesToDelete = apkDir.listFiles() ?: emptyArray()
799
+ OneKeyLog.info("AppUpdate", "clearCache: found ${filesToDelete.size} cached file(s) to delete in ${apkDir.absolutePath}")
800
+ var deletedCount = 0
801
+ filesToDelete.forEach { file ->
802
+ val size = file.length()
803
+ if (file.delete()) {
804
+ OneKeyLog.debug("AppUpdate", "clearCache: deleted ${file.name} (${size} bytes)")
805
+ deletedCount++
806
+ } else {
807
+ OneKeyLog.warn("AppUpdate", "clearCache: failed to delete ${file.name}")
808
+ }
809
+ }
810
+ OneKeyLog.info("AppUpdate", "clearCache: completed, deleted $deletedCount/${filesToDelete.size} files")
811
+ } else {
812
+ OneKeyLog.info("AppUpdate", "clearCache: apks cache directory does not exist, nothing to clean")
813
+ }
814
+ } else {
815
+ OneKeyLog.warn("AppUpdate", "clearCache: application context unavailable, skipping file cleanup")
816
+ }
817
+ }
818
+ }
819
+ }