Note: this only reflects my personal work this month. Visit the BTC Map Blog for consolidated monthly reports.
Table of Contents
Android
I predicted last month that the Compose Multiplatform rewrite would dominate this report, and it did. What started as a shared module and a proof of concept is now the whole app: Android runs the same Composables as the new desktop client, the old View layer is gone, and for the first time BTC Map has a native build for Linux, Windows and macOS.
Compose Multiplatform
The migration happened in the open, one slice at a time, and it touched every screen. The Android app is now a single host fragment wrapped around the shared UI root, and the pile of fragments, dialog fragments and XML layouts it used to carry is down to two layouts.
Moving code into the shared module also meant leaving the Android-only libraries behind:
- OkHttp became Ktor, Gson became kotlinx.serialization, and java.time became kotlinx-datetime
- The database layer turned suspending, which the upcoming web target needs, with a small bridge for the synchronous Compose screens
- A wasmJs target now compiles the shared core, so a web client is no longer blocked at the data layer
- Android and desktop share one string catalog, translated into all 36 app languages
Desktop App
The new :desktopApp module renders the shared UI in a desktop window.
There is no platform-specific UI to maintain, so desktop gets the same features as Android, plus a few that make sense with a mouse:
- Right-click the map to add a place, event or note at the clicked point
- Hover states on the area chips and the toolbar
- Keyboard shortcuts and copy actions in the search field
- A language setting that applies at the next launch
Compose Desktop has no native Wayland backend on Linux, so it runs through XWayland, and Java 2D applies only whole-number scales there. A KDE session at 275% arrived as 200%, and the shared UI looked smaller than it does on Android. The app now reads the real scale from the compositor and uses it as the window density.
Packaging
Conveyor builds the release packages on our Linux machine: a Linux AppImage, a Windows MSIX with an installer and a portable zip, and a signed macOS app with a Sparkle update feed. Previously we could only produce the AppImage, because jpackage cannot cross-compile. The Windows and macOS packages are still self-signed, so the installer asks the user to trust the certificate first, and the Windows build installs but does not start yet, which is an upstream MapLibre runtime issue.
Notes
Notes are a new feature I am quite happy about. A signed-in user drops a free-form text pin anywhere on the map, and it is private by default with an option to publish it. They are meant to add context regarding the surrounding area.
In the app, notes are a fourth marker layer next to merchants, events and exchanges. They are never clustered, they wear the standard marker colours (or a chosen icon), they sync incrementally like the other tables, and a new My notes screen lets you edit, flip visibility and delete them.
Events & Submissions
Submitting an event from the app was impossible until now. There is a proper add-event screen with date and time pickers and a positioning map, and the time is sent as a floating local wall-clock plus timezone=auto, so an event at 19:00 happens at 19:00 where it is, not where the submitter is.
The rest of the submission lifecycle came with it:
- A My events screen listing every submission, upcoming first, with duplicate and revoke actions
- Revoking a pending submission through the new self-service endpoint
- An event review queue for event managers, admins and roots, badged with the number of pending submissions
- An event sheet on the map, so an event opens in place instead of a separate screen
- A delete action for the submitter while the event is pending, and for privileged roles at any time
Admin & Moderation
The app is no longer only for reading. Area managers get a Manage areas screen listing cached communities with never-verified areas first, a detail screen with the area polygon and its fields, and the ability to verify an area and edit its name and description. Admins and roots get user management (roles and geofence, following the server policy exactly) and a place image moderation screen for the new uploads.
Map Work
MapLibre Compose 0.20.0 landed this month, and most of the map work went into closing the gap with the old Google Maps implementation:
- A compass above the community chips that appears whenever the view is not facing north
- A map tilt setting, and rotation, tilt, centre and zoom all remembered
- A place stays selected and the pin stays visible when you leave the map to report, boost or comment
- The map’s features load before the first frame instead of waiting for a move
- Correct CJK and Hangul labels through a bundled glyph provider with an OpenFreeMap fallback
- Search accepts coordinates, and OpenStreetMap results from Nominatim now sit under the local BTC Map hits, paced to its one-request-per-second policy
- Basemap points of interest that are not merchants open a small sheet with the localized name (with pinyin ruby), category and coordinates, plus “It accepts bitcoins” and “Save as note” actions
Beta Updates
Beta builds can finally update themselves in place: the update button downloads the APK with a progress bar and hands it to the system installer, the manifest carries a build time so a rebuild at the same version code is still offered, and the settings screen gained an About section with a manual update check.
Dev Tools
The devtools script grew into a real tool. app run covers the desktop app (under Compose Hot Reload) and Android, which now runs in Waydroid instead of a Google AVD, since the container boots Android on the host kernel and needs neither the emulator binary nor a system image. app deploy-beta takes the platforms to publish as flags, deploy-release became prepare-release, and the thin adb and Gradle wrappers are gone.
API
The API added the rest of the surface the new clients need. Most of it is the v4 REST API catching up with the RPC layer, since mobile apps never speak RPC.
Notes
The notes feature started on the API side. A new note table holds a coordinate, text, an icon and a public flag, and the v4 API exposes POST /v4/notes, GET|PATCH|DELETE /v4/notes/{id}, GET /v4/notes/search and GET /v4/users/me/notes. Public notes are geo-searchable, private ones are visible only to their author, and the owner list supports updated_since and include_deleted so clients can sync incrementally.
Place Images
Photos went from report evidence to a first-class store:
POST /v4/places/{id}/imageslets signed-in users attach a photo to a place directly, andGET /v4/places/{id}/imagesserves the metadata- Photos are validated like report evidence: at most 10 MB decoded, at most 20k pixels per side, raster formats only
DELETE /v4/places/{id}/images/{image_id}removes an upload, but only the uploader, admin or root may call itGET /v4/place-imageslists the newest uploads across every place for moderation, andGET /v4/users/me/place-imageslists your own- The uploader is exposed as a nested
authorobject, resolved acrossimage.dbandmain.db
Event Review
Events now carry a review status (pending, live or rejected) and an optional submitted_by attribution. Signed-in users submit through POST /v4/events, track their own through GET /v4/users/me/events, and revoke a pending submission with DELETE /v4/events/{id}. Event managers and above approve or reject through PUT /v4/events/{id}/status. Public reads stay live-only by default, and the geofence check moved into a shared service/geofence module used by both REST and RPC.
Areas
Area management opened up over REST:
POST /v4/areascreates an area with a fixed set of named fields, rejecting geofenced callers, since a new top-level area falls outside everything they managePATCH /v4/areas/{id}updates named fields only, with an immutable alias, nulls clearing optional tags and contact channels filtered through an allowlistverified_atis now part of the area response and the delta syncGET /v4/areas/{id}/reportsreturns a single area’s daily history in a typed shape instead of a raw tags map
Users & Access
GET /v4/users is an admin/root-only username search, and PATCH /v4/users/{id} updates roles and geofences while keeping admin as the role ceiling. Token versus user role precedence moved into one Auth::effective_roles helper so REST and RPC authorize identically.
I also started tracking last_used_at on access tokens, so unused or leaked tokens can be spotted. It is stamped on successful authentication and stays out of the updated_at trigger.
Dashboard
GET /v4/dashboard/infra serves the analytics dashboard to the dashboard, admin and root roles, mirroring the RPC gate.
Maintenance
A large part of the API’s month was paying down dependency debt, and it went deeper than version numbers:
- rusqlite 0.40 and deadpool-sqlite 0.14, moving bundled SQLite to 3.53.2
- nostr 0.45, matrix-sdk 0.19, argon2 0.6, sha2 0.11 and base64 0.23, with the breaking changes handled and existing password hashes still verifying
- clap is gone, after disabling a default feature on
namesthat pulled it in for a CLI we never use - Declared version floors raised to match
Cargo.lock, so the manifest describes what we actually build
The most satisfying cleanup was the OpenGraph image renderer. We used the staticmap crate, which dragged in an old rustls and six RUSTSEC advisories. It is replaced by an in-house renderer that fetches the covering tiles, composites them with the image crate, draws the marker so its tip sits on the element and encodes a 600x315 JPEG.
Docs
The new endpoints came with REST documentation pages for notes, place images and the infrastructure dashboard, and the users page grew the admin search and update calls.
CLI
area get,area addandarea updatenow use the v4 REST API instead of RPC, with percent-encoded path segments for aliases with spaces or non-ASCII charactersset-area-tagis replaced byarea update, which supports--name,--type,--descriptionwith--clear-description,--geojsonand repeatable--contactarea addrequires--typeand accepts an optional--name
Ansible
Infrastructure work was mostly maintenance this month, and it was a good reminder of why it matters:
- Every host was running a kernel vulnerable to CVE-2026-98432, a remote root in the IPv4 TCP IP-options code. Several of them exposed Go listeners with no packet filter in front. All five hosts were upgraded, rebooted and verified with their services healthy.
- Database replication on the backup host had been failing since August 17 because the deployed script used
mkdirwhere the repo hasmkdir -p, and every run after the first aborted underset -e. The snapshot timer kept compressing copies frozen at that date. The repo fix never landed because the inventory pointed at a hostname that does not resolve. The correct script is deployed and a full 4 GB sync plus an incremental run were verified. - The new Overpass host joined the inventory with a full system upgrade and SSH hardening. I also documented an API outage caused by
fcgiwraprunning a single worker, where one large query blocked every other request for about seven minutes. The fix is documented but not applied yet.
Future Plans
A few things on my mind:
- Cut the 1.3.0 release.
- Get the desktop builds to a state ready for public beta.
- Keep pushing the web target, now that the shared core compiles for it.
- Continue moving the CLI and the docs from RPC to v4.