It was the middle of a Friday prayer service at the local masjid. Everything was silent, the kind of quiet that makes every floorboard creak sound like a thunderclap. Then, it happened. My phone, tucked deep inside my pocket, decided it was the perfect time to play a ringtone at maximum volume. It wasn't just a simple notification; it was a loud, upbeat track that echoed off the walls. I felt the heat rise to my face instantly. I scrambled to silence it, but the damage was done.
Everyone has had that moment. Whether it is a job interview, a final exam, or a medical appointment, the social cost of a ringing phone is high. We live in an era of constant connectivity, but our tools for managing that connectivity remain frustratingly manual. I realized I was constantly toggling my volume, forgetting to unmute, and then missing important calls for hours afterward. I wanted a way to automate this, but existing solutions felt clunky, invasive, or relied on cloud servers that made me uncomfortable. I needed something that lived entirely on my device and respected my privacy.
The real issue isn't building a rule-based engine; it is ensuring that engine actually runs when it is supposed to. Android has evolved into a platform that is extremely aggressive toward background processes. Manufacturers like Samsung, Xiaomi, and OnePlus have implemented their own proprietary 'battery saving' layers that treat any background service as a threat to performance. If your app isn't actively displaying a UI, the system assumes it is a battery hog and kills it without warning. This is why most automation apps fail—they rely on standard AlarmManager or JobScheduler calls that simply get suppressed by OEM task killers the moment the screen goes dark.
To build Muffle, I had to architect a solution that could survive this environment. The core of my approach was moving away from standard background tasks and implementing a persistent Foreground Service coupled with a sticky BroadcastReceiver that monitors system state changes. I had to ensure the service was tied to a visible notification, which is the only way to signal to the Android system—and the user—that this process is intentional and necessary for the app's functionality. Without that persistent notification, the OS would eventually move my process to the background cache and wipe it out during a memory reclamation cycle.
Here is a simplified look at how I handle the lifecycle of the service to ensure it persists across reboots and deep-sleep states:
kotlin
class MuffleService : LifecycleService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
val notification = createPersistentNotification()
startForeground(NOTIFICATION_ID, notification)
// Register for system broadcasts that survive reboots
val filter = IntentFilter(Intent.ACTION_BOOT_COMPLETED)
registerReceiver(muffleReceiver, filter)
return START_STICKY
}
}
By returning START_STICKY, I am instructing the system to recreate the service as soon as resources become available if it gets killed. But that alone isn't enough. The AudioManager API in Android is restrictive; if you try to change volumes too frequently or from the wrong context, the system will ignore you. I had to implement a custom priority queue for my Routines so that if multiple triggers overlap—like a calendar event and a GPS geofence—the app calculates the correct sound state locally before ever touching the AudioManager methods. I also had to account for 'Do Not Disturb' access, which requires specific runtime permissions that the user must grant manually.
What surprised me most during development was that the biggest enemy wasn't the Android OS itself, but the 'Battery Optimization' whitelist. I spent weeks debugging why the GPS trigger would stop firing on specific devices. I assumed my geofencing logic was leaking memory or failing to register. It turned out that the devices were simply putting the location provider into a 'frozen' state when the screen was off for more than ten minutes. No amount of standard Android API usage could fix that. I had to learn the hard way that you cannot fight the OEM optimizations; you have to work within them by encouraging the user to exclude the app from battery optimizations in the system settings.
I also initially underestimated the complexity of the 'surviving reboots' requirement. I thought ACTION_BOOT_COMPLETED would be enough. However, some devices delay the intent broadcast until the user unlocks the screen for the first time after a restart. This meant that if a phone rebooted at 3:00 AM, my service wouldn't start until 8:00 AM when the user picked up the phone, potentially missing early morning prayer routines. I had to implement a secondary check that queries the current time against the scheduled routines as soon as the service initializes, rather than waiting for the next trigger event to fire. It was a simple realization, but it changed how I handle the state machine entirely.
If I were to start over, I would focus less on trying to be a 'smart' app and more on being a 'predictable' one. I spent too much time trying to make the GPS triggers perfectly accurate, which drains the battery precisely because you are fighting the OS's power-saving features. Instead, I should have leaned harder into the Calendar and Time triggers first, as they are far more reliable and require zero background power usage. The lesson here is that as a developer, you aren't just writing code for the OS; you are navigating the specific, fragmented reality of how different manufacturers choose to implement that OS.
For any developer building automation tools, my advice is to stop trusting the documentation's claim that a specific API 'should' work across all versions. The real-world behavior of Android is dictated by the manufacturer's overlay. Always build your app with the assumption that it will be killed, and ensure your data architecture can recover its state instantly. Whether you are building an automation app or a productivity tool, focus on being as lightweight as possible. If your app is a 'good citizen' that doesn't wake the CPU unnecessarily, the system is less likely to flag it as a battery drainer.
I built Muffle to solve a personal frustration, and it has evolved into a tool that helps others manage their focus during the moments that matter most. You can see how I approached the final build and handled the technical constraints of local-only storage at https://play.google.com/store/apps/details?id=com.muffle.app. It is a work in progress, much like the Android ecosystem itself.
Top comments (1)
The BOOT_COMPLETED delay is the one that costs everyone the most debugging time, and there's a fix worth knowing about: on devices with file-based encryption you can register a direct-boot-aware receiver for ACTION_LOCKED_BOOT_COMPLETED, which fires before the first unlock. Mark the receiver android:directBootAware="true" and a 3am reboot starts your service at 3am instead of waiting for the 8am unlock.
The catch is that until the user unlocks you can only read device-protected storage, so anything the service needs in order to reconstruct its schedule has to live in createDeviceProtectedStorageContext() rather than the default one. That dovetails with the state-machine change you described: if the routine definitions sit in device-protected storage, your "query current time against the schedule on init" check works on the locked-boot path too, and the unlock stops being on the critical path at all.
One other thing worth checking before your next release if you haven't already: since API 34 a foreground service has to declare a foregroundServiceType and hold the matching permission, and a mismatch throws at startForeground() rather than failing quietly. With calendar plus geofence triggers you're probably looking at location, and possibly specialUse, which now wants a written justification at Play review. Easier to write before you need it than during.
Curious where you landed on the whitelist prompt: do you ask for the battery-optimization exclusion during onboarding, or wait until you've actually detected that a kill happened?