Android SDK: Taming the Battery Manager
Android is infamous for aggressively killing background services. Chinese OEMs (like Xiaomi, Huawei) even have custom power managers that completely break standard Android background execution limits.
This page explains why Android kills your app and how Tracelet’s specific Android configurations solve it in the real world.
Optional dependency: high-accuracy GMS location
Tracelet’s Android SDK can use Google Play Services Location — FusedLocationProviderClient
(better accuracy and battery), hardware activity recognition (walking / driving / still),
and hardware geofencing. To keep the SDK lightweight and usable on Google-less devices, this
dependency is not bundled. You add it only if you want the high-accuracy path:
// android/app/build.gradle.kts
dependencies {
implementation("com.google.android.gms:play-services-location:21.3.0")
}It’s optional, and the SDK degrades gracefully. Without play-services-location, Tracelet
falls back to the standard AOSP LocationManager (plain GPS). Tracking still works — you just
lose the fused provider’s accuracy/battery benefits, hardware activity recognition, and hardware
geofencing. Recommended: add it unless you specifically target Google-less / AOSP devices
(e.g. Huawei without GMS, or de-Googled ROMs).
Minimum version: 21.2.0. Tracelet’s Android code calls the interface-based
FusedLocationProviderClient and ActivityRecognitionClient APIs, which only became
interfaces in play-services-location 21.2.0. Older releases (e.g. 19.0.0) ship these
as concrete classes, so calling into them throws
java.lang.IncompatibleClassChangeError at runtime. Tracelet publishes a Gradle
dependency constraint that raises play-services-location to 21.2.0+ automatically if
another dependency pulls in an older version, but if you pin the version yourself, keep it at
21.2.0 or newer (we recommend 21.3.0).
With play-services-location | Without it (AOSP fallback) |
|---|---|
| Fused location (best accuracy + battery) | Raw GPS / network via LocationManager |
Hardware activity recognition (onActivityChange) | Accelerometer-only motion detection |
| Hardware geofencing | Software (in-SDK) geofence evaluation |
Optional dependency: Play Integrity (device attestation)
If you use Tracelet’s device attestation feature (AttestationConfig), the Android
side generates its cryptographic token through Google Play Integrity. Just like
play-services-location, this dependency is not bundled — you add it only when you
enable attestation:
// android/app/build.gradle.kts
dependencies {
implementation("com.google.android.play:integrity:1.6.0")
}It’s optional, and attestation degrades gracefully. Without the Play Integrity
dependency, everything else in Tracelet works normally — only attestation is affected:
AttestationConfig(enabled: true) logs a warning and Tracelet.getAttestationToken()
returns null instead of a token, rather than crashing. Add the dependency only if you
enable attestation and need the Play Integrity verdict on Android. See the
Device Attestation guide
for the full workflow and server-side verification.
It must be implementation, not compileOnly. Play Integrity is compileOnly inside
the Tracelet SDK (so it is never shipped or forced on apps that don’t use attestation). If
you enable attestation without adding the dependency to your app module as
implementation, the classes are absent at runtime and attestation silently returns null.
Permissions & Manifest Setup
Tracelet automatically injects the necessary permissions into your AndroidManifest.xml when you compile your app. By default, it requests:
ACCESS_COARSE_LOCATION&ACCESS_FINE_LOCATION(Required for tracking)ACCESS_BACKGROUND_LOCATION(Required for tracking when app is closed)FOREGROUND_SERVICE&FOREGROUND_SERVICE_LOCATION(Required for continuous background execution)ACTIVITY_RECOGNITION(Required for the smart motion-detection engine)POST_NOTIFICATIONS(Required for Android 13+ foreground service UI)SCHEDULE_EXACT_ALARM(Required for precise Periodic Mode tracking)
Removing Unused Permissions (Manifest Merger)
If your app doesn’t need a specific feature (for example, your client doesn’t need motion detection, or you don’t use Periodic Mode), you can forcefully remove the permission using Android’s manifest merger tools:node="remove" directive in your app-level AndroidManifest.xml (android/app/src/main/AndroidManifest.xml).
Tracelet’s native code is fully guarded with checkSelfPermission(). Missing permissions will trigger graceful fallbacks, not crashes.
| Permission | Effect when removed (tools:node="remove") |
|---|---|
ACTIVITY_RECOGNITION | Safe. Falls back to basic accelerometer-only motion detection. The onActivityChange stream won’t fire (no walking/running/driving classification). |
ACCESS_BACKGROUND_LOCATION | Safe. Android 10+ will restrict tracking when the app is completely backgrounded or swiped away. |
SCHEDULE_EXACT_ALARM | Safe. Periodic mode falls back to using battery-friendly, but inexact, WorkManager timers. |
POST_NOTIFICATIONS | Safe. The foreground service persistent notification is hidden on Android 13+. Service still runs. |
REQUEST_IGNORE_BATTERY_OPTIMIZATIONS | Safe. You won’t be able to request battery optimization exemptions from the OS. |
FOREGROUND_SERVICE / FOREGROUND_SERVICE_LOCATION | Safe for geofence-only / periodic-only apps. Removing them disables continuous foreground tracking (start()), but standard geofencing and periodic mode keep working. Required for continuous background tracking. |
ACCESS_FINE_LOCATION / COARSE | UNSAFE. Without any location permission, Tracelet cannot obtain positions. |
Google Play foreground-service policy (effective Oct 28, 2026). Geofencing is
no longer a permitted use case for foreground service location. If your app uses a
foreground service solely for geofencing, you must remove
FOREGROUND_SERVICE_LOCATION (and FOREGROUND_SERVICE) from your merged
manifest. Tracelet’s standard geofence mode uses the native Geofence API and does
not start a foreground service, so geofence-only apps are compliant — just
strip the permissions below and keep foregroundService.enabled = false. See the
Geofencing guide
for details.
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Geofence-only / periodic-only app: no continuous foreground tracking -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" tools:node="remove" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" tools:node="remove" />
</manifest>Example: Removing Motion & Background Permissions
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Client doesn't want Activity/Motion tracking -->
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" tools:node="remove" />
<!-- Client only wants foreground tracking -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" tools:node="remove" />
<!-- Remove Exact Alarms -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" tools:node="remove" />
</manifest>Automatic ProGuard / R8 Rules
Tracelet heavily utilizes background Kotlin services, JNA bindings for the Rust core, and reflective Pigeon channels. If these are obfuscated or shrunk incorrectly, background tracking will silently fail when your app is compiled for release.
You do not need to manually configure ProGuard for Tracelet.
The tracelet_android plugin automatically ships with embedded consumer-rules.pro. When you build your Flutter app in release mode, Android’s R8 shrinker automatically extracts these rules and ensures all necessary classes (such as the HeadlessTaskService, BootReceiver, and the Rust uniffi bindings) survive shrinking.
Scenario 1: The Delivery Driver (Continuous Tracking)
Concepts Explored: Doze Mode, Foreground Services, Wakelocks
The Problem
Your user is a pizza delivery driver. They put the phone in their pocket, turning off the screen. After 15 minutes, Android enters Doze Mode. It shuts down network access, defers background jobs, and severely limits CPU wake-ups to save battery. If your app relies on a simple timer to get GPS, Android will simply refuse to run your timer.
How Tracelet Solves It: Foreground Service
A Foreground Service tells Android: “Hey, I am doing something incredibly important right now, and the user is fully aware of it. Do not kill me.”
android: tl.AndroidConfig(
foregroundService: tl.ForegroundServiceConfig(
enabled: true,
channelName: 'Delivery Tracking',
notificationText: 'Tracking your route to the customer',
notificationOngoing: true, // User cannot swipe it away
showNotificationOnPauseOnly: false, // Always visible — see the section below
actions: ['Pause', 'Complete'], // Adds interactive buttons to the notification
),
)By showing a persistent notification in the status bar, Tracelet elevates your app’s priority to almost the level of the foreground UI. Android will let Tracelet run indefinitely, bypassing Doze Mode restrictions.
Customizing the Notification Appearance
To make the persistent notification match your app’s branding, you can customize the icons and colors:
android: tl.AndroidConfig(
foregroundService: tl.ForegroundServiceConfig(
enabled: true,
notificationTitle: 'Delivery Mode',
notificationText: 'Tracking your route...',
notificationColor: '#0F9D58', // Your brand hex color
notificationSmallIcon: 'ic_tracelet_icon', // The icon name
notificationLargeIcon: 'ic_large_logo',
),
)Important Icon Rules:
- File Location: The image file must be placed in your Android project’s drawable folder (
android/app/src/main/res/drawable/ic_tracelet_icon.png). - Small Icon Constraints: Android requires the
notificationSmallIconto be completely flat, transparent, and white-only. If you use a colored logo, Android will simply render it as a solid gray or white square. - Color Property: The
notificationColorproperty will tint the background of your small icon to match your brand.
Android 13+ Note: You must request the POST_NOTIFICATIONS permission before starting the service, otherwise the notification will be silently suppressed by the OS. See the Flutter SDK: Permissions page for exactly how to request this from your Dart code.
Refreshing the Notification While Tracking (updateNotification())
Need to change the notification after tracking has already started — e.g. update the title from “En route” to “Arriving”, swap the text, or change the action buttons mid-trip? Calling setConfig() with a new ForegroundServiceConfig persists the values, but a notification-only change does not repost the notification that Android is already showing — the new content would only appear after an unrelated service restart or foreground transition.
Since v3.6.8, Tracelet.updateNotification() refreshes the live notification in place, without restarting the tracking pipeline:
// 1. Apply the new notification content.
await Tracelet.setConfig(
const tl.Config(
android: tl.AndroidConfig(
foregroundService: tl.ForegroundServiceConfig(
notificationTitle: 'Arriving',
notificationText: 'Your driver is 2 minutes away',
),
),
),
);
// 2. Repost the active notification with the new content.
await Tracelet.updateNotification();Safe by design. updateNotification() never restarts tracking, and it is a no-op when the foreground service is not currently running (nothing to refresh). On iOS it refreshes the running Live Activity instead (when you opted into one via liveActivityConfig); on web it is a no-op — so the same call is safe on every platform.
Updating the Notification Quietly (notificationOnlyAlertOnce)
There is a catch to the pattern above. Android treats every repost as a fresh alert unless you tell it otherwise, so a notification you refresh once a minute plays your notification sound once a minute, for as long as tracking runs.
Tracelet’s channel is created with vibration and lights disabled, so this is a sound, not a buzz — but on the default notificationPriority it is audible, and users notice it quickly.
Set notificationOnlyAlertOnce so only the first post alerts and every later update lands silently:
await Tracelet.setConfig(
const tl.Config(
android: tl.AndroidConfig(
foregroundService: tl.ForegroundServiceConfig(
notificationOnlyAlertOnce: true,
),
),
),
);This is worth setting for any notification whose content changes while tracking runs — a remaining-stops count, pending uploads, the current geofence, live accuracy — not just for the timer below.
Why not just lower the priority? Dropping notificationPriority to low or min also silences the channel, but it silences the first post too, and Android fixes a channel’s importance when the channel is created. An app already installed with a default-importance channel cannot lower it later without also changing channelId, which orphans the old channel in system settings. notificationOnlyAlertOnce has neither problem and works at any priority.
It defaults to false, which preserves the behaviour of every existing app. Nothing changes until you opt in.
Hiding the Notification While the App Is Open (showNotificationOnPauseOnly)
showNotificationOnPauseOnly: true takes the tracking notification down while the user is actually looking at your app, and puts it back when they leave. It is a genuinely nicer experience — the notification tray stays clean while the app is on screen — but it comes with one hard constraint, and the constraint is not obvious.
Hiding the notification means demoting the service. Android has no concept of a foreground service without a notification, so the only way to hide it is stopForeground(). Tracking continues and the service keeps running, but for as long as the notification is hidden your process holds no foreground service.
That is exactly the state Android kills when the user swipes your app out of recents.
showNotificationOnPauseOnly is ignored while stopOnTerminate: false (#378 ). The two settings ask for incompatible things and the survival promise wins: the notification stays visible.
Why it cannot be made to work
When a task is removed, ActivityManager decides which processes to kill from proc.foregroundServices — and it decides before onTaskRemoved is dispatched to your app’s main thread. So an SDK cannot rescue the situation by posting the notification at removal time; by then the verdict is in. Tracelet had exactly that mitigation, and it did not help.
The result was a race nobody could see. Swipe the app away in the gap between it leaving the screen and the notification coming back, and the process died: no headless task, no events, no logs, none of what stopOnTerminate: false exists to guarantee. Swipe a second later and everything worked perfectly. The gap was measured at 285 ms on a Pixel Fold and 700–1500 ms on an Android 15 device.
What Tracelet does instead
| Your config | Behaviour |
|---|---|
stopOnTerminate: false + showNotificationOnPauseOnly: true | The flag is ignored. The notification stays visible, the service is never demoted, and a swipe from recents can never kill the process. |
stopOnTerminate: true + showNotificationOnPauseOnly: true | Works exactly as documented — hidden while the app is on screen. Nothing is promised past the swipe here, so there is nothing to protect. |
showNotificationOnPauseOnly: false | The notification is always visible. This is the default. |
The override is not silent — that was half of what made the original bug expensive. One line is written to the always-on lifecycle log channel per start(), readable with Tracelet.getLogs() at any log level, including from a killed-state run:
notification: showNotificationOnPauseOnly ignored because stopOnTerminate=false — hiding the
notification demotes the foreground service, and a swipe from recents in that window kills the
process (#378). Set stopOnTerminate=true to hide it while the app is open, or
showNotificationOnPauseOnly=false to stop asking.getForegroundServiceHealth() reports the same thing from the other side. When the notification is legitimately hidden (stopOnTerminate: true), serviceForeground is false and lastForegroundPromotionResult is suppressed — a demotion you asked for, which is a different thing from a promotion the OS refused.
Choosing
Decide which one you actually need:
- Tracking must survive the user swiping the app away → keep
stopOnTerminate: falseand let the notification show. This is the right default for delivery, fleet, workforce and safety apps. - A clean notification tray matters more than surviving the swipe → set
stopOnTerminate: true. Tracking ends when the app is removed from recents, which for many consumer apps is exactly what the user expects anyway.
Note what you gain in the second case is bounded: on Android 14+ a location foreground service must show its notification once your app is off screen regardless, so the hiding only ever applies while the user is in your app.
This is Android-only. iOS has no foreground service, no notification to hide and no task-removal kill of this kind — showNotificationOnPauseOnly lives on AndroidConfig and the iOS SDK does not read it.
Showing an Elapsed Timer in the Notification
For apps built around a bounded session — a delivery run, a shift, a job, a drive — the notification can show a running clock counting up from a moment you choose:
final shiftStartedAt = DateTime.now();
await Tracelet.setConfig(
tl.Config(
android: tl.AndroidConfig(
foregroundService: tl.ForegroundServiceConfig(
notificationTitle: 'Shift in progress',
notificationStartedAt: shiftStartedAt.millisecondsSinceEpoch,
notificationShowTimer: true,
notificationOnlyAlertOnce: true,
),
),
),
);
await Tracelet.updateNotification();Android renders the clock itself. You call this once; the OS ticks the display at second resolution for the life of the notification. Your app never wakes up to redraw it, and reposting the notification for an unrelated reason — new text, a config change — does not restart the count.
You supply notificationStartedAt rather than Tracelet taking it from start(), because the period a user cares about often began before tracking did, or survives a tracking restart. To show “since tracking started”, pass the moment you called start().
Semantics worth knowing.
notificationShowTimer: false(the default) renders exactly today’s notification.notificationShowTimer: truewith nonotificationStartedAtshows no timer and logs why. Substituting “now” would restart the clock on every repost and misreport the session.- A
notificationStartedAtin the future is clamped to the current time per post, leaving the stored value untouched, so a device clock correction heals on the next repost. - The timer only counts up. To stop showing it, set
notificationShowTimer: false— the start instant stays stored but inert.
You can render elapsed time yourself by rewriting notificationText on a timer, and for a static count that is the right tool. The reason to prefer this is cost and resolution: a per-minute rewrite is a full round trip into native plus a repost — roughly 480 of them across an eight-hour shift — and showing ticking seconds that way is not practical at all. The OS chronometer is one call.
The iOS counterpart is the Live Activity timer, which takes the same two fields on liveActivityConfig.
Scenario 2: The Weather App (Periodic Tracking)
Concepts Explored: WorkManager vs. Exact Alarms
The Problem
Your user downloaded your local weather app. You want to wake up in the background once every 4 hours, get their location, and download the local forecast.
You do not want a persistent Foreground Service notification. The user would hate seeing a permanent “Weather tracking” notification in their drawer.
How Tracelet Solves It: Periodic Mode
Instead of a continuous service, Tracelet can use Android’s job schedulers to wake up briefly, get a fix, and go back to sleep.
android: tl.AndroidConfig(
periodicUseForegroundService: false,
periodicUseExactAlarms: true, // Uses AlarmManager instead of WorkManager
)The Two Schedulers
-
WorkManager (
periodicUseExactAlarms: false) The default. It is very battery friendly. However, Android decides when to run it. If you ask it to run every 15 minutes, Android might wait 45 minutes and run it when the user unlocks their phone to check Instagram (called “batching”). It is highly inexact. -
AlarmManager (
periodicUseExactAlarms: true) If you absolutely MUST wake the device up exactly at a specific time, use this. It requires you to declare theSCHEDULE_EXACT_ALARMpermission in yourandroid/app/src/main/AndroidManifest.xml:<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />Note: Google Play strictly reviews this permission. Only use it if your app is an alarm clock, calendar, or absolutely requires exact timing.
Scenario 3: The OEM Aggression
Concepts Explored: Settings Health API
The Problem
You did everything right. You have a Foreground Service. But the user owns a Xiaomi phone. MIUI has a proprietary “Battery Saver” that force-kills even Foreground Services after 5 minutes of screen-off time.
How Tracelet Solves It: Auto-Mitigation and Prompts
Tracelet automatically applies internal mitigations where possible, but sometimes the user physically has to whitelist your app in the OEM’s proprietary settings menu.
final health = await tl.Tracelet.getSettingsHealth();
if (health['isAggressiveOem'] == true) {
// Automatically opens the manufacturer-specific settings screen
// (e.g. Xiaomi Autostart, Huawei App Launch, Samsung Sleeping Apps)
await tl.Tracelet.showPowerManager();
}For a detailed example of how to implement this in your Flutter UI, see the Diagnostic Tools & Power Management page.