| … | … | @@ -3,6 +3,8 @@ |
| 3 | 3 | <!-- INTERNET: TCP to the lamps on the LAN. ACCESS_NETWORK_STATE: NsdManager (step 3). --> |
| 4 | 4 | <uses-permission android:name="android.permission.INTERNET" /> |
| 5 | 5 | <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> |
| 6 | <!-- Self-update: hand a downloaded APK to the system installer (UpdaterModule.kt). --> | |
| 7 | <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> | |
| 6 | 8 | |
| 7 | 9 | <application |
| 8 | 10 | android:name=".MainApplication" |
| … | … | @@ -20,5 +22,12 @@ |
| 20 | 22 | <category android:name="android.intent.category.LAUNCHER" /> |
| 21 | 23 | </intent-filter> |
| 22 | 24 | </activity> |
| 25 | <provider | |
| 26 | android:name="androidx.core.content.FileProvider" | |
| 27 | android:authorities="${applicationId}.updates" | |
| 28 | android:exported="false" | |
| 29 | android:grantUriPermissions="true"> | |
| 30 | <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/update_paths" /> | |
| 31 | </provider> | |
| 23 | 32 | </application> |
| 24 | 33 | </manifest> |
| … | … | @@ -8,7 +8,7 @@ import com.facebook.react.uimanager.ViewManager |
| 8 | 8 | /** The app's own native modules. Autolinking cannot find these: they are not a node_modules package. */ |
| 9 | 9 | class CthuloidPackage : ReactPackage { |
| 10 | 10 | override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> = |
| 11 | listOf(LampFinderModule(reactContext)) | |
| 11 | listOf(LampFinderModule(reactContext), UpdaterModule(reactContext)) | |
| 12 | 12 | |
| 13 | 13 | override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> = |
| 14 | 14 | emptyList() |
| … | … | @@ -0,0 +1,77 @@ |
| 1 | package org.circusautomatic.cthuloid | |
| 2 | ||
| 3 | import android.content.Intent | |
| 4 | import android.net.Uri | |
| 5 | import android.provider.Settings | |
| 6 | import androidx.core.content.FileProvider | |
| 7 | import com.facebook.react.bridge.Promise | |
| 8 | import com.facebook.react.bridge.ReactApplicationContext | |
| 9 | import com.facebook.react.bridge.ReactContextBaseJavaModule | |
| 10 | import com.facebook.react.bridge.ReactMethod | |
| 11 | import java.io.File | |
| 12 | ||
| 13 | /** | |
| 14 | * The native half of the self-update (DESIGN-android.md §6). JS finds, downloads and checks the | |
| 15 | * APK (app/updater.js); this module only says which version is installed and hands a downloaded | |
| 16 | * file to Android's package installer. The user always confirms the install, and Android refuses | |
| 17 | * an APK signed with any other key than this app's. | |
| 18 | * | |
| 19 | * Files are shared through FileProvider (res/xml/update_paths.xml): only the cache's updates/ dir. | |
| 20 | */ | |
| 21 | class UpdaterModule(private val ctx: ReactApplicationContext) : ReactContextBaseJavaModule(ctx) { | |
| 22 | ||
| 23 | companion object { | |
| 24 | const val NAME = "CthuloidUpdater" | |
| 25 | const val DIR = "updates" | |
| 26 | } | |
| 27 | ||
| 28 | override fun getName(): String = NAME | |
| 29 | ||
| 30 | override fun getConstants(): Map<String, Any> { | |
| 31 | val info = ctx.packageManager.getPackageInfo(ctx.packageName, 0) | |
| 32 | return mapOf( | |
| 33 | "versionName" to (info.versionName ?: ""), | |
| 34 | "updatesDir" to File(ctx.cacheDir, DIR).absolutePath | |
| 35 | ) | |
| 36 | } | |
| 37 | ||
| 38 | /** Has the user allowed "install unknown apps" for this app? */ | |
| 39 | @ReactMethod | |
| 40 | fun canInstall(promise: Promise) { | |
| 41 | promise.resolve(ctx.packageManager.canRequestPackageInstalls()) | |
| 42 | } | |
| 43 | ||
| 44 | /** Open this app's "install unknown apps" switch in system settings. */ | |
| 45 | @ReactMethod | |
| 46 | fun openInstallSettings(promise: Promise) { | |
| 47 | try { | |
| 48 | val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + ctx.packageName)) | |
| 49 | .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) | |
| 50 | ctx.startActivity(intent) | |
| 51 | promise.resolve(null) | |
| 52 | } catch (e: Exception) { | |
| 53 | promise.reject("E_SETTINGS", e.message, e) | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | /** Hand a checked APK under the updates dir to the system installer. */ | |
| 58 | @ReactMethod | |
| 59 | fun install(path: String, promise: Promise) { | |
| 60 | try { | |
| 61 | val dir = File(ctx.cacheDir, DIR).canonicalFile | |
| 62 | val file = File(path).canonicalFile | |
| 63 | if (file.parentFile != dir || !file.isFile) { | |
| 64 | promise.reject("E_PATH", "not a downloaded update: $path") | |
| 65 | return | |
| 66 | } | |
| 67 | val uri = FileProvider.getUriForFile(ctx, ctx.packageName + ".updates", file) | |
| 68 | val intent = Intent(Intent.ACTION_VIEW) | |
| 69 | .setDataAndType(uri, "application/vnd.android.package-archive") | |
| 70 | .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) | |
| 71 | ctx.startActivity(intent) | |
| 72 | promise.resolve(null) | |
| 73 | } catch (e: Exception) { | |
| 74 | promise.reject("E_INSTALL", e.message, e) | |
| 75 | } | |
| 76 | } | |
| 77 | } |
| … | … | @@ -0,0 +1,6 @@ |
| 1 | <?xml version="1.0" encoding="utf-8"?> | |
| 2 | <!-- Self-update: the installer may read downloaded APKs from the cache's updates/ dir, nothing else | |
| 3 | (UpdaterModule.kt). --> | |
| 4 | <paths> | |
| 5 | <cache-path name="updates" path="updates/" /> | |
| 6 | </paths> |
| … | … | @@ -5,6 +5,7 @@ import React, { useEffect, useState, useCallback, useRef } from 'react' |
| 5 | 5 | import { View, Text, ActivityIndicator, StyleSheet, SafeAreaView, BackHandler, Pressable, AppState, DeviceEventEmitter, NativeModules } from 'react-native' |
| 6 | 6 | import { WebView } from 'react-native-webview' |
| 7 | 7 | import { startWorklet } from './worklet-host' |
| 8 | import * as updater from './updater' | |
| 8 | 9 | |
| 9 | 10 | // The lamp finder (LampFinderModule.kt, NsdManager) -> the worklet's discovery. |
| 10 | 11 | // Browses only while the app is on screen: in the background the worklet is |
| … | … | @@ -27,6 +28,79 @@ function Center ({ children }) { |
| 27 | 28 | } |
| 28 | 29 | |
| 29 | 30 | export default function App () { |
| 31 | return ( | |
| 32 | <SafeAreaView style={s.fill}> | |
| 33 | <UpdateBanner /> | |
| 34 | <Main /> | |
| 35 | </SafeAreaView> | |
| 36 | ) | |
| 37 | } | |
| 38 | ||
| 39 | // Self-update (DESIGN-android.md §6): checks the forge on start and every 6 h; | |
| 40 | // shows a strip only when a newer complete release exists. Above every screen, | |
| 41 | // so a build whose controller will not start can still update itself. | |
| 42 | const CHECK_EVERY = 6 * 60 * 60 * 1000 | |
| 43 | ||
| 44 | function UpdateBanner () { | |
| 45 | const [u, setU] = useState(null) // { version, apkUrl, shaUrl } | |
| 46 | const [phase, setPhase] = useState('available') | |
| 47 | const [note, setNote] = useState('') | |
| 48 | const busy = useRef(false) | |
| 49 | const path = useRef(null) // checked APK on disk, for u.version only | |
| 50 | ||
| 51 | useEffect(() => { | |
| 52 | const run = () => { | |
| 53 | if (busy.current) return | |
| 54 | updater.check() | |
| 55 | .then((next) => { | |
| 56 | if (busy.current) return | |
| 57 | setU((prev) => { | |
| 58 | if (!prev || !next || prev.version !== next.version) { path.current = null; setPhase('available'); setNote('') } | |
| 59 | return next | |
| 60 | }) | |
| 61 | }) | |
| 62 | .catch((e) => console.warn('update check failed: ' + e.message)) // not 'up to date' | |
| 63 | } | |
| 64 | run() | |
| 65 | const t = setInterval(run, CHECK_EVERY) | |
| 66 | return () => clearInterval(t) | |
| 67 | }, []) | |
| 68 | ||
| 69 | const go = useCallback(async () => { | |
| 70 | if (busy.current) return | |
| 71 | busy.current = true | |
| 72 | try { | |
| 73 | if (!path.current) { | |
| 74 | setPhase('downloading'); setNote('0 %') | |
| 75 | path.current = await updater.download(u, (f) => setNote(Math.round(f * 100) + ' %')) | |
| 76 | } | |
| 77 | const opened = await updater.install(path.current) | |
| 78 | setPhase(opened ? 'installing' : 'permission') | |
| 79 | setNote('') | |
| 80 | } catch (e) { | |
| 81 | path.current = null | |
| 82 | setPhase('error'); setNote(e.message) | |
| 83 | } finally { | |
| 84 | busy.current = false | |
| 85 | } | |
| 86 | }, [u]) | |
| 87 | ||
| 88 | if (!u) return null | |
| 89 | const label = { | |
| 90 | available: 'Update ' + u.version + ' available — Install', | |
| 91 | downloading: 'Downloading ' + u.version + '… ' + note, | |
| 92 | permission: 'Allow "Install unknown apps" for Cthuloid Lights, then tap here', | |
| 93 | installing: 'Installing ' + u.version + ' — tap if the installer closed', | |
| 94 | error: 'Update failed: ' + note + ' — tap to retry' | |
| 95 | }[phase] | |
| 96 | return ( | |
| 97 | <Pressable style={s.banner} onPress={go} disabled={phase === 'downloading'}> | |
| 98 | <Text style={s.bannerText}>{label}</Text> | |
| 99 | </Pressable> | |
| 100 | ) | |
| 101 | } | |
| 102 | ||
| 103 | function Main () { | |
| 30 | 104 | const [url, setUrl] = useState(null) |
| 31 | 105 | const [error, setError] = useState(null) |
| 32 | 106 | const [stage, setStage] = useState('starting…') |
| … | … | @@ -87,7 +161,7 @@ function Page ({ url }) { |
| 87 | 161 | } |
| 88 | 162 | |
| 89 | 163 | return ( |
| 90 | <SafeAreaView style={s.fill}> | |
| 164 | <View style={s.fill}> | |
| 91 | 165 | <WebView |
| 92 | 166 | ref={ref} |
| 93 | 167 | source={{ uri: url }} |
| … | … | @@ -100,7 +174,7 @@ function Page ({ url }) { |
| 100 | 174 | domStorageEnabled |
| 101 | 175 | style={s.fill} |
| 102 | 176 | /> |
| 103 | </SafeAreaView> | |
| 177 | </View> | |
| 104 | 178 | ) |
| 105 | 179 | } |
| 106 | 180 | |
| … | … | @@ -109,5 +183,7 @@ const s = StyleSheet.create({ |
| 109 | 183 | center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, |
| 110 | 184 | dim: { opacity: 0.6, marginTop: 12, textAlign: 'center' }, |
| 111 | 185 | err: { color: '#c33', textAlign: 'center' }, |
| 112 | retry: { marginTop: 16, textDecorationLine: 'underline' } | |
| 186 | retry: { marginTop: 16, textDecorationLine: 'underline' }, | |
| 187 | banner: { backgroundColor: '#2d5fd0', paddingVertical: 10, paddingHorizontal: 16 }, | |
| 188 | bannerText: { color: '#fff', textAlign: 'center' } | |
| 113 | 189 | }) |
| … | … | @@ -0,0 +1,51 @@ |
| 1 | // Which update to offer, from the forge's release index (DESIGN-android.md §6). | |
| 2 | // Pure, no React Native: tested under Bare (test/update-pick.test.js). | |
| 3 | ||
| 4 | const TAG = /^android-v(\d+\.\d+\.\d+)$/ | |
| 5 | const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/ | |
| 6 | const APK = 'dist/cthuloid-lights.apk' | |
| 7 | const SHA = APK + '.sha256' | |
| 8 | ||
| 9 | export function compareSemver (a, b) { | |
| 10 | const x = SEMVER.exec(a) | |
| 11 | const y = SEMVER.exec(b) | |
| 12 | for (let i = 1; i <= 3; i++) { | |
| 13 | const d = Number(x[i]) - Number(y[i]) | |
| 14 | if (d !== 0) return d | |
| 15 | } | |
| 16 | return 0 | |
| 17 | } | |
| 18 | ||
| 19 | // files: release-index rows { repo, version, path }; path is | |
| 20 | // '<repo>/versions/<version>/<file>', served at <base>/releases/<repo>/<version>/<file>. | |
| 21 | // Returns { version, apkUrl, shaUrl } for the newest COMPLETE release newer than | |
| 22 | // `installed`, else null. Complete = both the APK and its .sha256 are listed: the | |
| 23 | // forge backends replicate file by file, and a half-copied release must not be offered. | |
| 24 | export function pickUpdate (files, installed, base) { | |
| 25 | if (typeof installed !== 'string' || !SEMVER.test(installed)) return null | |
| 26 | const seen = new Map() // semver -> Set of files | |
| 27 | for (const f of files) { | |
| 28 | if (!f || f.repo !== 'cthuloid') continue | |
| 29 | const m = TAG.exec(f.version) | |
| 30 | if (!m) continue | |
| 31 | const prefix = 'cthuloid/versions/' + f.version + '/' | |
| 32 | if (typeof f.path !== 'string' || !f.path.startsWith(prefix)) continue | |
| 33 | if (!seen.has(m[1])) seen.set(m[1], new Set()) | |
| 34 | seen.get(m[1]).add(f.path.slice(prefix.length)) | |
| 35 | } | |
| 36 | let best = null | |
| 37 | for (const [v, have] of seen) { | |
| 38 | if (!have.has(APK) || !have.has(SHA)) continue | |
| 39 | if (compareSemver(v, installed) <= 0) continue | |
| 40 | if (best === null || compareSemver(v, best) > 0) best = v | |
| 41 | } | |
| 42 | if (best === null) return null | |
| 43 | const dir = base + '/releases/cthuloid/android-v' + best + '/' | |
| 44 | return { version: best, apkUrl: dir + APK, shaUrl: dir + SHA } | |
| 45 | } | |
| 46 | ||
| 47 | // The .sha256 file is `sha256sum` output: '<hex> <name>\n'. | |
| 48 | export function parseSha256 (text) { | |
| 49 | const m = /^([0-9a-fA-F]{64})(\s|$)/.exec(String(text || '').trim()) | |
| 50 | return m ? m[1].toLowerCase() : null | |
| 51 | } |
| … | … | @@ -0,0 +1,66 @@ |
| 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. | |
| 3 | // check() -> { version, apkUrl, shaUrl } | null | |
| 4 | // download(u, p) -> path of an APK whose SHA-256 matched the published .sha256 | |
| 5 | // install(path) -> asks for "install unknown apps" first if needed; Android asks the user | |
| 6 | import { NativeModules } from 'react-native' | |
| 7 | import RNFS from 'react-native-fs' | |
| 8 | import { pickUpdate, parseSha256 } from './update-pick' | |
| 9 | ||
| 10 | export const FORGE = 'https://7rnx.net/git' | |
| 11 | const native = NativeModules.CthuloidUpdater | |
| 12 | ||
| 13 | export const installedVersion = native ? native.versionName : null | |
| 14 | ||
| 15 | export async function check () { | |
| 16 | if (!native) return null | |
| 17 | // release-index is the anonymous-allowed listing; the other one answers 403 to a phone. | |
| 18 | const res = await fetch(FORGE + '/rpc/release-index', { | |
| 19 | method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' | |
| 20 | }) | |
| 21 | if (!res.ok) throw new Error('release index: HTTP ' + res.status) | |
| 22 | const body = await res.json() | |
| 23 | const files = (body && body.result && body.result.files) || [] | |
| 24 | return pickUpdate(files, installedVersion, FORGE) | |
| 25 | } | |
| 26 | ||
| 27 | export async function download (u, onProgress) { | |
| 28 | const dir = native.updatesDir | |
| 29 | 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(() => {}) | |
| 32 | ||
| 33 | const shaRes = await fetch(u.shaUrl) | |
| 34 | if (!shaRes.ok) throw new Error('checksum: HTTP ' + shaRes.status) | |
| 35 | const want = parseSha256(await shaRes.text()) | |
| 36 | if (!want) throw new Error('checksum file is not a SHA-256') | |
| 37 | ||
| 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) } | |
| 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) | |
| 49 | } | |
| 50 | const got = (await RNFS.hash(path, 'sha256')).toLowerCase() | |
| 51 | if (got !== want) { | |
| 52 | await RNFS.unlink(path).catch(() => {}) | |
| 53 | throw new Error('download corrupted (checksum mismatch), try again') | |
| 54 | } | |
| 55 | return path | |
| 56 | } | |
| 57 | ||
| 58 | // true = the installer is open; false = sent to settings to allow installs first. | |
| 59 | export async function install (path) { | |
| 60 | if (!(await native.canInstall())) { | |
| 61 | await native.openInstallSettings() | |
| 62 | return false | |
| 63 | } | |
| 64 | await native.install(path) | |
| 65 | return true | |
| 66 | } |
| … | … | @@ -0,0 +1,57 @@ |
| 1 | import test from 'brittle' | |
| 2 | import { pickUpdate, compareSemver, parseSha256 } from '../app/update-pick.js' | |
| 3 | ||
| 4 | const BASE = 'https://7rnx.net/git' | |
| 5 | const row = (version, file, repo = 'cthuloid') => ({ repo, version, path: repo + '/versions/' + version + '/' + file }) | |
| 6 | const release = (v) => [row('android-v' + v, 'dist/cthuloid-lights.apk'), row('android-v' + v, 'dist/cthuloid-lights.apk.sha256')] | |
| 7 | ||
| 8 | test('newest complete release by semver, not by list position', (t) => { | |
| 9 | // The forge lists newest first; 0.10.0 > 0.9.0 numerically, not as text. | |
| 10 | const files = [...release('0.9.0'), ...release('0.10.0'), ...release('0.2.0')] | |
| 11 | const u = pickUpdate(files, '0.1.0', BASE) | |
| 12 | t.is(u.version, '0.10.0') | |
| 13 | t.is(u.apkUrl, BASE + '/releases/cthuloid/android-v0.10.0/dist/cthuloid-lights.apk') | |
| 14 | t.is(u.shaUrl, BASE + '/releases/cthuloid/android-v0.10.0/dist/cthuloid-lights.apk.sha256') | |
| 15 | }) | |
| 16 | ||
| 17 | test('a release missing its .sha256 (half replicated) is skipped', (t) => { | |
| 18 | const files = [...release('0.1.1'), row('android-v0.1.2', 'dist/cthuloid-lights.apk')] | |
| 19 | t.is(pickUpdate(files, '0.1.0', BASE).version, '0.1.1') | |
| 20 | }) | |
| 21 | ||
| 22 | test('nothing newer than installed -> null', (t) => { | |
| 23 | t.is(pickUpdate([...release('0.1.2'), ...release('0.1.1')], '0.1.2', BASE), null) | |
| 24 | t.is(pickUpdate(release('0.1.1'), '0.1.2', BASE), null) | |
| 25 | t.is(pickUpdate([], '0.1.0', BASE), null) | |
| 26 | }) | |
| 27 | ||
| 28 | test('other repos, branch builds and non-semver tags are ignored', (t) => { | |
| 29 | const files = [ | |
| 30 | ...release('0.1.1'), | |
| 31 | row('android-v9.0.0', 'dist/cthuloid-lights.apk', 'other'), row('android-v9.0.0', 'dist/cthuloid-lights.apk.sha256', 'other'), | |
| 32 | row('master-d85fb4c', 'dist/skipped.txt'), | |
| 33 | row('android-v2.0', 'dist/cthuloid-lights.apk'), row('android-v2.0', 'dist/cthuloid-lights.apk.sha256'), | |
| 34 | row('android-v3.0.0-rc1', 'dist/cthuloid-lights.apk'), row('android-v3.0.0-rc1', 'dist/cthuloid-lights.apk.sha256') | |
| 35 | ] | |
| 36 | t.is(pickUpdate(files, '0.1.0', BASE).version, '0.1.1') | |
| 37 | }) | |
| 38 | ||
| 39 | test('an unparseable installed version never offers an update', (t) => { | |
| 40 | t.is(pickUpdate(release('0.1.1'), 'dev', BASE), null) | |
| 41 | t.is(pickUpdate(release('0.1.1'), null, BASE), null) | |
| 42 | }) | |
| 43 | ||
| 44 | test('compareSemver', (t) => { | |
| 45 | t.ok(compareSemver('0.1.10', '0.1.9') > 0) | |
| 46 | t.ok(compareSemver('1.0.0', '0.99.99') > 0) | |
| 47 | t.is(compareSemver('0.1.2', '0.1.2'), 0) | |
| 48 | t.ok(compareSemver('0.1.2', '0.2.0') < 0) | |
| 49 | }) | |
| 50 | ||
| 51 | test('parseSha256 reads sha256sum output', (t) => { | |
| 52 | const hex = '9d541c3029794698fc1f0dd25a8c33cc397214b537d324078dda6dc3a5ce002f' | |
| 53 | t.is(parseSha256(hex + ' cthuloid-lights.apk\n'), hex) | |
| 54 | t.is(parseSha256(hex.toUpperCase()), hex) | |
| 55 | t.is(parseSha256('not a hash'), null) | |
| 56 | t.is(parseSha256(''), null) | |
| 57 | }) |
| … | … | @@ -127,7 +127,7 @@ does not apply: |
| 127 | 127 | 3. Lamp finder (NsdManager) + remembered addresses. |
| 128 | 128 | 4. CI (tag-only) + keystore + first published APK; on-phone e2e. |
| 129 | 129 | 5. Self-update: check, banner, download + checksum, installer hand-off; verified by |
| 130 | publishing `android-v0.1.1` and updating a phone that runs 0.1.0. | |
| 130 | publishing `android-v0.1.4` and updating a phone that runs 0.1.3 (the first build with the updater). | |
| 131 | 131 | |
| 132 | 132 | ## 9. Decisions for the user |
| 133 | 133 |