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