Skip to main content

iOS Native Architecture

All iOS native code lives under ios/. The implementation uses Swift for business logic with an Objective-C++ bridge file for TurboModule compatibility. Persistence uses Core Data with a SQLite store.

Component Overview

graph TD
BL["BackgroundLocation.mm<br/>(Obj-C++ TurboModule Bridge)"] --> LMW["LocationManagerWrapper<br/>(Swift)"]
LMW --> CLM["CLLocationManager"]
LMW --> LMD["LocationManagerDelegate"]
LMD --> LS["LocationStorage<br/>(Core Data)"]
LMD --> RCT["RCTEventEmitter<br/>→ JavaScript"]

RM["RecoveryManager"] --> LS
RM --> LMW

LMW --> TO["TrackingOptions"]
TO --> LA["LocationAccuracy"]

CDS["CoreDataStack"] --> LS

style BL fill:#147efb,color:#fff
style LMW fill:#f05138,color:#fff
style CLM fill:#333,color:#fff
style LS fill:#795548,color:#fff

TurboModule Bridge

BackgroundLocation.mm

The Objective-C++ bridge file connects the Codegen-generated spec to the Swift implementation. It uses @objc protocol conformance to forward all spec methods.

sequenceDiagram
participant JS as JavaScript
participant Spec as Codegen Spec
participant Bridge as BackgroundLocation.mm
participant Swift as LocationManagerWrapper

JS->>Spec: startTracking(tripId, options)
Spec->>Bridge: Forward via Obj-C++ binding
Bridge->>Swift: Call Swift method
Swift->>Swift: Configure CLLocationManager
Swift-->>Bridge: Return result
Bridge-->>Spec: Return to Codegen
Spec-->>JS: Promise resolves

The bridge handles type conversion between React Native types (ReadableMap, WritableMap) and Swift types. All TurboModule spec methods are declared in this file and forwarded to Swift classes.

This Objective-C++ bridge is necessary because React Native's TurboModule Codegen generates Objective-C++ code, and Swift classes cannot directly conform to the generated spec protocol.

LocationManagerWrapper

The central orchestrator for all CLLocationManager operations. This is the iOS equivalent of Android's LocationService + LocationProvider combined.

Configuration

When startTracking() is called, LocationManagerWrapper configures CLLocationManager with:

PropertySourceDefault
desiredAccuracyTrackingOptions.accuracy mapped via LocationAccuracykCLLocationAccuracyBest
distanceFilterTrackingOptions.distanceFilterkCLDistanceFilterNone
allowsBackgroundLocationUpdatesAlways true--
pausesLocationUpdatesAutomaticallyTrackingOptionsfalse
activityTypeMapped from options.other
showsBackgroundLocationIndicatorAlways true--

Lifecycle

stateDiagram-v2
[*] --> Idle
Idle --> Configuring: startTracking()
Configuring --> Tracking: CLLocationManager configured
Tracking --> Idle: stopTracking()
Tracking --> Recovering: App crash / system kill
Recovering --> Tracking: RecoveryManager resumes
Recovering --> Idle: Stop token found

Stop Token

Uses UserDefaults to store a boolean flag with a 60-second TTL, mirroring the Android SharedPreferences pattern. Checked before processing each location update in the delegate.

LocationManagerDelegate

Implements CLLocationManagerDelegate to handle all callbacks from CLLocationManager.

Location Updates

flowchart TD
CLM["CLLocationManager"] --> DID["didUpdateLocations:"]
DID --> TOKEN{Stop token<br/>valid?}
TOKEN -->|Yes| DROP["Drop locations"]
TOKEN -->|No| EACH["Process each CLLocation"]
EACH --> STORE["Write to LocationStorage<br/>(Core Data)"]
EACH --> EMIT["sendEvent: onLocationUpdate"]

Each CLLocation is converted to the library's coordinate format with all available fields: latitude, longitude, timestamp, horizontal accuracy, altitude, speed, course (bearing), vertical accuracy, and floor level (when available).

Error Handling

Delegate MethodEvent EmittedDescription
didFailWithError:onLocationErrorLocation provider failure
didChangeAuthorization: (downgrade)onLocationWarningPermission downgraded while tracking (PERMISSION_DOWNGRADED)
didChangeAuthorization: (revoked)onLocationErrorPermission fully revoked while tracking (PERMISSION_REVOKED)

Authorization Monitoring

The delegate monitors didChangeAuthorization: while tracking is active. If the user revokes or downgrades location permission via Settings while the app is tracking, the delegate emits the appropriate error or warning event to JavaScript.

