Two findings from dev-updater's session reading this codebase, both checked
against the code here before acting on them, and both real.
**A denied local-network permission was invisible.** The manifest requests
ACCESS_LOCAL_NETWORK and MainActivity asks for it, but nothing ever checked
whether it was granted -- and on Android 17 a denial is indistinguishable
from an unreachable server at the socket, because the OS simply drops the
traffic. So every screen would have shown "is ai-server running, and is
this device able to reach that address (WireGuard up)?", blaming two things
that were both fine.
Stated once at the root as a standing condition rather than appended to
each failure it might have caused: it is not a property of any one request,
and repeating it per error is how a message ends up saying the same thing
twice, which this app has already done once today.
**The Keystore read path was creating keys.** `unseal` called the
get-or-create key function, so a sealed token whose key had been lost -- a
device reset, or the app's data restored onto a device the key cannot
travel to -- generated a fresh key, then failed to decrypt with it, leaving
a key nothing had ever sealed with. The behaviour was already right by
accident (it fails soft to "not enrolled"), but the read side now asks for
the key without making one, which is what it meant all along.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
The other half of making setups editable: add, rename, rediscover and
remove, with a Test that tries a machine before anything is saved.
The screen cannot name a program, which is the point rather than an
omission -- providers are what the server found when it asked, so this app
has no way to introduce something to run. The dialog says so, because
"what it can run is discovered, not typed" is the answer to the question a
person will otherwise ask when they look for a command field.
Two things running it changed. The card showed "this machine / this
machine", because the seeded setup is *called* that and my fallback line
for a local setup said the same -- the line now says something the name
cannot also be. And the header row absorbed a fifth action without
complaint, which is the earlier title-and-actions split paying off exactly
as its comment predicted.
**Host key verification is the failure that would have made this look
broken.** Every machine fails it the first time, because its key is not in
known_hosts yet, and ssh's own words -- "Host key verification failed." --
are written for somebody at a terminal on the backend, which is exactly who
is not reading a phone. It now says what to do: ssh to it once from the
backend and try again. Permission denied gets the same treatment.
Deliberately *not* fixed by relaxing StrictHostKeyChecking. Accepting a new
key is a decision somebody should make with the key in front of them, not
something this app does quietly on their behalf while adding a machine.
Verified on the emulator against a running server: the seeded setup renders
with what was discovered on it, the add dialog explains itself, and Test
against an untrusted machine produces the full explanation rather than
ssh's four words.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
The point of the llama.cpp work was that models are managed from the app,
not by editing the backend's filesystem, so this is the screen for it:
search HuggingFace, expand a repository to see its GGUFs with sizes,
download one and watch it, cancel it, delete what is no longer wanted.
Everything shown is the server's state rather than the screen's. A
download started here keeps going when the screen closes, is visible from
any enrolled device, and its outcome outlives it -- demonstrated by
accident while testing, when a 538 MB download finished during an app
rebuild and was still there, complete, after reinstalling.
Polled rather than streamed, at 1.5s. A download belongs to the machine
rather than to any session, so it has no event stream of its own; this is
the one screen in the app that asks repeatedly instead of being told.
Three things the screenshots decided rather than the diff:
- **The list header no longer squeezes its title.** Adding a fourth action
to the row wrapped "AI Sessions" onto three lines. Title and actions now
have a row each, so a fifth costs nothing and the title is never what
gives.
- **A repository's files render inside its own card**, not as a section
after the list -- drawn after every card they read as belonging to
whichever was last.
- **A file already downloading says so** and is disabled, rather than
offering a Download button whose effect nobody can see.
The progress bar is determinate only when the server reported a size, and
says "total size unknown" otherwise rather than inventing a position.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
The server half went to rustfmt's defaults earlier today; this is the same
move for the app, and the same reasoning. Kotlin ships no formatter with
the Gradle build, so the question was which to adopt: ktfmt is Kotlin-org
owned now (it moved from facebook/ktfmt), is a formatter rather than a
configurable linter, and has essentially nothing to tune -- which is what
rule 27 is asking for. ktlint's .editorconfig surface is the thing that
rule warns against, and detekt is static analysis, whose job Android Lint
already does here.
One setting, and it is a choice between the tool's own two styles rather
than a tuning: kotlinLangStyle() is the 4-space one, which is what this
code already was. The 2-space default would have reindented every file to
say nothing.
./gradlew :androidApp:ktfmtFormat to apply
./gradlew :androidApp:ktfmtCheck to verify
Formatting only. The one thing worth checking by hand was the generated
PEM constant, since a leading newline there costs Android's
CertificateFactory its preamble sniff and fails at runtime nowhere near
the cause: ktfmt moved `.trimMargin()` onto its own line and left the
template alone, and the regenerated constant still starts at the opening
quotes.
Verified after: ktfmtCheck, compileDebugKotlin and lintDebug all pass, and
the APK builds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
`./gradlew :androidApp:lintDebug` had apparently never been run. It
reported 11 errors, 8 warnings and 3 hints, and one of the errors was a
crash: UsageScreen formats its reset countdown with java.time --
OffsetDateTime and Duration, both API 26 -- while minSdk is 24 and core
library desugaring was off. On 24 and 25 that is a NoClassDefFoundError,
and the `catch (_: Exception)` around the code does not stop an Error, so
the usage screen would have died rather than degraded.
Core library desugaring is now on, with desugar_jdk_libs 2.1.5. Verified
by looking in the built APK rather than trusting the flag: it now carries
Lj$/time/Duration and Lj$/time/OffsetDateTime, the backported classes the
call sites are rewritten against.
The rest: the two KTX suggestions taken (SharedPreferences.edit's block
form, which cannot forget its apply(), and String.toUri), the three
autoboxing hints taken (mutableIntStateOf/mutableLongStateOf), and
androidx.core:core-ktx declared at 1.19.0 rather than inherited through
activity-compose, since this code now calls its extensions directly.
Two are suppressed with their reasons, both scoped to the one element.
DiscouragedApi on the scanner's screenOrientation, which is not a pin but
the removal of the library's landscape lock. MissingApplicationIcon,
because there is no icon yet and that is a decision to make later, not an
oversight -- an app with no icon is obvious to anyone who opens a
launcher, so the warning tells nobody here anything.
0 errors now. The 4 warnings left are one thing: Compose Multiplatform
1.12.0 is out and this is on 1.11.1. That is an upgrade to decide on, not
a defect, so it is left for its own change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
Nothing behavioral except two status codes; mostly removing places where
the same rule was written down more than once and could drift.
- server/src/private.rs: the owner-only create/write helpers, which
config.rs, certs.rs, and the session dirs each had their own copy of
(certs.rs even duplicated the explanatory comment). One module owns the
modes now, so the "nothing this server writes is readable by anyone
else" property is checkable in one place.
- server/src/media.rs: the image media-type/extension table, which the
four places that have to agree on it each spelled out separately --
storing an upload, serving it back, building a content block, saving a
produced image. The differing *defaults* stay at the call sites with
the reasoning, since they genuinely differ by direction.
- routes.rs: a missing file was a 400 and an unreadable one a 400 with a
hand-rolled log line; they are now 404 and Internal respectively.
UnknownSession became NotFound, since it was the only 404-with-message.
- main.rs: xdg_dir takes the variable's value instead of reading the
environment, which drops the unsafe set_var from its test and lets the
test actually assert the relative-path rule.
- echo.rs had its own 4-byte hex generator beside session::random_hex.
- claude.rs: the two impl Translator blocks were one type's methods.
- Stale comments: phase-2 markers on shipped work, a permission-mode list
that had drifted from the CLI's, "dev-updater" as the leaf certificate's
fallback common name, a half-written sentence in build-apk.sh.
- App: the JSONArray walk written out in four fetchers, the four
near-identical BackHandlers in AppRoot, and SessionScreen's inline
fully-qualified names where the file otherwise imports.
- server/wg-test.log was committed by accident; *.log is ignored now, and
the gitignore comments describe where state actually lives.
- PLAN.md's backend layout gains the new modules and drops hosts.rs for
the ssh.rs that was built instead.
Verified: 35 server tests, clippy clean, app compiles warning-free, and a
scratch server driven over curl -- attachment upload/serve round-trip with
both a known and an unknown content type, the new 404s, transcript and
session-dir deletion, plus a real claude-cli session answering a prompt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
GET /usage serves the numbers behind Claude Code's /usage, read with the
CLI's own stored OAuth credentials (nothing to configure). The endpoint
is undocumented, so parsing is defensive -- the generic limits[] array
becomes labeled window bars, unknown kinds surface under their raw name,
and any failure degrades to an 'unavailable' snapshot with the reason.
One UsageProvider per paid service behind a caching monitor that
enforces the >=180s minimum poll regardless of phone refreshes; no
background polling at all. ureq (rustls) does the outbound call, with
the process-level CryptoProvider now chosen explicitly in main -- ureq
brings ring while axum-server brings aws-lc-rs, and with both in the
graph rustls refuses to guess.
App: a Usage screen off the session list -- per-window bars colored by
utilization with relative reset times. Verified live: 74%/26%/16%
windows rendered against the real endpoint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
Compose app mirroring local-updater's stack (single :androidApp module,
pinned CA, HttpURLConnection transport) plus what this app needs on top:
a bearer token sealed with an Android Keystore AES-GCM key, an
aiapp://enroll intent filter so scanning the server's terminal QR with
the stock camera enrolls the phone with no QR library, an SSE client
that resumes by transcript cursor, and a transcript renderer folding the
common event model into user bubbles, streaming text, collapsible tool
cards, and answerable question cards.
Verified on the tdep emulator against the real server: enrollment deep
link, list, spawn, streamed echo turn, question answer round trip, tool
card expansion, adjustResize keyboard behavior. Build is warning-clean
(compose.* accessors replaced with direct dependencies).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw