Tracking From the Grave: How Apps Follow Your Location After They're Killed

Tracking From the Grave: How Apps Follow Your Location After They're Killed

calendar_today agoschedule25 min read
— Originally published at blog.anmolthedeveloper.com

Before you start: there are three ways to build this

This blog covers all three, so pick the one that fits you before you follow the code.

  1. The plugin way (flutter_background_geolocation), covered in "Step by step: build this yourself in Flutter". Fastest to ship and the best battery behaviour. It is a paid plugin from Transistorsoft, free in debug builds, but a release build needs a license key tied to your package id or bundle id.
  2. The Dart-only way (geolocator plus your own foreground service), covered in the teardown section. Free, simpler, but weaker at surviving a force quit.
  3. The native way (your own Kotlin and Swift code over method and event channels), also in the teardown section. Free, most control, most work.

So if you are building your own tracking from scratch, options 2 and 3 need no license key at all. The key is only for option 1.

The one idea to hold in your head
A background location app does NOT stay running the whole time. It cannot. Both Android and iOS will kill it. So instead of staying alive, it hands a few smart triggers to the phone's operating system and then lets itself die. The OS keeps those triggers and wakes the app back up at exactly the right second. Accuracy does not come from running 24x7. It comes from being clever about WHEN to wake up and switch on GPS.

Read that twice. Once you accept that the app is supposed to die, everything else makes sense. Beginners assume the app is secretly running in a corner forever. It is not. It is mostly dead, and that is the whole trick.

Why you cannot just keep the app running

If any app could run GPS forever in the background, your battery would be gone by lunch and every shady app would be following you around. So phone makers put a hard stop on it. Two things happen:

  1. The OS kills background apps. When your phone needs memory, or you swipe the app away, or it just decides your app has been idle too long, the operating system shuts your app down. You do not get a vote.

  2. GPS is expensive. The GPS chip is one of the hungriest things on the phone. Keep it on continuously and the battery melts.

So the plugin works WITH these rules instead of fighting them. It assumes it will be killed and plans for resurrection. Think of it like leaving a note with a friend saying "wake me when I cross this line." You go to sleep. The friend (the OS) holds the note and shakes you awake at the right moment.

The heart of it: be lazy to be accurate

Here is the clever bit that makes both battery life AND accuracy possible at once. It sounds backwards, but stay with me.

When you are standing still, GPS is turned OFF. The app drops an invisible circle around your current spot (this circle is called a geofence) and goes to sleep. The phone uses almost no battery. The app process can even die. Nobody cares, because nothing is moving.

The moment you start moving and step outside that circle, the OS notices and wakes the app up. Now the app turns on high-accuracy GPS and records every point while you travel. When you stop again, it drops a fresh circle, switches GPS off, and goes back to sleep.

It is accurate BECAUSE it is lazy. It does not waste GPS while you sit at your desk, which keeps the battery happy, which means the OS does not feel the urge to kill it, which means it is alive and precise the moment you actually move. Battery saving and accuracy are the same feature here, not a trade off.

Now the part everyone asks about: what happens when the app dies

This is where Android and iOS go in completely different directions. One at a time.

Android: a permanent worker plus notes held by the system

While your app is alive in the background, Android keeps it tracking using a foreground service. You have seen it: that notification saying "App is using your location" which you cannot swipe away. It is the deal you make with Android. Show the user a notification, and in exchange you get to keep running and reading GPS in the background. No notification, no permission. That is the rule on modern Android.

This foreground service is set to restart itself if Android kills it for memory reasons. So far so good. But what about when your app process is FULLY dead? This is the magic part.

The plugin registers two kinds of "notes" with Google Play Services, which is a separate system app that does NOT die when your app dies:

  • Geofence triggers. "Hey Play Services, when this phone crosses this circle, wake my app."

  • Activity Recognition triggers. "Hey Play Services, when this phone goes from still to walking or driving, wake my app."

