whiskers.git / android / README.md
README.mdpreviewREADME.mdsource161 lines · 11.3 KB · raw

android

The Fire tablet shell: a Kotlin and Jetpack Compose app that draws Whiskers and listens to the child. It draws what the Rust core in ../crates decides and holds no conversation logic of its own.

The screen is a live voice screen in the style of a voice assistant: Whiskers in the middle of a black screen, a microphone button, a camera button and a goodnight button along the bottom, and subtitles for what Whiskers says. The eyes follow a finger anywhere on the screen. A quick tap on the cat tickles it, two fast circles round it make it dizzy, and the face changes with the conversation: listening, thinking, juggling while it gets ready, a sad cloud when offline, speaking.

PathWhat
app/src/main/kotlin/.../Cat.ktThe cat, drawn on a 15 x 16 pixel grid, one pose per mood, animated by a clock passed in. The household chooses the look (CatTheme): grey is played from art/ (data generated from crates/whiskers-art), ginger is drawn here.
app/src/main/kotlin/.../WhiskersScreen.ktThe screen, the buttons and the touch capture.
app/src/main/kotlin/.../CatState.ktThe state the core hands over (mirrors whiskers_pet::PetView).
app/src/test/.../ScreenshotTest.ktOne screenshot per state, off-device.
app/screenshots/The goldens. They are the pictures below.

Screenshots

These are the screenshot tests' goldens, so they are always the current look.

idlelisteningthinkingworking
speakingdizzytickledsleeping
juggling (busy)offlinelong linemic open
tangledlooking at a fingermic off, sending a pictureheld upright

The goldens are sized for a Fire HD 10 Kids tablet: 1920 x 1200 at 224 ppi, landscape.

Parents' screens

Hold the goodnight button to reach the parents' side, behind a multiplication a young child cannot do. It shows today's conversations with anything that needs a grown-up first, why the safety check stopped anything, pictures the child showed Whiskers, what Whiskers remembers (each with a Forget button) and a note for the day written by the model. The About your child card at the top takes the child's name and age (the age only from the supported range, the name checked by the core); Whiskers' greeting, persona and the guard's judging follow it, and the card says so while it is empty. Look of the cat shows the two cats, grey (the default) and ginger, as small pictures; the choice is a household setting, so it reaches every device with the next sync.

parents' viewwith the day's notethe gate

How a conversation runs

Controller runs it. The microphone starts off. Hold the microphone button, or hold the cat, to talk and let go to send; tap the microphone to leave it open and tap again to close it. Until the recogniser is really listening the cat juggles ("getting ready"), then perks up its ears; it thinks, then speaks. If it cannot reach its cloud it shows a sad face and a crossed-out cloud. Working out what to remember happens in the background (a small "writing in my journal" badge) and never holds up the next turn. Whiskers greets the child only the first time or after a long quiet: the conversation lives in the process (WhiskersApp), so folding or unfolding the phone changes nothing, and leaving the screen stops listening and talking.

Captions are lyrics: the words appear when the sound starts, the spoken part is bright, a ball hops from syllable to syllable, and a long line scrolls. The natural voice supplies the time of every character (Lyrics.kt); the tablet's voice supplies word positions (WordClock.kt); the offline voice below is paced by the length of each sentence it has made (LineTimeline in OfflineSpeech.kt).

PathWhat
Controller.ktThe loop, tested in ControllerTest.kt with fake ears, mouth and brain.
Brain.kt, EngineBrain.ktWhat the shell needs from the core, and the UniFFI-backed implementation.
Ears.kt, Mouth.kt, WordClock.kt, Lyrics.ktThe microphone, the speaker, and the caption clocks.
WhiskersApp.ktHolds the controller for the life of the process.
ServiceConfig.kt, ServiceSetup.ktWhere the service is: the typed address, Unset/Set, the hub that rebuilds what depends on it, the first-run card and its rules.
Pictures.kt, Sounds.ktCamera files turned into JPEGs; the poke and dizzy sounds.
ParentView.kt, ParentScreen.kt, ParentGate.kt, EngineParents.ktThe parents' side.

Build

One devshell for everything, the repository's own (it has the NDK and cargo-ndk as well as Gradle). Gradle builds the Rust core and its Kotlin bindings first (tools/build-core.sh):

nix develop ~/whiskers#android -c bash -c 'cd android && gradle :app:recordRoborazziDebug'   # rewrite the goldens
nix develop ~/whiskers#android -c bash -c 'cd android && gradle :app:verifyRoborazziDebug'   # fail on drift
nix develop ~/whiskers#android -c bash -c 'cd android && gradle :app:assembleDebug'          # the APK

