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