DEV Community

Haseeb
Haseeb

Posted on

Architecting a background location trigger that survives Android Doze mode

It happened during a quiet afternoon at the mosque. I was sitting in the back row, and suddenly, the person next to me had their phone blast an upbeat, high-volume notification sound. The immediate tension in the room was palpable, followed by the frantic, embarrassed fumbling to mute the device. I felt for them, mostly because I had been that person countless times. I realized then that my phone wasn't just a communication tool; it was a constant source of potential social friction, and my manual efforts to manage it were failing.

We live in a world of constant connectivity, yet our operating systems still treat "silencing the phone" as a purely manual task. You arrive at a meeting, you forget to silence the phone. You walk into a lecture, you forget. You leave, and you forget to unmute, missing important calls from family or work. The problem isn't that we don't care about etiquette; the problem is that human memory is fallible. I wanted a solution that didn't require me to interact with my UI every time my environment changed. I needed a system that observed context and acted accordingly, without me having to reach for the power button.

When I started building Muffle, I quickly realized that the primary challenge wasn't just detecting location—it was keeping that detection alive in the face of Android’s aggressive power management. Android Doze mode is the nemesis of any background process. If your app isn't a top-tier system utility, the OS will happily throttle your location requests, defer your tasks, and kill your background services to preserve battery life. My initial approach using a standard LocationManager was a total failure. I would set a geofence, walk into a building, and the app would simply do nothing because the system had put the process into a deep sleep state.

I pivoted to the GeofencingClient from Google Play Services, which is designed to handle this exact problem by offloading the monitoring to the system process. However, the limitation of geofences is their dependency on Google Play Services and the inherent latency. To ensure absolute reliability for things like prayer times or sensitive meetings, I had to architect a robust ForegroundService. A foreground service with a persistent notification is the only way to signal to the OS that your process is doing something the user actively cares about. But even then, if your service is just running a loop, you’re going to drain the battery and get flagged by the system.

I implemented a hybrid architecture. I used WorkManager for non-critical, time-based tasks because it is specifically designed to be Doze-friendly by batching jobs and respecting system windows. For the immediate, high-priority state changes, I used a ForegroundService tied to a NotificationChannel with high importance. The code snippet below shows how I handle the state transition within the service context to ensure the AudioManager actually updates the profile without being delayed by the CPU scheduler:

kotlin
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
if (audioManager.isNotificationPolicyAccessGranted) {
audioManager.ringerMode = AudioManager.RINGER_MODE_SILENT
}
} else {
audioManager.ringerMode = AudioManager.RINGER_MODE_SILENT
}

The real secret wasn't just the code; it was the persistence. I had to ensure that the service restarted if the system killed it to reclaim memory, so I set the service return value to START_STICKY. I also had to handle the ACTION_BOOT_COMPLETED broadcast receiver to ensure that whenever a user restarts their phone, my routines don't just disappear into the void. Without that reboot receiver, the app would fail the moment the user updated their phone or ran out of battery.

What truly surprised me during the development process was the behavior of the FusedLocationProviderClient. I initially assumed that setting an INTERVAL_FASTEST to a low number would give me the precision I needed for entering a building. I was wrong. By asking for too much precision, I was essentially forcing the GPS hardware to stay active, which triggered the system’s battery-saver heuristics to shut me down even faster. The more "demanding" my app was, the less "reliable" it became. I had to learn to balance the update intervals with the actual needs of the task.

I also learned that relying solely on GPS is a mistake in urban environments. Signal bounce between buildings creates "ghost" movements where the device thinks it has left a geofence when it hasn't. I ended up adding a logic layer that requires multiple consecutive location pings to confirm a transition state. If I were starting over, I would have integrated Wi-Fi-based location lookups much earlier. GPS is great for open spaces, but it is effectively useless indoors, and most of my users are silencing their phones inside buildings. Building a location-agnostic fallback using Wi-Fi SSID detection would have saved me weeks of debugging "failed" triggers.

Another edge case that caught me off guard was the Do Not Disturb (DND) access permission. On many Chinese-manufactured devices, the implementation of NotificationPolicyAccess is non-standard. I spent three days trying to figure out why my app worked perfectly on a Pixel but crashed on a specific Xiaomi device. It turned out that simply calling isNotificationPolicyAccessGranted wasn't enough; I had to wrap the call in a try-catch block because the system would sometimes throw a SecurityException even when the permission was technically granted. Always assume the manufacturer has customized the OS in ways that violate the standard Android documentation.

If you are building an app that manages system states, the biggest takeaway I can offer is this: respect the system’s resource management, don't fight it. If you try to force your process to stay alive 24/7 at high priority, the OS will eventually tag you as a battery drain and silently restrict your capabilities. Instead, design your architecture to be idempotent. Your logic should be able to run at any time, in any order, and still arrive at the correct sound state. If the OS kills your service and restarts it three minutes later, your app should just check the current state and apply the fix without needing to know the entire history of the previous session.

Automation is about removing the mental load, not adding to it. When building something like Muffle, you aren't just writing code; you are building a layer of trust between the user and their device. If your app misses a single trigger, the user loses trust and stops using it. That is why I focused so heavily on a local-first, privacy-conscious architecture. By keeping all the logic, location data, and routines on the device, I ensure that the app remains functional even when the user is offline or traveling in areas with poor data coverage.

Automation doesn't have to be complex to be effective. Sometimes the simplest way to handle background tasks is to avoid complex polling altogether. If you are interested in how I handled these background triggers while keeping the app entirely offline and privacy-focused, you can see how it performs on your own device: https://play.google.com/store/apps/details?id=com.muffle.app. Focus on the user's friction points, keep your background architecture lightweight, and always account for the weirdness of manufacturer-specific OS implementations. The goal is to make the phone work for the user, not the other way around.

Top comments (0)