mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Attempt to implement call reconnect
This commit is contained in:
@@ -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.ContextWrapper
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioManager
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
@@ -72,6 +73,7 @@ import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -315,11 +317,79 @@ actual fun CallMediaLayer(
|
||||
}
|
||||
}
|
||||
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(
|
||||
TAG,
|
||||
"RoomScope starting url=${connect.serverUrl} room=${connect.roomName} " +
|
||||
"(mic UI sync waits for CONNECTED; DISCONNECTED means join failed or network)",
|
||||
)
|
||||
key(reconnectNonce) {
|
||||
RoomScope(
|
||||
url = connect.serverUrl,
|
||||
token = connect.token,
|
||||
@@ -328,9 +398,16 @@ actual fun CallMediaLayer(
|
||||
typingNoiseDetection = false,
|
||||
),
|
||||
),
|
||||
audio = micRequestedOn,
|
||||
// Subscribe/play remote audio even when the local mic is muted.
|
||||
audio = true,
|
||||
video = false,
|
||||
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(
|
||||
TAG,
|
||||
"RoomScope onError state=${r.state} msg=${e?.message}",
|
||||
@@ -341,6 +418,10 @@ actual fun CallMediaLayer(
|
||||
Logger.w(TAG, "RoomScope onDisconnected state=${r.state}")
|
||||
},
|
||||
onConnected = { room ->
|
||||
connectionStatusText = null
|
||||
reconnectAttempt = 0
|
||||
reconnectGeneration += 1 // invalidate any pending reconnect
|
||||
|
||||
Logger.d(
|
||||
TAG,
|
||||
"RoomScope onConnected state=${room.state} micReq=$micRequestedOn " +
|
||||
@@ -360,25 +441,43 @@ actual fun CallMediaLayer(
|
||||
LaunchedEffect(room) {
|
||||
room.events.collect { event: RoomEvent ->
|
||||
when (event) {
|
||||
is RoomEvent.Connected ->
|
||||
is RoomEvent.Connected -> {
|
||||
connectionStatusText = null
|
||||
reconnectAttempt = 0
|
||||
reconnectGeneration += 1 // invalidate any pending reconnect
|
||||
Logger.d(TAG, "RoomEvent.Connected")
|
||||
is RoomEvent.Disconnected ->
|
||||
}
|
||||
is RoomEvent.Disconnected -> {
|
||||
val detail = event.error?.message ?: event.reason.toString()
|
||||
Logger.w(
|
||||
TAG,
|
||||
"RoomEvent.Disconnected reason=${event.reason} " +
|
||||
"err=${event.error?.message}",
|
||||
"RoomEvent.Disconnected reason=${event.reason} err=${event.error?.message}",
|
||||
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)
|
||||
is RoomEvent.Reconnecting ->
|
||||
scheduleReconnect(msg.takeIf { it.isNotBlank() })
|
||||
}
|
||||
is RoomEvent.Reconnecting -> {
|
||||
connectionStatusText = statusReconnecting
|
||||
Logger.d(TAG, "RoomEvent.Reconnecting")
|
||||
is RoomEvent.Reconnected ->
|
||||
}
|
||||
is RoomEvent.Reconnected -> {
|
||||
connectionStatusText = null
|
||||
Logger.d(TAG, "RoomEvent.Reconnected")
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CallRoomContent(
|
||||
room = room,
|
||||
session = connect,
|
||||
@@ -387,6 +486,35 @@ actual fun CallMediaLayer(
|
||||
onMicRequestedChange = { micRequestedOn = it },
|
||||
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 -> {
|
||||
@@ -420,10 +548,19 @@ private fun CallRoomContent(
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
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(session.roomName) { System.currentTimeMillis() }
|
||||
|
||||
DisposableEffect(room) {
|
||||
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val previousMode = audioManager.mode
|
||||
runCatching {
|
||||
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
}
|
||||
onDispose {
|
||||
runCatching {
|
||||
audioManager.mode = previousMode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
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) {
|
||||
Logger.d(
|
||||
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)
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -528,6 +654,8 @@ private fun CallRoomContent(
|
||||
showInCallControls = showInCallControls,
|
||||
)
|
||||
}
|
||||
// Note: we intentionally do NOT blur the whole scene here.
|
||||
// Only individual remote preview tiles apply haze when needed.
|
||||
if (showInCallControls) {
|
||||
CallInlineControlBar(
|
||||
room = room,
|
||||
@@ -1048,6 +1176,9 @@ private fun PreviewTile(
|
||||
dragModifier: Modifier = Modifier,
|
||||
) {
|
||||
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(
|
||||
modifier = Modifier
|
||||
.size(104.dp, 138.dp)
|
||||
@@ -1075,6 +1206,8 @@ private fun PreviewTile(
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
// Placeholder blur only for missing/disabled track.
|
||||
.hazeEffect(state = hazeState, style = HazeMaterials.thick())
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.55f)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
<string name="call_status_starting">Запуск вызова…</string>
|
||||
<string name="call_status_calling">Вызов…</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_incoming_subtitle">Входящий звонок</string>
|
||||
<string name="call_accept">Принять</string>
|
||||
|
||||
@@ -62,6 +62,8 @@
|
||||
<string name="call_status_starting">Starting call…</string>
|
||||
<string name="call_status_calling">Calling…</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_incoming_subtitle">Incoming call</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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -53,16 +51,33 @@ fun CallOverlay(modifier: Modifier = Modifier) {
|
||||
is CallUiState.Connecting,
|
||||
-> return
|
||||
is CallUiState.Failed -> {
|
||||
AlertDialog(
|
||||
onDismissRequest = { CallStore.dismissFailed() },
|
||||
title = { Text(stringResource(Res.string.call_failed_title)) },
|
||||
text = { Text(s.message) },
|
||||
confirmButton = {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f)),
|
||||
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() }) {
|
||||
Text(stringResource(Res.string.call_dismiss))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is CallUiState.Incoming -> {
|
||||
val me = ApiClient.user?.id
|
||||
|
||||
@@ -132,7 +132,9 @@ object CallStore {
|
||||
runCatching {
|
||||
withContext(Dispatchers.Default) {
|
||||
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)
|
||||
val label = peerLabel(peerUserId)
|
||||
LiveKitConnectSession(
|
||||
@@ -173,7 +175,7 @@ object CallStore {
|
||||
val display =
|
||||
if (inc.fromUsername.isNotBlank()) inc.fromUsername else label
|
||||
LiveKitConnectSession(
|
||||
serverUrl = Config.liveKitSignalingWsUrl(),
|
||||
serverUrl = Config.liveKitWsUrl(),
|
||||
token = tok.token,
|
||||
peerUserId = inc.fromUserId,
|
||||
peerDisplayName = display,
|
||||
@@ -229,7 +231,41 @@ object CallStore {
|
||||
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() {
|
||||
_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
|
||||
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`).
|
||||
* WebRTC media uses the configured calls port on the server (e.g. HAProxy in front of LiveKit).
|
||||
|
||||
+10
-10
@@ -8,30 +8,30 @@ compose-multiplatform = "1.10.3"
|
||||
#noinspection NewerVersionAvailable
|
||||
constraintlayout = "0.6.1-shaded"
|
||||
coreSplashscreen = "1.2.0"
|
||||
firebaseMessaging = "25.0.1"
|
||||
firebaseMessaging = "25.0.2"
|
||||
googleServices = "4.4.4"
|
||||
haze = "1.7.2"
|
||||
kotlin = "2.3.20"
|
||||
kotlin = "2.3.21"
|
||||
adaptiveAndroid = "1.2.0"
|
||||
biometric = "1.4.0-alpha06"
|
||||
gson = "2.13.2"
|
||||
biometric = "1.4.0-alpha07"
|
||||
gson = "2.14.0"
|
||||
kotlinxIoBytestring = "0.9.0"
|
||||
kotlinxCoroutinesCore = "1.10.2"
|
||||
kotlinxCoroutinesCore = "1.11.0"
|
||||
kotlinxIoCore = "0.9.0"
|
||||
multiplatformCryptoLibsodiumBindings = "0.9.5"
|
||||
multiplatformSettings = "1.3.0"
|
||||
serialization = "2.3.20"
|
||||
serialization = "2.3.21"
|
||||
serialization-json = "1.11.0"
|
||||
material = "1.13.0"
|
||||
activityKtx = "1.13.0"
|
||||
navigationCompose = "2.9.2"
|
||||
datastore = "1.2.1"
|
||||
security-crypto = "1.1.0"
|
||||
ktor = "3.4.2"
|
||||
ktor = "3.4.3"
|
||||
slf4j = "1.7.36"
|
||||
kotlinxDatetime = "0.7.1"
|
||||
kotlinxDatetime = "0.8.0"
|
||||
lifecycleRuntimeKtx = "2.10.0"
|
||||
composeBom = "2026.03.01"
|
||||
composeBom = "2026.05.00"
|
||||
composeMaterialIconsExtended = "1.7.3"
|
||||
composeMaterial3 = "1.10.0-alpha05"
|
||||
composeComponents = "1.10.3"
|
||||
@@ -41,7 +41,7 @@ androidxWork = "2.11.2"
|
||||
cryptography-kotlin = "0.6.0"
|
||||
krypto = "4.0.10"
|
||||
sqldelight = "2.3.2"
|
||||
livekitAndroid = "2.24.1"
|
||||
livekitAndroid = "2.25.2"
|
||||
livekitAndroidComposeComponents = "2.3.0"
|
||||
|
||||
[libraries]
|
||||
|
||||
Reference in New Issue
Block a user