When one of those conditions happens, Play Services reaches into your dead app and cold-starts a tiny piece of it called a BroadcastReceiver. Your app gets a few seconds of life, the engine wakes up, grabs the location, and does its job. Your Flutter and Dart code can be completely shut down and this still works. That is headless mode: the native engine runs without the rest of your app being awake.

And if the phone reboots? The plugin also registers a "on boot" note, so when the phone turns back on, it re-arms everything automatically.

iOS: the system relaunches your app for you

iOS is much stricter. You genuinely cannot run forever in the background, end of story. But Apple gives location tracking one special superpower: a couple of services are allowed to relaunch your app even after it has been force-quit, and even after a reboot. Almost nothing else on iOS can do this.

The two services are:

  • Region Monitoring, which is iOS's version of geofences. Cross a monitored circle and iOS relaunches your app in the background.

  • Significant Location Change (SLC), which fires when you move a meaningful distance, detected cheaply using cell towers and wifi instead of GPS.

When one of these fires, iOS relaunches your app in the background, hands it a flag saying "you woke up because of location," gives you a short moment to act, then suspends you again. Important detail: plain continuous GPS does NOT survive a force-quit on iOS by itself. So the plugin uses regions and SLC as the wake-up layer, and only switches on precise GPS once it is already awake and senses real movement.

This is also why, on iOS, the very first point after a kill can feel a little delayed. The app had to be relaunched by an event before it could even start tracking.

"Terminated" is not just one thing

Beginners treat "closed" as a single state. It is actually a ladder, and behaviour changes at each rung.

State What it means Does tracking survive?
Backgrounded, still alive You pressed home, app is in memory Yes, easily (foreground service on Android, background mode on iOS)
Killed by the OS for memory Phone needed RAM and shut your app Yes (Android restarts it and geofence/motion triggers revive it, iOS relaunches via SLC/regions)
Force-quit by the user You swiped it out of recents Android can be harsher and may delay, but Play Services triggers still cold-start a receiver. iOS still relaunches via regions and SLC
After reboot Phone was turned off and on Yes (Android re-arms with a boot receiver, iOS regions and SLC persist)

So when someone asks "does it work after the app is closed," the honest answer is "yes, but the mechanism that revives it depends on exactly how it was closed."

The last piece: never lose a single point

Accuracy is not only about capturing locations, it is about not dropping them. What if the phone is in a tunnel, or has no signal, or your server is down for a minute?

The plugin writes every location into a small local database (SQLite) on the phone FIRST, before doing anything else. A separate part of the system then uploads those saved points to your server. If the upload fails because there is no internet, the points wait in the database and go up later, in the correct order, once the connection is back.

Golden rule to copy for any tracking app: record locally first, sync as a totally separate concern. A dead zone should never punch a hole in your track.

The full picture in one diagram

That loop is the entire product. Sleep cheaply, wake on a real signal, track precisely while moving, save everything, repeat.


Step by step: build this yourself in Flutter

This section is a complete walk through using the flutter_background_geolocation package, since that is the battle-tested way to get all of the above without writing the native code yourself. Follow it top to bottom.

Step 0: understand what you are signing up for

This package is free to fully test in DEBUG builds. For a RELEASE build on Android and for the App Store on iOS, you need a paid license key tied to your package id or bundle id. So you build and test for free, and only pay when you ship. Plan for that.

Step 1: add the package

In your pubspec.yaml:

dependencies:
  flutter_background_geolocation: ^4.16.0
  # the package also needs its companion, added automatically in most cases

Then run flutter pub get. Always check pub.dev for the latest version number rather than copying mine.

Step 2: Android setup

2a. Permissions in AndroidManifest.xml

Inside android/app/src/main/AndroidManifest.xml, above the <application> tag, declare what you need. Each line has a reason:

<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- lets you track while the app is in the background -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- the permanent notification that keeps you alive -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<!-- detect still vs walking vs driving -->
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<!-- re-arm tracking after the phone reboots -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />

2b. License key (only needed for release)

Inside the <application> tag, add your license key as a meta-data line:

