| … | … | @@ -4,11 +4,17 @@ import android.content.Intent |
| 4 | 4 | import android.net.Uri |
| 5 | 5 | import android.provider.Settings |
| 6 | 6 | import androidx.core.content.FileProvider |
| 7 | import com.facebook.react.bridge.Arguments | |
| 7 | 8 | import com.facebook.react.bridge.Promise |
| 8 | 9 | import com.facebook.react.bridge.ReactApplicationContext |
| 9 | 10 | import com.facebook.react.bridge.ReactContextBaseJavaModule |
| 10 | 11 | import com.facebook.react.bridge.ReactMethod |
| 12 | import com.facebook.react.modules.core.DeviceEventManagerModule | |
| 11 | 13 | import java.io.File |
| 14 | import java.io.FileOutputStream | |
| 15 | import java.io.IOException | |
| 16 | import java.net.HttpURLConnection | |
| 17 | import java.net.URL | |
| 12 | 18 | |
| 13 | 19 | /** |
| 14 | 20 | * 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 |
| 35 | 41 | ) |
| 36 | 42 | } |
| 37 | 43 | |
| 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 | ||
| 38 | 132 | /** Has the user allowed "install unknown apps" for this app? */ |
| 39 | 133 | @ReactMethod |
| 40 | 134 | fun canInstall(promise: Promise) { |
| … | … | @@ -79,7 +79,7 @@ function UpdateBanner () { |
| 79 | 79 | busy.current = true |
| 80 | 80 | try { |
| 81 | 81 | if (!path.current) { |
| 82 | setPhase('downloading'); setNote('0 %') | |
| 82 | setPhase('downloading') | |
| 83 | 83 | path.current = await updater.download(u, (f) => setNote(Math.round(f * 100) + ' %')) |
| 84 | 84 | } |
| 85 | 85 | const opened = await updater.install(path.current) |
| … | … | @@ -87,12 +87,24 @@ function UpdateBanner () { |
| 87 | 87 | setNote('') |
| 88 | 88 | } catch (e) { |
| 89 | 89 | 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) } | |
| 91 | 94 | } finally { |
| 92 | 95 | busy.current = false |
| 93 | 96 | } |
| 94 | 97 | }, [u]) |
| 95 | 98 | |
| 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 | ||
| 96 | 108 | if (!u) { |
| 97 | 109 | return ( |
| 98 | 110 | <Pressable style={s.version} onPress={run}> |
| … | … | @@ -103,6 +115,7 @@ function UpdateBanner () { |
| 103 | 115 | const label = { |
| 104 | 116 | available: 'Update ' + u.version + ' available — Install', |
| 105 | 117 | downloading: 'Downloading ' + u.version + '… ' + note, |
| 118 | paused: 'Download paused at ' + (note || '0 %') + ' — tap to continue', | |
| 106 | 119 | permission: 'Allow "Install unknown apps" for Cthuloid Lights, then tap here', |
| 107 | 120 | installing: 'Installing ' + u.version + ' — tap if the installer closed', |
| 108 | 121 | error: 'Update failed: ' + note + ' — tap to retry' |
| … | … | @@ -1,9 +1,9 @@ |
| 1 | 1 | // 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. | |
| 3 | 3 | // check() -> { version, apkUrl, shaUrl } | null |
| 4 | 4 | // download(u, p) -> path of an APK whose SHA-256 matched the published .sha256 |
| 5 | 5 | // install(path) -> asks for "install unknown apps" first if needed; Android asks the user |
| 6 | import { NativeModules } from 'react-native' | |
| 6 | import { NativeModules, DeviceEventEmitter } from 'react-native' | |
| 7 | 7 | import RNFS from 'react-native-fs' |
| 8 | 8 | import { pickUpdate, parseSha256 } from './update-pick' |
| 9 | 9 | |
| … | … | @@ -24,28 +24,31 @@ export async function check () { |
| 24 | 24 | return pickUpdate(files, installedVersion, FORGE) |
| 25 | 25 | } |
| 26 | 26 | |
| 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. | |
| 27 | 30 | export async function download (u, onProgress) { |
| 28 | 31 | const dir = native.updatesDir |
| 29 | 32 | 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(() => {}) | |
| 32 | 35 | |
| 33 | 36 | const shaRes = await fetch(u.shaUrl) |
| 34 | 37 | if (!shaRes.ok) throw new Error('checksum: HTTP ' + shaRes.status) |
| 35 | 38 | const want = parseSha256(await shaRes.text()) |
| 36 | 39 | if (!want) throw new Error('checksum file is not a SHA-256') |
| 37 | 40 | |
| 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) | |
| 44 | 44 | }) |
| 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() | |
| 49 | 52 | } |
| 50 | 53 | const got = (await RNFS.hash(path, 'sha256')).toLowerCase() |
| 51 | 54 | if (got !== want) { |