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>
<!-- 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.microphone" android:required="false" />
<!-- To connect with relays -->
<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_MEDIA_PLAYBACK" />
<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" />
<!-- Phone calls -->
<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" />
<!-- Keeps screen on while playing videos -->
@@ -243,7 +247,7 @@
<service
android:name=".service.call.CallForegroundService"
android:foregroundServiceType="microphone"
android:foregroundServiceType="microphone|camera"
android:stopWithTask="true"
android:exported="false" />
@@ -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> = _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<Boolean> = _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
@@ -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) {
@@ -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
}
@@ -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<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
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)
@@ -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<String>,
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<String>,
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<String>,
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<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
private fun KeepScreenOn() {
val context = LocalContext.current
@@ -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))
}
}
}
+3
View File
@@ -796,6 +796,9 @@
<string name="call_bluetooth">Bluetooth</string>
<string name="call_voice">Voice 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_accept">Failed to accept call</string>
<string name="call_failed_session">Failed to create call session</string>
@@ -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<HexKey>
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<HexKey>? =
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
}
@@ -46,6 +46,7 @@ sealed interface CallState {
val callId: String,
val peerPubKeys: Set<HexKey>,
val callType: CallType,
val pendingPeerPubKeys: Set<HexKey> = emptySet(),
) : CallState
data class Connected(
@@ -53,7 +54,11 @@ sealed interface CallState {
val peerPubKeys: Set<HexKey>,
val callType: CallType,
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(
val callId: String,