<meta-data
  android:name="com.transistorsoft.locationmanager.license"
  android:value="YOUR_LICENSE_KEY" />

2c. Gradle repository

The package pulls its native engine from Maven. Recent versions wire this up for you, but if a build fails saying it cannot find tslocationmanager, check the package's install docs for the repository line to add in android/build.gradle or settings.gradle.

Step 3: iOS setup

3a. Info.plist text

In ios/Runner/Info.plist, add the permission descriptions. iOS shows these sentences to the user, so write them in plain language explaining WHY:

<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location to record your visits while you use the app.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We record your location in the background so your route is not lost.</string>
<key>NSMotionUsageDescription</key>
<string>We use motion data to save battery while tracking.</string>

3b. Background modes

Still in Info.plist, switch on the background abilities:

<key>UIBackgroundModes</key>
<array>
  <string>location</string>
  <string>fetch</string>
</array>

You can also enable these by ticking "Location updates" and "Background fetch" under Signing and Capabilities in Xcode.

Step 4: ask for permission the right way

Do not ask for background location on the very first screen. Both stores dislike it and users reject it. Ask for "while in use" first, explain why in your own UI, then request "always." The plugin requests what it needs when you call ready, but a gentle explanation screen before that hugely improves acceptance.

Step 5: configure and start tracking (the Dart part)

Here is a minimal but complete setup. Read the comments, they explain each choice.

import 'package:flutter_background_geolocation/flutter_background_geolocation.dart' as bg;

Future<void> initTracking() async {
  // 1. Listen for each new location
  bg.BackgroundGeolocation.onLocation((bg.Location location) {
    print('New location: ${location.coords.latitude}, ${location.coords.longitude}');
  });

  // 2. Listen for the still <-> moving switch
  bg.BackgroundGeolocation.onMotionChange((bg.Location location) {
    print('Moving state changed. isMoving = ${location.isMoving}');
  });

  // 3. Configure the engine
  await bg.BackgroundGeolocation.ready(bg.Config(
    desiredAccuracy: bg.Config.DESIRED_ACCURACY_HIGH, // best GPS while moving
    distanceFilter: 10,            // record a point every 10 meters of movement
    stopOnTerminate: false,        // keep tracking after the app is killed
    startOnBoot: true,             // re-arm after the phone reboots
    enableHeadless: true,          // allow waking with no UI alive
    foregroundService: true,       // the persistent notification on Android
    url: 'https://your-server.com/locations', // where to upload points
    autoSync: true,                // upload automatically
    batchSync: false,              // send points one by one (or batch them)
  )).then((bg.State state) {
    if (!state.enabled) {
      // 4. Actually start it
      bg.BackgroundGeolocation.start();
    }
  });
}

The two settings that make it survive being killed are stopOnTerminate: false and startOnBoot: true. The one that makes it work with no UI is enableHeadless: true.

Step 6: handle the headless wake-up (Android)

When the OS wakes your dead app, your normal Dart code is not running. You register a separate tiny function that the engine can call in that headless moment. Put this at the very top level of your main.dart, outside any class.

// must be a top-level function
@pragma('vm:entry-point')
void headlessTask(bg.HeadlessEvent headlessEvent) async {
  switch (headlessEvent.name) {
    case bg.Event.LOCATION:
      bg.Location location = headlessEvent.event;
      print('Headless location: $location');
      break;
    case bg.Event.MOTIONCHANGE:
      print('Headless motionchange');
      break;
  }
}

void main() {
  runApp(const MyApp());
  // register the headless handler
  bg.BackgroundGeolocation.registerHeadlessTask(headlessTask);
}

The @pragma('vm:entry-point') line is not optional. It stops Flutter from throwing this function away during release optimization, because the engine has to find it by name when the app is otherwise dead.

Step 7: test it properly

Testing with the app open proves nothing. Real tests:

  1. Start tracking, then swipe the app out of recents. Walk around the block. Check your server received points.

  2. Reboot the phone without opening the app. Move. Confirm tracking re-armed itself.

  3. Sit still for ten minutes and watch the battery. It should barely move. If GPS is stuck on, your config is wrong.

  4. Turn on airplane mode, move around, then turn internet back on. Confirm the queued points upload in order.

