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.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,12 +317,80 @@ 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)",
|
||||||
)
|
)
|
||||||
RoomScope(
|
key(reconnectNonce) {
|
||||||
|
RoomScope(
|
||||||
url = connect.serverUrl,
|
url = connect.serverUrl,
|
||||||
token = connect.token,
|
token = connect.token,
|
||||||
roomOptions = RoomOptions(
|
roomOptions = RoomOptions(
|
||||||
@@ -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 " +
|
||||||
@@ -356,37 +437,84 @@ actual fun CallMediaLayer(
|
|||||||
"RoomScope onConnected after camera off: micEn=${room.localParticipant.isMicrophoneEnabled}",
|
"RoomScope onConnected after camera off: micEn=${room.localParticipant.isMicrophoneEnabled}",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { room ->
|
) { room ->
|
||||||
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 -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CallRoomContent(
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
room = room,
|
CallRoomContent(
|
||||||
session = connect,
|
room = room,
|
||||||
showInCallControls = showInCallControls,
|
session = connect,
|
||||||
micRequestedOn = micRequestedOn,
|
showInCallControls = showInCallControls,
|
||||||
onMicRequestedChange = { micRequestedOn = it },
|
micRequestedOn = micRequestedOn,
|
||||||
modifier = modifier,
|
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 -> {
|
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
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user