Back to Home

GPS tracker in iOS: Kalman filter and geohash implementation

Article on implementing a background GPS tracker on SwiftUI using Kalman filter and geohash. Detailed breakdown of technical solutions for data processing, route visualization and passing App Store Review.

Background GPS tracker in iOS: complete technical guide
Advertisement 728x90

# How to Create a Background GPS Tracker in iOS: Technical Solutions and Pitfalls

After Google Timeline was shut down in 2024, developers lost a reliable tool for route analysis. Instead of cloud-based solutions, we're now building local apps that accurately track fuel consumption. We'll walk through implementing a GPS tracker in SwiftUI, covering noisy data handling, background optimization, and navigating App Store Review.

Basics of Background Tracking in CoreLocation

The key challenge is obtaining coordinates when the app is backgrounded without killing the battery. Here's what's required:

  • Activate allowsBackgroundLocationUpdates in CLLocationManager
  • Add Background Modes entitlement with the location type
  • Provide justification in Info.plist via NSLocationAlwaysAndWhenInUseUsageDescription

The initial implementation, which recorded every point, resulted in excessive data volume (thousands of points per trip). Optimization came from combining these parameters:

Google AdInline article slot
  • desiredAccuracy = kCLLocationAccuracyBest — prioritizes GPS over Wi-Fi/cellular towers
  • distanceFilter = 10 — ignores movements under 10 meters
  • Filtering by horizontalAccuracy > 50 — weeds out noisy data

These settings cut data down to 200–500 points per trip. With 60+ routes, the database takes up ~15 MB, which is fine for CoreData.

Kalman Filter Without IMU: Math for Highway Tracking

On sections with GPS jamming (Krasnodar–Gelendzhik), classic sensor-based solutions aren't available — CMMotionManager gets blocked in the background. We had to adapt the Kalman filter to work solely with coordinates:

func processLocation(_ location: CLLocation) -> CLLocation {
    guard let previous = lastFilteredLocation else {
        lastFilteredLocation = location
        return location
    }

    let dt = location.timestamp.timeIntervalSince(previous.timestamp)
    let processNoise = dt * speedVariance
    predictedVariance += processNoise

    let measurementVariance = location.horizontalAccuracy * location.horizontalAccuracy
    let kalmanGain = predictedVariance / (predictedVariance + measurementVariance)

    let lat = previous.coordinate.latitude
        + kalmanGain * (location.coordinate.latitude - previous.coordinate.latitude)
    let lon = previous.coordinate.longitude
        + kalmanGain * (location.coordinate.longitude - previous.coordinate.longitude)

    predictedVariance = (1 - kalmanGain) * predictedVariance

    return CLLocation(
        coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lon),
        altitude: location.altitude,
        horizontalAccuracy: sqrt(predictedVariance),
        verticalAccuracy: location.verticalAccuracy,
        timestamp: location.timestamp
    )
}

The algorithm dynamically adjusts trust in measurements: with high horizontalAccuracy (poor GPS signal), kalmanGain drops, and the system leans more on prior data. For signal dropouts longer than 10 seconds, we use post-processing with cubic splines.

Google AdInline article slot

Visualizing Routes by Speed in SwiftUI

iOS 17+ offers MapPolyline, but it's limited to one color per object. For dynamic speed-based coloring of routes, we needed to:

  • Break the track into segments with uniform speed ranges
  • Render each segment as a separate MapPolyline
  • Overlap adjacent segments by one point
struct SpeedSegment {
    let coordinates: [CLLocationCoordinate2D]
    let color: Color
}

func buildSegments(from points: [LocationPoint]) -> [SpeedSegment] {
    var segments: [SpeedSegment] = []
    var currentCoords: [CLLocationCoordinate2D] = []
    var currentColor: Color = .green

    for point in points {
        let color = speedColor(for: point.speed)
        if color != currentColor && !currentCoords.isEmpty {
            segments.append(SpeedSegment(
                coordinates: currentCoords,
                color: currentColor
            ))
            currentCoords = [currentCoords.last!]
        }
        currentCoords.append(point.coordinate)
        currentColor = color
    }

    if !currentCoords.isEmpty {
        segments.append(SpeedSegment(
            coordinates: currentCoords,
            color: currentColor
        ))
    }
    return segments
}

The system uses 4 color ranges:

  • Green: < 50 km/h (city)
  • Yellow: 50–90 km/h (highways)
  • Orange: 90–110 km/h (freeways)
  • Red: > 110 km/h

A typical route generates 20–50 polylines. With 500+ points, there can be lag on older devices, but performance is solid for most use cases.

Google AdInline article slot

Geohash Fog of War: Geospatial Optimization

For the "fog of war" effect (hiding untraveled sections), we chose geohash over quadtree or direct point storage. Advantages:

  • Fast lookup via prefix matching
  • Simple serialization to CoreData
  • Flexibility via precision parameter

| Precision | Cell Size | Application |

|-----------|----------------|-----------------|

| 5 | ~4.9 × 4.9 km | Country overview|

| 6 | ~1.2 × 0.6 km | City overview |

| 7 | ~150 × 150 m | Detailed view |

Implementation includes:

  • Dynamic precision switching based on MKCoordinateSpan
  • Checking visible cells via Set<String>
  • Dissipation animation with opacity 0.7s easeOut

Current limitation: rectangular cell boundaries. Next up: gradient rendering with edge blurring for a more natural look.

Key Takeaways

  • Input filtering is critical: without distanceFilter and horizontalAccuracy checks, the database balloons 5–10x
  • Live Activity has strict limits: 1 Hz updates are the max iOS allows
  • Geohash beats quadtree for mobile scenarios due to simpler serialization
  • Kalman filter without IMU handles up to 10-second signal dropouts
  • MapPolyline needs segmentation for dynamic coloring — one route = 20–50 objects

Live Activity: Combating Human Error

Problem: users forget to stop recording, draining the battery. Solution: a Lock Screen indicator via ActivityKit:

// Launch activity
let activity = try Activity.request(
    attributes: TripActivityAttributes(carName: "Polo Sedan"),
    content: .init(state: initialState)
)

// Update every second
await activity.update(.init(state: updatedState))

Key point: update frequency is capped at ~1 Hz. Sending data more often gets ignored by the system. That's enough for a speedometer, but high-frequency metrics need data aggregation.

Data Storage in CoreData

The database schema is optimized for fast access to routes and points:

TripEntity (1) ──── (*) LocationPointEntity
    ├── startDate
    ├── distance
    └── fuelCost

LocationPointEntity
    ├── latitude
    ├── longitude
    └── speed

ExploredCellEntity
    └── geohash (unique)

Implementation details:

  • NSMergeByPropertyObjectTrumpMergePolicy for geohash cells
  • Auto-deletion of old records via timestamp
  • Indexing by tripID and timestamp for faster queries

With 500+ points per route, minimizing fetch requests is crucial. All LocationPointEntity operations run in a single managed object context with batch updates.

— Editorial Team

Advertisement 728x90

Read Next