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