Ship a signed release APK

The release build type reuses the debug signing config, so a downloaded APK
installs over an existing one instead of demanding an uninstall. This never
goes to Play, which rejects debug keys anyway, so there is no private key
worth safeguarding for years and no way to strand anyone on a version that
can no longer be updated.

That choice is load-bearing in one direction: a release build is not
DEBUGGABLE, so adb shell run-as stops reading app data back out, and there is
no in-app restore. A signature change would have cost the drill progress.

npm run release stages release/404-Layers-<version>.apk with a checksum and
verifies the signature, since an unsigned APK builds fine and only fails at
install time, on someone else's phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 21:51:01 +03:00
parent 09feb899d6
commit 1d2cbda50d
6 changed files with 103 additions and 6 deletions

3
.gitignore vendored
View File

@@ -20,6 +20,9 @@ android/local.properties
android/.idea/ android/.idea/
*.apk *.apk
*.aab *.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 # 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 # 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. # landed in the tree. Signing keys belong in ~/.android, not in the repo.

View File

@@ -172,9 +172,29 @@ now empty and bar styling is configured in `capacitor.config.json`
**No community plugins.** Only first-party: `@capacitor/core`, `/cli`, **No community plugins.** Only first-party: `@capacitor/core`, `/cli`,
`/android`, `/haptics`, `/preferences`, `/app`. `/android`, `/haptics`, `/preferences`, `/app`.
**Debug signing.** The debug keystore is stable per-machine, so Phase 2's APK **Debug signing, releases included.** The debug keystore is stable per-machine,
installs over Phase 1's without an uninstall and drill progress survives. so Phase 2's APK installs over Phase 1's without an uninstall and drill progress
`appId` is `dev.ncode.sp404layers` and must not change for the same reason. 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-<version>.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 ## 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 test:watch` | Watch mode |
| `npm run typecheck` | `tsc --noEmit` | | `npm run typecheck` | `tsc --noEmit` |
| `npm run extract:data` | Regenerate the combo table from `reference/` | | `npm run extract:data` | Regenerate the combo table from `reference/` |
| `npm run release` | Build the signed release APK into `release/`, with checksum |
``` ```
src/domain/ src/domain/

View File

@@ -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 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. 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 ## What it does
| Tab | | | Tab | |
@@ -32,8 +51,9 @@ the combos you keep missing keep coming back. Progress is kept on the device.
## Status ## Status
It works and it's in daily use on one phone. It is **not** on any app store: it's It works and it's in daily use on one phone. It is **not** on any app store:
a debug-signed APK, sideloaded over `adb`. 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: It was built in two passes, and both are still in the repo:
@@ -70,6 +90,7 @@ npm run dev # http://localhost:5173
| Command | | | Command | |
|---|---| |---|---|
| `npm run release` | Build the signed release APK into `release/`, with its checksum |
| `npm test` | The domain suite | | `npm test` | The domain suite |
| `npm run typecheck` | `tsc --noEmit` | | `npm run typecheck` | `tsc --noEmit` |
| `npm run extract:data` | Regenerate the combo table from `reference/` | | `npm run extract:data` | Regenerate the combo table from `reference/` |

View File

@@ -8,7 +8,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1 versionCode 1
versionName "1.0" versionName "1.0.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -20,6 +20,11 @@ android {
release { release {
minifyEnabled false minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 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
} }
} }
} }

View File

@@ -8,8 +8,11 @@
"sync:www": "bash scripts/sync-www.sh", "sync:www": "bash scripts/sync-www.sh",
"sync": "npm run build && npx cap sync android", "sync": "npm run build && npx cap sync android",
"apk": "npm run sync && cd android && ./gradlew assembleDebug", "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", "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: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:awake": "bash scripts/device.sh awake",
"device:keep-awake": "bash scripts/device.sh keep-awake", "device:keep-awake": "bash scripts/device.sh keep-awake",
"device:sleep-normal": "bash scripts/device.sh sleep-normal", "device:sleep-normal": "bash scripts/device.sh sleep-normal",

44
scripts/release.mjs Normal file
View File

@@ -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}`);