Step 8: survive the cheap-phone problem (very important in India)

Many budget Android phones (Xiaomi, Oppo, Vivo, Realme and others) run aggressive "battery saver" software that kills background apps no matter what the rules say. This is the number one reason tracking apps fail in the real world here. Defenses:

  • Guide the user to disable battery optimization for your app. The plugin has a helper, bg.DeviceSettings, that can open the right settings screen.

  • For company-owned devices, look into Android Device Owner or a managed enrollment. When your app is the device owner, the OEM battery killers no longer apply, and force-quit and optimization simply stop being a problem. For a controlled set of devices, this is the most reliable answer by far.

  • Show a clear one-time screen explaining that they must allow background location and turn off battery optimization, or tracking will be unreliable.

Quick mental checklist

  1. The app is meant to die. Plan for resurrection, do not fight it.

  2. Sleep with GPS off behind a geofence. Wake on geofence exit or motion.

  3. Android revives via a foreground service plus Play Services geofence and activity triggers, re-armed on boot.

  4. iOS revives via Region Monitoring and Significant Location Change, which can relaunch even after force-quit.

  5. Save every point to local SQLite first, upload as a separate retrying step.

  6. stopOnTerminate: false, startOnBoot: true, enableHeadless: true are the three settings that matter most.

  7. On cheap Android phones, defeat the OEM battery killer or your tracking dies silently.


When does the foreground service actually show up? (clearing the confusion)

This confuses almost everyone, so let us nail it down. Remember one thing: that notification IS the foreground service. Visible means the service is running. Gone means it has stopped. There is no hidden third state.

When it starts

The foreground service starts only when the app is actively tracking, which means when the person is in the MOVING state. The flow looks like this:

So the notification is not meant to be visible all the time. It comes and goes depending on whether the person is moving. This is deliberate, not a bug. When the person sits still, GPS and the service both shut down, the phone sleeps, and the battery is safe.

If it stops (notification disappears), is that a problem?

No. The notification disappearing does NOT mean tracking has died. It only means the app went to sleep in stationary mode. The OS still holds the two triggers, the geofence and the motion trigger. The moment the person moves, it wakes the app, the service restarts, and the notification comes back.

Notification state What it actually means
Visible The app is actively tracking with GPS (moving)
Gone The app is asleep (stationary), waiting for a trigger. Tracking is NOT dead

So if the notification vanishes during testing, do not panic. Move around and it will reappear. Tracking was alive the whole time, just sleeping cheaply.

If you want the notification to stay visible always

If you would rather the user always sees that tracking is on, the plugin can be configured so the foreground service runs continuously for as long as tracking is enabled. The notification then stays permanent. The trade off is more battery usage. So you have two modes:

  • Battery friendly: notification appears only while moving. Best battery life.

  • Always visible: notification stays up the whole time tracking is enabled. More transparent and more reliable on aggressive phones, but uses more battery.

One caution: on newer Android (12 and above) Google restricts starting a foreground service from the background, so the exact come-and-go behavior can vary a little by Android version and plugin version. The plugin handles this internally, but always test on your real target phones to confirm the notification behaves the way you expect.


A different way real apps do this (a teardown story)

Everything above uses flutter_background_geolocation, which is the gold standard. But many tracking apps in the wild do NOT use it. They build a simpler version by hand, and it is worth understanding because you will meet it far more often than you expect.

There are two ways to build that simpler version. One stays entirely in Dart and leans on the geolocator plugin. The other drops into native Android and iOS code and talks to Flutter through channels, which gives you far more control and lets you read signals the plugin never surfaces. Let us walk through both.

Way 1: The Dart-only approach (the geolocator plugin)

