From 9778e3b1967aba4c8032b40a731c5b2fa0c96681 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Mon, 23 Mar 2026 10:56:52 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D1=8B=20=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=B2=20=D0=B4=D0=B8=D0=B0=D0=BB=D0=BE=D0=B3=D0=B5=20=D0=B8?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9=20permission=20flow=20A?= =?UTF-8?q?ndroid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 3 + app/src/main/AndroidManifest.xml | 5 + .../com/rosetta/messenger/MainActivity.kt | 189 +++++ .../rosetta/messenger/network/CallManager.kt | 749 ++++++++++++++++++ .../messenger/ui/chats/ChatDetailScreen.kt | 4 +- .../messenger/ui/chats/calls/CallOverlay.kt | 226 ++++++ 6 files changed, 1174 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/network/CallManager.kt create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c856419..2639c9a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -169,6 +169,9 @@ dependencies { implementation("androidx.camera:camera-lifecycle: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 implementation("androidx.profileinstaller:profileinstaller:1.3.1") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5044024..8c35235 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,11 @@ + + + + + diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 93007dc..c0edf82 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -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(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() } + ) } } diff --git a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt new file mode 100644 index 0000000..39b6d9a --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt @@ -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 = _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() + + private var iceServers: List = 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?) = 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?) = 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 { 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 { 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 + ) + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 9fbc2ac..ca3e0bc 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt new file mode 100644 index 0000000..03de7dc --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt @@ -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) +}