The driver apps
Drivers have a native app on each phone: Swift and SwiftUI on iOS in
apps/ios, Kotlin and Jetpack Compose on Android in apps/android. Neither is
a web page in a wrapper. Both talk to the same API as the web app, as the
signed-in driver.
Running them
Section titled “Running them”You need Xcode, Android Studio and their simulators installed once; see the repository’s setup notes. Then:
| Command | What it does |
|---|---|
pnpm mobile |
Starts the Android emulator and the iPhone simulator together. |
pnpm mobile:ios |
Builds the iOS app and opens it on the iPhone simulator. |
pnpm mobile:android |
Builds the Android app and opens it on the emulator. |
pnpm check:mobile |
Every check on both apps. Nothing ships unless it passes. |
pnpm dev |
The API and web app, for the apps to talk to. |
pnpm mobile:seed |
A business with a driver on the local API; prints the sign-in. |
Debug builds talk to pnpm dev on the Mac as localhost:4000. The iPhone
simulator shares the Mac’s network; the Android emulator is given it by
adb reverse, which pnpm mobile:android sets up. The emulator’s own
10.0.2.2 does not reach the Mac from apps, which use its simulated Wi-Fi. Release builds talk to
production over HTTPS only.
The iOS project is described in apps/ios/project.yml and generated by
XcodeGen; the .xcodeproj is not kept in git. Change the project there, never
in Xcode’s settings.
What they share with the web
Section titled “What they share with the web”Nothing that should match is copied by hand:
- Words come from the
driverAppnamespace of@bottle/i18n, the catalogue the web reads.scripts/mobile/generate.mjswrites Android’sstrings.xmland iOS’sLocalizable.xcstringsfrom it. - Colours come from the web’s
globals.csstokens, written intoBottleColorsin each app by the same script. - Faces are the web’s: Bricolage Grotesque and Inter, bundled in each app.
- The API is the one described in the API reference.
- Rules both apps must keep the same are written once, as JSON in
spec/mobile/, and each app’s unit tests run every case:outbox-scenarios.jsonfor sending what was done offline, anddelivery-scenarios.jsonfor writing up a delivery (what went, why the rest did not, empties back, cash and proof, and the outcome that is sent), andload-scenarios.jsonfor loading the van and topping it up.
pnpm check fails if a generated file is out of date.
The checks
Section titled “The checks”pnpm check:mobile runs all of these, and fails on the first problem. A
warning is a problem everywhere.
| iOS | Android |
|---|---|
| Swift 6, complete concurrency checking | Kotlin with every warning an error |
| Compiler warnings are errors | Android Lint, warnings as errors |
| SwiftLint, strict | detekt |
| swift-format, strict | ktlint |
| Unit tests (XCTest) | Unit tests (JUnit) |
| UI tests (XCUITest) driving the app on the simulator | UI tests (Compose) driving the app on the emulator |
The iOS tests run through scripts/mobile/ios-test.sh: Xcode 27’s xcodebuild
often does not exit once the tests are done, so the script watches for both
suites to finish, stops it, and reports. Every test has a time limit.
UI tests run against a stand-in API built into debug builds (-stub <scenario>
on iOS, Services.transportOverride on Android), so they cover wrong
passwords, two-step codes, several businesses and no signal without a server.
A UI test that signs in on iOS dismisses the save-password sheet first.
Every screen gets UI tests that press what a driver presses. Anything with
rules in it, above all the offline queue, gets unit tests; the shared
scenarios in spec/mobile/ are written once and both apps run them.
Signing in
Section titled “Signing in”The apps sign in as the web does, at /auth/sign-in/email, and keep the
session token from the set-auth-token header: in the Keychain on iOS, and
encrypted with a Keystore key on Android. A token that comes with a two-step
challenge opens nothing and is ignored; the one that comes with the code is the
session. The last person and business are remembered, so the app opens without
signal; only a 401 from the API signs a driver out.
The day on the phone
Section titled “The day on the phone”At the start of the day, and whenever there is signal, the apps fetch
GET /driver/day?date=, with the date as the phone sees it. One answer holds
everything the round screens show: the rounds sent to the driver with every
stop, the bases, the gas that can go off or come back on the van, what is on
each round’s van (loads, keyed by round id), and the cash in the cab. The apps keep the latest copy on the phone and work from it, so
losing signal changes nothing on screen.
Past days do not pile up. After each refresh, a business’s copies from more than seven days ago are deleted, with the Load up ticks kept for their rounds, unless something from those rounds is still waiting to send or was turned down. Today’s copy is never touched, and nothing unsent is ever deleted. The week is a margin: a bug in sending shows up long before anything is lost.
Sending once the app is closed
Section titled “Sending once the app is closed”While a round is out, the apps run for their location and send as they go. Closed with changes still waiting, they still send them:
- iOS asks to be woken (
BGAppRefreshTask,app.getbottle.driver.send) and sends from the business last chosen, with the token in the Keychain, asking again while anything is left. When iOS wakes it is iOS’s decision. - Android hands it to WorkManager, which runs as soon as there is signal, even if the app has been put away, and backs off while the API is having a moment.
The round screen and the background sender share one outbox per business, so nothing is sent twice at once and the outbox file has one writer.
Live location
Section titled “Live location”While a round is out, the apps send the phone’s position to
POST /rounds/{id}/position: the first fix at once, then about once a minute,
sooner after a move of 300 metres, never a fix blurrier than 100 metres
(spec/mobile/position-scenarios.json). Fixes are not queued without signal.
The API keeps a fix only if it is newer than the last, and rounds carry it as
van for twenty minutes: the office’s Rounds map and the customer’s tracking
page show it. Every five minutes the apps also ask GET /rounds/{id}/timing
from the latest fix, for the arrival line under each drop.
Location is When In Use only, and required to do the round: setting off asks for it, precise, and a screen covers the round if it is turned off while out.
- iOS uses
CLLocationUpdate.liveUpdateswith aCLBackgroundActivitySessionand thelocationbackground mode, so fixes keep coming while the driver navigates, with the system’s indicator showing. - Android uses the platform
LocationManager(no Google services) and alocationforeground service with a notification while out.
Under the stand-in API both apps use a fixed location instead of the phone’s;
-locationOff on iOS, or StubLocation(LocationAccess.Refused) on Android, is
a driver who has turned it off.
Offline, and sending twice
Section titled “Offline, and sending twice”A driver can lose signal at any moment, so the app keeps the day’s round on the phone and records everything done at a door there first. What it records waits in a queue and goes to the API the moment there is signal. Two API features make that safe:
- Every write carries an
Idempotency-Key, so a delivery sent again because its answer was lost is recorded once. - Deliveries and cash carry
occurredAt, so they say when they happened, not when the signal came back.
Before a release goes to a store
Section titled “Before a release goes to a store”Apple and Google both reject apps for things that are easy to miss. Every release is checked against this list. Items marked (once) are set up the first time and then only checked.
Both stores
Section titled “Both stores”-
pnpm check:mobilepasses, on the release build. - Tried on a real phone of each kind, including with no signal: deliver in aeroplane mode, then turn it off and watch it send.
- No placeholder text or unfinished screens. Reviewers reject apps that say something is coming.
- A reviewer’s sign-in: a demo business with a driver account and a round for today, and the details in the review notes.
- Every permission asked for is used, only when it is needed, and the message asking for it says why in plain words.
- Location is only used during a round, and the app says so on screen while it is.
- A way to delete the account, or a link to request it, reachable from the app. (once)
- Version and build numbers raised.
App Store
Section titled “App Store”- Apple Developer Program membership, in the company’s name. (once)
-
PrivacyInfo.xcprivacylists everything collected (location during a round, photos of where a delivery was left, names on signatures) and every “required reason” API used, such asUserDefaults. - The App Privacy answers in App Store Connect match the manifest.
- Location: both usage descriptions are set, background location is on only for rounds, and the blue indicator shows while it is in use.
- Camera usage description set.
- Screenshots for the current required phone sizes; icon 1024 px with no transparency.
- Export compliance answered (
ITSAppUsesNonExemptEncryptionis set). - Through TestFlight to at least one real driver before review.
Google Play
Section titled “Google Play”- Play Console account, as an organisation. A personal account must run a closed test with twelve testers for fourteen days before its first release. (once)
- Data safety form matches what the app collects.
- Background location: the declaration form, a short video of the feature, and a prominent disclosure in the app before the permission is asked for.
- Location runs as a foreground service of type
location, with its notification showing. - Targets the API level Google currently requires.
- Signed with the upload key; Play App Signing on. (once)
- Through internal testing to at least one real driver before review.