ionic-team / ionic-team/capacitor-geolocation

Android: clearWatch does not cancel the watch coroutine — a cleared watch keeps polling and later fires a timeout into the app

Open
#100 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Swift
Stars
12
Forks
19
PR merge metrics
No merged PRs in 30d

Description

### Summary

On Android, `clearWatch` removes the location listener and releases the `PluginCall`, but it never cancels the coroutine started in `startWatch`. The underlying flow keeps running. If the watch never delivered a location before it was cleared, `emitOrTimeoutBeforeFirstEmission` keeps counting and eventually emits a timeout — which is still delivered to the app's JavaScript callback, long after the watch was cleared.

### Versions

- `@capacitor/geolocation` 8.2.0
- `io.ionic.libs:iongeolocation-android` 2.2.0 (pinned in `android/build.gradle`)
- `@capacitor/core` / `@capacitor/android` 8.4.x

### Mechanism

`GeolocationPlugin.startWatch`:

```kotlin
private fun startWatch(call: PluginCall) {
coroutineScope.launch {
controller.addWatch(activity, locationOptions, watchId).collect { … }
}
watchingCalls[call.callbackId] = call
}
```

`clearWatch` does:

```kotlin
watchingCalls.remove(id)?.release(bridge)
val watchCleared = controller.clearWatch(id)
```

`controller.clearWatch` removes the `LocationHandler` and stops location updates, but nothing cancels the `launch { … collect }` above. The `callbackFlow` in `watchLocationUpdatesFlow` is never closed, so the collector stays suspended forever.

Meanwhile `IONGLOCController.addWatch` wraps the flow in:

```kotlin
.emitOrTimeoutBeforeFirstEmission(timeoutMillis = options.timeout)
```

and `emitOrTimeoutBeforeFirstEmission` runs `withTimeoutOrNull(timeoutMillis) { while (firstValue == null) delay(10) }`. That polling loop is unaffected by `clearWatch`, so for a watch that never produced a first emission it keeps polling on `Dispatchers.Main` for the full timeout, then sends `IONGLOCLocationRetrievalTimeoutException`.

That exception reaches `onLocationError(exception, call)` → `call.sendError(...)` → `reject(...)`. `PluginCall.reject` does not check `isReleased`, and `MessageHandler.sendResponseMessage` posts the message to the WebView regardless. On the JS side the callback for that watch id is still registered, because `clearWatch` never produces a `save: false` response for it.

### Consequences

1. **A timeout error arrives for a watch the app cleared long ago.** An app that restarts its watch on error will treat this as a fresh failure and restart a healthy watch — which, if it also has no fix yet, leaks another pending timeout. It is self-sustaining.
2. **Main-thread cost.** Every cleared-but-unfinished watch keeps a `delay(10)` loop (100 wakeups/second) on `Dispatchers.Main` until its timeout expires. Apps that use a long timeout to avoid the watch being torn down accumulate several of these at once.
3. Leaked collectors and `LocationCallback` references accumulate for the process lifetime.

This is easiest to hit with a long `timeout` on `watchPosition`, which is a reasonable configuration since the timeout tears the watch down when it fires.

### Reproduction

1. `watchPosition({ enableHighAccuracy: true, timeout: 600000, interval: 1000, minimumUpdateInterval: 1000 }, cb)` on Android.
2. Ensure no location is produced (indoors / no fix), so the watch makes no first emission.
3. `clearWatch({ id })` after a few seconds. Do not start another watch, so the observation is unambiguous.
4. Wait out the timeout.

Observed: `cb` is invoked with `OS-PLUG-GLOC-0010` about 10 minutes after the watch was cleared.
Expected: a cleared watch produces no further callbacks, and its pending work is cancelled.

### Field data

Measured over 14 days in a production app: 2 601 such timeout callbacks across 261 Android users, against 20 on iOS over the same period with the same JavaScript. Every one of them is for a watch that had already been cleared.

### Suggested fix

Keep the `Job` returned by `startWatch`'s `launch` alongside the `PluginCall`, and cancel it in `clearWatch`:

```kotlin
private val watchingJobs: MutableMap = mutableMapOf()

private fun startWatch(call: PluginCall) {
watchingJobs[call.callbackId] = coroutineScope.launch { … }
watchingCalls[call.callbackId] = call
}

fun clearWatch(call: PluginCall) {

watchingJobs.remove(id)?.cancel()
watchingCalls.remove(id)?.release(bridge)

}
```

Cancelling the collector also cancels the `channelFlow` in `emitOrTimeoutBeforeFirstEmission`, which stops both the polling loop and the late timeout emission.

Separately, it may be worth having `emitOrTimeoutBeforeFirstEmission` await the first emission instead of polling every 10 ms — the polling cost is paid on the main thread for the whole pre-fix period even when the watch is working normally.

Contributor guide

Open the contributing guide

Research direction

Start with GeolocationPlugin.startWatch and clearWatch, then trace IONGLOCController.addWatch through emitOrTimeoutBeforeFirstEmission. Track the coroutine Job with each watch and cancel it when clearWatch runs; done means clearing a watch stops its pending work and produces no later timeout callback.

Written by the indexing model from the issue text.

Assessment

Tech stack
android, kotlin
Domain
mobile
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
76/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.