It happened during a quiet Friday Jumu'ah prayer. The sermon was nearing its peak intensity, the congregation was silent, and then it happened. A phone in the third row began blaring a high-pitched, upbeat ringtone. The owner fumbled, dropped it, and the sound echoed against the walls for what felt like an eternity. I remember looking at my own phone, which I had thankfully silenced, and realizing that I had spent the entire walk to the mosque worrying about whether I had actually remembered to toggle that switch. It is a universal human experience of anxiety—the fear that your digital life will invade your physical presence at the worst possible moment.
Most of us live our lives in a constant state of manual device management. We toggle silent mode before stepping into a classroom, flip it to vibrate before a performance review, and then, inevitably, we forget to restore the volume. The friction isn't just the noise; it is the cognitive load of constantly remembering to act as a manual gatekeeper for our own devices. Existing solutions often relied on heavy, battery-draining polling mechanisms or required constant internet connectivity to track calendars. When I set out to build Muffle, I wanted something that functioned entirely locally, handled these transitions without constant user intervention, and—most importantly—would not turn the user's phone into a paperweight by 2:00 PM due to battery drain.
To solve this, I leaned heavily into the GeofencingClient API provided by Google Play Services, but I quickly realized that simply setting a boundary was not enough. The challenge with geofencing on Android is the inherent tension between accuracy and power consumption. If you set your LocationRequest granularity to high precision, you are essentially asking the system to keep the GPS radio active, which is a death sentence for your battery. I had to architect a system that balanced these needs using the GeofencingRequest builder, ensuring that transitions were captured reliably while keeping the app in a background state that the Android OS wouldn't immediately kill.
I opted for a PendingIntent approach rather than a persistent background service that polls location. This allows the system to wake up my broadcast receiver only when the specific geofence transition occurs. The crucial part was handling the GEOFENCE_TRANSITION_ENTER and GEOFENCE_TRANSITION_EXIT events. By setting the LoiteringDelay, I prevented the phone from vibrating or silencing if the user is just walking past a building's perimeter. Here is how I set up the initial request:
kotlin
val geofence = Geofence.Builder()
.setRequestId(routineId)
.setCircularRegion(lat, lng, radiusMeters)
.setTransitionTypes(Geofence.GEOFENCE_TRANSITION_ENTER or Geofence.GEOFENCE_TRANSITION_EXIT)
.setLoiteringDelay(30000) // 30 seconds to avoid false triggers
.setExpirationDuration(Geofence.NEVER_EXPIRE)
.build()
Beyond just the geofence, the real architectural decision was how to handle priority. If a user sets a location-based rule to silent, but a calendar event ends while they are still in that location, the system needs to know which state to revert to. I implemented a simple priority stack stored in an internal Room database. Every time a GeofenceTransition hits the receiver, the app queries the database for the active routine with the highest priority, calculates the desired AudioManager state, and updates the device accordingly. By keeping this logic entirely local, I avoided the need for external server calls, which kept the app responsive even in low-reception areas like basements or during travel.
What truly surprised me during development was how aggressive the Android system is toward background tasks, even those using the officially sanctioned APIs. I initially assumed that if I registered a geofence, the system would treat it as a high-priority event. I was wrong. On certain OEM skins like MIUI or Funtouch OS, the default battery optimization settings were killing my broadcast receivers within hours. I spent a week debugging why my triggers worked perfectly on my Pixel but failed consistently on budget-friendly devices. The realization was that I needed to explicitly handle REQUEST_IGNORE_BATTERY_OPTIMIZATIONS permissions and provide clear user instructions on how to 'lock' the app in the background manager, which is a reality of fragmented Android development that documentation often glosses over.
Another point of failure was the interaction between AudioManager and Do Not Disturb (DND) access. I discovered that AudioManager.setRingerMode is sometimes overridden by the system's own DND rules if the user has a 'Focus Mode' or 'Sleep Schedule' active. I had to build a diagnostic check that verifies NotificationManager.isNotificationPolicyAccessGranted before attempting to change modes. If the app doesn't have DND access, it simply cannot override system-level silencing. This meant my UI had to be reactive; it had to prompt the user to grant these sensitive permissions only when they first tried to create a rule, rather than asking for them upfront. That shift from 'asking for permissions at launch' to 'asking for permissions at the point of action' significantly improved my onboarding conversion rate.
If I were to rebuild this today, I would move away from relying solely on GeofencingClient for everything. I would implement a hybrid model using ActivityRecognitionClient to detect if the user is driving or walking. Currently, if a user drives past their office geofence at 60mph, the GEOFENCE_TRANSITION_ENTER event fires accurately, but if they were just sitting in a parked car nearby, it might toggle unnecessarily. Combining location data with activity state provides a much cleaner trigger mechanism, though it does add a layer of complexity to the battery management logic. It is a trade-off between absolute reliability and the complexity of the state machine.
For any developer working on background location or automation, the most important lesson I learned is that the user's perception of 'reliability' is tied to visibility. I initially had the app run completely silently in the background, but users found it unsettling. They didn't know if the app was working or if it had been killed by the system. Adding an optional, non-intrusive activity log that displays exactly when and why a routine was triggered changed the feedback loop. It transformed the app from a 'black box' into a transparent tool. When you are building background-heavy apps, your biggest competitor isn't another app—it is the operating system's battery manager.
Don't try to fight the Android OS; work within its constraints. Use WorkManager for tasks that don't need to be instantaneous, and keep your BroadcastReceiver logic as lean as humanly possible. If you are interested in how I managed to keep the logic for Muffle offline-first while handling complex prayer time calculations and geofencing, you can see how I structured the application at https://play.google.com/store/apps/details?id=com.muffle.app. It is a work in progress, but the core architecture has proven to be quite resilient against the various ways Android tries to optimize away background processes.
Top comments (0)