diff --git a/.cursor/skills/post-complete/ SKILL.md b/.cursor/skills/post-complete/ SKILL.md new file mode 100644 index 0000000..0cc74cf --- /dev/null +++ b/.cursor/skills/post-complete/ SKILL.md @@ -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. \ No newline at end of file diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/calls/CallMediaLayer.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/calls/CallMediaLayer.android.kt index f743a44..2fb5f62 100644 --- a/app/shared/src/androidMain/kotlin/ru/fromchat/calls/CallMediaLayer.android.kt +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/calls/CallMediaLayer.android.kt @@ -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(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)), ) } diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index c92ed54..47030a6 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -51,6 +51,8 @@ Запуск вызова… Вызов… Подключение… + Переподключение… + Переподключение… %1$s Разговор Входящий звонок Принять diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index db8f821..e34eb48 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -62,6 +62,8 @@ Starting call… Calling… Connecting… + Reconnecting… + Reconnecting… %1$s In call Incoming call Accept diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallOverlay.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallOverlay.kt index 2ec2fc9..41fcafa 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallOverlay.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallOverlay.kt @@ -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 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallStore.kt index d2e4a44..6fa5e66 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallStore.kt @@ -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 +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt index 24391a7..d1aea2d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/core/config/Config.kt @@ -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). diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e482fe7..cddcda3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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]