Instead of the geofence-sleep engine, these apps use the basic geolocator package to read positions and wrap it in their own foreground service that stays on the whole time tracking is enabled. No sleeping behind a geofence. The service starts, holds a wake lock, and streams positions on a distance filter until tracking is turned off.

Why build it this way? It is simple, it has no license cost, and often you do not need the app to survive a swipe-away for days. You just need reliable tracking during an active shift, which this handles fine.

The trade-offs you must know:

  • It burns more battery, because GPS and the service run continuously instead of sleeping behind a geofence.

  • Surviving a hard force-quit is weaker. These apps lean on stopWithTask=false, a boot receiver and a battery-optimisation exemption, but they do not have the Play Services geofence resurrection trick. So a fully killed process can simply stop until the user opens the app again.

  • It is much easier to reason about and debug, which is a real advantage when you are shipping fast.

Tip
If your tracking only needs to be reliable during a known active window, like a shift, a delivery, or a site visit, the simple always-on service is often the pragmatic choice. Save the full geofence engine for true all-day passive tracking where battery life is the main worry.

Way 2: The native approach with method and event channels

The Dart-only way is easy, but it only gives you what the plugin chooses to expose. For full control you drop into native code: location logic in Kotlin on Android and Swift on iOS, your own foreground service, results sent up to Flutter through a channel. Serious tracking apps usually land here, because the richest signals live in the native layer, not in Dart.

Method channel versus event channel, in one line each

  • A MethodChannel is a request and a response. Dart calls something like startTracking, native runs it and replies once. It is made for commands: start, stop, or ask a one-time question.

  • An EventChannel is a continuous stream. Native keeps pushing values, and Dart keeps listening. This is the perfect fit for a live location feed, because points arrive again and again.

The signals you can grab natively, and why they matter

In native code, each update can carry much more than latitude and longitude. These extra fields make a track trustworthy and easy to debug:

  • Speed in metres per second, straight from the GPS fix. Catches impossible jumps and tells walking from driving.

  • isMocked, the real spoof flag, read right at the source instead of through a wrapper.

  • Device info like model and manufacturer, so you can spot the cheap OEM phones that love to kill background apps.

  • Battery level and charging state, so your server understands that a dead battery, not a bug, is why the points stopped arriving.

Bundling all of these into every event turns a plain coordinate into a record you can actually trust.

Android native (Kotlin)

Inside your own foreground service, you use the FusedLocationProviderClient. On each update, you build a map with the extra signals and push it into the EventChannel sink.

// inside your location EventChannel StreamHandler
private val locationCallback = object : LocationCallback() {
    override fun onLocationResult(result: LocationResult) {
        val location = result.lastLocation ?: return

        // read the spoof flag at the source
        val isMock = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            location.isMock              // API 31 and above
        } else {
            @Suppress("DEPRECATION")
            location.isFromMockProvider  // older devices
        }

        // battery level right now
        val bm = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
        val batteryLevel = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)

        val data = mapOf(
            "latitude" to location.latitude,
            "longitude" to location.longitude,
            "speed" to location.speed,          // metres per second
            "accuracy" to location.accuracy,
            "isMocked" to isMock,
            "deviceModel" to Build.MODEL,
            "manufacturer" to Build.MANUFACTURER,
            "batteryLevel" to batteryLevel,
            "timestamp" to location.time
        )

        // send this event up to Dart
        eventSink?.success(data)
    }
}

iOS native (Swift)

On iOS you use CLLocationManager. The mock story is different here. Plain iOS does not hand you a mock provider flag the way Android does, but from iOS 15 you can check whether a fix was produced by a simulator through the location's sourceInformation.

