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