`adb shell run-as` refuses on a release build — "package not debuggable" — and
the app's private storage is then unreachable from the host. That storage is
where the face shards, the thumbnail store and the catalog live, so when a
device disagrees with the desktop about what it has synced there is no way to
find out which of them is right. An evening was spent guessing at exactly that.
`DARKROOM_DEBUGGABLE=1 ./docker/android/package.sh --install` now sets
`android:debuggable` through aapt2's `--debug-mode`, and nothing else changes.
Set through aapt2 rather than written into `AndroidManifest.xml` on purpose: the
flag then exists only for the build that asked for it, and a release build
cannot inherit it because somebody forgot to take it out again. A debuggable APK
lets any process on the device read this app's files, so it belongs on a test
tablet and nowhere else.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
package.sh does two things: it decides how the host reaches the image, and
it assembles an APK once inside it. Only the first half is host-specific.
CI already runs in that image, so the second half was about to be copied
into a workflow step -- two copies of aapt2/zipalign/apksigner ordering,
drifting apart at whatever rate the toolchain moves.
So it moves to docker/android/assemble-apk.sh, which assumes it is inside
the image and takes its paths from the environment, because the callers
disagree about them: the container mounts the repo at /work, the runner
checks it out wherever it likes. Every default reproduces what package.sh
did, so the host path is unchanged.
Two things stop being hard-coded on the way. The build-tools version and
the compile SDK are resolved from what is installed rather than written
out as 36.0.0 and android-36 -- the versions are Dockerfile ARGs, and a
second copy is a second thing to miss when they move. --min-sdk-version
now comes from that same ARG instead of a literal 28, which is the number
the API-level check in CI already reads.
The intermediates are removed at the end. They were harmless in a cache
directory nobody looks at; beside a published artefact they are four more
files for a glob to pick up by mistake.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The workspace read 0.1.0 for two releases, so every desktop binary reported a
version two releases stale. The APK was worse: `AndroidManifest.xml` states no
version at all, so a device showed `versionName=null` and `versionCode=0` while
the library inside the APK knew exactly what it was. A version edited by hand
in several files is a version that is wrong in at least one of them.
`tools/set-version.sh` is now the only thing that sets one. It takes the
version from the latest git tag, or is told, and writes the two files that must
state it before anything is built: the workspace `Cargo.toml`, from which every
crate inherits, and `packaging/PKGBUILD`, which pacman reads before a build
exists. It refreshes `Cargo.lock`, because members appear there by version and
CI builds `--locked`. `--commit` commits the result.
Android is not in that list on purpose. `package.sh` reads the version out of
`Cargo.toml` and hands it to `aapt2 link`, so the APK cannot drift from the
binary it contains — there is no third file to forget. `versionCode` has to be
one increasing integer, which a semantic version is not, so it is packed as
MAJOR*10000 + MINOR*100 + PATCH: ordered the way Android requires, and readable
at a glance.
A version that is not MAJOR.MINOR.PATCH is refused rather than coerced. It is
a contract with whoever reads a bug report, and silently turning "0.4" into
something else is worse than being asked to type it again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was no icon anywhere, and on Android that was not a missing line in the
manifest. `aapt2 link` was being handed a manifest and nothing else, so the
APK carried no res/ and no resources.arsc — there was no table for an
`@mipmap/...` reference to resolve against even if one had been written.
Packaging now compiles the resource tree first and links the result in, which
is the two steps aapt2 insists on: link reads compiled input only, never a
directory.
That absent table is also why the launcher caption was blank, which had
looked like a second, separate bug. `android:label="DarkRoom"` was there and
correct the whole time, and Settings' App info read it fine; the launcher
could not, because resolving a label goes through the package's Resources and
there were none to open. Nothing about the label changed here. It came back
with the table under it.
`android:icon` then names one drawable for both icon generations, because the
`anydpi-v26` qualifier is what separates them. API 26 and up take the adaptive
icon and its three layers; the third of those, monochrome, is what lets
Android 13 recolour it rather than drop the app out of the themed set. Below
26 the same name lands on a density-matched PNG. `roundIcon` is deliberately
absent — a launcher old enough to read it is one that would ignore the
adaptive XML, and minSdk is 28.
The desktop icon is one `@image-url` on the window, and the only raster asset
in a UI that is otherwise entirely Path. The reasoning at the top of
icons.slint does not reach it: that is about glyphs a font might not carry,
and this image is never drawn by us at all. It goes to the window manager,
which wants pixels and composites them unmasked, so it is pre-shaped with
rounded corners rather than square the way the Android layers are.
Which exposed Slint's resource default. An `@image-url` compiles down to the
absolute path it had on the build machine, to be opened at runtime — already
wrong for Android, where the build happens under /work inside a container and
no such directory exists on the device, and wrong silently, as an image that
loads empty. `EmbedFiles` puts the bytes in the binary instead. It reaches
nothing else, since every glyph is a Path.
Verified on a device: the APK installs and the home screen draws both the
icon and "DarkRoom" under it, where before it had neither. In the link step
the adaptive icon resolves at all six densities and resources.arsc lands
uncompressed, which API 30 requires and the existing zipalign preserves. On
the desktop by reading _NET_WM_ICON off the running window — 256x256, as
handed over. Where that actually shows is narrower than it sounds, and the
comment says so: Wayland ignores the property in favour of matching app_id
against an installed .desktop file, which this repo does not install.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The grid now zooms, which needs thumbnails at two resolutions rather than
one, and exposed a scrub that landed in the wrong place.
**Two thumbnail size classes.** `ThumbSize::Grid` (256px, ~10 KB) and
`Large` (1024px, ~45 KB), with the class part of the store key so both
coexist. Storing everything large would take the reference library from
~200 MB to ~860 MB, and shards sync, so that is transfer cost on every
device rather than only disk. A store written before the class existed
migrates in place: its entries are all grid-sized, which is what the
column defaults to, so nothing already fetched is discarded.
`forget` now drops every size for an image. Reading a single row left the
other class's bytes on the shard's tally for good, sealing it early on
space nothing occupied.
**Grid zoom.** Ctrl+wheel and pinch resize cells between 90px and 420px in
geometric steps, so the gesture feels the same at either end where a fixed
pixel step would be imperceptible at 400px and violent at 90px. Crossing
256px switches to the large class, so a zoomed cell is sharp rather than
upscaled. Columns and window capacity already derived from cell size, so
the grid reflows for free.
**The scrub landed about half a library too high.** It counted only dated
images while the grid shows all of them — 10,733 dated against 19,841
rows — and ignored `shadowed_by`. Verified against the live catalog: the
old formula gave 10,887, the new one 10,732, the true grid position
10,732. The scrub's count and the grid's window must use identical
predicates and ordering; a test now fails if they diverge.
**Timeline gestures are continuous.** Scrub and pan were quantised to
whole buckets, so a slow drag did nothing until it crossed a boundary and
then jumped a month. Both work in fractions of the visible span now, and
pinch-to-zoom arrives for tablet, where there is no wheel to reach the
axis with.
The pinch accumulator was wrong on first writing: it took at most one step
per update, so an 8x spread — three doublings — yielded one zoom level.
`log2().trunc()` now extracts every whole doubling and carries the
remainder. The original test asserted the wrong number and defended it in
a comment, which is worth remembering: a test can entrench a bug as
readily as catch one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two breaks that only appeared on a full `cargo test --workspace`.
`slint::android` exists only when compiling for Android, so darkroom-android
failed to compile on the host even though it is a workspace member. The entry
point is now gated on the target rather than on a feature.
The timeline forwarded `scrub` where the Timeline component declares
`scrub-to`, which the Slint compiler rejects.
Assisted-by: LLM