For agents, on top of README.md, which they read first.
Notes for agents
- 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. Use this repository's devshell (
nix develop ~/whiskers#android), not the dashboard's: 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). - Every clock in a test is fixed.
WhiskersScreentakessecondsas 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. - 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.
- 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 aCatState. - The grey cat is not drawn in this directory. It is
crates/whiskers-art's data, written asart/GreyScenes.kt(generated, gitignored) bytools/build-core.sh;art/Motion.ktandart/Flatten.ktplay it andFramesTestholds them tocrates/whiskers-art/fixtures/frames.txt. Change the art in the crate. The ginger cat is drawn by hand inCat.kt. - The look is a household setting (
HouseholdSettings.catTheme, grey by default), read by the controller about once a second intoScreenState.theme; never read a theme from anywhere else. - No gradle wrapper. Gradle comes from the nix devshell; do not commit a wrapper jar.
recordandverifyin one Gradle invocation race. Run them as separate commands, orverifyfails against filesrecordis still writing.jniLibs/,kotlin/uniffi/,art/GreyScenes.ktandart/PixelIconSet.ktare build outputs (gitignored), made bytools/build-core.shbefore every Gradle build. If the Kotlin cannot finduniffi.whiskers_ffi, the build ran outside the whiskers devshell.- uniffi enum values are UPPER_SNAKE in Kotlin, and an error variant field named
messagecollides withThrowable.message(it cost a failed build; the Rust field isreason). - The conversation loop is tested through
Brain,EarsandMouthfakes, 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. - Unverified on a tablet: speech recognition (
SystemEars), the system camera intents,MediaPlayerplayback 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. - 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.
- 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 theRecognitionServiceandTTS_SERVICEentries,isRecognitionAvailableis false on a phone that has Google's recogniser and the app silently falls to the bundled model. Keep both entries. - 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. - The service address is a runtime setting, device-only. Never add it to
HouseholdSettings(it would sync through the service it locates), never readBuildConfig.DEFAULT_SERVICEanywhere butWhiskersApp(it is only the default), and never build a URL from host text: parse withNativeService, then useServiceAddress.endpoint. Nothing is constructed fromServiceConfig.Unset; add new service-dependent objects toConnected, not beside it. offline.pngis a flaky golden (a blank first frame now and then:verifyfails once, passes on re-run; the golden once committed was a blank page). Re-run before suspecting your change.- The debug key is committed on purpose (
android/debug.keystore, passwordandroid), 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.
Her picture menu
- 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.
- Put away is not forgotten. The red trash can calls
Brain.putAway(the core'shide); the grown-ups' Restore callsParents.restore;forgetstays the parents' permanent act. Never wire the child's can toforget, and never offer herrestore: Restore lives only behind the grown-up lock. - The can must be held.
HoldToDelete(pure, tested) decides; a tap or a short hold only wobbles. Do not shortenHOLD_MSor fire on press. - What is read out is the core's wording (
Memory.aloud, fromFact.aloud), after the guard when it was filed; the fixed menu lines areMenuLines. Do not compose a sentence about her in Kotlin. Nothing is spoken while a turn is in progress or Whiskers is asleep. - Icons are
PixelIconSetnames 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 inUiArt. TimeLeftis only a picture bucket (lots, some, a little) over the minutes the core already reports; the limits themselves are the core's.- Menu goldens: the lazy grid composes after the first frame, so
menuShotadvances four;menu-memories,tangledandofflinecan still record or verify blank now and then (the known first-frame flake): look at the PNG after recording.
The offline voice
com/k2fsa/sherpa/onnx/Tts.ktis vendored on purpose (Apache-2.0, from sherpa-onnx'skotlin-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.sofiles;tools/fetch-offline-voice.shchecks its hash. Bump both together.- 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.
- A big
AudioTrackbuffer 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 bystop(), since a short one never fills the buffer. - Synthesis and playback run on one worker thread (
OfflineVoice); the model is touched only there. The native library is never loaded in unit tests:SpeechSynthis the seam. - A build without the fetched files compiles and runs;
OfflineVoice.available()is then false andVoicesgoes straight to the tablet voice.
Logging
Standing 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.
- Tags (
Area):Whiskers/Main,/Controller,/Ears,/Vosk,/Mouth,/Brain,/Sync,/Parents,/Pictures,/Rules(time limits, quiet hours, PIN, gate). - Levels:
vper-frame or per-touch noise;dordinary steps (start/ready/done, durations, counts);istate changes worth reading in a story (phase, mic mode, engine chosen, permission and picker results, HTTP 200 with latency and bytes);wdegraded but handled (fallback to the tablet voice, sync failed, breaker opened, wrong gate answer);esomething that should not happen, with the throwable so the stack trace prints. - A caught exception is always logged. No
catch (_: Exception)and no barerunCatchingthat drops the failure: add.onFailure { Wlog.w(...) }. RethrowCancellationException, and log it atdat most. - 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.
- Recipe:
adb logcat -s 'Whiskers/*'is not valid tag syntax on every adb; useadb logcat | grep Whiskers/oradb logcat -v time --pid=$(adb shell pidof app.whiskers). - The core's
initLogging()is not called yet: the uniffi bindings had no such symbol when this was written. Call it once inWhiskersApp.onCreatewhenwhiskers-ffiexports it.