Реализованы звонки в диалоге и полный permission flow Android
This commit is contained in:
@@ -7,6 +7,11 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<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_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
@@ -32,6 +33,8 @@ import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.data.RecentSearchesManager
|
||||
import com.rosetta.messenger.data.resolveAccountDisplayName
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.CallActionResult
|
||||
import com.rosetta.messenger.network.CallManager
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
@@ -46,6 +49,7 @@ import com.rosetta.messenger.ui.chats.GroupInfoScreen
|
||||
import com.rosetta.messenger.ui.chats.GroupSetupScreen
|
||||
import com.rosetta.messenger.ui.chats.RequestsListScreen
|
||||
import com.rosetta.messenger.ui.chats.SearchScreen
|
||||
import com.rosetta.messenger.ui.chats.calls.CallOverlay
|
||||
import com.rosetta.messenger.ui.components.OptimizedEmojiCache
|
||||
import com.rosetta.messenger.ui.components.SwipeBackBackgroundEffect
|
||||
import com.rosetta.messenger.ui.components.SwipeBackContainer
|
||||
@@ -116,6 +120,7 @@ class MainActivity : FragmentActivity() {
|
||||
|
||||
// 🔥 Инициализируем ProtocolManager для обработки онлайн статусов
|
||||
ProtocolManager.initialize(this)
|
||||
CallManager.initialize(this)
|
||||
|
||||
// 🔔 Инициализируем Firebase для push-уведомлений
|
||||
initializeFirebase()
|
||||
@@ -581,6 +586,177 @@ fun MainScreen(
|
||||
|
||||
// Load username AND name from AccountManager (persisted in DataStore)
|
||||
val context = LocalContext.current
|
||||
val callScope = rememberCoroutineScope()
|
||||
val callUiState by CallManager.state.collectAsState()
|
||||
var pendingOutgoingCall by remember { mutableStateOf<SearchUser?>(null) }
|
||||
var pendingIncomingAccept by remember { mutableStateOf(false) }
|
||||
var callPermissionsRequestedOnce by remember { mutableStateOf(false) }
|
||||
|
||||
val mandatoryCallPermissions = remember {
|
||||
listOf(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
val optionalCallPermissions = remember {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
listOf(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
val permissionsToRequest = remember(mandatoryCallPermissions, optionalCallPermissions) {
|
||||
mandatoryCallPermissions + optionalCallPermissions
|
||||
}
|
||||
|
||||
val hasMandatoryCallPermissions: () -> Boolean =
|
||||
remember(context, mandatoryCallPermissions) {
|
||||
{
|
||||
mandatoryCallPermissions.all { permission ->
|
||||
ContextCompat.checkSelfPermission(context, permission) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
}
|
||||
val hasOptionalCallPermissions: () -> Boolean =
|
||||
remember(context, optionalCallPermissions) {
|
||||
{
|
||||
optionalCallPermissions.all { permission ->
|
||||
ContextCompat.checkSelfPermission(context, permission) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val showCallError: (CallActionResult) -> Unit = { result ->
|
||||
val message =
|
||||
when (result) {
|
||||
CallActionResult.STARTED -> ""
|
||||
CallActionResult.ALREADY_IN_CALL -> "Сначала заверши текущий звонок"
|
||||
CallActionResult.NOT_AUTHENTICATED -> "Нет подключения к серверу"
|
||||
CallActionResult.ACCOUNT_NOT_BOUND -> "Аккаунт еще не инициализирован"
|
||||
CallActionResult.INVALID_TARGET -> "Не удалось определить пользователя для звонка"
|
||||
CallActionResult.NOT_INCOMING -> "Входящий звонок не найден"
|
||||
}
|
||||
if (message.isNotBlank()) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
val resolveCallableUser: suspend (SearchUser) -> SearchUser? = resolve@{ user ->
|
||||
val publicKey = user.publicKey.trim()
|
||||
if (publicKey.isNotBlank()) {
|
||||
return@resolve user.copy(publicKey = publicKey)
|
||||
}
|
||||
|
||||
val usernameQuery = user.username.trim().trimStart('@')
|
||||
if (usernameQuery.isBlank()) {
|
||||
return@resolve null
|
||||
}
|
||||
|
||||
ProtocolManager.getCachedUserByUsername(usernameQuery)?.let { cached ->
|
||||
if (cached.publicKey.isNotBlank()) return@resolve cached
|
||||
}
|
||||
|
||||
val results = ProtocolManager.searchUsers(usernameQuery)
|
||||
results.firstOrNull {
|
||||
it.publicKey.isNotBlank() &&
|
||||
it.username.trim().trimStart('@')
|
||||
.equals(usernameQuery, ignoreCase = true)
|
||||
}?.let { return@resolve it }
|
||||
|
||||
return@resolve results.firstOrNull { it.publicKey.isNotBlank() }
|
||||
}
|
||||
|
||||
val startOutgoingCallSafely: (SearchUser) -> Unit = { user ->
|
||||
callScope.launch {
|
||||
val resolved = resolveCallableUser(user)
|
||||
if (resolved == null) {
|
||||
showCallError(CallActionResult.INVALID_TARGET)
|
||||
return@launch
|
||||
}
|
||||
val result = CallManager.startOutgoingCall(resolved)
|
||||
if (result != CallActionResult.STARTED) {
|
||||
showCallError(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val acceptIncomingCallSafely: () -> Unit = {
|
||||
val result = CallManager.acceptIncomingCall()
|
||||
if (result != CallActionResult.STARTED) {
|
||||
showCallError(result)
|
||||
}
|
||||
}
|
||||
|
||||
val callPermissionLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { grantedMap ->
|
||||
callPermissionsRequestedOnce = true
|
||||
val micGranted =
|
||||
grantedMap[Manifest.permission.RECORD_AUDIO] == true ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
val bluetoothGranted =
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
true
|
||||
} else {
|
||||
grantedMap[Manifest.permission.BLUETOOTH_CONNECT] == true ||
|
||||
ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.BLUETOOTH_CONNECT
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
if (!micGranted) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Для звонков нужен доступ к микрофону",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} else {
|
||||
pendingOutgoingCall?.let { startOutgoingCallSafely(it) }
|
||||
if (pendingIncomingAccept) {
|
||||
acceptIncomingCallSafely()
|
||||
}
|
||||
if (!bluetoothGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Bluetooth недоступен: гарнитура может не работать",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
pendingOutgoingCall = null
|
||||
pendingIncomingAccept = false
|
||||
}
|
||||
|
||||
val startCallWithPermission: (SearchUser) -> Unit = { user ->
|
||||
val shouldRequestPermissions =
|
||||
!hasMandatoryCallPermissions() ||
|
||||
(!callPermissionsRequestedOnce && !hasOptionalCallPermissions())
|
||||
if (!shouldRequestPermissions) {
|
||||
startOutgoingCallSafely(user)
|
||||
} else {
|
||||
pendingOutgoingCall = user
|
||||
callPermissionLauncher.launch(permissionsToRequest.toTypedArray())
|
||||
}
|
||||
}
|
||||
val acceptCallWithPermission: () -> Unit = {
|
||||
val shouldRequestPermissions =
|
||||
!hasMandatoryCallPermissions() ||
|
||||
(!callPermissionsRequestedOnce && !hasOptionalCallPermissions())
|
||||
if (!shouldRequestPermissions) {
|
||||
acceptIncomingCallSafely()
|
||||
} else {
|
||||
pendingIncomingAccept = true
|
||||
callPermissionLauncher.launch(permissionsToRequest.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(accountPublicKey) {
|
||||
CallManager.bindAccount(accountPublicKey)
|
||||
}
|
||||
|
||||
LaunchedEffect(accountPublicKey, reloadTrigger) {
|
||||
if (accountPublicKey.isNotBlank()) {
|
||||
val accountManager = AccountManager(context)
|
||||
@@ -1075,6 +1251,9 @@ fun MainScreen(
|
||||
currentUserUsername = accountUsername,
|
||||
totalUnreadFromOthers = totalUnreadFromOthers,
|
||||
onBack = { popChatAndChildren() },
|
||||
onCallClick = { callableUser ->
|
||||
startCallWithPermission(callableUser)
|
||||
},
|
||||
onUserProfileClick = { user ->
|
||||
if (isCurrentAccountUser(user)) {
|
||||
// Свой профиль из чата открываем поверх текущего чата,
|
||||
@@ -1369,5 +1548,15 @@ fun MainScreen(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
CallOverlay(
|
||||
state = callUiState,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onAccept = { acceptCallWithPermission() },
|
||||
onDecline = { CallManager.declineIncomingCall() },
|
||||
onEnd = { CallManager.endCall() },
|
||||
onToggleMute = { CallManager.toggleMute() },
|
||||
onToggleSpeaker = { CallManager.toggleSpeaker() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
749
app/src/main/java/com/rosetta/messenger/network/CallManager.kt
Normal file
749
app/src/main/java/com/rosetta/messenger/network/CallManager.kt
Normal file
@@ -0,0 +1,749 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioManager
|
||||
import android.util.Log
|
||||
import java.security.SecureRandom
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.bouncycastle.math.ec.rfc7748.X25519
|
||||
import org.json.JSONObject
|
||||
import org.webrtc.AudioSource
|
||||
import org.webrtc.AudioTrack
|
||||
import org.webrtc.IceCandidate
|
||||
import org.webrtc.MediaConstraints
|
||||
import org.webrtc.PeerConnection
|
||||
import org.webrtc.PeerConnectionFactory
|
||||
import org.webrtc.RtpReceiver
|
||||
import org.webrtc.RtpTransceiver
|
||||
import org.webrtc.SdpObserver
|
||||
import org.webrtc.SessionDescription
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
enum class CallPhase {
|
||||
IDLE,
|
||||
INCOMING,
|
||||
OUTGOING,
|
||||
CONNECTING,
|
||||
ACTIVE
|
||||
}
|
||||
|
||||
enum class CallActionResult {
|
||||
STARTED,
|
||||
ALREADY_IN_CALL,
|
||||
NOT_AUTHENTICATED,
|
||||
ACCOUNT_NOT_BOUND,
|
||||
INVALID_TARGET,
|
||||
NOT_INCOMING
|
||||
}
|
||||
|
||||
data class CallUiState(
|
||||
val phase: CallPhase = CallPhase.IDLE,
|
||||
val peerPublicKey: String = "",
|
||||
val peerTitle: String = "",
|
||||
val peerUsername: String = "",
|
||||
val statusText: String = "",
|
||||
val durationSec: Int = 0,
|
||||
val isMuted: Boolean = false,
|
||||
val isSpeakerOn: Boolean = false,
|
||||
val keyCast: String = ""
|
||||
) {
|
||||
val isVisible: Boolean
|
||||
get() = phase != CallPhase.IDLE
|
||||
|
||||
val displayName: String
|
||||
get() = when {
|
||||
peerTitle.isNotBlank() -> peerTitle
|
||||
peerUsername.isNotBlank() -> peerUsername
|
||||
peerPublicKey.isNotBlank() -> peerPublicKey.take(12)
|
||||
else -> "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private enum class CallRole {
|
||||
CALLER,
|
||||
CALLEE
|
||||
}
|
||||
|
||||
/**
|
||||
* Android call signaling + WebRTC manager.
|
||||
* Mirrors desktop signaling flow on packets 0x1A/0x1B/0x1C.
|
||||
*/
|
||||
object CallManager {
|
||||
private const val TAG = "CallManager"
|
||||
private const val LOCAL_AUDIO_TRACK_ID = "rosetta_audio_track"
|
||||
private const val LOCAL_MEDIA_STREAM_ID = "rosetta_audio_stream"
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val secureRandom = SecureRandom()
|
||||
|
||||
private val _state = MutableStateFlow(CallUiState())
|
||||
val state: StateFlow<CallUiState> = _state.asStateFlow()
|
||||
|
||||
@Volatile
|
||||
private var initialized = false
|
||||
private var appContext: Context? = null
|
||||
private var ownPublicKey: String = ""
|
||||
|
||||
private var role: CallRole? = null
|
||||
private var roomId: String = ""
|
||||
private var offerSent = false
|
||||
private var remoteDescriptionSet = false
|
||||
|
||||
private var localPrivateKey: ByteArray? = null
|
||||
private var localPublicKey: ByteArray? = null
|
||||
|
||||
private var durationJob: Job? = null
|
||||
private var protocolStateJob: Job? = null
|
||||
|
||||
private var signalWaiter: ((Packet) -> Unit)? = null
|
||||
private var webRtcWaiter: ((Packet) -> Unit)? = null
|
||||
private var iceWaiter: ((Packet) -> Unit)? = null
|
||||
|
||||
private var peerConnectionFactory: PeerConnectionFactory? = null
|
||||
private var peerConnection: PeerConnection? = null
|
||||
private var audioSource: AudioSource? = null
|
||||
private var localAudioTrack: AudioTrack? = null
|
||||
private val bufferedRemoteCandidates = mutableListOf<IceCandidate>()
|
||||
|
||||
private var iceServers: List<PeerConnection.IceServer> = emptyList()
|
||||
|
||||
fun initialize(context: Context) {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
appContext = context.applicationContext
|
||||
|
||||
signalWaiter = ProtocolManager.waitCallSignal { packet ->
|
||||
scope.launch { handleSignalPacket(packet) }
|
||||
}
|
||||
webRtcWaiter = ProtocolManager.waitWebRtcSignal { packet ->
|
||||
scope.launch { handleWebRtcPacket(packet) }
|
||||
}
|
||||
iceWaiter = ProtocolManager.waitIceServers { packet ->
|
||||
handleIceServersPacket(packet)
|
||||
}
|
||||
|
||||
protocolStateJob =
|
||||
scope.launch {
|
||||
ProtocolManager.state.collect { protocolState ->
|
||||
when (protocolState) {
|
||||
ProtocolState.AUTHENTICATED -> {
|
||||
ProtocolManager.requestIceServers()
|
||||
}
|
||||
ProtocolState.DISCONNECTED -> {
|
||||
resetSession(reason = "Disconnected", notifyPeer = false)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProtocolManager.requestIceServers()
|
||||
}
|
||||
|
||||
fun bindAccount(publicKey: String) {
|
||||
ownPublicKey = publicKey.trim()
|
||||
}
|
||||
|
||||
fun startOutgoingCall(user: SearchUser): CallActionResult {
|
||||
val targetKey = user.publicKey.trim()
|
||||
if (targetKey.isBlank()) return CallActionResult.INVALID_TARGET
|
||||
if (!canStartNewCall()) return CallActionResult.ALREADY_IN_CALL
|
||||
if (ownPublicKey.isBlank()) return CallActionResult.ACCOUNT_NOT_BOUND
|
||||
if (!ProtocolManager.isAuthenticated()) return CallActionResult.NOT_AUTHENTICATED
|
||||
|
||||
resetSession(reason = null, notifyPeer = false)
|
||||
role = CallRole.CALLER
|
||||
setPeer(targetKey, user.title, user.username)
|
||||
updateState {
|
||||
it.copy(
|
||||
phase = CallPhase.OUTGOING,
|
||||
statusText = "Calling..."
|
||||
)
|
||||
}
|
||||
|
||||
ProtocolManager.sendCallSignal(
|
||||
signalType = SignalType.CALL,
|
||||
src = ownPublicKey,
|
||||
dst = targetKey
|
||||
)
|
||||
return CallActionResult.STARTED
|
||||
}
|
||||
|
||||
fun acceptIncomingCall(): CallActionResult {
|
||||
val snapshot = _state.value
|
||||
if (snapshot.phase != CallPhase.INCOMING) return CallActionResult.NOT_INCOMING
|
||||
if (snapshot.peerPublicKey.isBlank()) return CallActionResult.INVALID_TARGET
|
||||
if (ownPublicKey.isBlank()) return CallActionResult.ACCOUNT_NOT_BOUND
|
||||
|
||||
role = CallRole.CALLEE
|
||||
generateSessionKeys()
|
||||
val localPublic = localPublicKey ?: return CallActionResult.INVALID_TARGET
|
||||
|
||||
ProtocolManager.sendCallSignal(
|
||||
signalType = SignalType.KEY_EXCHANGE,
|
||||
src = ownPublicKey,
|
||||
dst = snapshot.peerPublicKey,
|
||||
sharedPublic = localPublic.toHex()
|
||||
)
|
||||
|
||||
updateState {
|
||||
it.copy(
|
||||
phase = CallPhase.CONNECTING,
|
||||
statusText = "Exchanging keys..."
|
||||
)
|
||||
}
|
||||
return CallActionResult.STARTED
|
||||
}
|
||||
|
||||
fun declineIncomingCall() {
|
||||
val snapshot = _state.value
|
||||
if (snapshot.phase != CallPhase.INCOMING) return
|
||||
if (ownPublicKey.isNotBlank() && snapshot.peerPublicKey.isNotBlank()) {
|
||||
ProtocolManager.sendCallSignal(
|
||||
signalType = SignalType.END_CALL,
|
||||
src = ownPublicKey,
|
||||
dst = snapshot.peerPublicKey
|
||||
)
|
||||
}
|
||||
resetSession(reason = null, notifyPeer = false)
|
||||
}
|
||||
|
||||
fun endCall() {
|
||||
resetSession(reason = null, notifyPeer = true)
|
||||
}
|
||||
|
||||
fun toggleMute() {
|
||||
updateState { current ->
|
||||
val nextMuted = !current.isMuted
|
||||
localAudioTrack?.setEnabled(!nextMuted)
|
||||
current.copy(isMuted = nextMuted)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleSpeaker() {
|
||||
updateState { current ->
|
||||
val nextSpeaker = !current.isSpeakerOn
|
||||
setSpeakerphone(nextSpeaker)
|
||||
current.copy(isSpeakerOn = nextSpeaker)
|
||||
}
|
||||
}
|
||||
|
||||
private fun canStartNewCall(): Boolean {
|
||||
return _state.value.phase == CallPhase.IDLE
|
||||
}
|
||||
|
||||
private suspend fun handleSignalPacket(packet: PacketSignalPeer) {
|
||||
when (packet.signalType) {
|
||||
SignalType.END_CALL_BECAUSE_BUSY -> {
|
||||
resetSession(reason = "User is busy", notifyPeer = false)
|
||||
return
|
||||
}
|
||||
SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED -> {
|
||||
resetSession(reason = "Peer disconnected", notifyPeer = false)
|
||||
return
|
||||
}
|
||||
SignalType.END_CALL -> {
|
||||
resetSession(reason = "Call ended", notifyPeer = false)
|
||||
return
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
val currentPeer = _state.value.peerPublicKey
|
||||
val src = packet.src.trim()
|
||||
if (currentPeer.isNotBlank() && src.isNotBlank() && src != currentPeer && src != ownPublicKey) {
|
||||
return
|
||||
}
|
||||
|
||||
when (packet.signalType) {
|
||||
SignalType.CALL -> {
|
||||
if (_state.value.phase != CallPhase.IDLE) {
|
||||
val callerKey = packet.src.trim()
|
||||
if (callerKey.isNotBlank() && ownPublicKey.isNotBlank()) {
|
||||
ProtocolManager.sendCallSignal(
|
||||
signalType = SignalType.END_CALL,
|
||||
src = ownPublicKey,
|
||||
dst = callerKey
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
val incomingPeer = packet.src.trim()
|
||||
if (incomingPeer.isBlank()) return
|
||||
role = CallRole.CALLEE
|
||||
resetRtcObjects()
|
||||
setPeer(incomingPeer, "", "")
|
||||
updateState {
|
||||
it.copy(
|
||||
phase = CallPhase.INCOMING,
|
||||
statusText = "Incoming call..."
|
||||
)
|
||||
}
|
||||
resolvePeerIdentity(incomingPeer)
|
||||
}
|
||||
SignalType.KEY_EXCHANGE -> {
|
||||
handleKeyExchange(packet)
|
||||
}
|
||||
SignalType.CREATE_ROOM -> {
|
||||
val incomingRoomId = packet.roomId.trim()
|
||||
if (incomingRoomId.isBlank()) return
|
||||
roomId = incomingRoomId
|
||||
updateState {
|
||||
it.copy(
|
||||
phase = CallPhase.CONNECTING,
|
||||
statusText = "Connecting..."
|
||||
)
|
||||
}
|
||||
ensurePeerConnectionAndOffer()
|
||||
}
|
||||
SignalType.ACTIVE_CALL -> Unit
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleKeyExchange(packet: PacketSignalPeer) {
|
||||
val peerKey = packet.src.trim().ifBlank { _state.value.peerPublicKey }
|
||||
if (peerKey.isBlank()) return
|
||||
setPeer(peerKey, _state.value.peerTitle, _state.value.peerUsername)
|
||||
|
||||
val peerPublicHex = packet.sharedPublic.trim()
|
||||
if (peerPublicHex.isBlank()) return
|
||||
|
||||
if (role == CallRole.CALLER) {
|
||||
generateSessionKeys()
|
||||
val sharedKey = computeSharedSecretHex(peerPublicHex) ?: return
|
||||
updateState { it.copy(keyCast = sharedKey.take(32), statusText = "Creating room...") }
|
||||
val localPublic = localPublicKey ?: return
|
||||
ProtocolManager.sendCallSignal(
|
||||
signalType = SignalType.KEY_EXCHANGE,
|
||||
src = ownPublicKey,
|
||||
dst = peerKey,
|
||||
sharedPublic = localPublic.toHex()
|
||||
)
|
||||
ProtocolManager.sendCallSignal(
|
||||
signalType = SignalType.CREATE_ROOM,
|
||||
src = ownPublicKey,
|
||||
dst = peerKey
|
||||
)
|
||||
updateState { it.copy(phase = CallPhase.CONNECTING) }
|
||||
return
|
||||
}
|
||||
|
||||
if (role == CallRole.CALLEE) {
|
||||
if (localPrivateKey == null || localPublicKey == null) {
|
||||
generateSessionKeys()
|
||||
}
|
||||
val sharedKey = computeSharedSecretHex(peerPublicHex) ?: return
|
||||
updateState { it.copy(keyCast = sharedKey.take(32), phase = CallPhase.CONNECTING) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleWebRtcPacket(packet: PacketWebRTC) {
|
||||
val phase = _state.value.phase
|
||||
if (phase != CallPhase.CONNECTING && phase != CallPhase.ACTIVE) return
|
||||
val pc = peerConnection ?: return
|
||||
|
||||
when (packet.signalType) {
|
||||
WebRTCSignalType.ANSWER -> {
|
||||
val answer = parseSessionDescription(packet.sdpOrCandidate) ?: return
|
||||
try {
|
||||
pc.setRemoteDescriptionAwait(answer)
|
||||
remoteDescriptionSet = true
|
||||
flushBufferedRemoteCandidates()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to set remote answer", e)
|
||||
}
|
||||
}
|
||||
WebRTCSignalType.ICE_CANDIDATE -> {
|
||||
val candidate = parseIceCandidate(packet.sdpOrCandidate) ?: return
|
||||
if (!remoteDescriptionSet) {
|
||||
bufferedRemoteCandidates.add(candidate)
|
||||
return
|
||||
}
|
||||
runCatching { pc.addIceCandidate(candidate) }
|
||||
}
|
||||
WebRTCSignalType.OFFER -> {
|
||||
val remoteOffer = parseSessionDescription(packet.sdpOrCandidate) ?: return
|
||||
try {
|
||||
pc.setRemoteDescriptionAwait(remoteOffer)
|
||||
remoteDescriptionSet = true
|
||||
flushBufferedRemoteCandidates()
|
||||
val answer = pc.createAnswerAwait()
|
||||
pc.setLocalDescriptionAwait(answer)
|
||||
ProtocolManager.sendWebRtcSignal(
|
||||
signalType = WebRTCSignalType.ANSWER,
|
||||
sdpOrCandidate = serializeSessionDescription(answer)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to handle remote offer", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIceServersPacket(packet: PacketIceServers) {
|
||||
iceServers =
|
||||
packet.iceServers.mapNotNull { server ->
|
||||
val url = server.url.trim()
|
||||
val transport = server.transport.trim()
|
||||
if (url.isBlank() || transport.isBlank()) return@mapNotNull null
|
||||
PeerConnection.IceServer
|
||||
.builder("turn:$url?transport=$transport")
|
||||
.setUsername(server.username)
|
||||
.setPassword(server.credential)
|
||||
.createIceServer()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensurePeerConnectionAndOffer() {
|
||||
val peerKey = _state.value.peerPublicKey
|
||||
if (peerKey.isBlank() || roomId.isBlank()) return
|
||||
if (offerSent) return
|
||||
|
||||
ensurePeerFactory()
|
||||
val factory = peerConnectionFactory ?: return
|
||||
val pc = peerConnection ?: createPeerConnection(factory) ?: return
|
||||
|
||||
if (audioSource == null) {
|
||||
audioSource = factory.createAudioSource(MediaConstraints())
|
||||
}
|
||||
if (localAudioTrack == null) {
|
||||
localAudioTrack = factory.createAudioTrack(LOCAL_AUDIO_TRACK_ID, audioSource)
|
||||
localAudioTrack?.setEnabled(!_state.value.isMuted)
|
||||
pc.addTrack(localAudioTrack, listOf(LOCAL_MEDIA_STREAM_ID))
|
||||
}
|
||||
|
||||
try {
|
||||
val offer = pc.createOfferAwait()
|
||||
pc.setLocalDescriptionAwait(offer)
|
||||
ProtocolManager.sendWebRtcSignal(
|
||||
signalType = WebRTCSignalType.OFFER,
|
||||
sdpOrCandidate = serializeSessionDescription(offer)
|
||||
)
|
||||
offerSent = true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to create/send offer", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPeerConnection(factory: PeerConnectionFactory): PeerConnection? {
|
||||
val rtcIceServers =
|
||||
if (iceServers.isNotEmpty()) {
|
||||
iceServers
|
||||
} else {
|
||||
listOf(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer())
|
||||
}
|
||||
|
||||
val configuration = PeerConnection.RTCConfiguration(rtcIceServers).apply {
|
||||
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
|
||||
}
|
||||
|
||||
val observer =
|
||||
object : PeerConnection.Observer {
|
||||
override fun onSignalingChange(newState: PeerConnection.SignalingState?) = Unit
|
||||
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState?) = Unit
|
||||
override fun onIceConnectionReceivingChange(receiving: Boolean) = Unit
|
||||
override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState?) = Unit
|
||||
override fun onIceCandidate(candidate: IceCandidate?) {
|
||||
if (candidate == null) return
|
||||
ProtocolManager.sendWebRtcSignal(
|
||||
signalType = WebRTCSignalType.ICE_CANDIDATE,
|
||||
sdpOrCandidate = serializeIceCandidate(candidate)
|
||||
)
|
||||
}
|
||||
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) = Unit
|
||||
override fun onAddStream(stream: org.webrtc.MediaStream?) = Unit
|
||||
override fun onRemoveStream(stream: org.webrtc.MediaStream?) = Unit
|
||||
override fun onDataChannel(dataChannel: org.webrtc.DataChannel?) = Unit
|
||||
override fun onRenegotiationNeeded() = Unit
|
||||
override fun onAddTrack(receiver: RtpReceiver?, mediaStreams: Array<out org.webrtc.MediaStream>?) = Unit
|
||||
override fun onTrack(transceiver: RtpTransceiver?) = Unit
|
||||
override fun onConnectionChange(newState: PeerConnection.PeerConnectionState?) {
|
||||
when (newState) {
|
||||
PeerConnection.PeerConnectionState.CONNECTED -> {
|
||||
onCallConnected()
|
||||
}
|
||||
PeerConnection.PeerConnectionState.DISCONNECTED,
|
||||
PeerConnection.PeerConnectionState.FAILED,
|
||||
PeerConnection.PeerConnectionState.CLOSED -> {
|
||||
resetSession(reason = "Connection lost", notifyPeer = false)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
peerConnection = factory.createPeerConnection(configuration, observer)
|
||||
return peerConnection
|
||||
}
|
||||
|
||||
private fun onCallConnected() {
|
||||
updateState { it.copy(phase = CallPhase.ACTIVE, statusText = "Call active") }
|
||||
durationJob?.cancel()
|
||||
durationJob =
|
||||
scope.launch {
|
||||
while (true) {
|
||||
delay(1_000L)
|
||||
_state.update { current ->
|
||||
if (current.phase != CallPhase.ACTIVE) return@update current
|
||||
current.copy(durationSec = current.durationSec + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPeer(publicKey: String, title: String, username: String) {
|
||||
updateState {
|
||||
it.copy(
|
||||
peerPublicKey = publicKey.trim(),
|
||||
peerTitle = title.trim(),
|
||||
peerUsername = username.trim()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolvePeerIdentity(publicKey: String) {
|
||||
scope.launch {
|
||||
val resolved = ProtocolManager.resolveUserInfo(publicKey)
|
||||
if (resolved != null && _state.value.peerPublicKey == publicKey) {
|
||||
setPeer(publicKey, resolved.title, resolved.username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensurePeerFactory() {
|
||||
if (peerConnectionFactory != null) return
|
||||
val context = appContext ?: return
|
||||
PeerConnectionFactory.initialize(
|
||||
PeerConnectionFactory.InitializationOptions.builder(context)
|
||||
.createInitializationOptions()
|
||||
)
|
||||
peerConnectionFactory = PeerConnectionFactory.builder().createPeerConnectionFactory()
|
||||
}
|
||||
|
||||
private fun resetSession(reason: String?, notifyPeer: Boolean) {
|
||||
val snapshot = _state.value
|
||||
val peerToNotify = snapshot.peerPublicKey
|
||||
if (notifyPeer && ownPublicKey.isNotBlank() && peerToNotify.isNotBlank()) {
|
||||
ProtocolManager.sendCallSignal(
|
||||
signalType = SignalType.END_CALL,
|
||||
src = ownPublicKey,
|
||||
dst = peerToNotify
|
||||
)
|
||||
}
|
||||
if (!reason.isNullOrBlank()) {
|
||||
Log.d(TAG, reason)
|
||||
}
|
||||
resetRtcObjects()
|
||||
role = null
|
||||
roomId = ""
|
||||
offerSent = false
|
||||
remoteDescriptionSet = false
|
||||
localPrivateKey = null
|
||||
localPublicKey = null
|
||||
durationJob?.cancel()
|
||||
durationJob = null
|
||||
setSpeakerphone(false)
|
||||
_state.value = CallUiState()
|
||||
}
|
||||
|
||||
private fun resetRtcObjects() {
|
||||
bufferedRemoteCandidates.clear()
|
||||
runCatching { localAudioTrack?.setEnabled(false) }
|
||||
runCatching { localAudioTrack?.dispose() }
|
||||
runCatching { audioSource?.dispose() }
|
||||
runCatching { peerConnection?.close() }
|
||||
localAudioTrack = null
|
||||
audioSource = null
|
||||
peerConnection = null
|
||||
}
|
||||
|
||||
private fun flushBufferedRemoteCandidates() {
|
||||
val pc = peerConnection ?: return
|
||||
if (bufferedRemoteCandidates.isEmpty()) return
|
||||
bufferedRemoteCandidates.forEach { candidate ->
|
||||
runCatching { pc.addIceCandidate(candidate) }
|
||||
}
|
||||
bufferedRemoteCandidates.clear()
|
||||
}
|
||||
|
||||
private fun generateSessionKeys() {
|
||||
val privateKey = ByteArray(32)
|
||||
secureRandom.nextBytes(privateKey)
|
||||
val publicKey = ByteArray(32)
|
||||
X25519.generatePublicKey(privateKey, 0, publicKey, 0)
|
||||
localPrivateKey = privateKey
|
||||
localPublicKey = publicKey
|
||||
}
|
||||
|
||||
private fun computeSharedSecretHex(peerPublicHex: String): String? {
|
||||
val privateKey = localPrivateKey ?: return null
|
||||
val peerPublic = peerPublicHex.hexToBytes() ?: return null
|
||||
if (peerPublic.size != 32) return null
|
||||
val shared = ByteArray(32)
|
||||
val ok = X25519.calculateAgreement(privateKey, 0, peerPublic, 0, shared, 0)
|
||||
if (!ok) return null
|
||||
return shared.toHex()
|
||||
}
|
||||
|
||||
private fun serializeSessionDescription(description: SessionDescription): String {
|
||||
return JSONObject()
|
||||
.put("type", description.type.canonicalForm())
|
||||
.put("sdp", description.description)
|
||||
.toString()
|
||||
}
|
||||
|
||||
private fun parseSessionDescription(raw: String): SessionDescription? {
|
||||
return runCatching {
|
||||
val json = JSONObject(raw)
|
||||
val type = SessionDescription.Type.fromCanonicalForm(json.getString("type"))
|
||||
val sdp = json.getString("sdp")
|
||||
SessionDescription(type, sdp)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun serializeIceCandidate(candidate: IceCandidate): String {
|
||||
return JSONObject()
|
||||
.put("candidate", candidate.sdp)
|
||||
.put("sdpMid", candidate.sdpMid)
|
||||
.put("sdpMLineIndex", candidate.sdpMLineIndex)
|
||||
.toString()
|
||||
}
|
||||
|
||||
private fun parseIceCandidate(raw: String): IceCandidate? {
|
||||
return runCatching {
|
||||
val json = JSONObject(raw)
|
||||
val candidate = json.getString("candidate")
|
||||
val sdpMid = if (json.has("sdpMid") && !json.isNull("sdpMid")) json.getString("sdpMid") else null
|
||||
val sdpMLineIndex = json.optInt("sdpMLineIndex", 0)
|
||||
IceCandidate(sdpMid, sdpMLineIndex, candidate)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun setSpeakerphone(enabled: Boolean) {
|
||||
val context = appContext ?: return
|
||||
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return
|
||||
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
audioManager.isSpeakerphoneOn = enabled
|
||||
}
|
||||
|
||||
private fun updateState(reducer: (CallUiState) -> CallUiState) {
|
||||
_state.update(reducer)
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun String.hexToBytes(): ByteArray? {
|
||||
val clean = trim().lowercase()
|
||||
if (clean.length % 2 != 0) return null
|
||||
return runCatching {
|
||||
ByteArray(clean.length / 2) { idx ->
|
||||
clean.substring(idx * 2, idx * 2 + 2).toInt(16).toByte()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun PeerConnection.createOfferAwait(): SessionDescription =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
createOffer(
|
||||
object : SdpObserver {
|
||||
override fun onCreateSuccess(sdp: SessionDescription?) {
|
||||
if (sdp == null) {
|
||||
continuation.resumeWithException(IllegalStateException("Offer is null"))
|
||||
return
|
||||
}
|
||||
continuation.resume(sdp)
|
||||
}
|
||||
|
||||
override fun onSetSuccess() = Unit
|
||||
override fun onCreateFailure(error: String?) {
|
||||
continuation.resumeWithException(
|
||||
IllegalStateException(error ?: "createOffer failed")
|
||||
)
|
||||
}
|
||||
|
||||
override fun onSetFailure(error: String?) = Unit
|
||||
},
|
||||
MediaConstraints().apply {
|
||||
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"))
|
||||
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "false"))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun PeerConnection.createAnswerAwait(): SessionDescription =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
createAnswer(
|
||||
object : SdpObserver {
|
||||
override fun onCreateSuccess(sdp: SessionDescription?) {
|
||||
if (sdp == null) {
|
||||
continuation.resumeWithException(IllegalStateException("Answer is null"))
|
||||
return
|
||||
}
|
||||
continuation.resume(sdp)
|
||||
}
|
||||
|
||||
override fun onSetSuccess() = Unit
|
||||
override fun onCreateFailure(error: String?) {
|
||||
continuation.resumeWithException(
|
||||
IllegalStateException(error ?: "createAnswer failed")
|
||||
)
|
||||
}
|
||||
|
||||
override fun onSetFailure(error: String?) = Unit
|
||||
},
|
||||
MediaConstraints()
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun PeerConnection.setLocalDescriptionAwait(description: SessionDescription) {
|
||||
suspendCancellableCoroutine<Unit> { continuation ->
|
||||
setLocalDescription(
|
||||
object : SdpObserver {
|
||||
override fun onCreateSuccess(sdp: SessionDescription?) = Unit
|
||||
override fun onSetSuccess() {
|
||||
continuation.resume(Unit)
|
||||
}
|
||||
override fun onCreateFailure(error: String?) = Unit
|
||||
override fun onSetFailure(error: String?) {
|
||||
continuation.resumeWithException(
|
||||
IllegalStateException(error ?: "setLocalDescription failed")
|
||||
)
|
||||
}
|
||||
},
|
||||
description
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun PeerConnection.setRemoteDescriptionAwait(description: SessionDescription) {
|
||||
suspendCancellableCoroutine<Unit> { continuation ->
|
||||
setRemoteDescription(
|
||||
object : SdpObserver {
|
||||
override fun onCreateSuccess(sdp: SessionDescription?) = Unit
|
||||
override fun onSetSuccess() {
|
||||
continuation.resume(Unit)
|
||||
}
|
||||
override fun onCreateFailure(error: String?) = Unit
|
||||
override fun onSetFailure(error: String?) {
|
||||
continuation.resumeWithException(
|
||||
IllegalStateException(error ?: "setRemoteDescription failed")
|
||||
)
|
||||
}
|
||||
},
|
||||
description
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -284,6 +284,7 @@ fun ChatDetailScreen(
|
||||
user: SearchUser,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToChat: (SearchUser) -> Unit,
|
||||
onCallClick: (SearchUser) -> Unit = {},
|
||||
onUserProfileClick: (SearchUser) -> Unit = {},
|
||||
onGroupInfoClick: (SearchUser) -> Unit = {},
|
||||
currentUserPublicKey: String,
|
||||
@@ -1873,8 +1874,7 @@ fun ChatDetailScreen(
|
||||
!isSystemAccount
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { /* TODO: Voice call */
|
||||
}
|
||||
onClick = { onCallClick(user) }
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.rosetta.messenger.ui.chats.calls
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.VolumeOff
|
||||
import androidx.compose.material.icons.filled.VolumeUp
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rosetta.messenger.network.CallPhase
|
||||
import com.rosetta.messenger.network.CallUiState
|
||||
|
||||
@Composable
|
||||
fun CallOverlay(
|
||||
state: CallUiState,
|
||||
isDarkTheme: Boolean,
|
||||
onAccept: () -> Unit,
|
||||
onDecline: () -> Unit,
|
||||
onEnd: () -> Unit,
|
||||
onToggleMute: () -> Unit,
|
||||
onToggleSpeaker: () -> Unit
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = state.isVisible,
|
||||
enter = fadeIn() + scaleIn(initialScale = 0.96f),
|
||||
exit = fadeOut() + scaleOut(targetScale = 0.96f)
|
||||
) {
|
||||
val scrim = Color.Black.copy(alpha = if (isDarkTheme) 0.56f else 0.42f)
|
||||
val cardColor = if (isDarkTheme) Color(0xFF1F1F24) else Color(0xFFF7F8FC)
|
||||
val titleColor = if (isDarkTheme) Color.White else Color(0xFF1F1F24)
|
||||
val subtitleColor = if (isDarkTheme) Color(0xFFB0B4C2) else Color(0xFF5C637A)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(scrim),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp),
|
||||
color = cardColor,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 10.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = state.displayName,
|
||||
color = titleColor,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = statusText(state),
|
||||
color = subtitleColor,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
|
||||
if (state.keyCast.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Key: ${state.keyCast}",
|
||||
color = subtitleColor.copy(alpha = 0.78f),
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
when (state.phase) {
|
||||
CallPhase.INCOMING -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
RoundActionButton(
|
||||
background = Color(0xFFE5484D),
|
||||
onClick = onDecline
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CallEnd,
|
||||
contentDescription = "Decline",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
RoundActionButton(
|
||||
background = Color(0xFF2CB96B),
|
||||
onClick = onAccept
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Call,
|
||||
contentDescription = "Accept",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CallPhase.ACTIVE -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
RoundActionButton(
|
||||
background = if (state.isMuted) Color(0xFF394150) else Color(0xFF2A313D),
|
||||
onClick = onToggleMute
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (state.isMuted) Icons.Default.MicOff else Icons.Default.Mic,
|
||||
contentDescription = "Mute",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
RoundActionButton(
|
||||
background = if (state.isSpeakerOn) Color(0xFF394150) else Color(0xFF2A313D),
|
||||
onClick = onToggleSpeaker
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (state.isSpeakerOn) Icons.Default.VolumeUp else Icons.Default.VolumeOff,
|
||||
contentDescription = "Speaker",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
RoundActionButton(
|
||||
background = Color(0xFFE5484D),
|
||||
onClick = onEnd
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CallEnd,
|
||||
contentDescription = "End call",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CallPhase.OUTGOING, CallPhase.CONNECTING -> {
|
||||
RoundActionButton(
|
||||
background = Color(0xFFE5484D),
|
||||
onClick = onEnd
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CallEnd,
|
||||
contentDescription = "End call",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CallPhase.IDLE -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoundActionButton(
|
||||
background: Color,
|
||||
onClick: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(64.dp).clip(CircleShape),
|
||||
color = background,
|
||||
shape = CircleShape,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 6.dp
|
||||
) {
|
||||
IconButton(onClick = onClick, modifier = Modifier.fillMaxSize()) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusText(state: CallUiState): String {
|
||||
return when (state.phase) {
|
||||
CallPhase.INCOMING -> "Incoming call"
|
||||
CallPhase.OUTGOING -> if (state.statusText.isNotBlank()) state.statusText else "Calling..."
|
||||
CallPhase.CONNECTING -> if (state.statusText.isNotBlank()) state.statusText else "Connecting..."
|
||||
CallPhase.ACTIVE -> formatDuration(state.durationSec)
|
||||
CallPhase.IDLE -> ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDuration(seconds: Int): String {
|
||||
val safe = seconds.coerceAtLeast(0)
|
||||
val minutes = safe / 60
|
||||
val remainingSeconds = safe % 60
|
||||
return "%02d:%02d".format(minutes, remainingSeconds)
|
||||
}
|
||||
Reference in New Issue
Block a user