1@README.md 2 3## Notes for agents 4 5- **Same test stack as the dashboard: Roborazzi on Robolectric.** Re-record the goldens in the same commit as any change to how something looks, and look at the PNGs. Build through this repository's `mise.toml` (`mise run android:record-goldens`), not the dashboard's toolchain: it adds the NDK. See `~/dashboard/android/CLAUDE.md` ("Screenshot tests") for the traps already paid for (JDK 21 `--add-exports`, Espresso 3.7.0). 6- **Every clock in a test is fixed.** `WhiskersScreen` takes `seconds` as a parameter; never read a real clock inside the cat or a test, or the goldens drift. Pick a test time outside the blink window (`t % 4 < 0.12`) unless a blink is the point. 7- **minSdk is 30, not 37.** The target tablet runs Fire OS 8 (Android 11). The dashboard targets the newest SDK; do not copy its minSdk. Fire OS has no Google services, so add no dependency that needs them. 8- **The cat's logic is in Rust.** Which mood, where the eyes look, what counts as a poke or a spin is `crates/whiskers-pet`. Do not re-implement it here to make a screenshot easier; pass a `CatState`. 9- **The grey cat is not drawn in this directory.** It is `crates/whiskers-art`'s data, written as `art/GreyScenes.kt` (generated, gitignored) by `tools/build-core.sh`; `art/Motion.kt` and `art/Flatten.kt` play it and `FramesTest` holds them to `crates/whiskers-art/fixtures/frames.txt`. Change the art in the crate. The ginger cat is drawn by hand in `Cat.kt`. 10- **The look is a household setting** (`HouseholdSettings.catTheme`, grey by default), read by the controller about once a second into `ScreenState.theme`; never read a theme from anywhere else. 11- **No gradle wrapper.** Gradle's version is `mise.toml`'s (`mise run android:debug`); do not commit a wrapper jar. 12- **`record` and `verify` in one Gradle invocation race.** Run them as separate commands, or `verify` fails against files `record` is still writing. 13- **`jniLibs/`, `kotlin/uniffi/`, `art/GreyScenes.kt` and `art/PixelIconSet.kt` are build outputs** (gitignored), made by `tools/build-core.sh` before every Gradle build. If the Kotlin cannot find `uniffi.whiskers_ffi`, the build ran without cargo-ndk and the NDK (use `mise run android:debug`). 14- **uniffi enum values are UPPER_SNAKE in Kotlin, and an error variant field named `message` collides with `Throwable.message`** (it cost a failed build; the Rust field is `reason`). 15- **The conversation loop is tested through `Brain`, `Ears` and `Mouth` fakes, not the native library.** Keep the native library out of unit tests: Robolectric cannot load it. Anything that needs the real core is a device test. 16- **Unverified on a tablet:** speech recognition (`SystemEars`), the system camera intents, `MediaPlayer` playback of the natural voice, and the on-device voice. They compile and their pure parts are tested; none has run on a Fire tablet yet. 17- **No CAMERA permission on purpose.** Pictures come from the system camera app through an intent, which needs none. Adding the permission makes those intents fail until it is granted. 18- **Package visibility cost a whole bug class.** From Android 11 an app cannot see other apps' services unless its manifest lists them in `<queries>`. Without the `RecognitionService` and `TTS_SERVICE` entries, `isRecognitionAvailable` is false on a phone that has Google's recogniser and the app silently falls to the bundled model. Keep both entries. 19- **Cleartext is allowed app-wide** (`usesCleartextTraffic`) because the Kotlin side (`Voices`) fetches audio over plain http from the private network; the Rust core's sockets are not subject to that setting. That network encrypts. If the services ever move to https, remove it. 20- **The service address is a runtime setting, device-only.** Never add it to `HouseholdSettings` (it would sync through the service it locates), never read `BuildConfig.DEFAULT_SERVICE` anywhere but `WhiskersApp` (it is only the default), and never build a URL from host text: parse with `NativeService`, then use `ServiceAddress.endpoint`. Nothing is constructed from `ServiceConfig.Unset`; add new service-dependent objects to `Connected`, not beside it. 21- **`offline.png` is a flaky golden** (a blank first frame now and then: `verify` fails once, passes on re-run; the golden once committed was a blank page). Re-run before suspecting your change. 22- **The debug key is committed on purpose** (`android/debug.keystore`, password `android`), so updates install over earlier builds. It is a fixture, not a secret, and not a release key. `WHISKERS_KEYSTORE*` env vars replace it (all four or the build fails, so a typo never falls back to the fixture); never put a real keystore or password in the repo. 23 24## Her picture menu 25 26- **She cannot read: every entry is a picture with a spoken name.** Never add an entry, card or button that is text only. The on-screen words are for the grown-up looking on. 27- **Put away is not forgotten.** The red trash can calls `Brain.putAway` (the core's `hide`); the grown-ups' Restore calls `Parents.restore`; `forget` stays the parents' permanent act. Never wire the child's can to `forget`, and never offer her `restore`: Restore lives only behind the grown-up lock. 28- **The can must be held.** `HoldToDelete` (pure, tested) decides; a tap or a short hold only wobbles. Do not shorten `HOLD_MS` or fire on press. 29- **What is read out is the core's wording** (`Memory.aloud`, from `Fact.aloud`), after the guard when it was filed; the fixed menu lines are `MenuLines`. Do not compose a sentence about her in Kotlin. Nothing is spoken while a turn is in progress or Whiskers is asleep. 30- **Icons are `PixelIconSet` names** the core admitted (`crates/whiskers-icons/allowlist.txt`); an unknown or missing name draws the pack's star. The check and the trash can are not in the pack and are drawn in `UiArt`. 31- **`TimeLeft` is only a picture bucket** (lots, some, a little) over the minutes the core already reports; the limits themselves are the core's. 32- **Menu goldens:** the lazy grid composes after the first frame, so `menuShot` advances four; `menu-memories`, `tangled` and `offline` can still record or verify blank now and then (the known first-frame flake): look at the PNG after recording. 33 34## The offline voice 35 36- **`com/k2fsa/sherpa/onnx/Tts.kt` is vendored on purpose** (Apache-2.0, from sherpa-onnx's `kotlin-api/` at the release the library comes from). The JNI layer reads its fields by name, so it must be the same release as the `.so` files; `tools/fetch-offline-voice.sh` checks its hash. Bump both together. 37- **The int8 voice is a trap, and fp16 does not load.** On the Fire HD 10 Kids (MT8169) the int8 Piper model takes 11 to 14 s to load and runs no faster than real time; the fp32 one loads in about 4 s and runs at a fifth of real time; sherpa-onnx 1.13.8 refuses the fp16 one with a Cast type error. Do not "save space" by switching models without timing it on the tablet. Measured 2026-10-04. 38- **A big `AudioTrack` buffer delays the first sound.** A normal track starts only once its whole buffer is full, so the 8 s buffer first tried made the Fire tablet wait 2.3 s before speaking (measured 2026-10-04); the buffer is under half a second and a second thread (`offline-voice-out`) does the blocking writes. The last piece is played out by `stop()`, since a short one never fills the buffer. 39- **Synthesis and playback run on one worker thread (`OfflineVoice`)**; the model is touched only there. The native library is never loaded in unit tests: `SpeechSynth` is the seam. 40- A build without the fetched files compiles and runs; `OfflineVoice.available()` is then false and `Voices` goes straight to the tablet voice. 41 42## Logging 43 44Standing requirement: bugs on the tablet must be debuggable from `adb logcat` alone. Every function that decides, waits, fails or talks to something outside the process logs through `Wlog` (`Log.kt`), never `android.util.Log` directly. `Wlog` swallows the stub's exception, so plain JVM unit tests are safe. 45 46- **Tags** (`Area`): `Whiskers/Main`, `/Controller`, `/Ears`, `/Vosk`, `/Mouth`, `/Brain`, `/Sync`, `/Parents`, `/Pictures`, `/Rules` (time limits, quiet hours, PIN, gate). 47- **Levels:** `v` per-frame or per-touch noise; `d` ordinary steps (start/ready/done, durations, counts); `i` state changes worth reading in a story (phase, mic mode, engine chosen, permission and picker results, HTTP 200 with latency and bytes); `w` degraded but handled (fallback to the tablet voice, sync failed, breaker opened, wrong gate answer); `e` something that should not happen, with the throwable so the stack trace prints. 48- **A caught exception is always logged.** No `catch (_: Exception)` and no bare `runCatching` that drops the failure: add `.onFailure { Wlog.w(...) }`. Rethrow `CancellationException`, and log it at `d` at most. 49- **NEVER log content.** Nothing the child or Whiskers said, not the child's name, no memory facts, no PIN or hash, no picture bytes, no keys, no URIs' paths. Log lengths, counts, ids, durations, codes and the uri scheme. 50- **Recipe:** `adb logcat -s 'Whiskers/*'` is not valid tag syntax on every adb; use `adb logcat | grep Whiskers/` or `adb logcat -v time --pid=$(adb shell pidof app.whiskers)`. 51- The core's `initLogging()` is not called yet: the uniffi bindings had no such symbol when this was written. Call it once in `WhiskersApp.onCreate` when `whiskers-ffi` exports it.