From 41c6c47efb7e7bb5cec91961ff5db3bb099dcea8 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 12 Jun 2026 21:29:40 +0800 Subject: [PATCH 1/6] Unify and refactor concurrent range downloader Replace module-local ConcurrentRangeDownloader with a single shared implementation and adapt consumers. Added a dependency on :onekeyfe_react-native-range-downloader in app-update's build.gradle and removed the duplicate ConcurrentRangeDownloader source from app-update. ReactNativeAppUpdate now imports the shared downloader and detects concurrent segment files when resuming; ReactNativeBundleUpdate cleans up legacy segment files and accounts for ".segN" in stem parsing. The range-downloader implementation was rewritten to stream into per-segment files (.segN), avoid full-file pre-allocation (fixes EROFS/ENOSPC on near-full devices), resume/concat segments into the final .partial, and adjust ETag/If-Range, cancellation and error handling accordingly. --- .../android/build.gradle | 5 + .../ConcurrentRangeDownloader.kt | 362 ------------------ .../ReactNativeAppUpdate.kt | 39 +- .../ReactNativeBundleUpdate.kt | 12 +- .../ConcurrentRangeDownloader.kt | 346 ++++++++--------- 5 files changed, 201 insertions(+), 563 deletions(-) delete mode 100644 native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ConcurrentRangeDownloader.kt 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..6fe99b36 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() @@ -520,23 +531,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 -> { 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..9ca1d7a0 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 @@ -1234,9 +1234,13 @@ 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() + // ConcurrentRangeDownloader's default segmentCount is 8. + for (i in 0 until 8) 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 @@ -1844,8 +1848,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-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..0ab1fd8e 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,37 @@ 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": a full-sized segment is kept; a short one resumes from its + * current length via `Range` + `If-Range`; with no strong validator (ETag) + * leftover segments can't be pinned to the server object and are wiped. The + * caller's whole-file SHA256 + GPG verify after promotion remains the final + * correctness backstop. + * + * 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 +60,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,10 +81,7 @@ 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 } @@ -81,8 +90,8 @@ class ConcurrentRangeDownloader( /** * 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 +100,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 } @@ -107,18 +117,31 @@ class ConcurrentRangeDownloader( } 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). + // A strong validator (ETag) is what lets If-Range pin a resumed segment + // to the exact object the segments were started against. Without it, + // leftover segments are untrustworthy — start fresh. 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) + + if (!hasValidator) { + wipeArtifacts(partialFile, segFile) + } + // Discard any leftover segment that can't belong to this plan (wrong + // length = different object/range, or an index beyond the plan). + 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() + } + } + + val transferred = AtomicLong(parts.sumOf { segFile(it.index).length() }) onProgress(transferred.get(), total) // Share the abort flag with the cancel handle so an external cancel() is @@ -126,76 +149,73 @@ 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 need fetching. + val pending = parts.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, etag, 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. Keep the segment files so the next attempt resumes when + // we have a validator; otherwise they can't be trusted — wipe them. + if (!hasValidator) wipeArtifacts(partialFile, segFile) 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)") + val incomplete = parts.firstOrNull { segFile(it.index).length() != it.length } + if (incomplete != null) { + if (!hasValidator) wipeArtifacts(partialFile, segFile) + 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). @@ -224,113 +244,69 @@ 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 +314,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, etag, segFile, part, rangeStart, aborted, onBytes) return } catch (e: FallbackException) { throw e @@ -356,7 +333,7 @@ class ConcurrentRangeDownloader( private fun fetchSegment( url: String, etag: String?, - partialFile: File, + segFile: File, part: Part, rangeStart: Long, aborted: AtomicBoolean, @@ -365,7 +342,8 @@ class ConcurrentRangeDownloader( 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. + // instead of 206, which we treat as a fallback signal — appending a + // from-zero body onto a partially-filled segment would corrupt it. if (etag != null) builder.addHeader("If-Range", etag) httpClient.newCall(builder.build()).execute().use { response -> if (response.code == 200) { @@ -375,16 +353,16 @@ class ConcurrentRangeDownloader( 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) + // 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()) } } From 4d90a42ccc15527d26787a545ae9eeea58067171 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 12 Jun 2026 21:36:14 +0800 Subject: [PATCH 2/6] Store APK downloads in filesDir (not cacheDir) Switch APK download location from context.cacheDir/apks to context.filesDir/apks to avoid system-reclaimable cache issues (EROFS/ENOSPC) during downloads. Rename getApkCacheDir to getApkDownloadDir, update callers and cleanup to match, and add a doc comment explaining the rationale and FileProvider exposure. Update res/xml/app_update_file_paths.xml to expose the new files-path (apks_files) while retaining the legacy cache-path for transition compatibility. --- .../ReactNativeAppUpdate.kt | 20 +++++++++++++++---- .../main/res/xml/app_update_file_paths.xml | 4 ++++ 2 files changed, 20 insertions(+), 4 deletions(-) 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 6fe99b36..9699b052 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 @@ -79,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 } @@ -108,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 @@ -1216,7 +1227,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 @@ + + + From 5c389c507624b1d8610e5ca5f9d5810650c935be Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 12 Jun 2026 22:28:49 +0800 Subject: [PATCH 3/6] Use absolute path for partial APK download Pass partialFile.absolutePath to ConcurrentRangeDownloader.download instead of the filename (partialFilePath). The downloader resolves relative paths against the process CWD ("/"), which could cause segment files to be written to the read-only root filesystem (EROFS). Using the absolute path ensures segments go into the app's files dir (e.g. filesDir/apks), matching react-native-bundle-update behavior; added an inline comment explaining this. --- .../nitro/reactnativeappupdate/ReactNativeAppUpdate.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 9699b052..f6dd670e 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 @@ -625,7 +625,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 From 6d1f1e651255890007f51e7d022646ee6abb036c Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 12 Jun 2026 22:59:26 +0800 Subject: [PATCH 4/6] Harden concurrent range downloader and resume logic Several fixes to make multi-segment downloads more robust and race-safe: - Use OkHttp .use() to ensure responses are closed and avoid resource leaks. - Treat segment files (.partial.segN) as first-class artifacts: sweep/delete sibling .segN on various error/restart paths in app-update and bundle-update so stale segment bytes can't be mistaken for valid resume data. - Make progress reporting thread-safe by using AtomicInteger + CAS to emit monotonic progress only when it increases. - Add defensive Content-Range handling: validate 206 Content-Range start matches expected resume offset (drop partial and restart if not), and in ConcurrentRangeDownloader verify per-segment Content-Range bounds and reject overlong segments. - Change resume semantics: object identity is no longer pinned by ETag/If-Range. Resume is unconditional; the caller's whole-file SHA256/GPG verify remains the final correctness backstop. Removed ETag plumbing and If-Range logic. - Correct progress accounting to include committed bytes already concatenated into .partial and avoid re-fetching segments whose extent is already covered by .partial. - Improve segment selection: pending segments exclude segments already committed into .partial; keep incomplete segment files for later resume instead of wiping them. - Add parseContentRangeBounds helper and related checks to ensure fetched slices exactly match the requested ranges. - Add DEFAULT_SEGMENT_COUNT and sweep segmented artifacts in ReactNativeRangeDownloader discard/cancel paths. These changes reduce corruption risk from misaligned 206s, stale sibling segments, and concurrent progress races, while keeping resume behavior predictable and recoverable via the final file verification. --- .../ReactNativeAppUpdate.kt | 31 ++-- .../ReactNativeBundleUpdate.kt | 44 +++++- .../ConcurrentRangeDownloader.kt | 134 +++++++++++++----- .../ReactNativeRangeDownloader.kt | 46 ++++-- 4 files changed, 190 insertions(+), 65 deletions(-) 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 f6dd670e..3d39d359 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 @@ -169,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() @@ -524,6 +525,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 @@ -588,6 +593,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 @@ -721,6 +728,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 -> { @@ -737,6 +746,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)") } @@ -755,6 +766,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 } @@ -773,6 +786,8 @@ class ReactNativeAppUpdate : HybridReactNativeAppUpdateSpec() { 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") if (partialFile.exists()) partialFile.delete() + // Full restart from byte zero → drop any sibling .segN too. + for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete() partialBytes = 0L serverWillResume = false } 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 9ca1d7a0..cc9547ad 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 @@ -1252,8 +1253,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 8) File("$partialFilePath.seg$i").delete() } } @@ -1265,16 +1269,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 } } } @@ -1329,6 +1339,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 8) File("$partialFilePath.seg$i").delete() } partialSize > 0 -> { partialBytes = partialSize @@ -1373,6 +1384,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 8) 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. @@ -1380,7 +1392,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}") @@ -1397,6 +1409,28 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { 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 and treat + // this response as a fresh full rewrite (200 semantics). + 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 restarting from scratch" + ) + if (partialFile.exists()) partialFile.delete() + for (i in 0 until 8) File("$partialFilePath.seg$i").delete() + partialBytes = 0L + isPartialResponse = false + } + } + // 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() 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 0ab1fd8e..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 @@ -29,11 +29,17 @@ import java.util.concurrent.atomic.AtomicReference * up to the real space limit instead of reserving everything at once. * * Resume across kill/suspend is simply "which `.segN` already exist - * and how big": a full-sized segment is kept; a short one resumes from its - * current length via `Range` + `If-Range`; with no strong validator (ETag) - * leftover segments can't be pinned to the server object and are wiped. The - * caller's whole-file SHA256 + GPG verify after promotion remains the final - * correctness backstop. + * 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. @@ -85,7 +91,7 @@ class ConcurrentRangeDownloader( 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 @@ -116,21 +122,20 @@ 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 segment - // to the exact object the segments were started against. Without it, - // leftover segments are untrustworthy — start fresh. - val hasValidator = !etag.isNullOrEmpty() partialFile.parentFile?.let { if (!it.exists()) it.mkdirs() } val parts = planRanges(total) - if (!hasValidator) { - wipeArtifacts(partialFile, segFile) - } + // 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). + // 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 @@ -141,7 +146,22 @@ class ConcurrentRangeDownloader( } } - val transferred = AtomicLong(parts.sumOf { segFile(it.index).length() }) + // `.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 @@ -150,8 +170,10 @@ class ConcurrentRangeDownloader( val fallback = AtomicBoolean(false) val firstError = AtomicReference(null) - // Only segments not yet fully on disk need fetching. - val pending = parts.filter { segFile(it.index).length() < it.length } + // 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) @@ -159,7 +181,7 @@ class ConcurrentRangeDownloader( val futures = pending.map { part -> pool.submit { try { - downloadSegment(url, etag, segFile(part.index), part, aborted) { delta -> + downloadSegment(url, segFile(part.index), part, aborted) { delta -> onProgress(transferred.addAndGet(delta), total) } } catch (e: FallbackException) { @@ -185,14 +207,16 @@ class ConcurrentRangeDownloader( } val err = firstError.get() if (err != null) { - // Transient. Keep the segment files so the next attempt resumes when - // we have a validator; otherwise they can't be trusted — wipe them. - if (!hasValidator) wipeArtifacts(partialFile, segFile) + // Transient. Always keep the segment files so the next attempt + // resumes — resume is unconditional now (no validator gate). throw err } - val incomplete = parts.firstOrNull { segFile(it.index).length() != it.length } + // 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) { - if (!hasValidator) wipeArtifacts(partialFile, segFile) + // Keep the segment files for the next attempt to resume. throw java.io.IOException("Concurrent download incomplete (segment ${incomplete.index})") } @@ -217,23 +241,25 @@ class ConcurrentRangeDownloader( } // 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 } @@ -305,7 +331,6 @@ class ConcurrentRangeDownloader( // failures in place. private fun downloadSegment( url: String, - etag: String?, segFile: File, part: Part, aborted: AtomicBoolean, @@ -318,7 +343,7 @@ class ConcurrentRangeDownloader( if (have >= part.length) return val rangeStart = part.start + have try { - fetchSegment(url, etag, segFile, part, rangeStart, aborted, onBytes) + fetchSegment(url, segFile, part, rangeStart, aborted, onBytes) return } catch (e: FallbackException) { throw e @@ -332,26 +357,41 @@ class ConcurrentRangeDownloader( private fun fetchSegment( url: String, - etag: String?, 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 — appending a - // from-zero body onto a partially-filled segment would corrupt it. - 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") // Append the fetched tail to the segment file. Append mode keeps // resume correct: we only ever request the bytes not yet on disk. @@ -368,5 +408,23 @@ class ConcurrentRangeDownloader( } } } + // 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..0a7241b6 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,23 +10,37 @@ 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() { + companion object { + // Segment count used by cancel/discardArtifacts when sweeping the sibling + // `.partial.segN` files. download() lets callers override segmentCount, + // but cancel/discard don't receive params, so we sweep the shipped default + // (matches ConcurrentRangeDownloader's default and download()'s `?: 8`), + // which covers the overwhelming majority of runs. A larger custom count + // would leave a few high-index `.segN` behind, but the next resume only + // trusts segments it re-reads, so the worst case is a little stale disk. + private const val DEFAULT_SEGMENT_COUNT = 8 + } + private class Listener(val id: Double, val callback: (RangeDownloadEvent) -> Unit) private val listeners = CopyOnWriteArrayList() @@ -158,8 +172,10 @@ 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() + // 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). + for (i in 0 until DEFAULT_SEGMENT_COUNT) File("$destFilePath.partial.seg$i").delete() File("$destFilePath.partial").delete() OneKeyLog.info("RangeDownloader", "discardArtifacts: channel=$channel taskId=$taskId") Unit @@ -174,7 +190,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() + // Same segmented-artifact sweep as discardArtifacts: per-segment `.segN` + // files plus the concatenated `.partial`. + for (i in 0 until DEFAULT_SEGMENT_COUNT) File("$destFilePath.partial.seg$i").delete() File("$destFilePath.partial").delete() OneKeyLog.info("RangeDownloader", "cancel: channel=$channel taskId=$taskId") Unit From bf7bc18bba198fd9cf1128d1fc20d5d082c09245 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 12 Jun 2026 23:12:26 +0800 Subject: [PATCH 5/6] Cleanup partial .segN files, close responses, fix progress Improve robustness of range/partial handling across native modules: - AppUpdate & BundleUpdate: ensure leftover concurrent segment files (.segN) are removed when promoting/discarding partials, and treat 206 Content-Range start mismatches as retryable errors (close response, delete partial+segments, and throw) to avoid writing mis-aligned bytes. BundleUpdate adds a CONCURRENT_SEGMENT_COUNT constant and defers promotion if concurrent segments exist. - RangeDownloader: replace hardcoded default segment sweep with sweepPartialArtifacts (glob by prefix) to clear any custom segment counts, and make progress reporting thread-safe using an AtomicInteger + CAS to dedupe/monotonize progress events. - AppUpdate: read ASC files with response.use to always close connections, enforce a max ASC size, and avoid leaking response bodies. These changes prevent mixing segmented and single-stream artifacts, avoid corrupted downloads from CDN/proxy 206 bugs, and fix leaked connections and duplicate progress events. --- .../ReactNativeAppUpdate.kt | 80 ++++++++++++------- .../ReactNativeBundleUpdate.kt | 41 +++++++--- .../ReactNativeRangeDownloader.kt | 46 ++++++----- 3 files changed, 107 insertions(+), 60 deletions(-) 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 3d39d359..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 @@ -505,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() @@ -784,12 +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() - // Full restart from byte zero → drop any sibling .segN too. for (i in 0 until CONCURRENT_SEGMENT_COUNT) buildFile("$partialFilePath.seg$i").delete() - partialBytes = 0L - serverWillResume = false + response.close() + throw java.io.IOException("206 Content-Range start mismatch (header='$contentRangeHeader', requested=$partialBytes); discarded partial, retry from scratch") } } @@ -908,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") 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 cc9547ad..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 @@ -1084,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() @@ -1240,8 +1246,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { // pre-allocated model; deleting it is a harmless no-op now.) if (partialFile.exists()) partialFile.delete() File("$partialFilePath.progress").delete() - // ConcurrentRangeDownloader's default segmentCount is 8. - for (i in 0 until 8) File("$partialFilePath.seg$i").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 @@ -1257,7 +1262,7 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { // 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 8) File("$partialFilePath.seg$i").delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete() } } @@ -1318,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 -> { @@ -1339,7 +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 8) File("$partialFilePath.seg$i").delete() + for (i in 0 until CONCURRENT_SEGMENT_COUNT) File("$partialFilePath.seg$i").delete() } partialSize > 0 -> { partialBytes = partialSize @@ -1384,7 +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 8) File("$partialFilePath.seg$i").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. @@ -1406,6 +1418,7 @@ 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 } @@ -1414,20 +1427,24 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { // 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 and treat - // this response as a fresh full rewrite (200 semantics). + // 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 restarting from scratch" + "downloadBundle: 206 Content-Range start=$contentRangeStart != partialBytes=$partialBytes, discarding partial and aborting attempt" ) if (partialFile.exists()) partialFile.delete() - for (i in 0 until 8) File("$partialFilePath.seg$i").delete() - partialBytes = 0L - isPartialResponse = false + 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") } } 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 0a7241b6..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 @@ -30,17 +30,6 @@ import java.util.concurrent.atomic.AtomicLong @DoNotStrip class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { - companion object { - // Segment count used by cancel/discardArtifacts when sweeping the sibling - // `.partial.segN` files. download() lets callers override segmentCount, - // but cancel/discard don't receive params, so we sweep the shipped default - // (matches ConcurrentRangeDownloader's default and download()'s `?: 8`), - // which covers the overwhelming majority of runs. A larger custom count - // would leave a few high-index `.segN` behind, but the next resume only - // trusts segments it re-reads, so the worst case is a little stale disk. - private const val DEFAULT_SEGMENT_COUNT = 8 - } - private class Listener(val id: Double, val callback: (RangeDownloadEvent) -> Unit) private val listeners = CopyOnWriteArrayList() @@ -97,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, @@ -107,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 } } } @@ -174,9 +168,9 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { cancelActive(channel, taskId) // 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). - for (i in 0 until DEFAULT_SEGMENT_COUNT) File("$destFilePath.partial.seg$i").delete() - File("$destFilePath.partial").delete() + // 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 } @@ -190,10 +184,9 @@ class ReactNativeRangeDownloader : HybridReactNativeRangeDownloaderSpec() { return Promise.async { // Stop workers first, then delete artifacts so nothing resurrects them. cancelActive(channel, taskId) - // Same segmented-artifact sweep as discardArtifacts: per-segment `.segN` - // files plus the concatenated `.partial`. - for (i in 0 until DEFAULT_SEGMENT_COUNT) File("$destFilePath.partial.seg$i").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 } @@ -204,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 From 06a60f2532c6361678ff565a5b46209eb95b7f4f Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 12 Jun 2026 23:14:00 +0800 Subject: [PATCH 6/6] chore: bump version to 3.0.66 --- native-modules/native-logger/package.json | 2 +- native-modules/react-native-aes-crypto/package.json | 2 +- native-modules/react-native-app-update/package.json | 2 +- native-modules/react-native-async-storage/package.json | 2 +- native-modules/react-native-background-thread/package.json | 2 +- native-modules/react-native-bundle-crypto/package.json | 2 +- native-modules/react-native-bundle-update/package.json | 2 +- .../react-native-check-biometric-auth-changed/package.json | 2 +- native-modules/react-native-cloud-fs/package.json | 2 +- native-modules/react-native-cloud-kit-module/package.json | 2 +- native-modules/react-native-device-utils/package.json | 2 +- native-modules/react-native-dns-lookup/package.json | 2 +- native-modules/react-native-get-random-values/package.json | 2 +- native-modules/react-native-keychain-module/package.json | 2 +- native-modules/react-native-lite-card/package.json | 2 +- native-modules/react-native-network-info/package.json | 2 +- native-modules/react-native-pbkdf2/package.json | 2 +- native-modules/react-native-perf-memory/package.json | 2 +- native-modules/react-native-perf-stats/package.json | 2 +- native-modules/react-native-ping/package.json | 2 +- native-modules/react-native-range-downloader/package.json | 2 +- native-modules/react-native-splash-screen/package.json | 2 +- native-modules/react-native-split-bundle-loader/package.json | 2 +- native-modules/react-native-tcp-socket/package.json | 2 +- native-modules/react-native-zip-archive/package.json | 2 +- native-views/react-native-auto-size-input/package.json | 2 +- native-views/react-native-chart-webview/package.json | 2 +- native-views/react-native-pager-view/package.json | 2 +- native-views/react-native-perp-depth-bar/package.json | 2 +- native-views/react-native-scroll-guard/package.json | 2 +- native-views/react-native-segment-slider/package.json | 2 +- native-views/react-native-skeleton/package.json | 2 +- native-views/react-native-tab-view/package.json | 2 +- 33 files changed, 33 insertions(+), 33 deletions(-) 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/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/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/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",