Writing a whole Android app in Rust
walker is an Android app written in Rust: egui on wgpu, offline vector maps, an embedded SurrealDB and a little Java. What worked and what hurt.
· Malik · 7 min read
One crate, two builds
Reviews and the desktop build never found a lifecycle bug. Each one was found on a phone. Most of the app was written and tested at a desk, in one language, and the phone got only what a desk can't show.
The language is Rust, and the app is walker, the walk, hike and ride tracker I make for Android. It keeps every track on the phone. The screens, the map, the database and the maths behind the numbers are one Rust crate. Java is left for the few things Android offers only to Java. There is no Kotlin and no Gradle.
The crate is built two ways: as a library that Android's NativeActivity loads on the phone,
and as a desktop build that opens a phone-sized window with the same app. I spend most of my
time in that window and in cargo test. A phone is for what the desktop can't show: touch,
the keyboard, TLS, JNI, the Android lifecycle, and insets, the edges of the window that the
status bar and the navigation bar cover.
The stack
For the UI I chose egui, through eframe. egui calls itself "a simple, fast, and highly portable immediate mode GUI library for Rust". Every frame, the code describes the screen again, and there is no widget tree to keep in sync with the data. eframe handles input and rendering, and offers two renderers, glow and wgpu.
The map chose between them. Maps are map regions the user downloads: extracts of the Protomaps planet build in PMTiles, a single-file archive of tiles. walkers, a slippy-map widget for egui, draws them as vector tiles, and its vector tiles need egui's wgpu renderer. That made the renderer wgpu, "a safe and portable graphics library for Rust based on the WebGPU API". How a map's contour lines and hillshading are made on the phone is the post that follows this one, Making contour lines and hillshading on the phone.
The database is SurrealDB, embedded in the process. It runs on SurrealKV, a key-value engine written in Rust, with everything that could reach the network switched off. I avoided the RocksDB engine because it is C++ and painful to cross-compile. SurrealDB is async and egui is not, so the database lives on a thread of its own. The UI never awaits it: it sends commands and picks up the answers once a frame, and SurrealDB's types never leave that module. A recording bypasses the database altogether. Each fix goes to a journal file, and the database sees one write when the activity is saved. What the database holds, and how it gets off the phone only as an encrypted backup, is in An app with no server.
The part that turns a noisy GPS track into distance and climb is a factor graph with a Levenberg–Marquardt solver, written in walker itself. Cleaning GPS tracks with a factor graph on a phone covers it, and Using every satellite your phone measures goes a level deeper, to the raw satellite measurements the phone reports.
The Java that is left
NativeActivity gives Rust a window and input events, and little else. What it can't do sits
in seven Java classes and a two-class port of a TLS helper (below). Together they are 3 % of
walker's code, as cloc counts it:
MainActivityforwards whatNativeActivitydrops, such as insets and permission results, and holds every method Rust calls.TrackingService, a foreground service, keeps a recording going with the screen off.Sensinghands GPS, the barometer and the step counter to Rust.Notices,RemindersandShareProvidercover notifications, a daily alarm and sharing a file.TextInputis an invisible view for the soft keyboard to type into.
Google now recommends GameActivity for new C and C++ apps. It is a Jetpack library, and it
brings text input with it. I stayed with NativeActivity, which is part of the platform, and
walker stays free of Kotlin, Gradle and AARs, the archives that Android libraries ship as. The
price of that choice is the keyboard, below.
Building without Gradle
I started with cargo-apk and dropped it: it can't declare a service or add Java code. One shell script now builds the APK with the SDK's own tools and no Gradle. The native library goes in uncompressed and 16 KB aligned, so Android can map it into memory straight from the APK instead of unpacking a copy first. The same script builds the App Bundle for Google Play.
One C dependency slipped in anyway: TLS brings in aws-lc-sys, whose build fails for Android until it is pointed at the NDK's compiler.
Testing
The source has 541 tests. Fourteen are ignored by default: timings, probes that read a
local file, and the only two tests that use the network. All of them run on the host with
cargo test, and clippy runs for the Android target as well as the host, because a host-only
clippy never sees the Android-only code. The rule that makes this work is to keep domain
logic free of UI and Android types. For layout, a script drives the desktop build, taps,
types and takes screenshots. The Java has no tests.
That leaves the lifecycle, where the desktop build has found nothing. After any change to the service or the activity, a checklist runs on a device: Home while recording, clearing Recents, killing the process, and a save dialog left open while Android destroys walker.
What hurt
Destroying the activity hung the app
android-activity's onDestroy blocks Java's main thread until the Rust side returns. winit
ignores the destroy (its source says "TODO: forward onDestroy notification to application"),
so the Rust side never returned. GPS fixes arrive on that main thread, so they stopped, and
Android killed the recording service as not responding. On my OnePlus 7 Pro a plain Home
press destroys the activity, so this was the common path. Starting the app a second time in
the same process fails too, because winit creates its event loop once per process. The fix:
on destroy, the app syncs the recording journal to disk and ends the process. The next launch
gets a fresh one, and the recording comes back from the journal. The cost is that the
database never shuts down cleanly on Android, so it replays its log at every start.
The soft keyboard
NativeActivity's view offers the keyboard nothing to type into, which is what TextInput
is for: it takes the keyboard's edits and passes them to Rust as egui events. Then the
keyboard flickered and lost text. While a text field had focus, egui hid and re-showed the
input method every frame. The hide dismissed my keyboard, and the show went to the wrong
view. The fix is to stop egui touching the keyboard on Android at all. One gap is still
open: an emoji can be deleted in pieces.
JNI fails late
Renaming a Java class, method or package breaks the Rust side silently, until that method is first called. That is also why the package name has no underscores, which JNI encodes in a way that is easy to get wrong. And the service has to load the Rust library itself, because a process started only for the service never loads the activity.
TLS
The Rust crate that checks certificates against Android's trust store calls a Kotlin class over JNI. The APK carries no Kotlin standard library, so I ported the class to Java, and a test fails when the crate's version changes.
Dependencies that disagreed
SurrealDB and walkers wanted versions of the same geometry crate that couldn't both be met, so walker carries a patched copy. The emulator's graphics driver crashed on wgpu's debug labels, so those are off on Android. And one SurrealDB release loses a write if a record is deleted and written again in one transaction.
Debug builds
Full debug info made the debug APK about 320 MB, so it keeps less. The dependencies are optimised, but walker's own code stays unoptimised for fast rebuilds, and that has a cost on the phone: cleaning two walks at launch took 37 seconds on the debug build.
Size and speed
SurrealDB made the arm64 library 62 MB. A release profile that trades build time for size brought it down to 25 MB. It has grown since, to 37.0 MB on 27 September 2026, 5.7 MB of it a catalogue of regions compiled in. A clean release build takes about three minutes.
On battery I have no measurement to share yet, so I won't claim one. What I can describe is the design: the database isn't written once a second while recording, and the notification shows numbers rather than a map, because drawing the track into it every few seconds would cost battery.
Would I do it again
Yes. One language from the sensor callback to the database means the types go all the way through, and the desktop build and host tests catch most mistakes in seconds. The cost is at the edges, where Android expects Java: the lifecycle, the keyboard and JNI. Budget for those, and keep a phone plugged in.