Always Permission Escalation

iOS requires a two-step permission flow for "Always" location access. The library handles this transparently through LocationManagerWrapper.

The Two-Step Flow

sequenceDiagram
participant App as Application
participant Hook as useLocationPermissions
participant Native as LocationManagerWrapper
participant CLM as CLLocationManager
participant User as User

App->>Hook: requestPermissions()
Hook->>Native: requestLocationPermission(foregroundOnly: false)

alt First-time request
Native->>CLM: requestWhenInUseAuthorization()
CLM->>User: "Allow While Using" prompt
User-->>CLM: Grants WhenInUse
CLM->>Native: didChangeAuthorization(.authorizedWhenInUse)
Native->>Native: Set shouldIgnoreNextAuthCallback = true
Native->>CLM: requestAlwaysAuthorization()
CLM->>User: "Upgrade to Always" prompt
User-->>CLM: Grants Always
CLM->>Native: didChangeAuthorization(.authorizedAlways)
Native-->>Hook: Return status: ALWAYS
end

alt Already has WhenInUse
Native->>Native: Set shouldIgnoreNextAuthCallback = true
Native->>CLM: requestAlwaysAuthorization()
CLM->>User: "Upgrade to Always" prompt
User-->>CLM: Decision
CLM->>Native: didChangeAuthorization(status)
Native-->>Hook: Return final status
end

The shouldIgnoreNextAuthCallback Guard

When requestAlwaysAuthorization() is called, iOS may immediately fire didChangeAuthorization: with the current (WhenInUse) status before the user has responded to the Always prompt. Without the guard, the completion handler would resolve prematurely with WHEN_IN_USE instead of waiting for the actual user decision.

The shouldIgnoreNextAuthCallback flag:

  1. Is set to true immediately before calling requestAlwaysAuthorization().
  2. Causes the next didChangeAuthorization: callback to be ignored (resets the flag but does not resolve the completion handler).
  3. The subsequent callback -- triggered by the actual user response -- resolves normally.
flowchart TD
REQ["requestAlwaysAuthorization()"] --> SET["shouldIgnoreNextAuthCallback = true"]
SET --> CB1["didChangeAuthorization<br/>(immediate, premature)"]
CB1 --> CHECK1{shouldIgnoreNextAuthCallback?}
CHECK1 -->|true| IGNORE["Ignore callback<br/>Reset flag to false"]
IGNORE --> CB2["didChangeAuthorization<br/>(actual user response)"]
CB2 --> CHECK2{shouldIgnoreNextAuthCallback?}
CHECK2 -->|false| RESOLVE["Resolve completion handler<br/>with actual status"]

This guard is necessary because iOS's delegate behavior when assigning a new delegate or calling requestAlwaysAuthorization() is to immediately fire didChangeAuthorization: with the current authorization status, which would resolve the pending promise before the user has made their choice.

Core Data Persistence

CoreDataStack

Sets up the Core Data environment:

  • NSPersistentContainer with the library's .xcdatamodeld model.
  • Main context for read operations on the main thread.
  • Background context via performBackgroundTask for write operations.
  • SQLite store for durable persistence.

LocationStorage

Core Data persistence layer with batched async writes, mirroring the Android Room implementation.

FeatureImplementation
Batch writesArray buffer, flushed at 10 items or 5-second timer
Force flushforceFlush() drains buffer before reads
Tracking stateCore Data entity for crash recovery
Thread safetyperformBackgroundTask for all writes
Read operationsMain context queries

Data Model

The Core Data model mirrors the Android Room schema:

erDiagram
LocationEntity {
string id PK
string tripId
double latitude
double longitude
int64 timestamp
float accuracy
double altitude
float speed
float bearing
float verticalAccuracy
}

TrackingStateEntity {
string id PK
string tripId
string optionsJson
boolean isActive
}

RecoveryManager

Handles crash recovery using iOS significant location monitoring -- a system-managed, low-power mechanism that relaunches the app when the device moves a significant distance (typically 500m+).

Recovery Flow

