An app with no server
walker has no server, no account and no sync. How it keeps everything on the phone, how its encrypted backups work, and what that costs.
· Malik · 11 min read
What "no server" rules out
An app with no server has no login screen, no password reset and no "we'll restore it for you". For the developer it means no backend to run, no user table to secure and no bill that grows with users, but also no dashboard that says what is going wrong on someone else's phone. That rules out more than it sounds.
I chose it anyway. A location history says where you live, where you work and where you spend your time, and I didn't want to hand mine to anyone. That is why walker, the walk, hike and ride tracker I make for Android, has a short first principle: all state lives on the phone. There is no account, no server and no sync, and no telemetry or crash reporting either. This post is about what replaces those things.
Where the state lives
Everything walker keeps is in the app's private storage. The database is SurrealDB, embedded in the app: it runs in process on SurrealKV, a key-value engine written in Rust, with its files in the app's directory. No port, no daemon, and its client protocols are compiled out. The rest of the stack is in Writing a whole Android app in Rust, the post to start with.
Two kinds of data stay out of the database. A recording in progress goes to a journal file, each fix appended as it arrives, so the database sees one write per saved walk. And a walk's raw satellite measurements go to a separate file, LZ4-compressed and checked by SHA-256. LZ4 took one bike ride's raw data from 8.5 MB to 3.2 MB.
One design choice comes straight from having no server: the raw fixes are the record, and the cleaned track is derived from them. I can't migrate anyone's data, but I can ship a better cleaner, and every old walk improves with it.
The network, only when you ask
walker does go online, but only for downloads the user asks for or has allowed:
- a map region, extracted from the Protomaps planet build with HTTP range requests;
- that region's terrain, from Mapterhorn, for heights and contour lines;
- an area of TopPlusOpen, BKG's topographic map, or of a German state's aerial photos, from that state;
- the day's public satellite orbits, from BKG, for cleaning a walk from the raw measurements. Settings → Recording → Download satellite orbits has Always, Ask and Never, and the default is Ask: walker asks when a recording starts and when it is saved, if today's orbits aren't there yet. With Always, it downloads them without asking.
A map download tells its provider the user's internet address and which region they want. The orbit download tells its host the user's internet address and the date. Neither ever carries a position or a route.
All of this goes through one HTTP client, in one source file. Every operation is written to a network log, which Privacy & storage shows: date, host, region, number of requests and bytes. The logging rule reached into TLS as well. Certificates are checked against Android's trust store. Whether one has been revoked, walker learns only from the OCSP response the server staples to its certificate, that is, sends along with it. Letting Android fetch that answer itself would mean requests that never appear in the log.
Android's backup, switched off
Android backs up app data to Google Drive by default. For apps targeting Android 12 or later,
turning that off with allowBackup="false" may still leave device-to-device transfers on,
depending on the phone's maker. Either would copy the database, with every track, off the
phone. walker sets both: allowBackup="false", and data extraction rules that exclude every
domain from cloud backup and from device transfer.
To be fair to Google: Auto Backup is end-to-end encrypted from Android 9 with the phone's screen lock. I still wanted the copy to be a file the user sees and moves. That leaves walker responsible for backups itself.
Backups as encrypted files
A backup is one file, walker-<date>.walkerbackup. It holds settings, every activity with
its track and raw measurements, steps, the names given to routes, badges and the network log.
The user sets a passphrase of at least 10 characters and chooses where the file goes: a
folder, a memory card or a cloud drive, through the save dialog or the share sheet. That is
the hiker's view, and Own your walks tells it; this is
what happens inside the file.
The file starts with a small header that names its key derivation, its cipher and their parameters. Each file carries them, so I can make them harder later and old files keep opening.
The key
The key comes from the passphrase through Argon2id. I chose it because it is memory-hard: each guess at a passphrase costs memory as well as time.
is the key. is the passphrase, exactly as typed, so a trailing space counts. is the salt, random and fresh for every file. is the memory it fills, the number of passes over it, the number of lanes, and the key's length. walker's settings are close to RFC 9106's recommendation for memory-constrained environments, and on a phone they take about a second.
Because a file carries its parameters, it also tells walker how much memory to take, and a hostile file could ask for far more than any phone has. walker checks the parameters against sane bounds before it allocates anything, and refuses a file outside them as damaged.
The passphrase is wiped from memory after use, and so are the copies kept by egui, the UI library walker is built on: its text field keeps an undo history, even for a masked field, so walker clears that history every frame.
The cipher
The cipher is XChaCha20-Poly1305, an AEAD: it encrypts and authenticates in one step.
is the plaintext, here the settings and tracks. is a random 192-bit nonce, the associated data, and the ciphertext, as long as . is a tag over and . is authenticated but not encrypted, and walker makes it the whole header, so a changed parameter or salt fails exactly like a changed ciphertext. A wrong passphrase and a damaged file give the same message, because the cipher can't tell them apart: either way, the tag doesn't match.
Why 192 bits? A nonce must never repeat under one key. If messages are sealed under one key with random 192-bit nonces, the chance that two share a nonce is at most
Here is the number of messages and the number of possible nonces. Even gives less than . With plain ChaCha20-Poly1305 (RFC 8439) the nonce has 96 bits, the bound is , and four billion messages reach . For walker the bound hardly matters, though: every file gets a fresh salt, so no two files share a key.
Streaming
A year of raw measurements can run to hundreds of megabytes, and it shouldn't sit in memory. After the settings and tracks, the raw files follow in chunks of up to 1 MiB, each sealed separately under the same key. Each chunk is bound to its position in the stream, to the backup's header, to the raw file it belongs to, and to whether it is the last.
Why a chunk can't be moved, dropped or added
Each attack on the stream runs into one of those bindings:
- Changed: the tag no longer matches.
- Moved or swapped: each chunk is sealed with its position. Read anywhere else, the tag fails.
- Renamed: the raw file's id is part of what each chunk's tag covers.
- Dropped: every chunk after the gap sits one position early, and fails as if moved.
- Cut short, even neatly between two chunks: walker needs the chunk flagged as the last before the file ends. Only the real last chunk carries that flag, and nobody without the key can seal another.
- Extended: after the last chunk, walker requires the end of the file. One byte more is refused as damaged.
- Spliced in from another file: a different salt means a different key, and every chunk is bound to the header of the file it came from.
This is close to STREAM, the construction Hoang, Reyhanitabar, Rogaway and Vizár proposed in 2015 for encrypting a stream in segments, and which they prove secure whenever the underlying AEAD is. walker's scheme differs from it in small ways, so I don't call it STREAM, and I haven't written a proof for walker's variant. The argument above rests on the two facts behind STREAM's proof: no two chunks share a nonce, and the AEAD's tag can't be forged. walker's tests check each case in the list but the splice.
The overhead
The overhead is a few dozen bytes per 1 MiB chunk: the 3.2 MB bike ride is four chunks, which add 228 bytes, about 0.007 per cent. In a test, sealing twenty 3 MiB files raised the app's peak memory by 10 MiB and took 0.3 seconds each way.
Restore only adds
A restore never overwrites. It shows a preview first: how many activities the file holds, their date range, and how many are new on this phone. Then it adds only what is missing:
- Activities are told apart by id, made when the recording starts, so it is unique across phones. An activity this phone already has is skipped, and this phone's copy wins.
- Settings stay this phone's. They are in the file, but a restore applies none of them, apart from two bits of bookkeeping (below).
- Steps come without the step counter's reading, which belongs to one phone's boot. Counting carries on from the days this phone has counted.
- The network log is merged, without duplicates.
- Route names and badges are told apart by id, like activities.
So a restore adds the activities, step days and log entries the phone doesn't have yet. For an activity, the id alone decides; for a step day, the date.
Two properties follow, and I rely on both. Write for what the phone stores after restoring the file onto the phone's data . The first is that restoring twice changes nothing:
After the first restore, every id, day and log entry in is on the phone, so the second time there is nothing left to add. The only settings a restore may change are the date of the last backup and whether the phone counts as set up, and only when that date moves later. By the second restore the date is equal, so they stay.
The second is that two phones' backups combine in either order, with two exceptions. Restoring one file and then the other leaves the phone with the same activities, days and log entries as restoring them the other way round. What order can change is which copy wins when both files hold an activity or a day this phone doesn't have: the file restored first. If one phone renamed that walk, order decides which name stays. The date of the last backup can differ too, because it moves only when a file holds everything the phone has at that moment.
Both properties need ids that are unique across phones. They are UUIDv7 (RFC 9562), with about 73 random bits after the start time, so a clash across phones is vanishingly unlikely: ten thousand activities, a walk a day for more than 27 years, give about .
Because nothing is changed or deleted, a restore needs no scary confirmation. Replacing everything is two deliberate steps: Delete all data, then restore.
Moving to a new phone is a backup on the old one and a restore on the new one, through Backup & restore; the help has the steps. Map regions aren't in the backup. They are public data, and the new phone downloads them again in Map packs.
One practical detail for anyone restoring into SurrealKV: a transaction has to fit in one memtable, an in-memory buffer of fixed size, and one transaction holding every track can outgrow it. walker writes the tracks first, in small batches, then the activities that point to them. A track is only ever read through its activity, so what the user sees is all or nothing.
What it costs
Local-first is a trade, and the costs are real. The first is that there is no cloud safety net. If the phone is lost and there is no backup, the walks are gone. walker reminds you on the map when the last backup is over 30 days old, and you can change that. It can't do more than remind.
The file and the passphrase are yours to keep. A forgotten passphrase can't be recovered, by me or anyone else. When a backup goes to another app through the share sheet, walker says "Handed to the app you chose. walker can't tell whether it arrived." That is literally true.
walker is one phone at a time. There is no sync between devices: a backup can carry walks from one phone to another, but two phones don't stay in step. Sync is on the "maybe later" list, and only if it is end-to-end encrypted.
The counterpart of "no bill that grows with users" is how walker is paid for. There is no server bill to recover, so walker is bought once, with no subscription, no in-app purchases and no ads.
For the developer, the format is forever. Every file ever written has to keep opening. A change to the format bumps its version, and walker still reads format 1. Enum values unknown to an older walker fall back to a default instead of failing.
Bugs are harder to see, too. With no crash reports, I learn about a problem from my own walks, from logs on a phone on my desk, and from people who write to me.
For me, that trade is the point. Nothing I could leak is on a server, because nothing is on a server.