It was the third row of the Friday congregational prayer. The hall was silent, filled with the soft murmur of recitation. Then, it happened. A high-pitched, upbeat ringtone cut through the quiet like a knife. Every head turned. The person whose phone it was scrambled to silence it, their face turning bright red in the dim light. I sat there, mortified for them, realizing that this wasn't just a nuisance—it was a recurring social anxiety. We all have those moments where we simply forget to flip the silent switch.
That sinking feeling isn't unique to prayer halls. It happens during job interviews, final exams, and high-stakes client meetings. Most of us rely on our memory to toggle our phone's sound profile, but memory is notoriously fallible under pressure. We arrive at a location or start an event and our focus is entirely on the task at hand, not our ringer. I wanted a solution that didn't just remind me, but took the action entirely off my plate, without requiring me to hand over my location data to a cloud server or manage a complex cloud-based subscription.
The real friction here is trust and reliability. Existing solutions often require an active internet connection to sync schedules or fetch location data, which introduces latency and privacy risks. If the server is down or the Wi-Fi drops, the phone stays loud. I needed a system that functioned as a local state machine. The problem with building this is that Android’s battery optimization is aggressive. It wants to kill background tasks to save energy, and if your task-scheduling logic relies on a remote server ping, you are constantly fighting the Doze mode and App Standby limitations. I needed the logic to live on the device, survive a reboot, and operate with zero external dependencies.
To build this, I settled on a local architecture using Room for persistence and WorkManager for background execution. The core challenge was ensuring that the sound state would persist regardless of the app's lifecycle. Room acts as the single source of truth for my 'Routines.' When a user defines a trigger—whether it’s a time-based event, a geofence, or a calculated prayer time—that configuration is stored locally as a database entity.
kotlin
@entity(tableName = "routines")
data class Routine(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val triggerType: TriggerType,
val soundAction: SoundAction,
val isActive: Boolean,
val priority: Int
)
I chose WorkManager because it is the only reliable way to schedule deferred, periodic tasks that survive process death. I avoid AlarmManager for the primary logic because it is notoriously difficult to manage when multiple triggers overlap. Instead, I use WorkManager with a PeriodicWorkRequest that checks the current state against the Room database every few minutes.
However, the real architectural hurdle was conflict resolution. What happens if a 'Meeting' routine and a 'Prayer' routine overlap? I implemented a priority-based listener. When the worker fires, it queries the database for all currently 'active' routines. It then filters these by a priority integer. The code sorts the list, picks the routine with the highest priority, and executes the AudioManager call. If no routines are active, it reverts to the user's default state. This prevents the phone from oscillating between vibrate and silent modes when multiple rules match the current timestamp or location.
What surprised me most during development was the 'Geofencing' trigger. I initially assumed that the Google Play Services GeofencingClient would be enough. It works fine when the user is moving, but if the phone sits idle for hours, the OS might throttle the location updates. I found out the hard way that relying solely on GPS for indoor spaces is a recipe for failure due to poor signal penetration. My first version failed because the geofence wouldn't trigger until the user walked out into the street.
I had to pivot to a hybrid approach. I kept the geofencing for broad entry/exit detection, but I introduced a local 'sensor check' that verifies the device's connectivity and movement patterns before committing to a mode change. This 'double-check' logic feels redundant, but it prevents the app from firing when the GPS drifts while you're sitting in a stationary chair.
Another assumption that bit me was the power of the 'silent' state. On modern Android versions, Do Not Disturb (DND) is a distinct permission. I assumed AudioManager.setRingerMode(AudioManager.RINGER_MODE_SILENT) would be sufficient. It isn't. If you want to allow specific 'Emergency Bypass' contacts to ring through, you must interact with the NotificationManager and manage 'ZenMode' configurations. This requires requesting ACCESS_NOTIFICATION_POLICY from the user. Managing the transition between standard silent mode and DND mode without wiping the user's custom DND settings was a technical nightmare that required a carefully curated State object in my database to keep track of the user's pre-existing DND configuration.
If I were starting over, I would have spent more time building a robust testing suite for the WorkManager chain. Debugging background tasks is painful because you cannot simply attach a debugger and wait for an hour for the trigger to fire. I spent far too many hours manually changing the system clock on my emulator to see if my WorkManager would trigger the correct sound state. Using WorkManager's TestDriver earlier in the process would have saved me weeks of manual validation.
Building this taught me that the most effective automation is invisible. If a user has to open the app to fix a bug in the automation, the automation has already failed. The goal is to build a 'set and forget' system. This means your data structures must be resilient. If the database schema changes, you need clean migrations, or you risk the app crashing silently in the background, leaving the user with a phone that won't silence itself.
For any developer building local-first Android apps, focus on the state machine early. Define your states—Silent, Vibrate, Normal, DND—and ensure that your logic can calculate the 'correct' state from scratch at any given moment by reading the database. Do not rely on 'event-based' state changes, where you increment or decrement a counter, because you will eventually lose track of the actual state. Always calculate the desired state based on the current context.
I built Muffle to solve that specific frustration of the ringing phone in a quiet place. It is a tool for those who want their phone to be as respectful as they are. You can see how I approached the local storage and background logic in the app here: https://play.google.com/store/apps/details?id=com.muffle.app. It is a journey of turning off the noise, both in terms of sound and in terms of unnecessary server requests.