Attempt to implement call reconnect

This commit is contained in:
2026-05-09 23:30:31 +03:00
Unverified
parent 1c7dc9a8f7
commit 5262c15f48
8 changed files with 304 additions and 57 deletions
+44
View File
@@ -0,0 +1,44 @@
---
name: post-complete
description: Build the Android debug APK, reinstall it on all available connected devices (real first, otherwise emulator), and launch the app. Use after a request is completed or when the user asks to verify changes on devices.
---
# Post-Complete
## When to use
Use this skill after a request was completed and the user wants to verify the result on real devices (if available) and emulators.
## Rules
- Do NOT use subagents in this skill.
- If Mobile MCP install fails because the APK file does not exist, investigate the build outputs directory structure, find the correct APK path, update the skill path, and retry.
- Don't ask questions unless you can't complete the task because my attention is needed. If something fails, try to investigate yourself.
## Instructions
1. In parallel:
- Build a fresh Android debug APK:
- Run: `./gradlew app:android:assembleDebug` from the Android project root.
- Do not try to execute this command multiple times or add additional options like `--no-daemon`.
- List available devices:
- Tool: `mobile_list_available_devices`
- Parameters: `{}` (no params)
2. Select the devices to install the app to:
- If at least one device is a physical Android device, install+launch on those devices.
- Otherwise, install+launch on emulators.
- If working with calls, install+launch on at least 2 devices (pick more devices from the available list).
- Do not ask the user to choose devices unless they explicitly say what device to use.
3. In parallel:
- For each chosen device, run `mobile_install_app` with:
- `device`: string (use `id` from `mobile_list_available_devices`)
- `path`: string (`app/android/build/outputs/apk/debug/android-debug.apk` converted to absolute path)
- `package`: string (`ru.fromchat.beta`)
- For each chosen device, run `mobile_launch_app` with:
- `device`: string (use `id` from `mobile_list_available_devices`)
- `packageName`: string (`ru.fromchat.beta`)
- Use exactly ONE parallel tool execution batch for everything:
- include every `mobile_install_app` call for every chosen device
- include every `mobile_launch_app` call for every chosen device
- Do NOT split into multiple parallel batches and do NOT run installs/launches in separate tool batches.
4. Completion:
- Only after every chosen device has successful results for ALL tool calls the agent should tell the user the task is completed.
- If any device fails any tool call, investigate and try again until they succeed.
@@ -8,6 +8,7 @@ import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.AudioManager
import android.media.projection.MediaProjectionManager import android.media.projection.MediaProjectionManager
import android.os.Build import android.os.Build
import android.os.PowerManager import android.os.PowerManager
@@ -72,6 +73,7 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.key
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -315,11 +317,79 @@ actual fun CallMediaLayer(
} }
} }
connect != null && permissionsGranted -> { connect != null && permissionsGranted -> {
val statusConnecting = stringResource(Res.string.call_status_connecting)
val statusReconnecting = stringResource(Res.string.call_status_reconnecting)
val statusReconnectingWithDetailTemplate =
stringResource(Res.string.call_status_reconnecting_with_detail)
var connectionStatusText by remember(connect.roomName) {
mutableStateOf<String?>(statusConnecting)
}
var reconnectNonce by remember(connect.roomName) { mutableStateOf(0) }
var reconnectAttempt by remember(connect.roomName) { mutableStateOf(0) }
var reconnectGeneration by remember(connect.roomName) { mutableStateOf(0) }
val reconnectScope = rememberCoroutineScope()
val safe = WindowInsets.safeDrawing.asPaddingValues()
val bannerTop = safe.calculateTopPadding() + 12.dp
// Start/stop the foreground call service once per call.
// Reconnect logic re-mounts RoomScope, so putting this here avoids repeated
// startForegroundService() races / crashes.
val ongoingChannelName = stringResource(Res.string.notif_call_channel_name)
val ongoingTitle = stringResource(Res.string.notif_call_ongoing_title)
val ongoingText = stringResource(Res.string.notif_call_ongoing_text)
val callStartWallMs = remember(connect.roomName) { System.currentTimeMillis() }
DisposableEffect(
ongoingChannelName,
connect.peerDisplayName,
ongoingTitle,
ongoingText,
callStartWallMs,
) {
CallForegroundService.start(
context.applicationContext,
ongoingChannelName,
connect.peerDisplayName,
ongoingTitle,
ongoingText,
callStartWallMs,
)
onDispose {
CallForegroundService.stop(context.applicationContext)
}
}
fun simplifyLiveKitErrorDetail(raw: String?): String? {
val t = raw?.trim().orEmpty()
if (t.isBlank()) return null
val m = Regex("\"detail\"\\s*:\\s*\"([^\"]+)\"").find(t)
return m?.groupValues?.getOrNull(1)?.takeIf { it.isNotBlank() } ?: t
}
fun scheduleReconnect(detail: String?) {
reconnectAttempt += 1
val attempt = reconnectAttempt
val simplified = simplifyLiveKitErrorDetail(detail)
connectionStatusText =
simplified?.let {
java.lang.String.format(statusReconnectingWithDetailTemplate, it)
} ?: statusReconnecting
val nextGen = reconnectGeneration + 1
reconnectGeneration = nextGen
reconnectScope.launch {
val delayMs = (attempt * 1000L).coerceAtMost(30_000L)
delay(delayMs)
if (reconnectGeneration != nextGen) return@launch
reconnectNonce += 1
}
}
Logger.d( Logger.d(
TAG, TAG,
"RoomScope starting url=${connect.serverUrl} room=${connect.roomName} " + "RoomScope starting url=${connect.serverUrl} room=${connect.roomName} " +
"(mic UI sync waits for CONNECTED; DISCONNECTED means join failed or network)", "(mic UI sync waits for CONNECTED; DISCONNECTED means join failed or network)",
) )
key(reconnectNonce) {
RoomScope( RoomScope(
url = connect.serverUrl, url = connect.serverUrl,
token = connect.token, token = connect.token,
@@ -328,9 +398,16 @@ actual fun CallMediaLayer(
typingNoiseDetection = false, typingNoiseDetection = false,
), ),
), ),
audio = micRequestedOn, // Subscribe/play remote audio even when the local mic is muted.
audio = true,
video = false, video = false,
onError = { r, e -> onError = { r, e ->
val msg = e?.message?.takeIf { it.isNotBlank() } ?: e?.toString().orEmpty()
val simplified = simplifyLiveKitErrorDetail(msg)
connectionStatusText =
simplified?.let {
java.lang.String.format(statusReconnectingWithDetailTemplate, it)
} ?: statusReconnecting
Logger.e( Logger.e(
TAG, TAG,
"RoomScope onError state=${r.state} msg=${e?.message}", "RoomScope onError state=${r.state} msg=${e?.message}",
@@ -341,6 +418,10 @@ actual fun CallMediaLayer(
Logger.w(TAG, "RoomScope onDisconnected state=${r.state}") Logger.w(TAG, "RoomScope onDisconnected state=${r.state}")
}, },
onConnected = { room -> onConnected = { room ->
connectionStatusText = null
reconnectAttempt = 0
reconnectGeneration += 1 // invalidate any pending reconnect
Logger.d( Logger.d(
TAG, TAG,
"RoomScope onConnected state=${room.state} micReq=$micRequestedOn " + "RoomScope onConnected state=${room.state} micReq=$micRequestedOn " +
@@ -360,25 +441,43 @@ actual fun CallMediaLayer(
LaunchedEffect(room) { LaunchedEffect(room) {
room.events.collect { event: RoomEvent -> room.events.collect { event: RoomEvent ->
when (event) { when (event) {
is RoomEvent.Connected -> is RoomEvent.Connected -> {
connectionStatusText = null
reconnectAttempt = 0
reconnectGeneration += 1 // invalidate any pending reconnect
Logger.d(TAG, "RoomEvent.Connected") Logger.d(TAG, "RoomEvent.Connected")
is RoomEvent.Disconnected -> }
is RoomEvent.Disconnected -> {
val detail = event.error?.message ?: event.reason.toString()
Logger.w( Logger.w(
TAG, TAG,
"RoomEvent.Disconnected reason=${event.reason} " + "RoomEvent.Disconnected reason=${event.reason} err=${event.error?.message}",
"err=${event.error?.message}",
event.error, event.error,
) )
is RoomEvent.FailedToConnect -> scheduleReconnect(detail)
}
is RoomEvent.FailedToConnect -> {
val msg =
event.error?.message
?.takeIf { it.isNotBlank() }
?: event.error?.toString()
.orEmpty()
Logger.e(TAG, "RoomEvent.FailedToConnect", event.error) Logger.e(TAG, "RoomEvent.FailedToConnect", event.error)
is RoomEvent.Reconnecting -> scheduleReconnect(msg.takeIf { it.isNotBlank() })
}
is RoomEvent.Reconnecting -> {
connectionStatusText = statusReconnecting
Logger.d(TAG, "RoomEvent.Reconnecting") Logger.d(TAG, "RoomEvent.Reconnecting")
is RoomEvent.Reconnected -> }
is RoomEvent.Reconnected -> {
connectionStatusText = null
Logger.d(TAG, "RoomEvent.Reconnected") Logger.d(TAG, "RoomEvent.Reconnected")
}
else -> {} else -> {}
} }
} }
} }
Box(modifier = Modifier.fillMaxSize()) {
CallRoomContent( CallRoomContent(
room = room, room = room,
session = connect, session = connect,
@@ -387,6 +486,35 @@ actual fun CallMediaLayer(
onMicRequestedChange = { micRequestedOn = it }, onMicRequestedChange = { micRequestedOn = it },
modifier = modifier, modifier = modifier,
) )
val status = connectionStatusText
if (status != null) {
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(start = 16.dp, end = 16.dp, top = bannerTop, bottom = 12.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.75f))
.padding(horizontal = 12.dp, vertical = 10.dp),
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.size(12.dp))
Text(
text = status,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
)
}
}
}
}
}
} }
} }
connect != null && !permissionsGranted -> { connect != null && !permissionsGranted -> {
@@ -420,10 +548,19 @@ private fun CallRoomContent(
modifier: Modifier, modifier: Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val ongoingChannelName = stringResource(Res.string.notif_call_channel_name)
val ongoingTitle = stringResource(Res.string.notif_call_ongoing_title) DisposableEffect(room) {
val ongoingText = stringResource(Res.string.notif_call_ongoing_text) val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val callStartWallMs = remember(session.roomName) { System.currentTimeMillis() } val previousMode = audioManager.mode
runCatching {
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
}
onDispose {
runCatching {
audioManager.mode = previousMode
}
}
}
DisposableEffect(Unit) { DisposableEffect(Unit) {
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
@@ -439,20 +576,6 @@ private fun CallRoomContent(
} }
} }
DisposableEffect(ongoingChannelName, session.peerDisplayName, ongoingTitle, ongoingText, callStartWallMs) {
CallForegroundService.start(
context.applicationContext,
ongoingChannelName,
session.peerDisplayName,
ongoingTitle,
ongoingText,
callStartWallMs,
)
onDispose {
CallForegroundService.stop(context.applicationContext)
}
}
LaunchedEffect(room, micRequestedOn) { LaunchedEffect(room, micRequestedOn) {
Logger.d( Logger.d(
TAG, TAG,
@@ -511,6 +634,9 @@ private fun CallRoomContent(
} }
} }
val participants = rememberParticipants(room).value
val local = room.localParticipant
val remote: Participant? = participants.firstOrNull { it != local }
val hazeState = rememberHazeState(blurEnabled = showInCallControls) val hazeState = rememberHazeState(blurEnabled = showInCallControls)
Box( Box(
modifier = modifier modifier = modifier
@@ -528,6 +654,8 @@ private fun CallRoomContent(
showInCallControls = showInCallControls, showInCallControls = showInCallControls,
) )
} }
// Note: we intentionally do NOT blur the whole scene here.
// Only individual remote preview tiles apply haze when needed.
if (showInCallControls) { if (showInCallControls) {
CallInlineControlBar( CallInlineControlBar(
room = room, room = room,
@@ -1048,6 +1176,9 @@ private fun PreviewTile(
dragModifier: Modifier = Modifier, dragModifier: Modifier = Modifier,
) { ) {
val tileShape = RoundedCornerShape(16.dp) val tileShape = RoundedCornerShape(16.dp)
// Only blur the *tile content* when the track isn't available yet.
// Do not blur the whole call scene; otherwise the opponent video stays blurred.
val hazeState = rememberHazeState(blurEnabled = ref == null || !showVideo)
Box( Box(
modifier = Modifier modifier = Modifier
.size(104.dp, 138.dp) .size(104.dp, 138.dp)
@@ -1075,6 +1206,8 @@ private fun PreviewTile(
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
// Placeholder blur only for missing/disabled track.
.hazeEffect(state = hazeState, style = HazeMaterials.thick())
.background(MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.55f)), .background(MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.55f)),
) )
} }
@@ -51,6 +51,8 @@
<string name="call_status_starting">Запуск вызова…</string> <string name="call_status_starting">Запуск вызова…</string>
<string name="call_status_calling">Вызов…</string> <string name="call_status_calling">Вызов…</string>
<string name="call_status_connecting">Подключение…</string> <string name="call_status_connecting">Подключение…</string>
<string name="call_status_reconnecting">Переподключение…</string>
<string name="call_status_reconnecting_with_detail">Переподключение… %1$s</string>
<string name="call_status_in_call">Разговор</string> <string name="call_status_in_call">Разговор</string>
<string name="call_incoming_subtitle">Входящий звонок</string> <string name="call_incoming_subtitle">Входящий звонок</string>
<string name="call_accept">Принять</string> <string name="call_accept">Принять</string>
@@ -62,6 +62,8 @@
<string name="call_status_starting">Starting call…</string> <string name="call_status_starting">Starting call…</string>
<string name="call_status_calling">Calling…</string> <string name="call_status_calling">Calling…</string>
<string name="call_status_connecting">Connecting…</string> <string name="call_status_connecting">Connecting…</string>
<string name="call_status_reconnecting">Reconnecting…</string>
<string name="call_status_reconnecting_with_detail">Reconnecting… %1$s</string>
<string name="call_status_in_call">In call</string> <string name="call_status_in_call">In call</string>
<string name="call_incoming_subtitle">Incoming call</string> <string name="call_incoming_subtitle">Incoming call</string>
<string name="call_accept">Accept</string> <string name="call_accept">Accept</string>
@@ -12,9 +12,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -53,16 +51,33 @@ fun CallOverlay(modifier: Modifier = Modifier) {
is CallUiState.Connecting, is CallUiState.Connecting,
-> return -> return
is CallUiState.Failed -> { is CallUiState.Failed -> {
AlertDialog( Box(
onDismissRequest = { CallStore.dismissFailed() }, modifier = modifier
title = { Text(stringResource(Res.string.call_failed_title)) }, .fillMaxSize()
text = { Text(s.message) }, .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f)),
confirmButton = { contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(Res.string.call_failed_title),
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(12.dp))
Text(
text = s.message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(24.dp))
TextButton(onClick = { CallStore.dismissFailed() }) { TextButton(onClick = { CallStore.dismissFailed() }) {
Text(stringResource(Res.string.call_dismiss)) Text(stringResource(Res.string.call_dismiss))
} }
}, }
) }
} }
is CallUiState.Incoming -> { is CallUiState.Incoming -> {
val me = ApiClient.user?.id val me = ApiClient.user?.id
@@ -132,7 +132,9 @@ object CallStore {
runCatching { runCatching {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val tok = ApiClient.fetchLiveKitToken(peerUserId, null) val tok = ApiClient.fetchLiveKitToken(peerUserId, null)
val signalUrl = Config.liveKitSignalingWsUrl() // LiveKit WS endpoint is exposed on the same host as the server config,
// using the configured calls port.
val signalUrl = Config.liveKitWsUrl()
ApiClient.sendLiveKitInvite(peerUserId, tok.roomName, signalUrl) ApiClient.sendLiveKitInvite(peerUserId, tok.roomName, signalUrl)
val label = peerLabel(peerUserId) val label = peerLabel(peerUserId)
LiveKitConnectSession( LiveKitConnectSession(
@@ -173,7 +175,7 @@ object CallStore {
val display = val display =
if (inc.fromUsername.isNotBlank()) inc.fromUsername else label if (inc.fromUsername.isNotBlank()) inc.fromUsername else label
LiveKitConnectSession( LiveKitConnectSession(
serverUrl = Config.liveKitSignalingWsUrl(), serverUrl = Config.liveKitWsUrl(),
token = tok.token, token = tok.token,
peerUserId = inc.fromUserId, peerUserId = inc.fromUserId,
peerDisplayName = display, peerDisplayName = display,
@@ -229,7 +231,41 @@ object CallStore {
if (_ui.value is CallUiState.Failed) clearToIdle() if (_ui.value is CallUiState.Failed) clearToIdle()
} }
/**
* Called when LiveKit room connection fails (WS join / signaling failure).
* Updates UI to [CallUiState.Failed] so the user can see an error dialog.
*/
fun onLiveKitConnectFailed(
session: LiveKitConnectSession,
rawMessage: String?,
) {
scope.launch {
val cur = _ui.value
if (cur is CallUiState.InCall &&
cur.session.roomName == session.roomName &&
cur.session.peerUserId == session.peerUserId
) {
val simplified =
rawMessage
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { simplifyLiveKitErrorDetail(it) }
?: "Failed to connect"
Logger.e(TAG, "LiveKit connect failed: $simplified")
_ui.value = CallUiState.Failed(simplified)
}
}
}
private fun clearToIdle() { private fun clearToIdle() {
_ui.value = CallUiState.Idle _ui.value = CallUiState.Idle
} }
} }
private fun simplifyLiveKitErrorDetail(raw: String): String {
// Server returns JSON like {"detail":"Not Found"}; extract just the detail.
val t = raw.trim()
val m = Regex("\"detail\"\\s*:\\s*\"([^\"]+)\"").find(t)
return m?.groupValues?.getOrNull(1)?.takeIf { it.isNotBlank() } ?: t
}
@@ -39,6 +39,21 @@ object Config {
val callsEnabled: Boolean val callsEnabled: Boolean
get() = config.callsEnabled get() = config.callsEnabled
/** Calls port used for LiveKit WS / reachability (defaults to [ru.fromchat.core.DEFAULT_CALLS_PORT]). */
val callsPort: Int
get() = config.callsPort
/**
* LiveKit WS endpoint derived from the active server config:
* - host = [ServerConfigData.serverIp]
* - port = [ServerConfigData.callsPort]
* - scheme = ws or wss based on [ServerConfigData.httpsEnabled]
*/
fun liveKitWsUrl(): String {
val scheme = if (config.httpsEnabled) "wss" else "ws"
return "$scheme://${config.serverIp}:${config.callsPort}"
}
/** /**
* LiveKit signaling WebSocket URL: same host and API port as HTTPS reverse proxy (`/api/livekit/rtc`). * LiveKit signaling WebSocket URL: same host and API port as HTTPS reverse proxy (`/api/livekit/rtc`).
* WebRTC media uses the configured calls port on the server (e.g. HAProxy in front of LiveKit). * WebRTC media uses the configured calls port on the server (e.g. HAProxy in front of LiveKit).
+10 -10
View File
@@ -8,30 +8,30 @@ compose-multiplatform = "1.10.3"
#noinspection NewerVersionAvailable #noinspection NewerVersionAvailable
constraintlayout = "0.6.1-shaded" constraintlayout = "0.6.1-shaded"
coreSplashscreen = "1.2.0" coreSplashscreen = "1.2.0"
firebaseMessaging = "25.0.1" firebaseMessaging = "25.0.2"
googleServices = "4.4.4" googleServices = "4.4.4"
haze = "1.7.2" haze = "1.7.2"
kotlin = "2.3.20" kotlin = "2.3.21"
adaptiveAndroid = "1.2.0" adaptiveAndroid = "1.2.0"
biometric = "1.4.0-alpha06" biometric = "1.4.0-alpha07"
gson = "2.13.2" gson = "2.14.0"
kotlinxIoBytestring = "0.9.0" kotlinxIoBytestring = "0.9.0"
kotlinxCoroutinesCore = "1.10.2" kotlinxCoroutinesCore = "1.11.0"
kotlinxIoCore = "0.9.0" kotlinxIoCore = "0.9.0"
multiplatformCryptoLibsodiumBindings = "0.9.5" multiplatformCryptoLibsodiumBindings = "0.9.5"
multiplatformSettings = "1.3.0" multiplatformSettings = "1.3.0"
serialization = "2.3.20" serialization = "2.3.21"
serialization-json = "1.11.0" serialization-json = "1.11.0"
material = "1.13.0" material = "1.13.0"
activityKtx = "1.13.0" activityKtx = "1.13.0"
navigationCompose = "2.9.2" navigationCompose = "2.9.2"
datastore = "1.2.1" datastore = "1.2.1"
security-crypto = "1.1.0" security-crypto = "1.1.0"
ktor = "3.4.2" ktor = "3.4.3"
slf4j = "1.7.36" slf4j = "1.7.36"
kotlinxDatetime = "0.7.1" kotlinxDatetime = "0.8.0"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
composeBom = "2026.03.01" composeBom = "2026.05.00"
composeMaterialIconsExtended = "1.7.3" composeMaterialIconsExtended = "1.7.3"
composeMaterial3 = "1.10.0-alpha05" composeMaterial3 = "1.10.0-alpha05"
composeComponents = "1.10.3" composeComponents = "1.10.3"
@@ -41,7 +41,7 @@ androidxWork = "2.11.2"
cryptography-kotlin = "0.6.0" cryptography-kotlin = "0.6.0"
krypto = "4.0.10" krypto = "4.0.10"
sqldelight = "2.3.2" sqldelight = "2.3.2"
livekitAndroid = "2.24.1" livekitAndroid = "2.25.2"
livekitAndroidComposeComponents = "2.3.0" livekitAndroidComposeComponents = "2.3.0"
[libraries] [libraries]