Skip to content

Kotlin Multiplatform

There is no Kotlin Multiplatform media SDK, and the community wrappers aren't production-ready. The recommended structure:

flowchart TB
  subgraph common [commonMain]
    API[Tele API client · Ktor]
    SM[Call / invite state machine]
    CE[[CallEngine interface]]
  end
  subgraph android [androidMain]
    AE[AndroidCallEngine<br/>io.livekit:livekit-android]
  end
  subgraph ios [iOS app target · Swift]
    IE[SwiftCallEngine<br/>client-sdk-swift via SPM]
  end
  SM --> CE
  AE -. implements .-> CE
  IE -. implements, injected into Kotlin .-> CE
  • Shared code handles everything Tele-specific: tokens, preview, join, errors, chat models.
  • Platform code handles media and UI: Compose on Android, SwiftUI on iOS.
  • Don't use cinterop to call the Swift SDK. Its async APIs aren't visible to Kotlin/Native. Implement CallEngine in Swift and inject it.

Shared: join

commonMain/TeleApi.kt
@Serializable data class IceServer(val urls: List<String>, val username: String? = null, val credential: String? = null)
@Serializable data class JoinResponse(
    @SerialName("ws_url") val wsUrl: String,
    val token: String,
    @SerialName("ice_servers") val iceServers: List<IceServer>? = null,
    val identity: String,
    val role: String,
)

interface CallEngine {
    suspend fun connect(wsUrl: String, token: String, iceServers: List<IceServer>?)
    suspend fun setMicrophone(enabled: Boolean)
    suspend fun setCamera(enabled: Boolean)
    suspend fun sendChat(text: String)
    fun disconnect()
}

class TeleApi(private val http: HttpClient, private val baseUrl: String, private val tokenProvider: suspend () -> String) {
    suspend fun joinByInvite(code: String, platform: String): JoinResponse {
        val res = http.post("$baseUrl/v1/invites/${code.encodeURLPathPart()}/join") {
            bearerAuth(tokenProvider())
            header("X-Client-Platform", platform)
        }
        if (!res.status.isSuccess()) throw TeleException(res.body<ErrorEnvelope>().error)
        return res.body()
    }
}

Android engine

androidMain/AndroidCallEngine.kt
class AndroidCallEngine(context: Context) : CallEngine {
    private val room = LiveKit.create(context)

    override suspend fun connect(wsUrl: String, token: String, iceServers: List<IceServer>?) {
        val options = if (iceServers == null) ConnectOptions() else ConnectOptions(
            // rtcConfig MUST be set, otherwise the SDK ignores custom ICE servers.
            rtcConfig = PeerConnection.RTCConfiguration(iceServers.map { s ->
                PeerConnection.IceServer.builder(s.urls)
                    .setUsername(s.username ?: "")
                    .setPassword(s.credential ?: "")
                    .createIceServer()
            })
        )
        room.connect(wsUrl, token, options)
    }
    override suspend fun setMicrophone(enabled: Boolean) { room.localParticipant.setMicrophoneEnabled(enabled) }
    override suspend fun setCamera(enabled: Boolean) { room.localParticipant.setCameraEnabled(enabled) }
    override suspend fun sendChat(text: String) { room.localParticipant.sendText(text, StreamTextOptions(topic = "lk.chat")) }
    override fun disconnect() = room.disconnect()
}

Dependencies: io.livekit:livekit-android:2.29.0. For UI, add io.livekit:livekit-android-compose-components:2.4.2 and pin livekit-android to 2.29.0 explicitly, because the compose artifact pulls in an older version.

Android ignores custom ICE servers without rtcConfig

ConnectOptions(iceServers = …) alone has no effect. Always pass a full rtcConfig as above. Otherwise users behind strict firewalls can't connect.

iOS engine (Swift)

iosApp/SwiftCallEngine.swift
import LiveKit
import Shared // your KMP framework

final class SwiftCallEngine: CallEngine {
    private let room = Room()

    func connect(wsUrl: String, token: String, iceServers: [IceServer]?) async throws {
        let servers = (iceServers ?? []).map {
            LiveKit.IceServer(urls: $0.urls, username: $0.username, credential: $0.credential)
        }
        try await room.connect(url: wsUrl, token: token,
                               connectOptions: ConnectOptions(iceServers: servers))
    }
    func setMicrophone(enabled: Bool) async throws { try await room.localParticipant.setMicrophone(enabled: enabled) }
    func setCamera(enabled: Bool) async throws { try await room.localParticipant.setCamera(enabled: enabled) }
    func sendChat(text: String) async throws { _ = try await room.localParticipant.sendText(text, for: "lk.chat") }
    func disconnect() { Task { await room.disconnect() } }
}

Add client-sdk-swift 2.17.0 with Swift Package Manager; CocoaPods is deprecated. Add NSCameraUsageDescription and NSMicrophoneUsageDescription to Info.plist, plus the audio background mode.

Check the exact SDK signatures

The snippets show the shape of the integration. Confirm method signatures against the SDK version you pin; the native SDKs change minor APIs between releases.

Route your invite URL (https://yourapp.example/call/{code}) in the native entry points: an Android intent filter with autoVerify, and an iOS Universal Links entitlement. Parse the code in shared code. See Scheduling & invites.