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