flowchart TD
CRASH["App terminated<br/>(crash or system kill)"] --> SIG["Significant location change<br/>(iOS relaunches app)"]
SIG --> RM["RecoveryManager"]
RM --> TOKEN{Stop token<br/>in UserDefaults?}
TOKEN -->|Set| ABORT["Do not recover"]
TOKEN -->|Clear| RATE{Recovery count<br/>< 5 this hour?}
RATE -->|No| SKIP["Skip recovery<br/>(rate limited)"]
RATE -->|Yes| READ["Read TrackingState<br/>from Core Data"]
READ --> ACTIVE{Was tracking<br/>active?}
ACTIVE -->|No| NOOP["No action needed"]
ACTIVE -->|Yes| RESUME["Resume CLLocationManager<br/>with saved TrackingOptions"]
RESUME --> INC["Increment recovery counter"]

Rate Limiting

The recovery manager limits itself to 5 recoveries per hour to prevent runaway recovery loops. The counter resets after 1 hour from the first recovery.

ConditionAction
Counter < 5, within 1 hourAllow recovery, increment counter
Counter >= 5, within 1 hourSkip recovery
Counter > 0, timestamp > 1 hour oldReset counter, allow recovery

Significant Location Monitoring

When tracking starts, LocationManagerWrapper also calls startMonitoringSignificantLocationChanges(). This serves as the recovery trigger:

  • While app is running -- Significant location changes are ignored (normal tracking is active).
  • After app termination -- iOS relaunches the app in the background when a significant location change occurs, allowing RecoveryManager to restore the tracking session.

Significant location monitoring is very low power. It uses cell tower transitions rather than GPS, so it does not significantly impact battery life.

TrackingOptions Mapping

TrackingOptions.swift converts the bridge values into iOS-native equivalents:

Bridge FieldiOS PropertyNotes
accuracy (string)CLLocationAccuracyMapped via LocationAccuracy.swift
distanceFilter (number)CLLocationDistance0 maps to kCLDistanceFilterNone
updateIntervalNot usediOS does not support interval-based updates
fastestIntervalNot usediOS does not support interval-based updates
foregroundOnlyallowsBackgroundLocationUpdatesInverted: foregroundOnly: true sets background updates to false
notificationOptionsNot usediOS does not show persistent notifications for location

iOS does not support time-based update intervals (updateInterval, fastestInterval). Location updates are delivered as fast as the hardware provides them, filtered only by distanceFilter and desiredAccuracy. Android-specific notification fields are silently ignored.

LocationAccuracy Mapping

LocationAccuracy.swift maps the library's accuracy enum to CLLocationAccuracy constants:

Library ValueiOS ConstantApproximate Accuracy
HIGH_ACCURACYkCLLocationAccuracyBest~5m
BALANCED_POWER_ACCURACYkCLLocationAccuracyHundredMeters~100m
LOW_POWERkCLLocationAccuracyKilometer~1km
NO_POWER / PASSIVEkCLLocationAccuracyThreeKilometers~3km

Event Emission

Unlike Android's SharedFlow architecture, iOS emits events directly from the delegate to JavaScript via RCTEventEmitter.

flowchart LR
DEL["LocationManagerDelegate"] -->|sendEvent| RCT["RCTEventEmitter"]
RCT --> NEE["NativeEventEmitter<br/>(JavaScript)"]
NEE --> HOOK["React Hook"]

The same four event names are emitted on both platforms:

EventTrigger
onLocationUpdatedidUpdateLocations: callback
onLocationErrordidFailWithError: or permission revoked
onLocationWarningPermission downgraded
onNotificationActionNot applicable (iOS does not use persistent tracking notifications)

addListener / removeListeners

The TurboModule spec requires addListener(eventName:) and removeListeners(count:) methods on iOS. These are called by NativeEventEmitter when JavaScript adds or removes event subscriptions. The implementation tracks listener count to avoid emitting events when no JavaScript listeners are active.

Platform Differences from Android

AspectAndroidiOS
Background executionForeground Service (persistent notification)CLLocationManager background mode (blue status bar indicator)
Update intervalsConfigurable (updateInterval, fastestInterval)Not supported (hardware-driven)
NotificationRequired (foreground service)Not applicable
Notification actionsUp to 3 action buttonsNot applicable
Location providerDual (Fused + Android fallback)CLLocationManager only
Event architectureSharedFlow singletons + coroutine collectionDirect delegate-to-RCTEventEmitter
Recovery mechanismWorkManager (CoroutineWorker)Significant location monitoring
Recovery rate limit5 restarts/hour (restart loop detection)5 recoveries/hour (rate limiting)
PersistenceRoom (SQLite)Core Data (SQLite)
Geofence limit100 (Google Play Services)20 (CLLocationManager)
Permission flowSequential prompts via PermissionsAndroidTwo-step WhenInUse-to-Always escalation

Next Steps