Repository navigation
Android native calls are disconnected by Telecom after exactly 2 minutes (setActive() is never called) #2137
Description
Summary
Since Sable Mobile switched to the native Android LiveKit path, every call on Android is torn down at almost exactly 120 seconds. The web/JS path on the same physical device is unaffected.
The cause is in tauri-plugin-livekit-mobile: AndroidCallController adds the Telecom call with DIRECTION_OUTGOING but never calls setActive() on the CallControlScope. The call therefore stays in DIALING forever, and AOSP Telecom auto-disconnects a call stuck in DIALING after a 2-minute timeout.
Evidence
Server-side LiveKit SFU session records for one physical device (Google Pixel 10a, Android 17), same Matrix room, same homeserver, same SFU:
| Date/time (UTC) | LiveKit SDK | Session duration |
|---|---|---|
| 2026-09-23 17:41 | JS 2.19.2 | 4m 37.4s |
| 2026-09-23 18:18 | JS 2.19.2 | 2m 06.3s |
| 2026-09-23 18:45 | JS 2.19.2 | 10m 32.7s |
| 2026-09-23 20:19 | ANDROID 2.27.0 | 2m 00.8s |
| 2026-09-24 22:17 | ANDROID 2.27.0 | 2m 01.1s |
| 2026-09-24 22:20 | ANDROID 2.27.0 | 2m 00.2s |
| 2026-09-24 22:22 | ANDROID 2.27.0 | 2m 01.2s |
| 2026-09-24 22:24 | ANDROID 2.27.0 | 2m 01.4s |
| 2026-09-25 00:32 | ANDROID 2.27.0 | 2m 01.7s |
| 2026-09-25 00:38 | ANDROID 2.27.0 | 2m 01.2s |
| 2026-09-25 00:41 | ANDROID 2.27.0 | 2m 01.6s |
| 2026-09-25 00:43 | ANDROID 2.27.0 | 1m 59.7s |
| 2026-09-25 00:46 | ANDROID 2.27.0 | 1m 59.4s |
| 2026-09-25 00:48 | ANDROID 2.27.0 | 1m 59.5s |
Eleven consecutive full-length native-path calls, all within 119.4–121.7s. Measured from LiveKit mediaTrack published to track_unpublished the spread tightens to 120.02s ± 0.15s.
Controls on the same server and network, all healthy:
| Client | LiveKit SDK | Longest session |
|---|---|---|
| Sable Desktop (Edge/Windows) | JS 2.19.2 | 1h 11m 49s |
| Element X Android (same phone) | JS 2.22.0 | >4m 21s, ended manually |
| Bot using server-sdk-go | GO 2.18.1 | 14m 43s |
The SFU records the close reason as CLIENT_REQUEST_LEAVE — the client deliberately sends a Leave; it is not an eviction, ICE failure, or token expiry.
Ruled out with server-side evidence: network path (identical 120s over WireGuard VPN and over cellular), gateway/proxy timeouts (no 120s value configured; the same gateway carried the 10m32s JS call), LiveKit token TTL (1 hour), MSC4140 delayed-event expiry (restarts succeeding right up to the leave), media health (RTP flowing normally until teardown).
Root cause
In android/src/main/java/app/tauri/livekit_mobile/AndroidCallController.kt:
startOutgoingCall() (line 111) calls addCall(..., CallAttributesCompat.DIRECTION_OUTGOING, ...) at line 117.
Inside the addCall block (lines ~219-248) the scope is captured and the mute flow is collected, but the call is never transitioned to active:
onSetActive = {},
onSetInactive = { onSystemSetInactive() },
) {
active.control = this
active.settled.complete(Unit)
...
}
Note that onSetActive is the inbound callback for a system-initiated resume — it is not a substitute for the app calling setActive().
Grepping the whole module confirms setActive is never invoked. The only Telecom transitions used are answer() (line 129, incoming only) and disconnect() (line 183).
Teardown chain:
- User joins a call,
startSystemCall→startOutgoingCall→addCall(DIRECTION_OUTGOING) - Call enters
DIALINGand never leaves it, becausesetActive()is never called - After ~120s AOSP Telecom times out the stale dialing call and invokes
onDisconnect onDisconnect→onSystemDisconnect()→LivekitMobilePlugin.kt:237→controller.disconnectActiveCall()- The LiveKit room is torn down and sends a Leave — which is exactly the
CLIENT_REQUEST_LEAVEthe SFU logs at 120s
This also explains why only the native path is affected: the JS/WebView path does not go through Telecom at all.
Suggested fix
Call setActive() once media is actually established. NativeCallController.kt:909 already handles RoomEvent.Connected, which is the natural trigger.
Sketch:
// AndroidCallController
fun markCallActive(callId: String, onResult: (Boolean) -> Unit = {}) {
scope.launch {
val control = awaitControl(callId)
if (control == null) { onResult(false); return@launch }
onResult(transact { control.setActive() })
}
}
Then invoke it from the RoomEvent.Connected handler for outgoing calls. Incoming calls already transition via answer().
Worth adding a regression test asserting that an outgoing call reaches ACTIVE, since the failure is invisible for the first two minutes of every call.
Environment
- Sable Mobile on Android, Google Pixel 10a, Android 17
- livekit-android 2.27.0 (signal protocol 13)
- LiveKit server via Matrix RTC (MSC4143 / MSC3401), lk-jwt-service 0.7.0
- Room version 10, no tombstone
Side note
The native path negotiates LiveKit signal protocol 13, while the JS clients on the same server negotiate 17. Unrelated to this bug, but may be worth checking whether the Android SDK pin is behind.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity