DEV Community

Haseeb
Haseeb

Posted on

Architecting a Low-Power GPS Geofencing Engine for Android

It was the middle of a Friday afternoon, and I was sitting in the front row of the prayer hall. The room was deathly quiet, filled with the soft hum of concentration. Then, it happened. A high-pitched, synthetic ringtone pierced the silence. Everyone jumped. I felt my face flush with heat as I frantically scrambled to silence my phone, realizing I had completely forgotten to mute it before entering. It was a small, three-second incident, but the embarrassment felt like it lasted for an hour. That was the moment I knew I had to solve this problem for good.

We have all been there. Whether it is a final exam, a surgical consult, or a board meeting, the friction of manually managing your phone's sound profile is a constant background anxiety. You arrive, you remember to silence, you get busy, and three hours later, you realize you have missed four critical calls because you forgot to unmute. Existing solutions often fall into two camps: over-engineered automation platforms that require a degree in systems engineering to configure, or "smart" apps that drain your battery in three hours because they ping the GPS receiver every thirty seconds. I wanted something that just worked, behaved like a native system service, and didn't turn my phone into a hand warmer.

When I started building Muffle, I knew geofencing was the primary requirement. Users shouldn't have to toggle schedules; the phone should know where it is. My first instinct was to write a background service that checked location coordinates against a database of user-defined zones. I quickly realized this was a recipe for disaster. If you query the LocationManager continuously while the screen is off, the Android framework will eventually throttle your process to save battery, or worse, the user will uninstall your app because their phone dies by lunchtime. The actual solution isn't to poll; it is to let the system handle the heavy lifting via the GeofencingClient API.

Instead of manual polling, I shifted to the GeofencingRequest builder. This API allows the developer to register specific GeofencingRequest objects with the system. The OS then monitors these areas using a combination of cell towers, Wi-Fi, and GPS, and fires an Intent only when a transition—entering or exiting—actually occurs. This offloads the computation to the system process. Here is how I set up the transition triggers:

kotlin
val geofence = Geofence.Builder()
.setRequestId(routine.id)
.setCircularRegion(lat, lon, radius)
.setTransitionTypes(Geofence.GEOFENCE_TRANSITION_ENTER or Geofence.GEOFENCE_TRANSITION_EXIT)
.setExpirationDuration(Geofence.NEVER_EXPIRE)
.build()

geofencingClient.addGeofences(geofencingRequest, pendingIntent)

The key architectural decision here was deciding how to handle the PendingIntent. I moved the logic into a BroadcastReceiver that triggers a JobIntentService. This ensures that even if the app process is killed by the OS, the system can wake up the app, process the sound action (like setting AudioManager.RINGER_MODE_SILENT), and then terminate cleanly. It keeps the footprint minimal while ensuring reliability.

What truly surprised me during development was how inaccurate GPS can be in high-density urban environments. I initially set my geofence radii to 50 meters, assuming that if I was that close, I was definitely inside the building. I was wrong. Between signal bouncing off skyscrapers and the aggressive battery optimizations in newer Android versions, a 50-meter radius resulted in "false exits" where the user would be sitting at their desk, and the phone would suddenly unmute because the system briefly lost the lock. I had to implement a persistence threshold. My logic now checks if the transition has been stable for at least 30 seconds before applying a sound change. This added a slight delay, but it eliminated the jarring "vibrate-on-vibrate-off" loop that plagued my initial beta builds.

I also severely underestimated the impact of Android's "Doze" mode. In older versions of Android, I could rely on my background service to stay alive. In Android 14 and beyond, the system is ruthless. If I had to start over, I would have avoided the AlarmManager for anything related to long-term scheduling. I spent two weeks debugging why my routines weren't firing after a reboot, only to realize I wasn't properly re-registering my geofences in the ACTION_BOOT_COMPLETED receiver. You cannot assume your app is the only thing running; you have to assume the OS will kill you, restart you, and kill you again. Your persistence layer must be bulletproof.

For any developer building location-aware apps, the most important lesson is to respect the user's hardware. Don't build a custom polling engine if the platform provides an event-driven API. It is tempting to write your own "better" loop, but the OS engineers have spent thousands of hours tuning the location provider to balance accuracy and power consumption. Lean into those native APIs, even when they feel restrictive. If you are building a tool that relies on user state, treat the state as ephemeral. Store everything in a local Room database and use it as the source of truth for your state machine. When the phone wakes up from a deep sleep, the first thing your service should do is query the database, not the current device sensor.

Automation is about removing friction, and if your app is the one causing the friction—by draining battery or failing to trigger—the user will lose trust instantly. Muffle is my attempt to solve that by keeping things simple, local, and respectful of system resources. You can see how I structured the service architecture at https://play.google.com/store/apps/details?id=com.muffle.app if you want to dig deeper into the implementation.

Top comments (0)