Design Maps & Live Location Tracking
1. Understanding the Problem
Design the Android client for a maps and live location tracking feature β think an Ola/Uber driver's position updating in real-time on a passenger's screen, or a delivery tracking screen in Swiggy. The core challenges: getting accurate location efficiently without destroying battery, sending/receiving location over a persistent connection, animating a marker smoothly across the map, drawing the route polyline, and handling the complex Android location permission landscape (foreground vs background).
Three technologies work together: FusedLocationProviderClient β Google's smart location provider that picks the best source (GPS, Wi-Fi, cell) for the requested accuracy/power trade-off. WebSocket β bi-directional persistent connection to stream location updates with low latency. ValueAnimator β smoothly interpolates a map marker between GPS fixes so it glides rather than teleports, even when updates arrive every 2β5 seconds.
Learn This Pattern ββ Functional Requirements
- Show user's own location on the map in real time
- Track a driver/delivery agent's position (received from server)
- Draw a route polyline from origin to destination
- Animate the driver marker smoothly between position updates
- Show ETA and distance, updated as driver moves
- Geofence: alert when driver enters a radius of the pickup point
- Location continues updating in background (driver app)
β Non-Functional Requirements
- Location must not drain battery β choose right accuracy tier
- Driver app: foreground service required for background location
- Android 10+ requires explicit ACCESS_BACKGROUND_LOCATION
- Marker animation must feel smooth (60 fps interpolation)
- Graceful degradation when GPS signal is weak/unavailable
- Map tile loading works offline (cached tiles)
- Handle camera auto-follow vs user-panned map interaction
2. The Set Up
Location accuracy vs battery β choosing the right priority
Key Android APIs at a glance
LocationCallback receives updates while service is alive. Background/killed: PendingIntent wakes a BroadcastReceiver.GoogleMap API: add/move markers, draw Polyline, control camera with animateCamera(), cluster dense markers with ClusterManager.serviceType=location in manifest since Android 10.Geofence with center + radius. OS triggers a PendingIntent when the device enters/exits β no polling needed.3. High-Level Design
4. Low-Level Design
FusedLocationProviderClient β location request setup
class LocationForegroundService : Service() {
private val fusedClient by lazy {
LocationServices.getFusedLocationProviderClient(this)
}
private lateinit var locationCallback: LocationCallback
override fun onCreate() {
super.onCreate()
startForeground(NOTIF_ID, buildNotification()) // required on Android 9+
startLocationUpdates()
}
private fun startLocationUpdates() {
val request = LocationRequest.Builder(
Priority.PRIORITY_HIGH_ACCURACY,
2000L // intervalMillis β target 2 s between fixes
)
.setMinUpdateIntervalMillis(1000L) // fastest if GPS provides faster
.setMinUpdateDistanceMeters(5f) // ignore updates < 5 m movement
.setWaitForAccurateLocation(false) // don't wait for GPS lock β use fused
.build()
locationCallback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
val location = result.lastLocation ?: return
// Upload to server via WebSocket
locationRepository.uploadLocation(
lat = location.latitude,
lng = location.longitude,
bearing = location.bearing, // heading in degrees β for marker rotation
accuracy = location.accuracy // horizontal accuracy in metres
)
}
}
fusedClient.requestLocationUpdates(request, locationCallback, Looper.getMainLooper())
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY // OS restarts service if killed
}
override fun onDestroy() {
fusedClient.removeLocationUpdates(locationCallback)
super.onDestroy()
}
}
Smooth marker animation with ValueAnimator
object MarkerAnimator {
/**
* Smoothly moves a marker from its current position to [newPosition]
* over [durationMs] milliseconds using spherical linear interpolation.
* Also rotates marker bearing so the car icon faces the direction of travel.
*/
fun animateTo(
marker: Marker,
newPosition: LatLng,
newBearing: Float,
durationMs: Long = 1000L
) {
val startLat = marker.position.latitude
val startLng = marker.position.longitude
val startBearing = marker.rotation
ValueAnimator.ofFloat(0f, 1f).apply {
duration = durationMs
interpolator = LinearInterpolator()
addUpdateListener { animator ->
val t = animator.animatedValue as Float
// Linearly interpolate lat/lng
val lat = startLat + (newPosition.latitude - startLat) * t
val lng = startLng + (newPosition.longitude - startLng) * t
marker.position = LatLng(lat, lng)
// Interpolate bearing β handle 359Β° β 1Β° wrap-around
var delta = newBearing - startBearing
if (delta > 180) delta -= 360
if (delta < -180) delta += 360
marker.rotation = startBearing + delta * t
}
start()
}
}
}
Drawing and updating a route Polyline
class PolylineDrawer(private val map: GoogleMap) {
private var polyline: Polyline? = null
/** Fetches route from Directions API and draws it */
suspend fun drawRoute(origin: LatLng, destination: LatLng) {
val points = directionsApi.getRoute(origin, destination) // list of LatLng
polyline?.remove()
polyline = map.addPolyline(
PolylineOptions()
.addAll(points)
.width(8f)
.color(Color.parseColor("#4285F4")) // Google blue
.jointType(JointType.ROUND)
.startCap(RoundCap())
.endCap(CustomCap(BitmapDescriptorFactory.fromResource(R.drawable.ic_arrow)))
)
}
/** Trims the polyline up to the driver's current position β "travelled" portion disappears */
fun trimUpTo(driverPos: LatLng) {
val pts = polyline?.points ?: return
val closestIdx = pts.indexOfMinBy { distanceBetween(it, driverPos) }
polyline?.points = pts.drop(closestIdx)
}
}
Camera auto-follow with user override detection
class CameraTracker(private val map: GoogleMap) {
private var isFollowing = true
init {
// If user manually pans the map, disable auto-follow
map.setOnCameraMoveStartedListener { reason ->
if (reason == GoogleMap.OnCameraMoveStartedListener.REASON_GESTURE) {
isFollowing = false
}
}
}
/** Called every time a new driver position arrives */
fun onDriverMoved(newPos: LatLng, bearing: Float) {
if (!isFollowing) return
val camPos = CameraPosition.Builder()
.target(newPos)
.zoom(17f)
.bearing(bearing) // rotate map to face direction of travel
.tilt(45f) // 3D tilt for navigation feel
.build()
map.animateCamera(CameraUpdateFactory.newCameraPosition(camPos), 600, null)
}
/** "Re-center" button taps re-enable following */
fun resumeFollowing() { isFollowing = true }
}
Geofencing β arrival alert at pickup point
class GeofenceManager(private val context: Context) {
private val client = LocationServices.getGeofencingClient(context)
fun addPickupGeofence(pickupLat: Double, pickupLng: Double, radiusMeters: Float = 200f) {
val geofence = Geofence.Builder()
.setRequestId("PICKUP_ZONE")
.setCircularRegion(pickupLat, pickupLng, radiusMeters)
.setTransitionTypes(Geofence.GEOFENCE_TRANSITION_ENTER)
.setExpirationDuration(Geofence.NEVER_EXPIRE)
.build()
val request = GeofencingRequest.Builder()
.setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER)
.addGeofence(geofence)
.build()
val pendingIntent = getPendingIntent() // points to GeofenceBroadcastReceiver
client.addGeofences(request, pendingIntent)
}
fun removePickupGeofence() = client.removeGeofences(listOf("PICKUP_ZONE"))
}
// BroadcastReceiver β fires when driver enters the 200 m pickup circle
class GeofenceBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val event = GeofencingEvent.fromIntent(intent) ?: return
if (event.geofenceTransition == Geofence.GEOFENCE_TRANSITION_ENTER) {
// Show "Your driver is nearby!" notification
showDriverNearbyNotification(context)
}
}
}
LLD Whiteboard
| Fragment / UI | LocationViewModel | WebSocketManager | MarkerAnimator / Map | FusedLocation |
|---|---|---|---|---|
1.TrackingFragment appears. onViewCreated() calls viewModel.startTracking(tripId) |
Opens WebSocket connection to wss://api/trip/{tripId}/track |
β | β | β |
| β | β | 2.WebSocket connects. Server sends initial driver snapshot: {lat, lng, bearing, eta} |
β | β |
| β | 3.Emits DriverState.Located(pos, bearing) via StateFlow |
β | β | β |
4.Fragment collects StateFlow. Calls markerAnimator.animateTo() and cameraTracker.onDriverMoved() |
β | β | Marker appears at initial position. Camera animates to driver with tilt + bearing. | Passenger's own position shown as blue dot via map.isMyLocationEnabled = true |
| β | β | β | 5.PolylineDrawer fetches route from Directions API and draws polyline origin β destination | β |
| Driver App / Server | WebSocket (Passenger) | LocationViewModel | MarkerAnimator | GoogleMap |
|---|---|---|---|---|
1.Driver FusedLocation fires. Driver app sends {lat, lng, bearing} to server via WebSocket every 2 s |
β | β | β | β |
| Server fans out to all passengers watching this tripId | 2.Passenger WebSocket receives onMessage(json). Parses DriverLocationUpdate. |
β | β | β |
| β | β | 3.ViewModel emits new DriverState.Located(newLatLng, newBearing, etaMinutes) |
β | β |
| β | β | β | 4.ValueAnimator lerps marker from old pos to new pos over 1 000 ms. Bearing rotates smoothly. Runs at 60 fps. |
Marker glides on map. ETA label in UI updates. |
| β | β | β | 5.polylineDrawer.trimUpTo(driverPos) β removes the already-travelled portion of the polyline |
Polyline shrinks from the origin end toward driver. |
| FusedLocation / OS | GeofencingClient | GeofenceBroadcastRx | NotificationManager | UI / Fragment |
|---|---|---|---|---|
| 1.OS continuously monitors driver position against registered geofences (low-power, handled by system) | β | β | β | β |
| β | 2.Driver crosses 200 m radius around pickup point. OS triggers the registered PendingIntent |
β | β | β |
| β | β | 3.onReceive() called. GeofencingEvent.fromIntent() confirms ENTER transition for "PICKUP_ZONE" |
β | β |
| β | β | 4.Removes geofence (no longer needed). Posts "Driver is 200 m away β head to pickup" notification. | Notification fires with sound + vibration on CH_DRIVER_NEARBY | β |
| β | β | β | β | 5.If app is in foreground: LiveData / StateFlow emits DriverState.Nearby β UI shows "Driver arriving!" banner |
5. Deep Dives
Location permission landscape β Android 10+ complexity
| Use Case | Required Permission | When Prompted | Notes |
|---|---|---|---|
| User's location on map (passenger) | ACCESS_FINE_LOCATION |
Runtime β app open | Shown once. Needed for PRIORITY_HIGH_ACCURACY. |
| Driver location in background | ACCESS_BACKGROUND_LOCATION |
Separate dialog on Android 10+ β taken to Settings on 11+ | Must request FINE first, then background separately. Most critical. |
| Geofencing | ACCESS_FINE_LOCATION + ACCESS_BACKGROUND_LOCATION |
Same as above | Geofences fire even when app is killed β needs background permission. |
| Foreground Service location | FOREGROUND_SERVICE_LOCATION (Android 14+) |
Declared in manifest β no dialog | New in API 34. Must add android:foregroundServiceType="location". |
Battery optimisation for the driver app
// Switch accuracy based on trip state
fun updateLocationPriority(tripState: TripState) {
val (priority, intervalMs) = when (tripState) {
TripState.IDLE -> Priority.PRIORITY_LOW_POWER to 60_000L // 1/min
TripState.SEARCHING -> Priority.PRIORITY_BALANCED_POWER to 10_000L // 1/10s
TripState.EN_ROUTE -> Priority.PRIORITY_HIGH_ACCURACY to 2_000L // 1/2s
TripState.COMPLETED -> Priority.PRIORITY_LOW_POWER to 60_000L
}
// Re-register LocationRequest with new parameters
fusedClient.removeLocationUpdates(locationCallback)
fusedClient.requestLocationUpdates(buildRequest(priority, intervalMs), locationCallback, looper)
}
// setMinUpdateDistanceMeters filters out noise when stationary
// β if driver is stopped at a red light, identical GPS fixes aren't uploaded
6. Expected at Each Level
- Knows
FusedLocationProviderClientvs rawLocationManager - Can set up
LocationRequestwith interval and priority - Understands foreground/background permission difference
- Can add a marker and draw a polyline with the Maps SDK
- Knows a Foreground Service is required for background GPS
- Uses
animateCamera()to move the map view
- Implements
ValueAnimatorLatLng lerp for smooth marker movement - Handles bearing wrap-around (359Β° β 1Β°) in animation
- Uses
setMinUpdateDistanceMetersto filter stationary noise - CameraTracker: auto-follow disabled on user pan gesture
- Polyline trimming β removes travelled portion as driver moves
- GeofencingClient for arrival alerts without polling
- Adapts priority/interval by trip state to save battery
- Dead reckoning: extrapolate marker position between fixes (Kalman filter)
- Map clustering with
ClusterManagerfor many driver markers - Custom tile provider for offline map tile caching
- Speed-adaptive interval: increase GPS rate when driver is moving fast
- Location spoof detection: compare GPS vs network vs sensor fusion
- Multi-stop route with ordered waypoints and live rerouting
- Battery impact dashboard: alert if driver app consumes > X % per hour
7. Interview Q&A (20 Questions)
FusedLocationProviderClient (FLPC) is Google's smart location provider that internally combines GPS, Wi-Fi positioning, Bluetooth beacons, and cell towers, choosing the best available source for the requested accuracy and battery trade-off. Raw LocationManager with GPS_PROVIDER only uses satellites β it's slow to get a fix (cold start can take 30β60 s), drains battery constantly, and fails indoors. FLPC gets a fix in ~1β2 s because it uses the last known location from Wi-Fi as an immediate result while GPS warms up. For consumer apps, always use FLPC over raw LocationManager.
PRIORITY_HIGH_ACCURACY enables GPS hardware β it can achieve 3β5 metre accuracy but uses significant battery. Use it when precise turn-by-turn navigation or real-time driver tracking is needed (active trip). PRIORITY_BALANCED_POWER uses Wi-Fi and cell towers only β no GPS chip activated. Accuracy degrades to 50β100 metres but battery impact is minimal. Use it when you just need city-block precision, e.g., checking if the driver is in the general area. For the passenger app just showing their own location as a blue dot, BALANCED is sufficient. Only activate HIGH_ACCURACY when the user is actively navigating.
Android aggressively kills background processes to conserve battery. A plain background service will be killed within minutes. A Foreground Service with a persistent notification signals to the OS that this is intentional, user-visible, ongoing work β it has much higher priority and is rarely killed. Since Android 8, location updates in a background service (no foreground promotion) are throttled to once per hour. For real-time driver tracking at 2 s intervals, a Foreground Service is mandatory. Declare android:foregroundServiceType="location" in the manifest (required since Android 10) and call startForeground(id, notification) within 5 seconds of service start.
GPS fixes arrive at 1β5 second intervals. Without animation, the marker teleports β jarring UX. Use ValueAnimator.ofFloat(0f, 1f) with the animation duration matching the update interval (e.g., 1 000 ms). In addUpdateListener, linearly interpolate the latitude and longitude: lat = startLat + (endLat - startLat) * t. Update marker.position on each frame. Also interpolate marker.rotation for the bearing β but handle the 359Β° β 1Β° wrap: compute the delta, if >180 subtract 360, if <-180 add 360. The LinearInterpolator gives a constant-speed glide that matches expected car movement.
Introduced in Android 10, ACCESS_BACKGROUND_LOCATION is a separate runtime permission from ACCESS_FINE_LOCATION. It grants the app the ability to receive location updates when the app is not visible to the user. You must request FINE first, and only after it's granted can you request BACKGROUND. On Android 11+, requesting both in the same dialog is not allowed β the system forces the user to go to Settings to grant background location (no runtime dialog). The Play Store also reviews background location use and requires a compelling justification. For driver apps, the justification is clear: the driver's location must update while they navigate. Always use it minimally and explain the benefit to the user before requesting.
Register GoogleMap.setOnCameraMoveStartedListener. The callback receives a reason integer. REASON_GESTURE means the user is physically panning/zooming β set a isFollowing = false flag. REASON_API_ANIMATION means your own code called animateCamera() β don't change the flag. When a new driver position arrives: if isFollowing is false, skip animateCamera() but still animate the marker. Show a "Re-center" FAB button that sets isFollowing = true and calls animateCamera() to snap back to the driver. This pattern matches the Uber/Ola UX exactly.
You register a Geofence with a circular region (center + radius) and transition types (ENTER/EXIT/DWELL). The GeofencingClient passes this to Google Play Services, which monitors the device's position against all registered geofences at the OS level β using low-power cell/Wi-Fi positioning, not GPS. When a transition is detected, it fires a PendingIntent to your BroadcastReceiver or IntentService. This is far more battery-efficient than polling: your app code doesn't run at all during monitoring. The OS handles everything and only wakes your app when the boundary is crossed. Remove the geofence immediately after the event to avoid lingering monitoring.
Fetch the route from the Google Directions API (or a Routes API equivalent) as a list of LatLng waypoints. Draw it with map.addPolyline(PolylineOptions().addAll(points)). Store the Polyline reference. As the driver moves, call polyline.remove() and re-draw with the updated points β or more efficiently: trim the waypoints list by removing all points before the driver's current position. Find the closest point in the list to the driver's LatLng, then call polyline.points = originalPoints.drop(closestIndex). This gives the appearance of the road "disappearing behind" the driver without a full redraw.
setMinUpdateDistanceMeters(5f) tells FusedLocation to suppress callbacks if the device has moved less than 5 metres since the last update, even if the time interval has elapsed. This filters out GPS jitter β when a driver is stationary (waiting at a signal), the GPS chipset still oscillates slightly, generating false position updates. Without this filter, you'd upload hundreds of tiny "moves" per minute to your backend, burning bandwidth and battery. Setting a threshold of 5β10 metres ensures only meaningful movement triggers a callback and a server upload. Combine with the time interval to get both time-based and distance-based filtering.
Implement exponential backoff in the WebSocket manager: on onFailure() or onClosing(), schedule a reconnect after min(baseDelay * 2^attempt, maxDelay) milliseconds, with jitter (e.g., add a random 0β1 s). Show a UI indicator ("Reconnecting...") while disconnected. Stop showing the driver marker moving β optionally grey it out. On reconnection, request a fresh snapshot from the server (last known position + ETA). Cap the retry counter; after N consecutive failures, show "Unable to track β check connection" and offer a manual retry button. On Android, also listen to ConnectivityManager network callbacks to trigger reconnection immediately when the network comes back.
Use ClusterManager from the Maps Android Utils library. It groups nearby markers into a single cluster marker showing the count, and splits them apart as the user zooms in. For updating positions: maintain a Map<driverId, Marker>. On each position update, call marker.position = newLatLng directly rather than removing and re-adding β this is O(1) and preserves the cluster grouping. For very high volumes (100+ drivers), consider only rendering drivers within the visible map bounds: use map.projection.visibleRegion.latLngBounds.contains(driverPos) to filter before adding to ClusterManager. Off-screen markers waste GPU memory.
Dead reckoning extrapolates the driver's position between GPS fixes using last known speed and bearing. If a fix arrives at time T with speed 14 m/s and bearing 90Β°, and the next fix is delayed by 1 second, you can estimate the position as newLat β lastLat + (speed * cos(bearing) * dt / R). This makes the marker move continuously rather than jumping in steps. A Kalman filter combines dead reckoning estimates with actual GPS measurements, weighing each by its uncertainty, to produce a smooth, filtered track. Used in Google Maps navigation to keep the blue dot moving smoothly between 1 s GPS fixes. For an interview, describe the concept and the trade-off: extra computation for smoother UX.
Use the Google Maps SDK's MapStyleOptions. Create a JSON style file in res/raw/map_style.json (generate it at mapstyle.withgoogle.com). Apply it with: map.setMapStyle(MapStyleOptions.loadRawResourceStyle(context, R.raw.map_style)). Call this inside onMapReady() after you have the GoogleMap reference. For dark mode: detect resources.configuration.uiMode and UI_MODE_NIGHT_MASK == UI_MODE_NIGHT_YES and load a dark style JSON. The style is client-side β no server changes needed. Commonly used: hide POI labels to reduce visual noise during navigation, change road colors to brand palette.
Three cases after ActivityResultContracts.RequestPermission(): (1) Granted β proceed normally. (2) Denied but not permanently β show a rationale dialog explaining why location is needed ("We need location to show your position on the map"), then re-request. (3) Permanently denied (shouldShowRequestPermissionRationale() returns false) β show a dialog with "Open Settings" that launches Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) so the user can manually enable it. Never request the permission in a loop. Show the feature as disabled with a CTA rather than crashing. For background location on Android 11+, the user is always directed to Settings β prepare UI for this flow.
Two approaches. (1) Google Maps SDK offline areas: the SDK itself supports caching tiles for a selected geographic region β the user downloads a rectangular area in advance. This works automatically without custom code. (2) Custom TileProvider: implement TileProvider.getTile(x, y, zoom) β check a local tile cache (LRU disk cache), return cached bytes if present, else fetch from tile server and cache. Use MBTiles format for structured offline storage. Register with map.addTileOverlay(TileOverlayOptions().tileProvider(yourProvider)). For navigation apps: pre-download tiles for the route corridor ahead of time using the route bounds to determine which tiles to cache.
fusedClient.lastLocation returns a Task that resolves with the most recently known location from FusedLocation's cache β it's instant (no GPS activation) but can be null if no location has been acquired recently (fresh reboot, no recent app used location). Use it for: showing an initial map position before live updates start ("Where was I last?"), pre-populating a pickup address field, or a coarse check to see if the user is in a service area. Don't use it as the main tracking mechanism β it's stale by design. Start requestLocationUpdates() for live data and use lastLocation only as a quick first position while the live stream warms up.
Several strategies together: (1) Adaptive interval: reduce frequency when stationary (setMinUpdateDistanceMeters) and increase only when moving fast. (2) Trip-state-based priority: drop to PRIORITY_BALANCED_POWER when idle, only switch to HIGH_ACCURACY during an active trip. (3) Server-side filtering: send location only when delta exceeds a threshold β client-side minUpdateDistanceMeters does some of this, but explicit client filtering before the WebSocket send adds another layer. (4) Batch uploads: if the server allows it, buffer 3β5 fixes locally and send as a batch β fewer network radio activations. (5) Request the driver's device to be excluded from Doze mode only during active trips via PowerManager.isIgnoringBatteryOptimizations() and guide the user to whitelist the app.
Multiple signals. (1) location.isMock (API 31+) / location.isFromMockProvider() (deprecated but works pre-31) β returns true if a mock location app is active. (2) Cross-check GPS location against network/cell location: if they consistently disagree by >1 km, flag as suspicious. (3) Velocity sanity check: if the position jumps 10 km in 1 second, that's physically impossible. (4) Sensor fusion: compare GPS bearing with accelerometer/gyroscope data β a spoofed GPS that "teleports" won't match inertial sensor data. (5) Check if developer options "Allow mock locations" is enabled: Settings.Secure.getInt(cr, Settings.Secure.ALLOW_MOCK_LOCATION). Flag these signals and escalate to a server-side anti-fraud system rather than blocking locally β the driver could appeal if flagged incorrectly.
map.isMyLocationEnabled = true enables the built-in blue dot showing the user's current position, plus a "My Location" button in the top-right corner. It requires ACCESS_FINE_LOCATION or ACCESS_COARSE_LOCATION to be granted at runtime β if you enable it without the permission, it throws a SecurityException. Always check ContextCompat.checkSelfPermission() before setting it to true. The SDK handles all the FusedLocation calls internally β you don't need to manually manage your own location updates for the blue dot. Use it for the passenger's position; use your own marker + FusedLocation for the driver's position that needs server upload and custom animation.
Two approaches. (1) Server-side: the server receives each driver position update and computes remaining distance using the Directions API or a routing engine (OSRM). It pushes the updated ETA back via WebSocket to the passenger. This is the standard approach β routing is expensive and best done server-side. (2) Client-side: the passenger app detects deviation by checking if the driver's position is >N metres off the drawn polyline. If deviated beyond a threshold, request a new route from the Directions API from driver's current position to destination, redraw the polyline, and recompute ETA from the new route's duration field. Debounce rerouting β don't trigger on every minor deviation, wait until the driver is consistently off-route for 10+ seconds.