DEV Community

XNeuronal
XNeuronal

Posted on

Android geofences that never fired: a permission promise that lied, and a cache that outlived the OS

A reminder tied to a place ("when I get home, take the bins out") is the one feature where you cannot poll. Waking the app every few minutes to read the GPS is exactly the battery profile Android punishes, and it is also unnecessary: Play services already runs a region monitor for every app on the device. You hand it a list of circles, it wakes a BroadcastReceiver when the phone crosses one, and your process does not exist in between.

That is the design that shipped: no foreground service, no location loop, an OS geofence per reminder. It was also silent, more than once, for unrelated reasons: no crash, no error, no notification. React Native 0.81 with a hand-written Kotlin module. Every block below is copied from the repository at the commit named above it.

The shape of the thing

The backend owns the reminders and exposes a projection: a list of {neuron_id, lat, lng, radius_m, direction, fallback_text}. A JS service fetches it on boot, on foreground, and whenever a socket frame says the set changed, then pushes the JSON to the native module. The Kotlin side removes the previous set and adds the new one.

frontend/android/app/src/main/java/com/xneuronal/GeofenceRegistrar.kt at ee55c2f:

    editor.apply()

    val client = LocationServices.getGeofencingClient(context)
    val pi = pendingIntent(context)
    client.removeGeofences(pi).addOnCompleteListener {
      if (geofences.isEmpty()) {
        Log.i(TAG, "register: set emptied (0 geofences monitored)")
        onResult(null, null)
        return@addOnCompleteListener
      }
      val request =
          GeofencingRequest.Builder()
              .setInitialTrigger(0)
              .addGeofences(geofences)
              .build()
      try {
        client
            .addGeofences(request, pi)
            .addOnSuccessListener {
              Log.i(TAG, "register: armed ${geofences.size} geofence(s)")
              onResult(null, null)
            }
            .addOnFailureListener { e ->
              Log.w(TAG, "register: addGeofences failed: ${e.message}")
              onResult("ADD_FAILED", e)
            }
Enter fullscreen mode Exit fullscreen mode

Two details matter later. editor.apply() writes the full JSON and each reminder's fallback text to SharedPreferences before the client is touched, so the native side always holds the last intended set even if arming fails. And setInitialTrigger(0) disables the initial-enter trigger: arming a circle while already standing inside it fires nothing. Right for "remind me when I arrive", and a trap later on.

The Log.i lines are not decoration. In a release build, JS console.* never reaches logcat, so the XNGeofence tag was the only trace of the chain on a production phone.

Root cause one: the permission promise resolved before the user answered

Since Android 10, background location is its own permission. A geofence transition delivered while the app is not on screen requires ACCESS_BACKGROUND_LOCATION, and since Android 11 that permission has no runtime dialog at all: the request sends the user to the app's location settings page, where "Allow all the time" lives. So the flow has two steps, foreground then background, and the second only makes sense after the first succeeded.

The first version of the bridge did this.

frontend/android/app/src/main/java/com/xneuronal/GeofenceModule.kt at da86318:

  fun requestAlwaysPermission(promise: Promise) {
    if (!hasFine()) {
      reactApplicationContext.currentActivity?.let { activity ->
        ActivityCompat.requestPermissions(
            activity,
            arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
            REQ_CODE
        )
      }
      promise.resolve("denied")
      return
    }
    if (hasBackground()) {
      promise.resolve("granted")
      return
    }
    promise.resolve("needs_settings")
  }
Enter fullscreen mode Exit fullscreen mode

ActivityCompat.requestPermissions is fire-and-forget; the answer arrives later in onRequestPermissionsResult. This method shows the foreground dialog and, on the very next line, tells JavaScript "denied" while the dialog is still on screen. The UI displayed its "you refused" step, the user tapped Allow in the system dialog behind it, the app got foreground location, and nobody ever asked for the background step, because from the JS side the flow had already failed. The reminder was persisted, geocoded and armed; the phone was simply not allowed to deliver it once the screen was off.

The rewrite goes through React Native's PermissionAwareActivity, whose requestPermissions takes a listener, and resolves the promise from inside it.

frontend/android/app/src/main/java/com/xneuronal/GeofenceModule.kt at ee55c2f:

  fun requestAlwaysPermission(promise: Promise) {
    if (hasBackground()) {
      promise.resolve("granted")
      return
    }
    val activity = reactApplicationContext.currentActivity as? PermissionAwareActivity
    if (activity == null) {
      // No activity to host a dialog (app not foregrounded). If fine is already
      // held the only remaining path is Settings, otherwise we can't ask.
      promise.resolve(if (hasFine()) "needs_settings" else "denied")
      return
    }
    if (!hasFine()) {
      requestFine(activity, promise)
    } else {
      requestBackground(activity, promise)
    }
  }
Enter fullscreen mode Exit fullscreen mode

requestFine asks for foreground location and, on grant, posts requestBackground to the main looper rather than calling it directly: React Native clears the current permission listener after it returns true, so installing the next listener synchronously would get it nulled. requestBackground then issues the request that, on Android 11 and later, opens the settings page.

Same file, same commit:

    activity.requestPermissions(
        arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
        REQ_BG
    ) { requestCode, _, grantResults ->
      if (requestCode != REQ_BG) return@requestPermissions false
      val granted =
          grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED
      promise.resolve(if (granted) "granted" else "needs_settings")
      true
    }
Enter fullscreen mode Exit fullscreen mode

A non-grant resolves needs_settings, not denied, because after repeated refusals the OS stops showing anything at all and the UI still needs a manual way to the settings page. The bug was a native method reporting an outcome it had not waited for, and a JS caller with no way to tell.

Root cause two: the app's cache survived what the OS forgot

With the permission fixed, reminders fired. Then one did not, with every permission granted, the reminder present in the projection, the phone in the circle. The device's location service dump showed no region registered for the app at all, and had shown none since a reboot two days earlier.

The JS sync kept a copy of the last set it had pushed in AsyncStorage, diffed the fresh projection against it, and skipped the native call when nothing had changed.

frontend/src/services/GeofencingService.ts at 67aac64:

  const current = await loadCurrent();
  const { toAdd, toRemove } = diffGeofences(current, next);

  // The owner has location reminders but hasn't granted "Always" yet → ask
  // the UI to run the disclosure + permission flow. A freshly added geofence
  // (toAdd) means the user just created a reminder : force the prompt so we
  // re-ask even if the session guard already fired. Passive boots respect it.
  if (next.length > 0) {
    const perm = await checkPermission();
    if (perm !== 'granted') {
      permissionPrompter?.({ force: Boolean(opts?.force) || toAdd.length > 0 });
    }
  }

  if (toAdd.length === 0 && toRemove.length === 0) return; // nothing changed
Enter fullscreen mode Exit fullscreen mode

Reasonable, and wrong about what it was diffing against. The cache describes what JavaScript last asked for. The OS region set is what Play services currently monitors, and the two diverge on every device reboot and every app update, both of which drop registered geofences while AsyncStorage survives untouched. After a reboot the cache said "all armed", the projection said "same reminders", the diff said "nothing to do", and every later sync agreed. The set stayed empty until a reminder was created or deleted, which could be never.

The fix is the boring one: the diff may no longer skip the native call.

frontend/src/services/GeofencingService.ts at 26b85e6:

  // Nothing to monitor and nothing previously registered → skip the native
  // round-trip (the common case for owners with no location reminders).
  if (next.length === 0 && current.length === 0) return;
Enter fullscreen mode Exit fullscreen mode

Everything else re-registers unconditionally. The native call is a remove-then-add of a handful of circles, idempotent and cheap; the early return was optimising a round-trip that costs nothing, at the price of an invariant it could not observe.

That covers the app being opened. It does not cover the days when it is not, so the persisted set from the first block gained a consumer that needs no JavaScript at all.

frontend/android/app/src/main/AndroidManifest.xml at 7a0a6ba:

      <receiver
        android:name=".GeofenceBootReceiver"
        android:exported="false">
        <intent-filter>
            <action android:name="android.intent.action.BOOT_COMPLETED" />
            <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
        </intent-filter>
      </receiver>
Enter fullscreen mode Exit fullscreen mode

The receiver reads the JSON back from SharedPreferences and calls the same register function. Later, a WorkManager periodic job on a six-hour interval, enqueued with ExistingPeriodicWorkPolicy.KEEP, started doing the same, because Play services also drops regions when location becomes unavailable (airplane mode, location toggled off) and never restores them. A coarse periodic re-arm is the battery-acceptable version of the polling we refused to do: it re-registers a set, it does not read a position.

The third silence: an anchor that was not where the house was

One more class of "never fired", smaller and instructive. "Home" cannot be geocoded; the user dictates an address, and the geocoder, lacking the house number, returns the centroid of the street. On a long street the front door can sit two hundred metres from that point, outside the tight circle an at-the-place reminder gets by default. The geofence was armed, permitted, and centred on the wrong spot.

backend/src/orchestrator/setLocationReminder.ts at 49a00fb:

export function radiusForReminder(
  content: string,
  placeQuery: string,
  explicitRadius?: number
): number {
  let radius =
    typeof explicitRadius === 'number' && explicitRadius >= 100
      ? Math.round(explicitRadius)
      : defaultRadiusForIntent(content);
  if (isColloquialPlace(placeQuery)) {
    radius = Math.max(radius, HOME_MIN_RADIUS_M);
  }
  return radius;
}
Enter fullscreen mode Exit fullscreen mode

HOME_MIN_RADIUS_M is 400. A colloquial place ("home", "the office") gets a floor wide enough to swallow street-level imprecision; named places keep their intent-derived radius, and an explicit distance from the user wins. No app rebuild was needed: the native side reads radius_m from the projection, and the unconditional re-register picked the new value up on the next sync.

What the code does not prove

Battery is asserted by architecture, not measured. The claim is "no location loop, OS geofences, one periodic re-arm every six hours"; nobody has profiled the app against a device with and without active reminders.

The unit tests cover the pure functions: shouldFire (direction, day-of-week window, cooldown), diffGeofences, the haversine distanceMeters, and insideOneShotEnters, the catch-up projection that fires a pending one-shot when the app opens already inside its circle, because setInitialTrigger(0) means a region re-armed after you arrived will never report the arrival. Nothing tests the Kotlin module. The Handler.post between the two permission steps is justified by a comment about listener lifetime, not by a test that would fail without it. The original bug, a promise resolved without awaiting, is exactly what a JS-side test cannot see: the mock resolves whatever you told it to.

The offline path duplicates logic. When the receiver wakes with no React context, it re-implements the temporal window and cooldown checks in Kotlin, mirroring shouldFire. Two implementations of one rule, kept in step by discipline. That notification also carries a hard-coded title in one language, because the path has no access to the JS translation layer.

And the diagnosis of the second bug rested on the location service's dump, read over adb on one device. The fix follows from a documented property of the platform, but the timeline that convinced us came from one phone's logs.

The lesson I kept: a client-side cache of "what I told the OS" records intent, not state. Reboots, updates and service-side purges reset state without touching intent, and an early return keyed on intent alone will sooner or later decline to repair a state it never looked at. If the repair is idempotent and cheap, do not guard it.

The geofences described here ship in the Android app at xneuronal.com.

Top comments (0)