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.
| Path | What |
|---|---|
app/src/main/kotlin/.../Cat.kt | The 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.kt | The screen, the buttons and the touch capture. |
app/src/main/kotlin/.../CatState.kt | The state the core hands over (mirrors whiskers_pet::PetView). |
app/src/test/.../ScreenshotTest.kt | One 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.
| idle | listening | thinking | working |
|---|---|---|---|
![]() | ![]() | ![]() | ![]() |
| speaking | dizzy | tickled | sleeping |
|---|---|---|---|
![]() | ![]() | ![]() | ![]() |
| juggling (busy) | offline | long line | mic open |
|---|---|---|---|
![]() | ![]() | ![]() | ![]() |
| tangled | looking at a finger | mic off, sending a picture | held upright |
|---|---|---|---|
![]() | ![]() | ![]() | ![]() |
The goldens are sized for a Fire HD 10 Kids tablet: 1920 x 1200 at 224 ppi, landscape.
Her menu: what Whiskers remembers, in pictures
A pixel button of the cat at the top right opens a side panel from the right with big picture entries (each has a spoken name; the words are for the grown-up looking on). Things I told you is a grid of memory cards: each shows the pictures she showed Whiskers about it, if any, and the pixel icon the service chose for it (a star until one is chosen). Tapping a card opens it large and reads it out with the lyric caption, in the words of her profile. At the bottom of an open card a green check keeps it (it just closes), and a red trash can puts it away, but only when held (a fill rises through it; a tap only wobbles it). A memory she puts away disappears from her grid and Whiskers stops using it; it is not forgotten: the grown-ups see it under Removed by her and can Restore it. Time left says how much of the day is left as lots, some or a little (stars, no numbers).
| the button | the panel | what Whiskers remembers | an open card |
|---|---|---|---|
![]() | ![]() | ![]() | ![]() |
| holding the trash can | after a tap | nothing yet | on a phone |
|---|---|---|---|
![]() | ![]() | ![]() |
| the grown-ups' list |
|---|
![]() |
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; what she put away is listed apart under Removed by her with Restore) 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' view | with the day's note | the 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).
| Path | What |
|---|---|
Controller.kt | The loop, tested in ControllerTest.kt with fake ears, mouth and brain. |
Brain.kt, EngineBrain.kt | What the shell needs from the core, and the UniFFI-backed implementation. |
Ears.kt, Mouth.kt, WordClock.kt, Lyrics.kt | The microphone, the speaker, and the caption clocks. |
WhiskersApp.kt | Holds the controller for the life of the process. |
ServiceConfig.kt, ServiceSetup.kt | Where 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.kt | Camera files turned into JPEGs; the poke and dizzy sounds. |
Memories.kt, MemoryMenu.kt, PixelArt.kt | Her menu: the memory model and the hold-to-put-away gesture; the panel, grid, cards and buttons; the pixel icons (art/PixelIconSet.kt is generated from crates/whiskers-icons) and the check and trash art drawn here. |
ParentView.kt, ParentScreen.kt, ParentGate.kt, EngineParents.kt | The 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(hostorhost: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. Putwhiskers.host=...in~/.gradle/gradle.propertiesto keep using it.-Pwhiskers.versionName=2026.10.5and-Pwhiskers.versionCode=2026100501set the release version (default for dev builds:0.1and1). The name is a calendar version with no zero padding on month or day. A release's code isyyyymmddfollowed 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, passwordandroid): a fixture, not a secret, so anyone who clones can build and update in place. To sign with your own key setWHISKERS_KEYSTORE,WHISKERS_KEYSTORE_PASSWORD,WHISKERS_KEY_ALIASandWHISKERS_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=falsebuilds without the offline voice (the lite variant, about 90 MB smaller).-Pwhiskers.applicationId=<id>changes the application id (defaultapp.whiskers).-Pwhiskers.abis=arm64-v8a,x86_64adds CPU types, for an emulator.- Install with
tools/install-android.sh(see its header forADB,ANDROID_SERIAL,WHISKERS_ANDROID_USERfor 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.
- On the service machine:
WHISKERS_ADDR=<private-ip>:47900 WHISKERS_MODEL_URL=<gateway> tools/run-whiskersd.sh, thentools/preflight.sh <private-ip>:47900. It must end withready. AWARNon/speakonly means the device uses its own voice. - Install with
tools/install-android.sh; grant the microphone when asked. - The natural voice (optional): put
ELEVENLABS_API_KEYandELEVENLABS_VOICE_IDin the environmentwhiskersdruns with (WHISKERS_WRAPintools/run-whiskersd.shcan supply them from a secret manager), then run the script again. - After the day's voice allowance, Whiskers says its big voice is resting and carries on in the device's voice.
- 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 run | cannot be reached | found |
|---|---|---|
![]() | ![]() | ![]() |
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).





























