Merge pull request #2088 from vitorpamplona/claude/review-call-permissions-utwfD
Add group call support with participant management
This commit is contained in:
@@ -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) {
|
||||
|
||||
+14
-2
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+150
-18
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user