DEV Community

Haseeb
Haseeb

Posted on

Architecting a background-controlled sound manager that actually survives Doze mode

It happened during a lecture. I was sitting in the front row, taking notes on my tablet, when my phone started vibrating against the wooden desk. The sound was amplified like a drum. The professor paused, the entire room turned, and I scrambled to find the volume button. It was one of those moments where you wish you could just disappear into the floorboards. I had meant to silence it before entering, but the thought had completely slipped my mind during the rush of finding a seat. That was the exact moment I realized I had to automate this.

Most of us live in a state of constant, low-level anxiety regarding our phone's sound profile. We attend meetings, medical appointments, or religious services where a sudden notification chime is the height of social friction. The current standard approach—manually toggling the ringer—is fundamentally flawed because it relies on human memory, which is notoriously unreliable in high-pressure or transitionary moments. We need a system that treats 'silence' as a context-aware state rather than a manual switch. Existing solutions often fail because they are either too complex for the average user or they get killed by the operating system the moment the screen turns off.

When I started building Muffle, I assumed I could just fire a BroadcastReceiver or use a simple Handler to monitor triggers. I was wrong. Android’s aggressive battery optimization, specifically Doze mode and App Standby, is the enemy of any service attempting to maintain a consistent state. To build something that actually works, I had to move away from standard background execution and embrace a Foreground Service combined with AlarmManager for precise scheduling. The core challenge was ensuring that when a routine triggers—like entering a geofenced area or hitting a specific time—the AudioManager actually processes the request even if the device has been sitting in a pocket for three hours.

I settled on a architecture where the Foreground Service acts as the primary controller, while AlarmManager with setExactAndAllowWhileIdle handles the wake-up calls. This ensures that even if the system tries to put the app into a deep sleep, the OS is forced to wake it up at the precise moment a routine should trigger. Here is a snippet of how I handle the AudioManager state transition within the service:

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

However, changing the ringer mode is only half the battle. If a user has a 'Do Not Disturb' policy enabled, the AudioManager might be restricted from making changes unless the app has the appropriate NotificationPolicyAccess permission. I had to implement a check to prompt the user to grant access to the DND settings during the initial setup. Without this specific permission, the AudioManager calls fail silently, leaving the user wondering why their phone is still ringing during their scheduled meetings. It is a classic case of an API that works perfectly in a debug environment but requires specific, non-obvious user intervention in the wild.

What truly surprised me during the development process was the behavior of GPS-based triggers. I initially thought that using the standard LocationManager would be sufficient for geofencing. I quickly found out that requesting location updates while the phone is in a deep sleep state causes the GPS radio to drain the battery at an unacceptable rate, which in turn causes the Android system to 'punish' the app by revoking its background privileges entirely. I had to pivot to using the GeofencingClient from the Google Play Services library. By shifting the heavy lifting of location monitoring to the system-level Google Play Services process, I could wait for the OS to broadcast a transition event rather than polling for coordinates myself. This is the difference between a battery-draining app and one that respects the device's resources.

Another point of failure was the reboot process. I naively assumed that simply registering my receivers in the AndroidManifest would be enough to handle device restarts. I missed the fact that if a user moves their app to an SD card or if the device is encrypted, the BOOT_COMPLETED intent might not be received as expected. I had to add a persistent storage layer—using Room database—to store the state of every routine. On startup, the service queries the database to see which routines should be currently active based on the current system time and the last known location. This 'state reconstruction' logic is what allows the app to feel like it never stopped running, even after a full system reboot.

If I were to rebuild this today, I would invest much more time into the dependency injection layer using Hilt from day one. I started with a more manual approach to service instantiation, and it became a nightmare when trying to unit test the transition between 'Silent' and 'Normal' modes. Testing sound state changes requires mocking the AudioManager and the NotificationManager, which is significantly easier when you have a clean DI graph. I also underestimated the complexity of time zone changes. If a user travels to a different time zone, their scheduled routines can drift by hours if you rely on system-provided time strings rather than ZonedDateTime objects. I had to refactor all my time calculations to use the java.time API to ensure that a 9:00 AM meeting remains a 9:00 AM meeting regardless of where the user is located.

For anyone looking to build background-heavy applications on Android, the biggest lesson is to stop fighting the OS and start working within its constraints. Don't try to keep a service running 24/7 if you can use WorkManager for periodic tasks or AlarmManager for precise ones. The goal is to be the 'best guest' on the user's device. If your app is responsible for the battery dropping 10% overnight, it doesn't matter how useful your features are; the user will uninstall it. Look into the 'Power Management' documentation for your specific target SDK version and test your app on a real device with battery optimization enabled. It is often the only way to catch the race conditions that occur when the system forces your process into the background.

Building tools that solve these small, daily frictions is incredibly rewarding because you are solving a problem you personally experience. Muffle exists because I was tired of being the person whose phone rang during a lecture. It doesn't rely on complex server-side logic; it is a locally-driven tool that respects user privacy and device battery life. You can see how I approached these challenges in the implementation here: https://play.google.com/store/apps/details?id=com.muffle.app. Keep building, keep testing on real hardware, and don't be afraid to scrap your initial architecture if it doesn't survive a night of Doze mode.

Top comments (0)