func locationManager(_ manager: CLLocationManager,
                     didUpdateLocations locations: [CLLocation]) {
    guard let location = locations.last else { return }

    // spoof hint on iOS 15 and above
    var isMocked = false
    if #available(iOS 15.0, *) {
        isMocked = location.sourceInformation?.isSimulatedBySoftware ?? false
    }

    // battery level right now
    UIDevice.current.isBatteryMonitoringEnabled = true
    let batteryLevel = Int(UIDevice.current.batteryLevel * 100)

    let data: [String: Any] = [
        "latitude": location.coordinate.latitude,
        "longitude": location.coordinate.longitude,
        "speed": location.speed,             // metres per second, -1 if unknown
        "accuracy": location.horizontalAccuracy,
        "isMocked": isMocked,
        "deviceModel": UIDevice.current.model,
        "batteryLevel": batteryLevel,
        "timestamp": location.timestamp.timeIntervalSince1970
    ]

    // send this event up to Dart
    eventSink?(data)
}

The Dart side, just listening

On the Flutter side you do almost nothing. You open the same two channels and listen. All the heavy lifting already happened natively, so Dart only reads a clean event.

class NativeTracking {
  static const _commands = MethodChannel('app/tracking/commands');
  static const _events = EventChannel('app/tracking/events');

  Future<void> start() => _commands.invokeMethod('startTracking');
  Future<void> stop() => _commands.invokeMethod('stopTracking');

  Stream<Map<String, dynamic>> get locationStream =>
      _events.receiveBroadcastStream().map((e) => Map<String, dynamic>.from(e));
}

// usage
final tracking = NativeTracking();
tracking.locationStream.listen((data) {
  if (data['isMocked'] == true) {
    // reject, native already flagged this point as fake
    return;
  }
  print('lat ${data['latitude']}, speed ${data['speed']}, '
      'battery ${data['batteryLevel']}, device ${data['deviceModel']}');
});

Tip
Do the mock check, the speed sanity check, and the device and battery reads in native code, not in Dart. That is where the real signals live, and it means the event that reaches Dart is already clean and trustworthy. The Dart layer should just consume a validated event, not try to police it after the fact.

Which way should you pick?

You want Go with
Fastest to ship, standard needs Way 1, the geolocator plugin in pure Dart
Full control, richer signals, your own foreground service and spoof logic Way 2, native code through method and event channels

Most apps start on Way 1 and move to Way 2 only when they hit a wall, like needing a signal the plugin does not expose, or tighter control over the service and the spoof checks.

The piece the earlier sections skipped: stopping fake GPS

Everything above assumed the location you receive is honest. Often it is not. Someone can install a fake-GPS app and punch in from home while the map shows the office. If location decides money in your app, like attendance, travel allowance, or delivery proof, you have to defend against this or the whole feature is meaningless.

Every Android location object carries a simple flag that tells you whether it came from a mock provider. In the geolocator package, each Position exposes isMocked. Checking it is the cheapest first line of defence you can add.

import 'package:geolocator/geolocator.dart';

Future<void> readLocation() async {
  Position position = await Geolocator.getCurrentPosition(
    desiredAccuracy: LocationAccuracy.high,
  );

  // Android marks positions that came from a fake GPS app
  if (position.isMocked) {
    // do not trust this point, flag the user and reject it
    print('Mocked location detected, rejecting this point');
    return;
  }

  print('Trusted location: ${position.latitude}, ${position.longitude}');
}

Real apps go further and refuse to run at all on devices that make spoofing easy:

  • Rooted or jailbroken devices are blocked, because root makes faking location trivial and hard to detect.

  • Emulators are blocked, since an emulator can report any coordinate you type into it.

  • Developer Mode being switched on is flagged, because most mock-location apps need it enabled.

Tip
isMocked is necessary but not enough on its own. A rooted device can hide the mock flag entirely. That is why serious apps combine the flag with root and emulator checks, and add a server-side sanity check too, like rejecting impossible speed between two points or a location that jumps across cities in a few seconds. Trust a position only after it clears all of these gates.

Live tracking versus store and forward

Earlier we said: save every point to SQLite first, then sync separately. That is right for route logging, where you want a complete gap-free history.

Some apps have a different need. An admin wants to watch a live map and see people move right now, and the store-and-forward queue is too slow for that. These apps push each position straight into a realtime database, usually Firebase Realtime Database, so the dashboard updates the instant a point arrives.

