Skip to content

Android native calls are disconnected by Telecom after exactly 2 minutes (setActive() is never called) #2137

Description

@Travisivart

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:

  1. User joins a call, startSystemCall → startOutgoingCall → addCall(DIRECTION_OUTGOING)
  2. Call enters DIALING and never leaves it, because setActive() is never called
  3. After ~120s AOSP Telecom times out the stale dialing call and invokes onDisconnect
  4. onDisconnect → onSystemDisconnect() → LivekitMobilePlugin.kt:237 → controller.disconnectActiveCall()
  5. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions