@onekeyfe/react-native-app-update 3.0.38 → 3.0.40
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/build.gradle
CHANGED
|
@@ -136,14 +136,16 @@ dependencies {
|
|
|
136
136
|
|
|
137
137
|
implementation project(":onekeyfe_react-native-native-logger")
|
|
138
138
|
|
|
139
|
+
// Shared crypto core: detached-ASC GPG verify, streaming SHA-256, and
|
|
140
|
+
// constant-time hex compare now live in react-native-bundle-crypto
|
|
141
|
+
// (BundleCryptoCore). This module delegates to it instead of carrying its
|
|
142
|
+
// own GPG public key + BouncyCastle verify implementation.
|
|
143
|
+
implementation project(":onekeyfe_react-native-bundle-crypto")
|
|
144
|
+
|
|
139
145
|
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
|
140
146
|
implementation "com.squareup.okio:okio:3.9.0"
|
|
141
147
|
implementation "androidx.core:core-ktx:1.15.0"
|
|
142
148
|
|
|
143
|
-
// BouncyCastle for GPG/PGP signature verification
|
|
144
|
-
implementation "org.bouncycastle:bcpg-jdk15to18:1.78.1"
|
|
145
|
-
implementation "org.bouncycastle:bcprov-jdk15to18:1.78.1"
|
|
146
|
-
|
|
147
149
|
// MMKV for reading DevSettings (compileOnly: provided by the host app via react-native-mmkv)
|
|
148
150
|
compileOnly "io.github.zhongwuzw:mmkv:2.2.4"
|
|
149
151
|
}
|
package/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ConcurrentRangeDownloader.kt
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
package com.margelo.nitro.reactnativeappupdate
|
|
2
|
+
|
|
3
|
+
import okhttp3.OkHttpClient
|
|
4
|
+
import okhttp3.Request
|
|
5
|
+
import java.io.File
|
|
6
|
+
import java.io.RandomAccessFile
|
|
7
|
+
import java.util.concurrent.Executors
|
|
8
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
9
|
+
import java.util.concurrent.atomic.AtomicLong
|
|
10
|
+
import java.util.concurrent.atomic.AtomicReference
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Splits a Range-capable download into [segmentCount] byte ranges fetched in
|
|
14
|
+
* parallel, each written directly into its own offset of ONE pre-allocated
|
|
15
|
+
* `.partial` file (no merge pass, 1x disk). A sidecar `<partial>.progress`
|
|
16
|
+
* manifest records each segment's durably-written cursor so an interrupted
|
|
17
|
+
* download resumes by re-requesting only the unfinished tail of each segment.
|
|
18
|
+
*
|
|
19
|
+
* Mirrors the desktop DesktopApiBundleUpdate concurrent path. This class is
|
|
20
|
+
* intentionally free of Android/OneKey dependencies (logging is injected) so
|
|
21
|
+
* it can be unit/type-checked standalone; the whole-file SHA256 check the
|
|
22
|
+
* caller already performs after promotion is the final correctness backstop.
|
|
23
|
+
*
|
|
24
|
+
* Invariant: the manifest is only meaningful as metadata for an existing
|
|
25
|
+
* `.partial`. Either both exist (resume) or neither does (fresh) — any other
|
|
26
|
+
* combination is treated as "no resumable state".
|
|
27
|
+
*/
|
|
28
|
+
internal class ConcurrentRangeDownloader(
|
|
29
|
+
private val httpClient: OkHttpClient,
|
|
30
|
+
private val segmentCount: Int = 8,
|
|
31
|
+
private val minConcurrentBytes: Long = 2L * 1024 * 1024,
|
|
32
|
+
private val maxPartRetry: Int = 3,
|
|
33
|
+
private val manifestFlushBytes: Long = 4L * 1024 * 1024,
|
|
34
|
+
private val log: (String) -> Unit = {},
|
|
35
|
+
) {
|
|
36
|
+
enum class Outcome {
|
|
37
|
+
/** `.partial` is fully on disk; caller should promote (rename) + verify. */
|
|
38
|
+
COMPLETED,
|
|
39
|
+
|
|
40
|
+
/** Concurrency unusable — caller should use its single-stream path. */
|
|
41
|
+
FALLBACK,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Thrown internally when a segment proves concurrency can't be used. */
|
|
45
|
+
private class FallbackException(message: String) : Exception(message)
|
|
46
|
+
|
|
47
|
+
private class Part(val index: Int, val start: Long, val end: Long, @Volatile var done: Long) {
|
|
48
|
+
val length: Long get() = end - start + 1
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private class Probe(val totalSize: Long, val etag: String?, val supportsRange: Boolean)
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Fills [partialFilePath] completely with the resource at [url] using
|
|
55
|
+
* concurrent ranges. See [Outcome]. Throws on a transient/IO error after
|
|
56
|
+
* per-segment retries, leaving the partial + manifest in place so a later
|
|
57
|
+
* attempt resumes.
|
|
58
|
+
*/
|
|
59
|
+
fun download(
|
|
60
|
+
url: String,
|
|
61
|
+
partialFilePath: String,
|
|
62
|
+
onProgress: (transferred: Long, total: Long) -> Unit,
|
|
63
|
+
): Outcome {
|
|
64
|
+
val partialFile = File(partialFilePath)
|
|
65
|
+
val manifestFile = File("$partialFilePath.progress")
|
|
66
|
+
|
|
67
|
+
// A bare `.partial` with no manifest is a single-stream leftover; let
|
|
68
|
+
// the caller's single-stream path resume it instead of discarding it.
|
|
69
|
+
if (partialFile.exists() && !manifestFile.exists()) {
|
|
70
|
+
log("concurrent: single-stream partial present, deferring to single-stream")
|
|
71
|
+
return Outcome.FALLBACK
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
val probe = probe(url) ?: return Outcome.FALLBACK
|
|
75
|
+
if (!probe.supportsRange || probe.totalSize < minConcurrentBytes) {
|
|
76
|
+
log("concurrent: not eligible (supportsRange=${probe.supportsRange}, size=${probe.totalSize})")
|
|
77
|
+
return Outcome.FALLBACK
|
|
78
|
+
}
|
|
79
|
+
val total = probe.totalSize
|
|
80
|
+
val etag = probe.etag
|
|
81
|
+
|
|
82
|
+
partialFile.parentFile?.let { if (!it.exists()) it.mkdirs() }
|
|
83
|
+
dropOrphanManifest(partialFile, manifestFile)
|
|
84
|
+
val parts = loadOrInitManifest(manifestFile, partialFile, total, etag)
|
|
85
|
+
|
|
86
|
+
val transferred = AtomicLong(parts.sumOf { it.done })
|
|
87
|
+
onProgress(transferred.get(), total)
|
|
88
|
+
|
|
89
|
+
val aborted = AtomicBoolean(false)
|
|
90
|
+
val fallback = AtomicBoolean(false)
|
|
91
|
+
val firstError = AtomicReference<Exception?>(null)
|
|
92
|
+
val lastFlushed = LongArray(parts.size) { parts[it].done }
|
|
93
|
+
|
|
94
|
+
val pool = Executors.newFixedThreadPool(minOf(segmentCount, parts.size))
|
|
95
|
+
try {
|
|
96
|
+
val futures = parts.map { part ->
|
|
97
|
+
pool.submit {
|
|
98
|
+
try {
|
|
99
|
+
downloadPart(url, etag, partialFile, part, aborted) { delta ->
|
|
100
|
+
val t = transferred.addAndGet(delta)
|
|
101
|
+
synchronized(lastFlushed) {
|
|
102
|
+
if (part.done - lastFlushed[part.index] >= manifestFlushBytes) {
|
|
103
|
+
lastFlushed[part.index] = part.done
|
|
104
|
+
flushManifest(manifestFile, total, etag, parts)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
onProgress(t, total)
|
|
108
|
+
}
|
|
109
|
+
} catch (e: FallbackException) {
|
|
110
|
+
fallback.set(true)
|
|
111
|
+
aborted.set(true)
|
|
112
|
+
firstError.compareAndSet(null, e)
|
|
113
|
+
} catch (e: Exception) {
|
|
114
|
+
aborted.set(true)
|
|
115
|
+
firstError.compareAndSet(null, e)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
futures.forEach { it.get() }
|
|
120
|
+
} finally {
|
|
121
|
+
pool.shutdownNow()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (fallback.get()) {
|
|
125
|
+
// Stale/unusable bytes — clear before the caller falls back.
|
|
126
|
+
discard(partialFile, manifestFile)
|
|
127
|
+
return Outcome.FALLBACK
|
|
128
|
+
}
|
|
129
|
+
val err = firstError.get()
|
|
130
|
+
if (err != null) {
|
|
131
|
+
// Transient — persist progress so the next attempt resumes, then bubble up.
|
|
132
|
+
flushManifest(manifestFile, total, etag, parts)
|
|
133
|
+
throw err
|
|
134
|
+
}
|
|
135
|
+
val got = parts.sumOf { it.done }
|
|
136
|
+
if (got < total) {
|
|
137
|
+
flushManifest(manifestFile, total, etag, parts)
|
|
138
|
+
throw java.io.IOException("Concurrent download incomplete ($got/$total)")
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Success: `.partial` is fully filled. The manifest's job is done and it
|
|
142
|
+
// must never outlive the `.partial` it describes (caller is about to
|
|
143
|
+
// promote it), so drop it now.
|
|
144
|
+
manifestFile.delete()
|
|
145
|
+
log("concurrent: completed ($total bytes)")
|
|
146
|
+
return Outcome.COMPLETED
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Single round-trip probe: a one-byte Range request that confirms Range
|
|
150
|
+
// support and captures total size + ETag. OkHttp follows redirects (the
|
|
151
|
+
// caller's client enforces HTTPS on each hop).
|
|
152
|
+
private fun probe(url: String): Probe? {
|
|
153
|
+
return try {
|
|
154
|
+
val req = Request.Builder().url(url).addHeader("Range", "bytes=0-0").build()
|
|
155
|
+
httpClient.newCall(req).execute().use { response ->
|
|
156
|
+
val etag = response.header("ETag")
|
|
157
|
+
when (response.code) {
|
|
158
|
+
206 -> {
|
|
159
|
+
val total = response.header("Content-Range")
|
|
160
|
+
?.let { Regex("""bytes \d+-\d+/(\d+)""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() }
|
|
161
|
+
if (total != null) Probe(total, etag, true) else Probe(0, etag, false)
|
|
162
|
+
}
|
|
163
|
+
200 -> {
|
|
164
|
+
// Server ignored Range — single-stream only.
|
|
165
|
+
val len = response.body?.contentLength() ?: -1L
|
|
166
|
+
Probe(if (len > 0) len else 0, etag, false)
|
|
167
|
+
}
|
|
168
|
+
else -> null
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} catch (e: Exception) {
|
|
172
|
+
log("concurrent: probe failed: ${e.javaClass.simpleName}")
|
|
173
|
+
null
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private fun dropOrphanManifest(partialFile: File, manifestFile: File) {
|
|
178
|
+
if (!partialFile.exists() && manifestFile.exists()) {
|
|
179
|
+
log("concurrent: dropping orphan manifest")
|
|
180
|
+
manifestFile.delete()
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private fun discard(partialFile: File, manifestFile: File) {
|
|
185
|
+
// Manifest first so it never outlives the partial it describes.
|
|
186
|
+
manifestFile.delete()
|
|
187
|
+
partialFile.delete()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Resume from a manifest whose size/ETag still match, else (re)create a
|
|
191
|
+
// fresh pre-allocated partial + manifest. Manifest is removed before the
|
|
192
|
+
// partial is (re)created, and written only after the partial exists.
|
|
193
|
+
private fun loadOrInitManifest(
|
|
194
|
+
manifestFile: File,
|
|
195
|
+
partialFile: File,
|
|
196
|
+
total: Long,
|
|
197
|
+
etag: String?,
|
|
198
|
+
): List<Part> {
|
|
199
|
+
if (manifestFile.exists() && partialFile.exists()) {
|
|
200
|
+
val parsed = parseManifest(manifestFile, total, etag, partialFile.length())
|
|
201
|
+
if (parsed != null) {
|
|
202
|
+
log("concurrent: resuming, transferred=${parsed.sumOf { it.done }}/$total")
|
|
203
|
+
return parsed
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
discard(partialFile, manifestFile)
|
|
207
|
+
RandomAccessFile(partialFile, "rw").use { it.setLength(total) }
|
|
208
|
+
val parts = ArrayList<Part>()
|
|
209
|
+
val chunk = (total + segmentCount - 1) / segmentCount
|
|
210
|
+
var i = 0
|
|
211
|
+
while (i < segmentCount) {
|
|
212
|
+
val start = i * chunk
|
|
213
|
+
if (start >= total) break
|
|
214
|
+
val end = minOf(start + chunk - 1, total - 1)
|
|
215
|
+
parts.add(Part(parts.size, start, end, 0))
|
|
216
|
+
i += 1
|
|
217
|
+
}
|
|
218
|
+
writeManifest(manifestFile, total, etag, parts)
|
|
219
|
+
return parts
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Manifest format (dependency-free, internal): line 0 "<size>|<etag>",
|
|
223
|
+
// then one "<index>,<start>,<end>,<done>" line per segment.
|
|
224
|
+
private fun writeManifest(manifestFile: File, total: Long, etag: String?, parts: List<Part>) {
|
|
225
|
+
val sb = StringBuilder()
|
|
226
|
+
sb.append(total).append('|').append(etag ?: "").append('\n')
|
|
227
|
+
for (p in parts) {
|
|
228
|
+
sb.append(p.index).append(',').append(p.start).append(',')
|
|
229
|
+
.append(p.end).append(',').append(p.done).append('\n')
|
|
230
|
+
}
|
|
231
|
+
manifestFile.writeText(sb.toString())
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
@Synchronized
|
|
235
|
+
private fun flushManifest(manifestFile: File, total: Long, etag: String?, parts: List<Part>) {
|
|
236
|
+
try {
|
|
237
|
+
writeManifest(manifestFile, total, etag, parts)
|
|
238
|
+
} catch (e: Exception) {
|
|
239
|
+
log("concurrent: manifest flush failed: ${e.javaClass.simpleName}")
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private fun parseManifest(manifestFile: File, total: Long, etag: String?, partialSize: Long): List<Part>? {
|
|
244
|
+
return try {
|
|
245
|
+
val lines = manifestFile.readText().trim().split('\n')
|
|
246
|
+
if (lines.isEmpty()) return null
|
|
247
|
+
val head = lines[0].split('|')
|
|
248
|
+
val savedSize = head.getOrNull(0)?.toLongOrNull() ?: return null
|
|
249
|
+
val savedEtag = head.getOrNull(1)?.takeIf { it.isNotEmpty() }
|
|
250
|
+
// Object must be identical to what's on disk and on the CDN.
|
|
251
|
+
if (savedSize != total || partialSize != total) return null
|
|
252
|
+
if (etag != null && savedEtag != null && etag != savedEtag) return null
|
|
253
|
+
val parts = ArrayList<Part>()
|
|
254
|
+
for (idx in 1 until lines.size) {
|
|
255
|
+
val cols = lines[idx].split(',')
|
|
256
|
+
if (cols.size != 4) return null
|
|
257
|
+
val i = cols[0].toIntOrNull() ?: return null
|
|
258
|
+
val s = cols[1].toLongOrNull() ?: return null
|
|
259
|
+
val e = cols[2].toLongOrNull() ?: return null
|
|
260
|
+
var d = cols[3].toLongOrNull() ?: return null
|
|
261
|
+
val segLen = e - s + 1
|
|
262
|
+
if (d < 0) d = 0
|
|
263
|
+
if (d > segLen) d = segLen
|
|
264
|
+
parts.add(Part(i, s, e, d))
|
|
265
|
+
}
|
|
266
|
+
if (parts.isEmpty()) null else parts
|
|
267
|
+
} catch (e: Exception) {
|
|
268
|
+
log("concurrent: manifest parse failed: ${e.javaClass.simpleName}")
|
|
269
|
+
null
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Download [start+done, end] of [part] into its own RandomAccessFile handle
|
|
274
|
+
// (each segment gets its own fd so concurrent positioned writes don't race),
|
|
275
|
+
// resuming from part.done and retrying transient failures in place.
|
|
276
|
+
private fun downloadPart(
|
|
277
|
+
url: String,
|
|
278
|
+
etag: String?,
|
|
279
|
+
partialFile: File,
|
|
280
|
+
part: Part,
|
|
281
|
+
aborted: AtomicBoolean,
|
|
282
|
+
onBytes: (delta: Long) -> Unit,
|
|
283
|
+
) {
|
|
284
|
+
var retry = 0
|
|
285
|
+
while (true) {
|
|
286
|
+
if (aborted.get()) throw java.io.IOException("aborted")
|
|
287
|
+
val rangeStart = part.start + part.done
|
|
288
|
+
if (rangeStart > part.end) return
|
|
289
|
+
try {
|
|
290
|
+
fetchSegment(url, etag, partialFile, part, rangeStart, aborted, onBytes)
|
|
291
|
+
return
|
|
292
|
+
} catch (e: FallbackException) {
|
|
293
|
+
throw e
|
|
294
|
+
} catch (e: Exception) {
|
|
295
|
+
if (aborted.get() || retry >= maxPartRetry) throw e
|
|
296
|
+
retry += 1
|
|
297
|
+
log("concurrent: segment ${part.index} retry $retry: ${e.javaClass.simpleName}")
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private fun fetchSegment(
|
|
303
|
+
url: String,
|
|
304
|
+
etag: String?,
|
|
305
|
+
partialFile: File,
|
|
306
|
+
part: Part,
|
|
307
|
+
rangeStart: Long,
|
|
308
|
+
aborted: AtomicBoolean,
|
|
309
|
+
onBytes: (delta: Long) -> Unit,
|
|
310
|
+
) {
|
|
311
|
+
val builder = Request.Builder().url(url)
|
|
312
|
+
.addHeader("Range", "bytes=$rangeStart-${part.end}")
|
|
313
|
+
// If-Range: a mismatched ETag makes the CDN reply 200 (full body)
|
|
314
|
+
// instead of 206, which we treat as a fallback signal.
|
|
315
|
+
if (etag != null) builder.addHeader("If-Range", etag)
|
|
316
|
+
httpClient.newCall(builder.build()).execute().use { response ->
|
|
317
|
+
if (response.code == 200) {
|
|
318
|
+
throw FallbackException("server returned 200 to a Range request")
|
|
319
|
+
}
|
|
320
|
+
if (response.code != 206) {
|
|
321
|
+
throw java.io.IOException("HTTP ${response.code}")
|
|
322
|
+
}
|
|
323
|
+
val body = response.body ?: throw java.io.IOException("Empty segment body")
|
|
324
|
+
RandomAccessFile(partialFile, "rw").use { raf ->
|
|
325
|
+
raf.seek(rangeStart)
|
|
326
|
+
body.byteStream().use { input ->
|
|
327
|
+
val buffer = ByteArray(8192)
|
|
328
|
+
while (true) {
|
|
329
|
+
if (aborted.get()) throw java.io.IOException("aborted")
|
|
330
|
+
val read = input.read(buffer)
|
|
331
|
+
if (read == -1) break
|
|
332
|
+
raf.write(buffer, 0, read)
|
|
333
|
+
part.done += read
|
|
334
|
+
onBytes(read.toLong())
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
package/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt
CHANGED
|
@@ -17,90 +17,16 @@ import com.margelo.nitro.nativelogger.OneKeyLog
|
|
|
17
17
|
import com.tencent.mmkv.MMKV
|
|
18
18
|
import okhttp3.OkHttpClient
|
|
19
19
|
import okhttp3.Request
|
|
20
|
-
import java.io.BufferedInputStream
|
|
21
20
|
import java.io.BufferedReader
|
|
22
21
|
import java.io.File
|
|
23
22
|
import java.io.FileInputStream
|
|
24
23
|
import java.io.FileOutputStream
|
|
25
24
|
import java.io.InputStreamReader
|
|
26
|
-
import java.security.MessageDigest
|
|
27
25
|
import java.util.concurrent.CopyOnWriteArrayList
|
|
28
26
|
import java.util.concurrent.TimeUnit
|
|
29
27
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
30
28
|
import java.util.concurrent.atomic.AtomicLong
|
|
31
|
-
import
|
|
32
|
-
import org.bouncycastle.openpgp.PGPSignatureList
|
|
33
|
-
import org.bouncycastle.openpgp.PGPUtil
|
|
34
|
-
import org.bouncycastle.openpgp.jcajce.JcaPGPObjectFactory
|
|
35
|
-
import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator
|
|
36
|
-
import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentVerifierBuilderProvider
|
|
37
|
-
import org.bouncycastle.jce.provider.BouncyCastleProvider
|
|
38
|
-
|
|
39
|
-
// OneKey GPG public key for signature verification
|
|
40
|
-
private const val GPG_PUBLIC_KEY = """-----BEGIN PGP PUBLIC KEY BLOCK-----
|
|
41
|
-
|
|
42
|
-
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
-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
-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
-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
-
IUDU2mKy3wmjwS38vIIu4yQyeUdl4EqwkCmGzWc7Cv2HlOG6rLcUdTAOMNBBX1IQ
|
|
47
|
-
iHKg9Bhh96MXYvBhEL7XHJ96S3+gTHw/LtrccBM+eiDJVHPZn+lw2HqX994DueLV
|
|
48
|
-
tAFDS+qf3ieX901IC97PTHsX6ztn9YZQtSGBJO3lEMBdC4ez2B7zUv4bgyfU+KvE
|
|
49
|
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-
IQTraK5UTx/djNJkYk+zaaZ6kL84ewUCactdeAUJDxpqwwAKCRCzaaZ6kL84e8TX
|
|
68
|
-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
-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
-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
-
IGKl+tUayKEEtq7+CrxWHwCXY+wFeeJFm2yhEMqeAZlVpsvGgtfWevQwHaRcld99
|
|
72
|
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-
7CXNe48eDJvjGjDVIpmfL5/59hc/L36HHj+PnFoqtkp2rnMij4ZEZ7iUDTzyXbne
|
|
89
|
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
-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
|
|
102
|
-
=HlTQ
|
|
103
|
-
-----END PGP PUBLIC KEY BLOCK-----"""
|
|
29
|
+
import com.margelo.nitro.reactnativebundlecrypto.BundleCryptoCore
|
|
104
30
|
|
|
105
31
|
private data class Listener(
|
|
106
32
|
val id: Double,
|
|
@@ -113,8 +39,6 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
113
39
|
companion object {
|
|
114
40
|
private const val CHANNEL_ID = "updateApp"
|
|
115
41
|
private const val NOTIFICATION_ID = 1
|
|
116
|
-
// Use our own BouncyCastle provider instance to avoid Android's stripped-down built-in "BC"
|
|
117
|
-
private val bcProvider = BouncyCastleProvider()
|
|
118
42
|
}
|
|
119
43
|
|
|
120
44
|
private val listeners = CopyOnWriteArrayList<Listener>()
|
|
@@ -196,20 +120,18 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
196
120
|
return file
|
|
197
121
|
}
|
|
198
122
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Thin delegate over the shared BundleCryptoCore.sha256OfFile. The streaming
|
|
125
|
+
* MessageDigest implementation now lives in react-native-bundle-crypto.
|
|
126
|
+
* Preserves the original throw-on-failure contract: callers here expect a
|
|
127
|
+
* non-null hex String or an exception, so a null sha256 (with its
|
|
128
|
+
* failureReason taxonomy) is surfaced as a thrown exception rather than a
|
|
129
|
+
* silent empty string.
|
|
130
|
+
*/
|
|
203
131
|
private fun computeSha256(file: File): String {
|
|
204
|
-
val
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
var count: Int
|
|
208
|
-
while (bis.read(buffer).also { count = it } > 0) {
|
|
209
|
-
digest.update(buffer, 0, count)
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
return bytesToHex(digest.digest())
|
|
132
|
+
val result = BundleCryptoCore.sha256OfFile(file.absolutePath)
|
|
133
|
+
return result.sha256
|
|
134
|
+
?: throw java.io.IOException("SHA256 computation failed: ${result.failureReason}")
|
|
213
135
|
}
|
|
214
136
|
|
|
215
137
|
/**
|
|
@@ -250,60 +172,34 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
250
172
|
/**
|
|
251
173
|
* Verify GPG signature of ASC content string and extract the SHA256 hash.
|
|
252
174
|
* Returns the SHA256 hash if signature is valid, null otherwise.
|
|
175
|
+
*
|
|
176
|
+
* Thin delegate over the shared BundleCryptoCore.verifyDetachedAsc — the
|
|
177
|
+
* BouncyCastle cleartext-signed-message parsing/verify and the OneKey GPG
|
|
178
|
+
* public key now live there as the single source of truth. Behavior is
|
|
179
|
+
* preserved: any non-valid result (NOT_PGP_SIGNED_MESSAGE / INVALID_FORMAT /
|
|
180
|
+
* NO_SIGNATURE / PUBKEY_NOT_FOUND / SIGNATURE_INVALID / SHA256_TOKEN_INVALID
|
|
181
|
+
* / ERROR_*) maps to null.
|
|
253
182
|
*/
|
|
254
183
|
private fun verifyAscContentAndExtractSha256(ascContent: String): String? {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
val sigStartIdx = lines.indexOfFirst { it == "-----BEGIN PGP SIGNATURE-----" }
|
|
260
|
-
val sigEndIdx = lines.indexOfFirst { it == "-----END PGP SIGNATURE-----" }
|
|
261
|
-
if (hashHeaderIdx < 0 || sigStartIdx < 0 || sigEndIdx < 0) return null
|
|
262
|
-
|
|
263
|
-
val bodyStartIdx = hashHeaderIdx + 2
|
|
264
|
-
val bodyLines = lines.subList(bodyStartIdx, sigStartIdx)
|
|
265
|
-
val cleartextBody = bodyLines.joinToString("\r\n").trimEnd()
|
|
266
|
-
val sigBlock = lines.subList(sigStartIdx, sigEndIdx + 1).joinToString("\n")
|
|
267
|
-
|
|
268
|
-
// Verify GPG signature
|
|
269
|
-
val sigInputStream = PGPUtil.getDecoderStream(sigBlock.byteInputStream())
|
|
270
|
-
val sigFactory = JcaPGPObjectFactory(sigInputStream)
|
|
271
|
-
val signatureList = sigFactory.nextObject()
|
|
272
|
-
if (signatureList !is PGPSignatureList || signatureList.isEmpty) return null
|
|
273
|
-
|
|
274
|
-
val pgpSignature = signatureList[0]
|
|
275
|
-
val pubKeyStream = PGPUtil.getDecoderStream(GPG_PUBLIC_KEY.byteInputStream())
|
|
276
|
-
val pgpPubKeyRingCollection = PGPPublicKeyRingCollection(pubKeyStream, JcaKeyFingerprintCalculator())
|
|
277
|
-
val publicKey = pgpPubKeyRingCollection.getPublicKey(pgpSignature.keyID) ?: return null
|
|
278
|
-
|
|
279
|
-
pgpSignature.init(JcaPGPContentVerifierBuilderProvider().setProvider(bcProvider), publicKey)
|
|
280
|
-
val unescapedLines = cleartextBody.lines().map { line ->
|
|
281
|
-
if (line.startsWith("- ")) line.substring(2) else line
|
|
184
|
+
val result = BundleCryptoCore.verifyDetachedAsc(ascContent)
|
|
185
|
+
if (!result.valid) {
|
|
186
|
+
OneKeyLog.error("AppUpdate", "ASC verification failed: ${result.reason}")
|
|
187
|
+
return null
|
|
282
188
|
}
|
|
283
|
-
|
|
284
|
-
pgpSignature.update(dataToVerify)
|
|
285
|
-
if (!pgpSignature.verify()) return null
|
|
286
|
-
|
|
287
|
-
// Extract SHA256 from verified cleartext
|
|
288
|
-
val sha256 = cleartextBody.trim().split("\\s+".toRegex())[0].lowercase()
|
|
289
|
-
if (sha256.length != 64 || !sha256.all { it in '0'..'9' || it in 'a'..'f' }) return null
|
|
290
|
-
return sha256
|
|
189
|
+
return result.sha256
|
|
291
190
|
}
|
|
292
191
|
|
|
293
192
|
private fun verifyAscAndExtractSha256(ascFile: File): String? {
|
|
294
193
|
return verifyAscContentAndExtractSha256(ascFile.readText())
|
|
295
194
|
}
|
|
296
195
|
|
|
297
|
-
/**
|
|
196
|
+
/**
|
|
197
|
+
* Constant-time comparison to prevent timing attacks on hash values.
|
|
198
|
+
* Delegates to the shared BundleCryptoCore.secureEqualHex (same byte-wise
|
|
199
|
+
* constant-time algorithm).
|
|
200
|
+
*/
|
|
298
201
|
private fun secureCompare(a: String, b: String): Boolean {
|
|
299
|
-
|
|
300
|
-
val bBytes = b.toByteArray(Charsets.UTF_8)
|
|
301
|
-
if (aBytes.size != bBytes.size) return false
|
|
302
|
-
var result = 0
|
|
303
|
-
for (i in aBytes.indices) {
|
|
304
|
-
result = result or (aBytes[i].toInt() xor bBytes[i].toInt())
|
|
305
|
-
}
|
|
306
|
-
return result == 0
|
|
202
|
+
return BundleCryptoCore.secureEqualHex(a, b)
|
|
307
203
|
}
|
|
308
204
|
|
|
309
205
|
/**
|
|
@@ -664,6 +560,65 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
664
560
|
}
|
|
665
561
|
}
|
|
666
562
|
|
|
563
|
+
// === Concurrent multi-range download (P2) ===
|
|
564
|
+
// Try the 8-range concurrent path first. On COMPLETED the
|
|
565
|
+
// .partial is fully on disk, so we promote + emit downloaded
|
|
566
|
+
// here (SHA/GPG verification still happens later via verifyAPK,
|
|
567
|
+
// same as the single-stream path). On FALLBACK we fall through
|
|
568
|
+
// to single-stream Phase 3 below. Transient errors bubble to the
|
|
569
|
+
// outer catch (the downloader keeps its partial + manifest).
|
|
570
|
+
sendEvent("update/start")
|
|
571
|
+
run {
|
|
572
|
+
val concurrentClient = OkHttpClient.Builder()
|
|
573
|
+
.connectTimeout(10, TimeUnit.SECONDS)
|
|
574
|
+
.readTimeout(60, TimeUnit.SECONDS)
|
|
575
|
+
.followRedirects(false)
|
|
576
|
+
.followSslRedirects(false)
|
|
577
|
+
.build()
|
|
578
|
+
var concurrentProgress = -1
|
|
579
|
+
val concurrentOutcome = ConcurrentRangeDownloader(
|
|
580
|
+
httpClient = concurrentClient,
|
|
581
|
+
log = { msg -> OneKeyLog.info("AppUpdate", msg) },
|
|
582
|
+
).download(url, partialFilePath) { transferred, total ->
|
|
583
|
+
if (total > 0) {
|
|
584
|
+
val p = ((transferred * 100) / total).toInt().coerceIn(0, 100)
|
|
585
|
+
if (p != concurrentProgress) {
|
|
586
|
+
sendEvent("update/downloading", progress = p)
|
|
587
|
+
builder.setProgress(100, p, false)
|
|
588
|
+
if (ActivityCompat.checkSelfPermission(
|
|
589
|
+
context, android.Manifest.permission.POST_NOTIFICATIONS
|
|
590
|
+
) == PackageManager.PERMISSION_GRANTED
|
|
591
|
+
) {
|
|
592
|
+
notifyManager.notify(NOTIFICATION_ID, builder.build())
|
|
593
|
+
}
|
|
594
|
+
concurrentProgress = p
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (concurrentOutcome == ConcurrentRangeDownloader.Outcome.COMPLETED) {
|
|
599
|
+
if (downloadedFile.exists()) downloadedFile.delete()
|
|
600
|
+
if (!partialFile.renameTo(downloadedFile)) {
|
|
601
|
+
OneKeyLog.error("AppUpdate", "downloadAPK: concurrent rename .partial -> final failed")
|
|
602
|
+
throw Exception("Failed to finalize download")
|
|
603
|
+
}
|
|
604
|
+
OneKeyLog.info("AppUpdate", "downloadAPK: concurrent download completed")
|
|
605
|
+
sendEvent("update/downloaded")
|
|
606
|
+
notifyManager.cancel(NOTIFICATION_ID)
|
|
607
|
+
builder.setContentText("")
|
|
608
|
+
.setProgress(0, 0, false)
|
|
609
|
+
.setOngoing(false)
|
|
610
|
+
.setAutoCancel(true)
|
|
611
|
+
if (ActivityCompat.checkSelfPermission(
|
|
612
|
+
context, android.Manifest.permission.POST_NOTIFICATIONS
|
|
613
|
+
) == PackageManager.PERMISSION_GRANTED
|
|
614
|
+
) {
|
|
615
|
+
notifyManager.notify(NOTIFICATION_ID, builder.build())
|
|
616
|
+
}
|
|
617
|
+
return@async
|
|
618
|
+
}
|
|
619
|
+
OneKeyLog.info("AppUpdate", "downloadAPK: concurrent not used, falling back to single-stream")
|
|
620
|
+
}
|
|
621
|
+
|
|
667
622
|
// Phase 3 — fetch (with Range header iff resuming).
|
|
668
623
|
val client = OkHttpClient.Builder()
|
|
669
624
|
.connectTimeout(10, TimeUnit.SECONDS)
|
|
@@ -675,8 +630,8 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
675
630
|
if (partialBytes > 0) {
|
|
676
631
|
requestBuilder.addHeader("Range", "bytes=$partialBytes-")
|
|
677
632
|
}
|
|
678
|
-
|
|
679
|
-
OneKeyLog.info("AppUpdate", "downloadAPK: starting download (resume=${partialBytes > 0})...")
|
|
633
|
+
// update/start already emitted before the concurrent attempt above.
|
|
634
|
+
OneKeyLog.info("AppUpdate", "downloadAPK: starting single-stream download (resume=${partialBytes > 0})...")
|
|
680
635
|
|
|
681
636
|
val response = client.newCall(requestBuilder.build()).execute()
|
|
682
637
|
|
|
@@ -950,79 +905,26 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() {
|
|
|
950
905
|
val ascContent = ascFile.readText()
|
|
951
906
|
OneKeyLog.info("AppUpdate", "verifyASC: ASC file loaded, size=${ascContent.length} bytes")
|
|
952
907
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
//
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
val bodyStartIdx = hashHeaderIdx + 2
|
|
972
|
-
val bodyLines = lines.subList(bodyStartIdx, sigStartIdx)
|
|
973
|
-
val cleartextBody = bodyLines.joinToString("\r\n").trimEnd()
|
|
974
|
-
|
|
975
|
-
val sigBlock = lines.subList(sigStartIdx, sigEndIdx + 1).joinToString("\n")
|
|
976
|
-
|
|
977
|
-
// Parse signature
|
|
978
|
-
OneKeyLog.info("AppUpdate", "verifyASC: parsing PGP signature...")
|
|
979
|
-
val sigInputStream = PGPUtil.getDecoderStream(sigBlock.byteInputStream())
|
|
980
|
-
val sigFactory = JcaPGPObjectFactory(sigInputStream)
|
|
981
|
-
val signatureList = sigFactory.nextObject()
|
|
982
|
-
|
|
983
|
-
if (signatureList !is PGPSignatureList || signatureList.isEmpty) {
|
|
984
|
-
OneKeyLog.error("AppUpdate", "verifyASC: no PGP signature found in ASC file")
|
|
985
|
-
throw Exception("No PGP signature found in ASC file")
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
val pgpSignature = signatureList[0]
|
|
989
|
-
val keyId = pgpSignature.keyID
|
|
990
|
-
OneKeyLog.info("AppUpdate", "verifyASC: signature keyID=${java.lang.Long.toHexString(keyId).uppercase()}")
|
|
991
|
-
|
|
992
|
-
// Parse public key
|
|
993
|
-
OneKeyLog.info("AppUpdate", "verifyASC: loading GPG public key...")
|
|
994
|
-
val pubKeyStream = PGPUtil.getDecoderStream(GPG_PUBLIC_KEY.byteInputStream())
|
|
995
|
-
val pgpPubKeyRingCollection = PGPPublicKeyRingCollection(pubKeyStream, JcaKeyFingerprintCalculator())
|
|
996
|
-
val publicKey = pgpPubKeyRingCollection.getPublicKey(keyId)
|
|
997
|
-
if (publicKey == null) {
|
|
998
|
-
OneKeyLog.error("AppUpdate", "verifyASC: GPG public key not found for keyID=${java.lang.Long.toHexString(keyId).uppercase()}")
|
|
999
|
-
throw Exception("GPG public key not found for signature verification")
|
|
1000
|
-
}
|
|
1001
|
-
OneKeyLog.info("AppUpdate", "verifyASC: public key matched, verifying signature...")
|
|
1002
|
-
|
|
1003
|
-
// Verify signature
|
|
1004
|
-
pgpSignature.init(JcaPGPContentVerifierBuilderProvider().setProvider(bcProvider), publicKey)
|
|
1005
|
-
|
|
1006
|
-
val unescapedLines = cleartextBody.lines().map { line ->
|
|
1007
|
-
if (line.startsWith("- ")) line.substring(2) else line
|
|
1008
|
-
}
|
|
1009
|
-
val dataToVerify = unescapedLines.joinToString("\r\n").toByteArray(Charsets.UTF_8)
|
|
1010
|
-
pgpSignature.update(dataToVerify)
|
|
1011
|
-
|
|
1012
|
-
if (!pgpSignature.verify()) {
|
|
1013
|
-
OneKeyLog.error("AppUpdate", "verifyASC: GPG signature verification FAILED")
|
|
1014
|
-
throw Exception("GPG signature verification failed for ASC file")
|
|
1015
|
-
}
|
|
1016
|
-
OneKeyLog.info("AppUpdate", "verifyASC: GPG signature verified OK")
|
|
1017
|
-
|
|
1018
|
-
// Extract SHA256 from cleartext (format: "<sha256hash> <filename>\n" or just "<sha256hash>")
|
|
1019
|
-
val sha256 = cleartextBody.trim().split("\\s+".toRegex())[0].lowercase()
|
|
1020
|
-
OneKeyLog.info("AppUpdate", "verifyASC: extracted SHA256=${sha256.take(16)}...")
|
|
1021
|
-
|
|
1022
|
-
if (sha256.length != 64 || !sha256.all { it in '0'..'9' || it in 'a'..'f' }) {
|
|
1023
|
-
OneKeyLog.error("AppUpdate", "verifyASC: invalid SHA256 hash format (length=${sha256.length})")
|
|
1024
|
-
throw Exception("Invalid SHA256 hash format in ASC file")
|
|
908
|
+
// Verify the detached SHA256SUMS.asc cleartext signature and extract
|
|
909
|
+
// the expected APK sha256. The BouncyCastle parse/verify, the OneKey
|
|
910
|
+
// GPG public key, and the sha256-token validation now live in the
|
|
911
|
+
// shared BundleCryptoCore.verifyDetachedAsc (single source of truth).
|
|
912
|
+
// On any non-valid result we throw, preserving this method's
|
|
913
|
+
// throw-on-failure contract; the reason taxonomy
|
|
914
|
+
// (NOT_PGP_SIGNED_MESSAGE / INVALID_FORMAT / NO_SIGNATURE /
|
|
915
|
+
// PUBKEY_NOT_FOUND / SIGNATURE_INVALID / SHA256_TOKEN_INVALID /
|
|
916
|
+
// ERROR_*) is logged for diagnostics.
|
|
917
|
+
OneKeyLog.info("AppUpdate", "verifyASC: verifying detached ASC signature via BundleCryptoCore...")
|
|
918
|
+
val ascResult = BundleCryptoCore.verifyDetachedAsc(ascContent)
|
|
919
|
+
// Capture in a local val so Kotlin can smart-cast to non-null below
|
|
920
|
+
// (sha256 is a cross-module data-class property, which is not
|
|
921
|
+
// smart-castable directly).
|
|
922
|
+
val sha256 = ascResult.sha256
|
|
923
|
+
if (!ascResult.valid || sha256 == null) {
|
|
924
|
+
OneKeyLog.error("AppUpdate", "verifyASC: ASC verification FAILED: ${ascResult.reason}")
|
|
925
|
+
throw Exception("ASC signature verification failed: ${ascResult.reason}")
|
|
1025
926
|
}
|
|
927
|
+
OneKeyLog.info("AppUpdate", "verifyASC: ASC verified OK, extracted SHA256=${sha256.take(16)}...")
|
|
1026
928
|
|
|
1027
929
|
// Verify APK file SHA256
|
|
1028
930
|
val apkFile = buildFile(filePath)
|