Approach Best for Latency Offline behaviour
Store and forward (local SQLite queue) Route history, reports, payroll proof Seconds to minutes Points wait safely and sync later in order
Live push (realtime database) An admin watching a live map Almost instant Needs a fallback, a purely live point can be lost with no connection

The two are not enemies. A robust app does both: push live for the dashboard, and still keep a local SQLite copy so a dead zone never punches a hole in the history. Live for the eyes, local for the record.

What to copy and what to avoid

Good ideas worth stealing from apps that ship this successfully:

  • Check isMocked on every point and reject mocked ones.

  • Block, or at least flag, rooted and emulator devices for any money-critical tracking.

  • Consider a live push to a realtime database when someone genuinely needs to watch a map, but keep the local queue as well.

  • Use stopWithTask=false plus a boot receiver so a swipe-away or a reboot does not silently end a shift.

Mistakes worth avoiding, all of which are common in shipped apps:

  • Shipping cleartext HTTP (usesCleartextTraffic=true) so that location data travels unencrypted. Always send coordinates over HTTPS.

  • Leaving a local test URL, like an http://192.168.x.x endpoint, hardcoded in the release build.

  • Hardcoding unrestricted map API keys in the app. Restrict every key by package name and signing certificate, and by the specific API it is allowed to call, or someone will run up your billing.

  • Trusting the phone's location blindly with no spoof defence at all.

The one line to remember from this teardown
The fancy plugin is not the only way, and it is not always the right way. Pick your architecture from the need. Simple always-on service for a bounded shift, full geofence engine for all-day passive tracking. And whatever you build, if location decides money, never trust a coordinate until it has proven it is not faked.


The third wake-up style: timer-driven periodic sampling

So far you have seen two ways an app gets woken up: the geofence and motion approach, where the OS wakes you because you moved, and the continuous foreground service, where you stay awake the whole time. There is a third style, and it answers a specific question: "the phone is in deep sleep, but I still want a location every few minutes." This is timer-driven or periodic sampling, built from two Android pieces working together, SCHEDULE_EXACT_ALARM and WAKE_LOCK.

First, why a normal timer does not work

Your instinct might be a simple loop or a Timer that fires every five minutes and grabs a location. In the background this quietly fails, and it is worth understanding why.

When the phone goes idle with the screen off, it enters a power saving state called Doze, and the CPU is put to sleep. While that happens, your app's own timers, Future.delayed calls, and background loops are all frozen. They do not fire until something wakes the CPU, and your app process can even be killed. So a plain "every five minutes" timer is simply not running when the phone is asleep, which is exactly when you needed it.

The key realisation
Anything that must fire while the phone sleeps cannot live inside your app's own timers. It has to be handed to the operating system, because the OS does not sleep the way your app does. That is what AlarmManager is for.

Piece one: SCHEDULE_EXACT_ALARM, the part that wakes the phone

AlarmManager is a system service that runs a small piece of your code at a chosen time, whether your app is alive or dead. Think of it as leaving an alarm clock with the OS instead of trying to stay awake yourself. There are two flavours, and the difference matters:

  • A normal (inexact) alarm gets batched and delayed by Doze to save battery. A five minute alarm might actually fire twenty minutes later. For location sampling this is useless.

  • An exact alarm, using setExactAndAllowWhileIdle, is allowed to fire on time even in Doze.

To use exact alarms on Android 12 and above (API 31), you must declare the SCHEDULE_EXACT_ALARM permission. Without it the system only gives you the inexact kind. This is exactly why tracking apps request it.

A catch worth knowing
An exact alarm is one-shot. It fires once and forgets. So every time it fires, you must schedule the next one yourself, or the cycle stops after a single tick. The pattern is: wake up, do the work, then set the next alarm before going back to sleep.

Piece two: WAKE_LOCK, the part that keeps the phone awake long enough

Here is the subtle part. When your alarm fires, the system gives your BroadcastReceiver only a very short window to run, roughly ten seconds.

