# Changelog Notable changes to Andee. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## Versioning policy - `versionCode` / `versionName` live in `app/build.gradle` and are currently **`9` / `"0.5.0"`**. - Releases are tagged `vMAJOR.MINOR.PATCH`, `versionName` mirrors the tag without the leading `v`, and `versionCode` increases by one per release. `versionCode` must increase **monotonically** or Android refuses the upgrade; the same number must never be reused for two different builds. - MAJOR for a change that breaks the wire protocol or an existing configuration; MINOR for a new capability; PATCH for fixes. **The project starts at `0.x`, and that is the accurate number rather than a modest one.** Nothing is frozen. The tool-schema contract a hub brain speaks has been rewritten repeatedly (`net/ToolSchemas.kt` has been rewritten in most releases — it read "in every release so far" until a release did not touch it, which is why the count is not written down here), the `local.properties` build flags that decide the first-install language, the thinking default and whether CodeFlying is compiled in have all been added since, and a saved scene is a shape that only appeared in `0.2.0`. Each of those is something a user or a second implementation would have to rebuild against, so `0.x` is being honest that MINOR may break. The release that moves off `0` should be the one where the wire contract is written down well enough that a second implementation could be built from it. **A version number identifies a release, not a build.** `versionCode` now moves with each release, so the installed app can say which *release* it came from — but nothing in it moves when `main` does. A build made from a commit between two tags reports the older release's number, so for anything not built at a tag, identify it by the short commit hash it was built from. (Embedding that hash into `versionName` at build time would close this hole; it is not implemented yet.) ## [Unreleased] ## [0.5.0] - 2026-10-16 **A finished conversation is now something the device puts away and can read back.** That is the new capability this release is numbered for — a stretch of talking that has ended stops costing context on every later request, and stops being lost with it. Two smaller changes ride along: a choice card's question and answer now land in the scrollback, and the README explains what signing is actually protecting. ### Added - **Topics: a finished stretch of conversation is folded away and can be picked back up.** A topic that has ended was the most expensive thing in every later request and the least likely to be needed again. The nightly review now marks where topics begin and end among the user's numbered turns (`fold_topic`), the device validates the range and swaps it for a one-line marker **after the turn it names**, and the verbatim conversation is archived so it can be read back. Three tools, all `localOnly`: **`fold_topic`** (review-only — an ordinary turn that calls it is refused, because folding is irreversible from the model's side and a request is not the moment to spend it), **`list_topics`** (id, title, when it ended, how many messages), and **`get_topic`** (the whole back-and-forth as plain text, truncated past a limit with `truncated` saying so). The device refuses a fold **with a reason** — a turn number that is not in the list, a range that ends at the latest turn (nothing has started after it, so nothing says it ended), something inside still waiting on an answer, an overlap with a topic already put away, too short, or a scene active — and the reason names the condition rather than inviting a retry. **At most three topics per review**, and they are put away when the review ends rather than when the tool is called. In the scrollback a folded topic is **a card standing where its messages stood** — the summary shown by default, with **Continue**, **Transcript** and **Made here**. Continuing inlines the archived conversation into the next turn; the transcript opens fullscreen (and says so honestly when the text has been cleared and only the summary is left); *Made here* reopens the pages produced inside that topic, each with its version. The summary's fourth line is deliberately **what the model left out**, because a summary that reads complete is one whose details are lost — `list_topics`' own description tells the model never to answer a question about a folded topic from its one-liner alone, and never to say it did not happen. `debug.sweep` (loopback only) runs the review on demand for testing. ### Fixed - **A choice card's question and answer now land in the scrollback.** `ui.ask` spoke the question and handed the chosen option straight to the brain, so the card came and went and the conversation showed the brain acting on a choice nobody could see being made. The question is an Andee row now and the answer — the button, or the words said instead — is a row of the user's. A timeout writes nothing, because nothing was chosen. ### Changed - **The README says what signing is for.** The signature is the only thing stopping a fake "update" from installing over a user's copy and inheriting the accessibility service, the secure-settings grant and everything in the vault. That is now stated where signing is already discussed, along with why a per-machine debug key is what produces `INSTALL_FAILED_UPDATE_INCOMPATIBLE`. - **The tool count in the READMEs and the DISCLAIMER is no longer a number.** Both said "sixty-odd tools", which was accurate when written and had drifted to seventy-two. The sentences now describe what the tools do and say "a few dozen", because a count in prose is a count that goes stale — the same lesson the versioning policy above already learned once. ## [0.4.1] - 2026-10-15 A day of fixes and one feature pair, and the version number is **a deliberate departure from the policy below**. The delta holds three `feat` commits — page versioning, the brain being able to see its own pages, and the default palette — and the policy says MINOR for a new capability, which would have made this `0.5.0`. It is `0.4.1` because the first two are two halves of repairing one incomplete feature rather than a new capability: `show_html` could already make a page and the panel could already list it, and what was missing was the model's knowledge of it and the device's willingness to treat a change as a change. Nothing in the wire contract or an existing configuration breaks either way, so neither number is wrong about compatibility — this one is about intent. ### Added - **Revising a page makes a new *version* of it, not a second page.** Changing a page used to archive the result as a brand-new artifact, so two rounds of revision left three near-identical entries on the *Made for you* shelf with nothing saying which one was current. An entry now carries which artifact it belongs to and which revision of it, the panel shows one row per artifact with its version number (`v3`), and the older revisions stay on disk behind it: **Versions** opens the list, any of them can be opened, and **Go back to this** makes an old one current again — **as a new version**, so the history stays append-only and the rollback itself can be rolled back. The brain is told the same thing, so a request to change an earlier page reads the current source back and files its answer as the next version instead of rebuilding a lookalike beside it. `show_html` gained an optional `revise` for naming the page explicitly; the device also infers it from the `get_page` in the same turn, and from the panel row the user tapped. One artifact keeps at most 20 revisions. Which artifact a revision belongs to is decided on the device and never asked of the model — and **the target is consumed rather than merely read**, a distinction found by hand on a device, where a target left standing folded a second, unrelated page in as a later version of the first. - **The brain can see the pages it has made.** `show_html` draws a page and the device keeps it — the *Made for you* panel lists them, so to the user they are a shelf — but nothing put that record into the model's context, so a page built yesterday did not exist to the brain that built it. A request to revise earlier work could come back as a fresh table that merely resembled the old one, or as a flat "I have not made that". Two read-only tools close it: **`list_pages`** returns every page with its id, title, date, size and whether the file is still there, and **`get_page`** reads one back by id so a revision is a revision rather than a new page wearing the old name. Both are deliberately **outside the `screen.` prefix**, so neither folds the card nor stops at the lock screen: reading a file reaches neither. Alongside them the newest few ride on the per-turn user message as a short index — the same place and the same reason as the notebook index, since a tool only answers a question the model thought to ask. `available: false` on a page whose file the cap has taken is reported honestly rather than passed over: the title and date are still true, the contents are not there to read. ### Changed - **The default appearance is light, not dark.** A fresh install now comes up with the light palette. A device that has touched the setting keeps whatever it chose — the rule reads only an explicit `night`, so nothing here can overrule a user who picked dark, and the dark palette stays one tap away in **Settings → Appearance**. Written this way round on purpose: the test is against the *non-default* value, which is what makes changing the default later a one-line edit rather than a migration. **Dark is not the older palette and is not being corrected** — it is the look this app was designed on, and light was derived from it as the inversion. `Theme.night`'s initial value moves with the default, so the frame drawn before the saved setting is read is already the right palette rather than flipping one frame in. - **Every face got its props, its idle life and its finishing touches.** Andee's mouth dropped twice — `−0.185 → −0.235 → −0.285` — so there is a finger of sphere between its eyes and its smile instead of the two crowding together. Sleep stopped being a flattening pose: the `sqx`/`sqy` that squashed the resting and sleeping ball into a puddle are gone, and the floppy horns, the ⌣ eyes and (while sleeping) the bubble carry it instead. ### Fixed - **Changing the model, the thinking switch or the reasoning effort no longer wipes the conversation.** Closing the settings card rebuilt the local brain on any `BrainConfig` difference, and a rebuild is the loss of the whole conversation — flipping the thinking switch reset the history mid-task, and the model then said it had no record of a page it had just built. Only a change of **who answers** (mode, key, endpoint) rebuilds now; model, thinking and reasoning effort are request parameters and are retuned in place, taking effect on the next request. ## [0.4.0] - 2026-10-14 Two new capabilities, both of them choices rather than defaults: **a second voice provider** (Google, selectable beside the existing one, contributed as PR #8) and **a launcher icon that is the character's thinking pose**. Behind them, three fixes to how the interface is drawn — the primary button, the type scale on a phone, and a tracker string that had been calling every new device a Volcengine device. ### Added - **A second voice provider.** `VoiceConfig.voiceProvider` reads `huoshan` or `google`, the settings card grew a **Voice provider** picker above the two credential blocks, and `AsrController` / `TtsController` now build one of two engines behind a shared interface (`AsrEngine`, `TtsEngine`); the two are streaming on both sides and reduce to the same five calls, so nothing above them changed. Volcengine stays the fallback — anything that is not exactly `google` reads as Volcengine, and it is the leftmost option — because for every device that predates this row, Volcengine is what it has been running. Google's two halves are different protocols: recognition runs over the **Gemini Live API** (a WebSocket, so it needs the project *number* and a location) and synthesis is plain HTTP against the same Vertex Express key. Its defaults are a real region and two model names rather than a blank (`asia-northeast1`, `gemini-3.5-transcribe-live-preview`, `gemini-3.1-flash-tts-preview`), and every hint on its rows is the value a blank row resolves to — a card that advertises a model but silently calls another is lying in the one place the user goes to decide. **`GOOGLE_VOICE_KEY` non-empty at package time is also what makes Google the default provider.** That is deliberate: a build is packaged for whoever it is packaged for, and baking a key is the most explicit statement of that intent there is. The repository's own source defaults to the empty string, so a checkout with nothing configured builds exactly what it built before Google existed. The self-check moved with it, and the move is the interesting part: `voiceKey` now asks for the key of the provider **actually in use**, and `voiceReach` probes that provider's host. The rows name neither — a green tick earned by the Volcengine key while the device is talking to Google is the exact lie that having one checklist instead of two exists to prevent. *(Contributed by @kuafu-limanxiang in [#8](https://github.com/kuafuai/andee/pull/8).)* - **The launcher icon is now the thinking pose, on white.** It was built from a sheet with a glow painted around the whole head and composited over that glow; it is now built from the cut-out of the character with a hand on its chin, on a plain `#ffffff`. Same character either way, so nothing about the identity changes — the icon is now the pose the app is in when it is thinking, and the background is literally white because these illustrations were drawn on white and the warm light on the ball's edge is painted on the ball itself. The inferred-glow treatment is not deleted; it is one flag away (`--bg glow`), because it is real work that came close. The icon set also came out smaller, 405.4 → 258.3 KiB. ### Changed - **The primary button is one flat fill instead of a blue-to-violet gradient.** A gradient inside a pill makes the button carry its own light source — the look of a landing page's call to action — and it was the only colour in the whole palette that did not answer to the theme, so on a light card it sat there like a sticker off another design. It is now `Palette.tint`, a step deeper than each theme's own accent, which keeps the button the loudest thing on a card for the reason that survives going flat: it is the only saturated fill on it. Violet went with the gradient — the interface has exactly one hue and it is blue. `Glass.TINT_START` and `Glass.TINT_END` are gone. The label had to move with the fill, and that is measurable rather than taste: it was `Glass.TITLE`, near-black by day and white at night, which works on a saturated ground at night and measures about **4:1** by day — under the line for body text. It is `Glass.ON_TINT` now, white in both themes, because both fills were picked deep enough to carry it. Seven call sites, one token each; no other surface changes. - **Type is sized against the device now, not just against the tablet.** The six rungs were drawn on a 711dp tablet where a card is 500dp wide with air around it. A phone is 392dp and the same card fills it edge to edge, so the same 26sp title read as a heading on one and a shout on the other — on the phone it showed up as a row title clipped to "Accessibilit…" with the next card pushed off screen entirely. Nothing about the type had grown: the box framing it lost a third of its width while padding, radii and line spacing stayed in dp, and that mismatch is what reads as clumsy rather than as large. Six rungs now carry a second column about three quarters of the first, chosen once from the device's short side against Android's own 600dp line (`sw600dp`) — the same line, not a second one. The tablet column is the original design and is untouched; a phone gets 20 / 15.5 / 13.5 / 12 / 10.5 / 9.5. **MICRO moves least on purpose**: it is already the floor of what can be read, and a floor that moves with the screen is not a floor. The system font scale is deliberately not read — a phone with text turned up still renders these at that scale, and the phone column is set with that in mind. - **Every English-facing markdown file is now free of Chinese characters.** The README had to be; the other fourteen did not, and they had accumulated UI labels, language badges (the Chinese badge text became `Chinese`, with its link target unchanged), and one piece of shorthand. The rule is mechanical now rather than a habit, and it is worth knowing what it deliberately excludes: **code identifiers, code comments and `CLAUDE.md` are out of scope** — the last one is written for AI assistants. ### Fixed - **The voice self-check named a provider the device might not be using.** `voiceKey` asked for `apiKey` and `voiceReach` probed `openspeech.bytedance.com`, unconditionally — correct while there was one provider, and a wrong answer the moment there were two. Both follow the selected provider now. ## [0.3.2] - 2026-10-14 Wake word plus polish, and the first release that ships the APK with the release rather than telling the reader to build one — see *Added* and `docs/releases/v0.3.2.md`. ### Added - **The wake word now listens with the screen off, and stops the tools that cannot work locked.** The gate used to hold the microphone only while the screen was on — a rule with a real reason behind it, since a dark tablet on a bedside table should not be listening all night, but the screen being off is also the normal state of a phone in a pocket, so it meant the assistant could not be called from the one place a person actually keeps their phone. The gate now asks `wakeListeningAllowed()` — *the screen is on, or the user left the "listen while locked" row on* — and that setting **defaults to on**. Its cost is stated on the row that flips it rather than left for the status bar to mention: the microphone stays open, and so does the system's recording indicator. Woken with the keyguard up, Andee can hear and answer — `tts.speak` needs nothing from the screen — but it must not pretend it can act. A gesture aimed at a locked device is dropped by the input pipeline and reports nothing; the lock screen's own `FLAG_SECURE` means there were never any logs to search; `screen.ui_tree` returns the keyguard's empty placeholder, indistinguishable from the blank-tree case; `device.launch` opens an Activity behind the lock screen where nobody sees it. Every one of those would come back to the brain as "completed" and be repeated to the user as something that happened, so `dispatch()` refuses them outright with a sentence saying why and what to do instead. `screen.` is blocked by prefix so a new tool in that namespace inherits the guard; the names outside it — `device.launch`, `open_url`, `scan`, `look`, `permissions.request`, `ui.ask`, `ui.alert`, `ui.show_html` — are the same act by another road. `device.battery`, `device.volume`, `tts.speak` and the whole `note.` namespace stay open, because they work perfectly well locked. Read live rather than mirrored into a flag, because the caller that matters fires at any point of a turn: locking the phone mid-answer is a normal thing to do, not a state transition to track. Verified on the tablet over the 9008 tool port, including the device being unlocked again to prove the guard reads the device rather than a memory of it. - **The voice segmenter now starts on the voice, not on the microphone.** Two symptoms reported separately, neither with a log line to explain it — "it takes three tries to enrol, but if I wait three seconds one is enough", and "it wakes on everything" — turned out to be one defect with two faces. `VoiceSegmenter` decides an utterance has started when the level holds for `START_FRAMES` frames, three of them, 30 ms, and the floor is seeded from the *first frame the microphone delivers*. Three frames is a click, a tap on the tablet's edge, or the recorder's own first read, so the segment started on the hardware rather than on the person. A segment that starts early comes out long and dirty and, past `MAX_FRAMES`, was **dropped in silence** — the caller's latch never counted down and the card sat on "listening…" for the rest of the window. That is the second and third try. The fix slows the decision down without throwing audio away: `START_FRAMES` 3 → 8 (80 ms) so a transient cannot open a segment, and `PREROLL_FRAMES` 15 → 30 (300 ms) so the onset is still in the ring when the decision lands. Delaying a decision is free; discarding the audio is not — which is why there is deliberately no warm-up that drops the first 200 ms. The obvious fix was proposed and withdrawn: a user who taps and speaks immediately would lose exactly the syllable the ring exists to protect. - **Five of the character's faces now run along the bottom of both cover images** (`images/cover*.png`, `images/social-preview*.png`), one word under each — Listen, Look, Think, Act, Speak. They are the app's own illustrations rather than drawings made for the web, and they are the *gesture* moods, the set the web renderer cannot draw because they use props — so this is the first place the cover shows the character instead of describing it. The row is confined to the left column by the head's own geometry, which is why the whole column moved up 60 px rather than the band being full width. ### Changed - **The APK now ships with the release.** Every release up to `0.3.1` said "no binary, on purpose", and the reason has not changed — the release build type has no `signingConfig` and the repository has no keystore, so `assembleRelease` emits an unsigned APK that will not install. What changed is the trade: a reader who cannot install anything has no way to try the app at all short of provisioning an Android SDK. The attached APK is the **debug** build — signed with the standard Android debug certificate and installs as-is — and it is deliberately **bare**: `CODEFLYING_ENABLED` is `false` with both `CODEFLYING_KEY` and `CODEFLYING_DOMAIN` empty, `DEFAULT_LANG` is `en`, and the thinking default is off. All four were read back out of the generated `BuildConfig` rather than assumed, and the artifacts were scanned for key material. **The cost is stated rather than buried**: a debug APK is `debuggable` and is signed with the shared Android debug key, so it cannot install over a release-signed build — moving from this to a signed release takes an uninstall, and an uninstall takes the notebook and the vault with it. The READMEs' advice (*hand out a signed release build, not the debug one*) is still right for anything actually distributed, and both now point at this stopgap so the two do not read as a contradiction. - **The wake-word prose moved with the default.** `wake_done`, `check_detail_wake_on` and `settings_wake_enrolled` all said the wake word works "while the screen is on" — in both languages — and both READMEs said "it only listens while the screen is on, screen off hands the microphone back". The default is the thing that changed, so every sentence describing it had to move with it or it would have shipped stale. ### Fixed - **The enrolment prompt appeared before the microphone existed.** The "now speak" prompt was set on the tapper's thread, before a recorder existed, so the user was invited to speak into a microphone that had not started. It now moves after `mic.start()` returns: the card reads "listening…" for the 100–300 ms the recorder takes to come up, and the prompt appears once something is listening. Nothing is taken from the user to pay for it. - **The wake threshold was anchored on the worst take rather than the closest.** `thresholdFor` was anchored on the *worst* pair, so one dirty take set the door width for the whole system — 2.2755 and 2.1083 against a measured band of non-wake speech at 2.21–2.59, with sixteen of sixty judgements within 0.06 of the line. `bestDistance` returns the nearest template, so the question the door width answers is "how far can a real attempt be from the nearest take" — and the evidence for that is the takes that landed close, not the one that did not. `MAX_THRESHOLD` 2.8 → 1.8, which had been picked as "the widest thing still callable a threshold" and never measured. Across three enrolments twenty minutes apart the spread between takes fell from 0.42 to 0.12. - `VoiceSegmenter` gained a pure-Kotlin `Stats` (frames / loud / firstLoud / peak / floor / segments / droppedShort / droppedLong) with no Android dependency, keeping the DSP half of the wake pipeline JVM-testable; `WakeEnrollUi` logs it once per attempt and `WakeMic` logs the read that ends a capture early, both under the `Wake` tag so one filter sees the whole pipeline. ## [0.3.1] - 2026-10-13 **A patch release by decision, against this file's own definition.** The delta carries six `feat` commits and the versioning policy above says a new capability is MINOR; the maintainer chose PATCH deliberately. It is written down here rather than left for someone to reconstruct from the history, because a version number that disagrees with the policy is exactly the kind of thing that reads as an oversight later. ### Added - **A day palette, alongside the night one.** The card had been one look — a translucent pane over a blurred photograph, a bright hairline, pale type — and half the requests on top of it wanted the opposite of those values. What blocked every one of them was that `Glass`'s colours were `const val`s: inlined into their readers at compile time, and so unable to vary at runtime at all. They are getters over a day and a night `Palette` now, **every name unchanged**, which is what let forty call sites stay where they are. Night is what the app shipped with and is the default; day is the inversion of it, with three values deliberately left alone because they name a position rather than a colour — the well stays recessed, the thumb stays raised, and the sheen flips end for end, because its job is a lit edge and the light does not come from below on either palette. A theme is a process-global with one listener, shaped after `AppLocale` for the same reason: almost no UI here lives in an Activity, so no system mechanism could reach a Service's overlays. The backdrop is the same photograph, blurred once and veiled in whichever direction the palette needs — black down to 0.22 at night (the original composite to the digit) and **white** up to 0.62 by day. One image, two veils, no asset change. Four faults presented as a single element looking wrong and are the reason this is one commit: `CardUi`'s colours lived in a companion object and were evaluated once at class load, so the card kept whichever palette the process saw first *for the life of the process*; the window's own chrome is built once and never hears about a change, which is what `applyPaletteToChrome` is for; the row tints were cached; and the light card's dim greys could not survive being asked to sit on a photograph. - **A "Looks & expressions" card in Settings**, showing every look and every expression on a ball of its own. A look chip is saved on tap; an expression chip only previews. - **A generated page picks its own look.** Every page came back the same dark slate, and the reason was not taste — it was a HOUSE STYLE block in the `show_html` description, ending in "use these exact values, no others". Asking for a light page worked only because the block left itself a way out, and that is a lever the user had to pull every single time. It could not have been fixed by wiring the block to the theme: a tool description is a string and cannot branch on `Theme.night`, so on a device switched to day the cards, the ball and the backdrop all went light and the one surface the model composes stayed in the night palette. That fix was on the table and was turned down on purpose — a page's look is the model's business, not ours. Two things stay, and neither is a matter of taste: the ``, because `HtmlActivity` sets `useWideViewPort` and a page without one is laid out at desktop width and scaled down, and the ~110×44 px the Done pill occupies in the top-right corner, because that pill is ours. **The cost is on the record**: for the small models this runs against, that description was the only lever on page quality, and nothing enforces any more that a generated page reads as part of Andee. - **The local LLM endpoint and model follow the UI language.** The on-device tab's two LLM rows already had a hint that doubled as the value a blank row resolves to, and both were hardcoded to DeepSeek — self-consistent for a Chinese card and wrong for an English one. The cheapest possible fix, changing only the hint text, is the worst version of it: the box would advertise `https://api.openai.com/v1` and then, on a blank row, call `https://api.deepseek.com`. The hint is read exactly when a user is deciding what to type, so a hint that lies about the fallback lies in the one place it will be believed. Hint and fallback now come from one pair of resolvers keyed off `uiLanguage`: an English card advertises and calls `https://api.openai.com/v1` + `gpt-4o-mini`, a Chinese card stays on `https://api.deepseek.com` + `deepseek-flash`. CodeFlying is deliberately *not* switched by language, because its model belongs to the hosted backend and an English build inventing `gpt-4o-mini` there would name a model CodeFlying does not serve. - **Errand trips from the check card are handed to the page instead of made by it**, so the list survives the trip. On the check *page* tapping a row's fix button sent the user to Settings and coming back landed on the list; from the check *card* the same three taps ended on the desktop with a ball in the corner, because the card was closed and the assistant folded before the destination launched — which does clear the screen, since an overlay outranks every Activity, but throws the list away: a window gets no `onResume`, and the destination went out with `FLAG_ACTIVITY_NEW_TASK` into a task of its own, so back from Settings had nowhere of ours to return to. `SelfCheckUi.onErrand` (was `onYieldScreen`) now hands the host the Intent and lets the host say whether it can carry it. `Fix.Grant` goes through the same door, so Allow on the microphone and notification rows stops ending on the launcher too. - **The prop-and-pose layer is on every look now**, not just Andee — stars, a heart, a shield, a sweat bead, the sliced lid, and the idle life that lets a look wear its own expressions and nap while nothing is happening. The argument for keeping it to one look was that the others had been tuned without it; that is overruled, because a look that is not alive standing next to one that is reads as the broken one. Switching it on for Imp needed a fix nothing at the call site would have hinted at: the lid's eye mesh was cut at a fixed 0.38 and Imp ships a resting eye already cut at 0.48, so an angry imp would have *opened* its eyes, in two moods, silently. - **Andee's horns stand higher**, fade to a peach tip and hang on two springs, so a wiggle or a turn swings them and they ring out; the mood sets where they rest. While idle it now wears a short run of safe faces and naps for under a minute instead of sleeping until touched — faces that report what the device is doing, or that read as an alarm, are left out on purpose. Hands are a round palm, a thumb and two capsule fingers, and the wink carries a floating heart instead of a hand, because a V, a pointing finger and a thumbs-up were each tried and each misread at this size. ### Changed - **Both READMEs lead with what it is.** They opened by showing what the thing *does* under a headline that framed it as a phone upgrade. The H3, the hero and the lead now say what it *is* and what it can *do* second, and the "why it is not the assistant already in your phone" section moved above the examples, which are now explicitly what its hands look like. Two earlier drafts were self-defeating and both were caught by reading them: one claimed "not a tool, not a companion" and then proved it with a capability list, the other denied being an assistant that operates your phone while the whole section below was exactly that. Neither is fixable with an adjective; the negations are now scoped to a category, and the section opens with the axis that separates the two — its own schedule, notebook, judgement about when to speak, and limits, all of which happen in the time you did **not** ask for anything. - **The English README no longer contains a single Chinese character** (`\p{Han}` returns nothing): the language badge, five look names, a dozen Chinese-language links, the language markers on the CodeFlying sites and a button quoted in Chinese. The Chinese README keeps its own language. ### Fixed - **`POST_NOTIFICATIONS` was declared in the manifest since the beginning and never requested** — never checked, never asked for, so on Android 13+ everything this app posts was dropped in silence: the persistent notification that is the way back to it, and every reminder `Scheduler` fires. The codebase half knew: `Scheduler` carries a comment blaming this permission for its own failure to notify, and nothing anywhere tested the claim. The row that looked like it covered this does not — The notification-access row is the notification *listener*, which can be granted and green while every notification the app sends disappears. Two things this needed beyond a catalog entry: `Perm.api`, because on API 32 and below the permission does not exist and `checkSelfPermission` answers **denied** for anything it does not know, so an ungated row would report a missing permission on every Android 12 device with no button and no way to fix it; and `SOLO_IDS`, because that set is a whitelist and a permission added by someone who never read it routes to handoff. - **Three documents still described things the code had stopped doing.** The data-flow table in both `DISCLAIMER`s and both `permissions` documents named `api.deepseek.com` as the default destination for the conversation, with no mention of the language dependency added in this release — so an English reader was told their data goes somewhere it does not. `CLAUDE.md` still called the `show_html` house style "the one deliberate exception" to the single-palette rule, two paragraphs after the same file recorded that the house style had been removed; there is no exception now. And the `POST_NOTIFICATIONS` row in the permission table said only that a reminder would not arrive, which undersold it: refusing also kills the notification that is the route back to screen control. ### Removed - **The raised mitts are gone.** Andee's expressions were built around a pair of mitts raised at the side — a fist under the chin for thinking, two fists up for celebrating, a hand on the belly for resting — and every one of them read as a lump at the eighty dp the ball is actually drawn at, with the parts sharing the body's own colour and material. Dropping them is the honest version of "this does not work at this size": the ball keeps the paws it hangs off the screen edge with. No expression collapses into CALM for it, because a mood is read through its own `Face` row and its body motion, neither of which ever came from the pose table. Two leftovers are deliberate and both say so where they live: WAVE keeps its fan of motion strokes with no hand to fan off them, and HOLDING keeps an empty row, because the signboard it waits behind is a real card in the ball's own window. - **The `show_html` house style**, along with the `LocalPrompt` §7 sentence that pointed at it and then asserted "that style is what you look like". Both were describing the prescribed look being removed. ## [0.3.0] - 2026-10-12 The release where the assistant stops living inside the accessibility service. Everything else here is smaller than that; the split is the reason it is a minor bump and the reason the upgrade notes matter, because a ROM that revokes accessibility now costs one capability instead of the whole app. ### Added - **The body is a foreground service; the accessibility service is a shim.** The body — ball, card, microphone, ASR/TTS, brain socket, scrollback, todos — used to be constructed inside `ScreenBodyService.onServiceConnected()`, and that class is an `AccessibilityService`. So a ROM that revokes accessibility (HyperOS, observed) took the whole assistant with it: no ball, no way to talk, no way back except knowing which switch in Settings to find. The failure mode was never "screen control is gone", it was "the app is gone". `BodyCoreService` now owns everything else and outlives the accessibility service, which is reduced to handing the screen capability over and taking it back — so a revoked service costs one capability, and the return trip is one tap on a notification that is still there. `screen.*` goes through a provider, so a call with no accessibility throws a readable error instead of no-op'ing or holding a controller built around a dead binding; the shot and gesture guards moved from an `init` block to `wireScreen()`, called on every attach, because left on the previous controller they would simply be absent on the new one. The launch is a persistent notification that carries whether screen control is up and the way back to Settings — posted by the body, so it survives the state it describes. The launcher is the one context allowed to claim the microphone foreground-service type, because a microphone-typed service cannot be created from the background and every other starter is background. - **The screen can be read with `MediaProjection` when accessibility is gone.** The split stopped a revoked service from taking the whole assistant, except that the assistant went **blind** — every way of reading a screen went through the accessibility service, so "what is this screen?" was the one question it could not answer on exactly the devices this project exists for. Deliberately a fallback and deliberately weaker: `screen.screenshot` only, because `look_region` and `zoom_aim` exist to zoom a target for a tap and `dispatchGesture` has no non-root substitute, so there is no tap on this path. The result says which way in it used, and carries no aim grid and no element list — handing the brain a picture it believes it can aim with would be worse than handing it fewer fields. Asked for only while it is the only option: the consent is a notification action, offered only when Andee is blind and has no capture, and `ACTION_ACCESSIBILITY_SETTINGS` stays the first action because the projection buys reading, not acting. - **Every mood has a `Pose` on top of its `Face` row** — a wink, eyes that look up and aside, lowered or worried lids, a squashed resting body, two puffy hands (open, fist, pointing) and pop-in props. Five gesture moods (wink, wave, protect, celebrate, rest) can be asked for by name or synonym in the model's own text, and the prompt says to keep them rare. Only a look with `BallLook.gestures` reads it, so the other five characters are unchanged. There is deliberately no open mouth: cross-fading it with the thin smile drew both at once and one poked through the other. - **The default ball is now the blue horned Andee** — a cobalt head with curled horns and glassy eyes, first in `BallLooks.ALL`, so a fresh install and a factory reset both land on it. Its numbers were measured off the reference picture through the renderer's own camera rather than eyeballed. The launcher icon set, README covers, social previews and org avatar are regenerated from the same source art. - **The CodeFlying per-user token is read from `assets/andee_key.txt`**, with `BuildConfig` as the fallback for dev builds. Production APKs are built once from shared source and the per-user `kft_` token is written into that asset at package time, so the base APK stays identical across users and the Gradle build cache no longer misses per user. - **`-PdbgSuffix=` builds a parallel package** for device verification. The package on the tablet was signed with a key this checkout does not have, so `pm install -r` was refused and the only alternative was uninstalling — which throws away chat history, notebook and vault. Off unless the property is passed, so ordinary builds are byte-identical. - Debug builds gain `BallLabActivity`, which shows one mood on a plain background for screenshots. ### Changed - **The version check moved to `https://api.heyandee.com`, from `https://andee.kuafuai.net`.** `VERSION_API_BASE` is still a hardcoded constant in `device/SelfCheck.kt`, and still the only address this app calls that the user did not configure — see `docs/permissions.md` — so the move is one line of source plus the three places that quote it and have to stay in step: both `permissions` documents and the note in `network_security_config.xml`. The old host was on the mainland, where the domain had to be ICP-filed before TLS would reach it at all; the new address resolves outside the mainland and retires that particular trap. It does not retire the other one: a domain still has to resolve *and* serve 443, and a fresh record is not everywhere at once. Measured while making the move, `1.1.1.1` still answered `NXDOMAIN` while `223.5.5.5` already had the address — on the wrong resolver that reads as "not found" on the version row, which is the intended failure rather than a fault to report. The service itself was checked before the switch: the new host answers `GET /api/version` with the same envelope, and the same `data` fields the client parses. - **The self-check page now stays up until the user closes it.** It is a list, and a user with five rows works through them one at a time — out to a Settings screen and back for each — but the page used to hand the screen to the assistant as soon as it could draw one, which happens on the *first* of those returns, so the list was taken away after row one. On a page whose whole job is to walk somebody down a list, the user is the only one who knows when the list is finished. Closing it with the Done button puts the assistant back. Two surfaces fighting for the screen was the real bug underneath and it stays fixed by the same move: nothing unfolds a card while the page is up. - **Closing a page puts the assistant back at full size, unconditionally.** The fold that makes room for a page is ours — our card is a fullscreen opaque overlay and would cover it — so undoing it is ours too, and it was missing from `pageClosed`. The first cut restored the card only if it had been fullscreen when the page went up; the report was the other way round, and closing the self-check card drew the same complaint and got the same answer. - Both READMEs point their CodeFlying links at the `/andee` one-click landing page rather than the generic official site, so every jump lands on the Andee entry page. ### Fixed - **`ui.show_html` no longer needs accessibility**, which it had started to. `awaitUncovered()` asked its question through `requireScreen()`, and `awaitUncovered` runs off `ensureCompact()` — which `ui.show_html` calls too, because our card would cover the page it is about to draw. So with accessibility off, the one tool that draws a page answered "accessibility is off" and the assistant told the user it could not show them anything. Introduced by `f364490`, which turned a nullable-but-always-present field into a throwing provider and rewrote every call site mechanically; the other sixteen are inside `screen.*` branches and are meant to throw. Returning early is also right on the merits: the wait exists so that a *tap* lands on the app rather than on the folded card, and there are no taps without accessibility. - **The self-check no longer mistakes a re-creation for a launch.** This page sends the user out through its own rows, so the platform killing it while they are away and rebuilding it on return is routine here rather than an edge case: grant the overlay, come back to a restarted Activity, and it used to unfold the card and finish before a single row was drawn. `savedInstanceState != null` is what tells the two apart. A related report — "I granted it, came back, it still said no, then re-check was fine" — is the platform's answer about a setting changed seconds ago not being ready in the frame the user returns on: the notification-access row is `OK` only once the listener is actually bound, and the read that finds it unbound is also the one that requests the rebind. `settleRecheck` takes two more local passes and stops, because a page that keeps re-reading itself is a page whose numbers stop meaning anything. (The retry is reasoned from the code path, not measured: writing the notification listener into `enabled_notification_listeners` does not stick, so the state cannot be manufactured from adb.) - **The body stays off the screen while the check page is up.** The report was "the check said the overlay was off, I granted it, came back, and I was on the assistant instead of the check" — the check was never gone, the page was still the resumed Activity with the assistant's fullscreen card painted over it. Two things put it there, both in the body's birth: `window.setCompact(false)`, and `window.show()`, which is subtler — this window is fullscreen *even when the ball is compact* and swallows the touches it does not use, so a page with a ball in the corner is a page whose buttons cannot be pressed (measured: the Done button did nothing). Telling a fresh launch apart from a return needed `SelfCheck.isPageOpen`, written down in the prefs file the startup check already uses. The startup self-check card is skipped while the page is up as well, because that page *is* the list. - **A just-fixed row stops showing as red when the card resumes.** The card's own rows send the user out to a system screen and the only thing that happened on the way back was a language comparison, so a row the user had just fixed stayed red until they found "check again" — and a stale red row reads as "the fix did not take". An Activity gets a resume callback and an overlay does not, which is why this is a change to one host rather than to the list; it is local-only on purpose, because the probes hold the card open for seconds. The first resume is skipped (a full pass just ran), the three probed rows keep their last reading (`SelfCheck.mergeLocal`), and `render` restores the scroll position — which only started to matter once redraws stopped being deliberate. - **A perch slide dragged the whole card off-screen, and a voice failure repeated forever.** A card held by the folded ball makes the window card-sized, so a perch slide (task start, page up) dragged the whole card into the bottom-right corner, half off-screen and untouchable; the perch entry points now take the signboard down first. Separately, the auto follow-up mic reopens after every answer, so a device with no voice set up raised the same failure card and scrollback row each time — now shown once per failure streak, reset by a working voice turn, leaving settings, or the user pressing the ball. - **`screen.screenshot`'s schema claimed something false.** It opened "every screenshot arrives with an 8x12 AIM GRID drawn on it" and promised the element list, both exactly untrue on the projection path — which is the only screen tool that still works there. The runtime `note` said so, but the schema is what the model reads *before* deciding to call it. - **Three documents still described the pre-split architecture.** `README.md` and `README.zh-CN.md` said "this app *is* the accessibility service" and pointed a reader at `ScreenBodyService.kt` as the file to read first — both false since `f364490`, which is the whole point of the split. The permission table in `docs/permissions.md` / `.zh-CN.md` said of accessibility that "every screen action errors", which stopped being true in `82f08db`: `screen.screenshot` has a fallback and the row now says so, along with what the fallback cannot do. ### Removed - `tools/check_launcher_icon.py`, `tools/cosmic_backdrop.py` and the old `make_launcher_icon.py`, replaced by a rewritten generator built on `tools/andee_head.py`. All three were written for the previous character and a star-field backdrop, neither of which is in use any more. `tools/make_social_preview.py` generates the social previews, both README covers and the org avatar from the same source art. ## [0.2.0] - 2026-10-10 Three new capabilities and two permission fixes. A minor bump rather than a patch, because the three are new capabilities by this file's own definition — the first release where that rule has had to decide anything. The permission pair is the part worth reading: the app had been telling the model things about the system permission sheet that measuring on a device showed to be false, in one case in the direction that made the model act on a grant nobody had given. ### Added - **A single tap on the ball now stops whatever is running and then listens.** A tap used to mean "listen" and nothing else, which left a gap the moment the ball was busy: to redirect it the user had to stop it first (three taps) and then ask again (one more) — two gestures for one intent. Tapping something busy and having it stop and listen is what one person does to another, and it is the only reading of the tap that is not "talk over the thing you cannot hear". The stop is `stopEverything()`, reused rather than reimplemented, so the single tap and the triple tap cannot drift into two different stops and the hub gets its `task.stop` either way. It is guarded on `dispatcher.isTaskActive()`: on an idle ball there is nothing to stop, and the call would still clear a pending "keep editing", tell the hub to stop, and flash "stopped" ahead of "listening…" — three side effects for a tap that only ever meant "listen". Triple-tap still stops, and is still the *immediate* stop, because a single tap has to wait out the double-tap window before it knows it is not the first of two. No gesture moved and no string changed: what a tap *does* depends on what is running, which is not something a line of text can say. Verified on device (HONOR AMM-AN00) — an idle tap listens without flashing "stopped". - **A device with no conversation is offered ready-made scenes instead of a dead line.** It used to show "no conversation yet" and nothing under it. There is nothing to be helpful *about* yet, and a ball in the corner has taught a newcomer nothing about what the thing does, so the one moment this app can be proactive without being early went unused. The card now shows a hint line — "tell me, or pick one" — then a bubble per preset from `ScenePresets`, each labelled with the scene's name and carrying a sentence the user could have said: the name is what a person picks between, the sentence is exactly the part a newcomer cannot invent. Both are fields of one `ScenePresets.Text`, so a bubble cannot promise one thing and send another. **Tapping a bubble adopts the scene before it speaks** — this was wrong first and is the part worth naming: a scene is something the notebook holds, so a bubble that only sent its sentence asked the model about a scene nobody had saved, and it correctly answered that it had never seen one, in front of the user. It is `adoptIfMissing` rather than `adopt` on purpose: a bubble is offered whether or not the preset was adopted, and `saveScene` replaces the whole row including `prompt`, so adopting twice would put the preset's text back over the user's own edited version with nothing to say it had happened. Pre-adopting the presets at install was the other candidate and is worse — a written-down preset is an ordinary scene, so deleting one would have it come back at the next install, the language would freeze to whatever the device spoke then, and it contradicts `LocalPrompt` §11. Emptiness is the whole safety story and it is mechanical: the moment there is a conversation the strip is `GONE` for good, because a suggestion strip parked over a live chat would be the app talking about itself instead of answering. Two numbers are enforced rather than asked for by `ScenePresetsTest`, which caps every summary in both languages and checks that every name a bubble can send is one `byName` can look back up. Verified on device (HONOR AMM-AN00) by reading the notebook over the 9008 tool port. - **Long-pressing a history row opens the log behind it.** The scrollback says what was said; everything that earned the right to say it — which tools ran, with what arguments, what came back, what the model reasoned, and what each request cost in prompt tokens — was already in `files/brain_trace.jsonl` and reachable only with `run-as`. The new page is built from those records and opened through `HtmlActivity`, which is where folding the card, hanging the ball off the ledge and the Done pill were already solved. Long-press because the other two gestures on a row are taken — a single tap opens a page row's page, a double tap reads a message fullscreen — and the two detectors had to become **one**, because `setOnTouchListener` replaces rather than chains, so a second one would have silently killed the first. **The row and its log are joined by `-`, and `gen` alone would have been wrong:** it is an in-memory counter that restarts at 1 with every process while the trace file is persistent, so on the test device `"gen":1` accounted for 176 of 278 records, and installing the change reproduced the collision within 89 seconds. A scrollback matching on `gen` would have opened a reinstall's first turn onto yesterday's first turn, with nothing on screen to say so; `BrainTraceTest` locks the join down, dash included. Two things the page got wrong before it was ever shown to anyone, both found by reading records back off the device rather than by reasoning about them: the `turn` card labelled its body "Said", so the user's own sentence appeared in the assistant's mouth on the first card of the page (the heading is where the origin belongs, and `kind` is the field that knows it), and the `end` card said "Turn ended" when `BrainTrace.end` is only written on an *abnormal* finish — two of three turns measured had none at all. Records written before this commit have no `turn` field, so a row from before the upgrade offers no gesture, and the same rule keeps hub-brain rows, scene-change rows and notification read-outs from offering a dead entry. ### Fixed - **`request_permissions` answered `{"started": true}` no matter what actually happened.** That was a hard-coded `true` meaning only that `startActivity` had not thrown. After a second refusal Android 11+ draws no dialog at all and the call still said true, so the brain read "it is on" and immediately called `get_location`. Measured on turn `1791621839492-4`: the request returned at +19.257s, `get_location` fired at +20.839s and answered `permission denied` 1 ms later — the model gave the user 1.582s to find a dialog that was never drawn, then spent 34.6s looking for a way around it. The reply now carries what happened rather than that something was attempted: `mode` (`solo` / `handoff` / `settings`, who answers the sheet), `result` (`granted` / `denied` / `no_dialog` / `waiting`) and `next`, one line saying what to do about that result. `mode` comes from `routeOf`, a pure function of the ids kept on a whitelist so a permission added to the catalog later falls on "the user was asked once more than necessary" rather than "the ball agreed on their behalf". `result` resolves the distinction that pays for all of it: a refusal and a silent block both read `PERMISSION_DENIED` and demand opposite responses, and `shouldShowRequestPermissionRationale` — readable only from an Activity — is what separates them. `PermissionRequestActivity` used to drop `grantResults` on the floor; it now hands the verdict back through arm/await/settle, and `PermissionsController` waits `DIALOG_GRACE_MS` for it, a grace period rather than a wait for the human. Verified on HONOR AMM-AN00 (location/solo/granted, contacts/handoff/waiting, steps/solo/waiting, each with the sheet really on screen); 37 unit tests pass, with `PermissionsRouteTest` pinning the routes from the id list without a device. - **The permission sheet was drawn *underneath* the expanded card, so the user could never see it.** An expanded card is a fullscreen surface and our overlay sits above every activity, so `GrantPermissionsActivity` was drawn below it. Verified on device: the dialog really was up — it was both `ResumedActivity` and `mCurrentFocus` — while a screenshot showed nothing but the card. A question nobody can see is worse than no question, and it is exactly the state `waiting` promises. `device.*` deliberately never folds, on the reasoning that reading state is a conversation rather than an actuation; this branch is the exception that reasoning did not cover, because it addresses the user in a window of the system's own. `device.scan` and `device.vault.fill` already fold for the same reason, so this joins them — and only when `confirm=true`, because the first call is the script the brain reads back and that one belongs in the card. - **Both READMEs gained a one-click build page and had its likely-reading fixed.** The nav and the CodeFlying paragraph now point at the `/andee` page — describe the app, tap the build button, get a pre-configured package with a QR code — and the "any app" feature line was reworded in both languages, because "not a list of integrations" was being read as a claim about integrations this app has rather than a claim about aim. The page itself is a separate service with no direct relationship to this project, as that paragraph already says. ### Changed - **The prompt's rule about the system permission sheet was wrong, and is now the measured one.** It said a system permission sheet "does not take a tap you inject", and that turned out to be false. With the card folded, an injected tap on "Always allow" really does grant: `READ_CONTACTS` went false → true, and one tap on "Allow this time" produced an `ACCESS_FINE_LOCATION` carrying `ONE_TIME`, a flag only that button can set. The earlier four failures had one cause between them and it was our own card eating the touch, not the sheet refusing it. So the rule changes from "you cannot press it" to **"you cannot read it, so do not aim at it"**: the sheet exposes no accessibility elements at all, so any tap is blind, and the button at the bottom of this particular screen is "Deny and don't ask again", which nothing can undo. That is the real reason this step stays the user's, and it is a reason that survives being measured. `LocalPrompt` §0's `type_text`-only rule is stated as the general one — a tool saying it pressed something is not the thing being done — and §1's list of genuine exceptions gains the sheet, which is worth naming because putting it on screen is the whole of what firing does. ## [0.1.1] - 2026-10-09 ### Fixed - **Four strings sent the user to a menu label that no longer exists — and one to a gesture that does not open settings at all.** The settings card has been reorganised twice since these sentences were written, and the sentences stayed behind, which is the worst kind of copy bug: the user follows the instructions, finds nothing, and concludes the app is broken rather than the text. - `check_detail_brain_key_missing`, `check_detail_brain_hub_missing` and `brain_no_key` all said **⚙ → "Brain"**. The section header is `settings_section_brain`, which reads **"Backend"** — there is no label containing that word in the settings card at all. The Volcengine tab names use it only in prose ("the brain lives on a hub you run yourself"), never as a destination. - `check_detail_voice_key_missing` said **⚙ → "Speech (Volcengine)"** — a section that no longer exists. The Volcengine rows were moved into Backend → on-device, and `settings_section_voice` / `settings_section_voice_key` are still in both string files but referenced by nothing. The path is now the same **Backend → on-device** as the two brain keys, which is where the field actually is. - `brain_no_key` also said **"long-press the ball, open settings"**. `onLongPress` does not open settings; it calls `dispatcher.expand()`, which brings the full card back. Settings is the ⚙ button in that card's control bar. The sentence now names both steps, because the first one is genuinely required — the control bar only exists at full size. - `check_detail_brain_hub_missing` gained the missing precondition: **the hub tab is hidden unless Advanced → "Show cloud option" is on.** `showHub` gates the tab in the picker, but `brainMode` does not consult it, so a device can sit in hub mode with the tab that fixes it invisible — turn the toggle on, pick the hub, turn it off again, and the self-check would have pointed at nothing. Every label these strings now name was checked against its resource value rather than transcribed from the sentence being replaced — which is how the one remaining error was caught: the English label is **"Show cloud option"**, singular, and the first draft of this fix wrote it plural. - **Both READMEs promised a first-run wizard that no longer exists.** "The first run is a stepped setup that walks you through both" — in both languages — `8dac568` deleted the wizard (`ui/FirstRunUi.kt`). A missing setup guide is indistinguishable from a broken install, so this read as a bug report rather than as stale prose. Both now say what the app does instead: the self-check runs on every start, the card names the missing key, and its button opens settings. - `README.md` named the Volcengine field by its Chinese label, while the English build displays **Volcengine API key** (`settings_voice_api_key`). The Chinese README already had this right; only the English file was pointing at a word that never appears on an English screen. - **A picture, a video or a clip now goes into a page, instead of being handed over as a URL.** The prompt said the opposite, and said it wrongly: *"this device cannot turn an image into a URL, so an `img` tag will not open"*. That is false for anything on the network — the page is served against a fake `https://` origin, the app holds `INTERNET`, and WebView loads an absolute `https://` subresource without complaint. What is true is narrower, and the old sentence flattened it into a blanket ban: a *local* file has no address a page can point at, and `http://` is refused. So the model, obeying its instructions, could not put a picture on screen — and what it did instead was hand the user a URL to open, on the device they were already holding. §7 now asks for the embedding directly (``, `