@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.
@@ -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
  }
@@ -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
+ }
@@ -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 org.bouncycastle.openpgp.PGPPublicKeyRingCollection
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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
- 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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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
- 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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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
- IGKl+tUayKEEtq7+CrxWHwCXY+wFeeJFm2yhEMqeAZlVpsvGgtfWevQwHaRcld99
72
- 5ousZOOqsCkl1J7rCeaIFowIEA3TzH0FWIQGahGiHN/+zwc7iSIL9gNEq4/AYJWK
73
- 80jPqyrRDia7VfZA/SULbWaPmmqrn/Y8qYl3jDvT/6BuwXFAgK9pz5NkWggkjAMX
74
- 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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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
- zWRwgehGTfsHx94eNW58kMqWq3DzcfW427qUbBvwzEOBO64eWgOKMINCyfqbtkpT
87
- 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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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
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
- private fun bytesToHex(bytes: ByteArray): String {
200
- return bytes.joinToString("") { "%02x".format(it) }
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 digest = MessageDigest.getInstance("SHA-256")
205
- BufferedInputStream(FileInputStream(file)).use { bis ->
206
- val buffer = ByteArray(8192)
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
- if (!ascContent.contains("-----BEGIN PGP SIGNED MESSAGE-----")) return null
256
-
257
- val lines = ascContent.lines()
258
- val hashHeaderIdx = lines.indexOfFirst { it.startsWith("Hash:") }
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
- val dataToVerify = unescapedLines.joinToString("\r\n").toByteArray(Charsets.UTF_8)
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
- /** Constant-time comparison to prevent timing attacks on hash values */
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
- val aBytes = a.toByteArray(Charsets.UTF_8)
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
- sendEvent("update/start")
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
- if (!ascContent.contains("-----BEGIN PGP SIGNED MESSAGE-----")) {
954
- OneKeyLog.error("AppUpdate", "verifyASC: ASC file missing PGP signed message header")
955
- throw Exception("ASC file does not contain a PGP signed message")
956
- }
957
-
958
- // Parse the cleartext signed message
959
- val lines = ascContent.lines()
960
- val hashHeaderIdx = lines.indexOfFirst { it.startsWith("Hash:") }
961
- val sigStartIdx = lines.indexOfFirst { it == "-----BEGIN PGP SIGNATURE-----" }
962
- val sigEndIdx = lines.indexOfFirst { it == "-----END PGP SIGNATURE-----" }
963
-
964
- if (hashHeaderIdx < 0 || sigStartIdx < 0 || sigEndIdx < 0) {
965
- OneKeyLog.error("AppUpdate", "verifyASC: invalid cleartext format (hashHeader=$hashHeaderIdx, sigStart=$sigStartIdx, sigEnd=$sigEndIdx)")
966
- throw Exception("Invalid PGP cleartext signed message format")
967
- }
968
-
969
- OneKeyLog.info("AppUpdate", "verifyASC: parsed cleartext message structure OK")
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-app-update",
3
- "version": "3.0.38",
3
+ "version": "3.0.40",
4
4
  "description": "react-native-app-update",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",