1# android 2 3The Fire tablet shell: a Kotlin and Jetpack Compose app that draws Whiskers and 4listens to the child. It draws what the Rust core in `../crates` decides and holds no 5conversation logic of its own. 6 7The screen is a live voice screen in the style of a voice assistant: Whiskers in 8the middle of a black screen, a microphone button, a camera button and a goodnight 9button along the bottom, and subtitles for what Whiskers says. The eyes follow a 10finger anywhere on the screen. A quick tap on the cat tickles it, two fast circles 11round it make it dizzy, and the face changes with the conversation: listening, 12thinking, juggling while it gets ready, a sad cloud when offline, speaking. 13 14| Path | What | 15|---|---| 16| `app/src/main/kotlin/.../Cat.kt`, `CharacterArt.kt`, `art/` | Where a character sits on the screen (`CatGeometry`), and how any of the thirteen is drawn: `art/ArtData.kt` reads `art.txt` (every character in every mood and every motion, generated from `crates/whiskers-art`), `art/Flatten.kt` carries a scene through its groups' motions to the rectangles at an instant, and `CharacterArt.kt` paints them, with a pack character's picture in its place. There is no per-character Kotlin. The character is the id the core gives. | 17| `app/src/main/kotlin/.../WhiskersScreen.kt` | The screen, the buttons and the touch capture. | 18| `app/src/main/kotlin/.../CatState.kt` | The state the core hands over (mirrors `whiskers_pet::PetView`). | 19| `app/src/test/.../ScreenshotTest.kt`, `Golden.kt` | One screenshot per state, off-device; `captureGolden` captures only a root that has really been drawn (a frame whose drawing was lost is invalidated and drawn again, never recorded blank). | 20| `app/screenshots/` | The goldens. They are the pictures below. | 21 22## Screenshots 23 24These are the screenshot tests' goldens, so they are always the current look. 25 26| idle | listening | thinking | working | 27|---|---|---|---| 28|  |  |  |  | 29 30| speaking | dizzy | tickled | sleeping | 31|---|---|---|---| 32|  |  |  |  | 33 34| juggling (busy) | offline | long line | mic open | 35|---|---|---|---| 36|  |  |  |  | 37 38| tangled | looking at a finger | mic off, sending a picture | held upright | 39|---|---|---|---| 40|  |  |  |  | 41 42The goldens are sized for a Fire HD 10 Kids tablet: 1920 x 1200 at 224 ppi, landscape. 43 44## Her menu: what Whiskers remembers, in pictures 45 46A pixel button of the cat at the top right opens a side panel from the right with big picture entries (each 47has a spoken name; the words are for the grown-up looking on). **Memories** is a grid of memory 48cards, cut into days by a divider, one card for each day she said a memory (so one she tells again on another day is on that day too, with the same picture, and twice on one day is one card; the core's `memory_days` places them from the moments the shell passes, one for each telling), square, three to a row on a phone and more on a larger screen: a card with pictures shows one of them, 49filling the square, and no icon; a card with none shows its pixel icon (a star until the service chooses one) 50large and centred. When a memory has more than one picture, the open card shows them all with a star on each, 51and tapping a star makes that picture the card's (the child may; the core keeps the choice and syncs it, and 52with none chosen the first picture is the card's). Tapping a card opens it large and reads it out with the lyric caption, in the 53words of her profile. At the bottom of an open card a green check keeps it (it just closes), and a red trash can 54puts it away, but only when **held** (a fill rises through it; a tap only wobbles it). A memory she puts away 55disappears from her grid and Whiskers stops using it; it is **not** forgotten: the grown-ups see it under 56**Removed by her** and can **Restore** it. **Time left** shows the day's time as three hearts, the pixel heart containers of A Link to the Past, each in five states 57(full, three quarters, half, a quarter, empty) and depleted from the right a quarter at a time; the core counts the quarters 58(`whiskers_core::heart_quarters`). With no daily limit there is nothing to deplete, so no hearts are drawn (a row of full ones would say 59a limit was there). Tapping it opens a page with the hearts large and in words: about how many minutes are left, the day's allowance, 60when quiet time starts ("in about 2 and a half hours", never a clock time) and that the hearts fill up again tomorrow morning. It has 61no controls and never shows the PIN; the arrow goes back to the panel. 62 63| the hearts in every state | the hearts' page | no limit | time is up | on a phone | 64|---|---|---|---|---| 65|  |  |  |  |  | 66 67**The voice battery.** Beside the hearts a pixel battery (the pack's `battery-N-6` icons, six bars) shows what is left of today's natural-voice allowance, with no number: full, half, low (one red bar), empty. Empty means the cat will use the plain voice on the device, which the hearts' page says in a sentence under a larger battery. The figures are the service's `/usage` (`daily_cap`, `daily_spent`), asked about once a minute off the time counter's thread (`Controller`); the core's mapping from them to bars is `VoiceBattery.of` (a bar shows while any of its sixth is left; a voice the service lacks counts as empty). Offline it keeps the last figure it read, and before the first one it shows a dim full battery, never an error. It is drawn on phones and tablets alike and is never a number or a word about the child. 68 69| every state | the panel, half | empty | low | not known yet | no limit | on a phone | its page | page, empty, on a phone | 70|---|---|---|---|---|---|---|---|---| 71|  |  |  |  |  |  |  |  |  | 72 73The cards are cut into her local days, newest first, by a divider across the whole grid: "Today", "Yesterday", the weekday for the rest of the week, then the date ("July 15"), and a holiday's own name when the day is one (with the holiday beside "Today" and "Yesterday"). The core decides the day and the name (`whiskers_core::days`); the shell supplies only the offset of her time zone at the moment each memory was learned. On the right edge a date scroller comes out while the grid moves: drag the thumb to any date, the day shows beside it, and each holiday has its pixel icon on the track (the thumb lands on it when it gets close). It works from the same list of days as the dividers, never from pixel heights. 74 75| the button | the panel | what Whiskers remembers | an open card | 76|---|---|---|---| 77|  |  |  |  | 78 79**Undo.** Putting a memory away shows a small pixel toast at the bottom of the screen, an arrow back and the word "Undo", for eight seconds (the core's `UNDO_WINDOW_MS`); the fixed line "Undo" is spoken as it appears, and tapping it brings the memory back. The parents' permanent forget is never undoable and shows no toast. 80 81| the toast over the grid | on a phone | below an open card | and on a phone | 82|---|---|---|---| 83|  |  |  |  | 84 85| holding the trash can | after a tap | nothing yet | on a phone | 86|---|---|---|---| 87|  |  |  |  | 88 89| the grown-ups' list | an open card with several pictures | the date scroller, on a holiday | 90|---|---|---| 91|  |  |  | 92 93## Choosing who Whiskers is 94 95The side panel's **Character** entry (it shows the character as she is now) takes her to the normal Whiskers screen in an edit mode: the character large with a pixel arrow on each side (the one before and the one after, wrapping round), and under it, where the subtitles usually are, the name of the character's voice with its own pair of arrows. The microphone, camera, pictures and close buttons are replaced by a green check, which keeps the shown character and voice, and an X, which throws the change away; nothing is saved before the check. Changing the character says "Hi Ada" in that character's own voice; choosing a voice says "Hi Ada, this voice is called Jennifer" in that voice, with the ball hopping over the syllables as captions do everywhere. The rules (what each tap means, what is said, what is saved) are the core's, twenty of them on the shared Rete engine in `crates/whiskers-chooser`; `Controller` only sends the input, shows the view and does the effects (`ChooserScreen.kt` draws it). The voice names come from the service's `/voices` through the engine, read when the chooser opens; if the list cannot be read the voice row shows the voice she has, the voice arrows are dimmed, and the log says why. A character with no voice of its own speaks in the household's base voice, never another character's. 96 97| the character | a voice introducing itself | list unreadable | a drawn cat | the bunny | on a phone | 98|---|---|---|---|---|---| 99|  |  |  |  |  |  | 100 101All thirteen characters (the grey cat, the ginger cat and the bunny, in that order, then the ten of the pack) are drawn from the same data (`crates/whiskers-art`, read from `art.txt`) and have the same features: the same body motion in each mood (a bob when listening, a slow tilt when thinking, squash and stretch driven by the mouth when speaking, a droop when sad, a spin when dizzy, a shake when poked, lying down when asleep, a slow breath at rest), the same mouth in four shapes that follows the voice (a cat's, a bunny's, and for a pack character one that suits the animal: cells under a nose, or a beak that drops), and the same shadow, sound waves, thought bubble, stars, hearts, balls, sweat, crossed cloud and z's round them. A drawn character has its own pose for each of the cat's moods and blinks and looks about; a pack character is one 9 by 9 picture moved by that same body and given a mouth over its face. A pack picture is drawn at a whole number of device pixels to each of its own with no smoothing (`CharacterArt.kt`). 102 103Each character's moods and mouth shapes, one picture each (the second row is the four shapes of the mouth, and the beat), and all thirteen together: 104 105  106 107| | | | 108|---|---|---| 109|  |  |  | 110|  |  |  | 111|  |  |  | 112|  |  |  | 113|  | | | 114 115## The speech cache 116 117Every line the app speaks whose words are decided in code (page names, the time-left lines, the undo toast, the greetings, the goodnight, the chooser's lines) is asked for as **fixed**: the device's cache is looked in first (`SpeechSource`, over the engine's `speech_find`, so a hit needs no network), then the service (`/speak/v2` with `voice` and `fixed`), and what the service sent is kept (`speech_keep_fixed`), so a line costs voice credits once ever. A memory read aloud and a model's reply are never fixed. When the check saves a voice, the service is asked in the background to pre-render the core's fixed lines and the app's own (`AppLines`) in it (`/speak/prerender`), stopping quietly where the day's allowance would be crossed. 118 119## Parents' screens 120 121Hold the goodnight button to reach the parents' side, behind a multiplication a young child 122cannot do. It shows today's conversations with anything that needs a grown-up first, why the 123safety check stopped anything, pictures the child showed Whiskers, what Whiskers remembers (each 124with 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** 125card at the top takes the child's name and age (the age only from the supported range, the 126name checked by the core); Whiskers' greeting, persona and the guard's judging follow it, and 127the card says so while it is empty. Which character Whiskers is, and its voice, are the child's alone: the parents' side has no control for either (see "Choosing who Whiskers is"). 128 129| parents' view | with the day's note | the gate | 130|---|---|---| 131|  |  |  | 132 133**The log, a page of days at a time.** The parents' side reads today, then the days before it seven at a time: the log ends with a **Load earlier days** row that asks for the next seven (`Parents.logPage`, over the core's `log_days` and a `digest` for each day; the row says "Loading…" while a page is on its way and is gone when the log goes no further back). A day whose lines the retention setting has emptied is still listed and says how many lines were emptied, so a day is never silently shorter. 134 135**Keeping the log.** A **Keeping the log** row offers Everything (the default), 30, 90 or 365 days (a number another device set is shown too, rather than hidden), and says what it does: lines older than that are emptied, never silently, and forgetting a memory is separate. It is one of the household settings the other rows save (`HouseholdSettings.logRetentionDays`; `EngineParents.setSettings` makes the core's `set_log_retention_days` call when the choice changed, which also empties what is now too old). 136 137**A turn being answered, and a turn that was cut off.** The digest says per turn whether it is being answered now or was cut off (`Exchange.in_progress`, `cut_off`). An in-progress turn is drawn **ANSWERING NOW** with a line saying it is not a failure, is counted apart ("1 answering now"), and the screen looks again every five seconds while any is; a cut-off turn is drawn **STOPPED**. The **NEEDS A GROWN-UP** flag and its red card stay on either. 138 139| keeping the log | a page of days with the load-more row | answering now | cut off | 140|---|---|---|---| 141|  |  |  |  | 142 143## First run, and which side a device opens on 144 145The child's name and age are entered on the parents' side only, so a device with no profile has nothing it may say yet. 146The core decides (`whiskers_core::Entrance`, asked through `Parents.entrance`): with a profile the child's side 147opens at once; with none the app first syncs with the service (a reinstall that gets the household document back is 148not a first run), and if there is still none, or the service could not be reached, the app opens on **Welcome to 149Whiskers**, which asks for the name and the age (both required) and then for a PIN. The child's side stays shut until the 150profile is saved, and Whiskers says nothing before the child's side has shown (`Controller.setChildSideShowing`). 151 152| first run | service not reached | a parent's phone that opens on the parents' side | 153|---|---|---| 154|  |  |  | 155 156**This device opens on** (parents' side, kept on this device only, never in the household document and not in a backup): 157**Whiskers** (the default) or **The parents' side**. A device that opens on the parents' side starts on the grown-up lock and 158asks the PIN first, in case a child picks up a parent's phone; after the PIN the parents' side shows, and going into 159Whiskers from there and coming back asks again. Choosing asks for the PIN again. It is offered only while a PIN is set (the 160multiplication is not a lock to open on), and clearing the PIN takes the device back to Whiskers. The parents' side is 161reached only through the lock: `Navigator.gateOpened` works only from the lock screen. 162 163## How a conversation runs 164 165`Controller` runs it. The microphone starts off. Hold the microphone button, or hold the cat, 166to talk and let go to send; tap the microphone to leave it open and tap again to close it. 167Until the recogniser is really listening the cat juggles ("getting ready"), then perks up its 168ears; it thinks, then speaks. If it cannot reach its cloud it shows a sad face and a crossed-out 169cloud. Working out what to remember happens in the background (a small "writing in my journal" 170badge) and never holds up the next turn. What an earlier run left waiting for that work survives a stop (the core keeps it), so `Controller.start` asks `Brain.waitingForMemory` and, when something waits, calls `reflect` once on the I/O thread with no badge: nothing on the child's screen waits for it. Whiskers greets the child only the first time or after a long 171quiet (which hello it says depends on how long she was away and her local time, which the controller hands to the core as it does for the time limits): the conversation lives in the process (`WhiskersApp`), so folding or unfolding the phone 172changes nothing, and leaving the screen stops listening and talking. 173 174Captions are lyrics: the words appear when the sound starts, the spoken part is bright, a ball 175hops from syllable to syllable, and a long line scrolls. The natural voice supplies the time of 176every 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`). 177 178| Path | What | 179|---|---| 180| `Controller.kt` | The loop, tested in `ControllerTest.kt` with fake ears, mouth and brain. | 181| `Brain.kt`, `EngineBrain.kt` | What the shell needs from the core, and the UniFFI-backed implementation. | 182| `Ears.kt`, `Mouth.kt`, `WordClock.kt`, `Lyrics.kt` | The microphone, the speaker, and the caption clocks. | 183| `WhiskersApp.kt` | Holds the controller for the life of the process. | 184| `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. | 185| `Pictures.kt`, `Sounds.kt` | Camera files turned into JPEGs; the poke and dizzy sounds. | 186| `MemoryDays.kt` | The grid's rows and the date scroller's positions, both from the core's list of days (`DayLayout`, pure and tested in `MemoryDaysTest.kt`). | 187| `Hearts.kt` | The heart art (grids of palette letters, drawn at whole pixels), the hearts' page and its words (`TimeWords`). Tested in `HeartsTest.kt`. | 188| `ChooserScreen.kt`, `Characters.kt`, `CharacterArt.kt`, `Speech.kt` | The chooser's edit mode and its models; drawing a character; the speech cache's order of asking and the app's fixed lines. | 189| `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. | 190| `ParentView.kt`, `ParentScreen.kt`, `ParentGate.kt`, `EngineParents.kt` | The parents' side: the log by days (`logItems`), the retention row (`RetentionCard`), a turn's state (`TurnState`). Tested in `ParentLogTest.kt`. | 191| `Navigator.kt`, `FirstRunScreen.kt`, `OpeningSide.kt` | Which screen shows and the only ways to change it; the first-run screen for a device with no child profile; the per-device choice of which side the app opens on. Tested in `NavigatorTest.kt`. | 192 193## Backing up 194 195The parents' side has a **Backup** card (behind the grown-up lock like everything else there). 196 197| | | 198|---|---| 199|  |  | 200|  |  | 201 202**Back up now** writes `whiskers-backup-YYYY-MM-DD-HHMM.zip` into the public **Downloads** folder (MediaStore, no permission needed) and then shows the file's name and size. Copy it somewhere safe: it is the only copy that survives uninstalling the app. 203 204**What is in it:** the device's database (`whiskers.db`): the conversation, what Whiskers remembers (with every time she said it and how it was worded before), the parents' log and the copy of every device's log, the household settings (the grown-ups' choices and the child's profile) and the day's time, and every picture, as a consistent copy taken while the app runs; and this device's service address as a separate entry. `manifest.json` comes first and lists every entry with its size. A memory the parents forgot is not in it, nor are its pictures, words or embedding. 205 206**What is not:** the device id (a restored device makes a fresh one, so two devices never share a name; the minutes the old id counted stay in the household document as another device's, so nothing is lost or counted twice), the app's own diagnostics (logcat), the files made for drawing pictures (they are made again from the database), the voices and speech models (they ship in the app), and anything in the service's own copy (the service keeps its master copy of the chat, memories, household, journal and pictures separately). 207 208**Restoring, step by step** (after reinstalling, or on another device): 209 2101. Put the zip somewhere the device can open it (Downloads, a USB stick, a cloud folder). 2112. Open the grown-up lock and find the **Backup** card. Press **Restore from a backup** and choose the file. 2123. The system picker closes the app's front, so the grown-up lock closes: open it again. The card now names the file and asks **This replaces what is on this device. Continue?** 2134. **Hold** the red button until it fills (a tap only wobbles it), or press Cancel. 2145. Whiskers checks the whole file before it touches anything, then swaps it in. If the backup carries a service address that differs from this device's, the card asks whether to use it; with nothing to ask it restarts by itself after three seconds, and after a minute with no answer it keeps this device's own address and restarts. 2156. If anything is wrong with the file (not a backup, damaged, cut short, made by a newer Whiskers) the card says so in plain words and **nothing on the device is changed**. 216 217The app starts again with the restored data. The next sync brings the service's newer copy over what the backup held. 218 219## Build 220 221`mise install` once, then (each task fetches the Android SDK packages it needs and builds the Rust core and its Kotlin bindings first, `tools/build-core.sh`): 222 223 mise run android:record-goldens # rewrite the goldens 224 mise run android:verify-goldens # fail on drift 225 mise run android:test # the JVM unit tests 226 mise run android:debug # the APK 227 mise run android:lite # the APK without the offline voice 228 229`record` and `verify` are separate tasks on purpose (together in one Gradle run they race). The optional `-Pwhiskers.*` properties below are read by these tasks from the environment (`WHISKERS_HOST`, `WHISKERS_VERSION_NAME`, `WHISKERS_VERSION_CODE`, `WHISKERS_APPLICATION_ID`, `WHISKERS_ABIS`; see `tools/android-gradle.sh`). With nix, `nix develop` first gives a shell with mise; the same tasks apply. 230 231### Configuration 232 233- **The service's address is a runtime setting, not a build input.** The address of the machine running `whiskersd` 234 (`host` or `host:port`, port 47900 by default, plain http because the private network or VPN is what encrypts) is 235 typed by a grown-up in the parents' side and kept **on the device only** (it cannot sync through the service it 236 locates). A build needs no address, so a release APK can be shared: a downloaded app starts unconfigured (see 237 "First run" below). 238- `-Pwhiskers.host=<host[:port]>` is optional and only the **default** for that setting: the owner's own builds, and 239 installs made with it, keep working with no typing. An address saved in the app always wins over the build default. 240 Put `whiskers.host=...` in `~/.gradle/gradle.properties` to keep using it. 241- `-Pwhiskers.versionName=2026.10.5` and `-Pwhiskers.versionCode=2026100501` set the release version (default for dev builds: `0.1` and the 242 number of commits in the history, `git rev-list --count HEAD`, or `1` outside git, so two dev builds of different commits 243 can be told apart and a later one installs over an earlier one; `tools/check-versions.sh` fails if that derivation goes 244 missing). The name is a calendar version with no zero padding on month or day. A release's code is `yyyymmdd` 245 followed by a two-digit build of the day (`01`, `02`, ...), so a later build always has a larger code and installs 246 over the last. The application id default is unchanged. 247- **Signing:** by default the APK is signed with the debug key committed beside the project (`android/debug.keystore`, password 248 `android`): a fixture, not a secret, so anyone who clones can build and update in place. To sign with your own key set 249 `WHISKERS_KEYSTORE`, `WHISKERS_KEYSTORE_PASSWORD`, `WHISKERS_KEY_ALIAS` and `WHISKERS_KEY_PASSWORD` (all four, or the build 250 fails). An app signed with one key cannot be updated in place by an APK signed with another: uninstall first. What the 251 service keeps comes back with the next sync; what is only on the device (the service address) is typed again. 252- `-Pwhiskers.offlineVoice=false` builds without the offline voice (the lite variant, about 90 MB smaller). 253- `-Pwhiskers.applicationId=<id>` changes the application id (default `app.whiskers`). 254- `-Pwhiskers.abis=arm64-v8a,x86_64` adds CPU types, for an emulator. 255- Install with `mise run android:install` (`tools/install-android.sh`; see its header for `ADB`, `ANDROID_SERIAL`, 256 `WHISKERS_ANDROID_USER` for a tablet with several profiles, and the Windows-adb staging variables). 257 258## Running the service for a demo 259 260The device talks to one service, `whiskersd`, so it needs access to that network and nothing 261else: no keys on the device. 262 2631. **On the service machine:** `WHISKERS_ADDR=<private-ip>:47900 WHISKERS_MODEL_URL=<gateway> 264 mise run whiskersd:service`, then `mise run preflight -- <private-ip>:47900`. It must end with `ready`. 265 A `WARN` on `/speak` only means the device uses its own voice. 2662. **Install** with `mise run android:install`; grant the microphone when asked. 2673. **The natural voice** (optional): put `ELEVENLABS_API_KEY` and `ELEVENLABS_VOICE_ID` in the 268 environment `whiskersd` runs with (`WHISKERS_WRAP` in `tools/run-whiskersd.sh`, or fnox, can supply them 269 from a secret manager), then run the script again. 2704. After the day's voice allowance, Whiskers says its big voice is resting and carries on in the 271 device's voice. 2725. **First run:** a build without a default address starts unconfigured: Whiskers says "Ask a grown-up to finish setting 273 me up" (in the tablet's voice) to anything the child does. Hold the goodnight (X) button, pass the grown-up lock, and the 274 first-run card asks for the address. **Check** asks the service (`POST /usage`, which changes nothing; the service has no 275 separate health route) and says plainly whether it was found; **Save** is offered after a successful check, and **Save 276 anyway** after one that failed. The address is switched to at once, with no reinstall. Then fill in **About your child**. 277 Later the address is the **Whiskers' service** row in the parents' side (**Change address**). 278 279| first run | cannot be reached | found | 280|---|---|---| 281|  |  |  | 282 283The address is validated by the Rust core (`whiskers-core`'s `ServiceAddress`: trims, accepts a bare host, `host:port` or 284`http://host[:port]`, refuses https with an explanation, paths, queries, credentials and anything that is not a plausible 285host); the shell never builds a URL, it asks the address for its endpoints. Unset is a typed state (`ServiceConfig.Unset`), 286and the engine, parents' side and natural voice are built only from a set one (`ServiceHub` in `ServiceConfig.kt`). 287 288On a phone the app uses Google's speech recogniser (the bundled offline one is the fallback for 289the Fire tablet). 290 291## The offline voice 292 293When the natural voice cannot be had (allowance spent, no network) Whiskers speaks with a small neural 294voice that lives in the app, so it is never silent: Piper's `en_US-ljspeech-medium` (full precision, 63.5 MB), run by 295sherpa-onnx 1.13.8, arm64 only. A line is tried natural, then this, then the tablet's own engine (only if it really 296spoke), then paced captions. `mise run android:fetch-voices` (`tools/fetch-offline-voice.sh`) fetches the library and the voice (pinned by hash; gradle runs it before 297every 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 298tablet), speaks at about a fifth of real time, a sentence at a time so the first is heard within a second, and is 299freed after two idle minutes. Licences are in [NOTICE.md](NOTICE.md). Adds about 85 MB to the APK. 300To hear it without the natural voice on a debug build: 301`adb shell am broadcast -a <applicationId>.DEBUG_SAY -p <applicationId> --ez natural false --es text "..."` (add `--user N` on a multi-profile tablet) 302(`--ez natural true` turns the natural voice back on).