Ten story missions through a rain-slick megacity. GPS-tracked, voice-acted by your phone, and built around one idea: somebody is waiting for you to slow down.
Android · Flutter · completely offline · no Google Play Services · no accounts, no ads, no tracking
A job on the Ninsei strip. One package, no questions. It pays enough to matter and it is almost certainly a trap.
Twenty minutes later you are running through a drainage tunnel with a containment shell that will not stop transmitting, and a voice you have never heard says:
"Oh. You are very loud. I mean that kindly. Your heart is the loudest thing I have heard in nineteen years, four months, and some days I did not count properly. Please do not stop. I am using it to know which way is up."
Ten missions later you are forty floors up a corporate arcology, and the only thing keeping an intelligence alive is whether you keep running.
Bring whatever you normally listen to. Every few minutes the app takes over the audio: a radio squelch, your music drops out, and your handler talks. Then it hands your music back.
Five characters, each with their own synthesised voice — dry and unhurried, clipped, jittery, or corporate and cold — so you know who is speaking with the phone in your pocket.
Nothing is streamed. Every voice is generated on your device. The app has no internet permission at all.
And it puts your music back. Android's transient audio focus is a loan — the player pauses when the app takes it and is meant to resume when it is handed back. Several popular players only honour that for short interruptions and stay silent after a long transmission. Nothing an app can do to its own focus request changes that, so this one watches: if music was demonstrably playing before a transmission and is still silent a couple of seconds after focus went back, it presses play. Switchable off in Settings.
Streaks that don't punish you for Monday. Pick your own weekly commitment — minutes, kilometres, or missions. A week still in progress never breaks a streak; only a finished week that fell short does.
And it is yours to take with you. Settings → Data exports everything the app knows — profile, campaign progress, achievements, codex, and every run with its GPS trace — as one plain JSON file, handed to the share sheet. Importing it back either replaces this device (a clean restore onto a new phone) or merges, adding runs and progress this device is missing while keeping its own settings. Merging only ever adds, so bringing in an old backup cannot relock a mission. No storage permission is involved in either direction: export goes out through the share sheet, import comes in through the system document picker.
Flutter, targeting Android first with iOS scaffolding already in place. Built against Flutter 3.44.8 / Dart 3.12.
flutter pub get
flutter run # debug, on a connected device
flutter build apk --release # or: flutter build appbundleGrant location when asked. Missions with a time target work fine without it; distance targets obviously do not.
Requires JDK 17. A newer default JDK (26 on current Arch) breaks AGP's
jdkImage transform; pin it with
flutter config --jdk-dir /usr/lib/jvm/java-17-openjdk.
Release builds are signed from android/key.properties — copy
android/key.properties.example and fill it in. Without that file the release
APK comes out unsigned, which is what F-Droid's build server needs; sign it
yourself before installing one.
Android SDK notes, learned the hard way:
path_provider_androidpulls inpackage:jni, which compiles native code, so the NDK is required (ndk;28.2.13676358andcmake;3.22.1).android/build.gradle.ktsraises every plugin module to the app'scompileSdk. Several plugins still pinandroid-35, and without this you would need every historical SDK platform installed just to build.minSdkandtargetSdk, which actually affect runtime behaviour, are untouched.
flutter analyze # clean
flutter test # 164 testsThe run engine takes its location source, narrator and clock by injection, so
test/run_engine_test.dart plays whole missions against synthetic GPS in
milliseconds — distance filtering, auto-pause, beat ordering, chase adjudication
and degraded-GPS behaviour. test/campaign_test.dart validates the shipped story
JSON. The widget tests drive the real screens against real storage.
Nothing in this repo is a binary of uncertain origin. The sound effects, the launcher icons and the screenshots above are all produced by code:
dart run tool/gen_sfx.dart # synthesises assets/sfx/*.wav
dart run tool/gen_icons.dart # renders every launcher icon
flutter test tool/screenshots/capture_test.dart # regenerates docs/screenshotsThe screenshot tool drives the real widgets with seeded data, so the images in this README cannot drift from what the app actually looks like.
A mission pack is one JSON file. Drop it into the app's documents directory under
sprawlrun/mission_packs/ and hit Settings → Mission packs → Reload — no
rebuild, no app update. See docs/MISSION_PACKS.md for
the format, and assets/missions/sprawl_prime.json for a worked example.
lib/
models/ Mission, StoryBeat, RunGoal, RunRecord, Profile, Achievement
data/ JSON repositories (runs, profile, mission packs) + backup
services/ run_engine · narrator · location · stats · energy · achievements
state/ AppState — the single source of truth the UI reads
screens/ dashboard · brief · run HUD · summary · stats · wall · codex · settings
widgets/ backdrop · panels · glitch text · rings & bars · route trace
theme/ palette and typography
tool/ SFX, icon and screenshot generators
Some notes on the shape of it:
RunEngineowns no persistence and no UI. It consumes aLocationSourceand aNarratorand emits state plus an event stream. That is what makes a 25-minute mission testable in milliseconds.- Derived data is always derived. Stats, streaks and achievement progress are pure functions of the run log, recomputed on change rather than maintained as counters that can fall out of step.
- Elapsed time comes from the wall clock, not from counting timer ticks, so backgrounding the app cannot lose time.
- Distance is filtered, not trusted. Fixes worse than 35 m accuracy are dropped; steps under the noise floor hold the reference point rather than advancing it, so slow movement accumulates instead of being thrown away; physically impossible jumps are rejected until several in a row suggest the device really did move.
- Auto-pause requires a live GPS. With no fixes arriving there is no way to tell a stopped runner from a lost signal, and freezing the clock would quietly ruin a time-based mission.
- No maps. Tiles need a network. Routes are drawn from the stored trace as a neon filament on a grid.
- No accounts or sync. There is no
INTERNETpermission in the manifest. Backups move by hand: exporting hands a file to the system share sheet and importing reads one back through the system document picker, so the app needs no storage permission and never sees a file you did not choose. The merged manifest carries two things it did not ask for directly —ACCESS_NETWORK_STATE, pulled in by ExoPlayer viajust_audio, which reads connectivity status and grants no network access; and a self-defined…DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSIONfromshare_plus, which only lets the app receive its own share-result broadcast. WithoutINTERNET, nothing here could reach the network regardless. android:allowBackupis left at Android's default, which istrue. That means the OS backup agent may copy the run log to the user's Google account if they have device backup switched on — the app never does this itself and could not, but the data can still leave the device by that route. Setting it tofalsewould close that path and also disable device-to-device transfer; the deliberate choice is to leave the platform behaviour alone and say so plainly rather than to claim more isolation than the app actually has.- No Google Play Services.
geolocator_androiddeclaresplay-services-location, which is proprietary. The app module excludes thecom.google.android.gmsgroup so it never reaches the APK, and setsforceLocationManager: trueso the AOSPLocationManageris used directly. Verified: zero classes undercom/googleare defined in the shipped dex. The cost is a slower first fix and marginally worse battery than the fused provider; the gain is that the app is free software all the way down and runs identically on a de-Googled ROM. - No
ACCESS_BACKGROUND_LOCATION. Tracking with the screen off works through a foreground service, which is the narrower permission and the one Play Store review does not treat as a special case. Every run holds one, and it posts the ongoing notification counting down whatever target you set — Android requires a notification for a foreground service, and it is the honest signal that a mission is live. It is the only notification the app ever shows, which is whatPOST_NOTIFICATIONSis asked for; refuse it and tracking is unaffected, only the notice is hidden. A mission started with no GPS keeps the same service underFOREGROUND_SERVICE_SPECIAL_USErather than claiming a location it does not have, so a time-target run cannot be frozen in a pocket halfway through the story. - No heart rate or cadence. Nothing here needs a strap, and inventing an effort metric from GPS alone would be worse than not having one.
App code is MIT.
Bundled fonts are SIL Open Font License 1.1 — Orbitron, Rajdhani and Share Tech
Mono, with their licence texts alongside them in assets/fonts/. All audio and
artwork is generated by the scripts in tool/ and carries the same MIT terms as
the rest of the code.



