diff --git a/.gitignore b/.gitignore index 844ad38..2aab283 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ android/local.properties android/.idea/ *.apk *.aab +# Staged release artifacts. The APK is covered above; this also catches the +# checksum file that sits beside it. +release/ # No exception for debug.keystore: an earlier "!debug.keystore" here deliberately # un-ignored it, which would have committed a signing key silently had one ever # landed in the tree. Signing keys belong in ~/.android, not in the repo. diff --git a/CLAUDE.md b/CLAUDE.md index 7f43886..fba0ddb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,9 +172,29 @@ now empty and bar styling is configured in `capacitor.config.json` **No community plugins.** Only first-party: `@capacitor/core`, `/cli`, `/android`, `/haptics`, `/preferences`, `/app`. -**Debug signing.** The debug keystore is stable per-machine, so Phase 2's APK -installs over Phase 1's without an uninstall and drill progress survives. -`appId` is `dev.ncode.sp404layers` and must not change for the same reason. +**Debug signing, releases included.** The debug keystore is stable per-machine, +so Phase 2's APK installs over Phase 1's without an uninstall and drill progress +survives. `appId` is `dev.ncode.sp404layers` and must not change for the same +reason. + +The `release` build type reuses that key on purpose, via the plugin's built-in +`signingConfig signingConfigs.debug`, so no keystore path or password lands in a +tracked file. This only ever ships as a sideloaded APK, never to Play — which +rejects debug keys anyway. The trade: anyone can build an APK that upgrades over +it, against which there is no key to keep safe for years and no way to strand +anyone on a version that can no longer be updated. + +Verified rather than assumed: the release APK's signer digest is +`2494c983…551cc`, identical to every debug build, so it installs in place. That +matters more than it looks, because a release build is **not `DEBUGGABLE`** — the +`adb shell run-as` trick that reads `CapacitorStorage.xml` back out stops working +the moment one is installed, and there is no in-app restore to fall back on. A +signature change would therefore have cost the drill progress outright. + +`npm run release` (`scripts/release.mjs`) builds it, stages +`release/404-Layers-.apk` with a `.sha256` beside it, and fails loudly +if apksigner reports no certificate: an unsigned APK builds happily and only +fails at install time, on someone else's phone. `release/` is gitignored. ## Phase 2: the React port @@ -187,6 +207,7 @@ kit — the app is four tabs and a reducer, and none of those earn their keep. | `npm run test:watch` | Watch mode | | `npm run typecheck` | `tsc --noEmit` | | `npm run extract:data` | Regenerate the combo table from `reference/` | +| `npm run release` | Build the signed release APK into `release/`, with checksum | ``` src/domain/ diff --git a/README.md b/README.md index 43e17a8..ee226e9 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,25 @@ with what it does on that layer. Then it drills you on them. Left: sample mode, nothing held — three functions available. Right: the same screen with `SHIFT` held — thirty-five, and every pad now shows what it becomes. +## Install + +Download the APK from the [latest release](https://git.jawhng.xyz/jawhng/404-Layers/releases), +open it on your phone, and allow installing from unknown sources when Android +asks. It needs **Android 7.0 or newer** and is about 3 MB. + +It isn't on the Play Store, so Android will warn you it came from outside the +store — that warning is about where the file came from, not what's in it. Two +things worth knowing before you trust a stranger's APK: + +- It's signed with a **debug key**, not a private one. Nothing here is worth + protecting with a key I'd have to keep safe for years, and it means a lost + keystore can never strand you on a version that can't be updated. +- It asks for `INTERNET` and `VIBRATE`. The vibration is the drill feedback; + the internet permission is Capacitor's default and the app never uses it — + there are no network calls in the source, and everything it needs is bundled. + +Each release lists a `sha256` if you want to check the download. + ## What it does | Tab | | @@ -32,8 +51,9 @@ the combos you keep missing keep coming back. Progress is kept on the device. ## Status -It works and it's in daily use on one phone. It is **not** on any app store: it's -a debug-signed APK, sideloaded over `adb`. +It works and it's in daily use on one phone. It is **not** on any app store: +it's a sideloaded APK, either from the release page above or built from source +below. It was built in two passes, and both are still in the repo: @@ -70,6 +90,7 @@ npm run dev # http://localhost:5173 | Command | | |---|---| +| `npm run release` | Build the signed release APK into `release/`, with its checksum | | `npm test` | The domain suite | | `npm run typecheck` | `tsc --noEmit` | | `npm run extract:data` | Regenerate the combo table from `reference/` | diff --git a/android/app/build.gradle b/android/app/build.gradle index f744046..3d83ccc 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -8,7 +8,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 - versionName "1.0" + versionName "1.0.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. @@ -20,6 +20,11 @@ android { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + // Debug key on purpose: this only ever ships as a sideloaded APK, never + // to Play. Same signature as every build so far, so a release installs + // over an existing one instead of demanding an uninstall. Using the + // built-in config keeps the path and password out of this file. + signingConfig signingConfigs.debug } } } diff --git a/package.json b/package.json index a4f4fe9..5c36395 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,11 @@ "sync:www": "bash scripts/sync-www.sh", "sync": "npm run build && npx cap sync android", "apk": "npm run sync && cd android && ./gradlew assembleDebug", + "apk:release": "npm run sync && cd android && ./gradlew assembleRelease", "apk:phase1": "npm run sync:www && npx cap sync android && cd android && ./gradlew assembleDebug", "install:apk": "adb install -r android/app/build/outputs/apk/debug/app-debug.apk", + "install:release": "adb install -r android/app/build/outputs/apk/release/app-release.apk", + "release": "node scripts/release.mjs", "device:awake": "bash scripts/device.sh awake", "device:keep-awake": "bash scripts/device.sh keep-awake", "device:sleep-normal": "bash scripts/device.sh sleep-normal", diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100644 index 0000000..e7833a7 --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +/** + * Builds the release APK and stages it under release/ under a name a stranger + * can recognise, next to the checksum to verify a download against. + * + * It verifies the signature rather than trusting that the signing config took: + * an unsigned APK builds happily and only fails at install time, on someone + * else's phone. + */ +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + +execFileSync("npm", ["run", "apk:release"], { cwd: root, stdio: "inherit" }); + +const built = join(root, "android/app/build/outputs/apk/release/app-release.apk"); +const name = `404-Layers-${version}.apk`; +const out = join(root, "release", name); + +mkdirSync(dirname(out), { recursive: true }); +copyFileSync(built, out); + +const bytes = readFileSync(out); +const sha = createHash("sha256").update(bytes).digest("hex"); +writeFileSync(`${out}.sha256`, `${sha} ${name}\n`); + +// build-tools is pinned in CLAUDE.md; a missing apksigner should fail loudly. +const sdk = process.env.ANDROID_HOME ?? join(process.env.HOME ?? "", "Android/Sdk"); +const certs = execFileSync(join(sdk, "build-tools/36.1.0/apksigner"), ["verify", "--print-certs", out], { + encoding: "utf8", +}); + +const digest = certs.match(/certificate SHA-256 digest: (\w+)/)?.[1] ?? ""; +if (digest === "") throw new Error(`apksigner reported no certificate:\n${certs}`); + +console.log(`\n${name} ${(bytes.length / 1e6).toFixed(2)} MB`); +console.log(`file sha256 ${sha}`); +console.log(`cert sha256 ${digest}`); +console.log(`\nStaged at ${out}`);