Freshness compared the commits under a component's own cwd, which is right about where its files are and wrong about what its build reads. tdep-survey's two clients each source one shared scripts/android-sdk-env.sh: a fix committed there changed what every build does, moved no component's subtree head, rebuilt nothing, and left every card correctly reporting "current" while answering a narrower question than the reader was asking. alsoWatch names the rest. The paths join the component's own in the same subtree-head and dirty comparisons -- git takes several pathspecs, so it stays one call each -- and it is part of the acceptance gate like any other declared field. Declared rather than inferred: which files a build reads is not knowable from here, and both wrong guesses are expensive. Raised by the tdep-survey session, which traced why its dioxus client could never become stale on the serving host. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
558 lines
25 KiB
Markdown
558 lines
25 KiB
Markdown
# dev-updater
|
|
|
|
Keeps a phone's locally-built debug APKs current, without a browser and
|
|
without leaving files in the Downloads folder to clean up by hand.
|
|
|
|
Two halves:
|
|
|
|
- `server/` — a small Rust (Axum) server that runs on the build machine. It
|
|
is told about *projects*, finds the APKs built under them, and serves
|
|
them over pinned TLS.
|
|
- `app/` — "Dev Updater", an Android app that lists what the server has,
|
|
shows which builds are newer than what's installed, and hands downloads to
|
|
the system package installer.
|
|
|
|
Adding an app is done from the phone: point it at a project directory and
|
|
the server works out the rest (which APK, which package it replaces, what
|
|
to call it). Nothing about any particular project is compiled in.
|
|
|
|
## Getting started
|
|
|
|
```sh
|
|
cargo build --release --manifest-path server/Cargo.toml
|
|
./server/target/release/dev-updater --download # generates its CA on first start
|
|
./app/build-apk.sh # embeds that CA
|
|
```
|
|
|
|
Run it **from the repo root**, as above: the server takes its own app
|
|
project from the working directory, and started elsewhere its own card has
|
|
no checkout to pull. Once it is working, `./start.sh` does all of this and
|
|
installs it as a service, which is the one command to run after a pull.
|
|
|
|
Then install the app once by hand — nothing else can install it before it
|
|
exists. `--download` serves its APK over plain HTTP at
|
|
`http://<this machine>:8091`, so a link typed into the phone's browser
|
|
downloads it directly. After that first install the app updates itself the
|
|
same way it updates everything else, over the pinned TLS port, and
|
|
`--download` isn't needed again.
|
|
|
|
Open the app and scan the QR the server printed on first start — that is
|
|
what enrolls the phone; see [below](#enrolling-a-phone). Then tap **Add**,
|
|
put your repo directory in "Where to look", and the projects underneath it
|
|
show up as one-tap suggestions — the ones built at least once, plus any
|
|
carrying a `.dev-updater.ron` of their own, which can be added before
|
|
their first build.
|
|
|
|
To develop the app itself against a local emulator instead:
|
|
|
|
```sh
|
|
cd app && ./run-android.sh
|
|
```
|
|
|
|
## How an app gets found
|
|
|
|
The path you give is to a *project*, never to an APK. The server
|
|
rediscovers the build underneath it on every request, so an ordinary
|
|
rebuild needs no reconfiguration, and a build landing in a different
|
|
variant directory is picked up on its own.
|
|
|
|
Discovery matches the handful of path shapes Android build tooling
|
|
actually emits into (see `server/src/discover.rs` for the list — Gradle at
|
|
one or two module levels, Flutter, and dioxus-cli's generated project).
|
|
That is a deliberate choice over a general recursive search, which is far
|
|
too slow to sit behind an interactive screen. Measured against one real
|
|
28 GB / 39k-file project, warm cache:
|
|
|
|
| approach | time |
|
|
|---|---|
|
|
| `find -name '*.apk'` | 406 ms |
|
|
| the same, depth-limited | 305 ms |
|
|
| the same, pruning `.git`/`deps`/`.fingerprint`/… | 132 ms |
|
|
| the patterns actually used | **6 ms** |
|
|
|
|
Depth limits buy nothing, because the breadth is in shallow Cargo/Gradle
|
|
output directories. Supporting another build system means adding a pattern
|
|
there.
|
|
|
|
When a project has several builds, the newest wins and the card offers a
|
|
**Variant** menu to pin a specific one instead. That choice lives on the
|
|
phone, not on the server, and travels with the download — two devices
|
|
enrolled against one build machine each pick for themselves. The path is
|
|
checked against the builds the server can actually see, so a pin left
|
|
pointing at something a `gradlew clean` removed falls back to the newest
|
|
rather than failing.
|
|
|
|
## Configuration
|
|
|
|
`config.ron` (in `$XDG_CONFIG_HOME/dev-updater/`, created on first
|
|
change, `--config` to move it) holds the repo roots, the added apps, and
|
|
the hashes of the enrolled device tokens. It is written owner-only. It
|
|
lives there rather than in the repo because it is per machine: paths in it
|
|
resolve on the machine that wrote them, and a checkout shared between a
|
|
machine and a VM would otherwise hand each the other's paths -- which
|
|
showed up as every app reading "not built".
|
|
|
|
Point `repoRoots` at as many directories as you like, including a
|
|
directory another machine's projects are mounted at; discovery treats them
|
|
all the same. Everything in it is editable from the phone.
|
|
|
|
### Pulling and building from the phone
|
|
|
|
An app whose project is a git checkout can be brought up to date from the
|
|
phone, and needs no configuration to be pullable: the card shows the
|
|
branch, and grows a **Pull** button when the remote actually has something
|
|
this checkout doesn't. A button that is always there says nothing about
|
|
whether pressing it would do anything, so its presence is the signal.
|
|
Without a build command it pulls and stops, which is all it can honestly
|
|
do — how a project builds is still never guessed.
|
|
|
|
Give it a command to have Pull build as well:
|
|
|
|
```ron
|
|
projects: [
|
|
(
|
|
key: "ai-app",
|
|
projectPath: "~/repos/ai-app/app",
|
|
components: [Apk(name: "app", build: "./build-apk.sh")],
|
|
),
|
|
],
|
|
```
|
|
|
|
Better still, let the project carry that itself — see [below](#letting-a-project-carry-its-own-build-step).
|
|
|
|
Set `gitPull: false` on a checkout that should never be moved from a
|
|
phone. Paths accept `~`, and are shown that way.
|
|
|
|
### Updating this server itself
|
|
|
|
The **Dev Updater** card pulls and builds like any other, and is
|
|
configured like any other: `.dev-updater.ron` in this repository
|
|
declares its name and its two components — a `Server` (the binary) and an
|
|
`Apk` (the phone app), built in that order. Its server row installs,
|
|
starts, stops and reports like anyone else's.
|
|
|
|
It is a row in `config.ron` like any other too, so anything set per
|
|
project — `gitIpv4`, say — has somewhere to live for this card as well.
|
|
Everything else in that row is rewritten at each start from the
|
|
declaration above and from the directory the server was started in, so
|
|
edit those rather than the row.
|
|
|
|
What is compiled in is not *whether* it restarts but *how*. Its server
|
|
component is this process, so restarting means exec-ing the binary just
|
|
built rather than asking the service script to stop and start the code
|
|
doing the asking — which would kill the build partway through, with
|
|
nobody left to report how it went. The `exec` keeps the PID, so whatever
|
|
supervises the server sees one continuous process rather than needing to
|
|
be told to restart it. The Restart button takes the same route, and for
|
|
the same reason: the script's restart would kill this process before it
|
|
could answer, so a restart that worked would reach the phone as a failed
|
|
request.
|
|
|
|
Two buttons on that row are worth knowing about before pressing them.
|
|
This app reaches the server *through* the server, so **Stop** and
|
|
**Uninstall** strand the phone until someone starts it again on the build
|
|
machine; the app says so and asks first. They are offered rather than
|
|
hidden because there are good reasons to want them, and a button that
|
|
silently does nothing is worse than one that warns.
|
|
|
|
Its build step is the one that is never held for acceptance, because
|
|
gating it would protect nothing: pulling this repository replaces the
|
|
binary that would be doing the gating.
|
|
|
|
That closes the loop: new commits land, you press Pull, and the server and
|
|
the APK it offers are both current. No other app can ask for the same:
|
|
restarting to pick up an unrelated project's build would drop every other
|
|
request for no reason.
|
|
|
|
Pull acts on the build machine — fetch, fast-forward, then run the command
|
|
— while **Update** still means "install what's built onto this phone", so
|
|
the two never mean each other.
|
|
|
|
While it runs, each component's own row says which step is happening and
|
|
the last line that component printed. The command's output is read as it
|
|
arrives rather than collected at the end, so a long build is visibly
|
|
moving instead of being indistinguishable from a stuck one. When it
|
|
finishes, all of that goes and the button becomes pressable again: what a
|
|
row shows is work in progress, not a report on work that is over.
|
|
|
|
Components of one project build at the same time and are independent of
|
|
each other, so updating one client of a project that builds two leaves the
|
|
other's button live rather than making it wait. A pull is the exception —
|
|
there is one checkout, so it waits for everything and everything waits for
|
|
it.
|
|
|
|
Deliberate limits, because a phone is a bad place to resolve a mess:
|
|
|
|
- Only a fast-forward. A branch that has diverged is reported, not merged
|
|
or rebased.
|
|
- A dirty working tree is refused outright, and left exactly as it was.
|
|
- A branch tracking no upstream has nothing to pull, and says so.
|
|
- Nothing is pulled without `gitPull`, and no build command is guessed.
|
|
|
|
**Showing the list never changes your repositories.** It asks each remote
|
|
what it has (`git ls-remote`) and asks whether that commit is already an
|
|
ancestor of what is checked out — no objects downloaded, no tracking refs
|
|
moved, no `FETCH_HEAD`. The question is deliberately "would a pull move
|
|
HEAD?" rather than "does the remote differ from `origin/main`?": the
|
|
latter calls a checkout that has fetched but not merged up to date, and
|
|
calls one carrying local commits behind. Fetching is Pull's job, which is why
|
|
the card says "new commits" rather than a count: counting needs the
|
|
objects, and downloading them is the thing the button is for.
|
|
|
|
Checks are started when the list loads — opening the app, resuming it, or
|
|
pressing Refresh — and run concurrently, so several projects cost about
|
|
one round trip rather than one each. There is no interval over which a
|
|
previous answer is reused: one `ls-remote` is a fraction of a second, and
|
|
rationing it meant a push made shortly after a check went unnoticed until
|
|
the window expired. The only limit is that one checkout never has two
|
|
checks running at once.
|
|
|
|
**The list never waits for them.** It answers from what is already known
|
|
and says a check is still running; the phone looks again a moment later
|
|
and the "new commits" badge appears on its own — about a second after the
|
|
list, in practice. This is worth stating because the obvious design —
|
|
wait for the answer, then reply — made every reopen of the app stall
|
|
behind a round trip to the git host. What the list is actually about
|
|
(which apps have builds, and whether this phone has them) is entirely
|
|
local and takes milliseconds.
|
|
|
|
Those follow-up looks pass `?recheck=false`, which collects the answer
|
|
without asking again. Otherwise each look would start a fresh check, find
|
|
it outstanding, and never stop.
|
|
|
|
Each check is still bounded by an SSH connect timeout and a hard stop, so
|
|
an unreachable remote fails rather than hanging, and a failure keeps the
|
|
last known answer instead of claiming there is nothing new. A pull marks
|
|
its checkout current straight away, so a card stops offering what you just
|
|
took.
|
|
|
|
### On-demand builds
|
|
|
|
An app whose committed build output isn't what a phone needs can declare a
|
|
build step, run automatically right before that app is downloaded:
|
|
|
|
```ron
|
|
projects: [
|
|
(
|
|
key: "app-dioxus",
|
|
projectPath: "/home/you/repos/example/app-dioxus",
|
|
components: [
|
|
Apk(
|
|
name: "app",
|
|
build: "./build-android-arm.sh",
|
|
staleWhen: (
|
|
path: "target/dx/app-dioxus/debug/android/app/app/src/main/jniLibs/arm64-v8a/libmain.so",
|
|
olderThan: "target/dx/app-dioxus/debug/android/app/app/src/main/jniLibs/x86_64/libmain.so",
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
```
|
|
|
|
The motivating case: an app with architecture-specific native code,
|
|
iterated against an x86_64 emulator, leaves its arm64 slice stale.
|
|
Rebuilding it on every local build would be wasted work, so it happens
|
|
lazily at the one moment a real phone is about to be handed the APK — the
|
|
app shows "Building for phone…" while it runs.
|
|
|
|
`staleWhen` compares two build outputs against each other rather than
|
|
either against source, which is what makes it cheap and needs no knowledge
|
|
of what the build reads. The consequence is that it only means anything
|
|
once `olderThan` has itself been built at least once.
|
|
|
|
### Files a build reads from somewhere else
|
|
|
|
A component is considered out of date when the commits under its own
|
|
directory move past the ones it was built from — `cwd`, or the project
|
|
root for a component that names none. That is right about where a
|
|
component's *files* are and wrong about what its build *reads*: a project
|
|
whose clients each source one shared script, which locates their SDK and
|
|
their signing key, has a file that changes what every build does and moves
|
|
no component's directory. A commit to it rebuilds nothing, and every card
|
|
correctly reports "current" while answering a narrower question than you
|
|
were asking.
|
|
|
|
A component can name the rest of what it reads:
|
|
|
|
```ron
|
|
Apk(
|
|
name: "app",
|
|
cwd: "app-dioxus",
|
|
build: "./build-android-arm.sh",
|
|
alsoWatch: ["scripts"],
|
|
),
|
|
```
|
|
|
|
Those paths join its own in the same comparison — a commit touching any of
|
|
them is a commit to this component, and an uncommitted change in one makes
|
|
its freshness unknown, exactly as one in its own directory does. Declared
|
|
rather than worked out, because which files a build reads is not something
|
|
this server can know: guess too wide and every commit rebuilds everything,
|
|
too narrow and you are back to the silence above.
|
|
|
|
Paths are relative to `projectPath` (absolute ones are also accepted), and
|
|
`build` is argv, run without a shell — written either as a line you would
|
|
type or as explicit arguments; the string form splits on whitespace and
|
|
has no quoting, so an argument containing a space needs the array.
|
|
|
|
### Servers, and the service script
|
|
|
|
A `Server` component is a long-running process on the **build machine**,
|
|
and it names one script that this server drives it through:
|
|
|
|
```ron
|
|
Server(
|
|
name: "backend",
|
|
build: "cargo build --release --manifest-path ../server/Cargo.toml",
|
|
service: "../server/service",
|
|
),
|
|
```
|
|
|
|
That script is run as `<script> <subcommand>` for `install`, `uninstall`,
|
|
`start`, `stop`, `restart` and `status`. Nothing about systemd or OpenRC
|
|
is compiled in here — which init system is present, and how a unit gets
|
|
written into it, is knowledge that belongs where the service does. See
|
|
`server/service` in this repository for one that handles both; it is meant
|
|
to be copied.
|
|
|
|
`status` has the only contract. It prints exactly one of:
|
|
|
|
| | |
|
|
|---|---|
|
|
| `running` | the service is up |
|
|
| `stopped` | installed, not running, because somebody stopped it |
|
|
| `failed` | installed, not running, because it fell over |
|
|
| `not-installed` | no unit for it |
|
|
|
|
and exits 0. Anything else it prints, or any non-zero exit, means it could
|
|
not tell — which the card shows as "couldn't check" rather than as a
|
|
service that is down.
|
|
|
|
`failed` earns its own word because both alternatives mislead. Reporting a
|
|
crash as `stopped` sends you looking for who stopped it, and exiting
|
|
non-zero reports "couldn't check" when the script found out perfectly
|
|
well. Note that OpenRC *does* exit non-zero for a crashed service, so a
|
|
script leaning on the exit status hits exactly that — read the word it
|
|
prints instead. The card draws `failed` in red, beside the green it draws
|
|
`running` in.
|
|
|
|
**A service script must never prompt.** It is run with stdin closed and no
|
|
terminal, so a `sudo` password prompt would not fail — it would hang until
|
|
the timeout with the card stuck mid-action. Anything needing root should
|
|
exit with a message telling you to run it by hand once.
|
|
|
|
The card shows the state and offers only what fits it: `Install` when
|
|
there is no unit, `Start` when it is stopped or failed, `Stop`/`Restart`
|
|
when it is running. Uninstall asks first. State is read in the background on the same
|
|
refresh that checks git remotes, never while the manifest is being built —
|
|
asking a service manager costs a process spawn, and the manifest is
|
|
fetched on every open and resume.
|
|
|
|
A successful build restarts a server that is **already running**, and
|
|
leaves a stopped one stopped: you stopped it on purpose, and a build is no
|
|
reason to overrule that.
|
|
|
|
### Letting a project carry its own build step
|
|
|
|
Writing that block into this machine's `config.ron` works, but it puts
|
|
project knowledge on the machine rather than in the project. A project can
|
|
instead carry a `.dev-updater.ron` of its own, in the directory you would
|
|
add — every key it understands, all of them optional.
|
|
|
|
These files are [RON](https://github.com/ron-rs/ron) with one house rule: a
|
|
file is the *body* of the config, so it has no outer parentheses and
|
|
nothing in it is indented for them. Comments and trailing commas are fine,
|
|
and an optional value is written as itself rather than wrapped in `Some`.
|
|
|
|
```ron
|
|
// What to call this project before there is a build to read a label from.
|
|
// A built APK wins: it is the authority on what will actually install.
|
|
label: "Thing",
|
|
|
|
// Offer a Pull button. On by default; set false for a checkout that should
|
|
// never be moved from a phone. Per project -- there is one checkout.
|
|
gitPull: true,
|
|
|
|
// What this project produces, in the order it should be produced. A
|
|
// component that fails stops the ones after it.
|
|
components: [
|
|
// An APK, installed on the phone.
|
|
Apk(
|
|
name: "app",
|
|
// The command, either as a line the way you would type it or as
|
|
// explicit arguments. It is argv either way, run without a shell:
|
|
// a program with a "/" in it resolves against the project
|
|
// directory, anything else is a PATH lookup. The string form
|
|
// splits on whitespace and has no quoting, so an argument
|
|
// containing a space needs the array.
|
|
build: "./build-android-arm.sh",
|
|
// Working directory, relative to the project. Omit to run in it.
|
|
cwd: ".",
|
|
// Serve a debug-symbol-stripped copy. Off by default, and a
|
|
// decision rather than something guessed from the APK's contents.
|
|
strip: false,
|
|
// Run the build before a download when the first path is older
|
|
// than the second. Omit it and it runs when a pull brings commits,
|
|
// or when nothing is built yet.
|
|
staleWhen: (
|
|
path: "…/arm64-v8a/libmain.so",
|
|
olderThan: "…/x86_64/libmain.so",
|
|
),
|
|
),
|
|
// A server on the build machine. Delivering one is restarting it, so a
|
|
// successful build does that -- there is no flag asking whether to.
|
|
// The script is what knows which init system is here; see above.
|
|
Server(
|
|
name: "backend",
|
|
build: "cargo build --release",
|
|
service: "../server/service",
|
|
),
|
|
],
|
|
```
|
|
|
|
Most projects need far less than that — a label and one component with a
|
|
command is the usual whole of it:
|
|
|
|
```ron
|
|
label: "AI Sessions",
|
|
|
|
components: [Apk(name: "app", build: "./build-apk.sh")],
|
|
```
|
|
|
|
dev-updater configures its own entry this way too, in `app/`, rather than
|
|
compiling the answer in — see that file for the self-update shape.
|
|
|
|
That file is a **request, not an instruction**. Nothing in it runs until
|
|
you have read it on the app's card and pressed *Accept build step* —
|
|
whereupon it is copied into `config.ron`, and from then on behaves
|
|
exactly like a hand-written one. The card shows the request in full rather
|
|
than a diff against the last one: these are a few lines, so reading the
|
|
whole thing is quicker than reading a change to it, and doesn't depend on
|
|
remembering the previous version. It is the same bargain an AUR helper
|
|
offers when it shows you a PKGBUILD.
|
|
|
|
If the file later changes — which is to say, when a `git pull` brings a
|
|
new one — it goes back to being unaccepted: the card shows the new request
|
|
and the previously accepted command stops running until you accept again.
|
|
Pulling still works while something is waiting, and stops after the
|
|
fast-forward; that is how the new request arrives to be read in the first
|
|
place.
|
|
|
|
The reason for the ceremony is that the two halves of this server carry
|
|
very different risk. An APK it serves is sandboxed by Android and you
|
|
already chose to install it. A component's `build` runs on the build machine
|
|
as the user who started the server, so a checkout must never be able to
|
|
change what that command is without anyone seeing it. Accepting is not a
|
|
claim that the build *script* is safe — its contents live in the
|
|
repository and change freely with every pull, which is exactly the trust
|
|
anyone building the project already extends. It is the narrower claim that
|
|
this project runs a build step at all, and that this is the one.
|
|
|
|
## Enrolling a phone
|
|
|
|
Every request to the TLS port carries a bearer token. Pinning
|
|
authenticates the server to the phone; the token is the other direction,
|
|
and it is needed because these routes can repoint the scanner, enumerate
|
|
what is on disk, add apps by path, and run a configured build command on
|
|
the build machine.
|
|
|
|
The server generates one on first start and prints it as a QR code, which
|
|
is the whole enrollment flow — open the app on an unenrolled phone, tap
|
|
**Scan QR code**, and point it at the terminal. There is deliberately no
|
|
manual-entry form: the token is 256 random bits, so typing it is not a
|
|
thing anyone would do.
|
|
|
|
```sh
|
|
dev-updater --rotate-token # a lost phone: invalidates every enrolled
|
|
# token and prints a fresh QR
|
|
```
|
|
|
|
Only the SHA-256 of the token is stored, in `config.ron`, so a leaked
|
|
config doesn't leak the credential — which also means it is shown exactly
|
|
once, at the moment it is generated. On the phone it is kept encrypted
|
|
under an Android Keystore key. Rejections are logged with the peer address
|
|
and never with the header, and they are delayed slightly so a port scanner
|
|
shows up as a slow drip.
|
|
|
|
The bootstrap listener (`--download`) is deliberately *not* behind the
|
|
token: it exists for a browser that has nothing to authenticate with yet,
|
|
and it serves only this app's own APK.
|
|
|
|
## Reachable only through WireGuard
|
|
|
|
Both listeners bind the `wg0` address and nothing else, so neither is on
|
|
the LAN. That is the outer of the two gates — the tunnel decides who can
|
|
try, the token decides who is answered — and it is worth having on its own
|
|
account: an unenrolled scanner never reaches the token check, and the
|
|
bootstrap port's plain HTTP travels inside the tunnel's encryption. As a
|
|
bonus the updater works away from home, which on a LAN address it never
|
|
could.
|
|
|
|
It fails to start if `wg0` is down rather than falling back. The escape
|
|
hatch is explicit — `--bind 0.0.0.0` restores the old LAN behaviour, still
|
|
behind the token but with nothing in front of it — because this server is
|
|
also how a stranded app gets reinstalled, and that recovery shouldn't
|
|
depend on the tunnel being healthy.
|
|
|
|
## Why TLS, and why plain HTTP for the bootstrap
|
|
|
|
Everything on the main port either *is*, or *decides*, what the app hands
|
|
to `REQUEST_INSTALL_PACKAGES` next, so a MITM there could install arbitrary
|
|
bytes. The app pins the dev CA in `certs/` and trusts nothing else — not
|
|
even the system trust store.
|
|
|
|
The `--download` bootstrap port is plain HTTP on purpose: a stock
|
|
browser has nothing to pin against before the app is installed, so TLS
|
|
there would only mean a trust-warning wall instead of a working link. It
|
|
serves that one APK and nothing that can change server state.
|
|
|
|
The server generates its own CA on first start, into
|
|
`$XDG_CONFIG_HOME/dev-updater/certs` — outside the repo, so a machine
|
|
that can read the repo can't read the key that signs certificates this app
|
|
trusts. The app embeds whatever `ca.pem` is there when `app/build-apk.sh`
|
|
runs, so the pinned certificate follows the machine that built the APK and
|
|
there is nothing to paste.
|
|
|
|
Regenerating the CA strands the installed app — it can only be updated
|
|
*through* the pinned server — so recovery means rebuilding it and
|
|
reinstalling over the bootstrap port.
|
|
|
|
## Large APKs
|
|
|
|
An APK whose native libraries make up most of its size can be served as a
|
|
debug-symbol-stripped, re-signed copy (`<name>.slim.apk`, cached against the
|
|
original's mtime+size). One real case goes 224 MB → 53 MB. The untouched
|
|
build stays on disk for local `ndk-stack`/`logcat` work; only the slim copy
|
|
is served.
|
|
|
|
It is `strip: true` on the APK component, declared rather than detected.
|
|
Whether the symbols are worth the transfer is a judgement about the
|
|
project — a Compose app with a few small AndroidX `.so` files saves under
|
|
1% — and guessing it from the APK's contents was a guess that then needed
|
|
an override anyway. It stays this server's business rather than the build
|
|
script's because it is a property of *this* hop to a phone: a project
|
|
built without dev-updater has no reason to strip.
|
|
|
|
## Testing
|
|
|
|
The Kotlin half has no unit tests, but it does have two checks that
|
|
should stay green: `./gradlew :androidApp:lintDebug` and ktfmt (`ktfmtCheck`,
|
|
or `ktfmtFormat` to fix). Formatting is plain `kotlinLangStyle()` with
|
|
nothing configured, the same bargain `cargo fmt` makes on the Rust side.
|
|
|
|
`./run-tests.sh` (forwards arguments to `cargo test`). Covers the parts
|
|
with logic worth testing: APK discovery, config round-tripping, staleness,
|
|
the git read/pull/check split, token gating, certificate generation, the
|
|
acceptance gate, and the self-restart predicate. The app is UI over the
|
|
server's HTTP API and is verified by running it.
|
|
|
|
## Versions
|
|
|
|
Kotlin 2.4.10 · Compose Multiplatform 1.12.0 · AGP 9.3.2 · Gradle 9.7.1 ·
|
|
JDK 21 · compileSdk/targetSdk 37. `server/` targets Rust edition 2024.
|
|
|
|
Compose Multiplatform's material3 is on a separate release train and is
|
|
pinned separately, at 1.9.0 — the newest material3 is still an alpha
|
|
while runtime/foundation/ui are stable at 1.12.0.
|