Реализованы звонки в диалоге и полный permission flow Android
This commit is contained in:
@@ -169,6 +169,9 @@ dependencies {
|
|||||||
implementation("androidx.camera:camera-lifecycle:1.3.1")
|
implementation("androidx.camera:camera-lifecycle:1.3.1")
|
||||||
implementation("androidx.camera:camera-view:1.3.1")
|
implementation("androidx.camera:camera-view:1.3.1")
|
||||||
|
|
||||||
|
// WebRTC for voice calls
|
||||||
|
implementation("io.github.webrtc-sdk:android:125.6422.07")
|
||||||
|
|
||||||
// Baseline Profiles for startup performance
|
// Baseline Profiles for startup performance
|
||||||
implementation("androidx.profileinstaller:profileinstaller:1.3.1")
|
implementation("androidx.profileinstaller:profileinstaller:1.3.1")
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,11 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<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.REQUEST_INSTALL_PACKAGES" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
|
<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.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
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.RecentSearchesManager
|
||||||
import com.rosetta.messenger.data.resolveAccountDisplayName
|
import com.rosetta.messenger.data.resolveAccountDisplayName
|
||||||
import com.rosetta.messenger.database.RosettaDatabase
|
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.ProtocolManager
|
||||||
import com.rosetta.messenger.network.ProtocolState
|
import com.rosetta.messenger.network.ProtocolState
|
||||||
import com.rosetta.messenger.network.SearchUser
|
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.GroupSetupScreen
|
||||||
import com.rosetta.messenger.ui.chats.RequestsListScreen
|
import com.rosetta.messenger.ui.chats.RequestsListScreen
|
||||||
import com.rosetta.messenger.ui.chats.SearchScreen
|
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.OptimizedEmojiCache
|
||||||
import com.rosetta.messenger.ui.components.SwipeBackBackgroundEffect
|
import com.rosetta.messenger.ui.components.SwipeBackBackgroundEffect
|
||||||
import com.rosetta.messenger.ui.components.SwipeBackContainer
|
import com.rosetta.messenger.ui.components.SwipeBackContainer
|
||||||
@@ -116,6 +120,7 @@ class MainActivity : FragmentActivity() {
|
|||||||
|
|
||||||
// 🔥 Инициализируем ProtocolManager для обработки онлайн статусов
|
// 🔥 Инициализируем ProtocolManager для обработки онлайн статусов
|
||||||
ProtocolManager.initialize(this)
|
ProtocolManager.initialize(this)
|
||||||
|
CallManager.initialize(this)
|
||||||
|
|
||||||
// 🔔 Инициализируем Firebase для push-уведомлений
|
// 🔔 Инициализируем Firebase для push-уведомлений
|
||||||
initializeFirebase()
|
initializeFirebase()
|
||||||
@@ -581,6 +586,177 @@ fun MainScreen(
|
|||||||
|
|
||||||
// Load username AND name from AccountManager (persisted in DataStore)
|
// Load username AND name from AccountManager (persisted in DataStore)
|
||||||
val context = LocalContext.current
|
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) {
|
LaunchedEffect(accountPublicKey, reloadTrigger) {
|
||||||
if (accountPublicKey.isNotBlank()) {
|
if (accountPublicKey.isNotBlank()) {
|
||||||
val accountManager = AccountManager(context)
|
val accountManager = AccountManager(context)
|
||||||
@@ -1075,6 +1251,9 @@ fun MainScreen(
|
|||||||
currentUserUsername = accountUsername,
|
currentUserUsername = accountUsername,
|
||||||
totalUnreadFromOthers = totalUnreadFromOthers,
|
totalUnreadFromOthers = totalUnreadFromOthers,
|
||||||
onBack = { popChatAndChildren() },
|
onBack = { popChatAndChildren() },
|
||||||
|
onCallClick = { callableUser ->
|
||||||
|
startCallWithPermission(callableUser)
|
||||||
|
},
|
||||||
onUserProfileClick = { user ->
|
onUserProfileClick = { user ->
|
||||||
if (isCurrentAccountUser(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,
|
user: SearchUser,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onNavigateToChat: (SearchUser) -> Unit,
|
onNavigateToChat: (SearchUser) -> Unit,
|
||||||
|
onCallClick: (SearchUser) -> Unit = {},
|
||||||
onUserProfileClick: (SearchUser) -> Unit = {},
|
onUserProfileClick: (SearchUser) -> Unit = {},
|
||||||
onGroupInfoClick: (SearchUser) -> Unit = {},
|
onGroupInfoClick: (SearchUser) -> Unit = {},
|
||||||
currentUserPublicKey: String,
|
currentUserPublicKey: String,
|
||||||
@@ -1873,8 +1874,7 @@ fun ChatDetailScreen(
|
|||||||
!isSystemAccount
|
!isSystemAccount
|
||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { /* TODO: Voice call */
|
onClick = { onCallClick(user) }
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default
|
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