Configuration

  • The service's address is a runtime setting, not a build input. The address of the machine running whiskersd (host or host:port, port 47900 by default, plain http because the private network or VPN is what encrypts) is typed by a grown-up in the parents' side and kept on the device only (it cannot sync through the service it locates). A build needs no address, so a release APK can be shared: a downloaded app starts unconfigured (see "First run" below).
  • -Pwhiskers.host=<host[:port]> is optional and only the default for that setting: the owner's own builds, and installs made with it, keep working with no typing. An address saved in the app always wins over the build default. Put whiskers.host=... in ~/.gradle/gradle.properties to keep using it.
  • -Pwhiskers.versionName=2026.10.5 and -Pwhiskers.versionCode=2026100501 set the release version (default for dev builds: 0.1 and 1). The name is a calendar version with no zero padding on month or day. A release's code is yyyymmdd followed by a two-digit build of the day (01, 02, ...), so a later build always has a larger code and installs over the last. The application id default is unchanged.
  • Signing: by default the APK is signed with the debug key committed beside the project (android/debug.keystore, password android): a fixture, not a secret, so anyone who clones can build and update in place. To sign with your own key set WHISKERS_KEYSTORE, WHISKERS_KEYSTORE_PASSWORD, WHISKERS_KEY_ALIAS and WHISKERS_KEY_PASSWORD (all four, or the build fails). An app signed with one key cannot be updated in place by an APK signed with another: uninstall first. What the service keeps comes back with the next sync; what is only on the device (the service address) is typed again.
  • -Pwhiskers.offlineVoice=false builds without the offline voice (the lite variant, about 90 MB smaller).
  • -Pwhiskers.applicationId=<id> changes the application id (default app.whiskers).
  • -Pwhiskers.abis=arm64-v8a,x86_64 adds CPU types, for an emulator.
  • Install with tools/install-android.sh (see its header for ADB, ANDROID_SERIAL, WHISKERS_ANDROID_USER for a tablet with several profiles, and the Windows-adb staging variables).

Running the service for a demo

The device talks to one service, whiskersd, so it needs access to that network and nothing else: no keys on the device.

  1. On the service machine: WHISKERS_ADDR=<private-ip>:47900 WHISKERS_MODEL_URL=<gateway> tools/run-whiskersd.sh, then tools/preflight.sh <private-ip>:47900. It must end with ready. A WARN on /speak only means the device uses its own voice.
  2. Install with tools/install-android.sh; grant the microphone when asked.
  3. The natural voice (optional): put ELEVENLABS_API_KEY and ELEVENLABS_VOICE_ID in the environment whiskersd runs with (WHISKERS_WRAP in tools/run-whiskersd.sh can supply them from a secret manager), then run the script again.
  4. After the day's voice allowance, Whiskers says its big voice is resting and carries on in the device's voice.
  5. First run: a build without a default address starts unconfigured: Whiskers says "Ask a grown-up to finish setting me up" (in the tablet's voice) to anything the child does. Hold the goodnight (X) button, pass the grown-up lock, and the first-run card asks for the address. Check asks the service (POST /usage, which changes nothing; the service has no separate health route) and says plainly whether it was found; Save is offered after a successful check, and Save anyway after one that failed. The address is switched to at once, with no reinstall. Then fill in About your child. Later the address is the Whiskers' service row in the parents' side (Change address).
first runcannot be reachedfound

The address is validated by the Rust core (whiskers-core's ServiceAddress: trims, accepts a bare host, host:port or http://host[:port], refuses https with an explanation, paths, queries, credentials and anything that is not a plausible host); the shell never builds a URL, it asks the address for its endpoints. Unset is a typed state (ServiceConfig.Unset), and the engine, parents' side and natural voice are built only from a set one (ServiceHub in ServiceConfig.kt).

On a phone the app uses Google's speech recogniser (the bundled offline one is the fallback for the Fire tablet).

The offline voice

When the natural voice cannot be had (allowance spent, no network) Whiskers speaks with a small neural voice that lives in the app, so it is never silent: Piper's en_US-ljspeech-medium (full precision, 63.5 MB), run by sherpa-onnx 1.13.8, arm64 only. A line is tried natural, then this, then the tablet's own engine (only if it really spoke), then paced captions. tools/fetch-offline-voice.sh fetches the library and the voice (pinned by hash; gradle runs it before every build, and a build where it fails simply does not offer the voice). The model loads on the first line (about 4 s on a Fire tablet), speaks at about a fifth of real time, a sentence at a time so the first is heard within a second, and is freed after two idle minutes. Licences are in NOTICE.md. Adds about 85 MB to the APK. To hear it without the natural voice on a debug build: adb shell am broadcast -a <applicationId>.DEBUG_SAY -p <applicationId> --ez natural false --es text "..." (add --user N on a multi-profile tablet) (--ez natural true turns the natural voice back on).