diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index d03cf083a..796a4b8b7 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -14,8 +14,9 @@ - + + @@ -40,10 +41,13 @@ + + + @@ -243,7 +247,7 @@ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt index 40c81c048..087914550 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt @@ -20,10 +20,12 @@ */ package com.vitorpamplona.amethyst.service.call +import android.Manifest import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.PackageManager import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener @@ -39,6 +41,7 @@ import android.os.PowerManager import android.os.VibrationEffect import android.os.Vibrator import android.os.VibratorManager +import androidx.core.content.ContextCompat import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -63,6 +66,14 @@ class CallAudioManager( private val _audioRoute = MutableStateFlow(AudioRoute.EARPIECE) val audioRoute: StateFlow = _audioRoute.asStateFlow() + private fun hasBluetoothPermission(): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == + PackageManager.PERMISSION_GRANTED + } else { + true + } + private val _isBluetoothAvailable = MutableStateFlow(false) val isBluetoothAvailable: StateFlow = _isBluetoothAvailable.asStateFlow() @@ -115,13 +126,18 @@ class CallAudioManager( previousAudioMode = audioManager.mode audioManager.mode = AudioManager.MODE_IN_COMMUNICATION - _isBluetoothAvailable.value = hasBluetoothDevice() - if (_isBluetoothAvailable.value) { - startBluetoothSco() + if (hasBluetoothPermission()) { + _isBluetoothAvailable.value = hasBluetoothDevice() + if (_isBluetoothAvailable.value) { + startBluetoothSco() + } else { + routeToEarpiece() + } + registerBluetoothScoReceiver() } else { + _isBluetoothAvailable.value = false routeToEarpiece() } - registerBluetoothScoReceiver() } fun restoreAudioMode() { @@ -220,6 +236,7 @@ class CallAudioManager( } private fun routeToBluetooth() { + if (!hasBluetoothPermission()) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val btDevice = audioManager diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index e8b35e816..db8d4f106 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -117,7 +117,7 @@ class CallController( callManager.state.collect { state -> when (state) { is CallState.IncomingCall -> { - audioManager.startRinging() + withContext(Dispatchers.IO) { audioManager.startRinging() } showIncomingCallNotification(state.callerPubKey) } @@ -128,7 +128,7 @@ class CallController( is CallState.Connecting -> { audioManager.stopRinging() audioManager.stopRingbackTone() - audioManager.switchToCallAudioMode() + withContext(Dispatchers.IO) { audioManager.switchToCallAudioMode() } audioManager.acquireProximityWakeLock() NotificationUtils.cancelCallNotification(context) } @@ -337,6 +337,17 @@ class CallController( fun getEglBase() = webRtcSession?.eglBase + fun invitePeer(peerPubKey: String) { + scope.launch { + val session = webRtcSession ?: return@launch + session.createOffer { sdp -> + scope.launch { + callManager.invitePeer(peerPubKey, sdp.description) + } + } + } + } + fun hangup() { scope.launch { callManager.hangup() @@ -461,10 +472,12 @@ class CallController( private fun startForegroundService() { try { val peerName = callManager.currentPeerPubKey() ?: "" + val isVideo = _isVideoEnabled.value val intent = Intent(context, CallForegroundService::class.java).apply { action = CallForegroundService.ACTION_START putExtra(CallForegroundService.EXTRA_PEER_NAME, peerName) + putExtra(CallForegroundService.EXTRA_IS_VIDEO, isVideo) } context.startForegroundService(intent) } catch (e: Exception) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt index 607e48155..1b0a00a49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallForegroundService.kt @@ -45,6 +45,7 @@ class CallForegroundService : Service() { const val ACTION_START = "com.vitorpamplona.amethyst.CALL_START" const val ACTION_STOP = "com.vitorpamplona.amethyst.CALL_STOP" const val EXTRA_PEER_NAME = "peer_name" + const val EXTRA_IS_VIDEO = "is_video" } override fun onBind(intent: Intent?): IBinder? = null @@ -62,14 +63,25 @@ class CallForegroundService : Service() { when (intent?.action) { ACTION_START -> { val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown" + val isVideo = intent.getBooleanExtra(EXTRA_IS_VIDEO, false) val notification = buildNotification(peerName) val hasAudioPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + val hasCameraPermission = + ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED try { val fgsType = - if (hasAudioPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + var type = 0 + if (hasAudioPermission) { + type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } + if (isVideo && hasCameraPermission) { + type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA + } + type } else { 0 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt index 841caf946..50717b100 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.call import android.Manifest import android.content.Context import android.content.pm.PackageManager +import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable @@ -43,30 +44,42 @@ fun hasCallPermissions( return true } +fun buildCallPermissions(isVideo: Boolean): Array { + val permissions = mutableListOf(Manifest.permission.RECORD_AUDIO) + if (isVideo) { + permissions.add(Manifest.permission.CAMERA) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + permissions.add(Manifest.permission.BLUETOOTH_CONNECT) + } + return permissions.toTypedArray() +} + @Composable fun rememberCallWithPermission( context: Context, isVideo: Boolean = false, onCall: () -> Unit, ): () -> Unit { - val permissions = - if (isVideo) { - arrayOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA) - } else { - arrayOf(Manifest.permission.RECORD_AUDIO) - } + val permissions = remember(isVideo) { buildCallPermissions(isVideo) } val launcher = rememberLauncherForActivityResult( ActivityResultContracts.RequestMultiplePermissions(), ) { results -> - val allGranted = results.values.all { it } - if (allGranted) onCall() + // Bluetooth is optional — proceed if core permissions are granted + if (hasCallPermissions(context, isVideo)) onCall() } return remember(onCall, isVideo) { { if (hasCallPermissions(context, isVideo)) { + // Core permissions granted; still request BT if missing + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + !hasPermission(context, Manifest.permission.BLUETOOTH_CONNECT) + ) { + launcher.launch(arrayOf(Manifest.permission.BLUETOOTH_CONNECT)) + } onCall() } else { launcher.launch(permissions) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index bba33f7a6..c60152515 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -44,22 +44,27 @@ import androidx.compose.material.icons.filled.Call 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.PersonAdd import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.filled.VideocamOff import androidx.compose.material.icons.filled.VolumeOff import androidx.compose.material.icons.filled.VolumeUp +import androidx.compose.material3.AlertDialog import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Snackbar import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -68,6 +73,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView @@ -76,8 +83,11 @@ import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.service.call.AudioRoute import com.vitorpamplona.amethyst.service.call.CallController +import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.stringRes @@ -123,10 +133,10 @@ fun CallScreen( is CallState.Offering -> { if (isInPipMode) { - PipCallUI(peerPubKey = state.peerPubKeys.first(), statusText = stringRes(R.string.call_calling), accountViewModel = accountViewModel) + PipCallUI(peerPubKeys = state.peerPubKeys, statusText = stringRes(R.string.call_calling), accountViewModel = accountViewModel) } else { CallInProgressUI( - peerPubKey = state.peerPubKeys.first(), + peerPubKeys = state.peerPubKeys, statusText = stringRes(R.string.call_calling), accountViewModel = accountViewModel, onHangup = { scope.launch { callManager.hangup() } }, @@ -136,7 +146,7 @@ fun CallScreen( is CallState.IncomingCall -> { if (isInPipMode) { - PipCallUI(callerPubKey = state.callerPubKey, statusText = stringRes(R.string.call_incoming), accountViewModel = accountViewModel) + PipCallUI(peerPubKeys = state.groupMembers, statusText = stringRes(R.string.call_incoming), accountViewModel = accountViewModel) } else { val isVideoCall = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO val acceptWithPermission = @@ -144,7 +154,7 @@ fun CallScreen( callController?.acceptIncomingCall(state.sdpOffer) } IncomingCallUI( - callerPubKey = state.callerPubKey, + groupMembers = state.groupMembers, callType = state.callType, accountViewModel = accountViewModel, onAccept = acceptWithPermission, @@ -155,10 +165,10 @@ fun CallScreen( is CallState.Connecting -> { if (isInPipMode) { - PipCallUI(peerPubKey = state.peerPubKeys.first(), statusText = stringRes(R.string.call_connecting), accountViewModel = accountViewModel) + PipCallUI(peerPubKeys = state.peerPubKeys, statusText = stringRes(R.string.call_connecting), accountViewModel = accountViewModel) } else { CallInProgressUI( - peerPubKey = state.peerPubKeys.first(), + peerPubKeys = state.peerPubKeys, statusText = stringRes(R.string.call_connecting), accountViewModel = accountViewModel, onHangup = { scope.launch { callManager.hangup() } }, @@ -178,6 +188,7 @@ fun CallScreen( onToggleMute = { callController?.toggleAudioMute() }, onToggleVideo = { callController?.toggleVideo() }, onCycleAudioRoute = { callController?.cycleAudioRoute() }, + onInvitePeer = { peerPubKey -> callController?.invitePeer(peerPubKey) }, ) } } @@ -185,7 +196,7 @@ fun CallScreen( is CallState.Ended -> { if (!isInPipMode) { CallInProgressUI( - peerPubKey = state.peerPubKeys.first(), + peerPubKeys = state.peerPubKeys, statusText = stringRes(R.string.call_ended), accountViewModel = accountViewModel, onHangup = { onCallEnded() }, @@ -220,7 +231,7 @@ fun CallScreen( @Composable private fun CallInProgressUI( - peerPubKey: String, + peerPubKeys: Set, statusText: String, accountViewModel: AccountViewModel, onHangup: () -> Unit, @@ -238,21 +249,16 @@ private fun CallInProgressUI( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - LoadUser(baseUserHex = peerPubKey, accountViewModel = accountViewModel) { user -> - if (user != null) { - ClickableUserPicture( - baseUser = user, - size = 120.dp, - accountViewModel = accountViewModel, - ) - Spacer(modifier = Modifier.height(16.dp)) - UsernameDisplay( - baseUser = user, - accountViewModel = accountViewModel, - fontWeight = FontWeight.Bold, - ) - } - } + GroupCallPictures( + peerPubKeys = peerPubKeys, + size = 120.dp, + accountViewModel = accountViewModel, + ) + Spacer(modifier = Modifier.height(16.dp)) + GroupCallNames( + peerPubKeys = peerPubKeys, + accountViewModel = accountViewModel, + ) Spacer(modifier = Modifier.height(8.dp)) Text( text = statusText, @@ -279,7 +285,7 @@ private fun CallInProgressUI( @Composable private fun IncomingCallUI( - callerPubKey: String, + groupMembers: Set, callType: com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType, accountViewModel: AccountViewModel, onAccept: () -> Unit, @@ -298,21 +304,16 @@ private fun IncomingCallUI( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - LoadUser(baseUserHex = callerPubKey, accountViewModel = accountViewModel) { user -> - if (user != null) { - ClickableUserPicture( - baseUser = user, - size = 120.dp, - accountViewModel = accountViewModel, - ) - Spacer(modifier = Modifier.height(16.dp)) - UsernameDisplay( - baseUser = user, - accountViewModel = accountViewModel, - fontWeight = FontWeight.Bold, - ) - } - } + GroupCallPictures( + peerPubKeys = groupMembers, + size = 120.dp, + accountViewModel = accountViewModel, + ) + Spacer(modifier = Modifier.height(16.dp)) + GroupCallNames( + peerPubKeys = groupMembers, + accountViewModel = accountViewModel, + ) Spacer(modifier = Modifier.height(8.dp)) Text( text = @@ -370,6 +371,7 @@ private fun ConnectedCallUI( onToggleMute: () -> Unit, onToggleVideo: () -> Unit, onCycleAudioRoute: () -> Unit, + onInvitePeer: (String) -> Unit = {}, ) { var elapsed by remember { mutableLongStateOf(0L) } @@ -391,6 +393,20 @@ private fun ConnectedCallUI( val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState() val currentAudioRoute by (callController?.audioRoute ?: defaultRoute).collectAsState() + var showAddParticipant by remember { mutableStateOf(false) } + + if (showAddParticipant) { + AddParticipantDialog( + accountViewModel = accountViewModel, + existingPeers = state.allPeerPubKeys, + onInvite = { peerPubKey -> + onInvitePeer(peerPubKey) + showAddParticipant = false + }, + onDismiss = { showAddParticipant = false }, + ) + } + Box( modifier = Modifier @@ -433,28 +449,31 @@ private fun ConnectedCallUI( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - LoadUser(baseUserHex = state.peerPubKeys.first(), accountViewModel = accountViewModel) { user -> - if (user != null) { - ClickableUserPicture( - baseUser = user, - size = 120.dp, - accountViewModel = accountViewModel, - ) - Spacer(modifier = Modifier.height(16.dp)) - UsernameDisplay( - baseUser = user, - accountViewModel = accountViewModel, - fontWeight = FontWeight.Bold, - textColor = Color.White, - ) - } - } + GroupCallPictures( + peerPubKeys = state.allPeerPubKeys, + size = 120.dp, + accountViewModel = accountViewModel, + ) + Spacer(modifier = Modifier.height(16.dp)) + GroupCallNames( + peerPubKeys = state.allPeerPubKeys, + accountViewModel = accountViewModel, + textColor = Color.White, + ) Spacer(modifier = Modifier.height(8.dp)) Text( text = formatDuration(elapsed), color = Color.White.copy(alpha = 0.7f), fontSize = 16.sp, ) + if (state.pendingPeerPubKeys.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringRes(R.string.call_waiting_for_others), + color = Color.White.copy(alpha = 0.5f), + fontSize = 13.sp, + ) + } } } else { // Timer overlay @@ -533,6 +552,17 @@ private fun ConnectedCallUI( modifier = Modifier.size(28.dp), ) } + IconButton( + onClick = { showAddParticipant = true }, + modifier = Modifier.size(56.dp), + ) { + Icon( + imageVector = Icons.Default.PersonAdd, + contentDescription = stringRes(R.string.call_add_participant), + tint = Color.White, + modifier = Modifier.size(28.dp), + ) + } } Spacer(modifier = Modifier.height(24.dp)) FloatingActionButton( @@ -584,12 +614,10 @@ private fun formatDuration(seconds: Long): String { @Composable private fun PipCallUI( - peerPubKey: String = "", - callerPubKey: String = "", + peerPubKeys: Set, statusText: String, accountViewModel: AccountViewModel, ) { - val pubKey = peerPubKey.ifEmpty { callerPubKey } Box( modifier = Modifier @@ -601,15 +629,11 @@ private fun PipCallUI( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - LoadUser(baseUserHex = pubKey, accountViewModel = accountViewModel) { user -> - if (user != null) { - ClickableUserPicture( - baseUser = user, - size = 48.dp, - accountViewModel = accountViewModel, - ) - } - } + GroupCallPictures( + peerPubKeys = peerPubKeys, + size = 48.dp, + accountViewModel = accountViewModel, + ) Spacer(modifier = Modifier.height(4.dp)) Text( text = statusText, @@ -665,15 +689,11 @@ private fun PipConnectedCallUI( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - LoadUser(baseUserHex = state.peerPubKeys.first(), accountViewModel = accountViewModel) { user -> - if (user != null) { - ClickableUserPicture( - baseUser = user, - size = 48.dp, - accountViewModel = accountViewModel, - ) - } - } + GroupCallPictures( + peerPubKeys = state.allPeerPubKeys, + size = 48.dp, + accountViewModel = accountViewModel, + ) Spacer(modifier = Modifier.height(4.dp)) Text( text = formatDuration(elapsed), @@ -696,6 +716,238 @@ private fun PipConnectedCallUI( } } +@Composable +private fun AddParticipantDialog( + accountViewModel: AccountViewModel, + existingPeers: Set, + onInvite: (String) -> Unit, + onDismiss: () -> Unit, +) { + val userSuggestions = + remember { + UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder()) + } + var searchText by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.call_add_participant)) }, + text = { + Column { + OutlinedTextField( + value = searchText, + onValueChange = { + searchText = it + userSuggestions.processCurrentWord(it) + }, + label = { Text(stringRes(R.string.call_search_users)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + Box(modifier = Modifier.height(300.dp)) { + ShowUserSuggestionList( + userSuggestions = userSuggestions, + onSelect = { user -> + if (user.pubkeyHex !in existingPeers) { + onInvite(user.pubkeyHex) + } + }, + accountViewModel = accountViewModel, + ) + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringRes(R.string.call_dismiss)) + } + }, + ) +} + +@Composable +private fun GroupCallPictures( + peerPubKeys: Set, + size: Dp, + accountViewModel: AccountViewModel, +) { + val userList = remember(peerPubKeys) { peerPubKeys.toList() } + val displayCount = minOf(userList.size, 4) + val remaining = userList.size - displayCount + + when (userList.size) { + 0 -> {} + + 1 -> { + LoadUser(baseUserHex = userList[0], accountViewModel = accountViewModel) { user -> + if (user != null) { + ClickableUserPicture( + baseUser = user, + size = size, + accountViewModel = accountViewModel, + ) + } + } + } + + else -> { + Box(Modifier.size(size), contentAlignment = Alignment.TopEnd) { + when (displayCount) { + 2 -> { + BaseUserPicture( + baseUserHex = userList[0], + size = size.div(1.5f), + accountViewModel = accountViewModel, + outerModifier = Modifier.size(size.div(1.5f)).align(Alignment.CenterStart), + ) + BaseUserPicture( + baseUserHex = userList[1], + size = size.div(1.5f), + accountViewModel = accountViewModel, + outerModifier = Modifier.size(size.div(1.5f)).align(Alignment.CenterEnd), + ) + } + + 3 -> { + BaseUserPicture( + baseUserHex = userList[0], + size = size.div(1.8f), + accountViewModel = accountViewModel, + outerModifier = Modifier.size(size.div(1.8f)).align(Alignment.BottomStart), + ) + BaseUserPicture( + baseUserHex = userList[1], + size = size.div(1.8f), + accountViewModel = accountViewModel, + outerModifier = Modifier.size(size.div(1.8f)).align(Alignment.TopCenter), + ) + BaseUserPicture( + baseUserHex = userList[2], + size = size.div(1.8f), + accountViewModel = accountViewModel, + outerModifier = Modifier.size(size.div(1.8f)).align(Alignment.BottomEnd), + ) + } + + else -> { + BaseUserPicture( + baseUserHex = userList[0], + size = size.div(2f), + accountViewModel = accountViewModel, + outerModifier = Modifier.size(size.div(2f)).align(Alignment.BottomStart), + ) + BaseUserPicture( + baseUserHex = userList[1], + size = size.div(2f), + accountViewModel = accountViewModel, + outerModifier = Modifier.size(size.div(2f)).align(Alignment.TopStart), + ) + BaseUserPicture( + baseUserHex = userList[2], + size = size.div(2f), + accountViewModel = accountViewModel, + outerModifier = Modifier.size(size.div(2f)).align(Alignment.BottomEnd), + ) + if (remaining > 0) { + Box( + modifier = + Modifier + .size(size.div(2f)) + .align(Alignment.TopEnd) + .background( + MaterialTheme.colorScheme.surfaceVariant, + CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "+$remaining", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = (size.value / 5).sp, + fontWeight = FontWeight.Bold, + ) + } + } else { + BaseUserPicture( + baseUserHex = userList[3], + size = size.div(2f), + accountViewModel = accountViewModel, + outerModifier = Modifier.size(size.div(2f)).align(Alignment.TopEnd), + ) + } + } + } + } + } + } +} + +@Composable +private fun GroupCallNames( + peerPubKeys: Set, + accountViewModel: AccountViewModel, + textColor: Color = Color.Unspecified, +) { + val userList = remember(peerPubKeys) { peerPubKeys.toList() } + + when (userList.size) { + 0 -> {} + + 1 -> { + LoadUser(baseUserHex = userList[0], accountViewModel = accountViewModel) { user -> + if (user != null) { + UsernameDisplay( + baseUser = user, + accountViewModel = accountViewModel, + fontWeight = FontWeight.Bold, + textColor = textColor, + ) + } + } + } + + else -> { + val displayCount = minOf(userList.size, 2) + val remaining = userList.size - displayCount + + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + for (i in 0 until displayCount) { + if (i > 0) { + Text( + text = ", ", + fontWeight = FontWeight.Bold, + color = textColor, + ) + } + LoadUser(baseUserHex = userList[i], accountViewModel = accountViewModel) { user -> + if (user != null) { + UsernameDisplay( + baseUser = user, + accountViewModel = accountViewModel, + fontWeight = FontWeight.Bold, + textColor = textColor, + ) + } + } + } + if (remaining > 0) { + Text( + text = " +$remaining", + fontWeight = FontWeight.Bold, + color = textColor, + textAlign = TextAlign.Center, + ) + } + } + } + } +} + @Composable private fun KeepScreenOn() { val context = LocalContext.current diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreenPreviews.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreenPreviews.kt index 85d832c63..01e228faa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreenPreviews.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreenPreviews.kt @@ -33,11 +33,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BluetoothAudio import androidx.compose.material.icons.filled.Call 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.Person +import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.filled.VideocamOff import androidx.compose.material.icons.filled.VolumeOff @@ -57,8 +59,142 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +// ---- Shared building blocks for previews ---- + @Composable -private fun PreviewCallInProgress(statusText: String) { +private fun PreviewAvatar( + modifier: Modifier = Modifier, + tint: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = modifier.size(120.dp), + tint = tint, + ) +} + +@Composable +private fun PreviewGroupAvatars( + count: Int, + tint: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + val halfSize = 60.dp + Box(Modifier.size(120.dp)) { + repeat(minOf(count, 4)) { i -> + val alignment = + when (i) { + 0 -> Alignment.BottomStart + 1 -> Alignment.TopStart + 2 -> Alignment.BottomEnd + else -> Alignment.TopEnd + } + if (i < 3 || count <= 4) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(halfSize).align(alignment), + tint = tint, + ) + } else { + Box( + modifier = + Modifier + .size(halfSize) + .align(alignment) + .background( + MaterialTheme.colorScheme.surfaceVariant, + CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = "+${count - 3}", + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + ) + } + } + } + } +} + +@Composable +private fun PreviewCallControls( + isMuted: Boolean = false, + isVideoEnabled: Boolean = false, + audioRoute: String = "earpiece", + showAddParticipant: Boolean = false, +) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { + Icon( + imageVector = if (isMuted) Icons.Default.MicOff else Icons.Default.Mic, + contentDescription = "Mute", + tint = if (isMuted) Color.Red else Color.White, + modifier = Modifier.size(28.dp), + ) + } + IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { + Icon( + imageVector = if (isVideoEnabled) Icons.Default.Videocam else Icons.Default.VideocamOff, + contentDescription = "Camera", + tint = if (!isVideoEnabled) Color.Red else Color.White, + modifier = Modifier.size(28.dp), + ) + } + IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { + Icon( + imageVector = + when (audioRoute) { + "speaker" -> Icons.Default.VolumeUp + "bluetooth" -> Icons.Default.BluetoothAudio + else -> Icons.Default.VolumeOff + }, + contentDescription = "Audio route", + tint = + when (audioRoute) { + "speaker" -> Color.Cyan + "bluetooth" -> Color(0xFF2196F3) + else -> Color.White + }, + modifier = Modifier.size(28.dp), + ) + } + if (showAddParticipant) { + IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { + Icon( + Icons.Default.PersonAdd, + contentDescription = "Add participant", + tint = Color.White, + modifier = Modifier.size(28.dp), + ) + } + } + } + Spacer(modifier = Modifier.height(24.dp)) + FloatingActionButton( + onClick = {}, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), + ) { + Icon(Icons.Default.CallEnd, "Hang up", tint = Color.White, modifier = Modifier.size(32.dp)) + } + } +} + +// ---- 1. Offering (Calling...) ---- + +@Composable +private fun PreviewCallInProgress( + name: String, + statusText: String, +) { Box( modifier = Modifier @@ -70,24 +206,11 @@ private fun PreviewCallInProgress(statusText: String) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Icon( - Icons.Default.Person, - contentDescription = null, - modifier = Modifier.size(120.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + PreviewAvatar() Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Alice", - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - ) + Text(text = name, fontWeight = FontWeight.Bold, fontSize = 20.sp) Spacer(modifier = Modifier.height(8.dp)) - Text( - text = statusText, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 16.sp, - ) + Text(text = statusText, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 16.sp) Spacer(modifier = Modifier.height(48.dp)) FloatingActionButton( onClick = {}, @@ -95,12 +218,7 @@ private fun PreviewCallInProgress(statusText: String) { shape = CircleShape, modifier = Modifier.size(64.dp), ) { - Icon( - Icons.Default.CallEnd, - contentDescription = "Hang up", - tint = Color.White, - modifier = Modifier.size(32.dp), - ) + Icon(Icons.Default.CallEnd, "Hang up", tint = Color.White, modifier = Modifier.size(32.dp)) } } } @@ -110,29 +228,15 @@ private fun PreviewCallInProgress(statusText: String) { @Composable fun PreviewCallingScreen() { ThemeComparisonColumn { - PreviewCallInProgress("Calling...") + PreviewCallInProgress("Alice", "Calling\u2026") } } -@Preview(showBackground = true, widthDp = 360, heightDp = 640) -@Composable -fun PreviewConnectingScreen() { - ThemeComparisonColumn { - PreviewCallInProgress("Connecting...") - } -} +// ---- 2. Offering group call ---- @Preview(showBackground = true, widthDp = 360, heightDp = 640) @Composable -fun PreviewCallEndedScreen() { - ThemeComparisonColumn { - PreviewCallInProgress("Call ended") - } -} - -@Preview(showBackground = true, widthDp = 360, heightDp = 640) -@Composable -fun PreviewIncomingCallScreen() { +fun PreviewCallingGroupScreen() { ThemeComparisonColumn { Box( modifier = @@ -145,53 +249,64 @@ fun PreviewIncomingCallScreen() { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Icon( - Icons.Default.Person, - contentDescription = null, - modifier = Modifier.size(120.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + PreviewGroupAvatars(3) Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Bob", - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - ) + Text("Alice, Bob +1", fontWeight = FontWeight.Bold, fontSize = 20.sp) Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Incoming voice call...", - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 16.sp, - ) + Text("Calling\u2026", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 16.sp) Spacer(modifier = Modifier.height(48.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(48.dp), + FloatingActionButton( + onClick = {}, + containerColor = Color.Red, + shape = CircleShape, + modifier = Modifier.size(64.dp), ) { - FloatingActionButton( - onClick = {}, - containerColor = Color.Red, - shape = CircleShape, - modifier = Modifier.size(64.dp), - ) { - Icon( - Icons.Default.CallEnd, - contentDescription = "Reject", - tint = Color.White, - modifier = Modifier.size(32.dp), - ) + Icon(Icons.Default.CallEnd, "Hang up", tint = Color.White, modifier = Modifier.size(32.dp)) + } + } + } + } +} + +// ---- 3. Connecting ---- + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewConnectingScreen() { + ThemeComparisonColumn { + PreviewCallInProgress("Alice", "Connecting\u2026") + } +} + +// ---- 4. Incoming voice call ---- + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewIncomingVoiceCallScreen() { + ThemeComparisonColumn { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + PreviewAvatar() + Spacer(modifier = Modifier.height(16.dp)) + Text("Bob", fontWeight = FontWeight.Bold, fontSize = 20.sp) + Spacer(modifier = Modifier.height(8.dp)) + Text("Incoming voice call\u2026", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 16.sp) + Spacer(modifier = Modifier.height(48.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) { + FloatingActionButton(onClick = {}, containerColor = Color.Red, shape = CircleShape, modifier = Modifier.size(64.dp)) { + Icon(Icons.Default.CallEnd, "Reject", tint = Color.White, modifier = Modifier.size(32.dp)) } - FloatingActionButton( - onClick = {}, - containerColor = Color(0xFF4CAF50), - shape = CircleShape, - modifier = Modifier.size(64.dp), - ) { - Icon( - Icons.Default.Call, - contentDescription = "Accept", - tint = Color.White, - modifier = Modifier.size(32.dp), - ) + FloatingActionButton(onClick = {}, containerColor = Color(0xFF4CAF50), shape = CircleShape, modifier = Modifier.size(64.dp)) { + Icon(Icons.Default.Call, "Accept", tint = Color.White, modifier = Modifier.size(32.dp)) } } } @@ -199,6 +314,80 @@ fun PreviewIncomingCallScreen() { } } +// ---- 5. Incoming video call ---- + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewIncomingVideoCallScreen() { + ThemeComparisonColumn { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + PreviewAvatar() + Spacer(modifier = Modifier.height(16.dp)) + Text("Bob", fontWeight = FontWeight.Bold, fontSize = 20.sp) + Spacer(modifier = Modifier.height(8.dp)) + Text("Incoming video call\u2026", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 16.sp) + Spacer(modifier = Modifier.height(48.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) { + FloatingActionButton(onClick = {}, containerColor = Color.Red, shape = CircleShape, modifier = Modifier.size(64.dp)) { + Icon(Icons.Default.CallEnd, "Reject", tint = Color.White, modifier = Modifier.size(32.dp)) + } + FloatingActionButton(onClick = {}, containerColor = Color(0xFF4CAF50), shape = CircleShape, modifier = Modifier.size(64.dp)) { + Icon(Icons.Default.Call, "Accept", tint = Color.White, modifier = Modifier.size(32.dp)) + } + } + } + } + } +} + +// ---- 6. Incoming group call ---- + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewIncomingGroupCallScreen() { + ThemeComparisonColumn { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + PreviewGroupAvatars(4) + Spacer(modifier = Modifier.height(16.dp)) + Text("Bob, Alice +2", fontWeight = FontWeight.Bold, fontSize = 20.sp) + Spacer(modifier = Modifier.height(8.dp)) + Text("Incoming voice call\u2026", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 16.sp) + Spacer(modifier = Modifier.height(48.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) { + FloatingActionButton(onClick = {}, containerColor = Color.Red, shape = CircleShape, modifier = Modifier.size(64.dp)) { + Icon(Icons.Default.CallEnd, "Reject", tint = Color.White, modifier = Modifier.size(32.dp)) + } + FloatingActionButton(onClick = {}, containerColor = Color(0xFF4CAF50), shape = CircleShape, modifier = Modifier.size(64.dp)) { + Icon(Icons.Default.Call, "Accept", tint = Color.White, modifier = Modifier.size(32.dp)) + } + } + } + } + } +} + +// ---- 7. Connected P2P voice call ---- + @Preview(showBackground = true, widthDp = 360, heightDp = 640) @Composable fun PreviewConnectedCallScreen() { @@ -214,91 +403,25 @@ fun PreviewConnectedCallScreen() { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Icon( - Icons.Default.Person, - contentDescription = null, - modifier = Modifier.size(120.dp), - tint = Color.White.copy(alpha = 0.5f), - ) + PreviewAvatar(tint = Color.White.copy(alpha = 0.5f)) Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "Alice", - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - color = Color.White, - ) + Text("Alice", fontWeight = FontWeight.Bold, fontSize = 20.sp, color = Color.White) Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "02:45", - color = Color.White.copy(alpha = 0.7f), - fontSize = 16.sp, - ) + Text("02:45", color = Color.White.copy(alpha = 0.7f), fontSize = 16.sp) } Column( - modifier = - Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 48.dp), + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 48.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - IconButton( - onClick = {}, - modifier = Modifier.size(56.dp), - ) { - Icon( - imageVector = Icons.Default.Mic, - contentDescription = "Mute", - tint = Color.White, - modifier = Modifier.size(28.dp), - ) - } - IconButton( - onClick = {}, - modifier = Modifier.size(56.dp), - ) { - Icon( - imageVector = Icons.Default.VideocamOff, - contentDescription = "Camera off", - tint = Color.Red, - modifier = Modifier.size(28.dp), - ) - } - IconButton( - onClick = {}, - modifier = Modifier.size(56.dp), - ) { - Icon( - imageVector = Icons.Default.VolumeOff, - contentDescription = "Speaker", - tint = Color.White, - modifier = Modifier.size(28.dp), - ) - } - } - Spacer(modifier = Modifier.height(24.dp)) - FloatingActionButton( - onClick = {}, - containerColor = Color.Red, - shape = CircleShape, - modifier = Modifier.size(64.dp), - ) { - Icon( - Icons.Default.CallEnd, - contentDescription = "Hang up", - tint = Color.White, - modifier = Modifier.size(32.dp), - ) - } + PreviewCallControls(showAddParticipant = true) } } } } +// ---- 8. Connected P2P muted + video + speaker ---- + @Preview(showBackground = true, widthDp = 360, heightDp = 640) @Composable fun PreviewConnectedCallMuted() { @@ -314,12 +437,7 @@ fun PreviewConnectedCallMuted() { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Icon( - Icons.Default.Person, - contentDescription = null, - modifier = Modifier.size(120.dp), - tint = Color.White.copy(alpha = 0.5f), - ) + PreviewAvatar(tint = Color.White.copy(alpha = 0.5f)) Spacer(modifier = Modifier.height(16.dp)) Text("Alice", fontWeight = FontWeight.Bold, fontSize = 20.sp, color = Color.White) Spacer(modifier = Modifier.height(8.dp)) @@ -327,36 +445,176 @@ fun PreviewConnectedCallMuted() { } Column( - modifier = - Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 48.dp), + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 48.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { - Icon(Icons.Default.MicOff, "Unmute", tint = Color.Red, modifier = Modifier.size(28.dp)) - } - IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { - Icon(Icons.Default.Videocam, "Camera on", tint = Color.White, modifier = Modifier.size(28.dp)) - } - IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { - Icon(Icons.Default.VolumeUp, "Earpiece", tint = Color.Cyan, modifier = Modifier.size(28.dp)) - } - } - Spacer(modifier = Modifier.height(24.dp)) - FloatingActionButton( - onClick = {}, - containerColor = Color.Red, - shape = CircleShape, - modifier = Modifier.size(64.dp), - ) { - Icon(Icons.Default.CallEnd, "Hang up", tint = Color.White, modifier = Modifier.size(32.dp)) - } + PreviewCallControls( + isMuted = true, + isVideoEnabled = true, + audioRoute = "speaker", + showAddParticipant = true, + ) } } } } + +// ---- 9. Connected group call with pending peers ---- + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewConnectedGroupCallWithPending() { + ThemeComparisonColumn { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + PreviewGroupAvatars(3, tint = Color.White.copy(alpha = 0.5f)) + Spacer(modifier = Modifier.height(16.dp)) + Text("Alice, Bob +1", fontWeight = FontWeight.Bold, fontSize = 20.sp, color = Color.White) + Spacer(modifier = Modifier.height(8.dp)) + Text("01:30", color = Color.White.copy(alpha = 0.7f), fontSize = 16.sp) + Spacer(modifier = Modifier.height(4.dp)) + Text("Waiting for others to join\u2026", color = Color.White.copy(alpha = 0.5f), fontSize = 13.sp) + } + + Column( + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PreviewCallControls(showAddParticipant = true) + } + } + } +} + +// ---- 10. Connected with Bluetooth audio ---- + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewConnectedCallBluetooth() { + ThemeComparisonColumn { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + PreviewAvatar(tint = Color.White.copy(alpha = 0.5f)) + Spacer(modifier = Modifier.height(16.dp)) + Text("Alice", fontWeight = FontWeight.Bold, fontSize = 20.sp, color = Color.White) + Spacer(modifier = Modifier.height(8.dp)) + Text("10:03", color = Color.White.copy(alpha = 0.7f), fontSize = 16.sp) + } + + Column( + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PreviewCallControls(audioRoute = "bluetooth", showAddParticipant = true) + } + } + } +} + +// ---- 11. Call ended ---- + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewCallEndedScreen() { + ThemeComparisonColumn { + PreviewCallInProgress("Alice", "Call ended") + } +} + +// ---- 12. Group call with 5+ members (shows +N badge) ---- + +@Preview(showBackground = true, widthDp = 360, heightDp = 640) +@Composable +fun PreviewGroupCallLargeGroup() { + ThemeComparisonColumn { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + PreviewGroupAvatars(6, tint = Color.White.copy(alpha = 0.5f)) + Spacer(modifier = Modifier.height(16.dp)) + Text("Alice, Bob +4", fontWeight = FontWeight.Bold, fontSize = 20.sp, color = Color.White) + Spacer(modifier = Modifier.height(8.dp)) + Text("03:21", color = Color.White.copy(alpha = 0.7f), fontSize = 16.sp) + } + + Column( + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + PreviewCallControls(showAddParticipant = true) + } + } + } +} + +// ---- 13. PiP mode (small) ---- + +@Preview(showBackground = true, widthDp = 200, heightDp = 120) +@Composable +fun PreviewPipCallUI() { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon(Icons.Default.Person, contentDescription = null, modifier = Modifier.size(48.dp)) + Spacer(modifier = Modifier.height(4.dp)) + Text("Calling\u2026", fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +// ---- 14. PiP connected (small) ---- + +@Preview(showBackground = true, widthDp = 200, heightDp = 120) +@Composable +fun PreviewPipConnectedCallUI() { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon(Icons.Default.Person, contentDescription = null, modifier = Modifier.size(48.dp), tint = Color.White.copy(alpha = 0.5f)) + Spacer(modifier = Modifier.height(4.dp)) + Text("02:45", fontSize = 10.sp, color = Color.White.copy(alpha = 0.7f)) + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b2c1dba13..fe8bc0049 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -796,6 +796,9 @@ Bluetooth Voice call Video call + Waiting for others to join\u2026 + Add participant + Search users\u2026 Failed to start call Failed to accept call Failed to create call session diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 1d879d65d..724cab6a7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -155,15 +155,37 @@ class CallManager( fun onCallAnswered(event: CallAnswerEvent) { val current = _state.value val callId = event.callId() + val answeringPeer = event.pubKey when (current) { is CallState.Offering -> { if (callId != current.callId) return - _state.value = CallState.Connecting(current.callId, current.peerPubKeys, current.callType) + // First answer: start the call immediately. Remaining peers stay pending. + val pending = current.peerPubKeys - answeringPeer + _state.value = + CallState.Connecting( + current.callId, + setOf(answeringPeer), + current.callType, + pendingPeerPubKeys = pending, + ) cancelTimeout() onAnswerReceived?.invoke(event) } + is CallState.Connecting -> { + // Another peer answered while we're still connecting with the first + if (callId != current.callId) return + if (answeringPeer in current.pendingPeerPubKeys) { + _state.value = + current.copy( + peerPubKeys = current.peerPubKeys + answeringPeer, + pendingPeerPubKeys = current.pendingPeerPubKeys - answeringPeer, + ) + } + // TODO: establish additional WebRTC peer connection for this peer + } + is CallState.IncomingCall -> { // Another device of this user answered the call — stop ringing. if (callId != current.callId) return @@ -171,9 +193,19 @@ class CallManager( } is CallState.Connected -> { - // Renegotiation answer (e.g., peer accepted our video upgrade offer) if (callId != current.callId) return - onAnswerReceived?.invoke(event) + if (answeringPeer in current.pendingPeerPubKeys) { + // A pending peer just joined the group call + _state.value = + current.copy( + peerPubKeys = current.peerPubKeys + answeringPeer, + pendingPeerPubKeys = current.pendingPeerPubKeys - answeringPeer, + ) + // TODO: establish additional WebRTC peer connection for this peer + } else { + // Renegotiation answer (e.g., peer accepted our video upgrade offer) + onAnswerReceived?.invoke(event) + } } else -> { @@ -185,11 +217,31 @@ class CallManager( fun onCallRejected(event: CallRejectEvent) { val current = _state.value val callId = event.callId() + val rejectingPeer = event.pubKey when (current) { is CallState.Offering -> { if (callId != current.callId) return - transitionToEnded(current.callId, current.peerPubKeys, EndReason.PEER_REJECTED) + val remaining = current.peerPubKeys - rejectingPeer + if (remaining.isEmpty()) { + transitionToEnded(current.callId, current.peerPubKeys, EndReason.PEER_REJECTED) + } else { + _state.value = current.copy(peerPubKeys = remaining) + } + } + + is CallState.Connecting -> { + // A pending peer rejected while we're already connecting with another + if (callId != current.callId) return + _state.value = + current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys - rejectingPeer) + } + + is CallState.Connected -> { + // A pending peer rejected while we're already in the call + if (callId != current.callId) return + _state.value = + current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys - rejectingPeer) } is CallState.IncomingCall -> { @@ -245,9 +297,40 @@ class CallManager( peerPubKeys = current.peerPubKeys, callType = current.callType, startedAtEpoch = TimeUtils.now(), + pendingPeerPubKeys = current.pendingPeerPubKeys, ) } + /** Invites a new peer into the current call by sending them an offer. */ + suspend fun invitePeer( + peerPubKey: HexKey, + sdpOffer: String, + ) { + val current = _state.value + val callId: String + val callType: CallType + when (current) { + is CallState.Connecting -> { + callId = current.callId + callType = current.callType + _state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys + peerPubKey) + } + + is CallState.Connected -> { + callId = current.callId + callType = current.callType + _state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys + peerPubKey) + } + + else -> { + return + } + } + + val result = factory.createCallOffer(sdpOffer, peerPubKey, callId, callType, signer) + publishEvent(result.wrap) + } + suspend fun hangup() { val peerPubKeys: Set val callId: String @@ -285,18 +368,67 @@ class CallManager( fun onPeerHangup(event: CallHangupEvent) { val current = _state.value val callId = event.callId() ?: return - val currentCallId = - when (current) { - is CallState.Offering -> current.callId - is CallState.Connecting -> current.callId - is CallState.Connected -> current.callId - is CallState.IncomingCall -> current.callId - else -> return - } - if (callId != currentCallId) return + val leavingPeer = event.pubKey - val peerPubKeys = currentPeerPubKeys() ?: return - transitionToEnded(callId, peerPubKeys, EndReason.PEER_HANGUP) + when (current) { + is CallState.Connected -> { + if (callId != current.callId) return + val connectedRemaining = current.peerPubKeys - leavingPeer + val pendingRemaining = current.pendingPeerPubKeys - leavingPeer + if (connectedRemaining.isEmpty() && pendingRemaining.isEmpty()) { + transitionToEnded(callId, current.allPeerPubKeys, EndReason.PEER_HANGUP) + } else { + _state.value = + current.copy( + peerPubKeys = connectedRemaining, + pendingPeerPubKeys = pendingRemaining, + ) + } + } + + is CallState.Connecting -> { + if (callId != current.callId) return + val connectedRemaining = current.peerPubKeys - leavingPeer + val pendingRemaining = current.pendingPeerPubKeys - leavingPeer + if (connectedRemaining.isEmpty() && pendingRemaining.isEmpty()) { + transitionToEnded(callId, current.peerPubKeys + current.pendingPeerPubKeys, EndReason.PEER_HANGUP) + } else { + _state.value = + current.copy( + peerPubKeys = connectedRemaining, + pendingPeerPubKeys = pendingRemaining, + ) + } + } + + is CallState.Offering -> { + if (callId != current.callId) return + val remaining = current.peerPubKeys - leavingPeer + if (remaining.isEmpty()) { + transitionToEnded(callId, current.peerPubKeys, EndReason.PEER_HANGUP) + } else { + _state.value = current.copy(peerPubKeys = remaining) + } + } + + is CallState.IncomingCall -> { + if (callId != current.callId) return + if (leavingPeer == current.callerPubKey) { + transitionToEnded(callId, current.groupMembers, EndReason.PEER_HANGUP) + } else { + val remaining = current.groupMembers - leavingPeer + if (remaining.size <= 1) { + transitionToEnded(callId, current.groupMembers, EndReason.PEER_HANGUP) + } else { + _state.value = current.copy(groupMembers = remaining) + } + } + } + + else -> { + return + } + } } fun onSignalingEvent(event: Event) { @@ -330,13 +462,13 @@ class CallManager( /** Returns the first peer pubkey (for P2P calls) or null. */ fun currentPeerPubKey(): HexKey? = currentPeerPubKeys()?.firstOrNull() - /** Returns all peer pubkeys for the current call. */ + /** Returns all peer pubkeys for the current call (connected + pending). */ fun currentPeerPubKeys(): Set? = when (val s = _state.value) { is CallState.Offering -> s.peerPubKeys is CallState.IncomingCall -> s.peerPubKeys() - is CallState.Connecting -> s.peerPubKeys - is CallState.Connected -> s.peerPubKeys + is CallState.Connecting -> s.peerPubKeys + s.pendingPeerPubKeys + is CallState.Connected -> s.allPeerPubKeys else -> null } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt index 940eefdaa..001ebcd87 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallState.kt @@ -46,6 +46,7 @@ sealed interface CallState { val callId: String, val peerPubKeys: Set, val callType: CallType, + val pendingPeerPubKeys: Set = emptySet(), ) : CallState data class Connected( @@ -53,7 +54,11 @@ sealed interface CallState { val peerPubKeys: Set, val callType: CallType, val startedAtEpoch: Long, - ) : CallState + val pendingPeerPubKeys: Set = emptySet(), + ) : CallState { + /** All peers: connected + still pending. */ + val allPeerPubKeys: Set get() = peerPubKeys + pendingPeerPubKeys + } data class Ended( val callId: String,