CraftBridge.startNetworkMonitoring stores its NetworkCallback in a single field and registers unconditionally:
private var networkCallback: ConnectivityManager.NetworkCallback? = null
@JavascriptInterface
fun startNetworkMonitoring() {
val connectivityManager = activity.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
networkCallback = object : ConnectivityManager.NetworkCallback() { ... }
val request = NetworkRequest.Builder().addCapability(NET_CAPABILITY_INTERNET).build()
connectivityManager.registerNetworkCallback(request, networkCallback!!)
}A second call registers a second callback and overwrites the reference to the first. From then on:
- the first callback is orphaned — still registered with
ConnectivityManager, still waking the app on every network change, and no longer reachable, sounregisterNetworkCallbackcan never be called with it; - every change fires twice, so
_craftNetworkChangeCallbackis invoked once per registered callback and the page sees duplicate events; stopNetworkMonitoringunregisters only the most recent one, so the duplicates continue after the page has stopped monitoring.
It is easy to reach
craft.onNetworkChange(cb) is the kind of call a component makes on mount. Two components that both care about connectivity, or one that remounts, is enough. Nothing in the SDK says start is not idempotent.
This is the same shape as #179 (watchPosition orphaning its predecessor) in a different place, which suggests the pattern rather than the instance is the thing to fix.
Suggested fix
Guard on the field:
if (networkCallback != null) returnor unregister the previous one first. The guard is probably what a caller expects — "start monitoring" twice should mean monitoring, not monitoring twice — but either is better than the current behaviour, and the choice is observable, so it is worth deciding rather than picking here.
Zig reproduces it deliberately
CraftNative.startNetworkWatch registers a new callback each time and overwrites its own reference, exactly as the shim does. Guarding on the Zig path alone would mean a page calling start twice gets one event per change through Zig and two through Kotlin — a divergence that only shows up as a differing event count, which is worse than the bug being consistent. The comment at the seam says so.