Opening hook
It happened during a quiet afternoon in the mosque. The imam was mid-sentence when a rhythmic, high-pitched notification ping echoed through the prayer hall, followed instantly by the frantic fumbling of a phone screen. My own phone was in my pocket, and for a split second, my heart skipped a beat, wondering if I had remembered to flip that physical silent switch on the side of my device. That specific, public anxiety—the fear of a digital interruption in a space designed for stillness—is what eventually pushed me to build Muffle.
The problem
We live in an era of constant connectivity, but our operating systems are ironically bad at context-awareness. Android offers 'Do Not Disturb' modes, but they are often binary or require manual toggling. If you are in a meeting, you might remember to silence your phone, but you will almost certainly forget to turn the ringer back on afterward, potentially missing urgent calls for the rest of the afternoon. The friction isn't just in the silencing; it is in the mental overhead of tracking your own sound state.
I looked for existing solutions, but most were either bloated, privacy-invasive, or lacked specific triggers like prayer times or granular geofencing. I wanted a system that was truly 'set and forget.' The challenge wasn't just building a GUI for rules; it was creating a background architecture that could reliably fire these rules without the Android OS killing the process to save battery. Dealing with Doze Mode and standby restrictions meant that I couldn't just rely on a simple Handler or a basic Thread. I needed something that understood the lifecycle of an Android device, and that led me down the rabbit hole of power-efficient background execution.
The technical decision / implementation
When I started designing Muffle, I realized that relying on a single Service was a death sentence for battery life and reliability. Android's battery optimizations are aggressive; if your app is a resource hog, it gets killed. I decided to split the workload between a Foreground Service for persistent, time-sensitive tasks and WorkManager for deferred, opportunistic execution. The Foreground Service is necessary because the user expects immediate sound changes when a geofence trigger hits or a prayer time rolls around. You cannot risk a 15-minute delay caused by the system deferring a background job.
However, keeping a Foreground Service running 24/7 is not the right move. I architected the system so the service only remains active when there is a pending, high-precision task—like a specific calendar event start time. For everything else, I offloaded work to WorkManager.
kotlin
val workRequest = OneTimeWorkRequestBuilder()
.setInitialDelay(timeUntilNextRoutine, TimeUnit.MILLISECONDS)
.setConstraints(Constraints.Builder()
.setRequiresBatteryNotLow(true)
.build())
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"MuffleTask",
ExistingWorkPolicy.REPLACE,
workRequest
)
The crucial decision here was using enqueueUniqueWork with an ExistingWorkPolicy.REPLACE flag. This allows me to effectively 'reschedule' the next sound change whenever a user updates their routine. If the user edits a schedule, I cancel the previous pending job and queue the new one. This ensures that even if the app process is killed by the user or the system, WorkManager maintains the task state in its internal database. When the phone reboots, the system automatically triggers my BroadcastReceiver that listens for ACTION_BOOT_COMPLETED, allowing me to re-register the necessary workers. This hybrid approach—using the persistent notification of the Foreground Service only when absolutely necessary and WorkManager for the heavy lifting—kept my battery usage statistics nearly invisible.
What surprised you / what you'd do differently
What truly blindsided me was the inconsistency of the LocationManager and the GeofencingClient across different OEM implementations. I spent weeks debugging why the geofence wouldn't trigger on certain Chinese-manufactured devices, only to discover that their custom battery savers were aggressively stripping permissions from background processes that weren't actively showing a notification. I initially assumed that if I requested the proper foreground permissions, the system would honor them globally. I was wrong.
I eventually had to implement a 'keep-alive' check that logs the status of the LocationManager to a local file. If I detect that the location service has been silently suppressed, I prompt the user to manually white-list the app in their battery settings. If I were starting over today, I would move away from relying on the standard GeofencingClient for everything. I would instead implement a polling mechanism that uses a FusedLocationProviderClient with a much larger radius, which seems to trigger significantly more reliably on power-constrained hardware.
Another lesson was the fragility of AlarmManager with setExactAndAllowWhileIdle. I learned the hard way that using this too frequently results in 'bucketed' alarms, meaning the OS will effectively ignore your requested execution time to batch it with other apps. I had to learn to structure my architecture to be 'batch-friendly,' even when I desperately wanted precision. I realized that as a developer, you aren't just writing code for the OS; you are writing code for a system that is actively trying to outsmart you to preserve its own longevity.
Practical takeaway
If you are building an Android app that needs to run in the background, stop trying to fight the system. Don't look for 'hacks' to keep your process alive. Instead, learn the specific limitations of WorkManager and Foreground Service. Understand that your code will be killed, and your database will be the only source of truth for what needs to happen next. Build your app to be stateless and resilient to crashes; assume that the user's phone will power off or lose focus at the worst possible moment.
When you stop trying to keep your app running forever, you actually end up writing cleaner, more efficient code. You start planning for the 're-trigger' scenario, which makes your app more stable overall. Muffle was born out of a simple need to automate a task that shouldn't require human memory, and by leaning into the native Android lifecycle rather than fighting against it, I managed to create something that stays out of the user's way. You can see how these architectural choices hold up in practice at https://play.google.com/store/apps/details?id=com.muffle.app.
Top comments (0)