Skip to content

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.

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.

Nothing that should match is copied by hand:

  • Words come from the driverApp namespace of @bottle/i18n, the catalogue the web reads. scripts/mobile/generate.mjs writes Android’s strings.xml and iOS’s Localizable.xcstrings from it.
  • Colours come from the web’s globals.css tokens, written into BottleColors in 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.json for sending what was done offline, and delivery-scenarios.json for writing up a delivery (what went, why the rest did not, empties back, cash and proof, and the outcome that is sent), and load-scenarios.json for loading the van and topping it up.

pnpm check fails if a generated file is out of date.

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.

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.

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.

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.

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.liveUpdates with a CLBackgroundActivitySession and the location background mode, so fixes keep coming while the driver navigates, with the system’s indicator showing.
  • Android uses the platform LocationManager (no Google services) and a location foreground 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.

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.

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.

  • pnpm check:mobile passes, 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.
  • Apple Developer Program membership, in the company’s name. (once)
  • PrivacyInfo.xcprivacy lists everything collected (location during a round, photos of where a delivery was left, names on signatures) and every “required reason” API used, such as UserDefaults.
  • 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 (ITSAppUsesNonExemptEncryption is set).
  • Through TestFlight to at least one real driver before review.
  • 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.