Merge pull request #2088 from vitorpamplona/claude/review-call-permissions-utwfD

Add group call support with participant management
This commit is contained in:
Vitor Pamplona
2026-04-02 22:14:35 -04:00
committed by GitHub
10 changed files with 1010 additions and 301 deletions
+6 -2
View File
@@ -14,8 +14,9 @@
</queries> </queries>
<!-- Doesn't require a camera --> <!-- Doesn't require a camera or microphone -->
<uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<!-- To connect with relays --> <!-- To connect with relays -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
@@ -40,10 +41,13 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" /> <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- Phone calls --> <!-- Phone calls -->
<uses-permission android:name="android.permission.VIBRATE"/> <uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Keeps screen on while playing videos --> <!-- Keeps screen on while playing videos -->
@@ -243,7 +247,7 @@
<service <service
android:name=".service.call.CallForegroundService" android:name=".service.call.CallForegroundService"
android:foregroundServiceType="microphone" android:foregroundServiceType="microphone|camera"
android:stopWithTask="true" android:stopWithTask="true"
android:exported="false" /> android:exported="false" />
@@ -20,10 +20,12 @@
*/ */
package com.vitorpamplona.amethyst.service.call package com.vitorpamplona.amethyst.service.call
import android.Manifest
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager
import android.hardware.Sensor import android.hardware.Sensor
import android.hardware.SensorEvent import android.hardware.SensorEvent
import android.hardware.SensorEventListener import android.hardware.SensorEventListener
@@ -39,6 +41,7 @@ import android.os.PowerManager
import android.os.VibrationEffect import android.os.VibrationEffect
import android.os.Vibrator import android.os.Vibrator
import android.os.VibratorManager import android.os.VibratorManager
import androidx.core.content.ContextCompat
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -63,6 +66,14 @@ class CallAudioManager(
private val _audioRoute = MutableStateFlow(AudioRoute.EARPIECE) private val _audioRoute = MutableStateFlow(AudioRoute.EARPIECE)
val audioRoute: StateFlow<AudioRoute> = _audioRoute.asStateFlow() val audioRoute: StateFlow<AudioRoute> = _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) private val _isBluetoothAvailable = MutableStateFlow(false)
val isBluetoothAvailable: StateFlow<Boolean> = _isBluetoothAvailable.asStateFlow() val isBluetoothAvailable: StateFlow<Boolean> = _isBluetoothAvailable.asStateFlow()
@@ -115,6 +126,7 @@ class CallAudioManager(
previousAudioMode = audioManager.mode previousAudioMode = audioManager.mode
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
if (hasBluetoothPermission()) {
_isBluetoothAvailable.value = hasBluetoothDevice() _isBluetoothAvailable.value = hasBluetoothDevice()
if (_isBluetoothAvailable.value) { if (_isBluetoothAvailable.value) {
startBluetoothSco() startBluetoothSco()
@@ -122,6 +134,10 @@ class CallAudioManager(
routeToEarpiece() routeToEarpiece()
} }
registerBluetoothScoReceiver() registerBluetoothScoReceiver()
} else {
_isBluetoothAvailable.value = false
routeToEarpiece()
}
} }
fun restoreAudioMode() { fun restoreAudioMode() {
@@ -220,6 +236,7 @@ class CallAudioManager(
} }
private fun routeToBluetooth() { private fun routeToBluetooth() {
if (!hasBluetoothPermission()) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val btDevice = val btDevice =
audioManager audioManager
@@ -117,7 +117,7 @@ class CallController(
callManager.state.collect { state -> callManager.state.collect { state ->
when (state) { when (state) {
is CallState.IncomingCall -> { is CallState.IncomingCall -> {
audioManager.startRinging() withContext(Dispatchers.IO) { audioManager.startRinging() }
showIncomingCallNotification(state.callerPubKey) showIncomingCallNotification(state.callerPubKey)
} }
@@ -128,7 +128,7 @@ class CallController(
is CallState.Connecting -> { is CallState.Connecting -> {
audioManager.stopRinging() audioManager.stopRinging()
audioManager.stopRingbackTone() audioManager.stopRingbackTone()
audioManager.switchToCallAudioMode() withContext(Dispatchers.IO) { audioManager.switchToCallAudioMode() }
audioManager.acquireProximityWakeLock() audioManager.acquireProximityWakeLock()
NotificationUtils.cancelCallNotification(context) NotificationUtils.cancelCallNotification(context)
} }
@@ -337,6 +337,17 @@ class CallController(
fun getEglBase() = webRtcSession?.eglBase 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() { fun hangup() {
scope.launch { scope.launch {
callManager.hangup() callManager.hangup()
@@ -461,10 +472,12 @@ class CallController(
private fun startForegroundService() { private fun startForegroundService() {
try { try {
val peerName = callManager.currentPeerPubKey() ?: "" val peerName = callManager.currentPeerPubKey() ?: ""
val isVideo = _isVideoEnabled.value
val intent = val intent =
Intent(context, CallForegroundService::class.java).apply { Intent(context, CallForegroundService::class.java).apply {
action = CallForegroundService.ACTION_START action = CallForegroundService.ACTION_START
putExtra(CallForegroundService.EXTRA_PEER_NAME, peerName) putExtra(CallForegroundService.EXTRA_PEER_NAME, peerName)
putExtra(CallForegroundService.EXTRA_IS_VIDEO, isVideo)
} }
context.startForegroundService(intent) context.startForegroundService(intent)
} catch (e: Exception) { } catch (e: Exception) {
@@ -45,6 +45,7 @@ class CallForegroundService : Service() {
const val ACTION_START = "com.vitorpamplona.amethyst.CALL_START" const val ACTION_START = "com.vitorpamplona.amethyst.CALL_START"
const val ACTION_STOP = "com.vitorpamplona.amethyst.CALL_STOP" const val ACTION_STOP = "com.vitorpamplona.amethyst.CALL_STOP"
const val EXTRA_PEER_NAME = "peer_name" const val EXTRA_PEER_NAME = "peer_name"
const val EXTRA_IS_VIDEO = "is_video"
} }
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
@@ -62,14 +63,25 @@ class CallForegroundService : Service() {
when (intent?.action) { when (intent?.action) {
ACTION_START -> { ACTION_START -> {
val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown" val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown"
val isVideo = intent.getBooleanExtra(EXTRA_IS_VIDEO, false)
val notification = buildNotification(peerName) val notification = buildNotification(peerName)
val hasAudioPermission = val hasAudioPermission =
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED
val hasCameraPermission =
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
try { try {
val fgsType = val fgsType =
if (hasAudioPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE 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 { } else {
0 0
} }
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.call
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -43,30 +44,42 @@ fun hasCallPermissions(
return true return true
} }
fun buildCallPermissions(isVideo: Boolean): Array<String> {
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 @Composable
fun rememberCallWithPermission( fun rememberCallWithPermission(
context: Context, context: Context,
isVideo: Boolean = false, isVideo: Boolean = false,
onCall: () -> Unit, onCall: () -> Unit,
): () -> Unit { ): () -> Unit {
val permissions = val permissions = remember(isVideo) { buildCallPermissions(isVideo) }
if (isVideo) {
arrayOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
} else {
arrayOf(Manifest.permission.RECORD_AUDIO)
}
val launcher = val launcher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(), ActivityResultContracts.RequestMultiplePermissions(),
) { results -> ) { results ->
val allGranted = results.values.all { it } // Bluetooth is optional — proceed if core permissions are granted
if (allGranted) onCall() if (hasCallPermissions(context, isVideo)) onCall()
} }
return remember(onCall, isVideo) { return remember(onCall, isVideo) {
{ {
if (hasCallPermissions(context, 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() onCall()
} else { } else {
launcher.launch(permissions) launcher.launch(permissions)
@@ -44,22 +44,27 @@ import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CallEnd import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff 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.Videocam
import androidx.compose.material.icons.filled.VideocamOff import androidx.compose.material.icons.filled.VideocamOff
import androidx.compose.material.icons.filled.VolumeOff import androidx.compose.material.icons.filled.VolumeOff
import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Snackbar import androidx.compose.material3.Snackbar
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -68,6 +73,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView 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.commons.call.CallState
import com.vitorpamplona.amethyst.service.call.AudioRoute import com.vitorpamplona.amethyst.service.call.AudioRoute
import com.vitorpamplona.amethyst.service.call.CallController 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.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay 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.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -123,10 +133,10 @@ fun CallScreen(
is CallState.Offering -> { is CallState.Offering -> {
if (isInPipMode) { 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 { } else {
CallInProgressUI( CallInProgressUI(
peerPubKey = state.peerPubKeys.first(), peerPubKeys = state.peerPubKeys,
statusText = stringRes(R.string.call_calling), statusText = stringRes(R.string.call_calling),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } }, onHangup = { scope.launch { callManager.hangup() } },
@@ -136,7 +146,7 @@ fun CallScreen(
is CallState.IncomingCall -> { is CallState.IncomingCall -> {
if (isInPipMode) { 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 { } else {
val isVideoCall = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO val isVideoCall = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO
val acceptWithPermission = val acceptWithPermission =
@@ -144,7 +154,7 @@ fun CallScreen(
callController?.acceptIncomingCall(state.sdpOffer) callController?.acceptIncomingCall(state.sdpOffer)
} }
IncomingCallUI( IncomingCallUI(
callerPubKey = state.callerPubKey, groupMembers = state.groupMembers,
callType = state.callType, callType = state.callType,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onAccept = acceptWithPermission, onAccept = acceptWithPermission,
@@ -155,10 +165,10 @@ fun CallScreen(
is CallState.Connecting -> { is CallState.Connecting -> {
if (isInPipMode) { 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 { } else {
CallInProgressUI( CallInProgressUI(
peerPubKey = state.peerPubKeys.first(), peerPubKeys = state.peerPubKeys,
statusText = stringRes(R.string.call_connecting), statusText = stringRes(R.string.call_connecting),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } }, onHangup = { scope.launch { callManager.hangup() } },
@@ -178,6 +188,7 @@ fun CallScreen(
onToggleMute = { callController?.toggleAudioMute() }, onToggleMute = { callController?.toggleAudioMute() },
onToggleVideo = { callController?.toggleVideo() }, onToggleVideo = { callController?.toggleVideo() },
onCycleAudioRoute = { callController?.cycleAudioRoute() }, onCycleAudioRoute = { callController?.cycleAudioRoute() },
onInvitePeer = { peerPubKey -> callController?.invitePeer(peerPubKey) },
) )
} }
} }
@@ -185,7 +196,7 @@ fun CallScreen(
is CallState.Ended -> { is CallState.Ended -> {
if (!isInPipMode) { if (!isInPipMode) {
CallInProgressUI( CallInProgressUI(
peerPubKey = state.peerPubKeys.first(), peerPubKeys = state.peerPubKeys,
statusText = stringRes(R.string.call_ended), statusText = stringRes(R.string.call_ended),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onHangup = { onCallEnded() }, onHangup = { onCallEnded() },
@@ -220,7 +231,7 @@ fun CallScreen(
@Composable @Composable
private fun CallInProgressUI( private fun CallInProgressUI(
peerPubKey: String, peerPubKeys: Set<String>,
statusText: String, statusText: String,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onHangup: () -> Unit, onHangup: () -> Unit,
@@ -238,21 +249,16 @@ private fun CallInProgressUI(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
LoadUser(baseUserHex = peerPubKey, accountViewModel = accountViewModel) { user -> GroupCallPictures(
if (user != null) { peerPubKeys = peerPubKeys,
ClickableUserPicture(
baseUser = user,
size = 120.dp, size = 120.dp,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
UsernameDisplay( GroupCallNames(
baseUser = user, peerPubKeys = peerPubKeys,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
fontWeight = FontWeight.Bold,
) )
}
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = statusText, text = statusText,
@@ -279,7 +285,7 @@ private fun CallInProgressUI(
@Composable @Composable
private fun IncomingCallUI( private fun IncomingCallUI(
callerPubKey: String, groupMembers: Set<String>,
callType: com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType, callType: com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onAccept: () -> Unit, onAccept: () -> Unit,
@@ -298,21 +304,16 @@ private fun IncomingCallUI(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
LoadUser(baseUserHex = callerPubKey, accountViewModel = accountViewModel) { user -> GroupCallPictures(
if (user != null) { peerPubKeys = groupMembers,
ClickableUserPicture(
baseUser = user,
size = 120.dp, size = 120.dp,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
UsernameDisplay( GroupCallNames(
baseUser = user, peerPubKeys = groupMembers,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
fontWeight = FontWeight.Bold,
) )
}
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = text =
@@ -370,6 +371,7 @@ private fun ConnectedCallUI(
onToggleMute: () -> Unit, onToggleMute: () -> Unit,
onToggleVideo: () -> Unit, onToggleVideo: () -> Unit,
onCycleAudioRoute: () -> Unit, onCycleAudioRoute: () -> Unit,
onInvitePeer: (String) -> Unit = {},
) { ) {
var elapsed by remember { mutableLongStateOf(0L) } var elapsed by remember { mutableLongStateOf(0L) }
@@ -391,6 +393,20 @@ private fun ConnectedCallUI(
val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState() val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState()
val currentAudioRoute by (callController?.audioRoute ?: defaultRoute).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( Box(
modifier = modifier =
Modifier Modifier
@@ -433,28 +449,31 @@ private fun ConnectedCallUI(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
LoadUser(baseUserHex = state.peerPubKeys.first(), accountViewModel = accountViewModel) { user -> GroupCallPictures(
if (user != null) { peerPubKeys = state.allPeerPubKeys,
ClickableUserPicture(
baseUser = user,
size = 120.dp, size = 120.dp,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
UsernameDisplay( GroupCallNames(
baseUser = user, peerPubKeys = state.allPeerPubKeys,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
fontWeight = FontWeight.Bold,
textColor = Color.White, textColor = Color.White,
) )
}
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = formatDuration(elapsed), text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f), color = Color.White.copy(alpha = 0.7f),
fontSize = 16.sp, 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 { } else {
// Timer overlay // Timer overlay
@@ -533,6 +552,17 @@ private fun ConnectedCallUI(
modifier = Modifier.size(28.dp), 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)) Spacer(modifier = Modifier.height(24.dp))
FloatingActionButton( FloatingActionButton(
@@ -584,12 +614,10 @@ private fun formatDuration(seconds: Long): String {
@Composable @Composable
private fun PipCallUI( private fun PipCallUI(
peerPubKey: String = "", peerPubKeys: Set<String>,
callerPubKey: String = "",
statusText: String, statusText: String,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
val pubKey = peerPubKey.ifEmpty { callerPubKey }
Box( Box(
modifier = modifier =
Modifier Modifier
@@ -601,15 +629,11 @@ private fun PipCallUI(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
LoadUser(baseUserHex = pubKey, accountViewModel = accountViewModel) { user -> GroupCallPictures(
if (user != null) { peerPubKeys = peerPubKeys,
ClickableUserPicture(
baseUser = user,
size = 48.dp, size = 48.dp,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
}
}
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = statusText, text = statusText,
@@ -665,15 +689,11 @@ private fun PipConnectedCallUI(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
LoadUser(baseUserHex = state.peerPubKeys.first(), accountViewModel = accountViewModel) { user -> GroupCallPictures(
if (user != null) { peerPubKeys = state.allPeerPubKeys,
ClickableUserPicture(
baseUser = user,
size = 48.dp, size = 48.dp,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) )
}
}
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = formatDuration(elapsed), text = formatDuration(elapsed),
@@ -696,6 +716,238 @@ private fun PipConnectedCallUI(
} }
} }
@Composable
private fun AddParticipantDialog(
accountViewModel: AccountViewModel,
existingPeers: Set<String>,
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<String>,
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<String>,
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 @Composable
private fun KeepScreenOn() { private fun KeepScreenOn() {
val context = LocalContext.current val context = LocalContext.current
@@ -33,11 +33,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons 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.Call
import androidx.compose.material.icons.filled.CallEnd import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.Person 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.Videocam
import androidx.compose.material.icons.filled.VideocamOff import androidx.compose.material.icons.filled.VideocamOff
import androidx.compose.material.icons.filled.VolumeOff import androidx.compose.material.icons.filled.VolumeOff
@@ -57,8 +59,142 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
// ---- Shared building blocks for previews ----
@Composable @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( Box(
modifier = modifier =
Modifier Modifier
@@ -70,24 +206,11 @@ private fun PreviewCallInProgress(statusText: String) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Icon( PreviewAvatar()
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(120.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(text = name, fontWeight = FontWeight.Bold, fontSize = 20.sp)
text = "Alice",
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(text = statusText, color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 16.sp)
text = statusText,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp,
)
Spacer(modifier = Modifier.height(48.dp)) Spacer(modifier = Modifier.height(48.dp))
FloatingActionButton( FloatingActionButton(
onClick = {}, onClick = {},
@@ -95,12 +218,7 @@ private fun PreviewCallInProgress(statusText: String) {
shape = CircleShape, shape = CircleShape,
modifier = Modifier.size(64.dp), modifier = Modifier.size(64.dp),
) { ) {
Icon( Icon(Icons.Default.CallEnd, "Hang up", tint = Color.White, modifier = Modifier.size(32.dp))
Icons.Default.CallEnd,
contentDescription = "Hang up",
tint = Color.White,
modifier = Modifier.size(32.dp),
)
} }
} }
} }
@@ -110,29 +228,15 @@ private fun PreviewCallInProgress(statusText: String) {
@Composable @Composable
fun PreviewCallingScreen() { fun PreviewCallingScreen() {
ThemeComparisonColumn { ThemeComparisonColumn {
PreviewCallInProgress("Calling...") PreviewCallInProgress("Alice", "Calling\u2026")
} }
} }
@Preview(showBackground = true, widthDp = 360, heightDp = 640) // ---- 2. Offering group call ----
@Composable
fun PreviewConnectingScreen() {
ThemeComparisonColumn {
PreviewCallInProgress("Connecting...")
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 640) @Preview(showBackground = true, widthDp = 360, heightDp = 640)
@Composable @Composable
fun PreviewCallEndedScreen() { fun PreviewCallingGroupScreen() {
ThemeComparisonColumn {
PreviewCallInProgress("Call ended")
}
}
@Preview(showBackground = true, widthDp = 360, heightDp = 640)
@Composable
fun PreviewIncomingCallScreen() {
ThemeComparisonColumn { ThemeComparisonColumn {
Box( Box(
modifier = modifier =
@@ -145,53 +249,64 @@ fun PreviewIncomingCallScreen() {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Icon( PreviewGroupAvatars(3)
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(120.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text("Alice, Bob +1", fontWeight = FontWeight.Bold, fontSize = 20.sp)
text = "Bob",
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text("Calling\u2026", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 16.sp)
text = "Incoming voice call...",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 16.sp,
)
Spacer(modifier = Modifier.height(48.dp)) Spacer(modifier = Modifier.height(48.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(48.dp),
) {
FloatingActionButton( FloatingActionButton(
onClick = {}, onClick = {},
containerColor = Color.Red, containerColor = Color.Red,
shape = CircleShape, shape = CircleShape,
modifier = Modifier.size(64.dp), modifier = Modifier.size(64.dp),
) { ) {
Icon( Icon(Icons.Default.CallEnd, "Hang up", tint = Color.White, modifier = Modifier.size(32.dp))
Icons.Default.CallEnd,
contentDescription = "Reject",
tint = Color.White,
modifier = Modifier.size(32.dp),
)
} }
FloatingActionButton( }
onClick = {}, }
containerColor = Color(0xFF4CAF50), }
shape = CircleShape, }
modifier = Modifier.size(64.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,
) { ) {
Icon( Column(
Icons.Default.Call, horizontalAlignment = Alignment.CenterHorizontally,
contentDescription = "Accept", verticalArrangement = Arrangement.Center,
tint = Color.White, ) {
modifier = Modifier.size(32.dp), 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, "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) @Preview(showBackground = true, widthDp = 360, heightDp = 640)
@Composable @Composable
fun PreviewConnectedCallScreen() { fun PreviewConnectedCallScreen() {
@@ -214,91 +403,25 @@ fun PreviewConnectedCallScreen() {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Icon( PreviewAvatar(tint = Color.White.copy(alpha = 0.5f))
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(120.dp),
tint = Color.White.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.height(16.dp)) 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)) 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( Column(
modifier = modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 48.dp),
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Row( PreviewCallControls(showAddParticipant = true)
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),
)
}
} }
} }
} }
} }
// ---- 8. Connected P2P muted + video + speaker ----
@Preview(showBackground = true, widthDp = 360, heightDp = 640) @Preview(showBackground = true, widthDp = 360, heightDp = 640)
@Composable @Composable
fun PreviewConnectedCallMuted() { fun PreviewConnectedCallMuted() {
@@ -314,12 +437,7 @@ fun PreviewConnectedCallMuted() {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
Icon( PreviewAvatar(tint = Color.White.copy(alpha = 0.5f))
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(120.dp),
tint = Color.White.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
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)) Spacer(modifier = Modifier.height(8.dp))
@@ -327,36 +445,176 @@ fun PreviewConnectedCallMuted() {
} }
Column( Column(
modifier = modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 48.dp),
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Row( PreviewCallControls(
modifier = Modifier.fillMaxWidth(), isMuted = true,
horizontalArrangement = Arrangement.SpaceEvenly, 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),
) { ) {
IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { Column(
Icon(Icons.Default.MicOff, "Unmute", tint = Color.Red, modifier = Modifier.size(28.dp)) modifier = Modifier.fillMaxSize(),
} horizontalAlignment = Alignment.CenterHorizontally,
IconButton(onClick = {}, modifier = Modifier.size(56.dp)) { verticalArrangement = Arrangement.Center,
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)) 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))
}
}
} }
+3
View File
@@ -796,6 +796,9 @@
<string name="call_bluetooth">Bluetooth</string> <string name="call_bluetooth">Bluetooth</string>
<string name="call_voice">Voice call</string> <string name="call_voice">Voice call</string>
<string name="call_video">Video call</string> <string name="call_video">Video call</string>
<string name="call_waiting_for_others">Waiting for others to join\u2026</string>
<string name="call_add_participant">Add participant</string>
<string name="call_search_users">Search users\u2026</string>
<string name="call_failed_start">Failed to start call</string> <string name="call_failed_start">Failed to start call</string>
<string name="call_failed_accept">Failed to accept call</string> <string name="call_failed_accept">Failed to accept call</string>
<string name="call_failed_session">Failed to create call session</string> <string name="call_failed_session">Failed to create call session</string>
@@ -155,15 +155,37 @@ class CallManager(
fun onCallAnswered(event: CallAnswerEvent) { fun onCallAnswered(event: CallAnswerEvent) {
val current = _state.value val current = _state.value
val callId = event.callId() val callId = event.callId()
val answeringPeer = event.pubKey
when (current) { when (current) {
is CallState.Offering -> { is CallState.Offering -> {
if (callId != current.callId) return 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() cancelTimeout()
onAnswerReceived?.invoke(event) 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 -> { is CallState.IncomingCall -> {
// Another device of this user answered the call — stop ringing. // Another device of this user answered the call — stop ringing.
if (callId != current.callId) return if (callId != current.callId) return
@@ -171,10 +193,20 @@ class CallManager(
} }
is CallState.Connected -> { is CallState.Connected -> {
// Renegotiation answer (e.g., peer accepted our video upgrade offer)
if (callId != current.callId) return if (callId != current.callId) return
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) onAnswerReceived?.invoke(event)
} }
}
else -> { else -> {
return return
@@ -185,11 +217,31 @@ class CallManager(
fun onCallRejected(event: CallRejectEvent) { fun onCallRejected(event: CallRejectEvent) {
val current = _state.value val current = _state.value
val callId = event.callId() val callId = event.callId()
val rejectingPeer = event.pubKey
when (current) { when (current) {
is CallState.Offering -> { is CallState.Offering -> {
if (callId != current.callId) return if (callId != current.callId) return
val remaining = current.peerPubKeys - rejectingPeer
if (remaining.isEmpty()) {
transitionToEnded(current.callId, current.peerPubKeys, EndReason.PEER_REJECTED) 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 -> { is CallState.IncomingCall -> {
@@ -245,9 +297,40 @@ class CallManager(
peerPubKeys = current.peerPubKeys, peerPubKeys = current.peerPubKeys,
callType = current.callType, callType = current.callType,
startedAtEpoch = TimeUtils.now(), 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() { suspend fun hangup() {
val peerPubKeys: Set<HexKey> val peerPubKeys: Set<HexKey>
val callId: String val callId: String
@@ -285,18 +368,67 @@ class CallManager(
fun onPeerHangup(event: CallHangupEvent) { fun onPeerHangup(event: CallHangupEvent) {
val current = _state.value val current = _state.value
val callId = event.callId() ?: return val callId = event.callId() ?: return
val currentCallId = val leavingPeer = event.pubKey
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 peerPubKeys = currentPeerPubKeys() ?: return when (current) {
transitionToEnded(callId, peerPubKeys, EndReason.PEER_HANGUP) 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) { fun onSignalingEvent(event: Event) {
@@ -330,13 +462,13 @@ class CallManager(
/** Returns the first peer pubkey (for P2P calls) or null. */ /** Returns the first peer pubkey (for P2P calls) or null. */
fun currentPeerPubKey(): HexKey? = currentPeerPubKeys()?.firstOrNull() 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<HexKey>? = fun currentPeerPubKeys(): Set<HexKey>? =
when (val s = _state.value) { when (val s = _state.value) {
is CallState.Offering -> s.peerPubKeys is CallState.Offering -> s.peerPubKeys
is CallState.IncomingCall -> s.peerPubKeys() is CallState.IncomingCall -> s.peerPubKeys()
is CallState.Connecting -> s.peerPubKeys is CallState.Connecting -> s.peerPubKeys + s.pendingPeerPubKeys
is CallState.Connected -> s.peerPubKeys is CallState.Connected -> s.allPeerPubKeys
else -> null else -> null
} }
@@ -46,6 +46,7 @@ sealed interface CallState {
val callId: String, val callId: String,
val peerPubKeys: Set<HexKey>, val peerPubKeys: Set<HexKey>,
val callType: CallType, val callType: CallType,
val pendingPeerPubKeys: Set<HexKey> = emptySet(),
) : CallState ) : CallState
data class Connected( data class Connected(
@@ -53,7 +54,11 @@ sealed interface CallState {
val peerPubKeys: Set<HexKey>, val peerPubKeys: Set<HexKey>,
val callType: CallType, val callType: CallType,
val startedAtEpoch: Long, val startedAtEpoch: Long,
) : CallState val pendingPeerPubKeys: Set<HexKey> = emptySet(),
) : CallState {
/** All peers: connected + still pending. */
val allPeerPubKeys: Set<HexKey> get() = peerPubKeys + pendingPeerPubKeys
}
data class Ended( data class Ended(
val callId: String, val callId: String,