OverviewFilesCommitsIssuesDocsReleasesPeers

cthuloid commits / cf59932

android: resumable update download
Android freezes the app when the screen locks, aborting the download socket ("Software caused connection abort"), and a retry started the 90 MB over. UpdaterModule.download now keeps the partial file and resumes it with a Range request, accepting a 206 only at the exact offset; the SHA-256 check of the whole file still gates the install. The strip shows "Download paused at N %" and continues by itself when the app is back on screen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cf59932460d481d7a924f930f585751444f80e8e
by plankatron <fleeky@gmail.com> · 2026-10-02T17:45:59.000Z

Changed files (3) +126 -16

android/android/app/src/main/java/org/circusautomatic/cthuloid/UpdaterModule.kt +94 -0
……@@ -4,11 +4,17 @@ import android.content.Intent
44import android.net.Uri
55import android.provider.Settings
66import androidx.core.content.FileProvider
7import com.facebook.react.bridge.Arguments
78import com.facebook.react.bridge.Promise
89import com.facebook.react.bridge.ReactApplicationContext
910import com.facebook.react.bridge.ReactContextBaseJavaModule
1011import com.facebook.react.bridge.ReactMethod
12import com.facebook.react.modules.core.DeviceEventManagerModule
1113import java.io.File
14import java.io.FileOutputStream
15import java.io.IOException
16import java.net.HttpURLConnection
17import java.net.URL
1218
1319/**
1420 * The native half of the self-update (DESIGN-android.md §6). JS finds, downloads and checks the
……@@ -35,6 +41,94 @@ class UpdaterModule(private val ctx: ReactApplicationContext) : ReactContextBase
3541 )
3642 }
3743
44 /**
45 * Download [url] into [path] (under the updates dir), RESUMING a partial file. Android freezes the
46 * app when the screen locks, which aborts the socket ("Software caused connection abort"); a retry
47 * then continues from the bytes already on disk instead of starting the 90 MB over.
48 *
49 * Resume asks for `Range: bytes=<have>-` and appends only if the reply is a 206 starting exactly at
50 * <have>. The forge sends no ETag, but each release URL is immutable (the version is in the path)
51 * and JS checks the SHA-256 of the whole file afterwards, so a bad splice is caught, never installed.
52 * Progress goes to JS as "cthuloid-update-progress" events: { done, total }.
53 */
54 @ReactMethod
55 fun download(url: String, path: String, promise: Promise) {
56 val dir = File(ctx.cacheDir, DIR).canonicalFile
57 val file = File(path).canonicalFile
58 if (file.parentFile != dir) {
59 promise.reject("E_PATH", "not in the updates dir: $path")
60 return
61 }
62 Thread {
63 try {
64 dir.mkdirs()
65 var have = if (file.isFile) file.length() else 0L
66 val conn = (URL(url).openConnection() as HttpURLConnection).apply {
67 connectTimeout = 15000
68 readTimeout = 30000
69 if (have > 0) setRequestProperty("Range", "bytes=$have-")
70 }
71 try {
72 val code = conn.responseCode
73 val append: Boolean
74 val total: Long
75 when {
76 code == 416 && have > 0 -> { // already complete
77 promise.resolve(have.toDouble())
78 return@Thread
79 }
80 code == 206 && have > 0 -> {
81 val m = Regex("""bytes (\d+)-\d+/(\d+)""").find(conn.getHeaderField("Content-Range") ?: "")
82 if (m == null || m.groupValues[1].toLong() != have) throw IOException("server resumed at the wrong offset")
83 append = true
84 total = m.groupValues[2].toLong()
85 }
86 code == 200 -> { // no resume: start over
87 append = false
88 have = 0L
89 total = conn.contentLengthLong
90 }
91 else -> throw IOException("HTTP $code")
92 }
93 conn.inputStream.use { input ->
94 FileOutputStream(file, append).use { out ->
95 val buf = ByteArray(64 * 1024)
96 var done = have
97 var lastEmit = 0L
98 while (true) {
99 val n = input.read(buf)
100 if (n < 0) break
101 out.write(buf, 0, n)
102 done += n
103 val now = System.currentTimeMillis()
104 if (now - lastEmit > 250) { progress(done, total); lastEmit = now }
105 }
106 progress(done, total)
107 if (total > 0 && done != total) throw IOException("download ended early ($done of $total bytes)")
108 promise.resolve(done.toDouble())
109 }
110 }
111 } finally {
112 conn.disconnect()
113 }
114 } catch (e: Exception) {
115 // The partial file is KEPT: the next attempt resumes from it.
116 promise.reject("E_DOWNLOAD", e.message ?: e.toString(), e)
117 }
118 }.start()
119 }
120
121 private fun progress(done: Long, total: Long) {
122 ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
123 .emit("cthuloid-update-progress", Arguments.createMap().apply {
124 putDouble("done", done.toDouble()); putDouble("total", total.toDouble())
125 })
126 }
127
128 // Required by RN's event emitter contract when JS subscribes through NativeEventEmitter.
129 @ReactMethod fun addListener(eventName: String) {}
130 @ReactMethod fun removeListeners(count: Int) {}
131
38132 /** Has the user allowed "install unknown apps" for this app? */
39133 @ReactMethod
40134 fun canInstall(promise: Promise) {
android/app/App.jsx +15 -2
……@@ -79,7 +79,7 @@ function UpdateBanner () {
7979 busy.current = true
8080 try {
8181 if (!path.current) {
82 setPhase('downloading'); setNote('0 %')
82 setPhase('downloading')
8383 path.current = await updater.download(u, (f) => setNote(Math.round(f * 100) + ' %'))
8484 }
8585 const opened = await updater.install(path.current)
……@@ -87,12 +87,24 @@ function UpdateBanner () {
8787 setNote('')
8888 } catch (e) {
8989 path.current = null
90 setPhase('error'); setNote(e.message)
90 // Cut off mid-download (screen locked, WiFi dropped): the bytes so far are
91 // kept, and the download continues when the app is back on screen.
92 if (e.paused) setPhase('paused')
93 else { setPhase('error'); setNote(e.message) }
9194 } finally {
9295 busy.current = false
9396 }
9497 }, [u])
9598
99 const resumeRef = useRef(null)
100 resumeRef.current = phase === 'paused' ? go : null
101 useEffect(() => {
102 const sub = AppState.addEventListener('change', (state) => {
103 if (state === 'active' && resumeRef.current) resumeRef.current()
104 })
105 return () => sub.remove()
106 }, [])
107
96108 if (!u) {
97109 return (
98110 <Pressable style={s.version} onPress={run}>
……@@ -103,6 +115,7 @@ function UpdateBanner () {
103115 const label = {
104116 available: 'Update ' + u.version + ' available — Install',
105117 downloading: 'Downloading ' + u.version + '… ' + note,
118 paused: 'Download paused at ' + (note || '0 %') + ' — tap to continue',
106119 permission: 'Allow "Install unknown apps" for Cthuloid Lights, then tap here',
107120 installing: 'Installing ' + u.version + ' — tap if the installer closed',
108121 error: 'Update failed: ' + note + ' — tap to retry'
android/app/updater.js +17 -14
……@@ -1,9 +1,9 @@
11// Self-update from the forge (DESIGN-android.md §6). Android's own HTTPS (fetch,
2// react-native-fs), never the worklet: Bare on Android has no DNS.
2// UpdaterModule.download), never the worklet: Bare on Android has no DNS.
33// check() -> { version, apkUrl, shaUrl } | null
44// download(u, p) -> path of an APK whose SHA-256 matched the published .sha256
55// install(path) -> asks for "install unknown apps" first if needed; Android asks the user
6import { NativeModules } from 'react-native'
6import { NativeModules, DeviceEventEmitter } from 'react-native'
77import RNFS from 'react-native-fs'
88import { pickUpdate, parseSha256 } from './update-pick'
99
……@@ -24,28 +24,31 @@ export async function check () {
2424 return pickUpdate(files, installedVersion, FORGE)
2525}
2626
27// Resumable: a download cut off (the screen locked, the app was frozen, WiFi
28// dropped) leaves its partial file, and the next call continues from it
29// (UpdaterModule.download). Only OTHER versions' files are removed.
2730export async function download (u, onProgress) {
2831 const dir = native.updatesDir
2932 await RNFS.mkdir(dir)
30 // One update on disk at a time: earlier downloads (finished or partial) go.
31 for (const f of await RNFS.readDir(dir)) await RNFS.unlink(f.path).catch(() => {})
33 const name = 'cthuloid-lights-' + u.version + '.apk'
34 for (const f of await RNFS.readDir(dir)) if (f.name !== name) await RNFS.unlink(f.path).catch(() => {})
3235
3336 const shaRes = await fetch(u.shaUrl)
3437 if (!shaRes.ok) throw new Error('checksum: HTTP ' + shaRes.status)
3538 const want = parseSha256(await shaRes.text())
3639 if (!want) throw new Error('checksum file is not a SHA-256')
3740
38 const path = dir + '/cthuloid-lights-' + u.version + '.apk'
39 const job = RNFS.downloadFile({
40 fromUrl: u.apkUrl,
41 toFile: path,
42 progressDivider: 2,
43 progress: (p) => { if (onProgress && p.contentLength > 0) onProgress(p.bytesWritten / p.contentLength) }
41 const path = dir + '/' + name
42 const sub = DeviceEventEmitter.addListener('cthuloid-update-progress', (p) => {
43 if (onProgress && p.total > 0) onProgress(p.done / p.total)
4444 })
45 const r = await job.promise
46 if (r.statusCode !== 200) {
47 await RNFS.unlink(path).catch(() => {})
48 throw new Error('download: HTTP ' + r.statusCode)
45 try {
46 await native.download(u.apkUrl, path)
47 } catch (e) {
48 e.paused = true // the partial file is kept; calling download() again resumes
49 throw e
50 } finally {
51 sub.remove()
4952 }
5053 const got = (await RNFS.hash(path, 'sha256')).toLowerCase()
5154 if (got !== want) {