But getting a GPS fix is slow and asynchronous. A cold fix can take five to thirty seconds. If you request a location and then let your receiver return, the CPU goes back to sleep before the fix arrives, so your callback never completes or your upload dies halfway.

The fix is to grab a partial wake lock the moment your receiver runs. It keeps the CPU awake while the screen stays off, using very little battery. You hold it while you request the location, wait for the fix, save it, and upload it. Then you release it so the phone can sleep again.

One line to remember
The alarm wakes the phone at the right second. The wake lock keeps it awake just long enough to finish the job. One is about when, the other is about how long.

The full loop

What it looks like in Kotlin

// 1. Schedule the next wake-up
fun scheduleNextWakeup(context: Context) {
    val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
    val intent = Intent(context, LocationAlarmReceiver::class.java)
    val pi = PendingIntent.getBroadcast(
        context, 0, intent,
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
    )
    val triggerAt = System.currentTimeMillis() + 5 * 60 * 1000 // 5 minutes later

    // exact, and allowed to fire even in Doze (needs SCHEDULE_EXACT_ALARM)
    am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi)
}

// 2. The receiver: stay awake, get a location, re-arm, then sleep
class LocationAlarmReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
        val wakeLock = pm.newWakeLock(
            PowerManager.PARTIAL_WAKE_LOCK, "app:LocationWakeLock"
        )
        wakeLock.acquire(60_000) // safety timeout, hold at most 60 seconds

        val fused = LocationServices.getFusedLocationProviderClient(context)
        fused.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null)
            .addOnSuccessListener { location ->
                saveAndUpload(location) // your own save plus upload
            }
            .addOnCompleteListener {
                scheduleNextWakeup(context)          // re-arm the next alarm
                if (wakeLock.isHeld) wakeLock.release() // let the phone sleep
            }
    }
}

Two caveats you must plan for

  • Doze rate-limits even exact alarms. In deep Doze, setExactAndAllowWhileIdle reliably fires only about once every nine minutes. So you cannot sample every thirty seconds this way, and for continuous tracking you still need a foreground service. Real apps often combine both: a foreground service while the person is active, alarm-based sampling for check-ins while the phone is idle.

  • Battery optimisation can block it. Requesting REQUEST_IGNORE_BATTERY_OPTIMIZATIONS and guiding the user to allow it makes these alarms fire far more reliably, especially on the aggressive cheap OEM phones we keep coming back to.

So where does this fit among the three styles?

Wake-up style What triggers a location Best for
Geofence and motion (the plugin way) You physically move All-day passive tracking with great battery life
Continuous foreground service Nothing, it never sleeps Dense, precise tracking during an active shift
Timer-driven periodic sampling A clock, every few minutes Occasional check-ins while the phone is idle, cheaper than staying awake

The takeaway
Exact alarms plus a wake lock are the tool when you want a location on a schedule rather than on movement. The alarm handles when to wake, the wake lock handles staying awake long enough to finish, and you re-arm the alarm on every tick. Just remember Doze will not let you sample faster than roughly every nine minutes, so pair it with a foreground service whenever you need something tighter.

1 Comment

1 vote
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

Breaking the AI Data Bottleneck: How Hammerspace's AI Data Platform Eliminates Migration Nightmares

Tom Smithverified - Mar 16

Chrome Cut Android Scroll Jank 48%: What to Check on Your Site

ApogeeWatcherverified - Jul 29

How to Build Responsive Flutter Apps for Phones, Foldables, Tablets & Web (2026)

techwithsam - Mar 22

Cisco's Amy Chang: A Model's "Passport" Doesn't Tell You Where It Actually Came From

Tom Smithverified - Aug 27

From Spaghetti to Structure: Why I Migrated My Production Flutter App to Clean Architecture

Lordhacker756 - Mar 31
chevron_left
137 Points5 Badges
1Posts
0Comments
I'm Anmol Singh Tuteja, a mobile developer based in Raipur, India. I started coding in 2015, mostly ... Show more

Related Jobs

View all jobs →

Commenters (This Week)

11 comments
2 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!