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
DIALING and never leaves it, because setActive() 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_LEAVE the 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.
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:AndroidCallControlleradds the Telecom call withDIRECTION_OUTGOINGbut never callssetActive()on theCallControlScope. The call therefore stays inDIALINGforever, and AOSP Telecom auto-disconnects a call stuck inDIALINGafter 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:
Eleven consecutive full-length native-path calls, all within 119.4–121.7s. Measured from LiveKit
mediaTrack publishedtotrack_unpublishedthe spread tightens to 120.02s ± 0.15s.Controls on the same server and network, all healthy:
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) callsaddCall(..., CallAttributesCompat.DIRECTION_OUTGOING, ...)at line 117.Inside the
addCallblock (lines ~219-248) the scope is captured and the mute flow is collected, but the call is never transitioned to active:) {
active.control = this
active.settled.complete(Unit)
...
}
Note that
onSetActiveis the inbound callback for a system-initiated resume — it is not a substitute for the app callingsetActive().Grepping the whole module confirms
setActiveis never invoked. The only Telecom transitions used areanswer()(line 129, incoming only) anddisconnect()(line 183).Teardown chain:
startSystemCall→startOutgoingCall→addCall(DIRECTION_OUTGOING)DIALINGand never leaves it, becausesetActive()is never calledonDisconnectonDisconnect→onSystemDisconnect()→LivekitMobilePlugin.kt:237→controller.disconnectActiveCall()CLIENT_REQUEST_LEAVEthe SFU logs at 120sThis 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:909already handlesRoomEvent.Connected, which is the natural trigger.Sketch:
Then invoke it from the
RoomEvent.Connectedhandler for outgoing calls. Incoming calls already transition viaanswer().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
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.