From 05e0e70f2afb2ac5c8a6f4bbc6e5cae3776dd0d4 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 23 Apr 2026 16:49:40 +0300 Subject: [PATCH] Implement basic calls Signed-off-by: denis0001-dev Made-with: Cursor --- .cursor/rules/android-build-deploy-device.mdc | 3 +- app/android/build.gradle.kts | 4 +- app/shared/build.gradle.kts | 4 +- .../src/androidMain/AndroidManifest.xml | 14 + .../fromchat/calls/CallForegroundService.kt | 209 +++ .../fromchat/calls/CallMediaLayer.android.kt | 1159 +++++++++++++++++ .../calls/InlineCallChrome.android.kt | 3 + .../composeResources/values-ru/strings.xml | 18 + .../composeResources/values/strings.xml | 18 + .../kotlin/ru/fromchat/api/ApiClient.kt | 52 + .../kotlin/ru/fromchat/api/Models.kt | 27 + .../ru/fromchat/calls/CallMediaLayer.kt | 13 + .../kotlin/ru/fromchat/calls/CallOverlay.kt | 117 ++ .../kotlin/ru/fromchat/calls/CallStore.kt | 200 +++ .../ru/fromchat/calls/InlineCallChrome.kt | 3 + .../fromchat/calls/LiveKitConnectSession.kt | 9 + .../commonMain/kotlin/ru/fromchat/ui/App.kt | 91 +- .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 8 +- .../kotlin/ru/fromchat/ui/dm/DmPanel.kt | 2 +- .../ui/main/settings/SettingsExpressive.kt | 6 +- .../ru/fromchat/utils/LastSeenFormat.kt | 40 +- .../ru/fromchat/calls/CallMediaLayer.ios.kt | 16 + .../ru/fromchat/calls/InlineCallChrome.ios.kt | 3 + gradle.properties | 9 +- gradle/libs.versions.toml | 6 + gradlew | 20 +- gradlew.bat | 4 +- settings.gradle.kts | 2 + 28 files changed, 2002 insertions(+), 58 deletions(-) create mode 100644 app/shared/src/androidMain/AndroidManifest.xml create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/calls/CallForegroundService.kt create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/calls/CallMediaLayer.android.kt create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/calls/InlineCallChrome.android.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallMediaLayer.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallOverlay.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallStore.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/calls/InlineCallChrome.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/calls/LiveKitConnectSession.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/calls/CallMediaLayer.ios.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/calls/InlineCallChrome.ios.kt diff --git a/.cursor/rules/android-build-deploy-device.mdc b/.cursor/rules/android-build-deploy-device.mdc index b95b490..c8837b9 100644 --- a/.cursor/rules/android-build-deploy-device.mdc +++ b/.cursor/rules/android-build-deploy-device.mdc @@ -7,10 +7,11 @@ alwaysApply: true Whenever you change anything under **`app:android`**, **`app:shared`** (`commonMain` / `androidMain`), or **`utils:shared`** in ways that affect the Android app: -1. **Build** the debug APK: `./gradlew :app:android:assembleDebug` from the Android repo root. If `JAVA_HOME` pointing at Android Studio’s JBR fails, use a working JDK 17+ on the machine. +1. **Build** the debug APK: `./gradlew :app:android:assembleDebug` from the Android repo root. On macOS, use Android Studio’s bundled JBR (e.g. `JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"`, or unset `JAVA_HOME` so `gradlew` defaults to it). If that JBR is missing or fails, use another working JDK 21+ (`GRADLE_JAVA_HOME`). 2. **Read Mobile MCP tool schemas** under the project’s MCP descriptors for `user-Mobile MCP` before calling tools (required). 3. **`mobile_list_available_devices`** — get all `id`s (and `name` / `type` if present). 4. **Use a real device by default.** Do **not** install or launch on an emulator **unless** the user **explicitly** asked to use the emulator, **or** no physical device is connected and **only** emulator(s) are available. Classify devices using `type != "emulator"` when reliable; if `type` is wrong or missing, treat as physical when the **name** does not look like an AVD (e.g. not `sdk_gphone`, not a generic emulator name). When one or more physical devices are available, install and launch **only** on those—**never** also push to emulators in the same step. When **no** physical device is connected, use every returned target (emulators). + - **All connected phones (e.g. two-device FromChat testing):** When the user asks to **always run on every / both connected devices** (or similar), repeat **`mobile_install_app`** and **`mobile_launch_app`** for **each** connected physical device ID returned by **`mobile_list_available_devices`**, not just one. If only one device is online, use that one; do not fail the step. 5. For **each** chosen device `id`: **`mobile_install_app`** with `path` = absolute path to `app/android/build/outputs/apk/debug/android-debug.apk` in this repo. diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts index b9a04cf..dc3d661 100644 --- a/app/android/build.gradle.kts +++ b/app/android/build.gradle.kts @@ -56,12 +56,12 @@ val fixComposeResourcesStructure = tasks.register("fixCompose extensions.configure { namespace = "ru.fromchat" - compileSdk = 36 + compileSdk = 37 defaultConfig { applicationId = "ru.fromchat" minSdk = 24 - targetSdk = 36 + targetSdk = 37 versionCode = 1 versionName = "1.0" } diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index 56d7f1a..c330782 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -89,13 +89,15 @@ kotlin { implementation(libs.androidx.work.runtime.ktx) implementation(libs.tweetnacl.java) implementation(libs.sqldelight.driver.android) + implementation(libs.livekit.android) + implementation(libs.livekit.android.compose.components) } iosMain.dependencies { implementation(libs.jetbrains.kotlinx.io.bytestring) implementation(libs.jetbrains.kotlinx.coroutines.core) implementation(libs.ktor.client.darwin) - implementation("com.ionspin.kotlin:multiplatform-crypto-libsodium-bindings:0.9.5") + implementation(libs.multiplatform.crypto.libsodium.bindings) implementation(libs.sqldelight.driver.native) } } diff --git a/app/shared/src/androidMain/AndroidManifest.xml b/app/shared/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..8de968b --- /dev/null +++ b/app/shared/src/androidMain/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/calls/CallForegroundService.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/calls/CallForegroundService.kt new file mode 100644 index 0000000..15a6a77 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/calls/CallForegroundService.kt @@ -0,0 +1,209 @@ +package ru.fromchat.calls + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.graphics.Color +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.Person +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat + +/** + * Foreground call session: keeps camera / mic eligible in background. + * + * Uses [NotificationCompat.CallStyle] for ongoing calls (API 31+) so the system treats this as an + * active call (priority in shade, better integration with OEM “live” / call surfaces). Samsung + * **Now Brief** is first‑party only; there is no public API for third‑party apps to plug into it. + */ +class CallForegroundService : Service() { + + override fun onBind(intent: Intent?) = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_HANG_UP -> { + CallStore.endCall() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + return START_NOT_STICKY + } + ACTION_START -> startAsForeground(intent!!) + ACTION_STOP -> { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + return START_STICKY + } + + private fun startAsForeground(startIntent: Intent) { + val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + val channelLabel = startIntent.getStringExtra(EXTRA_CHANNEL_NAME) ?: "Ongoing call" + val peerName = startIntent.getStringExtra(EXTRA_PEER_NAME)?.trim().orEmpty().ifBlank { + startIntent.getStringExtra(EXTRA_TITLE).orEmpty() + } + if (peerName.isBlank()) { + stopSelf() + return + } + val statusText = startIntent.getStringExtra(EXTRA_STATUS_TEXT).orEmpty() + val startWallMs = startIntent.getLongExtra(EXTRA_START_WALL_MS, System.currentTimeMillis()) + + ensureActiveCallChannel(nm, channelLabel) + + val smallIcon = try { + packageManager.getApplicationInfo(packageName, 0).icon + } catch (_: Exception) { + android.R.drawable.sym_call_outgoing + } + + val hangUpPi = PendingIntent.getService( + this, + RC_HANG_UP, + Intent(this, CallForegroundService::class.java).apply { action = ACTION_HANG_UP }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val openApp = packageManager.getLaunchIntentForPackage(packageName)?.apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + val contentPi = if (openApp != null) { + PendingIntent.getActivity( + this, + RC_OPEN_APP, + openApp, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } else { + null + } + + val caller = Person.Builder() + .setName(peerName) + .setImportant(true) + .build() + + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(smallIcon) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(Notification.CATEGORY_CALL) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setWhen(startWallMs) + .setShowWhen(false) + .setUsesChronometer(true) + .setChronometerCountDown(false) + .setColorized(true) + .setColor(Color.rgb(0, 107, 94)) + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE) + } + } + if (contentPi != null) { + builder.setContentIntent(contentPi) + } + if (statusText.isNotBlank()) { + builder.setSubText(statusText) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.setStyle( + NotificationCompat.CallStyle.forOngoingCall( + caller, + hangUpPi, + ), + ) + } else { + builder.setContentTitle(peerName) + .setContentText(statusText.ifBlank { channelLabel }) + } + + val notification = builder.build() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Camera + mic only. Do not use MEDIA_PROJECTION here: that type needs + // FOREGROUND_SERVICE_MEDIA_PROJECTION + capture privileges; screen share uses + // Activity-scoped MediaProjection (LiveKit), not this service. + val types = ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA or + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, types) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + private fun ensureActiveCallChannel(nm: NotificationManager, label: String) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + nm.deleteNotificationChannel(LEGACY_CHANNEL_ID) + val existing = nm.getNotificationChannel(CHANNEL_ID) + if (existing != null && existing.importance < NotificationManager.IMPORTANCE_DEFAULT) { + nm.deleteNotificationChannel(CHANNEL_ID) + } + if (nm.getNotificationChannel(CHANNEL_ID) == null) { + val ch = NotificationChannel( + CHANNEL_ID, + label, + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = label + setBypassDnd(false) + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + } + nm.createNotificationChannel(ch) + } + } + + companion object { + private const val CHANNEL_ID = "fromchat_call_active" + private const val LEGACY_CHANNEL_ID = "fromchat_call_ongoing" + private const val NOTIFICATION_ID = 99101 + private const val RC_HANG_UP = 1002 + private const val RC_OPEN_APP = 1003 + + const val EXTRA_CHANNEL_NAME = "extra_channel_name" + const val EXTRA_PEER_NAME = "extra_peer_name" + const val EXTRA_TITLE = "extra_title" + const val EXTRA_STATUS_TEXT = "extra_status_text" + const val EXTRA_START_WALL_MS = "extra_start_wall_ms" + + private const val ACTION_START = "ru.fromchat.calls.CallForegroundService.START" + private const val ACTION_STOP = "ru.fromchat.calls.CallForegroundService.STOP" + private const val ACTION_HANG_UP = "ru.fromchat.calls.CallForegroundService.HANG_UP" + + fun start( + context: Context, + channelName: String, + peerDisplayName: String, + title: String, + statusText: String, + callStartWallMs: Long, + ) { + val app = context.applicationContext + val intent = Intent(app, CallForegroundService::class.java).apply { + action = ACTION_START + putExtra(EXTRA_CHANNEL_NAME, channelName) + putExtra(EXTRA_PEER_NAME, peerDisplayName) + putExtra(EXTRA_TITLE, title) + putExtra(EXTRA_STATUS_TEXT, statusText) + putExtra(EXTRA_START_WALL_MS, callStartWallMs) + } + ContextCompat.startForegroundService(app, intent) + } + + fun stop(context: Context) { + val app = context.applicationContext + app.startService( + Intent(app, CallForegroundService::class.java).apply { action = ACTION_STOP }, + ) + } + } +} 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 new file mode 100644 index 0000000..e48b121 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/calls/CallMediaLayer.android.kt @@ -0,0 +1,1159 @@ +package ru.fromchat.calls + +import android.Manifest +import android.app.Activity +import android.app.Notification +import android.app.NotificationChannel +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 androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CallEnd +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.MicOff +import androidx.compose.material.icons.filled.PresentToAll +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material.icons.filled.VideocamOff +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.zIndex +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import dev.chrisbanes.haze.HazeProgressive +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.materials.HazeMaterials +import dev.chrisbanes.haze.rememberHazeState +import io.livekit.android.compose.local.RoomScope +import io.livekit.android.compose.state.rememberParticipantTrackReferences +import io.livekit.android.compose.state.rememberParticipants +import io.livekit.android.compose.types.TrackReference +import io.livekit.android.compose.ui.ScaleType +import io.livekit.android.compose.ui.VideoTrackView +import io.livekit.android.room.Room +import io.livekit.android.room.participant.LocalParticipant +import io.livekit.android.room.participant.Participant +import io.livekit.android.room.track.Track +import io.livekit.android.room.track.screencapture.ScreenCaptureParams +import kotlin.math.roundToInt +import kotlin.math.sqrt +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.* +import ru.fromchat.api.ApiClient +import ru.fromchat.api.ProfileCache +import ru.fromchat.core.Logger +import ru.fromchat.ui.chat.Avatar + +private const val TAG = "CallMediaLayer" +private const val SCREEN_SHARE_NOTIFICATION_ID = 99102 +private const val SCREEN_SHARE_CHANNEL_ID = "fromchat_call_screenshare" + +private val REQUIRED_PERMISSIONS = arrayOf( + Manifest.permission.RECORD_AUDIO, + Manifest.permission.CAMERA, +) + +private enum class VideoSlot { + RemoteScreen, + RemoteCam, + LocalScreen, + LocalCam, +} + +private tailrec fun findActivity(ctx: Context?): Activity? = when (ctx) { + is Activity -> ctx + is ContextWrapper -> findActivity(ctx.baseContext) + else -> null +} + +@OptIn(ExperimentalHazeMaterialsApi::class) +@Composable +actual fun CallMediaLayer( + connect: LiveKitConnectSession?, + showDialingPlaceholder: Boolean, + showInCallControls: Boolean, + modifier: Modifier, +) { + val context = LocalContext.current + var permissionsGranted by remember { + mutableStateOf( + REQUIRED_PERMISSIONS.all { + ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + }, + ) + } + var askedForPermissions by remember { mutableStateOf(false) } + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ) { result -> + permissionsGranted = result.values.all { it } + } + + LaunchedEffect(connect, showDialingPlaceholder) { + val needMedia = connect != null || showDialingPlaceholder + if (needMedia && !permissionsGranted && !askedForPermissions) { + askedForPermissions = true + launcher.launch(REQUIRED_PERMISSIONS) + } + } + + when { + connect == null && showDialingPlaceholder -> { + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } + } + connect != null && permissionsGranted -> { + RoomScope( + url = connect.serverUrl, + token = connect.token, + audio = true, + video = true, + onConnected = { room -> + launch { + delay(200) + runCatching { + room.localParticipant.setMicrophoneEnabled(true) + room.localParticipant.setCameraEnabled(true) + }.onFailure { Logger.e(TAG, "onConnected media enable failed", it) } + } + }, + ) { room -> + CallRoomContent( + room = room, + session = connect, + showInCallControls = showInCallControls, + modifier = modifier, + ) + } + } + connect != null && !permissionsGranted -> { + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + } + } + else -> { + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + ) + } + } +} + +@OptIn(ExperimentalHazeMaterialsApi::class) +@Composable +private fun CallRoomContent( + room: Room, + session: LiveKitConnectSession, + showInCallControls: Boolean, + 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(Unit) { + val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + val previous = am.mode + am.mode = AudioManager.MODE_IN_COMMUNICATION + onDispose { + am.mode = previous + } + } + + DisposableEffect(ongoingChannelName, session.peerDisplayName, ongoingTitle, ongoingText, callStartWallMs) { + CallForegroundService.start( + context.applicationContext, + ongoingChannelName, + session.peerDisplayName, + ongoingTitle, + ongoingText, + callStartWallMs, + ) + onDispose { + CallForegroundService.stop(context.applicationContext) + } + } + + val hazeState = rememberHazeState(blurEnabled = showInCallControls) + val navBottomDp = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + Box(modifier = modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .hazeSource(hazeState), + ) { + CallParticipantVideos( + room = room, + session = session, + showInCallControls = showInCallControls, + ) + } + if (showInCallControls) { + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(168.dp + navBottomDp) + .zIndex(0.5f) + .hazeEffect(state = hazeState, style = HazeMaterials.thin()) { + progressive = HazeProgressive.verticalGradient( + startIntensity = 0f, + endIntensity = 1f, + ) + }, + ) + CallInlineControlBar( + room = room, + hazeState = hazeState, + modifier = Modifier + .align(Alignment.BottomCenter) + .zIndex(1f) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(bottom = 12.dp), + ) + } + } +} + +@Composable +private fun CallParticipantVideos( + room: Room, + session: LiveKitConnectSession, + showInCallControls: Boolean, +) { + val participants = rememberParticipants(room).value + val local = room.localParticipant + val remote: Participant? = participants.firstOrNull { it != local } + if (remote == null) { + SoloCallParticipantVideos(room, local, session, showInCallControls) + } else { + DuoCallParticipantVideos( + room = room, + remote = remote, + local = local, + session = session, + showInCallControls = showInCallControls, + ) + } +} + +@Composable +private fun SoloCallParticipantVideos( + room: Room, + local: LocalParticipant, + session: LiveKitConnectSession, + showInCallControls: Boolean, +) { + var localCamOn by remember { mutableStateOf(true) } + var localLevel by remember { mutableFloatStateOf(0f) } + LaunchedEffect(local) { + while (isActive) { + localCamOn = local.isCameraEnabled + localLevel = localLevel * 0.82f + local.audioLevel * 0.18f + delay(90) + } + } + val localCamRefs = rememberParticipantTrackReferences( + sources = listOf(Track.Source.CAMERA), + passedParticipant = local, + onlySubscribed = true, + ).value + val lcRef = localCamRefs.firstOrNull() + val self = ApiClient.user + val selfPic = self?.id?.let { ProfileCache.get(it)?.profilePicture } ?: self?.profile_picture + val selfName = self?.displayName?.takeIf { !it.isNullOrBlank() } ?: self?.username.orEmpty() + val peerPic = ProfileCache.get(session.peerUserId)?.profilePicture + Box(Modifier.fillMaxSize()) { + when { + // Never full-screen your own screen share; show camera or placeholders instead. + lcRef != null && localCamOn -> { + VideoTrackView( + trackReference = lcRef, + modifier = Modifier.fillMaxSize(), + room = room, + mirror = true, + scaleType = ScaleType.Fill, + ) + } + lcRef != null && !localCamOn -> { + RemoteVideoOffPlaceholder( + displayName = selfName, + profilePictureUrl = selfPic, + label = selfName, + audioLevel = localLevel, + modifier = Modifier.fillMaxSize(), + ) + } + else -> { + RemoteVideoOffPlaceholder( + displayName = session.peerDisplayName, + profilePictureUrl = peerPic, + label = session.peerDisplayName, + audioLevel = 0f, + modifier = Modifier.fillMaxSize(), + ) + } + } + CallVolumeAvatars( + remoteName = session.peerDisplayName, + remotePic = peerPic, + remoteLevel = 0f, + selfName = selfName, + selfPic = selfPic, + localLevel = localLevel, + modifier = Modifier + .align(Alignment.TopEnd) + .windowInsetsPadding(WindowInsets.statusBars) + .padding(12.dp), + ) + } +} + +@Composable +private fun DuoCallParticipantVideos( + room: Room, + remote: Participant, + local: LocalParticipant, + session: LiveKitConnectSession, + showInCallControls: Boolean, +) { + var remoteCamOn by remember { mutableStateOf(true) } + var localCamOn by remember { mutableStateOf(true) } + var remoteLevel by remember { mutableFloatStateOf(0f) } + var localLevel by remember { mutableFloatStateOf(0f) } + + LaunchedEffect(remote, local) { + while (isActive) { + remoteCamOn = remote.isCameraEnabled + remoteLevel = remoteLevel * 0.82f + remote.audioLevel * 0.18f + localCamOn = local.isCameraEnabled + localLevel = localLevel * 0.82f + local.audioLevel * 0.18f + delay(90) + } + } + + val remoteScreenRefs = rememberParticipantTrackReferences( + sources = listOf(Track.Source.SCREEN_SHARE), + passedParticipant = remote, + onlySubscribed = true, + ).value + val remoteCamRefs = rememberParticipantTrackReferences( + sources = listOf(Track.Source.CAMERA), + passedParticipant = remote, + onlySubscribed = true, + ).value + val localScreenRefs = rememberParticipantTrackReferences( + sources = listOf(Track.Source.SCREEN_SHARE), + passedParticipant = local, + onlySubscribed = true, + ).value + val localCamRefs = rememberParticipantTrackReferences( + sources = listOf(Track.Source.CAMERA), + passedParticipant = local, + onlySubscribed = true, + ).value + + val rsRef = remoteScreenRefs.firstOrNull() + val rcRef = remoteCamRefs.firstOrNull() + val lsRef = localScreenRefs.firstOrNull() + val lcRef = localCamRefs.firstOrNull() + + var userMain by remember { mutableStateOf(null) } + + fun defaultMainSlot(): VideoSlot? = when { + rsRef != null -> VideoSlot.RemoteScreen + rcRef != null && remoteCamOn -> VideoSlot.RemoteCam + lcRef != null && localCamOn -> VideoSlot.LocalCam + rcRef != null -> VideoSlot.RemoteCam + lcRef != null -> VideoSlot.LocalCam + else -> null + } + + val effectiveMain = userMain ?: defaultMainSlot() + + LaunchedEffect(userMain, rsRef, rcRef, lsRef, lcRef, remoteCamOn, localCamOn) { + val u = userMain ?: return@LaunchedEffect + val ok = when (u) { + VideoSlot.RemoteScreen -> rsRef != null + VideoSlot.RemoteCam -> rcRef != null + VideoSlot.LocalScreen -> false + VideoSlot.LocalCam -> lcRef != null + } + if (!ok) userMain = null + } + + fun refFor(slot: VideoSlot): TrackReference? = when (slot) { + VideoSlot.RemoteScreen -> rsRef + VideoSlot.RemoteCam -> rcRef + VideoSlot.LocalScreen -> lsRef + VideoSlot.LocalCam -> lcRef + } + + fun isScreen(slot: VideoSlot): Boolean = + slot == VideoSlot.RemoteScreen || slot == VideoSlot.LocalScreen + + fun camEnabled(slot: VideoSlot): Boolean = when (slot) { + VideoSlot.RemoteCam -> remoteCamOn + VideoSlot.LocalCam -> localCamOn + else -> true + } + + val orderedSlots = listOf( + VideoSlot.RemoteScreen, + VideoSlot.RemoteCam, + VideoSlot.LocalCam, + ).filter { slot -> + val r = refFor(slot) + when { + r == null -> false + isScreen(slot) -> true + else -> true + } + } + + val previewSlots = orderedSlots.filter { it != effectiveMain }.take(3) + + val peerPic = ProfileCache.get(session.peerUserId)?.profilePicture + val self = ApiClient.user + val selfId = self?.id + val selfPic = selfId?.let { ProfileCache.get(it)?.profilePicture } + ?: self?.profile_picture + val selfName = self?.displayName?.takeIf { !it.isNullOrBlank() } ?: self?.username.orEmpty() + + Box(modifier = Modifier.fillMaxSize()) { + val mainSlot = effectiveMain + val mainRef = mainSlot?.let { refFor(it) } + val swapMain: () -> Unit = { + if (previewSlots.isNotEmpty()) userMain = previewSlots.first() + } + Box(modifier = Modifier.fillMaxSize()) { + if (mainSlot != null && mainRef != null && (isScreen(mainSlot) || camEnabled(mainSlot))) { + VideoTrackView( + trackReference = mainRef, + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = swapMain, + ), + room = room, + mirror = mainSlot == VideoSlot.LocalCam || mainSlot == VideoSlot.LocalScreen, + scaleType = ScaleType.Fill, + ) + } else if (mainSlot != null && mainRef != null && !isScreen(mainSlot) && !camEnabled(mainSlot)) { + RemoteVideoOffPlaceholder( + displayName = session.peerDisplayName, + profilePictureUrl = if (mainSlot == VideoSlot.RemoteCam) peerPic else selfPic, + label = if (mainSlot == VideoSlot.RemoteCam) session.peerDisplayName else selfName, + audioLevel = if (mainSlot == VideoSlot.RemoteCam) remoteLevel else localLevel, + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = swapMain, + ), + ) + } else { + RemoteVideoOffPlaceholder( + displayName = session.peerDisplayName, + profilePictureUrl = peerPic, + label = session.peerDisplayName, + audioLevel = remoteLevel, + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = swapMain, + ), + ) + } + } + + CallVolumeAvatars( + remoteName = session.peerDisplayName, + remotePic = peerPic, + remoteLevel = remoteLevel, + selfName = selfName, + selfPic = selfPic, + localLevel = localLevel, + modifier = Modifier + .align(Alignment.TopEnd) + .windowInsetsPadding(WindowInsets.statusBars) + .padding(12.dp), + ) + + CallPreviewCluster( + room = room, + previewSlots = previewSlots, + refFor = ::refFor, + isScreen = ::isScreen, + camEnabled = ::camEnabled, + showInCallControls = showInCallControls, + onSelectMain = { slot -> userMain = slot }, + ) + } +} + +@Composable +private fun RemoteVideoOffPlaceholder( + displayName: String, + profilePictureUrl: String?, + label: String, + audioLevel: Float, + modifier: Modifier, +) { + val t = rememberInfiniteTransition(label = "callGrad").animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(14_000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "t", + ) + val c0 = MaterialTheme.colorScheme.primaryContainer + val c1 = MaterialTheme.colorScheme.tertiaryContainer + val c2 = MaterialTheme.colorScheme.secondaryContainer + val shift = t.value * 400f + val cap = 0.55f + val boost = (audioLevel.coerceIn(0f, cap) / cap).coerceIn(0f, 1f) + val scale = 1f + boost * 0.14f + Box( + modifier = modifier.background( + Brush.linearGradient( + colors = listOf(c0, c1, c2, c0), + start = Offset(shift, 0f), + end = Offset(shift + 900f, 900f), + ), + ), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Avatar( + profilePictureUrl = profilePictureUrl, + displayName = displayName, + modifier = Modifier + .size(120.dp) + .clip(CircleShape) + .graphicsLayer { + scaleX = scale + scaleY = scale + }, + ) + Spacer(Modifier.size(16.dp)) + Text( + text = label, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onPrimaryContainer, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 24.dp), + ) + } + } +} + +@Composable +private fun CallVolumeAvatars( + remoteName: String, + remotePic: String?, + remoteLevel: Float, + selfName: String, + selfPic: String?, + localLevel: Float, + modifier: Modifier, +) { + val cap = 0.55f + fun scaleFor(level: Float): Float { + val boost = (level.coerceIn(0f, cap) / cap).coerceIn(0f, 1f) + return 1f + boost * 0.12f + } + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(40.dp) + .graphicsLayer { + scaleX = scaleFor(remoteLevel) + scaleY = scaleFor(remoteLevel) + }, + contentAlignment = Alignment.Center, + ) { + Avatar( + profilePictureUrl = remotePic, + displayName = remoteName, + modifier = Modifier + .size(36.dp) + .clip(CircleShape), + ) + } + Box( + modifier = Modifier + .size(40.dp) + .graphicsLayer { + scaleX = scaleFor(localLevel) + scaleY = scaleFor(localLevel) + }, + contentAlignment = Alignment.Center, + ) { + Avatar( + profilePictureUrl = selfPic, + displayName = selfName, + modifier = Modifier + .size(36.dp) + .clip(CircleShape), + ) + } + } +} + +@Composable +private fun CallPreviewCluster( + room: Room, + previewSlots: List, + refFor: (VideoSlot) -> TrackReference?, + isScreen: (VideoSlot) -> Boolean, + camEnabled: (VideoSlot) -> Boolean, + showInCallControls: Boolean, + onSelectMain: (VideoSlot) -> Unit, +) { + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val scope = rememberCoroutineScope() + val safe = WindowInsets.safeDrawing.asPaddingValues() + val topPx = with(density) { safe.calculateTopPadding().toPx() } + val bottomPx = with(density) { safe.calculateBottomPadding().toPx() } + val startPx = with(density) { safe.calculateLeftPadding(layoutDirection).toPx() } + val endPx = with(density) { safe.calculateRightPadding(layoutDirection).toPx() } + + val gap = 8.dp + val controlsReserve = if (showInCallControls) with(density) { 108.dp.toPx() } else with(density) { 16.dp.toPx() } + val handleH = 10.dp + val handlePx = with(density) { handleH.toPx() } + + val posX = remember { Animatable(0f) } + val posY = remember { Animatable(0f) } + var layoutReady by remember { mutableStateOf(false) } + var stripHorizontal by remember { mutableStateOf(false) } + + BoxWithConstraints(Modifier.fillMaxSize()) { + val marginPx = with(density) { 12.dp.toPx() } + val pipWpx = with(density) { 104.dp.toPx() } + val pipHpx = with(density) { 138.dp.toPx() } + val gapPx = with(density) { gap.toPx() } + val n = previewSlots.size.coerceAtLeast(1) + val clusterHCol = n * pipHpx + (n - 1).coerceAtLeast(0) * gapPx + handlePx + with(density) { 4.dp.toPx() } + val clusterWRow = n * pipWpx + (n - 1).coerceAtLeast(0) * gapPx + + val maxXPxCol = with(density) { maxWidth.toPx() } - endPx - pipWpx + val maxYPxCol = with(density) { maxHeight.toPx() } - bottomPx - controlsReserve - clusterHCol + val maxXPxRow = with(density) { maxWidth.toPx() } - endPx - clusterWRow + val topRowY = topPx + marginPx + + LaunchedEffect(maxWidth, maxHeight, bottomPx, controlsReserve, endPx, topPx) { + if (!layoutReady) { + posX.snapTo(maxXPxCol.coerceAtLeast(marginPx + startPx)) + posY.snapTo((marginPx + topPx).coerceAtMost(maxYPxCol)) + layoutReady = true + } + } + + fun clampCol(o: Offset): Offset = Offset( + x = o.x.coerceIn(marginPx + startPx, maxXPxCol), + y = o.y.coerceIn(marginPx + topPx, maxYPxCol), + ) + + fun clampRow(o: Offset): Offset = Offset( + x = o.x.coerceIn(marginPx + startPx, maxXPxRow), + y = o.y.coerceIn(topRowY, topRowY), + ) + + fun nearestCornerCol(o: Offset): Offset { + val xs = listOf(marginPx + startPx, maxXPxCol) + val ys = listOf(marginPx + topPx, maxYPxCol) + var best = Offset(xs.first(), ys.first()) + var bestD = Float.MAX_VALUE + for (x in xs) { + for (y in ys) { + val dx = (o.x - x).toDouble() + val dy = (o.y - y).toDouble() + val d = sqrt(dx * dx + dy * dy).toFloat() + if (d < bestD) { + bestD = d + best = Offset(x, y) + } + } + } + return best + } + + val springSnap = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ) + + if (previewSlots.isEmpty()) return@BoxWithConstraints + + fun moveCluster(amount: Offset) { + val raw = Offset(posX.value + amount.x, posY.value + amount.y) + val next = if (stripHorizontal) clampRow(raw) else clampCol(raw) + val wantStrip = !stripHorizontal && raw.y < topPx + with(density) { 88.dp.toPx() } + if (wantStrip && previewSlots.isNotEmpty()) { + stripHorizontal = true + scope.launch { + posX.snapTo(clampRow(Offset(posX.value, topRowY)).x) + posY.snapTo(topRowY) + } + return + } + scope.launch { + posX.snapTo(next.x) + posY.snapTo(next.y) + } + } + + val dragModifier = Modifier.pointerInput(layoutReady, stripHorizontal, maxXPxCol, maxYPxCol, maxXPxRow, topRowY, marginPx, startPx, topPx) { + if (!layoutReady) return@pointerInput + detectDragGestures( + onDrag = { change, amount -> + change.consume() + moveCluster(amount) + }, + onDragEnd = { + scope.launch { + val t = if (stripHorizontal) { + Offset( + posX.value.coerceIn(marginPx + startPx, maxXPxRow), + topRowY, + ) + } else { + nearestCornerCol(Offset(posX.value, posY.value)) + } + coroutineScope { + awaitAll( + async { posX.animateTo(t.x, springSnap) }, + async { posY.animateTo(t.y, springSnap) }, + ) + } + } + }, + onDragCancel = { + scope.launch { + val t = if (stripHorizontal) { + Offset(posX.value.coerceIn(marginPx + startPx, maxXPxRow), topRowY) + } else { + nearestCornerCol(Offset(posX.value, posY.value)) + } + coroutineScope { + awaitAll( + async { posX.animateTo(t.x, springSnap) }, + async { posY.animateTo(t.y, springSnap) }, + ) + } + } + }, + ) + } + + Box( + modifier = Modifier + .offset { IntOffset(posX.value.roundToInt(), posY.value.roundToInt()) }, + ) { + if (!stripHorizontal) { + Column(verticalArrangement = Arrangement.spacedBy(gap)) { + for (slot in previewSlots) { + PreviewTile( + room = room, + ref = refFor(slot), + mirror = slot == VideoSlot.LocalCam || slot == VideoSlot.LocalScreen, + showVideo = refFor(slot) != null && (isScreen(slot) || camEnabled(slot)), + onTap = { onSelectMain(slot) }, + ) + } + Spacer(Modifier.size(4.dp)) + Box( + modifier = Modifier + .size(104.dp, handleH) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f)) + .then(dragModifier), + ) + } + } else { + Row( + horizontalArrangement = Arrangement.spacedBy(gap), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(10.dp, 138.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f)) + .then(dragModifier), + ) + for (slot in previewSlots) { + PreviewTile( + room = room, + ref = refFor(slot), + mirror = slot == VideoSlot.LocalCam || slot == VideoSlot.LocalScreen, + showVideo = refFor(slot) != null && (isScreen(slot) || camEnabled(slot)), + onTap = { onSelectMain(slot) }, + ) + } + } + } + } + } +} + +@Composable +private fun PreviewTile( + room: Room, + ref: TrackReference?, + mirror: Boolean, + showVideo: Boolean, + onTap: () -> Unit, +) { + Box( + modifier = Modifier + .size(104.dp, 138.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onTap, + ), + ) { + if (ref != null && showVideo) { + VideoTrackView( + trackReference = ref, + modifier = Modifier.fillMaxSize(), + room = room, + mirror = mirror, + scaleType = ScaleType.Fill, + ) + } else { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Filled.VideocamOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@OptIn(ExperimentalHazeMaterialsApi::class) +@Composable +private fun CallInlineControlBar( + room: Room, + hazeState: dev.chrisbanes.haze.HazeState, + modifier: Modifier, +) { + val scope = rememberCoroutineScope() + val localParticipant = room.localParticipant + var micOn by remember { mutableStateOf(true) } + var camOn by remember { mutableStateOf(true) } + var shareOn by remember { mutableStateOf(false) } + + LaunchedEffect(localParticipant) { + while (isActive) { + micOn = localParticipant.isMicrophoneEnabled + camOn = localParticipant.isCameraEnabled + shareOn = localParticipant.isScreenShareEnabled + delay(400) + } + } + + val context = LocalContext.current + val shareNotifTitle = stringResource(Res.string.notif_screenshare_title) + val shareNotifText = stringResource(Res.string.notif_screenshare_text) + val updatedTitle by rememberUpdatedState(shareNotifTitle) + val updatedText by rememberUpdatedState(shareNotifText) + + val notifPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { } + + fun buildShareNotification(): Notification { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val ch = NotificationChannel( + SCREEN_SHARE_CHANNEL_ID, + updatedTitle, + NotificationManager.IMPORTANCE_LOW, + ) + nm.createNotificationChannel(ch) + } + val smallIcon = try { + context.packageManager.getApplicationInfo(context.packageName, 0).icon + } catch (_: Exception) { + android.R.drawable.stat_sys_upload + } + return NotificationCompat.Builder(context, SCREEN_SHARE_CHANNEL_ID) + .setContentTitle(updatedTitle) + .setContentText(updatedText) + .setSmallIcon(smallIcon) + .setOngoing(true) + .build() + } + + val shareLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + if (result.resultCode == Activity.RESULT_OK && result.data != null) { + scope.launch { + val notif = buildShareNotification() + val ok = runCatching { + localParticipant.setScreenShareEnabled( + true, + ScreenCaptureParams( + mediaProjectionPermissionResultData = result.data!!, + notificationId = SCREEN_SHARE_NOTIFICATION_ID, + notification = notif, + ), + ) + }.onFailure { Logger.e(TAG, "setScreenShareEnabled failed", it) } + if (ok.isSuccess) shareOn = true + } + } + } + + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .fillMaxWidth(0.86f) + .clip(RoundedCornerShape(28.dp)) + .hazeEffect(state = hazeState, style = HazeMaterials.thick()) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.34f)), + ) + Row( + modifier = Modifier + .fillMaxWidth(0.86f) + .padding(horizontal = 12.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + FilledTonalIconButton( + onClick = { + scope.launch { + val next = !micOn + if (runCatching { localParticipant.setMicrophoneEnabled(next) }.isSuccess) { + micOn = next + } + } + }, + modifier = Modifier.size(52.dp), + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = if (micOn) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.errorContainer + }, + contentColor = if (micOn) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onErrorContainer + }, + ), + ) { + Icon( + imageVector = if (micOn) Icons.Filled.Mic else Icons.Filled.MicOff, + contentDescription = stringResource(Res.string.cd_call_mic), + ) + } + Spacer(Modifier.size(10.dp)) + FilledTonalIconButton( + onClick = { + scope.launch { + val next = !camOn + if (runCatching { localParticipant.setCameraEnabled(next) }.isSuccess) { + camOn = next + } + } + }, + modifier = Modifier.size(52.dp), + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = if (camOn) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.errorContainer + }, + contentColor = if (camOn) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onErrorContainer + }, + ), + ) { + Icon( + imageVector = if (camOn) Icons.Filled.Videocam else Icons.Filled.VideocamOff, + contentDescription = stringResource(Res.string.cd_call_camera), + ) + } + Spacer(Modifier.size(10.dp)) + FilledTonalIconButton( + onClick = { + if (shareOn) { + scope.launch { + if (runCatching { localParticipant.setScreenShareEnabled(false) }.isSuccess) { + shareOn = false + } + } + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val granted = ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) == PackageManager.PERMISSION_GRANTED + if (!granted) { + notifPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + return@FilledTonalIconButton + } + } + val act = findActivity(context) ?: return@FilledTonalIconButton + val m = act.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager + shareLauncher.launch(m.createScreenCaptureIntent()) + } + }, + modifier = Modifier.size(52.dp), + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = if (shareOn) { + MaterialTheme.colorScheme.tertiaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHighest + }, + contentColor = if (shareOn) { + MaterialTheme.colorScheme.onTertiaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ), + ) { + Icon( + imageVector = Icons.Filled.PresentToAll, + contentDescription = stringResource(Res.string.cd_call_screenshare), + ) + } + Spacer(Modifier.size(18.dp)) + FilledIconButton( + onClick = { CallStore.endCall() }, + modifier = Modifier.size(56.dp), + shape = CircleShape, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ), + ) { + Icon( + imageVector = Icons.Filled.CallEnd, + contentDescription = stringResource(Res.string.cd_call_end), + ) + } + } + } +} \ No newline at end of file diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/calls/InlineCallChrome.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/calls/InlineCallChrome.android.kt new file mode 100644 index 0000000..9b45aff --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/calls/InlineCallChrome.android.kt @@ -0,0 +1,3 @@ +package ru.fromchat.calls + +actual val UseInlineInCallChrome: Boolean = true diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index 4594532..c534604 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -44,6 +44,24 @@ Группа %1$d человек Позвонить + Микрофон + Камера + Демонстрация экрана + Завершить + Запуск вызова… + Вызов… + Подключение… + Разговор + Входящий звонок + Принять + Отклонить + Не удалось соединить + ОК + Демонстрация экрана + Захват экрана активен для этого звонка + Активный звонок + Видеозвонок + Камера и микрофон остаются включёнными, пока вы вне приложения Вы Человек %1$d Это сообщение не удалось показать. diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index 05196fa..cdb1d8f 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -55,6 +55,24 @@ Group %1$d people Call + Microphone + Camera + Share screen + End call + Starting call… + Calling… + Connecting… + In call + Incoming call + Accept + Decline + Could not connect + OK + Screen sharing + Screen capture is active for this call + Ongoing call + Video call + Camera and microphone stay active while you’re away You diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt index 48ad5f4..3b0d8d0 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/ApiClient.kt @@ -850,4 +850,56 @@ object ApiClient { ) } } + + suspend fun fetchLiveKitToken(peerUserId: Int, roomName: String? = null): LiveKitTokenResponse { + if (_suspensionState.value.isSuspended) { + throw IllegalStateException("Suspended") + } + return http + .post("${Config.apiBaseUrl}/livekit/token") { + contentType(ContentType.Application.Json) + setBody(LiveKitTokenRequest(peerUserId = peerUserId, roomName = roomName)) + } + .body() + } + + suspend fun sendLiveKitInvite(toUserId: Int, roomName: String, serverUrl: String) { + if (_suspensionState.value.isSuspended) return + WebSocketManager.send( + WebSocketMessage( + type = "call_signaling", + credentials = WebSocketCredentials( + scheme = "Bearer", + credentials = getTokenSafely(), + ), + data = json.encodeToJsonElement( + CallSignalingLiveKitPayload( + toUserId = toUserId, + roomName = roomName, + serverUrl = serverUrl, + ), + ), + ), + ) + } + + suspend fun sendLiveKitControl(toUserId: Int, kind: String, roomName: String? = null) { + if (_suspensionState.value.isSuspended) return + WebSocketManager.send( + WebSocketMessage( + type = "call_signaling", + credentials = WebSocketCredentials( + scheme = "Bearer", + credentials = getTokenSafely(), + ), + data = json.encodeToJsonElement( + CallSignalingLiveKitControl( + toUserId = toUserId, + kind = kind, + roomName = roomName, + ), + ), + ), + ) + } } \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt index b8f4742..43f886e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/Models.kt @@ -419,4 +419,31 @@ data class VerifyResponse( @Serializable data class FcmTokenRequest( val token: String +) + +@Serializable +data class LiveKitTokenRequest( + @SerialName("peer_user_id") val peerUserId: Int, + @SerialName("room_name") val roomName: String? = null, +) + +@Serializable +data class LiveKitTokenResponse( + @SerialName("server_url") val serverUrl: String, + val token: String, + @SerialName("room_name") val roomName: String, +) + +@Serializable +data class CallSignalingLiveKitPayload( + val toUserId: Int, + val roomName: String, + val serverUrl: String, +) + +@Serializable +data class CallSignalingLiveKitControl( + val toUserId: Int, + val kind: String, + val roomName: String? = null, ) \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallMediaLayer.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallMediaLayer.kt new file mode 100644 index 0000000..3ed561f --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallMediaLayer.kt @@ -0,0 +1,13 @@ +package ru.fromchat.calls + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +expect fun CallMediaLayer( + connect: LiveKitConnectSession?, + showDialingPlaceholder: Boolean, + /** Android: show mic/camera/share/end over video when in an active LiveKit session. */ + showInCallControls: Boolean = false, + modifier: Modifier = Modifier, +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallOverlay.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallOverlay.kt new file mode 100644 index 0000000..e7e9be2 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallOverlay.kt @@ -0,0 +1,117 @@ +package ru.fromchat.calls + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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 +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import ru.fromchat.Res +import ru.fromchat.* +import ru.fromchat.api.ApiClient +import ru.fromchat.api.ProfileCache +import ru.fromchat.api.visibleDisplayName + +@Composable +fun CallOverlay(modifier: Modifier = Modifier) { + val state by CallStore.ui.collectAsState() + when (val s = state) { + CallUiState.Idle -> return + is CallUiState.Failed -> { + AlertDialog( + onDismissRequest = { CallStore.dismissFailed() }, + title = { Text(stringResource(Res.string.call_failed_title)) }, + text = { Text(s.message) }, + confirmButton = { + TextButton(onClick = { CallStore.dismissFailed() }) { + Text(stringResource(Res.string.call_dismiss)) + } + }, + ) + } + is CallUiState.Connecting -> { + Box( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.55f)), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(Res.string.call_status_starting), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + is CallUiState.Incoming -> { + val me = ApiClient.user?.id + val cached = ProfileCache.get(s.fromUserId) + val title = + cached?.visibleDisplayName(me)?.takeIf { it.isNotBlank() } + ?: cached?.username?.takeIf { it.isNotBlank() } + ?: stringResource(Res.string.user_fallback, s.fromUserId) + 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 = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(Res.string.call_incoming_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(24.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = { CallStore.declineIncoming() }) { + Text(stringResource(Res.string.call_decline)) + } + Button(onClick = { CallStore.acceptIncoming() }) { + Text(stringResource(Res.string.call_accept)) + } + } + } + } + } + is CallUiState.InCall -> { + Box(modifier = modifier.fillMaxSize()) { + CallMediaLayer( + connect = s.session, + showDialingPlaceholder = false, + showInCallControls = true, + modifier = Modifier.fillMaxSize(), + ) + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallStore.kt new file mode 100644 index 0000000..52ffe25 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/calls/CallStore.kt @@ -0,0 +1,200 @@ +package ru.fromchat.calls + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import ru.fromchat.api.ApiClient +import ru.fromchat.api.ProfileCache +import ru.fromchat.api.WebSocketMessage +import ru.fromchat.api.visibleDisplayName +import ru.fromchat.core.Logger + +private const val TAG = "CallStore" + +sealed class CallUiState { + data object Idle : CallUiState() + + data class Connecting( + val peerUserId: Int, + ) : CallUiState() + + data class Incoming( + val fromUserId: Int, + val fromUsername: String, + val roomName: String, + val serverUrl: String, + ) : CallUiState() + + data class InCall( + val session: LiveKitConnectSession, + ) : CallUiState() + + data class Failed( + val message: String, + ) : CallUiState() +} + +object CallStore { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val _ui = MutableStateFlow(CallUiState.Idle) + val ui: StateFlow = _ui.asStateFlow() + + private fun peerLabel(peerUserId: Int): String { + val me = ApiClient.user?.id + val p = ProfileCache.get(peerUserId) + val label = p?.visibleDisplayName(me)?.orEmpty()?.ifBlank { null } + return label ?: p?.username?.takeIf { it.isNotBlank() } ?: "User $peerUserId" + } + + fun onWebSocketMessage(message: WebSocketMessage) { + if (message.type != "call_signaling") return + val data = message.data ?: return + scope.launch { + runCatching { handleSignalingPayload(data) } + .onFailure { Logger.e(TAG, "call_signaling handling failed", it) } + } + } + + private suspend fun handleSignalingPayload(data: JsonElement) { + val obj = data.jsonObject + val kind = obj["kind"]?.jsonPrimitive?.contentOrNull + val fromUserId = obj["fromUserId"]?.jsonPrimitive?.content?.toIntOrNull() + val currentId = ApiClient.user?.id ?: return + if (kind != null) { + if (fromUserId == null || fromUserId == currentId) return + when (kind) { + "decline", "end", "cancel" -> { + when (val s = _ui.value) { + is CallUiState.InCall -> + if (s.session.peerUserId == fromUserId) clearToIdle() + is CallUiState.Connecting -> + if (s.peerUserId == fromUserId) clearToIdle() + is CallUiState.Incoming -> + if (s.fromUserId == fromUserId) clearToIdle() + else -> {} + } + } + "accept" -> {} + else -> {} + } + return + } + val serverUrl = obj["serverUrl"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } + val roomName = obj["roomName"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } + if (serverUrl == null || roomName == null || fromUserId == null) return + if (fromUserId == currentId) return + val fromUsername = obj["fromUsername"]?.jsonPrimitive?.contentOrNull.orEmpty() + if (_ui.value is CallUiState.InCall) return + _ui.value = CallUiState.Incoming( + fromUserId = fromUserId, + fromUsername = fromUsername, + roomName = roomName, + serverUrl = serverUrl, + ) + } + + fun startOutgoingCall(peerUserId: Int) { + if (peerUserId <= 0 || peerUserId == ApiClient.user?.id) return + scope.launch { + _ui.value = CallUiState.Connecting(peerUserId) + runCatching { + withContext(Dispatchers.Default) { + val tok = ApiClient.fetchLiveKitToken(peerUserId, null) + ApiClient.sendLiveKitInvite(peerUserId, tok.roomName, tok.serverUrl) + val label = peerLabel(peerUserId) + LiveKitConnectSession( + serverUrl = tok.serverUrl, + token = tok.token, + peerUserId = peerUserId, + peerDisplayName = label, + roomName = tok.roomName, + ) + } + }.onSuccess { session -> + _ui.value = CallUiState.InCall(session) + }.onFailure { e -> + Logger.e(TAG, "startOutgoingCall failed", e) + _ui.value = CallUiState.Failed(e.message ?: "Error") + delay(2500) + if (_ui.value is CallUiState.Failed) clearToIdle() + } + } + } + + fun acceptIncoming() { + val inc = _ui.value as? CallUiState.Incoming ?: return + scope.launch { + runCatching { + withContext(Dispatchers.Default) { + val tok = ApiClient.fetchLiveKitToken(inc.fromUserId, inc.roomName) + ApiClient.sendLiveKitControl(inc.fromUserId, "accept", inc.roomName) + val label = peerLabel(inc.fromUserId) + val display = + if (inc.fromUsername.isNotBlank()) inc.fromUsername else label + LiveKitConnectSession( + serverUrl = tok.serverUrl, + token = tok.token, + peerUserId = inc.fromUserId, + peerDisplayName = display, + roomName = tok.roomName, + ) + } + }.onSuccess { session -> + _ui.value = CallUiState.InCall(session) + }.onFailure { e -> + Logger.e(TAG, "acceptIncoming failed", e) + clearToIdle() + _ui.value = CallUiState.Failed(e.message ?: "Error") + delay(2500) + if (_ui.value is CallUiState.Failed) clearToIdle() + } + } + } + + fun declineIncoming(sendControl: Boolean = true) { + val inc = _ui.value as? CallUiState.Incoming ?: return + if (!sendControl) { + clearToIdle() + return + } + scope.launch { + runCatching { ApiClient.sendLiveKitControl(inc.fromUserId, "decline", inc.roomName) } + clearToIdle() + } + } + + fun endCall() { + when (val s = _ui.value) { + is CallUiState.InCall -> { + val peer = s.session.peerUserId + val room = s.session.roomName + scope.launch { + runCatching { ApiClient.sendLiveKitControl(peer, "end", room) } + clearToIdle() + } + } + is CallUiState.Connecting, + is CallUiState.Incoming, + -> clearToIdle() + else -> {} + } + } + + fun dismissFailed() { + if (_ui.value is CallUiState.Failed) clearToIdle() + } + + private fun clearToIdle() { + _ui.value = CallUiState.Idle + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/InlineCallChrome.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/calls/InlineCallChrome.kt new file mode 100644 index 0000000..b0b6f38 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/calls/InlineCallChrome.kt @@ -0,0 +1,3 @@ +package ru.fromchat.calls + +expect val UseInlineInCallChrome: Boolean diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/calls/LiveKitConnectSession.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/calls/LiveKitConnectSession.kt new file mode 100644 index 0000000..38484a7 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/calls/LiveKitConnectSession.kt @@ -0,0 +1,9 @@ +package ru.fromchat.calls + +data class LiveKitConnectSession( + val serverUrl: String, + val token: String, + val peerUserId: Int, + val peerDisplayName: String, + val roomName: String, +) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index 810ae6a..28f34ca 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -10,6 +10,8 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable @@ -21,6 +23,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.unit.IntOffset import androidx.lifecycle.Lifecycle @@ -76,6 +79,8 @@ import ru.fromchat.ui.main.settings.SettingsSecurityHubScreen import ru.fromchat.ui.main.settings.SettingsSecurityPasswordFlowScreen import ru.fromchat.ui.main.settings.SettingsServerToolsScreen import ru.fromchat.ui.profile.ProfileScreen +import ru.fromchat.calls.CallOverlay +import ru.fromchat.calls.CallStore import ru.fromchat.ui.setup.ServerConfigScreen val LocalNavController = compositionLocalOf { error("NavController not provided") } @@ -123,6 +128,7 @@ private fun handlePresenceEvent(message: WebSocketMessage) { "suspended", "unsuspended", "account_deleted" -> handleAccountLifecycleEvent(message) "statusUpdate" -> message.data?.jsonObject?.let(::handlePresenceStatus) "dmTyping", "stopDmTyping" -> message.data?.jsonObject?.let { handlePresenceTyping(message.type, it) } + "call_signaling" -> CallStore.onWebSocketMessage(message) "updates" -> { val data = message.data ?: return val updates = ApiClient.json.decodeFromJsonElement(data) @@ -321,50 +327,41 @@ fun App( } } - // Set up global auth error handler - LaunchedEffect(navController) { - ApiClient.onAuthError = { - Logger.d("App", "Global auth error handler triggered, navigating to login") - navController.navigate("login") { - popUpTo("chat") { inclusive = true } - } - } - } - CompositionLocalProvider( LocalNavController provides navController, LocalSystemBarsVisibility provides rememberSystemBarsController() ) { if (startDestination != null) { val animationSpec = tween(400) - NavHost( - navController = navController, - startDestination = startDestination!!, - enterTransition = { - slideIntoContainer( - Start, - animationSpec = animationSpec - ) - }, - exitTransition = { - slideOutOfContainer( - Start, - animationSpec = animationSpec - ) - }, - popEnterTransition = { - slideIntoContainer( - End, - animationSpec = animationSpec - ) - }, - popExitTransition = { - slideOutOfContainer( - End, - animationSpec = animationSpec - ) - } - ) { + Box(Modifier.fillMaxSize()) { + NavHost( + navController = navController, + startDestination = startDestination!!, + enterTransition = { + slideIntoContainer( + Start, + animationSpec = animationSpec + ) + }, + exitTransition = { + slideOutOfContainer( + Start, + animationSpec = animationSpec + ) + }, + popEnterTransition = { + slideIntoContainer( + End, + animationSpec = animationSpec + ) + }, + popExitTransition = { + slideOutOfContainer( + End, + animationSpec = animationSpec + ) + } + ) { composable("serverConfig") { ServerConfigScreen() } @@ -613,6 +610,24 @@ fun App( ) } } + // Register only after NavHost has attached a graph; otherwise 401 during startup/call can crash. + DisposableEffect(navController) { + ApiClient.onAuthError = { + Logger.d("App", "Global auth error handler triggered, navigating to login") + runCatching { + navController.navigate("login") { + popUpTo("chat") { inclusive = true } + } + }.onFailure { e -> + Logger.w("App", "Auth navigation failed: ${e.message}", e) + } + } + onDispose { + ApiClient.onAuthError = null + } + } + CallOverlay(Modifier.fillMaxSize()) + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 9f913ff..e029a56 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -67,6 +67,7 @@ import ru.fromchat.api.UserStatusStore import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketMessage import ru.fromchat.api.WebSocketUpdatesData +import ru.fromchat.calls.CallStore import ru.fromchat.core.Logger import ru.fromchat.net.NetworkConnectivity import ru.fromchat.ui.HapticFeedbackEvent @@ -578,7 +579,12 @@ fun ChatScreen( onBack = { navController.navigateUp() }, backContentDescription = stringResource(Res.string.back), showCallButton = panel.showCallButton() && !isReadOnly, - onCallClick = { /* TODO: Handle call */ }, + onCallClick = { + val peer = panelState.profileUserId + if (peer != null && peer > 0) { + scope.launch { CallStore.startOutgoingCall(peer) } + } + }, callContentDescription = cdCall, titleChrome = { ChatFloatingTitleChrome( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt index f28c8f0..471527f 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/dm/DmPanel.kt @@ -418,7 +418,7 @@ class DmPanel( override suspend fun handleDeleteMessage(messageId: Int) {} - override fun showCallButton(): Boolean = false + override fun showCallButton(): Boolean = true override fun getTypingHandler(): TypingHandler = typingHandler diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsExpressive.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsExpressive.kt index 42b1ee0..9531bf3 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsExpressive.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/SettingsExpressive.kt @@ -39,9 +39,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.graphics.shapes.Morph @@ -136,11 +136,11 @@ fun SettingsSecurityMorphedPasswordHero( val usePredictive = predictiveProgress != null && predictiveFromStep != null && predictiveToStep != null val fromStep = when { - usePredictive -> predictiveFromStep!! + usePredictive -> predictiveFromStep else -> step } val toStep = when { - usePredictive -> predictiveToStep!! + usePredictive -> predictiveToStep else -> step } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/LastSeenFormat.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/LastSeenFormat.kt index 5b9d798..4cc8d41 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/utils/LastSeenFormat.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/utils/LastSeenFormat.kt @@ -4,22 +4,48 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import kotlinx.datetime.DatePeriod import kotlinx.datetime.DayOfWeek -import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.minus +import kotlinx.datetime.number import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res -import ru.fromchat.* +import ru.fromchat.month_apr +import ru.fromchat.month_aug +import ru.fromchat.month_dec +import ru.fromchat.month_feb +import ru.fromchat.month_jan +import ru.fromchat.month_jul +import ru.fromchat.month_jun +import ru.fromchat.month_mar +import ru.fromchat.month_may +import ru.fromchat.month_nov +import ru.fromchat.month_oct +import ru.fromchat.month_sep +import ru.fromchat.presence_date_full +import ru.fromchat.presence_date_this_year +import ru.fromchat.presence_online +import ru.fromchat.presence_recently +import ru.fromchat.presence_today_at +import ru.fromchat.presence_weekday_at +import ru.fromchat.presence_yesterday_at +import ru.fromchat.weekday_fri +import ru.fromchat.weekday_mon +import ru.fromchat.weekday_sat +import ru.fromchat.weekday_sun +import ru.fromchat.weekday_thu +import ru.fromchat.weekday_tue +import ru.fromchat.weekday_wed import kotlin.time.Clock import kotlin.time.ExperimentalTime +import kotlin.time.Instant private fun formatFromXmlTemplate(template: String, vararg args: Any): String { var result = template args.forEachIndexed { index, arg -> val n = index + 1 val text = arg.toString() - result = result.replace("%${n}\$s", text).replace("%${n}\$d", text) + result = result.replace($$"%$${n}$s", text).replace($$"%$${n}$d", text) } return result } @@ -124,12 +150,12 @@ fun formatLastSeen(online: Boolean, lastSeenIso: String?, s: LastSeenFormatStrin formatFromXmlTemplate(s.weekdayAt, label, timePart) } lastDate.year == nowDate.year -> { - val mon = s.monthShort(lastDate.monthNumber) - formatFromXmlTemplate(s.dateThisYear, lastDate.dayOfMonth, mon, timePart) + val mon = s.monthShort(lastDate.month.number) + formatFromXmlTemplate(s.dateThisYear, lastDate.day, mon, timePart) } else -> { - val mon = s.monthShort(lastDate.monthNumber) - formatFromXmlTemplate(s.dateFull, lastDate.dayOfMonth, mon, lastDate.year, timePart) + val mon = s.monthShort(lastDate.month.number) + formatFromXmlTemplate(s.dateFull, lastDate.day, mon, lastDate.year, timePart) } } } diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/calls/CallMediaLayer.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/calls/CallMediaLayer.ios.kt new file mode 100644 index 0000000..8b0fe20 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/calls/CallMediaLayer.ios.kt @@ -0,0 +1,16 @@ +package ru.fromchat.calls + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +actual fun CallMediaLayer( + connect: LiveKitConnectSession?, + showDialingPlaceholder: Boolean, + showInCallControls: Boolean, + modifier: Modifier, +) { + Box(modifier = modifier.fillMaxSize()) +} diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/calls/InlineCallChrome.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/calls/InlineCallChrome.ios.kt new file mode 100644 index 0000000..f1bb0d3 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/calls/InlineCallChrome.ios.kt @@ -0,0 +1,3 @@ +package ru.fromchat.calls + +actual val UseInlineInCallChrome: Boolean = false diff --git a/gradle.properties b/gradle.properties index 325ab5e..8058c6f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,11 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 +# If the Gradle client JVM crashes before the daemon starts (SIGBUS in CodeHeap::allocate), prefer +# Android Studio’s JBR: export GRADLE_JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" +# (or leave JAVA_HOME unset on macOS: gradlew picks that path automatically). Otherwise use another JDK 21+. +# (see gradlew). Disabling class sharing can also help: add -Xshare:off below. +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 -Xshare:off # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. For more details, visit # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects @@ -25,4 +29,5 @@ android.uniquePackageNames=false android.dependency.useConstraints=true android.r8.strictFullModeForKeepRules=false android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false -org.gradle.configuration-cache=true \ No newline at end of file +# Configuration cache can surface noisy IDE/sync issues on some setups; turn on when stable. +org.gradle.configuration-cache=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5c5ffc9..e482fe7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ gson = "2.13.2" kotlinxIoBytestring = "0.9.0" kotlinxCoroutinesCore = "1.10.2" kotlinxIoCore = "0.9.0" +multiplatformCryptoLibsodiumBindings = "0.9.5" multiplatformSettings = "1.3.0" serialization = "2.3.20" serialization-json = "1.11.0" @@ -40,6 +41,8 @@ androidxWork = "2.11.2" cryptography-kotlin = "0.6.0" krypto = "4.0.10" sqldelight = "2.3.2" +livekitAndroid = "2.24.1" +livekitAndroidComposeComponents = "2.3.0" [libraries] androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" } @@ -65,6 +68,7 @@ kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.re kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization-json" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" } +multiplatform-crypto-libsodium-bindings = { module = "com.ionspin.kotlin:multiplatform-crypto-libsodium-bindings", version.ref = "multiplatformCryptoLibsodiumBindings" } multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" } multiplatform-settings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatformSettings" } multiplatform-settings-serialization = { module = "com.russhwolf:multiplatform-settings-serialization", version.ref = "multiplatformSettings" } @@ -105,6 +109,8 @@ sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sq sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } sqldelight-driver-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" } +livekit-android = { module = "io.livekit:livekit-android", version.ref = "livekitAndroid" } +livekit-android-compose-components = { module = "io.livekit:livekit-android-compose-components", version.ref = "livekitAndroidComposeComponents" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/gradlew b/gradlew index 4f906e0..d2f528e 100755 --- a/gradlew +++ b/gradlew @@ -44,7 +44,8 @@ APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# 64m is too small for the wrapper client JVM on recent JDKs (VM init / code cache); keep this modest but safe. +DEFAULT_JVM_OPTS='"-Xmx512m" "-Xms128m" "-Xshare:off"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" @@ -82,6 +83,23 @@ esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +# If the default JDK crashes on startup (e.g. SIGBUS in CodeHeap::allocate during VM init on some +# macOS + JDK combinations), point Gradle at another JDK 21+ without changing your global JAVA_HOME: +# export GRADLE_JAVA_HOME="/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home" +if [ -n "$GRADLE_JAVA_HOME" ] && [ -x "$GRADLE_JAVA_HOME/bin/java" ]; then + JAVA_HOME="$GRADLE_JAVA_HOME" + export JAVA_HOME +fi + +# macOS: when JAVA_HOME is still unset, use the JBR bundled with Android Studio.app (recommended on Apple Silicon). +if [ "$darwin" = "true" ] && [ -z "${JAVA_HOME:-}" ]; then + _STUDIO_JBR="/Applications/Android Studio.app/Contents/jbr/Contents/Home" + if [ -x "$_STUDIO_JBR/bin/java" ]; then + JAVA_HOME="$_STUDIO_JBR" + export JAVA_HOME + fi + unset _STUDIO_JBR +fi # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then diff --git a/gradlew.bat b/gradlew.bat index 107acd3..7e01ad8 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -33,7 +33,9 @@ set APP_HOME=%DIRNAME% for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" +@rem If the JVM crashes on startup, set GRADLE_JAVA_HOME to Temurin/OpenJDK 21+ (see gradle.properties). +if defined GRADLE_JAVA_HOME if exist "%GRADLE_JAVA_HOME%\bin\java.exe" set "JAVA_HOME=%GRADLE_JAVA_HOME%" +set DEFAULT_JVM_OPTS="-Xmx512m" "-Xms128m" "-Xshare:off" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome diff --git a/settings.gradle.kts b/settings.gradle.kts index 6a8eccf..0f9efe5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,6 +33,8 @@ dependencyResolutionManagement { } } mavenCentral() + // LiveKit Android pulls com.github.davidliu:audioswitch from JitPack. + maven("https://jitpack.io") } }