diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 1153730c..0389bc83 100644 --- a/native-modules/native-logger/package.json +++ b/native-modules/native-logger/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-logger", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-native-logger", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-aes-crypto/package.json b/native-modules/react-native-aes-crypto/package.json index 604cada2..ddf4b300 100644 --- a/native-modules/react-native-aes-crypto/package.json +++ b/native-modules/react-native-aes-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-aes-crypto", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-aes-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-app-update/android/build.gradle b/native-modules/react-native-app-update/android/build.gradle index 78120a0e..517459d8 100644 --- a/native-modules/react-native-app-update/android/build.gradle +++ b/native-modules/react-native-app-update/android/build.gradle @@ -142,6 +142,11 @@ dependencies { // own GPG public key + BouncyCastle verify implementation. implementation project(":onekeyfe_react-native-bundle-crypto") + // Shared 8-range concurrent downloader (segment-file model). app-update no + // longer carries its own copy of ConcurrentRangeDownloader — it consumes the + // single shared implementation here, same as react-native-bundle-update. + implementation project(":onekeyfe_react-native-range-downloader") + implementation "com.squareup.okhttp3:okhttp:4.12.0" implementation "com.squareup.okio:okio:3.9.0" implementation "androidx.core:core-ktx:1.15.0" diff --git a/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ConcurrentRangeDownloader.kt b/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ConcurrentRangeDownloader.kt deleted file mode 100644 index 4a73b903..00000000 --- a/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ConcurrentRangeDownloader.kt +++ /dev/null @@ -1,362 +0,0 @@ -package com.margelo.nitro.reactnativeappupdate - -import okhttp3.OkHttpClient -import okhttp3.Request -import java.io.File -import java.io.RandomAccessFile -import java.util.concurrent.Executors -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicLong -import java.util.concurrent.atomic.AtomicReference - -/** - * Splits a Range-capable download into [segmentCount] byte ranges fetched in - * parallel, each written directly into its own offset of ONE pre-allocated - * `.partial` file (no merge pass, 1x disk). A sidecar `.progress` - * manifest records each segment's durably-written cursor so an interrupted - * download resumes by re-requesting only the unfinished tail of each segment. - * - * Mirrors the desktop DesktopApiBundleUpdate concurrent path. This class is - * intentionally free of Android/OneKey dependencies (logging is injected) so - * it can be unit/type-checked standalone. - * - * LOAD-BEARING: the caller's whole-file SHA256 + GPG verify performed AFTER - * promotion is the final correctness backstop for this concurrent path and must - * NEVER be skipped. Resume here is best-effort (ETag-gated; no ETag => fresh - * start), and concurrent stitching of ranges has no per-segment integrity - * check — a mismatched or mixed file is only ever caught by that downstream - * whole-file verify. Removing it would let a corrupt/mixed APK reach install. - * - * Invariant: the manifest is only meaningful as metadata for an existing - * `.partial`. Either both exist (resume) or neither does (fresh) — any other - * combination is treated as "no resumable state". - */ -internal class ConcurrentRangeDownloader( - private val httpClient: OkHttpClient, - private val segmentCount: Int = 8, - private val minConcurrentBytes: Long = 2L * 1024 * 1024, - private val maxPartRetry: Int = 3, - private val manifestFlushBytes: Long = 4L * 1024 * 1024, - private val log: (String) -> Unit = {}, -) { - enum class Outcome { - /** `.partial` is fully on disk; caller should promote (rename) + verify. */ - COMPLETED, - - /** Concurrency unusable — caller should use its single-stream path. */ - FALLBACK, - } - - /** Thrown internally when a segment proves concurrency can't be used. */ - private class FallbackException(message: String) : Exception(message) - - private class Part(val index: Int, val start: Long, val end: Long, @Volatile var done: Long) { - val length: Long get() = end - start + 1 - } - - private class Probe(val totalSize: Long, val etag: String?, val supportsRange: Boolean) - - /** - * Fills [partialFilePath] completely with the resource at [url] using - * concurrent ranges. See [Outcome]. Throws on a transient/IO error after - * per-segment retries, leaving the partial + manifest in place so a later - * attempt resumes. - */ - fun download( - url: String, - partialFilePath: String, - onProgress: (transferred: Long, total: Long) -> Unit, - ): Outcome { - val partialFile = File(partialFilePath) - val manifestFile = File("$partialFilePath.progress") - - // A bare `.partial` with no manifest is a single-stream leftover; let - // the caller's single-stream path resume it instead of discarding it. - if (partialFile.exists() && !manifestFile.exists()) { - log("concurrent: single-stream partial present, deferring to single-stream") - return Outcome.FALLBACK - } - - val probe = probe(url) ?: return Outcome.FALLBACK - if (!probe.supportsRange || probe.totalSize < minConcurrentBytes) { - log("concurrent: not eligible (supportsRange=${probe.supportsRange}, size=${probe.totalSize})") - return Outcome.FALLBACK - } - val total = probe.totalSize - val etag = probe.etag - - partialFile.parentFile?.let { if (!it.exists()) it.mkdirs() } - dropOrphanManifest(partialFile, manifestFile) - val parts = loadOrInitManifest(manifestFile, partialFile, total, etag) - - val transferred = AtomicLong(parts.sumOf { it.done }) - onProgress(transferred.get(), total) - - val aborted = AtomicBoolean(false) - val fallback = AtomicBoolean(false) - val firstError = AtomicReference(null) - val lastFlushed = LongArray(parts.size) { parts[it].done } - - val pool = Executors.newFixedThreadPool(minOf(segmentCount, parts.size)) - try { - val futures = parts.map { part -> - pool.submit { - try { - downloadPart(url, etag, partialFile, part, aborted) { delta -> - val t = transferred.addAndGet(delta) - synchronized(lastFlushed) { - if (part.done - lastFlushed[part.index] >= manifestFlushBytes) { - lastFlushed[part.index] = part.done - flushManifest(manifestFile, total, etag, parts) - } - } - onProgress(t, total) - } - } catch (e: FallbackException) { - fallback.set(true) - aborted.set(true) - firstError.compareAndSet(null, e) - } catch (e: Exception) { - aborted.set(true) - firstError.compareAndSet(null, e) - } - } - } - futures.forEach { it.get() } - } finally { - pool.shutdownNow() - } - - if (fallback.get()) { - // Stale/unusable bytes — clear before the caller falls back. - discard(partialFile, manifestFile) - return Outcome.FALLBACK - } - val err = firstError.get() - if (err != null) { - // Transient — persist progress so the next attempt resumes, then bubble up. - flushManifest(manifestFile, total, etag, parts) - throw err - } - val got = parts.sumOf { it.done } - if (got < total) { - flushManifest(manifestFile, total, etag, parts) - throw java.io.IOException("Concurrent download incomplete ($got/$total)") - } - - // Success: `.partial` is fully filled. The manifest's job is done and it - // must never outlive the `.partial` it describes (caller is about to - // promote it), so drop it now. - manifestFile.delete() - log("concurrent: completed ($total bytes)") - return Outcome.COMPLETED - } - - // Single round-trip probe: a one-byte Range request that confirms Range - // support and captures total size + ETag. The caller's OkHttp client has - // redirects DISABLED (followRedirects(false)/followSslRedirects(false)), so - // a 3xx is not a 200/206 and probe() returns null -> Outcome.FALLBACK; the - // single-stream path (also redirect-disabled) then handles it. - // TODO: if APK origins ever start issuing redirects, decide deliberately - // whether to enable followSslRedirects(true) (HTTPS-only) here; until then - // redirecting origins simply fall back to single-stream. - private fun probe(url: String): Probe? { - return try { - val req = Request.Builder().url(url).addHeader("Range", "bytes=0-0").build() - httpClient.newCall(req).execute().use { response -> - val etag = response.header("ETag") - when (response.code) { - 206 -> { - val total = response.header("Content-Range") - ?.let { Regex("""bytes \d+-\d+/(\d+)""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() } - if (total != null) Probe(total, etag, true) else Probe(0, etag, false) - } - 200 -> { - // Server ignored Range — single-stream only. - val len = response.body?.contentLength() ?: -1L - Probe(if (len > 0) len else 0, etag, false) - } - else -> null - } - } - } catch (e: Exception) { - log("concurrent: probe failed: ${e.javaClass.simpleName}") - null - } - } - - private fun dropOrphanManifest(partialFile: File, manifestFile: File) { - if (!partialFile.exists() && manifestFile.exists()) { - log("concurrent: dropping orphan manifest") - manifestFile.delete() - } - } - - private fun discard(partialFile: File, manifestFile: File) { - // Manifest first so it never outlives the partial it describes. - manifestFile.delete() - partialFile.delete() - } - - // Resume from a manifest whose size/ETag still match, else (re)create a - // fresh pre-allocated partial + manifest. Manifest is removed before the - // partial is (re)created, and written only after the partial exists. - private fun loadOrInitManifest( - manifestFile: File, - partialFile: File, - total: Long, - etag: String?, - ): List { - if (manifestFile.exists() && partialFile.exists()) { - val parsed = parseManifest(manifestFile, total, etag, partialFile.length()) - if (parsed != null) { - log("concurrent: resuming, transferred=${parsed.sumOf { it.done }}/$total") - return parsed - } - } - discard(partialFile, manifestFile) - RandomAccessFile(partialFile, "rw").use { it.setLength(total) } - val parts = ArrayList() - val chunk = (total + segmentCount - 1) / segmentCount - var i = 0 - while (i < segmentCount) { - val start = i * chunk - if (start >= total) break - val end = minOf(start + chunk - 1, total - 1) - parts.add(Part(parts.size, start, end, 0)) - i += 1 - } - writeManifest(manifestFile, total, etag, parts) - return parts - } - - // Manifest format (dependency-free, internal): line 0 "|", - // then one ",,," line per segment. - private fun writeManifest(manifestFile: File, total: Long, etag: String?, parts: List) { - val sb = StringBuilder() - sb.append(total).append('|').append(etag ?: "").append('\n') - for (p in parts) { - sb.append(p.index).append(',').append(p.start).append(',') - .append(p.end).append(',').append(p.done).append('\n') - } - manifestFile.writeText(sb.toString()) - } - - @Synchronized - private fun flushManifest(manifestFile: File, total: Long, etag: String?, parts: List) { - try { - writeManifest(manifestFile, total, etag, parts) - } catch (e: Exception) { - log("concurrent: manifest flush failed: ${e.javaClass.simpleName}") - } - } - - private fun parseManifest(manifestFile: File, total: Long, etag: String?, partialSize: Long): List? { - return try { - val lines = manifestFile.readText().trim().split('\n') - if (lines.isEmpty()) return null - val head = lines[0].split('|') - val savedSize = head.getOrNull(0)?.toLongOrNull() ?: return null - val savedEtag = head.getOrNull(1)?.takeIf { it.isNotEmpty() } - // Object must be identical to what's on disk and on the CDN. - if (savedSize != total || partialSize != total) return null - // Without an ETag we have no strong validator that the bytes already - // on disk belong to the SAME build as the one the CDN is serving now. - // A same-size-but-different build (common during a staged rollout) - // would otherwise resume by stitching old + new bytes into one file. - // The downstream whole-file SHA256 + GPG verify WOULD reject that - // mixed file, but only after we've wasted the whole download. So treat - // an ETag-less manifest as untrustworthy across restarts: bail out - // here and let the caller start fresh. - // (If a weaker validator is ever wanted, persist+compare Last-Modified - // instead of dropping outright.) - if (etag == null || savedEtag == null) return null - if (etag != savedEtag) return null - val parts = ArrayList() - for (idx in 1 until lines.size) { - val cols = lines[idx].split(',') - if (cols.size != 4) return null - val i = cols[0].toIntOrNull() ?: return null - val s = cols[1].toLongOrNull() ?: return null - val e = cols[2].toLongOrNull() ?: return null - var d = cols[3].toLongOrNull() ?: return null - val segLen = e - s + 1 - if (d < 0) d = 0 - if (d > segLen) d = segLen - parts.add(Part(i, s, e, d)) - } - if (parts.isEmpty()) null else parts - } catch (e: Exception) { - log("concurrent: manifest parse failed: ${e.javaClass.simpleName}") - null - } - } - - // Download [start+done, end] of [part] into its own RandomAccessFile handle - // (each segment gets its own fd so concurrent positioned writes don't race), - // resuming from part.done and retrying transient failures in place. - private fun downloadPart( - url: String, - etag: String?, - partialFile: File, - part: Part, - aborted: AtomicBoolean, - onBytes: (delta: Long) -> Unit, - ) { - var retry = 0 - while (true) { - if (aborted.get()) throw java.io.IOException("aborted") - val rangeStart = part.start + part.done - if (rangeStart > part.end) return - try { - fetchSegment(url, etag, partialFile, part, rangeStart, aborted, onBytes) - return - } catch (e: FallbackException) { - throw e - } catch (e: Exception) { - if (aborted.get() || retry >= maxPartRetry) throw e - retry += 1 - log("concurrent: segment ${part.index} retry $retry: ${e.javaClass.simpleName}") - } - } - } - - private fun fetchSegment( - url: String, - etag: String?, - partialFile: File, - part: Part, - rangeStart: Long, - aborted: AtomicBoolean, - onBytes: (delta: Long) -> Unit, - ) { - val builder = Request.Builder().url(url) - .addHeader("Range", "bytes=$rangeStart-${part.end}") - // If-Range: a mismatched ETag makes the CDN reply 200 (full body) - // instead of 206, which we treat as a fallback signal. - if (etag != null) builder.addHeader("If-Range", etag) - httpClient.newCall(builder.build()).execute().use { response -> - if (response.code == 200) { - throw FallbackException("server returned 200 to a Range request") - } - if (response.code != 206) { - throw java.io.IOException("HTTP ${response.code}") - } - val body = response.body ?: throw java.io.IOException("Empty segment body") - RandomAccessFile(partialFile, "rw").use { raf -> - raf.seek(rangeStart) - body.byteStream().use { input -> - val buffer = ByteArray(8192) - while (true) { - if (aborted.get()) throw java.io.IOException("aborted") - val read = input.read(buffer) - if (read == -1) break - raf.write(buffer, 0, read) - part.done += read - onBytes(read.toLong()) - } - } - } - } - } -} diff --git a/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt b/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt index 6bae92ab..65424b6e 100644 --- a/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt +++ b/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt @@ -28,6 +28,11 @@ import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong import com.margelo.nitro.reactnativebundlecrypto.BundleCryptoCore +// Shared 8-range concurrent downloader (segment-file model, no whole-file +// pre-allocation). Previously app-update bundled its own private copy; it now +// consumes the single shared implementation in react-native-range-downloader +// so a fix lands once for both APK and JS-bundle downloads. +import com.margelo.nitro.reactnativerangedownloader.ConcurrentRangeDownloader private data class Listener( val id: Double, @@ -40,6 +45,12 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { companion object { private const val CHANNEL_ID = "updateApp" private const val NOTIFICATION_ID = 1 + + // Must match ConcurrentRangeDownloader's default segmentCount: the + // concurrent downloader writes sibling segment files + // ".seg0"..".seg${N-1}", and Phase 2 below scans this + // range to detect an in-flight concurrent download. + private const val CONCURRENT_SEGMENT_COUNT = 8 } private val listeners = CopyOnWriteArrayList() @@ -68,10 +79,21 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { listeners.removeAll { it.id == id } } - private fun getApkCacheDir(): File { + /** + * Directory for downloaded APK artifacts (.apk / .partial / .segN / .asc). + * + * Uses filesDir, NOT cacheDir: cacheDir is system-reclaimable — under + * storage pressure Android can purge it or make it unwritable mid-download, + * surfacing as EROFS/ENOSPC when opening a new segment file. filesDir is + * persistent app data and is the same location react-native-bundle-update + * downloads to. It is still installable: the APK is handed to the system + * installer via FileProvider, whose exposes this dir + * (see res/xml/app_update_file_paths.xml: ). + */ + private fun getApkDownloadDir(): File { val context = NitroModules.applicationContext ?: throw SecurityException("Application context unavailable") - val apkDir = File(context.cacheDir, "apks") + val apkDir = File(context.filesDir, "apks") if (!apkDir.exists()) apkDir.mkdirs() return apkDir } @@ -97,7 +119,7 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { val file = if (stripped.startsWith("/")) { File(stripped) } else { - File(getApkCacheDir(), stripped) + File(getApkDownloadDir(), stripped) } // Validate the resolved path is within the app's cache or files directory @@ -147,16 +169,17 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { .followSslRedirects(false) .build() val request = Request.Builder().url(ascUrl).build() - val response = client.newCall(request).execute() - if (!response.isSuccessful) return null val content = StringBuilder() val maxAscSize = 10 * 1024 - val body = response.body ?: return null - BufferedReader(InputStreamReader(body.byteStream())).use { reader -> - var line: String? - while (reader.readLine().also { line = it } != null) { - content.append(line).append("\n") - if (content.length > maxAscSize) return null + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) return null + val body = response.body ?: return null + BufferedReader(InputStreamReader(body.byteStream())).use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + content.append(line).append("\n") + if (content.length > maxAscSize) return null + } } } val ascContent = content.toString() @@ -482,10 +505,24 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { expectedSize > 0 && existingSize > expectedSize -> { OneKeyLog.warn("AppUpdate", "downloadAPK: existing APK larger than expected, deleting") downloadedFile.delete() + // Oversized final means local state is untrustworthy. A stale + // single-stream .partial and any sibling .segN left by an earlier + // concurrent run could otherwise be picked up below and reused — + // wipe them so this restarts cleanly from byte zero. + if (partialFile.exists()) partialFile.delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete() } expectedSize > 0 && existingSize < expectedSize -> { OneKeyLog.info("AppUpdate", "downloadAPK: existing APK smaller than expected, promoting to .partial for resume") if (partialFile.exists()) partialFile.delete() + // Drop any sibling .segN BEFORE the promotion. The promoted final + // must become a clean single-stream resume cursor: if .segN + // survived, Phase 2's hasConcurrentSegments check would fire and + // the concurrent downloader would treat this legacy .partial as a + // concat committed-cursor, risking a mixed file. With no .segN, + // Phase 2 takes the single-stream size-based path and Range-resumes + // from the partial's length. + for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete() if (!downloadedFile.renameTo(partialFile)) { OneKeyLog.warn("AppUpdate", "downloadAPK: rename to .partial failed, deleting stale final") downloadedFile.delete() @@ -502,6 +539,10 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { OneKeyLog.info("AppUpdate", "downloadAPK: existing APK hash mismatch, deleting and re-downloading") downloadedFile.delete() if (partialFile.exists()) partialFile.delete() + // Final was stale → start from byte zero. Wipe any + // sibling .segN left by an earlier concurrent run so + // it can't be mistaken for trustworthy in-flight bytes. + for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete() } ApkVerifyOutcome.Indeterminate -> { // ASC could not be fetched (offline) or could @@ -520,23 +561,21 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { // Phase 2 — pick up an in-flight partial. // - // A concurrent (multi-range) partial is pre-allocated to the FULL - // size up front (RandomAccessFile.setLength(total)) and tracks the - // real, durably-written cursor only in its sidecar - // ".progress" manifest — the .partial itself is zero-filled - // past the real data. The size-based classification below is blind - // to that: it would see partialSize == expectedSize and try to - // promote+SHA-verify a mostly-zeroed file, hit HashMismatch, and - // DELETE it — nuking every interrupted concurrent download back to - // byte 0. So when the manifest exists, skip the size-based + // The concurrent (multi-range) downloader stores in-flight bytes in + // sibling segment files ".seg0"..".segN" — NOT in + // the .partial (the .partial only ever holds real, fully-assembled + // bytes, written by the concurrent downloader's final concat or by + // the single-stream path). When any segment file exists, an + // interrupted concurrent download owns the slot: skip the size-based // promote/discard branches entirely and let the concurrent - // downloader below own the file: it resumes from the manifest (or - // returns FALLBACK and hands the bytes back to single-stream). - // The path must match exactly what ConcurrentRangeDownloader writes: - // File("$partialFilePath.progress"). - val hasConcurrentManifest = buildFile("$partialFilePath.progress").exists() + // downloader below resume (it picks up each .segN, or returns + // FALLBACK and hands the bytes back to single-stream). The segment + // path must match exactly what ConcurrentRangeDownloader writes: + // File("$partialFilePath.seg$i"). + val hasConcurrentSegments = + (0 until CONCURRENT_SEGMENT_COUNT).any { buildFile("$partialFilePath.seg$it").exists() } var partialBytes = 0L - if (partialFile.exists() && !hasConcurrentManifest) { + if (partialFile.exists() && !hasConcurrentSegments) { val partialSize = partialFile.length() when { expectedSize > 0 && partialSize == expectedSize -> { @@ -568,6 +607,8 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { expectedSize > 0 && partialSize > expectedSize -> { OneKeyLog.warn("AppUpdate", "downloadAPK: stale partial (>expected $partialSize/$expectedSize), discarding") partialFile.delete() + // Partial bytes are untrustworthy → restart from zero; drop any sibling .segN too. + for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete() } partialSize > 0 -> { partialBytes = partialSize @@ -605,7 +646,14 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { val concurrentOutcome = ConcurrentRangeDownloader( httpClient = concurrentClient, log = { msg -> OneKeyLog.info("AppUpdate", msg) }, - ).download(url, partialFilePath) { transferred, total -> + // MUST be the ABSOLUTE path. partialFilePath is just a + // file NAME (".partial"); the downloader does + // File(path) on it, which resolves a relative name + // against the process CWD ("/") and writes the segment + // files to the read-only root fs → EROFS. Resolve it to + // the real apks dir (filesDir/apks) first, exactly like + // react-native-bundle-update passes an absolute path. + ).download(url, partialFile.absolutePath) { transferred, total -> if (total > 0) { val p = ((transferred * 100) / total).toInt().coerceIn(0, 100) // Only the thread that advances the percent emits; a @@ -694,6 +742,8 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { // anyone reading the error. OneKeyLog.warn("AppUpdate", "downloadAPK: 416 recovery hash mismatch — server build changed mid-download") if (partialFile.exists()) partialFile.delete() + // Server build changed → these bytes are worthless; drop sibling .segN too. + for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete() throw java.io.IOException("Server build changed mid-download (size matches but hash differs)") } PromoteOutcome.Deferred -> { @@ -710,6 +760,8 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { } OneKeyLog.warn("AppUpdate", "downloadAPK: HTTP 416 (range not satisfiable), discarding partial and failing attempt") if (partialFile.exists()) partialFile.delete() + // Partial discarded as unusable → drop sibling .segN too so the next attempt starts clean. + for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete() throw Exception("HTTP 416 (range not satisfiable)") } @@ -728,6 +780,8 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { if (expectsResume && !serverWillResume) { OneKeyLog.warn("AppUpdate", "downloadAPK: requested Range but server returned 200, restarting from scratch") if (partialFile.exists()) partialFile.delete() + // Restarting from byte zero → drop any sibling .segN too. + for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete() partialBytes = 0L } @@ -744,10 +798,20 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { if (serverWillResume) { val rangeStart = rangeMatch?.groupValues?.getOrNull(1)?.toLongOrNull() if (rangeStart == null || rangeStart != partialBytes) { - OneKeyLog.warn("AppUpdate", "downloadAPK: 206 Content-Range start mismatch (header='${response.header("Content-Range")}', requested=$partialBytes); treating as full restart") + // This 206 body is a slice starting at the wrong offset (CDN bug / + // proxy rewrite). It is NOT a full file, so we must not consume it as + // one — doing so would write mis-aligned bytes (caught only later at + // SHA/GPG, after a bogus "downloaded" event). Wipe the partial + any + // .segN so the next attempt starts clean from byte zero, close the + // bad response, and throw a retryable error. The outer + // catch(Exception) emits update/error and rethrows; finally resets + // isDownloading, so this won't wedge. + val contentRangeHeader = response.header("Content-Range") + OneKeyLog.warn("AppUpdate", "downloadAPK: 206 Content-Range start mismatch (header='$contentRangeHeader', requested=$partialBytes); discarding partial and retrying from scratch") if (partialFile.exists()) partialFile.delete() - partialBytes = 0L - serverWillResume = false + for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete() + response.close() + throw java.io.IOException("206 Content-Range start mismatch (header='$contentRangeHeader', requested=$partialBytes); discarded partial, retry from scratch") } } @@ -866,33 +930,37 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { .followSslRedirects(false) .build() val request = Request.Builder().url(ascFileUrl).build() - val response = client.newCall(request).execute() - - if (!response.isSuccessful) { - OneKeyLog.error("AppUpdate", "downloadASC: HTTP error, statusCode=${response.code}") - throw Exception(response.code.toString()) - } - - OneKeyLog.info("AppUpdate", "downloadASC: HTTP 200, reading ASC content...") + // Wrap in .use so the Response (and its connection) is released on every + // exit path — including the !isSuccessful / empty-body / oversize throws, + // which previously leaked the connection back to the pool unclosed. + val ascContent = client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + OneKeyLog.error("AppUpdate", "downloadASC: HTTP error, statusCode=${response.code}") + throw Exception(response.code.toString()) + } - val content = StringBuilder() - val maxAscSize = 10 * 1024 // 10 KB max for ASC files - val body = response.body ?: throw Exception("Empty ASC response body") - BufferedReader(InputStreamReader(body.byteStream())).use { reader -> - var line: String? - while (reader.readLine().also { line = it } != null) { - content.append(line).append("\n") - if (content.length > maxAscSize) { - OneKeyLog.error("AppUpdate", "downloadASC: ASC file exceeds max size ($maxAscSize bytes)") - throw Exception("ASC file exceeds maximum allowed size") + OneKeyLog.info("AppUpdate", "downloadASC: HTTP 200, reading ASC content...") + + val content = StringBuilder() + val maxAscSize = 10 * 1024 // 10 KB max for ASC files + val body = response.body ?: throw Exception("Empty ASC response body") + BufferedReader(InputStreamReader(body.byteStream())).use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + content.append(line).append("\n") + if (content.length > maxAscSize) { + OneKeyLog.error("AppUpdate", "downloadASC: ASC file exceeds max size ($maxAscSize bytes)") + throw Exception("ASC file exceeds maximum allowed size") + } } } - } - val ascContent = content.toString() - if (ascContent.isEmpty()) { - OneKeyLog.error("AppUpdate", "downloadASC: ASC content is empty") - throw Exception("Empty ASC file") + val parsed = content.toString() + if (parsed.isEmpty()) { + OneKeyLog.error("AppUpdate", "downloadASC: ASC content is empty") + throw Exception("Empty ASC file") + } + parsed } OneKeyLog.info("AppUpdate", "downloadASC: ASC content size=${ascContent.length} bytes") @@ -1207,7 +1275,8 @@ n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ OneKeyLog.warn("AppUpdate", "$tag: application context unavailable, skipping file cleanup") return } - val apkDir = File(context.cacheDir, "apks") + // Must match getApkDownloadDir() (filesDir/apks). + val apkDir = File(context.filesDir, "apks") if (!apkDir.exists()) { OneKeyLog.info("AppUpdate", "$tag: apks cache directory does not exist, nothing to clean") return diff --git a/native-modules/react-native-app-update/android/src/main/res/xml/app_update_file_paths.xml b/native-modules/react-native-app-update/android/src/main/res/xml/app_update_file_paths.xml index 4e3516ee..08cad790 100644 --- a/native-modules/react-native-app-update/android/src/main/res/xml/app_update_file_paths.xml +++ b/native-modules/react-native-app-update/android/src/main/res/xml/app_update_file_paths.xml @@ -1,4 +1,8 @@ + + + diff --git a/native-modules/react-native-app-update/package.json b/native-modules/react-native-app-update/package.json index 0953d2ba..f1c199c8 100644 --- a/native-modules/react-native-app-update/package.json +++ b/native-modules/react-native-app-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-app-update", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-app-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-async-storage/package.json b/native-modules/react-native-async-storage/package.json index b22470e0..e057d1a9 100644 --- a/native-modules/react-native-async-storage/package.json +++ b/native-modules/react-native-async-storage/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-async-storage", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-async-storage", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-background-thread/package.json b/native-modules/react-native-background-thread/package.json index 04d08e47..f04b3c6f 100644 --- a/native-modules/react-native-background-thread/package.json +++ b/native-modules/react-native-background-thread/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-background-thread", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-crypto/package.json b/native-modules/react-native-bundle-crypto/package.json index b5cf90dd..89876982 100644 --- a/native-modules/react-native-bundle-crypto/package.json +++ b/native-modules/react-native-bundle-crypto/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-crypto", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-bundle-crypto", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-bundle-update/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt b/native-modules/react-native-bundle-update/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt index 7906888c..9d2d6813 100644 --- a/native-modules/react-native-bundle-update/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt +++ b/native-modules/react-native-bundle-update/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt @@ -20,6 +20,7 @@ import java.nio.file.Path import java.nio.file.Paths import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong import java.util.zip.ZipEntry import java.util.zip.ZipInputStream @@ -1083,6 +1084,12 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { companion object { private const val PREFS_NAME = "BundleUpdatePrefs" + + // Number of concurrent segment files (`.seg0..seg{N-1}`) the + // ConcurrentRangeDownloader produces. MUST equal the segmentCount passed + // to ConcurrentRangeDownloader (currently the default 8). Every place + // that cleans up `.segN` files must iterate `0 until CONCURRENT_SEGMENT_COUNT`. + private const val CONCURRENT_SEGMENT_COUNT = 8 } private val listeners = CopyOnWriteArrayList() @@ -1234,9 +1241,12 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { if (verifyBundleSHA256(filePath, sha256)) { OneKeyLog.info("BundleUpdate", "downloadBundle: existing file SHA256 valid, skipping download") // Final file is authoritative — drop any stale concurrent - // partial/manifest left by an earlier interrupted attempt. + // partial + segment files left by an earlier interrupted + // attempt. (".progress" is a legacy manifest from the old + // pre-allocated model; deleting it is a harmless no-op now.) if (partialFile.exists()) partialFile.delete() File("$partialFilePath.progress").delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete() // Keep isDownloading held across the skip delay below. Clearing // it before the sleep opens a ~1s window where a second // downloadBundle could pass the getAndSet guard and run @@ -1248,8 +1258,11 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { } else { OneKeyLog.warn("BundleUpdate", "downloadBundle: existing file SHA256 mismatch, re-downloading") downloadedFile.delete() - // Stale completed file invalidates any partial too. + // Stale completed file invalidates any partial too — including + // the concurrent segment files, otherwise the next resume would + // pick up bytes belonging to the rejected build. if (partialFile.exists()) partialFile.delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete() } } @@ -1261,16 +1274,22 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { // downloader keeps its partial + manifest for resume). sendEvent("update/start") run { - var concurrentProgress = -1 + // onProgress is invoked concurrently by all 8 worker threads, so + // a plain `var` read-compare-write races (duplicate/out-of-order + // progress events). Use AtomicInteger + CAS: only the thread that + // wins the compareAndSet to a strictly higher value emits, which + // also keeps progress monotonic. (Worst case a race only affects + // progress eventing, never file bytes.) + val concurrentProgress = AtomicInteger(-1) val concurrentOutcome = ConcurrentRangeDownloader( httpClient = httpClient, log = { msg -> OneKeyLog.info("BundleUpdate", msg) }, ).download(downloadUrl, partialFilePath) { transferred, total -> if (total > 0) { val p = ((transferred * 100) / total).toInt().coerceIn(0, 100) - if (p != concurrentProgress) { + val prev = concurrentProgress.get() + if (p > prev && concurrentProgress.compareAndSet(prev, p)) { sendEvent("update/downloading", progress = p) - concurrentProgress = p } } } @@ -1304,7 +1323,14 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { // before discarding so we save a full re-download. val expectedSize = if (params.fileSize > 0) params.fileSize.toLong() else 0L var partialBytes = 0L - if (partialFile.exists()) { + // If any concurrent `.segN` files survive, the `.partial` here is the + // concurrent committed cursor, not a single-stream partial. Defer to + // the concurrent downloader (which already ran above and may resume on + // a later attempt) and skip size-based promote/discard, which would + // otherwise misjudge a bare `.partial` when the concurrent path + // returned FALLBACK but left `.segN` residue. Mirrors app-update. + val hasConcurrentSegments = (0 until CONCURRENT_SEGMENT_COUNT).any { File("$partialFilePath.seg$it").exists() } + if (partialFile.exists() && !hasConcurrentSegments) { val partialSize = partialFile.length() when { expectedSize > 0 && partialSize == expectedSize -> { @@ -1325,6 +1351,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { expectedSize > 0 && partialSize > expectedSize -> { OneKeyLog.warn("BundleUpdate", "downloadBundle: stale partial (>expected), discarding: $partialSize/$expectedSize") partialFile.delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete() } partialSize > 0 -> { partialBytes = partialSize @@ -1369,6 +1396,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { } OneKeyLog.warn("BundleUpdate", "downloadBundle: HTTP 416 (range not satisfiable), discarding partial and failing this attempt") if (partialFile.exists()) partialFile.delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete() // Don't pre-emit update/error here; the outer catch is the // single source of error events. sanitizeErrorMessageForEvent // recognizes "HTTP " prefix and forwards this string verbatim. @@ -1376,7 +1404,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { } val expectsResume = partialBytes > 0 - val isPartialResponse = response.code == 206 + var isPartialResponse = response.code == 206 if (!response.isSuccessful || (response.code != 200 && response.code != 206)) { OneKeyLog.error("BundleUpdate", "downloadBundle: HTTP error, statusCode=${response.code}") @@ -1390,9 +1418,36 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { if (expectsResume && !isPartialResponse) { OneKeyLog.warn("BundleUpdate", "downloadBundle: requested Range but server returned 200, restarting from scratch") if (partialFile.exists()) partialFile.delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete() partialBytes = 0L } + // On a 206 the server's `Content-Range` start MUST equal the offset + // we asked to resume from (`partialBytes`). A misconfigured server or + // proxy can return a 206 whose range starts somewhere else; appending + // that slice onto our `.partial` would splice mismatched bytes and the + // final SHA256 would fail only after a full download. Guard here: if + // the start is missing or != partialBytes, drop the partial+segments + // and abort this attempt. We must NOT reuse this body as a 200-style + // full rewrite: a mismatched 206 body is still a range slice, not the + // whole file, so writing it would produce a corrupt bundle. Close the + // response and throw a retryable error — with partial+segN already + // gone, the next attempt naturally restarts from 0. + if (isPartialResponse && partialBytes > 0) { + val contentRangeStart = response.header("Content-Range") + ?.let { Regex("""bytes\s+(\d+)-\d+/\d+""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() } + if (contentRangeStart == null || contentRangeStart != partialBytes) { + OneKeyLog.warn( + "BundleUpdate", + "downloadBundle: 206 Content-Range start=$contentRangeStart != partialBytes=$partialBytes, discarding partial and aborting attempt" + ) + if (partialFile.exists()) partialFile.delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete() + response.close() + throw java.io.IOException("206 Content-Range start mismatch (got=$contentRangeStart, want=$partialBytes); discarded partial, retry from scratch") + } + } + // Close the response before throwing on a null body — OkHttp // holds connection resources on the response wrapper itself, // and `throw` here exits the function before any byteStream() @@ -1844,8 +1899,12 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { downloadDir.listFiles()?.forEach { file -> val name = file.name // Strip the trailing extension chain to recover the - // "{appV}-{bV}" stem (e.g. "6.3.0-123.zip.partial"). + // "{appV}-{bV}" stem (e.g. "6.3.0-123.zip.partial", or a + // concurrent segment file "6.3.0-123.zip.partial.seg3"). var stem = name + // Concurrent segment files end in ".segN" — peel that off + // first so the rest of the chain strips as usual. + stem = stem.replace(Regex("""\.seg\d+$"""), "") for (suffix in listOf(".resume", ".progress", ".partial", ".zip")) { if (stem.endsWith(suffix)) { stem = stem.substring(0, stem.length - suffix.length) diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index 09a3c700..a8794852 100644 --- a/native-modules/react-native-bundle-update/package.json +++ b/native-modules/react-native-bundle-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-update", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-bundle-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-check-biometric-auth-changed/package.json b/native-modules/react-native-check-biometric-auth-changed/package.json index 4fac1f6a..199f1245 100644 --- a/native-modules/react-native-check-biometric-auth-changed/package.json +++ b/native-modules/react-native-check-biometric-auth-changed/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-check-biometric-auth-changed", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-check-biometric-auth-changed", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-fs/package.json b/native-modules/react-native-cloud-fs/package.json index a906a661..7d75e86d 100644 --- a/native-modules/react-native-cloud-fs/package.json +++ b/native-modules/react-native-cloud-fs/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-fs", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-cloud-fs TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-cloud-kit-module/package.json b/native-modules/react-native-cloud-kit-module/package.json index 48e96f25..a706ecc5 100644 --- a/native-modules/react-native-cloud-kit-module/package.json +++ b/native-modules/react-native-cloud-kit-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-kit-module", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-cloud-kit-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-device-utils/package.json b/native-modules/react-native-device-utils/package.json index c32a726b..59580141 100644 --- a/native-modules/react-native-device-utils/package.json +++ b/native-modules/react-native-device-utils/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-device-utils", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-device-utils", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-dns-lookup/package.json b/native-modules/react-native-dns-lookup/package.json index b6844df6..8c9d6047 100644 --- a/native-modules/react-native-dns-lookup/package.json +++ b/native-modules/react-native-dns-lookup/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-dns-lookup", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-dns-lookup", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-get-random-values/package.json b/native-modules/react-native-get-random-values/package.json index ee5c551d..654c9e46 100644 --- a/native-modules/react-native-get-random-values/package.json +++ b/native-modules/react-native-get-random-values/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-get-random-values", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-get-random-values", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-keychain-module/package.json b/native-modules/react-native-keychain-module/package.json index d3755d54..ab6ef64e 100644 --- a/native-modules/react-native-keychain-module/package.json +++ b/native-modules/react-native-keychain-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-keychain-module", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-keychain-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-lite-card/package.json b/native-modules/react-native-lite-card/package.json index 3180a08b..3f8ce5db 100644 --- a/native-modules/react-native-lite-card/package.json +++ b/native-modules/react-native-lite-card/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-lite-card", - "version": "3.0.65", + "version": "3.0.66", "description": "lite card", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-network-info/package.json b/native-modules/react-native-network-info/package.json index a1ef4a3c..575c7117 100644 --- a/native-modules/react-native-network-info/package.json +++ b/native-modules/react-native-network-info/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-network-info", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-network-info", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-pbkdf2/package.json b/native-modules/react-native-pbkdf2/package.json index 8850d0d2..4263fb1e 100644 --- a/native-modules/react-native-pbkdf2/package.json +++ b/native-modules/react-native-pbkdf2/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pbkdf2", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-pbkdf2", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-memory/package.json b/native-modules/react-native-perf-memory/package.json index 6d398a7c..cd1caac7 100644 --- a/native-modules/react-native-perf-memory/package.json +++ b/native-modules/react-native-perf-memory/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-memory", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-perf-memory", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-perf-stats/package.json b/native-modules/react-native-perf-stats/package.json index a1493062..de999730 100644 --- a/native-modules/react-native-perf-stats/package.json +++ b/native-modules/react-native-perf-stats/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-stats", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-perf-stats", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-ping/package.json b/native-modules/react-native-ping/package.json index d4b4d248..0af955e9 100644 --- a/native-modules/react-native-ping/package.json +++ b/native-modules/react-native-ping/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-ping", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-ping TurboModule for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt index d49e8109..eabd5188 100644 --- a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ConcurrentRangeDownloader.kt @@ -3,7 +3,8 @@ package com.margelo.nitro.reactnativerangedownloader import okhttp3.OkHttpClient import okhttp3.Request import java.io.File -import java.io.RandomAccessFile +import java.io.FileInputStream +import java.io.FileOutputStream import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong @@ -11,26 +12,43 @@ import java.util.concurrent.atomic.AtomicReference /** * Splits a Range-capable download into [segmentCount] byte ranges fetched in - * parallel, each written directly into its own offset of ONE pre-allocated - * `.partial` file (no merge pass, 1x disk). A sidecar `.progress` - * manifest records each segment's durably-written cursor so an interrupted - * download resumes by re-requesting only the unfinished tail of each segment. + * parallel, each STREAMED INTO ITS OWN sibling file `.segN` with plain + * sequential `FileOutputStream` appends (O_WRONLY). Once every segment file is + * fully present, the segments are concatenated in order into the `.partial` + * (and freed as they are consumed, so the peak footprint stays ~1x the file + * plus one segment). Mirrors the iOS RangeDownloader segment-file model. * - * Mirrors the desktop DesktopApiBundleUpdate concurrent path. This class is - * intentionally free of Android/OneKey dependencies (logging is injected) so - * it can be unit/type-checked standalone; the whole-file SHA256 check the - * caller already performs after promotion is the final correctness backstop. + * Why segment files instead of one pre-allocated `.partial` with positioned + * writes: the previous design pre-allocated the full size up front via + * `RandomAccessFile(partial, "rw").setLength(total)`. That O_RDWR open + + * large reservation fails with EROFS/ENOSPC on near-full f2fs devices (and any + * storage that rejects a large up-front reservation), aborting the WHOLE + * download before a byte is fetched. This design only ever does plain O_WRONLY + * sequential writes (segment fetch + concat) and O_RDONLY reads — the same I/O + * shape as the caller's proven single-stream path — so it grows incrementally + * up to the real space limit instead of reserving everything at once. * - * Invariant: the manifest is only meaningful as metadata for an existing - * `.partial`. Either both exist (resume) or neither does (fresh) — any other - * combination is treated as "no resumable state". + * Resume across kill/suspend is simply "which `.segN` already exist + * and how big" plus "how far the `.partial` concat already committed": a + * full-sized segment is kept; a short one resumes from its current length via + * `Range`; a segment whose extent is already inside the committed `.partial` + * prefix is done even if its `.segN` was deleted mid-concat. Object identity is + * intentionally NOT pinned (no ETag/If-Range) — resume is unconditional, and a + * mid-flight object swap that slips through is caught by the caller's whole-file + * SHA256 + GPG verify after promotion, which then drives a clean full + * re-download (concat deletes every `.segN` on success). That verify is the sole + * correctness backstop. The only validator-free safety nets kept inline are: a + * 200 to a Range request → FallbackException, an over-long/oversized segment → + * discard, and a per-segment Content-Range bounds check against mis-aligned 206s. + * + * This class is intentionally free of Android/OneKey dependencies (logging is + * injected) so it can be unit/type-checked standalone. */ class ConcurrentRangeDownloader( private val httpClient: OkHttpClient, private val segmentCount: Int = 8, private val minConcurrentBytes: Long = 2L * 1024 * 1024, private val maxPartRetry: Int = 3, - private val manifestFlushBytes: Long = 4L * 1024 * 1024, private val log: (String) -> Unit = {}, ) { enum class Outcome { @@ -48,7 +66,7 @@ class ConcurrentRangeDownloader( * Cooperative-cancel handle the caller can register a download against. The * adapter keeps these in a per-taskId registry so `cancel`/`discardArtifacts` * can flip [aborted] and `shutdownNow()` the worker pool BEFORE deleting the - * `.partial`/`.progress`, so no in-flight worker resurrects a deleted file. + * segment files, so no in-flight worker resurrects a deleted file. */ class CancelHandle { val aborted = AtomicBoolean(false) @@ -69,20 +87,17 @@ class ConcurrentRangeDownloader( } } - private class Part(val index: Int, val start: Long, val end: Long, done: Long) { - // AtomicLong so manifest snapshots read a consistent value even if the - // owning thread ever changes (cross-thread reads in flushManifest). - val done = AtomicLong(done) + private class Part(val index: Int, val start: Long, val end: Long) { val length: Long get() = end - start + 1 } - private class Probe(val totalSize: Long, val etag: String?, val supportsRange: Boolean) + private class Probe(val totalSize: Long, val supportsRange: Boolean) /** * Fills [partialFilePath] completely with the resource at [url] using * concurrent ranges. See [Outcome]. Throws on a transient/IO error after - * per-segment retries, leaving the partial + manifest in place so a later - * attempt resumes. + * per-segment retries, leaving the segment files in place so a later attempt + * resumes. */ fun download( url: String, @@ -91,11 +106,12 @@ class ConcurrentRangeDownloader( onProgress: (transferred: Long, total: Long) -> Unit, ): Outcome { val partialFile = File(partialFilePath) - val manifestFile = File("$partialFilePath.progress") + val segFile: (Int) -> File = { index -> File("$partialFilePath.seg$index") } - // A bare `.partial` with no manifest is a single-stream leftover; let - // the caller's single-stream path resume it instead of discarding it. - if (partialFile.exists() && !manifestFile.exists()) { + // A bare `.partial` with NO segment files is a single-stream leftover; + // let the caller's single-stream path resume it instead of touching it. + val anyLeftoverSeg = (0 until segmentCount).any { segFile(it).exists() } + if (partialFile.exists() && !anyLeftoverSeg) { log("concurrent: single-stream partial present, deferring to single-stream") return Outcome.FALLBACK } @@ -106,19 +122,46 @@ class ConcurrentRangeDownloader( return Outcome.FALLBACK } val total = probe.totalSize - val etag = probe.etag - // A strong validator (ETag) is what lets If-Range pin a resumed range to - // the exact object the partial was started against. Without it we cannot - // safely trust or persist `.partial`/`.progress` across attempts, so we - // start fresh and skip manifest persistence (the caller's whole-file - // SHA256 check after promotion remains the final correctness backstop). - val hasValidator = !etag.isNullOrEmpty() partialFile.parentFile?.let { if (!it.exists()) it.mkdirs() } - dropOrphanManifest(partialFile, manifestFile) - val parts = loadOrInitManifest(manifestFile, partialFile, total, etag, hasValidator) - val transferred = AtomicLong(parts.sumOf { it.done.get() }) + val parts = planRanges(total) + + // Resume is unconditional: object identity is NOT pinned (no ETag) — any + // mid-flight object swap that survives this far is caught by the caller's + // whole-file SHA256/GPG verify, which then drives a clean full re-download + // (concat deletes every `.segN` on success, so the retry starts fresh). + // We therefore never wipe `.segN` for "no/changed validator" reasons. + + // Discard any leftover segment that can't belong to this plan (wrong + // length = different object/range, or an index beyond the plan). This is + // a pure size check, independent of any validator. + for (i in 0 until segmentCount) { + val f = segFile(i) + if (!f.exists()) continue + val expected = parts.getOrNull(i)?.length + if (expected == null || f.length() > expected) { + log("concurrent: discarding stale/oversized segment $i") + f.delete() + } + } + + // `.partial`'s current length is the committed-concat cursor: every byte + // below it has already been appended into `.partial` and its source + // `.segN` may have been deleted by an interrupted concat. Such prefix + // segments are DONE — they must not be re-fetched (that would waste the + // network and break the "~1x + one segment" footprint target). + val committedBytes = if (partialFile.exists()) partialFile.length() else 0L + // A segment is "committed" when `.partial` already covers its full extent. + val isCommitted: (Part) -> Boolean = { committedBytes >= it.start + it.length } + + // Progress baseline: committed bytes already in `.partial`, plus the + // current length of every not-yet-committed segment file (committed + // segments are already accounted for by `committedBytes`, so adding their + // `.segN` length — if it still exists — would double-count). + val transferred = AtomicLong( + committedBytes + parts.filterNot(isCommitted).sumOf { segFile(it.index).length() } + ) onProgress(transferred.get(), total) // Share the abort flag with the cancel handle so an external cancel() is @@ -126,94 +169,97 @@ class ConcurrentRangeDownloader( val aborted = cancelHandle?.aborted ?: AtomicBoolean(false) val fallback = AtomicBoolean(false) val firstError = AtomicReference(null) - val lastFlushed = LongArray(parts.size) { parts[it].done.get() } - val pool = Executors.newFixedThreadPool(minOf(segmentCount, parts.size)) - cancelHandle?.attach(pool) - try { - val futures = parts.map { part -> - pool.submit { - try { - downloadPart(url, etag, partialFile, part, aborted) { delta -> - val t = transferred.addAndGet(delta) - if (hasValidator) { - synchronized(lastFlushed) { - if (part.done.get() - lastFlushed[part.index] >= manifestFlushBytes) { - lastFlushed[part.index] = part.done.get() - flushManifest(manifestFile, total, etag, parts) - } - } + // Only segments not yet fully on disk AND not already committed into + // `.partial` need fetching. A committed prefix segment whose `.segN` was + // deleted by an interrupted concat must NOT be treated as missing. + val pending = parts.filterNot(isCommitted).filter { segFile(it.index).length() < it.length } + if (pending.isNotEmpty()) { + val pool = Executors.newFixedThreadPool(minOf(segmentCount, pending.size)) + cancelHandle?.attach(pool) + try { + val futures = pending.map { part -> + pool.submit { + try { + downloadSegment(url, segFile(part.index), part, aborted) { delta -> + onProgress(transferred.addAndGet(delta), total) } - onProgress(t, total) + } catch (e: FallbackException) { + fallback.set(true) + aborted.set(true) + firstError.compareAndSet(null, e) + } catch (e: Exception) { + aborted.set(true) + firstError.compareAndSet(null, e) } - } catch (e: FallbackException) { - fallback.set(true) - aborted.set(true) - firstError.compareAndSet(null, e) - } catch (e: Exception) { - aborted.set(true) - firstError.compareAndSet(null, e) } } + futures.forEach { it.get() } + } finally { + pool.shutdownNow() } - futures.forEach { it.get() } - } finally { - pool.shutdownNow() } if (fallback.get()) { // Stale/unusable bytes — clear before the caller falls back. - discard(partialFile, manifestFile) + wipeArtifacts(partialFile, segFile) return Outcome.FALLBACK } val err = firstError.get() if (err != null) { - if (hasValidator) { - // Transient — persist progress so the next attempt resumes, then bubble up. - flushManifest(manifestFile, total, etag, parts) - } else { - // No validator: resume state is untrustworthy, so don't persist - // it — discard and let the next attempt start clean. - discard(partialFile, manifestFile) - } + // Transient. Always keep the segment files so the next attempt + // resumes — resume is unconditional now (no validator gate). throw err } - val got = parts.sumOf { it.done.get() } - if (got < total) { - if (hasValidator) { - flushManifest(manifestFile, total, etag, parts) - } else { - discard(partialFile, manifestFile) - } - throw java.io.IOException("Concurrent download incomplete ($got/$total)") + // A committed prefix segment is complete even though its `.segN` is gone; + // only not-yet-committed segments must have a full-length `.segN`. + val incomplete = parts.filterNot(isCommitted) + .firstOrNull { segFile(it.index).length() != it.length } + if (incomplete != null) { + // Keep the segment files for the next attempt to resume. + throw java.io.IOException("Concurrent download incomplete (segment ${incomplete.index})") } - // Success: `.partial` is fully filled. The manifest's job is done and it - // must never outlive the `.partial` it describes (caller is about to - // promote it), so drop it now. - manifestFile.delete() + // All segments complete → assemble the `.partial`. + concatenate(partialFile, parts, segFile, total) log("concurrent: completed ($total bytes)") return Outcome.COMPLETED } + private fun planRanges(total: Long): List { + val parts = ArrayList() + val chunk = (total + segmentCount - 1) / segmentCount + var i = 0 + while (i < segmentCount) { + val start = i * chunk + if (start >= total) break + val end = minOf(start + chunk - 1, total - 1) + parts.add(Part(parts.size, start, end)) + i += 1 + } + return parts + } + // Single round-trip probe: a one-byte Range request that confirms Range - // support and captures total size + ETag. OkHttp follows redirects (the - // caller's client enforces HTTPS on each hop). + // support and captures total size. Object identity is intentionally NOT + // validated here (no ETag/If-Range) — the caller's whole-file SHA256 + GPG + // verify after promotion is the sole correctness backstop, so resume is + // always allowed. OkHttp follows redirects (the caller's client enforces + // HTTPS on each hop). private fun probe(url: String): Probe? { return try { val req = Request.Builder().url(url).addHeader("Range", "bytes=0-0").build() httpClient.newCall(req).execute().use { response -> - val etag = response.header("ETag") when (response.code) { 206 -> { val total = response.header("Content-Range") ?.let { Regex("""bytes \d+-\d+/(\d+)""").find(it)?.groupValues?.getOrNull(1)?.toLongOrNull() } - if (total != null) Probe(total, etag, true) else Probe(0, etag, false) + if (total != null) Probe(total, true) else Probe(0, false) } 200 -> { // Server ignored Range — single-stream only. val len = response.body?.contentLength() ?: -1L - Probe(if (len > 0) len else 0, etag, false) + Probe(if (len > 0) len else 0, false) } else -> null } @@ -224,113 +270,68 @@ class ConcurrentRangeDownloader( } } - private fun dropOrphanManifest(partialFile: File, manifestFile: File) { - if (!partialFile.exists() && manifestFile.exists()) { - log("concurrent: dropping orphan manifest") - manifestFile.delete() - } - } - - private fun discard(partialFile: File, manifestFile: File) { - // Manifest first so it never outlives the partial it describes. - manifestFile.delete() + private fun wipeArtifacts(partialFile: File, segFile: (Int) -> File) { partialFile.delete() + for (i in 0 until segmentCount) segFile(i).delete() } - // Resume from a manifest whose size/ETag still match, else (re)create a - // fresh pre-allocated partial + manifest. Manifest is removed before the - // partial is (re)created, and written only after the partial exists. - private fun loadOrInitManifest( - manifestFile: File, + // Concatenate the completed segment files, in order, into the `.partial`. + // Append-mode + the `.partial`'s current length as the resume cursor make + // this idempotent and crash-safe: an interrupted concat resumes where it + // left off, and each segment is deleted only after it has been fully + // appended, so the peak footprint stays ~1x the file plus one segment + // (critical on near-full devices — a 2x "all segs + full copy" peak would + // re-introduce the out-of-space failure this design exists to avoid). + private fun concatenate( partialFile: File, + parts: List, + segFile: (Int) -> File, total: Long, - etag: String?, - hasValidator: Boolean, - ): List { - // Only trust an existing manifest when a strong validator pins it to the - // server object; otherwise always start fresh. - if (hasValidator && manifestFile.exists() && partialFile.exists()) { - val parsed = parseManifest(manifestFile, total, etag, partialFile.length()) - if (parsed != null) { - log("concurrent: resuming, transferred=${parsed.sumOf { it.done.get() }}/$total") - return parsed - } - } - discard(partialFile, manifestFile) - RandomAccessFile(partialFile, "rw").use { it.setLength(total) } - val parts = ArrayList() - val chunk = (total + segmentCount - 1) / segmentCount - var i = 0 - while (i < segmentCount) { - val start = i * chunk - if (start >= total) break - val end = minOf(start + chunk - 1, total - 1) - parts.add(Part(parts.size, start, end, 0)) - i += 1 - } - // Persist the manifest only when it can be safely resumed later. - if (hasValidator) writeManifest(manifestFile, total, etag, parts) - return parts - } - - // Manifest format (dependency-free, internal): line 0 "|", - // then one ",,," line per segment. - private fun writeManifest(manifestFile: File, total: Long, etag: String?, parts: List) { - val sb = StringBuilder() - sb.append(total).append('|').append(etag ?: "").append('\n') - for (p in parts) { - sb.append(p.index).append(',').append(p.start).append(',') - .append(p.end).append(',').append(p.done.get()).append('\n') - } - manifestFile.writeText(sb.toString()) - } - - @Synchronized - private fun flushManifest(manifestFile: File, total: Long, etag: String?, parts: List) { - try { - writeManifest(manifestFile, total, etag, parts) - } catch (e: Exception) { - log("concurrent: manifest flush failed: ${e.javaClass.simpleName}") + ) { + var written = if (partialFile.exists()) partialFile.length() else 0L + if (written > total) { + // Corrupt/over-long prior concat — restart clean. + partialFile.delete() + written = 0L } - } - - private fun parseManifest(manifestFile: File, total: Long, etag: String?, partialSize: Long): List? { - return try { - val lines = manifestFile.readText().trim().split('\n') - if (lines.isEmpty()) return null - val head = lines[0].split('|') - val savedSize = head.getOrNull(0)?.toLongOrNull() ?: return null - val savedEtag = head.getOrNull(1)?.takeIf { it.isNotEmpty() } - // Object must be identical to what's on disk and on the CDN. - if (savedSize != total || partialSize != total) return null - if (etag != null && savedEtag != null && etag != savedEtag) return null - val parts = ArrayList() - for (idx in 1 until lines.size) { - val cols = lines[idx].split(',') - if (cols.size != 4) return null - val i = cols[0].toIntOrNull() ?: return null - val s = cols[1].toLongOrNull() ?: return null - val e = cols[2].toLongOrNull() ?: return null - var d = cols[3].toLongOrNull() ?: return null - val segLen = e - s + 1 - if (d < 0) d = 0 - if (d > segLen) d = segLen - parts.add(Part(i, s, e, d)) + FileOutputStream(partialFile, /* append = */ true).use { out -> + var cursor = 0L + for (part in parts) { + val segEndInFinal = cursor + part.length + if (written < segEndInFinal) { + val seg = segFile(part.index) + // Skip the prefix of this segment that a prior interrupted + // concat already appended (append always writes at EOF). + val skip = (written - cursor).coerceAtLeast(0L) + FileInputStream(seg).use { input -> + var toSkip = skip + while (toSkip > 0) { + val s = input.skip(toSkip) + if (s <= 0) break + toSkip -= s + } + input.copyTo(out) + } + out.flush() + written = segEndInFinal + } + cursor = segEndInFinal + segFile(part.index).delete() } - if (parts.isEmpty()) null else parts - } catch (e: Exception) { - log("concurrent: manifest parse failed: ${e.javaClass.simpleName}") - null + } + if (partialFile.length() != total) { + partialFile.delete() + throw java.io.IOException("Concat size mismatch (${partialFile.length()}/$total)") } } - // Download [start+done, end] of [part] into its own RandomAccessFile handle - // (each segment gets its own fd so concurrent positioned writes don't race), - // resuming from part.done and retrying transient failures in place. - private fun downloadPart( + // Fetch [start+have, end] of [part] into its OWN segment file via plain + // sequential O_WRONLY appends (no positioned writes, no pre-allocation), + // resuming from the segment file's current length and retrying transient + // failures in place. + private fun downloadSegment( url: String, - etag: String?, - partialFile: File, + segFile: File, part: Part, aborted: AtomicBoolean, onBytes: (delta: Long) -> Unit, @@ -338,10 +339,11 @@ class ConcurrentRangeDownloader( var retry = 0 while (true) { if (aborted.get()) throw java.io.IOException("aborted") - val rangeStart = part.start + part.done.get() - if (rangeStart > part.end) return + val have = segFile.length() + if (have >= part.length) return + val rangeStart = part.start + have try { - fetchSegment(url, etag, partialFile, part, rangeStart, aborted, onBytes) + fetchSegment(url, segFile, part, rangeStart, aborted, onBytes) return } catch (e: FallbackException) { throw e @@ -355,40 +357,74 @@ class ConcurrentRangeDownloader( private fun fetchSegment( url: String, - etag: String?, - partialFile: File, + segFile: File, part: Part, rangeStart: Long, aborted: AtomicBoolean, onBytes: (delta: Long) -> Unit, ) { - val builder = Request.Builder().url(url) + val request = Request.Builder().url(url) .addHeader("Range", "bytes=$rangeStart-${part.end}") - // If-Range: a mismatched ETag makes the CDN reply 200 (full body) - // instead of 206, which we treat as a fallback signal. - if (etag != null) builder.addHeader("If-Range", etag) - httpClient.newCall(builder.build()).execute().use { response -> + .build() + httpClient.newCall(request).execute().use { response -> + // A 200 (full body) to a Range request is the one validator-free + // safety net we keep: appending a from-zero body onto a partially + // filled segment would corrupt it, so bail to the single-stream path. if (response.code == 200) { throw FallbackException("server returned 200 to a Range request") } if (response.code != 206) { throw java.io.IOException("HTTP ${response.code}") } + // Verify the 206 covers exactly the slice we asked for. This guards + // against a proxy/CDN returning a mis-aligned 206 (wrong window), + // which would otherwise silently corrupt the assembled file. A + // missing/mismatched Content-Range is treated as transient (retry). + // It canNOT detect an object swapped behind an identical window — + // that is the caller's whole-file SHA256/GPG verify's job. + val contentRange = response.header("Content-Range") + ?: throw java.io.IOException("206 without Content-Range") + val bounds = parseContentRangeBounds(contentRange) + ?: throw java.io.IOException("unparseable Content-Range: $contentRange") + if (bounds.first != rangeStart || bounds.second != part.end) { + throw java.io.IOException( + "Content-Range mismatch: got ${bounds.first}-${bounds.second}, " + + "expected $rangeStart-${part.end}" + ) + } val body = response.body ?: throw java.io.IOException("Empty segment body") - RandomAccessFile(partialFile, "rw").use { raf -> - raf.seek(rangeStart) + // Append the fetched tail to the segment file. Append mode keeps + // resume correct: we only ever request the bytes not yet on disk. + FileOutputStream(segFile, /* append = */ true).use { out -> body.byteStream().use { input -> val buffer = ByteArray(8192) while (true) { if (aborted.get()) throw java.io.IOException("aborted") val read = input.read(buffer) if (read == -1) break - raf.write(buffer, 0, read) - part.done.addAndGet(read.toLong()) + out.write(buffer, 0, read) onBytes(read.toLong()) } } } } + // A 206 can still over-deliver (server ignored our end bound). A segment + // longer than its planned length is unusable — drop it so the next + // attempt re-fetches cleanly rather than concatenating misaligned bytes. + if (segFile.length() > part.length) { + segFile.delete() + throw java.io.IOException( + "Segment ${part.index} overran (${segFile.length()}/${part.length})" + ) + } + } + + // Parse "bytes -/" → (start, end). Returns null when the + // header is absent-of-bounds (e.g. "bytes */1234") or otherwise unparseable. + private fun parseContentRangeBounds(value: String): Pair? { + val m = Regex("""bytes\s+(\d+)-(\d+)/""").find(value) ?: return null + val start = m.groupValues[1].toLongOrNull() ?: return null + val end = m.groupValues[2].toLongOrNull() ?: return null + return start to end } } diff --git a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt index e022a2fd..f80fc2aa 100644 --- a/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt +++ b/native-modules/react-native-range-downloader/android/src/main/java/com/margelo/nitro/reactnativerangedownloader/ReactNativeRangeDownloader.kt @@ -10,20 +10,23 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicLong -// P1: faithful migration of the Android concurrent multi-range downloader. +// P1: Nitro adapter for the Android concurrent multi-range downloader. // -// The core algorithm (.partial preallocation + .progress manifest resume + -// 8-segment thread pool + If-Range/200 fallback) lives unchanged in the -// in-module ConcurrentRangeDownloader helper, copied byte-for-byte from -// react-native-bundle-update. This class is the Nitro adapter: it builds the -// HTTPS-only OkHttpClient, drives the helper, finalizes on COMPLETED (promote -// .partial -> dest + optional SHA256 self-check), and bridges progress to the -// shared listener registry as RangeDownloadEvent (tagged with channel/taskId). +// The core algorithm lives in the in-module ConcurrentRangeDownloader helper: +// each of the N segments streams into its own sibling file +// `.partial.seg0` .. `.partial.segN-1` (no whole-file preallocation, +// no `.progress` manifest), and on success the segments are concatenated in +// order into `.partial`. Resume re-uses whatever bytes each `.segN` +// already holds. An 8-segment thread pool plus a 200-to-a-Range fallback round +// it out (object identity is not pinned — no ETag/If-Range; the optional +// whole-file SHA256 self-check below is the correctness backstop). This class +// builds the HTTPS-only OkHttpClient, drives the helper, +// finalizes on COMPLETED (promote .partial -> dest + optional SHA256 +// self-check), and bridges progress to the shared listener registry as +// RangeDownloadEvent (tagged with channel/taskId). // // Android has no background-session concept: `channel` is only an event label -// + artifact-directory tag and does not change the download mechanism. The -// on-disk format (.partial + .progress) is kept exactly as shipped so existing -// interrupted downloads resume cleanly. +// + artifact-directory tag and does not change the download mechanism. @DoNotStrip class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { @@ -83,7 +86,12 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { val cancelHandle = ConcurrentRangeDownloader.CancelHandle() activeDownloads[runKey] = cancelHandle - var lastProgress = -1 + // The progress callback is invoked concurrently by the helper's worker + // threads, so guard lastProgress with an AtomicInteger + CAS: only the + // thread that advances the percentage to a strictly higher value wins the + // CAS and emits the event, which keeps progress monotonic and de-duped + // without a lock (this only affects event ordering, never file bytes). + val lastProgress = java.util.concurrent.atomic.AtomicInteger(-1) val outcome = try { ConcurrentRangeDownloader( httpClient = httpClient, @@ -93,9 +101,9 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { ).download(downloadUrl, partialFilePath, cancelHandle) { transferred, total -> if (total > 0) { val p = ((transferred * 100) / total).toInt().coerceIn(0, 100) - if (p != lastProgress) { + val prev = lastProgress.get() + if (p > prev && lastProgress.compareAndSet(prev, p)) { sendEvent(channel, taskId, type = "progress", progress = p) - lastProgress = p } } } @@ -158,9 +166,11 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { // files, otherwise a still-running segment could re-create the .partial we // just deleted. cancelActive(channel, taskId) - // Manifest first so it never outlives the partial it describes. - File("$destFilePath.partial.progress").delete() - File("$destFilePath.partial").delete() + // Sweep the per-segment `.segN` files plus the concatenated `.partial` so a + // future resume can't re-trust stale bytes (no `.progress` manifest exists + // anymore in the segmented model). Glob by filename prefix so any custom + // segmentCount is fully cleared, not just the shipped default of 8. + sweepPartialArtifacts(destFilePath) OneKeyLog.info("RangeDownloader", "discardArtifacts: channel=$channel taskId=$taskId") Unit } @@ -174,8 +184,9 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { return Promise.async { // Stop workers first, then delete artifacts so nothing resurrects them. cancelActive(channel, taskId) - File("$destFilePath.partial.progress").delete() - File("$destFilePath.partial").delete() + // Same segmented-artifact sweep as discardArtifacts: glob every per-segment + // `.segN` file by prefix plus the concatenated `.partial`. + sweepPartialArtifacts(destFilePath) OneKeyLog.info("RangeDownloader", "cancel: channel=$channel taskId=$taskId") Unit } @@ -186,6 +197,17 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { activeDownloads.remove(runKey(channel, taskId))?.cancel() } + // Delete every sibling artifact for [destFilePath]: all `.partial.seg` + // segment files (matched by filename prefix, so any segmentCount is swept, not + // just the shipped default) plus the concatenated `.partial` itself. + private fun sweepPartialArtifacts(destFilePath: String) { + val partial = File("$destFilePath.partial") + partial.parentFile + ?.listFiles { f -> f.name.startsWith(partial.name + ".seg") } + ?.forEach { it.delete() } + partial.delete() + } + // Atomically replace [dest] with [src] so a kill mid-finalize never leaves // NEITHER file. On API 26+ uses Files.move with ATOMIC_MOVE/REPLACE_EXISTING // (single-step rename onto the destination). On older APIs (java.nio.file is diff --git a/native-modules/react-native-range-downloader/package.json b/native-modules/react-native-range-downloader/package.json index 65fdd623..9d6e2d51 100644 --- a/native-modules/react-native-range-downloader/package.json +++ b/native-modules/react-native-range-downloader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-range-downloader", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-range-downloader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-splash-screen/package.json b/native-modules/react-native-splash-screen/package.json index 13ae7cc9..44426c96 100644 --- a/native-modules/react-native-splash-screen/package.json +++ b/native-modules/react-native-splash-screen/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-splash-screen", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-splash-screen", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-split-bundle-loader/package.json b/native-modules/react-native-split-bundle-loader/package.json index 218b1ba6..908213d9 100644 --- a/native-modules/react-native-split-bundle-loader/package.json +++ b/native-modules/react-native-split-bundle-loader/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-split-bundle-loader", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-split-bundle-loader", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json index b2ccbc88..cf64fda4 100644 --- a/native-modules/react-native-tcp-socket/package.json +++ b/native-modules/react-native-tcp-socket/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tcp-socket", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-tcp-socket", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-modules/react-native-zip-archive/package.json b/native-modules/react-native-zip-archive/package.json index 964f40a2..e46b42d8 100644 --- a/native-modules/react-native-zip-archive/package.json +++ b/native-modules/react-native-zip-archive/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-zip-archive", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-zip-archive Nitro HybridObject for OneKey", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-auto-size-input/package.json b/native-views/react-native-auto-size-input/package.json index 51ef6a2d..753b978c 100644 --- a/native-views/react-native-auto-size-input/package.json +++ b/native-views/react-native-auto-size-input/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-auto-size-input", - "version": "3.0.65", + "version": "3.0.66", "description": "Auto-sizing text input with font scaling, prefix and suffix support", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-chart-webview/package.json b/native-views/react-native-chart-webview/package.json index 631353c4..dbaba130 100644 --- a/native-views/react-native-chart-webview/package.json +++ b/native-views/react-native-chart-webview/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-chart-webview", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-chart-webview", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-pager-view/package.json b/native-views/react-native-pager-view/package.json index 19261da0..2adba670 100644 --- a/native-views/react-native-pager-view/package.json +++ b/native-views/react-native-pager-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pager-view", - "version": "3.0.65", + "version": "3.0.66", "description": "React Native wrapper for Android and iOS ViewPager", "source": "./src/index.tsx", "main": "./lib/module/index.js", diff --git a/native-views/react-native-perp-depth-bar/package.json b/native-views/react-native-perp-depth-bar/package.json index 31d4ba16..de450c78 100644 --- a/native-views/react-native-perp-depth-bar/package.json +++ b/native-views/react-native-perp-depth-bar/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perp-depth-bar", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-perp-depth-bar", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index 86b51fd5..a2066007 100644 --- a/native-views/react-native-scroll-guard/package.json +++ b/native-views/react-native-scroll-guard/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-scroll-guard", - "version": "3.0.65", + "version": "3.0.66", "description": "A native view wrapper that prevents parent scrollable containers (PagerView/ViewPager2) from intercepting child scroll gestures", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-segment-slider/package.json b/native-views/react-native-segment-slider/package.json index dd177b65..e76364d9 100644 --- a/native-views/react-native-segment-slider/package.json +++ b/native-views/react-native-segment-slider/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-segment-slider", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-segment-slider", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-skeleton/package.json b/native-views/react-native-skeleton/package.json index 702fa0c6..5e278f4f 100644 --- a/native-views/react-native-skeleton/package.json +++ b/native-views/react-native-skeleton/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-skeleton", - "version": "3.0.65", + "version": "3.0.66", "description": "react-native-skeleton", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/native-views/react-native-tab-view/package.json b/native-views/react-native-tab-view/package.json index 2402f943..81dfd5ac 100644 --- a/native-views/react-native-tab-view/package.json +++ b/native-views/react-native-tab-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tab-view", - "version": "3.0.65", + "version": "3.0.66", "description": "Native Bottom Tabs for React Native (UIKit implementation)", "source": "./src/index.tsx", "main": "./lib/module/index.js",