Android System Design Interview Questions
Design live location tracking for a delivery app like Blinkit or Zomato.
Tier: CommonDifficulty: Hard
This is two apps, not one. The rider app publishes a position for a whole eight hour shift without flattening the phone, and the customer app draws a pin that glides across a map from points that arrive every few seconds. Nearly every decision here is a trade between those two, and the one idea that makes it work is that the rate the phone samples location and the rate it publishes location are different numbers, set by different rules.
What I'd clarify first
- How many riders are live at once, because ten thousand riders publishing every five seconds is two thousand messages a second before anybody opens a tracking screen.
- How fresh the customer's pin has to be, and how accurate. Five seconds and fifteen metres is a normal answer, one second and three metres is a different and much more expensive system.
- Does the customer watch one assigned rider or a map of many. One rider is a subscription, a map of many is a viewport query and a completely different fan out.
- Does the rider app keep tracking when it is backgrounded or the screen is off, which it has to, because a rider looks at their phone for a few seconds every few minutes.
- What the battery budget is. A shift on one charge is the real requirement, and it is the constraint that writes the rest of the design.
The rider side
- The fused location provider, with a priority and interval that follow the rider's state. Moving between stops is high accuracy every five seconds. Parked at the store waiting for a picker is balanced power every fifteen. Inside the last hundred and fifty metres of the drop is high accuracy every two seconds, because that is the only minute anybody actually watches.
- A foreground service typed for location, with an ongoing notification the rider can see, since the screen is off for most of a shift and a backgrounded process stops getting fixes.
- A distance and time filter between sampling and publishing. A rider stuck at a counter should send nothing, not the same point three hundred times an hour. The heartbeat is the only reason a parked rider sends anything at all, and it exists so the customer's screen can tell parked apart from dead.
- Batching and a compact payload. Points are small, so what costs is the per message overhead. A short binary or compact JSON body, a few points per publish while moving fast, one per publish while arriving.
- An outbox on disk. Basement car parks and lifts are normal, not edge cases. Fixes are written locally whether or not there is a connection and replayed in order when it comes back.
- A per rider sequence number and a device timestamp on every point, with the server recording its own receive time alongside. The sequence makes a duplicate delivery harmless and the two timestamps are how a batch that arrives ten minutes late still draws in the right order.
The transport
MQTT over a long lived TCP connection is the right default, and the reasons are specific rather than fashionable. It is a publish and subscribe protocol, so the rider publishes to one topic and the broker fans that out to whoever is subscribed, which means the rider app never learns how many customers are watching. Its headers are a couple of bytes against a payload of a couple of dozen, where an HTTPS request per point spends most of its bytes on connection setup and headers. QoS 1 gives at least once delivery, which is exactly the guarantee we want given the sequence number already makes duplicates harmless. A retained message on the topic means a customer opening the screen gets the last known position instantly rather than waiting for the next publish. A last will and testament message lets the broker announce the rider as offline when the connection dies rather than waiting for somebody to notice.
Topics are one per order rather than one per rider, because the topic is also the access control boundary. Polling is wrong for the obvious reason, a poll fast enough to look live is a request every two seconds per customer and it is still always behind. A raw WebSocket would work and we would then spend a quarter of the project rebuilding fan out, subscriptions, retained state and a presence signal that MQTT already has. Plain HTTPS still wins for everything that is not a stream, order state changes, going on and off shift, and the delivery confirmation, because those want a response, a retry policy and an idempotency key.
The customer side
The tracking screen subscribes when it becomes visible and unsubscribes when it stops, so a customer with the app in their pocket costs nothing. The retained message puts the pin on the map before any live point arrives. Between points the marker is interpolated rather than moved, so a position every five seconds becomes sixty frames a second of smooth motion. When the next point is late the marker dead reckons a short way along the leg it was already travelling, capped at half that leg, and then stops. A pin that keeps guessing drives through buildings and sends a customer down to the street to meet somebody who is not there. After that the screen says stale and shows the timestamp of the last real point, because a frozen marker with no label reads as a rider who has stopped moving. The ETA comes from the server's routing service, never from dividing distance by speed on the phone, since two clients would otherwise disagree about the same delivery.
On the server
Points land in an ingestion service that writes them to a stream, which absorbs a lunchtime surge without the consumers falling over, and everything downstream reads from there. A last known position store, keyed by rider, is what a late subscriber and the retained message are served from. A geofence service watches the distance to the drop and raises the arriving event, so the rule lives in one place instead of being reimplemented on two clients. The routing service owns the ETA. The raw point history goes to cold storage for support and disputes, with a retention window, not kept forever because it is convenient.
Battery and data
The adaptive interval is the whole budget. A fix every second for eight hours means the GPS radio is never allowed to sleep, and that alone is most of a phone battery before the screen, the network and the rest of the rider app are counted. So the expensive settings are bought for the minutes that matter and nothing else. On the customer side, the screen downgrades the moment it is not visible, and a backgrounded customer app holds no connection at all. If a delivery genuinely needs to notify somebody while the app is closed, that is a push notification, not a socket kept alive.
Security and privacy
A customer may subscribe only to the topic for their own active order, enforced by broker access control lists on connect, not by the client asking nicely. The subscription dies when the order does. The rider's location is published only while a tracking state is active, so a rider between orders is not being followed. Raw coordinates never go into logs or analytics events, because a leaked log of a rider's day is a map of where they live.
How this maps onto Android
- Location comes from
FusedLocationProviderClient. ALocationRequest.BuildercarriesPRIORITY_HIGH_ACCURACYorPRIORITY_BALANCED_POWER_ACCURACYwithsetIntervalMillisandsetMinUpdateIntervalMillis, and a state change rebuilds the request rather than filtering a fast one in code. - The rider app runs a foreground service.
android:foregroundServiceType="location"in the manifest, theFOREGROUND_SERVICE_LOCATIONpermission, andFOREGROUND_SERVICE_TYPE_LOCATIONpassed tostartForeground. Doze and the background limits will stop a plain background service getting fixes, and the notification is not a nuisance here, it is the honest signal that the app is tracking. - Permissions differ sharply between the two apps. The rider app asks for
ACCESS_FINE_LOCATIONand then, separately and with an explanation screen,ACCESS_BACKGROUND_LOCATION, which on Android 11 and above the user can only grant from settings. The customer app needs no location permission at all, because it is drawing somebody else's position on a map. - Arriving is a geofence.
GeofencingClientwith aGEOFENCE_TRANSITION_ENTERaround the drop, with the server geofence as the authority and the device one as the fast local hint. - The marker animates with a
ValueAnimatordriving the map SDK's marker position, orAnimatableandanimateToin Compose. Either way the animation runs for the publish interval and the newest point restarts it from wherever the pin currently is. - The subscription follows the lifecycle.
repeatOnLifecycle(STARTED)around the collect, orcollectAsStateWithLifecycle, so the topic is subscribed when the tracking screen is visible and dropped when it is not. - The outbox is Room, written on
Dispatchers.IO, with aWorkManagerjob as the backstop that flushes anything left if the service was killed before the connection came back.
The code
Four pieces carry this design. The policy turns a rider state and a speed into a cadence, which is the entire battery story in one function. The gate is the difference between sampling and publishing, and it is the reason a parked rider is quiet. The outbox is the tunnel, sequenced, replayed in order and acknowledged cumulatively. The interpolator is the customer's half, a glide with a hard limit on how far it is willing to guess. The service, the broker client and the map are out of this tree.
Running the first two against a rider parked at a store, the cadence is BalancedPower interval 15000ms filter 30m heartbeat 90000ms, and six samples of GPS wobble over the next seventy five seconds publish 1 of 6, the first one, with everything from two to four metres filtered out, then the heartbeat at ninety seconds publishes one keep alive. Running the outbox against a dropped connection, a first attempt drains [1, 2, 3], the connection dies before any acknowledgement, the outbox still holds 5, the reconnect drains [1, 2, 3, 4, 5] in order, and after the acknowledgement the size is 0, dropped 0, and a second drain returns nothing. Replayed once, in order, nothing lost and nothing sent twice.
Java
com.androidinterview.tracking.geo.LatLng.java
package com.androidinterview.tracking.geo;
// A point on the ground, plus the two bits of arithmetic the rest of this
// design needs. Distances are metres and times are milliseconds everywhere,
// stated once here so nothing downstream has to guess.
public record LatLng(double lat, double lng) {
public static final double EARTH_RADIUS_METERS = 6_371_000.0;
// Equirectangular, not haversine. Over the couple of kilometres between a
// dark store and a customer the error is centimetres, and this runs on
// every fix and every animation frame. Haversine is the right call the
// moment anything here measures a trip rather than a leg.
public double metersTo(LatLng other) {
double dLat = Math.toRadians(other.lat - lat);
double dLng = Math.toRadians(other.lng - lng) * Math.cos(Math.toRadians((lat + other.lat) / 2));
return EARTH_RADIUS_METERS * Math.sqrt(dLat * dLat + dLng * dLng);
}
// Straight line fraction of the way to another point. A t below zero or
// above one extrapolates on purpose, which is what the dead reckoning in
// the interpolator is built on.
public LatLng towards(LatLng other, double t) {
return new LatLng(lat + (other.lat - lat) * t, lng + (other.lng - lng) * t);
}
@Override
public String toString() {
return String.format("%.5f,%.5f", lat, lng);
}
}
com.androidinterview.tracking.marker.MarkerInterpolator.java
package com.androidinterview.tracking.marker;
import com.androidinterview.tracking.geo.LatLng;
// The customer app gets a point every few seconds and draws sixty frames a
// second, so this is the piece that turns one into the other. It glides the
// pin along the last leg, keeps gliding for a short window when the next point
// is late, and then stops and admits it does not know.
public final class MarkerInterpolator {
// One animation frame's worth of truth for the customer's map.
public record Frame(
LatLng position,
// Nothing has arrived for longer than it should have. The screen
// greys the pin and shows the timestamp rather than letting a
// frozen marker read as a rider who has stopped moving.
boolean stale,
long ageMillis,
// Server time on the newest fix, which is what the "updated 2
// minutes ago" line renders from. Never the phone's clock.
long lastServerMillis) {}
private final long staleAfterMillis;
// How far past the newest fix we are willing to guess, as a fraction of the
// leg just travelled. Half a leg smooths over one dropped packet. A whole
// leg starts driving the pin through buildings, and a customer who walks
// out to meet a rider who was never there is worse than a pin that paused.
private final double deadReckonFraction;
private LatLng from;
private LatLng to;
private long legStartedAt;
private long legMillis = 1L;
private long lastFixAt;
private long lastServerMillis;
public MarkerInterpolator() {
this(15_000, 0.5);
}
public MarkerInterpolator(long staleAfterMillis, double deadReckonFraction) {
this.staleAfterMillis = staleAfterMillis;
this.deadReckonFraction = deadReckonFraction;
}
// The first call is the retained message the broker hands over the moment
// the screen subscribes, so the pin is on the map before any live point
// arrives. It places the marker rather than animating to it.
public void onFix(LatLng position, long atMillis, long serverMillis) {
LatLng current = from == null ? position : frameAt(atMillis).position();
from = current;
to = position;
legMillis = lastFixAt == 0L ? 1L : Math.max(1L, atMillis - lastFixAt);
legStartedAt = atMillis;
lastFixAt = atMillis;
lastServerMillis = serverMillis;
}
// Called once per frame with the phone's monotonic clock. The device clock
// is used here and nowhere else, and only to answer "how long since we
// heard anything".
public Frame frameAt(long atMillis) {
if (to == null) throw new IllegalStateException("no fix yet, subscribe before drawing");
long age = atMillis - lastFixAt;
boolean stale = age > staleAfterMillis;
double raw = (double) (atMillis - legStartedAt) / legMillis;
// Clamped at one once stale, so the pin settles on the last point we
// actually received and stops inventing a route.
double limit = stale ? 1.0 : 1.0 + deadReckonFraction;
double t = Math.max(0.0, Math.min(limit, raw));
return new Frame(from.towards(to, t), stale, age, lastServerMillis);
}
public boolean hasFix() {
return to != null;
}
}
com.androidinterview.tracking.outbox.LocationOutbox.java
package com.androidinterview.tracking.outbox;
import com.androidinterview.tracking.geo.LatLng;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
// The rider app's answer to a basement car park. Fixes go in whether or not
// there is a connection, they leave in sequence order, and nothing is thrown
// away because a socket dropped.
public final class LocationOutbox {
// One published point. The sequence number is per rider and strictly
// increasing, and it is the whole reason a delivery survives a tunnel. The
// broker delivers at least once, so the server will see duplicates, and
// (riderId, seq) is the key it deduplicates on.
public record Fix(
String riderId,
long seq,
LatLng position,
// Taken on the phone when the fix was taken, so a batch that
// arrives ten minutes late still draws in the right order. The
// server stamps its own receive time beside it, because a rider
// whose clock is an hour out would otherwise rewrite history.
long deviceMillis) {}
private final String riderId;
private final int capacity;
private final Deque<Fix> pending = new ArrayDeque<>();
private final Deque<Fix> inFlight = new ArrayDeque<>();
private long nextSeq = 1L;
private long dropped;
public LocationOutbox(String riderId) {
// Roughly eight hours of moving fixes. A delivery is twenty minutes,
// so in practice this is never reached, and it is here because an
// unbounded queue on a phone is a crash waiting for a long enough
// outage.
this(riderId, 6_000);
}
public LocationOutbox(String riderId, int capacity) {
this.riderId = riderId;
this.capacity = capacity;
}
public Fix enqueue(LatLng position, long deviceMillis) {
Fix fix = new Fix(riderId, nextSeq++, position, deviceMillis);
if (pending.size() + inFlight.size() >= capacity) {
pending.pollFirst();
dropped++;
}
pending.addLast(fix);
return fix;
}
// Handed to the publisher. They stay in flight rather than leaving, so a
// drop before the broker acknowledges them replays them instead of losing
// them.
public List<Fix> drain(int max) {
int take = Math.min(max, pending.size());
List<Fix> batch = new ArrayList<>(take);
for (int i = 0; i < take; i++) {
Fix fix = pending.removeFirst();
inFlight.addLast(fix);
batch.add(fix);
}
return batch;
}
// QoS 1 acknowledgement from the broker, and the only thing that deletes a
// fix. Acknowledgements are cumulative because the sequence is.
public void acknowledge(long seq) {
while (!inFlight.isEmpty() && inFlight.peekFirst().seq() <= seq) inFlight.removeFirst();
}
// The connection died. Everything unacknowledged goes back to the front of
// the queue, still in sequence order, and the next connect replays it.
public void onDisconnected() {
while (!inFlight.isEmpty()) pending.addFirst(inFlight.removeLast());
}
public int size() {
return pending.size() + inFlight.size();
}
public long dropped() {
return dropped;
}
public List<Fix> snapshot() {
List<Fix> all = new ArrayList<>(pending);
all.addAll(inFlight);
return all;
}
}
com.androidinterview.tracking.policy.LocationPolicy.java
package com.androidinterview.tracking.policy;
import com.androidinterview.tracking.state.TrackingState;
// The whole battery budget of this feature is these numbers. A rider works an
// eight hour shift on one charge, so the honest framing is that a fix every
// second for the entire shift is not available to us at any price, and every
// line here is about spending the fixes where somebody is looking.
public final class LocationPolicy {
// Maps onto the fused provider's priorities. Kept as our own enum so this
// file stays pure JVM and testable, and so the rest of the app never has
// to know which location library is underneath.
public enum Accuracy { HIGH_ACCURACY, BALANCED_POWER, LOW_POWER }
// One request's worth of settings. intervalMillis and minIntervalMillis go
// straight into LocationRequest.Builder. The displacement filter and the
// heartbeat are ours, because what the phone samples and what the phone
// publishes are two different rates, and conflating them is the battery bug.
public record Cadence(
Accuracy accuracy,
long intervalMillis,
long minIntervalMillis,
double minDisplacementMeters,
long heartbeatMillis) {}
// Below this the rider is parked, waiting at the store, or standing in a
// lift. GPS jitter alone reads as roughly half a metre a second.
private static final double STOPPED_METERS_PER_SECOND = 1.0;
private LocationPolicy() {}
// Null means take no fixes at all. Not a slow cadence, none.
public static Cadence cadenceFor(TrackingState state, double speedMetersPerSecond) {
if (state instanceof TrackingState.Arriving) {
// The customer is watching a pin close in on their door, so this
// is where the expensive settings are worth it.
return new Cadence(Accuracy.HIGH_ACCURACY, 2_000, 1_000, 10.0, 20_000);
}
if (state instanceof TrackingState.Tracking) {
if (speedMetersPerSecond < STOPPED_METERS_PER_SECOND) {
// Queueing at the pickup counter. Nothing is moving, so a GPS
// lock costs battery to redraw the same pin.
return new Cadence(Accuracy.BALANCED_POWER, 15_000, 5_000, 30.0, 90_000);
}
return new Cadence(Accuracy.HIGH_ACCURACY, 5_000, 2_000, 25.0, 60_000);
}
return null;
}
}
com.androidinterview.tracking.policy.PublishGate.java
package com.androidinterview.tracking.policy;
import com.androidinterview.tracking.geo.LatLng;
// The gate between the fixes the phone takes and the points that leave it.
// Sampling at five seconds and publishing everything sampled is how a rider
// stuck at a traffic light sends four hundred identical points an hour.
public final class PublishGate {
// A fix whose accuracy circle is wider than this is a cell tower guess.
// Publishing it teleports the pin across two neighbourhoods and back.
private static final double WORST_USABLE_ACCURACY_METERS = 100.0;
private LatLng lastPublished;
private long lastPublishedAt;
public boolean accept(LocationPolicy.Cadence cadence, LatLng position, double accuracyMeters, long atMillis) {
if (accuracyMeters > WORST_USABLE_ACCURACY_METERS) return false;
if (lastPublished == null) return publish(position, atMillis);
double moved = lastPublished.metersTo(position);
long silentFor = atMillis - lastPublishedAt;
// Distance filter first, heartbeat second. The heartbeat exists only so
// the customer's screen can tell a parked rider apart from a dead one.
if (moved < cadence.minDisplacementMeters() && silentFor < cadence.heartbeatMillis()) return false;
return publish(position, atMillis);
}
private boolean publish(LatLng position, long atMillis) {
lastPublished = position;
lastPublishedAt = atMillis;
return true;
}
public void reset() {
lastPublished = null;
lastPublishedAt = 0L;
}
}
com.androidinterview.tracking.state.TrackingState.java
package com.androidinterview.tracking.state;
// What the rider app is doing right now, which is the single input that
// decides how often the phone asks for a fix, whether anything is published at
// all, and what the customer is allowed to subscribe to. Tracking that does
// not end is the privacy bug in this whole design, so DELIVERED is a real
// state and not a flag on TRACKING.
public sealed interface TrackingState {
// Inside the geofence around the drop the customer sees "arriving", and
// the phone spends its remaining battery budget there, because that is the
// one minute of the delivery anybody actually watches.
int ARRIVING_RADIUS_METERS = 150;
record Idle() implements TrackingState {}
record Tracking(String orderId) implements TrackingState {}
record Arriving(String orderId, double distanceToDropMeters) implements TrackingState {}
record Delivered(String orderId, long atMillis) implements TrackingState {}
default String activeOrderId() {
if (this instanceof Tracking t) return t.orderId();
if (this instanceof Arriving a) return a.orderId();
if (this instanceof Delivered d) return d.orderId();
return null;
}
// The only question the location layer asks this machine.
default boolean publishing() {
return this instanceof Tracking || this instanceof Arriving;
}
// One transition table, so nothing anywhere else in the app decides it has
// worked out that a delivery is over. Anything not listed leaves the state
// alone, which is how a duplicate server event or a late fix arriving
// after the drop stays harmless.
default TrackingState on(TrackingEvent event) {
if (event instanceof TrackingEvent.Assigned a && this instanceof Idle) {
return new Tracking(a.orderId());
}
if (event instanceof TrackingEvent.DistanceToDrop d && this instanceof Tracking t) {
return d.meters() <= ARRIVING_RADIUS_METERS ? new Arriving(t.orderId(), d.meters()) : this;
}
// Leaving the geofence again is normal, a rider parks, walks off to the
// wrong gate and comes back, so this edge runs both ways.
if (event instanceof TrackingEvent.DistanceToDrop d && this instanceof Arriving a) {
return d.meters() > ARRIVING_RADIUS_METERS
? new Tracking(a.orderId())
: new Arriving(a.orderId(), d.meters());
}
if (event instanceof TrackingEvent.DeliveryConfirmed c && publishing()) {
return new Delivered(activeOrderId(), c.atMillis());
}
if (event instanceof TrackingEvent.Released) {
return new Idle();
}
return this;
}
sealed interface TrackingEvent {
record Assigned(String orderId) implements TrackingEvent {}
// Recomputed from every accepted fix, which is cheap, and confirmed by
// the server geofence, which is authoritative.
record DistanceToDrop(double meters) implements TrackingEvent {}
// Server confirmed, never inferred on the phone. A rider standing in
// the right doorway has not delivered anything.
record DeliveryConfirmed(long atMillis) implements TrackingEvent {}
record Released() implements TrackingEvent {}
}
}
Kotlin
com.androidinterview.tracking.geo.Geo.kt
package com.androidinterview.tracking.geo
import kotlin.math.cos
import kotlin.math.roundToInt
import kotlin.math.sqrt
// The two units this design keeps confusing itself about if they stay raw
// numbers. A distance filter in metres and an interval in milliseconds are
// both "a number that got bigger", and swapping them compiles happily.
@JvmInline
value class Meters(val value: Double) : Comparable<Meters> {
override fun compareTo(other: Meters): Int = value.compareTo(other.value)
operator fun minus(other: Meters): Meters = Meters(value - other.value)
override fun toString(): String = "${value.roundToInt()}m"
}
@JvmInline
value class Millis(val value: Long) : Comparable<Millis> {
override fun compareTo(other: Millis): Int = value.compareTo(other.value)
override fun toString(): String = "${value}ms"
}
// A point on the ground. Deliberately not a value class, because an inline
// value class carries one field and a coordinate is two, and packing a pair of
// doubles into a Long to win the allocation would cost far more in readability
// than it saves on a phone that allocates a handful of these a second.
data class LatLng(val lat: Double, val lng: Double) {
// Equirectangular, not haversine. Over the couple of kilometres between a
// dark store and a customer the error is centimetres, and this runs on
// every fix and every animation frame. Haversine is the right call the
// moment anything here measures a trip rather than a leg.
fun metersTo(other: LatLng): Meters {
val dLat = Math.toRadians(other.lat - lat)
val dLng = Math.toRadians(other.lng - lng) * cos(Math.toRadians((lat + other.lat) / 2))
return Meters(EARTH_RADIUS_METERS * sqrt(dLat * dLat + dLng * dLng))
}
// Straight line fraction of the way to another point. t below zero or
// above one extrapolates on purpose, which is what the dead reckoning in
// the interpolator is built on.
fun towards(other: LatLng, t: Double): LatLng =
LatLng(lat + (other.lat - lat) * t, lng + (other.lng - lng) * t)
override fun toString(): String = "%.5f,%.5f".format(lat, lng)
companion object {
const val EARTH_RADIUS_METERS = 6_371_000.0
}
}
com.androidinterview.tracking.marker.MarkerInterpolator.kt
package com.androidinterview.tracking.marker
import com.androidinterview.tracking.geo.LatLng
// One animation frame's worth of truth for the customer's map.
data class Frame(
val position: LatLng,
// Nothing has arrived for longer than it should have. The screen greys the
// pin and shows the timestamp rather than letting a frozen marker read as
// a rider who has stopped moving.
val stale: Boolean,
val ageMillis: Long,
// Server time on the newest fix, which is what the "updated 2 minutes ago"
// line renders from. Never the phone's clock.
val lastServerMillis: Long,
)
// The customer app gets a point every few seconds and draws sixty frames a
// second, so this is the piece that turns one into the other. It glides the
// pin along the last leg, keeps gliding for a short window when the next point
// is late, and then stops and admits it does not know.
class MarkerInterpolator(
private val staleAfterMillis: Long = 15_000,
// How far past the newest fix we are willing to guess, as a fraction of
// the leg just travelled. Half a leg smooths over one dropped packet. A
// whole leg starts driving the pin through buildings, and a customer who
// walks out to meet a rider who was never there is worse than a pin that
// paused.
private val deadReckonFraction: Double = 0.5,
) {
private var from: LatLng? = null
private var to: LatLng? = null
private var legStartedAt = 0L
private var legMillis = 1L
private var lastFixAt = 0L
private var lastServerMillis = 0L
// The first call is the retained message the broker hands over the moment
// the screen subscribes, so the pin is on the map before any live point
// arrives. It places the marker rather than animating to it.
fun onFix(position: LatLng, atMillis: Long, serverMillis: Long) {
val current = if (from == null) position else frameAt(atMillis).position
from = current
to = position
legMillis = if (lastFixAt == 0L) 1L else maxOf(1L, atMillis - lastFixAt)
legStartedAt = atMillis
lastFixAt = atMillis
lastServerMillis = serverMillis
}
// Called once per frame with the phone's monotonic clock. The device clock
// is used here and nowhere else, and only to answer "how long since we
// heard anything".
fun frameAt(atMillis: Long): Frame {
val start = from
val end = to
require(start != null && end != null) { "no fix yet, subscribe before drawing" }
val age = atMillis - lastFixAt
val stale = age > staleAfterMillis
val raw = (atMillis - legStartedAt).toDouble() / legMillis
// Clamped at one once stale, so the pin settles on the last point we
// actually received and stops inventing a route.
val limit = if (stale) 1.0 else 1.0 + deadReckonFraction
val t = raw.coerceIn(0.0, limit)
return Frame(start.towards(end, t), stale, age, lastServerMillis)
}
val hasFix: Boolean get() = to != null
}
com.androidinterview.tracking.outbox.LocationOutbox.kt
package com.androidinterview.tracking.outbox
import com.androidinterview.tracking.geo.LatLng
// One published point. The sequence number is per rider and strictly
// increasing, and it is the whole reason a delivery survives a tunnel. The
// broker delivers at least once, so the server will see duplicates, and
// (riderId, seq) is the key it deduplicates on.
data class Fix(
val riderId: String,
val seq: Long,
val position: LatLng,
// Taken on the phone when the fix was taken, so a batch that arrives ten
// minutes late still draws in the right order. The server stamps its own
// receive time beside it, because a rider whose clock is an hour out would
// otherwise rewrite history.
val deviceMillis: Long,
)
// The rider app's answer to a basement car park. Fixes go in whether or not
// there is a connection, they leave in sequence order, and nothing is thrown
// away because a socket dropped.
class LocationOutbox(
private val riderId: String,
// Roughly eight hours of moving fixes. A delivery is twenty minutes, so in
// practice this is never reached, and it is here because an unbounded
// queue on a phone is a crash waiting for a long enough outage.
private val capacity: Int = 6_000,
) {
private val pending = ArrayDeque<Fix>()
private val inFlight = ArrayDeque<Fix>()
private var nextSeq = 1L
var dropped = 0L
private set
fun enqueue(position: LatLng, deviceMillis: Long): Fix {
val fix = Fix(riderId, nextSeq++, position, deviceMillis)
if (pending.size + inFlight.size >= capacity) {
pending.removeFirstOrNull()
dropped++
}
pending.addLast(fix)
return fix
}
// Handed to the publisher. They stay in flight rather than leaving, so a
// drop before the broker acknowledges them replays them instead of losing
// them.
fun drain(max: Int): List<Fix> {
val batch = ArrayList<Fix>(minOf(max, pending.size))
repeat(minOf(max, pending.size)) {
val fix = pending.removeFirst()
inFlight.addLast(fix)
batch.add(fix)
}
return batch
}
// QoS 1 acknowledgement from the broker, and the only thing that deletes a
// fix. Acknowledgements are cumulative because the sequence is.
fun acknowledge(seq: Long) {
while (inFlight.isNotEmpty() && inFlight.first().seq <= seq) inFlight.removeFirst()
}
// The connection died. Everything unacknowledged goes back to the front of
// the queue, still in sequence order, and the next connect replays it.
fun onDisconnected() {
while (inFlight.isNotEmpty()) pending.addFirst(inFlight.removeLast())
}
val size: Int get() = pending.size + inFlight.size
fun snapshot(): List<Fix> = pending.toList() + inFlight.toList()
}
com.androidinterview.tracking.policy.LocationPolicy.kt
package com.androidinterview.tracking.policy
import com.androidinterview.tracking.geo.LatLng
import com.androidinterview.tracking.geo.Meters
import com.androidinterview.tracking.geo.Millis
import com.androidinterview.tracking.state.TrackingState
// Maps onto the fused provider's priorities. Kept as our own enum so this file
// stays pure JVM and testable, and so the rest of the app never has to know
// which location library is underneath.
enum class Accuracy { HighAccuracy, BalancedPower, LowPower }
// One request's worth of settings. interval and minInterval go straight into
// LocationRequest.Builder, minDisplacement and heartbeat are ours, because
// what the phone samples and what the phone publishes are two different rates
// and conflating them is the battery bug.
data class Cadence(
val accuracy: Accuracy,
val interval: Millis,
val minInterval: Millis,
val minDisplacement: Meters,
val heartbeat: Millis,
)
// The whole battery budget of this feature is these numbers. A rider works an
// eight hour shift on one charge, so the honest framing is that a second by
// second GPS fix for the entire shift is not available to us at any price, and
// every line here is about spending the fixes where somebody is looking.
object LocationPolicy {
// Below this the rider is parked, waiting at the store, or standing in a
// lift. GPS jitter alone reads as roughly half a metre a second.
private const val STOPPED_METERS_PER_SECOND = 1.0
// Null means take no fixes at all. Not a slow cadence, none.
fun cadenceFor(state: TrackingState, speedMetersPerSecond: Double): Cadence? = when (state) {
is TrackingState.Idle, is TrackingState.Delivered -> null
// The customer is watching a pin close in on their door, so this is
// where the expensive settings are worth it.
is TrackingState.Arriving -> cadence(Accuracy.HighAccuracy, 2_000, 1_000, 10.0, 20_000)
// Under the threshold the rider is queueing at the pickup counter, and
// a GPS lock is battery spent to redraw the same pin.
is TrackingState.Tracking ->
if (speedMetersPerSecond < STOPPED_METERS_PER_SECOND) {
cadence(Accuracy.BalancedPower, 15_000, 5_000, 30.0, 90_000)
} else {
cadence(Accuracy.HighAccuracy, 5_000, 2_000, 25.0, 60_000)
}
}
private fun cadence(accuracy: Accuracy, interval: Long, minInterval: Long, displacement: Double, heartbeat: Long) =
Cadence(accuracy, Millis(interval), Millis(minInterval), Meters(displacement), Millis(heartbeat))
}
// The gate between the fixes the phone takes and the points that leave it.
// Sampling at five seconds and publishing everything sampled is how a rider
// stuck at a traffic light sends four hundred identical points an hour.
class PublishGate {
private var lastPublished: LatLng? = null
private var lastPublishedAt = 0L
// A fix whose accuracy circle is wider than this is a cell tower guess.
// Publishing it teleports the pin across two neighbourhoods and back.
private val worstUsableAccuracy = Meters(100.0)
fun accept(cadence: Cadence, position: LatLng, accuracy: Meters, atMillis: Long): Boolean {
if (accuracy > worstUsableAccuracy) return false
val previous = lastPublished ?: return publish(position, atMillis)
val moved = previous.metersTo(position)
val silentFor = atMillis - lastPublishedAt
// Distance filter first, heartbeat second. The heartbeat exists only so
// the customer's screen can tell a parked rider apart from a dead one.
if (moved < cadence.minDisplacement && silentFor < cadence.heartbeat.value) return false
return publish(position, atMillis)
}
private fun publish(position: LatLng, atMillis: Long): Boolean {
lastPublished = position
lastPublishedAt = atMillis
return true
}
fun reset() {
lastPublished = null
lastPublishedAt = 0L
}
}
com.androidinterview.tracking.state.TrackingState.kt
package com.androidinterview.tracking.state
import com.androidinterview.tracking.geo.Meters
// What the rider app is doing right now, which is the single input that
// decides how often the phone asks for a fix, whether anything is published at
// all, and what the customer is allowed to subscribe to. Tracking that does
// not end is the privacy bug in this whole design, so Delivered is a real
// state and not a flag on Tracking.
sealed interface TrackingState {
data object Idle : TrackingState
data class Tracking(val orderId: String) : TrackingState
// Inside the geofence around the drop. The customer sees "arriving" and
// the phone spends its remaining battery budget here, because this is the
// one minute of the delivery anybody actually watches.
data class Arriving(val orderId: String, val distanceToDrop: Meters) : TrackingState
data class Delivered(val orderId: String, val atMillis: Long) : TrackingState
val activeOrderId: String?
get() = when (this) {
is Idle -> null
is Tracking -> orderId
is Arriving -> orderId
is Delivered -> orderId
}
// The only question the location layer asks this machine.
val publishing: Boolean get() = this is Tracking || this is Arriving
}
sealed interface TrackingEvent {
data class Assigned(val orderId: String) : TrackingEvent
// Recomputed from every accepted fix, which is cheap, and confirmed by the
// server geofence, which is authoritative.
data class DistanceToDrop(val distance: Meters) : TrackingEvent
// Server confirmed, never inferred on the phone. A rider standing in the
// right doorway has not delivered anything.
data class DeliveryConfirmed(val atMillis: Long) : TrackingEvent
data object Released : TrackingEvent
}
const val ARRIVING_RADIUS_METERS = 150.0
// One transition table, so nothing anywhere else in the app decides it has
// worked out that a delivery is over. Anything not listed leaves the state
// alone, which is how a duplicate server event or a late fix arriving after
// the drop stays harmless.
fun TrackingState.on(event: TrackingEvent): TrackingState = when {
event is TrackingEvent.Assigned && this is TrackingState.Idle ->
TrackingState.Tracking(event.orderId)
event is TrackingEvent.DistanceToDrop && this is TrackingState.Tracking ->
if (event.distance.value <= ARRIVING_RADIUS_METERS) {
TrackingState.Arriving(orderId, event.distance)
} else {
this
}
// Leaving the geofence again is normal, a rider parks, walks off to the
// wrong gate and comes back, so this edge runs both ways.
event is TrackingEvent.DistanceToDrop && this is TrackingState.Arriving ->
if (event.distance.value > ARRIVING_RADIUS_METERS) {
TrackingState.Tracking(orderId)
} else {
TrackingState.Arriving(orderId, event.distance)
}
event is TrackingEvent.DeliveryConfirmed && publishing ->
TrackingState.Delivered(activeOrderId!!, event.atMillis)
event is TrackingEvent.Released -> TrackingState.Idle
else -> this
}
Tradeoffs I'd call out
- Adaptive cadence versus a fixed one. A fixed interval is trivial to reason about and trivial to test, and it is what the ride hailing design can get away with, because a car on a trip is moving by definition. A delivery rider spends a third of the shift standing still, so a fixed interval either wastes that third or makes the arriving moment look choppy. The adaptive version buys both and costs a state machine that has to be right.
- MQTT versus a WebSocket we own. MQTT gives fan out, retained state, a presence signal and a quality of service ladder for free, and costs a broker to run, monitor and scale, plus a protocol most Android teams have not debugged before. A WebSocket is one less moving part and about a quarter of a project of rebuilt plumbing.
- How far to dead reckon. Guessing past the last real point hides a dropped packet and looks better. Guessing too far invents a route, and the failure is loud, a customer standing outside watching a pin that was never on their street. Half a leg and then an honest stale label is the line I would defend.
What breaks at scale, offline, and on a poor connection
At scale the pressure is on ingestion and fan out rather than on any one phone, and the client's contribution is not sending what nobody is looking at. Ten thousand riders on a five second interval is manageable, the same ten thousand on one second is not, and the filter is what keeps the average rider well under their nominal rate. Offline, the rider app degrades quietly, fixes queue on disk, the service keeps running, and the customer's screen shows the last known position with its timestamp and a stale label rather than a marker frozen mid street with no explanation. On reconnect the whole queue replays in order and the server deduplicates on rider and sequence, so a customer watching may see the pin catch up quickly, which is far better than a hole in the trail. On a poor connection the reconnect backs off with jitter, because a broker restart drops every rider in a city at the same instant and an unjittered retry brings them all back on the same second. Process death is routine on the rider side, so the tracking state is reloaded from the server on launch and never inferred from what the app remembers.
Read more Change location settings (opens in a new tab)Foreground service types (opens in a new tab)Request location access in the background (opens in a new tab)Create and monitor geofences (opens in a new tab)
Watch