# 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 (``, `