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