Compare commits

..

2 Commits

Author SHA1 Message Date
1d2cbda50d 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>
2026-09-17 21:51:01 +03:00
09feb899d6 Drop the Design section from the README
It described the visual language rather than telling a reader anything
they need in order to build, run or understand the app. The fonts it
mentioned are still covered by public/fonts/README.md and the licence
section, so nothing is orphaned by removing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 21:35:36 +03:00
6 changed files with 103 additions and 17 deletions

3
.gitignore vendored
View File

@@ -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.

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`,
`/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-<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
@@ -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/

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
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/` |
@@ -127,17 +148,6 @@ testable, and immediately surfaced a real bug in the weighted pick.
`CLAUDE.md` holds the fuller engineering notes, including a number of
Android-specific traps that cost real time.
## Design
The look follows Teenage Engineering's EP-133 manual: flat fills, heavy outlines,
hard zero-blur shadows, one orange accent that means exactly one thing (this
control is live), and monospaced technical annotation. The panel is laid out from
the hardware — the control groups carry the same silkscreen captions as the
device, and the screen is a circular housing behind a rectangular LCD.
Type is Space Grotesk and Space Mono, self-hosted so the app works with no
network.
## Data
The combo data is derived from the SP-404MK2 reference manual (v5.50): control

View File

@@ -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
}
}
}

View File

@@ -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",

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