Реализованы звонки в диалоге и полный permission flow Android

This commit is contained in:
2026-03-23 10:56:52 +05:00
parent 4664aa9482
commit 9778e3b196
6 changed files with 1174 additions and 2 deletions

View File

@@ -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" />

View File

@@ -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() }
)
}
}

View 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
)
}
}
}

View File

@@ -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

View File

@@ -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)
}