diff --git a/android/app/build.gradle b/android/app/build.gradle index f47eee7bc95..e6263f83e1d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -153,6 +153,8 @@ dependencies { // For SecureKeystore (EncryptedSharedPreferences) implementation 'androidx.security:security-crypto:1.1.0' + + testImplementation 'junit:junit:4.13.2' } apply plugin: 'com.google.gms.google-services' diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipIncomingCallDispatch.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipIncomingCallDispatch.kt new file mode 100644 index 00000000000..555ea01d0d6 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipIncomingCallDispatch.kt @@ -0,0 +1,25 @@ +package chat.rocket.reactnative.voip + +/** + * Pure routing for an incoming VoIP FCM push after [VoipPayload.isVoipIncomingCall] is true. + * Stale (invalid or expired lifetime) pushes must not reach busy vs show branching. + */ +internal enum class VoipIncomingPushAction { + STALE, + REJECT_BUSY, + SHOW_INCOMING +} + +internal fun decideIncomingVoipPushAction( + isValidForIncomingHandling: Boolean, + hasActiveCall: Boolean +): VoipIncomingPushAction { + if (!isValidForIncomingHandling) { + return VoipIncomingPushAction.STALE + } + return if (hasActiveCall) { + VoipIncomingPushAction.REJECT_BUSY + } else { + VoipIncomingPushAction.SHOW_INCOMING + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt index f3f24fc6094..20c2aefc918 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt @@ -1,5 +1,6 @@ package chat.rocket.reactnative.voip +import android.Manifest import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager @@ -7,8 +8,10 @@ import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.graphics.Bitmap import android.media.AudioAttributes +import android.media.AudioManager import android.media.RingtoneManager import android.os.Build import android.os.Bundle @@ -17,6 +20,7 @@ import android.os.Looper import android.provider.Settings import android.util.Log import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat import androidx.localbroadcastmanager.content.LocalBroadcastManager import android.content.ComponentName import android.net.Uri @@ -72,8 +76,13 @@ class VoipNotification(private val context: Context) { private val SUPPORTED_VOIP_FEATURES = JSONArray().apply { put("audio") } private val timeoutHandler = Handler(Looper.getMainLooper()) private val timeoutCallbacks = mutableMapOf() - private var ddpClient: DDPClient? = null - private var isDdpLoggedIn = false + private val ddpRegistry = VoipPerCallDdpRegistry { client -> + client.clearQueuedMethodCalls() + client.disconnect() + } + + /** False when [callId] was reassigned or torn down (stale DDP callback). */ + private fun isLiveClient(callId: String, client: DDPClient) = ddpRegistry.clientFor(callId) === client /** * Cancels a VoIP notification by ID. @@ -131,7 +140,7 @@ class VoipNotification(private val context: Context) { putExtras(payload.toBundle()) } ) - stopDDPClientInternal() + ddpRegistry.stopClient(payload.callId) Log.d(TAG, "Timed out incoming VoIP call: ${payload.callId}") } @@ -143,7 +152,7 @@ class VoipNotification(private val context: Context) { fun handleDeclineAction(context: Context, payload: VoipPayload) { Log.d(TAG, "Decline action triggered for callId: ${payload.callId}") cancelTimeout(payload.callId) - if (isDdpLoggedIn) { + if (ddpRegistry.isLoggedIn(payload.callId)) { sendRejectSignal(context, payload) } else { queueRejectSignal(context, payload) @@ -235,7 +244,7 @@ class VoipNotification(private val context: Context) { fun finish(ddpSuccess: Boolean) { if (!finished.compareAndSet(false, true)) return timeoutRunnable?.let { timeoutHandler.removeCallbacks(it) } - stopDDPClientInternal() + ddpRegistry.stopClient(payload.callId) if (ddpSuccess) { answerIncomingCall(payload.callId) VoipModule.storeInitialEvents(payload) @@ -262,14 +271,14 @@ class VoipNotification(private val context: Context) { timeoutRunnable = postedTimeout timeoutHandler.postDelayed(postedTimeout, 10_000L) - val client = ddpClient + val client = ddpRegistry.clientFor(payload.callId) if (client == null) { Log.d(TAG, "Native DDP client unavailable for accept ${payload.callId}") finish(false) return } - if (isDdpLoggedIn) { + if (ddpRegistry.isLoggedIn(payload.callId)) { sendAcceptSignal(context, payload) { success -> finish(success) } @@ -338,7 +347,7 @@ class VoipNotification(private val context: Context) { } private fun sendRejectSignal(context: Context, payload: VoipPayload) { - val client = ddpClient + val client = ddpRegistry.clientFor(payload.callId) if (client == null) { Log.d(TAG, "Native DDP client unavailable, cannot send reject for ${payload.callId}") return @@ -348,12 +357,12 @@ class VoipNotification(private val context: Context) { client.callMethod("stream-notify-user", params) { success -> Log.d(TAG, "Native reject signal result for ${payload.callId}: $success") - stopDDPClientInternal() + ddpRegistry.stopClient(payload.callId) } } private fun queueRejectSignal(context: Context, payload: VoipPayload) { - val client = ddpClient + val client = ddpRegistry.clientFor(payload.callId) if (client == null) { Log.d(TAG, "Native DDP client unavailable, cannot queue reject for ${payload.callId}") return @@ -363,13 +372,13 @@ class VoipNotification(private val context: Context) { client.queueMethodCall("stream-notify-user", params) { success -> Log.d(TAG, "Queued native reject signal result for ${payload.callId}: $success") - stopDDPClientInternal() + ddpRegistry.stopClient(payload.callId) } Log.d(TAG, "Queued native reject signal for ${payload.callId}") } - private fun flushPendingQueuedSignalsIfNeeded(): Boolean { - val client = ddpClient ?: return false + private fun flushPendingQueuedSignalsIfNeeded(callId: String): Boolean { + val client = ddpRegistry.clientFor(callId) ?: return false if (!client.hasQueuedMethodCalls()) { return false } @@ -383,7 +392,7 @@ class VoipNotification(private val context: Context) { payload: VoipPayload, onComplete: (Boolean) -> Unit ) { - val client = ddpClient + val client = ddpRegistry.clientFor(payload.callId) if (client == null) { Log.d(TAG, "Native DDP client unavailable, cannot send accept for ${payload.callId}") onComplete(false) @@ -406,7 +415,7 @@ class VoipNotification(private val context: Context) { payload: VoipPayload, onComplete: (Boolean) -> Unit ) { - val client = ddpClient + val client = ddpRegistry.clientFor(payload.callId) if (client == null) { Log.d(TAG, "Native DDP client unavailable, cannot queue accept for ${payload.callId}") onComplete(false) @@ -436,13 +445,13 @@ class VoipNotification(private val context: Context) { val userId = ejson.userId() if (userId.isNullOrEmpty()) { Log.d(TAG, "Missing userId, cannot build stream-notify-user params for ${payload.callId}") - stopDDPClientInternal() + ddpRegistry.stopClient(payload.callId) return null } val deviceId = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) if (deviceId.isNullOrEmpty()) { Log.d(TAG, "Missing deviceId, cannot build stream-notify-user params for ${payload.callId}") - stopDDPClientInternal() + ddpRegistry.stopClient(payload.callId) return null } return VoipMediaCallIdentity(userId, deviceId) @@ -477,12 +486,75 @@ class VoipNotification(private val context: Context) { } } + /** + * True when the user is already in a call: this app's Telecom connections (ringing, dialing, + * active, hold — same idea as iOS CXCallObserver "any non-ended"), any system in-call state + * (API 26+ when READ_PHONE_STATE is granted), or audio in communication mode (fallback on all + * API levels when Telecom is unavailable or denied). + */ + private fun hasActiveCall(context: Context): Boolean { + val ownBusy = VoiceConnectionService.currentConnections.values.any { connection -> + when (connection.state) { + android.telecom.Connection.STATE_RINGING, + android.telecom.Connection.STATE_DIALING, + android.telecom.Connection.STATE_ACTIVE, + android.telecom.Connection.STATE_HOLDING -> true + else -> false + } + } + if (ownBusy) { + return true + } + return hasSystemLevelActiveCallIndicators(context) + } + + /** + * Telecom in-call check (API 26+) requires [READ_PHONE_STATE]; without it, [TelecomManager.isInCall] + * can throw [SecurityException]. Always falls back to [AudioManager.MODE_IN_COMMUNICATION] or + * [AudioManager.MODE_IN_CALL] on all APIs to catch both VoIP and cellular calls. + */ + private fun hasSystemLevelActiveCallIndicators(context: Context): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == + PackageManager.PERMISSION_GRANTED + if (granted) { + val telecom = context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager + try { + if (telecom?.isInCall == true) { + return true + } + } catch (e: SecurityException) { + Log.w(TAG, "TelecomManager.isInCall not allowed", e) + } + } + } + val audio = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + if (audio?.mode == AudioManager.MODE_IN_COMMUNICATION || audio?.mode == AudioManager.MODE_IN_CALL) { + return true + } + return false + } + + /** + * Rejects an incoming call because the user is already on another call. + * Sends a reject signal via DDP and cleans up without showing any UI. + */ + @JvmStatic + fun rejectBusyCall(context: Context, payload: VoipPayload) { + Log.d(TAG, "Rejected busy call ${payload.callId} — user already on a call") + cancelTimeout(payload.callId) + startListeningForCallEnd(context, payload) + if (ddpRegistry.isLoggedIn(payload.callId)) { + sendRejectSignal(context, payload) + } else { + queueRejectSignal(context, payload) + } + } + // -- Native DDP Listener (Call End Detection) -- @JvmStatic fun startListeningForCallEnd(context: Context, payload: VoipPayload) { - stopDDPClientInternal() - val ejson = Ejson() ejson.host = payload.host val userId = ejson.userId() @@ -496,12 +568,14 @@ class VoipNotification(private val context: Context) { val deviceId = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) val callId = payload.callId val client = DDPClient() - ddpClient = client - isDdpLoggedIn = false + ddpRegistry.putClient(callId, client) Log.d(TAG, "Starting DDP listener for call $callId") - client.onCollectionMessage = { message -> + client.onCollectionMessage = collector@{ message -> + if (!isLiveClient(callId, client)) { + return@collector + } Log.d(TAG, "DDP received message: $message") val fields = message.optJSONObject("fields") if (fields != null) { @@ -525,6 +599,9 @@ class VoipNotification(private val context: Context) { )) { val appContext = context.applicationContext Handler(Looper.getMainLooper()).post { + if (!isLiveClient(callId, client)) { + return@post + } cancelTimeout(callId) disconnectIncomingCall(callId, false) cancelById(appContext, payload.notificationId) @@ -533,7 +610,7 @@ class VoipNotification(private val context: Context) { putExtras(payload.toBundle()) } ) - stopDDPClientInternal() + ddpRegistry.stopClient(callId) } } } @@ -543,21 +620,27 @@ class VoipNotification(private val context: Context) { } client.connect(payload.host) { connected -> + if (!isLiveClient(callId, client)) { + return@connect + } if (!connected) { Log.d(TAG, "DDP connection failed") - stopDDPClientInternal() + ddpRegistry.stopClient(callId) return@connect } client.login(token) { loggedIn -> + if (!isLiveClient(callId, client)) { + return@login + } if (!loggedIn) { Log.d(TAG, "DDP login failed") - stopDDPClientInternal() + ddpRegistry.stopClient(callId) return@login } - isDdpLoggedIn = true - if (flushPendingQueuedSignalsIfNeeded()) { + ddpRegistry.markLoggedIn(callId) + if (flushPendingQueuedSignalsIfNeeded(callId)) { return@login } @@ -570,9 +653,12 @@ class VoipNotification(private val context: Context) { } client.subscribe("stream-notify-user", params) { subscribed -> + if (!isLiveClient(callId, client)) { + return@subscribe + } Log.d(TAG, "DDP subscribe result: $subscribed") if (!subscribed) { - stopDDPClientInternal() + ddpRegistry.stopClient(callId) } } } @@ -583,14 +669,7 @@ class VoipNotification(private val context: Context) { @JvmStatic fun stopDDPClient() { Log.d(TAG, "stopDDPClient called from JS") - stopDDPClientInternal() - } - - private fun stopDDPClientInternal() { - isDdpLoggedIn = false - ddpClient?.clearQueuedMethodCalls() - ddpClient?.disconnect() - ddpClient = null + ddpRegistry.stopAllClients() } } @@ -613,7 +692,24 @@ class VoipNotification(private val context: Context) { fun onMessageReceived(voipPayload: VoipPayload) { when { - voipPayload.isVoipIncomingCall() -> showIncomingCall(voipPayload) + voipPayload.isVoipIncomingCall() -> { + val isValidForIncoming = + voipPayload.getRemainingLifetimeMs() != null && !voipPayload.isExpired() + when (decideIncomingVoipPushAction(isValidForIncoming, hasActiveCall(context))) { + VoipIncomingPushAction.STALE -> { + if (voipPayload.getRemainingLifetimeMs() == null) { + Log.w( + TAG, + "Skipping incoming VoIP call without a valid createdAt timestamp - callId: ${voipPayload.callId}" + ) + } else { + Log.d(TAG, "Skipping expired incoming VoIP call - callId: ${voipPayload.callId}") + } + } + VoipIncomingPushAction.REJECT_BUSY -> rejectBusyCall(context, voipPayload) + VoipIncomingPushAction.SHOW_INCOMING -> showIncomingCall(voipPayload) + } + } else -> Log.w(TAG, "Ignoring unsupported VoIP payload type: ${voipPayload.type}") } } diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipPerCallDdpRegistry.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipPerCallDdpRegistry.kt new file mode 100644 index 00000000000..6f48819f40a --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipPerCallDdpRegistry.kt @@ -0,0 +1,50 @@ +package chat.rocket.reactnative.voip + +/** + * Per-call DDP client slots: each [callId] maps to at most one [DDPClient] in production. + * Isolates teardown so a busy-call reject (call B) does not disconnect call A's listener. + */ +internal class VoipPerCallDdpRegistry( + private val releaseClient: (T) -> Unit +) { + private val lock = Any() + private val clients = mutableMapOf() + private val loggedInCallIds = mutableSetOf() + + fun clientFor(callId: String): T? = synchronized(lock) { clients[callId] } + + fun isLoggedIn(callId: String): Boolean = synchronized(lock) { loggedInCallIds.contains(callId) } + + fun putClient(callId: String, client: T) { + synchronized(lock) { + clients.remove(callId)?.let(releaseClient) + clients[callId] = client + loggedInCallIds.remove(callId) + } + } + + fun markLoggedIn(callId: String) { + synchronized(lock) { + loggedInCallIds.add(callId) + } + } + + fun stopClient(callId: String) { + synchronized(lock) { + loggedInCallIds.remove(callId) + clients.remove(callId)?.let(releaseClient) + } + } + + fun stopAllClients() { + synchronized(lock) { + loggedInCallIds.clear() + clients.values.forEach(releaseClient) + clients.clear() + } + } + + fun clientCount(): Int = synchronized(lock) { clients.size } + + fun clientIds(): Set = synchronized(lock) { clients.keys.toSet() } +} diff --git a/android/app/src/test/java/chat/rocket/reactnative/voip/VoipIncomingCallDispatchTest.kt b/android/app/src/test/java/chat/rocket/reactnative/voip/VoipIncomingCallDispatchTest.kt new file mode 100644 index 00000000000..4dd6cac6d9c --- /dev/null +++ b/android/app/src/test/java/chat/rocket/reactnative/voip/VoipIncomingCallDispatchTest.kt @@ -0,0 +1,39 @@ +package chat.rocket.reactnative.voip + +import org.junit.Assert.assertEquals +import org.junit.Test + +class VoipIncomingCallDispatchTest { + + @Test + fun `stale push with active call does not route to reject busy`() { + assertEquals( + VoipIncomingPushAction.STALE, + decideIncomingVoipPushAction(isValidForIncomingHandling = false, hasActiveCall = true) + ) + } + + @Test + fun `stale push without active call does not route to show incoming`() { + assertEquals( + VoipIncomingPushAction.STALE, + decideIncomingVoipPushAction(isValidForIncomingHandling = false, hasActiveCall = false) + ) + } + + @Test + fun `valid push with active call rejects busy`() { + assertEquals( + VoipIncomingPushAction.REJECT_BUSY, + decideIncomingVoipPushAction(isValidForIncomingHandling = true, hasActiveCall = true) + ) + } + + @Test + fun `valid push without active call shows incoming`() { + assertEquals( + VoipIncomingPushAction.SHOW_INCOMING, + decideIncomingVoipPushAction(isValidForIncomingHandling = true, hasActiveCall = false) + ) + } +} diff --git a/android/app/src/test/java/chat/rocket/reactnative/voip/VoipPerCallDdpRegistryTest.kt b/android/app/src/test/java/chat/rocket/reactnative/voip/VoipPerCallDdpRegistryTest.kt new file mode 100644 index 00000000000..64eb10cda24 --- /dev/null +++ b/android/app/src/test/java/chat/rocket/reactnative/voip/VoipPerCallDdpRegistryTest.kt @@ -0,0 +1,77 @@ +package chat.rocket.reactnative.voip + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class VoipPerCallDdpRegistryTest { + + private fun registry(): Pair, VoipPerCallDdpRegistry> { + val released = mutableListOf() + return released to VoipPerCallDdpRegistry { released.add(it) } + } + + @Test + fun `stopClient removes only the named callId`() { + val (released, reg) = registry() + reg.putClient("callA", "clientA") + reg.putClient("callB", "clientB") + reg.stopClient("callA") + assertEquals(listOf("clientA"), released) + assertEquals(setOf("callB"), reg.clientIds()) + assertEquals("clientB", reg.clientFor("callB")) + assertNull(reg.clientFor("callA")) + } + + @Test + fun `stopAllClients disconnects every entry`() { + val (released, reg) = registry() + reg.putClient("callA", "clientA") + reg.putClient("callB", "clientB") + reg.stopAllClients() + assertEquals(2, released.size) + assertTrue(released.containsAll(listOf("clientA", "clientB"))) + assertEquals(0, reg.clientCount()) + assertTrue(reg.clientIds().isEmpty()) + } + + @Test + fun `starting a second listener for another callId does not release the first`() { + val (released, reg) = registry() + reg.putClient("callA", "clientA") + reg.putClient("callB", "clientB") + assertTrue(released.isEmpty()) + assertEquals(setOf("callA", "callB"), reg.clientIds()) + } + + @Test + fun `putClient for the same callId releases the previous client`() { + val (released, reg) = registry() + reg.putClient("callA", "first") + reg.putClient("callA", "second") + assertEquals(listOf("first"), released) + assertEquals("second", reg.clientFor("callA")) + } + + @Test + fun `loggedIn state is per callId`() { + val (_, reg) = registry() + reg.putClient("callA", "a") + reg.putClient("callB", "b") + assertFalse(reg.isLoggedIn("callA")) + reg.markLoggedIn("callA") + assertTrue(reg.isLoggedIn("callA")) + assertFalse(reg.isLoggedIn("callB")) + } + + @Test + fun `stopClient clears loggedIn for that callId`() { + val (_, reg) = registry() + reg.putClient("callA", "a") + reg.markLoggedIn("callA") + reg.stopClient("callA") + assertFalse(reg.isLoggedIn("callA")) + } +} diff --git a/app/i18n/locales/ar.json b/app/i18n/locales/ar.json index 1e546665cfc..6f7d73bf42d 100644 --- a/app/i18n/locales/ar.json +++ b/app/i18n/locales/ar.json @@ -425,6 +425,8 @@ "Pause": "توقف مؤقت", "Permalink_copied_to_clipboard": "تم نسخ الرابط الثابت إلى الحافظة!", "Phone": "الهاتف", + "Phone_state_permission_message": "يتيح ذلك لـ Rocket.Chat معرفة ما إذا كنت في مكالمة هاتفية أو VoIP حاليًا كي تُعالج المكالمات الواردة بشكل صحيح.", + "Phone_state_permission_title": "السماح بالوصول إلى حالة الهاتف", "Pin": "ثبت", "Pinned": "مثبت", "Play": "لعب", diff --git a/app/i18n/locales/bn-IN.json b/app/i18n/locales/bn-IN.json index 00461f636f3..bc54f394f74 100644 --- a/app/i18n/locales/bn-IN.json +++ b/app/i18n/locales/bn-IN.json @@ -595,6 +595,8 @@ "Permalink_copied_to_clipboard": "পারমালিঙ্ক ক্লিপবোর্ডে কপি করা হয়েছে!", "Person_or_channel": "ব্যক্তি বা চ্যানেল", "Phone": "ফোন", + "Phone_state_permission_message": "এটি Rocket.Chat-কে আপনি ইতিমধ্যে কোনও ফোন বা VoIP কলে আছেন কিনা তা শনাক্ত করতে দেয়, যাতে ইনকামিং কলগুলি সঠিকভাবে পরিচালনা করা যায়।", + "Phone_state_permission_title": "ফোনের অবস্থা অ্যাক্সেসের অনুমতি দিন", "Pin": "পিন", "Pinned": "পিনকরা", "Pinned_a_message": "একটি বার্তা পিন করা হয়েছে:", diff --git a/app/i18n/locales/cs.json b/app/i18n/locales/cs.json index 2dbba085c23..963fa4aaa65 100644 --- a/app/i18n/locales/cs.json +++ b/app/i18n/locales/cs.json @@ -634,6 +634,8 @@ "Permalink_copied_to_clipboard": "Trvalý odkaz zkopírován do schránky!", "Person_or_channel": "Osoba nebo kanál", "Phone": "Telefon", + "Phone_state_permission_message": "Rocket.Chat tak může zjistit, zda již probíhá telefonní nebo hlasový (VoIP) hovor, a správně zpracovat příchozí hovory.", + "Phone_state_permission_title": "Povolit přístup ke stavu telefonu", "Pin": "Kolík", "Pinned": "Připnuto", "Pinned_a_message": "Připnuta zpráva:", diff --git a/app/i18n/locales/de.json b/app/i18n/locales/de.json index 54a8b4c607b..3820aa84fae 100644 --- a/app/i18n/locales/de.json +++ b/app/i18n/locales/de.json @@ -583,6 +583,8 @@ "Pause": "Pause", "Permalink_copied_to_clipboard": "Permalink in die Zwischenablage kopiert!", "Phone": "Telefon", + "Phone_state_permission_message": "Damit kann Rocket.Chat erkennen, ob Sie bereits in einem Telefon- oder VoIP-Gespräch sind, damit eingehende Anrufe korrekt behandelt werden können.", + "Phone_state_permission_title": "Zugriff auf den Telefonstatus erlauben", "Pin": "Anheften", "Pinned": "Angeheftet", "Place_chat_on_hold": "Chat in die Warteschleife stellen", diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 1424e485c6a..c7fcfd0e9a4 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -644,6 +644,7 @@ "Open_your_authentication_app_and_enter_the_code": "Open your authentication app and enter the code.", "OR": "OR", "OS": "OS", + "Ok": "Ok", "Overwrites_the_server_configuration_and_use_room_config": "Overwrites the workspace configuration and use room config", "Owner": "Owner", "Parent_channel_or_group": "Parent channel or group", @@ -662,6 +663,8 @@ "Permalink_copied_to_clipboard": "Permalink copied to clipboard!", "Person_or_channel": "Person or channel", "Phone": "Phone", + "Phone_state_permission_message": "This lets Rocket.Chat detect when you are already on a phone or VoIP call so incoming calls can be handled correctly.", + "Phone_state_permission_title": "Allow phone state access", "Pin": "Pin", "Pinned": "Pinned", "Pinned_a_message": "Pinned a message:", diff --git a/app/i18n/locales/es.json b/app/i18n/locales/es.json index ce474956c45..1a48434414b 100644 --- a/app/i18n/locales/es.json +++ b/app/i18n/locales/es.json @@ -306,6 +306,8 @@ "Passwords_do_not_match": "Las contraseñas no coinciden.", "Pause": "Pausa", "Permalink_copied_to_clipboard": "¡Enlace permanente copiado al portapapeles!", + "Phone_state_permission_message": "Permite que Rocket.Chat detecte si ya está en una llamada telefónica o VoIP para gestionar correctamente las llamadas entrantes.", + "Phone_state_permission_title": "Permitir acceso al estado del teléfono", "Pin": "Fijar", "Pinned": "Fijado", "Play": "Jugar", diff --git a/app/i18n/locales/fi.json b/app/i18n/locales/fi.json index 9bf7a5015ad..527e0589e2d 100644 --- a/app/i18n/locales/fi.json +++ b/app/i18n/locales/fi.json @@ -560,6 +560,8 @@ "Pause": "Tauko", "Permalink_copied_to_clipboard": "Pysyvä linkki kopioitu leikepöydälle!", "Phone": "Puhelin", + "Phone_state_permission_message": "Tämän avulla Rocket.Chat voi havaita, oletko jo puhelimessa tai VoIP-puhelussa, jotta saapuvat puhelut voidaan käsitellä oikein.", + "Phone_state_permission_title": "Salli puhelimen tilan käyttö", "Pin": "Kiinnitä", "Pinned": "Kiinnitetty", "Place_chat_on_hold": "Aseta keskustelu pitoon", diff --git a/app/i18n/locales/fr.json b/app/i18n/locales/fr.json index 972d2b74d05..51bc292be23 100644 --- a/app/i18n/locales/fr.json +++ b/app/i18n/locales/fr.json @@ -520,6 +520,8 @@ "Pause": "Pause", "Permalink_copied_to_clipboard": "Lien permanent copié dans le presse-papiers !", "Phone": "Téléphone", + "Phone_state_permission_message": "Cela permet à Rocket.Chat de détecter si vous êtes déjà en appel téléphonique ou VoIP afin de traiter correctement les appels entrants.", + "Phone_state_permission_title": "Autoriser l’accès à l’état du téléphone", "Pin": "Épingler", "Pinned": "Épinglé", "Place_chat_on_hold": "Mettre le chat en attente", diff --git a/app/i18n/locales/hi-IN.json b/app/i18n/locales/hi-IN.json index 9f7cfa2d41f..a6db967d350 100644 --- a/app/i18n/locales/hi-IN.json +++ b/app/i18n/locales/hi-IN.json @@ -595,6 +595,8 @@ "Permalink_copied_to_clipboard": "पर्मालिंक क्लिपबोर्ड पर कॉपी किया गया!", "Person_or_channel": "व्यक्ति या चैनल", "Phone": "फ़ोन", + "Phone_state_permission_message": "इससे Rocket.Chat यह पता लगा सकता है कि क्या आप पहले से किसी फ़ोन या VoIP कॉल पर हैं, ताकि आने वाली कॉल को सही ढंग से संभाला जा सके।", + "Phone_state_permission_title": "फ़ोन स्थिति तक पहुँच की अनुमति दें", "Pin": "पिन", "Pinned": "पिन किया गया", "Pinned_a_message": "एक संदेश को पिन किया गया है:", diff --git a/app/i18n/locales/hu.json b/app/i18n/locales/hu.json index 808b52c576f..8d42ef93ba7 100644 --- a/app/i18n/locales/hu.json +++ b/app/i18n/locales/hu.json @@ -596,6 +596,8 @@ "Permalink_copied_to_clipboard": "Permalink a vágólapra másolva!", "Person_or_channel": "Személy vagy csatorna", "Phone": "Telefon", + "Phone_state_permission_message": "Így a Rocket.Chat észlelheti, ha már telefonos vagy VoIP-hívásban van, hogy a bejövő hívások helyesen kezelhetők legyenek.", + "Phone_state_permission_title": "Telefonállapot elérésének engedélyezése", "Pin": "Pin", "Pinned": "Kitűzve", "Pinned_a_message": "Kitűzött üzenet:", diff --git a/app/i18n/locales/it.json b/app/i18n/locales/it.json index 842ca1eb51c..2d0a234efcf 100644 --- a/app/i18n/locales/it.json +++ b/app/i18n/locales/it.json @@ -459,6 +459,8 @@ "Pause": "Pausa", "Permalink_copied_to_clipboard": "Permalink copiato negli appunti!", "Phone": "Telefono", + "Phone_state_permission_message": "Consente a Rocket.Chat di rilevare se sei già in una chiamata telefonica o VoIP, così da gestire correttamente le chiamate in arrivo.", + "Phone_state_permission_title": "Consenti l’accesso allo stato del telefono", "Pin": "Appunta", "Pinned": "Appuntati", "Play": "Giocare", diff --git a/app/i18n/locales/ja.json b/app/i18n/locales/ja.json index 99a7b3a5316..54463dc744c 100644 --- a/app/i18n/locales/ja.json +++ b/app/i18n/locales/ja.json @@ -383,6 +383,8 @@ "Passwords_do_not_match": "パスワードが一致しません。", "Pause": "一時停止", "Permalink_copied_to_clipboard": "リンクをクリップボードにコピーしました!", + "Phone_state_permission_message": "電話またはVoIP通話中かどうかをRocket.Chatが検知し、着信を適切に処理できるようにします。", + "Phone_state_permission_title": "電話状態へのアクセスを許可", "Pin": "ピン留め", "Pinned": "ピン留めされました", "Play": "遊ぶ", diff --git a/app/i18n/locales/nl.json b/app/i18n/locales/nl.json index ed78f0aacc7..a8270680f0a 100644 --- a/app/i18n/locales/nl.json +++ b/app/i18n/locales/nl.json @@ -520,6 +520,8 @@ "Pause": "Pauze", "Permalink_copied_to_clipboard": "Permalink gekopiëerd naar klembord!", "Phone": "Telefoon", + "Phone_state_permission_message": "Hiermee kan Rocket.Chat detecteren of u al in een telefoon- of VoIP-gesprek bent, zodat inkomende oproepen correct worden afgehandeld.", + "Phone_state_permission_title": "Toegang tot telefoonstatus toestaan", "Pin": "Vastzetten", "Pinned": "Vastgezet", "Place_chat_on_hold": "Chat in de wacht zetten", diff --git a/app/i18n/locales/nn.json b/app/i18n/locales/nn.json index 53c3ad2dcab..2a21563e8fa 100644 --- a/app/i18n/locales/nn.json +++ b/app/i18n/locales/nn.json @@ -290,6 +290,8 @@ "Passwords_do_not_match": "Passorda stemmer ikkje overeins", "Pause": "Pause", "Phone": "Telefon", + "Phone_state_permission_message": "Dette lèt Rocket.Chat oppdage om du allereie er i ein telefon- eller VoIP-samtale, slik at innkomande samtalar kan handsamast korrekt.", + "Phone_state_permission_title": "Tillat tilgang til telefonstatus", "Pin": "Pin", "Pinned_a_message": "Pinned en melding:", "Please_add_a_comment": "Vennligst legg til en kommentar", diff --git a/app/i18n/locales/no.json b/app/i18n/locales/no.json index 435d844fbf6..c5099736262 100644 --- a/app/i18n/locales/no.json +++ b/app/i18n/locales/no.json @@ -623,6 +623,8 @@ "Permalink_copied_to_clipboard": "Permalenke kopiert til utklippstavlen!", "Person_or_channel": "Person eller kanal", "Phone": "Telefon", + "Phone_state_permission_message": "Dette lar Rocket.Chat oppdage om du allerede er i en telefon- eller VoIP-samtale, slik at innkommende anrop kan håndteres riktig.", + "Phone_state_permission_title": "Tillat tilgang til telefonstatus", "Pin": "Pin", "Pinned": "Festet", "Pinned_a_message": "Festet en melding:", diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json index 202e4fa9767..6bc53a93125 100644 --- a/app/i18n/locales/pt-BR.json +++ b/app/i18n/locales/pt-BR.json @@ -643,6 +643,8 @@ "Permalink_copied_to_clipboard": "Link-permanente copiado para a área de transferência!", "Person_or_channel": "Pessoa ou canal", "Phone": "Telefone", + "Phone_state_permission_message": "Isso permite que o Rocket.Chat detecte quando você já está em uma chamada telefônica ou VoIP para que as chamadas recebidas sejam tratadas corretamente.", + "Phone_state_permission_title": "Permitir acesso ao estado do telefone", "Pin": "Fixar", "Pinned": "Mensagens Fixadas", "Pinned_a_message": "Fixou uma mensagem:", diff --git a/app/i18n/locales/pt-PT.json b/app/i18n/locales/pt-PT.json index f29eb505e13..508f2dbb54e 100644 --- a/app/i18n/locales/pt-PT.json +++ b/app/i18n/locales/pt-PT.json @@ -396,6 +396,8 @@ "Pause": "Pausa", "Permalink_copied_to_clipboard": "Link permanente copiado para a área de transferência!", "Phone": "Telefone", + "Phone_state_permission_message": "Isto permite ao Rocket.Chat detetar se já está numa chamada telefónica ou VoIP, para que as chamadas recebidas sejam tratadas corretamente.", + "Phone_state_permission_title": "Permitir acesso ao estado do telefone", "Pin": "Afixar", "Pinned": "Afixada", "Pinned_a_message": "Fixou uma mensagem:", diff --git a/app/i18n/locales/ru.json b/app/i18n/locales/ru.json index 7989d141de6..959846c0c61 100644 --- a/app/i18n/locales/ru.json +++ b/app/i18n/locales/ru.json @@ -550,6 +550,8 @@ "Pause": "Пауза", "Permalink_copied_to_clipboard": "Постоянная ссылка скопирована в буфер обмена!", "Phone": "Телефон", + "Phone_state_permission_message": "Это позволяет Rocket.Chat определять, идёт ли у вас уже телефонный или VoIP-звонок, чтобы правильно обрабатывать входящие вызовы.", + "Phone_state_permission_title": "Разрешить доступ к состоянию телефона", "Pin": "Прикрепить сообщение", "Pinned": "Прикреплено", "Place_chat_on_hold": "Поставить чат на удержание", diff --git a/app/i18n/locales/sl-SI.json b/app/i18n/locales/sl-SI.json index b553fcf87a1..df6f3740624 100644 --- a/app/i18n/locales/sl-SI.json +++ b/app/i18n/locales/sl-SI.json @@ -532,6 +532,8 @@ "Pause": "Premor", "Permalink_copied_to_clipboard": "Permalink, kopirano v odložišče!", "Phone": "Telefon", + "Phone_state_permission_message": "To omogoča Rocket.Chatu, da zazna, ali ste že v telefonskem ali VoIP klicu, da se dohodni klici pravilno obdelajo.", + "Phone_state_permission_title": "Dovoli dostop do stanja telefona", "Pin": "Zatič", "Pinned": "Pripet", "Place_chat_on_hold": "Postavite klepet na \"pridržan\"", diff --git a/app/i18n/locales/sv.json b/app/i18n/locales/sv.json index 128ead6427a..dbcd9485958 100644 --- a/app/i18n/locales/sv.json +++ b/app/i18n/locales/sv.json @@ -559,6 +559,8 @@ "Pause": "Paus", "Permalink_copied_to_clipboard": "Permalänken har kopierats till urklipp.", "Phone": "Telefonnummer", + "Phone_state_permission_message": "Detta låter Rocket.Chat upptäcka om du redan är i ett telefon- eller VoIP-samtal så att inkommande samtal kan hanteras korrekt.", + "Phone_state_permission_title": "Tillåt åtkomst till telefonstatus", "Pin": "Fäst", "Pinned": "Fästa", "Place_chat_on_hold": "Parkera chatten", diff --git a/app/i18n/locales/ta-IN.json b/app/i18n/locales/ta-IN.json index 97aa4a2ebc6..e03ada72848 100644 --- a/app/i18n/locales/ta-IN.json +++ b/app/i18n/locales/ta-IN.json @@ -595,6 +595,8 @@ "Permalink_copied_to_clipboard": "நிரல்களுக்கு பதில் நகல் எடுக்கப்பட்டுவிட்டது!", "Person_or_channel": "நபர் அல்லது சேனல்", "Phone": "கைபேசி", + "Phone_state_permission_message": "நீங்கள் ஏற்கனவே தொலைபேசி அல்லது VoIP அழைப்பில் இருக்கிறீர்களா என்பதை Rocket.Chat கண்டறிய இது உதவுகிறது; வரும் அழைப்புகள் சரியாக கையாளப்படும்.", + "Phone_state_permission_title": "தொலைபேசி நிலை அணுகலை அனுமதி", "Pin": "சிறுமைக்கான பிணி", "Pinned": "பிண்டு", "Pinned_a_message": "ஒரு செய்தியை கொண்டுவந்தன்:", diff --git a/app/i18n/locales/te-IN.json b/app/i18n/locales/te-IN.json index b37d33c3328..af9faec25c3 100644 --- a/app/i18n/locales/te-IN.json +++ b/app/i18n/locales/te-IN.json @@ -594,6 +594,8 @@ "Permalink_copied_to_clipboard": "పర్మాలింక్ కోపీ అయ్యింది!", "Person_or_channel": "వ్యక్తి లేదా చానల్", "Phone": "ఫోన్", + "Phone_state_permission_message": "మీరు ఇప్పటికే ఫోన్ లేదా VoIP కాల్‌లో ఉన్నారో లేదో Rocket.Chat గుర్తించడానికి ఇది సహాయపడుతుంది, తద్వారా ఇన్‌కమింగ్ కాల్‌లు సరిగ్గా నిర్వహించబడతాయి.", + "Phone_state_permission_title": "ఫోన్ స్థితి యాక్సెస్‌ను అనుమతించండి", "Pin": "పిన్", "Pinned": "పిన్ చేసినది", "Pinned_a_message": "ఒక సందేశాన్ని పించింది:", diff --git a/app/i18n/locales/tr.json b/app/i18n/locales/tr.json index 4fa8d8d4c0c..2cefc6d8732 100644 --- a/app/i18n/locales/tr.json +++ b/app/i18n/locales/tr.json @@ -442,6 +442,8 @@ "Pause": "Duraklat", "Permalink_copied_to_clipboard": "Kalıcı bağlantı panoya kopyalandı!", "Phone": "Telefon", + "Phone_state_permission_message": "Bununla Rocket.Chat, zaten bir telefon veya VoIP görüşmesinde olup olmadığınızı algılayabilir; böylece gelen aramalar doğru şekilde yönetilir.", + "Phone_state_permission_title": "Telefon durumuna erişime izin ver", "Pin": "Sabitle", "Pinned": "Sabitlendi", "Play": "Oyna", diff --git a/app/i18n/locales/zh-CN.json b/app/i18n/locales/zh-CN.json index d08e49e1493..1407c9f7217 100644 --- a/app/i18n/locales/zh-CN.json +++ b/app/i18n/locales/zh-CN.json @@ -425,6 +425,8 @@ "Pause": "暂停", "Permalink_copied_to_clipboard": "永久链接已复制到剪贴板!", "Phone": "电话", + "Phone_state_permission_message": "这样 Rocket.Chat 可以检测您是否已在通话或 VoIP 通话中,从而正确处理来电。", + "Phone_state_permission_title": "允许访问电话状态", "Pin": "钉选", "Pinned": "被钉选", "Play": "玩", diff --git a/app/i18n/locales/zh-TW.json b/app/i18n/locales/zh-TW.json index 5debed3b591..f1cfa644d26 100644 --- a/app/i18n/locales/zh-TW.json +++ b/app/i18n/locales/zh-TW.json @@ -442,6 +442,8 @@ "Pause": "暫停", "Permalink_copied_to_clipboard": "永久連結已複製到剪貼簿!", "Phone": "電話", + "Phone_state_permission_message": "讓 Rocket.Chat 偵測您是否已在一般電話或 VoIP 通話中,以正確處理來電。", + "Phone_state_permission_title": "允許存取電話狀態", "Pin": "釘選", "Pinned": "被釘選", "Play": "遊戲", diff --git a/app/lib/methods/voipPhoneStatePermission.test.ts b/app/lib/methods/voipPhoneStatePermission.test.ts new file mode 100644 index 00000000000..072431a034b --- /dev/null +++ b/app/lib/methods/voipPhoneStatePermission.test.ts @@ -0,0 +1,66 @@ +import { PermissionsAndroid } from 'react-native'; + +describe('requestPhoneStatePermission', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does not call PermissionsAndroid.request when not on Android', () => { + jest.resetModules(); + jest.doMock('./helpers', () => ({ + ...jest.requireActual('./helpers'), + isAndroid: false + })); + const spy = jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue('granted' as never); + const { requestPhoneStatePermission } = require('./voipPhoneStatePermission'); + + requestPhoneStatePermission(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('requests READ_PHONE_STATE on Android with i18n rationale keys', () => { + jest.resetModules(); + const mockT = jest.fn((key: string) => key); + jest.doMock('../../i18n', () => ({ + __esModule: true, + default: { t: mockT } + })); + jest.doMock('./helpers', () => ({ + ...jest.requireActual('./helpers'), + isAndroid: true + })); + const spy = jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue('granted' as never); + const { requestPhoneStatePermission } = require('./voipPhoneStatePermission'); + + requestPhoneStatePermission(); + + expect(mockT).toHaveBeenCalledWith('Ok'); + expect(mockT).toHaveBeenCalledWith('Phone_state_permission_message'); + expect(mockT).toHaveBeenCalledWith('Phone_state_permission_title'); + expect(spy).toHaveBeenCalledWith(PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE, { + buttonPositive: 'Ok', + message: 'Phone_state_permission_message', + title: 'Phone_state_permission_title' + }); + }); + + it('does not prompt again in the same session on Android', () => { + jest.resetModules(); + jest.doMock('../../i18n', () => ({ + __esModule: true, + default: { t: jest.fn((key: string) => key) } + })); + jest.doMock('./helpers', () => ({ + ...jest.requireActual('./helpers'), + isAndroid: true + })); + const spy = jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue('granted' as never); + const { requestPhoneStatePermission } = require('./voipPhoneStatePermission'); + + requestPhoneStatePermission(); + requestPhoneStatePermission(); + + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/lib/methods/voipPhoneStatePermission.ts b/app/lib/methods/voipPhoneStatePermission.ts new file mode 100644 index 00000000000..7801e89abeb --- /dev/null +++ b/app/lib/methods/voipPhoneStatePermission.ts @@ -0,0 +1,22 @@ +import { PermissionsAndroid } from 'react-native'; + +import i18n from '../../i18n'; +import { isAndroid } from './helpers'; + +let askedThisSession = false; + +export const requestPhoneStatePermission = (): void => { + if (!isAndroid) { + return; + } + if (askedThisSession) { + return; + } + askedThisSession = true; + + PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE, { + buttonPositive: i18n.t('Ok'), + message: i18n.t('Phone_state_permission_message'), + title: i18n.t('Phone_state_permission_title') + }); +}; diff --git a/app/lib/services/voip/MediaCallEvents.test.ts b/app/lib/services/voip/MediaCallEvents.test.ts new file mode 100644 index 00000000000..32d2367caa7 --- /dev/null +++ b/app/lib/services/voip/MediaCallEvents.test.ts @@ -0,0 +1,373 @@ +import { DeviceEventEmitter } from 'react-native'; +import RNCallKeep from 'react-native-callkeep'; + +import { DEEP_LINKING } from '../../../actions/actionsTypes'; +import type { VoipPayload } from '../../../definitions/Voip'; +import NativeVoipModule from '../../native/NativeVoip'; +import { getInitialMediaCallEvents, setupMediaCallEvents } from './MediaCallEvents'; +import { useCallStore } from './useCallStore'; + +const mockDispatch = jest.fn(); +const mockSetNativeAcceptedCallId = jest.fn(); +const mockAddEventListener = jest.fn(); +const mockRNCallKeepClearInitialEvents = jest.fn(); +const mockSetCurrentCallActive = jest.fn(); + +jest.mock('../../methods/helpers', () => ({ + ...jest.requireActual('../../methods/helpers'), + isIOS: false +})); + +jest.mock('../../store', () => ({ + __esModule: true, + default: { + dispatch: (...args: unknown[]) => mockDispatch(...args) + } +})); + +jest.mock('./useCallStore', () => ({ + useCallStore: { + getState: jest.fn() + } +})); + +jest.mock('../../native/NativeVoip', () => ({ + __esModule: true, + default: { + clearInitialEvents: jest.fn(), + getInitialEvents: jest.fn(() => null) + } +})); + +jest.mock('react-native-callkeep', () => ({ + __esModule: true, + default: { + addEventListener: (...args: unknown[]) => mockAddEventListener(...args), + clearInitialEvents: (...args: unknown[]) => mockRNCallKeepClearInitialEvents(...args), + setCurrentCallActive: (...args: unknown[]) => mockSetCurrentCallActive(...args), + getInitialEvents: jest.fn(() => Promise.resolve([])) + } +})); + +jest.mock('./MediaSessionInstance', () => ({ + mediaSessionInstance: { + endCall: jest.fn() + } +})); + +jest.mock('../restApi', () => ({ + registerPushToken: jest.fn(() => Promise.resolve()) +})); + +function buildIncomingPayload(overrides: Partial = {}): VoipPayload { + return { + callId: 'call-b-uuid', + caller: 'caller-id', + username: 'caller', + host: 'https://server-b.example.com', + hostName: 'Server B', + type: 'incoming_call', + notificationId: 1, + ...overrides + }; +} + +function getToggleHoldHandler(): (payload: { hold: boolean; callUUID: string }) => void { + const call = mockAddEventListener.mock.calls.find(([name]) => name === 'didToggleHoldCallAction'); + if (!call) { + throw new Error('didToggleHoldCallAction listener not registered'); + } + return call[1] as (payload: { hold: boolean; callUUID: string }) => void; +} + +/** Minimal store slice: handler only runs hold logic when call + matching callId/native id exist. */ +const activeCallBase = { + call: {} as object, + callId: 'uuid-1', + nativeAcceptedCallId: null as string | null +}; + +describe('MediaCallEvents cross-server accept (slice 3)', () => { + const getState = useCallStore.getState as jest.Mock; + + describe('VoipAccept via setupMediaCallEvents', () => { + let teardown: (() => void) | undefined; + + beforeEach(() => { + jest.clearAllMocks(); + mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() })); + (NativeVoipModule.getInitialEvents as jest.Mock).mockReturnValue(null); + getState.mockReturnValue({ setNativeAcceptedCallId: mockSetNativeAcceptedCallId }); + teardown = setupMediaCallEvents(); + }); + + afterEach(() => { + teardown?.(); + teardown = undefined; + }); + + describe('VoipAcceptSucceeded', () => { + it('sets nativeAcceptedCallId and dispatches deepLinkingOpen with host and callId for incoming_call', () => { + const payload = buildIncomingPayload({ + callId: 'workspace-b-call', + host: 'https://workspace-b.open.rocket.chat' + }); + + DeviceEventEmitter.emit('VoipAcceptSucceeded', payload); + + expect(NativeVoipModule.clearInitialEvents).toHaveBeenCalledTimes(1); + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('workspace-b-call'); + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ + type: DEEP_LINKING.OPEN, + params: { + callId: 'workspace-b-call', + host: 'https://workspace-b.open.rocket.chat' + } + }); + }); + + it('does not dispatch or set native id when type is not incoming_call', () => { + DeviceEventEmitter.emit( + 'VoipAcceptSucceeded', + buildIncomingPayload({ + callId: 'outgoing-payload-id', + type: 'outgoing_call' + }) + ); + + expect(NativeVoipModule.clearInitialEvents).not.toHaveBeenCalled(); + expect(mockSetNativeAcceptedCallId).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('dedupes duplicate VoipAcceptSucceeded for the same callId (idempotent native delivery)', () => { + const payload = buildIncomingPayload({ callId: 'dedupe-id' }); + + DeviceEventEmitter.emit('VoipAcceptSucceeded', payload); + DeviceEventEmitter.emit('VoipAcceptSucceeded', payload); + + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledTimes(1); + }); + }); + + describe('VoipAcceptFailed', () => { + it('dispatches deepLinkingOpen with voipAcceptFailed after native failure event', () => { + DeviceEventEmitter.emit( + 'VoipAcceptFailed', + buildIncomingPayload({ + callId: 'failed-b', + host: 'https://workspace-b.example.com', + username: 'remote-user' + }) + ); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ + type: DEEP_LINKING.OPEN, + params: { + host: 'https://workspace-b.example.com', + callId: 'failed-b', + username: 'remote-user', + voipAcceptFailed: true + } + }); + expect(NativeVoipModule.clearInitialEvents).toHaveBeenCalled(); + }); + + it('dedupes duplicate VoipAcceptFailed delivery for the same callId', () => { + const raw = buildIncomingPayload({ callId: 'fail-dedupe' }); + + DeviceEventEmitter.emit('VoipAcceptFailed', raw); + DeviceEventEmitter.emit('VoipAcceptFailed', raw); + + expect(mockDispatch).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('getInitialMediaCallEvents', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() })); + mockRNCallKeepClearInitialEvents.mockClear(); + (NativeVoipModule.getInitialEvents as jest.Mock).mockReset(); + (NativeVoipModule.clearInitialEvents as jest.Mock).mockClear(); + (RNCallKeep.getInitialEvents as jest.Mock).mockResolvedValue([]); + getState.mockReturnValue({ setNativeAcceptedCallId: mockSetNativeAcceptedCallId }); + }); + + it('returns true and dispatches failure deep link when stash has voipAcceptFailed + host + callId', async () => { + (NativeVoipModule.getInitialEvents as jest.Mock).mockReturnValue({ + voipAcceptFailed: true, + callId: 'cold-fail-call', + host: 'https://server-b.cold', + username: 'caller-cold', + caller: 'id', + hostName: 'B', + type: 'incoming_call', + notificationId: 1 + }); + + const result = await getInitialMediaCallEvents(); + + expect(result).toBe(true); + expect(mockDispatch).toHaveBeenCalledWith({ + type: DEEP_LINKING.OPEN, + params: { + host: 'https://server-b.cold', + callId: 'cold-fail-call', + username: 'caller-cold', + voipAcceptFailed: true + } + }); + expect(mockRNCallKeepClearInitialEvents).toHaveBeenCalled(); + expect(NativeVoipModule.clearInitialEvents).toHaveBeenCalled(); + expect(mockSetNativeAcceptedCallId).not.toHaveBeenCalled(); + }); + + it('on Android cold start, dispatches success deep link when incoming payload is present (answered path)', async () => { + (NativeVoipModule.getInitialEvents as jest.Mock).mockReturnValue( + buildIncomingPayload({ + callId: 'android-cold-accept', + host: 'https://android-b.example.com' + }) + ); + + const result = await getInitialMediaCallEvents(); + + expect(result).toBe(true); + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('android-cold-accept'); + expect(mockDispatch).toHaveBeenCalledWith({ + type: DEEP_LINKING.OPEN, + params: { + callId: 'android-cold-accept', + host: 'https://android-b.example.com' + } + }); + }); + }); +}); + +describe('setupMediaCallEvents — didToggleHoldCallAction', () => { + const toggleHold = jest.fn(); + const getState = useCallStore.getState as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + toggleHold.mockClear(); + mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() })); + getState.mockReturnValue({ ...activeCallBase, isOnHold: false, toggleHold }); + }); + + it('registers didToggleHoldCallAction via RNCallKeep.addEventListener', () => { + setupMediaCallEvents(); + expect(mockAddEventListener).toHaveBeenCalledWith('didToggleHoldCallAction', expect.any(Function)); + }); + + it('hold: true when isOnHold is false calls toggleHold once and does not setCurrentCallActive', () => { + setupMediaCallEvents(); + getToggleHoldHandler()({ hold: true, callUUID: 'uuid-1' }); + expect(toggleHold).toHaveBeenCalledTimes(1); + expect(mockSetCurrentCallActive).not.toHaveBeenCalled(); + }); + + it('hold: true when isOnHold is true does not call toggleHold', () => { + getState.mockReturnValue({ ...activeCallBase, isOnHold: true, toggleHold }); + setupMediaCallEvents(); + getToggleHoldHandler()({ hold: true, callUUID: 'uuid-1' }); + expect(toggleHold).not.toHaveBeenCalled(); + }); + + it('hold: false after OS-initiated hold calls toggleHold once (auto-resume) and setCurrentCallActive', () => { + setupMediaCallEvents(); + const handler = getToggleHoldHandler(); + handler({ hold: true, callUUID: 'uuid-1' }); + getState.mockReturnValue({ ...activeCallBase, isOnHold: true, toggleHold }); + handler({ hold: false, callUUID: 'uuid-1' }); + expect(toggleHold).toHaveBeenCalledTimes(2); + expect(mockSetCurrentCallActive).toHaveBeenCalledTimes(1); + expect(mockSetCurrentCallActive).toHaveBeenCalledWith('uuid-1'); + }); + + it('hold: false without prior OS-initiated hold does not call toggleHold or setCurrentCallActive', () => { + setupMediaCallEvents(); + getToggleHoldHandler()({ hold: false, callUUID: 'uuid-1' }); + expect(toggleHold).not.toHaveBeenCalled(); + expect(mockSetCurrentCallActive).not.toHaveBeenCalled(); + }); + + it('consecutive hold: true events call toggleHold only once', () => { + setupMediaCallEvents(); + const handler = getToggleHoldHandler(); + handler({ hold: true, callUUID: 'uuid-1' }); + getState.mockReturnValue({ ...activeCallBase, isOnHold: true, toggleHold }); + handler({ hold: true, callUUID: 'uuid-1' }); + expect(toggleHold).toHaveBeenCalledTimes(1); + }); + + it('clears stale auto-hold when callUUID does not match current call id (e.g. new workspace / call)', () => { + setupMediaCallEvents(); + const handler = getToggleHoldHandler(); + handler({ hold: true, callUUID: 'uuid-1' }); + expect(toggleHold).toHaveBeenCalledTimes(1); + getState.mockReturnValue({ + call: {}, + callId: 'uuid-2', + nativeAcceptedCallId: null, + isOnHold: true, + toggleHold + }); + handler({ hold: false, callUUID: 'uuid-1' }); + expect(toggleHold).toHaveBeenCalledTimes(1); + expect(mockSetCurrentCallActive).not.toHaveBeenCalled(); + handler({ hold: false, callUUID: 'uuid-2' }); + expect(toggleHold).toHaveBeenCalledTimes(1); + expect(mockSetCurrentCallActive).not.toHaveBeenCalled(); + }); + + it('does not toggle when there is no active call object even if ids match', () => { + setupMediaCallEvents(); + const handler = getToggleHoldHandler(); + getState.mockReturnValue({ + call: null, + callId: 'uuid-1', + nativeAcceptedCallId: null, + isOnHold: false, + toggleHold + }); + handler({ hold: true, callUUID: 'uuid-1' }); + expect(toggleHold).not.toHaveBeenCalled(); + }); + + it('hold: false does not call toggleHold when user already manually resumed before OS unhold arrives', () => { + setupMediaCallEvents(); + const handler = getToggleHoldHandler(); + + // OS holds the call + handler({ hold: true, callUUID: 'uuid-1' }); + expect(toggleHold).toHaveBeenCalledTimes(1); + + // User manually resumes — isOnHold is now false + getState.mockReturnValue({ ...activeCallBase, isOnHold: false, toggleHold }); + + // OS sends hold: false — should be a no-op since call is already resumed + handler({ hold: false, callUUID: 'uuid-1' }); + expect(toggleHold).toHaveBeenCalledTimes(1); + expect(mockSetCurrentCallActive).not.toHaveBeenCalled(); + }); + + it('cleanup removes didToggleHoldCallAction subscription', () => { + const remove = jest.fn(); + mockAddEventListener.mockImplementation((event: string) => { + if (event === 'didToggleHoldCallAction') { + return { remove }; + } + return { remove: jest.fn() }; + }); + const cleanup = setupMediaCallEvents(); + cleanup(); + expect(remove).toHaveBeenCalled(); + }); +}); diff --git a/app/lib/services/voip/MediaCallEvents.ts b/app/lib/services/voip/MediaCallEvents.ts index e36f5daf341..86cfc73d96a 100644 --- a/app/lib/services/voip/MediaCallEvents.ts +++ b/app/lib/services/voip/MediaCallEvents.ts @@ -95,6 +95,38 @@ export const setupMediaCallEvents = (): (() => void) => { // signal before JS runs. JS receives VoipAcceptSucceeded after success. } + /** Tracks OS-driven hold (competing call) so we only auto-resume that path, not manual hold. */ + let wasAutoHeld = false; + subscriptions.push( + RNCallKeep.addEventListener('didToggleHoldCallAction', ({ hold, callUUID }) => { + const { call, callId, nativeAcceptedCallId, isOnHold, toggleHold } = useCallStore.getState(); + const eventUuid = callUUID.toLowerCase(); + const activeUuid = (callId ?? nativeAcceptedCallId ?? '').toLowerCase(); + + // No active media call or event is for another CallKit/Telecom session — drop stale closure state + // (e.g. workspace/server switch, logout, or call ended while setupMediaCallEvents still lives on Root). + if (!call || !activeUuid || eventUuid !== activeUuid) { + wasAutoHeld = false; + return; + } + + if (hold) { + if (!isOnHold) { + toggleHold(); + wasAutoHeld = true; + } + return; + } + if (wasAutoHeld) { + if (isOnHold) { + toggleHold(); + RNCallKeep.setCurrentCallActive(callUUID); + } + wasAutoHeld = false; + } + }) + ); + subscriptions.push( Emitter.addListener(EVENT_VOIP_ACCEPT_SUCCEEDED, (data: VoipPayload) => { try { diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts index ccca424a58b..2418d2c82a0 100644 --- a/app/lib/services/voip/MediaSessionInstance.test.ts +++ b/app/lib/services/voip/MediaSessionInstance.test.ts @@ -1,4 +1,8 @@ +import type { IClientMediaCall } from '@rocket.chat/media-signaling'; +import RNCallKeep from 'react-native-callkeep'; + import type { IDDPMessage } from '../../../definitions/IDDPMessage'; +import Navigation from '../../navigation/appNavigation'; import { mediaSessionStore } from './MediaSessionStore'; import { mediaSessionInstance } from './MediaSessionInstance'; @@ -47,7 +51,14 @@ jest.mock('react-native-webrtc', () => ({ mediaDevices: { getUserMedia: jest.fn() } })); -jest.mock('react-native-callkeep', () => ({})); +jest.mock('react-native-callkeep', () => ({ + __esModule: true, + default: { + endCall: jest.fn(), + setCurrentCallActive: jest.fn(), + setAvailable: jest.fn() + } +})); jest.mock('react-native-device-info', () => ({ getUniqueId: jest.fn(() => 'test-device-id'), @@ -64,6 +75,11 @@ jest.mock('../../navigation/appNavigation', () => ({ default: { navigate: jest.fn() } })); +const mockRequestPhoneStatePermission = jest.fn(); +jest.mock('../../methods/voipPhoneStatePermission', () => ({ + requestPhoneStatePermission: () => mockRequestPhoneStatePermission() +})); + type MockMediaSignalingSession = { userId: string; sessionId: string; @@ -110,6 +126,35 @@ function getStreamNotifyHandler(): (ddpMessage: IDDPMessage) => void { throw new Error('stream-notify-user handler not registered'); } +function getNewCallHandler(): (payload: { call: IClientMediaCall }) => void { + const session = createdSessions[0]; + if (!session) { + throw new Error('no session created'); + } + const entry = session.on.mock.calls.find(([name]) => name === 'newCall'); + if (!entry) { + throw new Error('newCall handler not registered'); + } + return entry[1] as (payload: { call: IClientMediaCall }) => void; +} + +function buildClientMediaCall(options: { + callId: string; + role: 'caller' | 'callee'; + hidden?: boolean; + reject?: jest.Mock; +}): IClientMediaCall { + const reject = options.reject ?? jest.fn(); + const emitter = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }; + return { + callId: options.callId, + role: options.role, + hidden: options.hidden ?? false, + reject, + emitter: emitter as unknown as IClientMediaCall['emitter'] + } as unknown as IClientMediaCall; +} + describe('MediaSessionInstance', () => { beforeEach(() => { jest.clearAllMocks(); @@ -197,6 +242,75 @@ describe('MediaSessionInstance', () => { }); }); + describe('newCall (no JS busy-reject; native decides)', () => { + it('allows incoming callee newCall when store already has an active call', () => { + const mockSetCall = jest.fn(); + mockUseCallStoreGetState.mockReturnValue({ + reset: mockCallStoreReset, + setCall: mockSetCall, + resetNativeCallId: jest.fn(), + call: { callId: 'active-a' } as IClientMediaCall, + callId: 'active-a', + nativeAcceptedCallId: null + }); + mediaSessionInstance.init('user-1'); + const incoming = buildClientMediaCall({ callId: 'incoming-b', role: 'callee' }); + getNewCallHandler()({ call: incoming }); + expect(incoming.reject).not.toHaveBeenCalled(); + expect(RNCallKeep.endCall).not.toHaveBeenCalledWith('incoming-b'); + }); + + it('allows incoming callee newCall when nativeAcceptedCallId is set but differs from incoming callId', () => { + mockUseCallStoreGetState.mockReturnValue({ + reset: mockCallStoreReset, + setCall: jest.fn(), + resetNativeCallId: jest.fn(), + call: { callId: 'active-a' } as IClientMediaCall, + callId: 'active-a', + nativeAcceptedCallId: 'native-other' + }); + mediaSessionInstance.init('user-1'); + const incoming = buildClientMediaCall({ callId: 'incoming-b', role: 'callee' }); + getNewCallHandler()({ call: incoming }); + expect(incoming.reject).not.toHaveBeenCalled(); + expect(RNCallKeep.endCall).not.toHaveBeenCalledWith('incoming-b'); + }); + + it('allows incoming callee newCall when nativeAcceptedCallId matches incoming callId', () => { + mockUseCallStoreGetState.mockReturnValue({ + reset: mockCallStoreReset, + setCall: jest.fn(), + resetNativeCallId: jest.fn(), + call: null, + callId: null, + nativeAcceptedCallId: 'same-id' + }); + mediaSessionInstance.init('user-1'); + const incoming = buildClientMediaCall({ callId: 'same-id', role: 'callee' }); + getNewCallHandler()({ call: incoming }); + expect(incoming.reject).not.toHaveBeenCalled(); + expect(RNCallKeep.endCall).not.toHaveBeenCalledWith('same-id'); + }); + + it('does not reject outgoing (caller) newCall; binds call and navigates', () => { + const mockSetCall = jest.fn(); + mockUseCallStoreGetState.mockReturnValue({ + reset: mockCallStoreReset, + setCall: mockSetCall, + resetNativeCallId: jest.fn(), + call: null, + callId: null, + nativeAcceptedCallId: null + }); + mediaSessionInstance.init('user-1'); + const outgoing = buildClientMediaCall({ callId: 'out-c', role: 'caller' }); + getNewCallHandler()({ call: outgoing }); + expect(outgoing.reject).not.toHaveBeenCalled(); + expect(mockSetCall).toHaveBeenCalledWith(outgoing); + expect(Navigation.navigate).toHaveBeenCalledWith('CallView'); + }); + }); + describe('stream-notify-user (notification/accepted gated)', () => { it('does not call answerCall when nativeAcceptedCallId is null', async () => { const answerSpy = jest.spyOn(mediaSessionInstance, 'answerCall').mockResolvedValue(undefined); @@ -314,4 +428,15 @@ describe('MediaSessionInstance', () => { answerSpy.mockRestore(); }); }); + + describe('startCall', () => { + it('requests phone state permission fire-and-forget when starting a call', () => { + mediaSessionInstance.init('user-1'); + mockRequestPhoneStatePermission.mockClear(); + const session = createdSessions[0]; + mediaSessionInstance.startCall('peer-1', 'user'); + expect(mockRequestPhoneStatePermission).toHaveBeenCalledTimes(1); + expect(session.startCall).toHaveBeenCalledWith('user', 'peer-1'); + }); + }); }); diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index f60af626c28..613092f27d5 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -20,6 +20,7 @@ import type { IceServer } from '../../../definitions/Voip'; import type { IDDPMessage } from '../../../definitions/IDDPMessage'; import type { ISubscription, TSubscriptionModel } from '../../../definitions'; import { getUidDirectMessage } from '../../methods/helpers/helpers'; +import { requestPhoneStatePermission } from '../../methods/voipPhoneStatePermission'; class MediaSessionInstance { private iceServers: IceServer[] = []; @@ -137,6 +138,7 @@ class MediaSessionInstance { }; public startCall = (userId: string, actor: CallActorType) => { + requestPhoneStatePermission(); console.log('[VoIP] Starting call:', userId); this.instance?.startCall(actor, userId); }; diff --git a/app/lib/services/voip/voipBlocksIncomingVideoconf.test.ts b/app/lib/services/voip/voipBlocksIncomingVideoconf.test.ts new file mode 100644 index 00000000000..72adfd61b02 --- /dev/null +++ b/app/lib/services/voip/voipBlocksIncomingVideoconf.test.ts @@ -0,0 +1,52 @@ +import type { IClientMediaCall } from '@rocket.chat/media-signaling'; + +import { voipBlocksIncomingVideoconf } from './voipBlocksIncomingVideoconf'; + +type CallStoreSlice = { + call: IClientMediaCall | null; + nativeAcceptedCallId: string | null; +}; + +const mockGetState = jest.fn( + (): CallStoreSlice => ({ + call: null, + nativeAcceptedCallId: null + }) +); + +jest.mock('./useCallStore', () => ({ + useCallStore: { + getState: () => mockGetState() + } +})); + +describe('voipBlocksIncomingVideoconf', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetState.mockReturnValue({ + call: null, + nativeAcceptedCallId: null + }); + }); + + it('returns true when VoIP store has an active call', () => { + const activeCall = { callId: 'voip-1' } as unknown as IClientMediaCall; + mockGetState.mockReturnValue({ + call: activeCall, + nativeAcceptedCallId: null + }); + expect(voipBlocksIncomingVideoconf()).toBe(true); + }); + + it('returns true when nativeAcceptedCallId is set (pending native bind)', () => { + mockGetState.mockReturnValue({ + call: null, + nativeAcceptedCallId: 'pending' + }); + expect(voipBlocksIncomingVideoconf()).toBe(true); + }); + + it('returns false when there is no active VoIP call and no pending native accept', () => { + expect(voipBlocksIncomingVideoconf()).toBe(false); + }); +}); diff --git a/app/lib/services/voip/voipBlocksIncomingVideoconf.ts b/app/lib/services/voip/voipBlocksIncomingVideoconf.ts new file mode 100644 index 00000000000..b80d37a2748 --- /dev/null +++ b/app/lib/services/voip/voipBlocksIncomingVideoconf.ts @@ -0,0 +1,7 @@ +import { useCallStore } from './useCallStore'; + +/** When true, incoming direct videoconf "call" handling should no-op (VoIP already active or native accept not yet bound). */ +export function voipBlocksIncomingVideoconf(): boolean { + const { call, nativeAcceptedCallId } = useCallStore.getState(); + return call != null || nativeAcceptedCallId != null; +} diff --git a/app/sagas/videoConf.ts b/app/sagas/videoConf.ts index d9ca57c4f2a..19126bf0131 100644 --- a/app/sagas/videoConf.ts +++ b/app/sagas/videoConf.ts @@ -20,6 +20,7 @@ import { showToast } from '../lib/methods/helpers/showToast'; import { videoConfJoin } from '../lib/methods/videoConf'; import { videoConferenceCancel, notifyUser, videoConferenceStart } from '../lib/services/restApi'; import { type ICallInfo } from '../reducers/videoConf'; +import { voipBlocksIncomingVideoconf } from '../lib/services/voip/voipBlocksIncomingVideoconf'; interface IGenericAction extends Action { type: string; @@ -47,6 +48,8 @@ const CALL_INTERVAL = 3000; const CALL_ATTEMPT_LIMIT = 10; function* onDirectCall(payload: ICallInfo) { + if (voipBlocksIncomingVideoconf()) return; + const calls = yield* appSelector(state => state.videoConf.calls); const currentCall = calls.find(c => c.callId === payload.callId); const hasAnotherCall = calls.find(c => c.action === 'call'); diff --git a/ios/Libraries/AppDelegate+Voip.swift b/ios/Libraries/AppDelegate+Voip.swift index 81b7f7ccc3c..a5d36d60af2 100644 --- a/ios/Libraries/AppDelegate+Voip.swift +++ b/ios/Libraries/AppDelegate+Voip.swift @@ -40,7 +40,7 @@ extension AppDelegate: PKPushRegistryDelegate { return } - VoipService.prepareIncomingCall(voipPayload) + VoipService.prepareIncomingCall(voipPayload, storeEventsForJs: true) RNCallKeep.reportNewIncomingCall( callId, diff --git a/ios/Libraries/VoipPerCallDdpRegistry.swift b/ios/Libraries/VoipPerCallDdpRegistry.swift new file mode 100644 index 00000000000..607279cadc3 --- /dev/null +++ b/ios/Libraries/VoipPerCallDdpRegistry.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Isolates DDP clients by `callId` so a second incoming call does not tear down the first listener. +/// Mirrors Android `VoipPerCallDdpRegistry` (see `VoipPerCallDdpRegistryTest` on Android). +final class VoipPerCallDdpRegistry { + private let lock = NSLock() + private var clients: [String: T] = [:] + private var loggedInCallIds = Set() + private let releaseClient: (T) -> Void + + init(releaseClient: @escaping (T) -> Void) { + self.releaseClient = releaseClient + } + + func clientFor(callId: String) -> T? { + lock.lock() + defer { lock.unlock() } + return clients[callId] + } + + func isLoggedIn(callId: String) -> Bool { + lock.lock() + defer { lock.unlock() } + return loggedInCallIds.contains(callId) + } + + func putClient(callId: String, client: T) { + lock.lock() + defer { lock.unlock() } + if let old = clients.removeValue(forKey: callId) { + loggedInCallIds.remove(callId) + releaseClient(old) + } + clients[callId] = client + } + + func markLoggedIn(callId: String) { + lock.lock() + defer { lock.unlock() } + loggedInCallIds.insert(callId) + } + + func stopClient(callId: String) { + lock.lock() + defer { lock.unlock() } + loggedInCallIds.remove(callId) + if let c = clients.removeValue(forKey: callId) { + releaseClient(c) + } + } + + func stopAllClients() { + lock.lock() + defer { lock.unlock() } + loggedInCallIds.removeAll() + clients.values.forEach(releaseClient) + clients.removeAll() + } + + func clientCount() -> Int { + lock.lock() + defer { lock.unlock() } + return clients.count + } + + func clientIds() -> Set { + lock.lock() + defer { lock.unlock() } + return Set(clients.keys) + } +} diff --git a/ios/Libraries/VoipService.swift b/ios/Libraries/VoipService.swift index ae8ce33af2a..2ff974286b5 100644 --- a/ios/Libraries/VoipService.swift +++ b/ios/Libraries/VoipService.swift @@ -36,24 +36,28 @@ public final class VoipService: NSObject { private static var lastVoipToken: String = loadPersistedVoipToken() private static var voipRegistry: PKPushRegistry? private static var incomingCallTimeouts: [String: DispatchWorkItem] = [:] - private static var ddpClient: DDPClient? + private static let ddpRegistry = VoipPerCallDdpRegistry { client in + client.clearQueuedMethodCalls() + client.disconnect() + } private static let callObserver = CXCallObserver() private static let incomingCallObserver = IncomingCallObserver() private static var isCallObserverConfigured = false - private static var observedIncomingCall: ObservedIncomingCall? - private static var isDdpLoggedIn = false + private static var observedIncomingCalls: [UUID: ObservedIncomingCall] = [:] /// Deduplication guard: `CXCallObserver` can call `callChanged` with `hasConnected = true` /// multiple times for the same call (e.g. observer re-registration, system race). This set - /// ensures `handleNativeAccept` sends the DDP accept signal exactly once per callId. + /// ensures `handleNativeAccept` sends the DDP accept signal exactly once per `callId` (not a + /// single global slot — several distinct `callId`s may be present during call-waiting). /// - /// Lifecycle: + /// Lifecycle (per `callId`): /// Added: At the start of `handleNativeAccept()`, before any DDP call. /// Removed: After native accept DDP succeeds or fails, /// on call timeout (`handleIncomingCallTimeout`), /// on DDP call-end signal from another device (ddp stream listener), - /// on CallKit call-ended observer event (only before connect — `observedIncomingCall` is cleared on answer). + /// on CallKit call-ended observer event (only before connect — that call's entry is removed from `observedIncomingCalls` on answer). /// - /// Memory: One entry only while a native accept is in flight; cleared when the DDP accept finishes or other exit paths run. + /// Memory: Each `callId` is tracked independently while its native accept is in flight; entries are + /// cleared when that call's DDP accept finishes or another exit path runs for that `callId`. private static var nativeAcceptHandledCallIds = Set() private enum VoipMediaCallAnswerKind { @@ -135,8 +139,24 @@ public final class VoipService: NSObject { #endif } - public static func prepareIncomingCall(_ payload: VoipPayload) { - storeInitialEvents(payload) + /// Returns `true` when CXCallObserver reports any non-ended call (ringing or connected), + /// including phone, FaceTime, and third-party VoIP. + /// + /// **Call-waiting (current `AppDelegate+Voip` behavior):** This is **not** called from the PushKit + /// path; CallKit handles multiple simultaneous calls. Kept for parity with Android busy detection, + /// documentation of `prepareIncomingCall(_:storeEventsForJs:)`, and optional future or test use. + public static func hasActiveCall() -> Bool { + configureCallObserverIfNeeded() + return callObserver.calls.contains { !$0.hasEnded } + } + + /// Prepares DDP listener and timeout for an incoming VoIP push. When `storeEventsForJs` is false + /// (e.g. user is already on a call and we will `rejectBusyCall` immediately), skip stashing payload + /// for `getInitialEvents` so JS does not treat an auto-rejected call as a real incoming ring. + public static func prepareIncomingCall(_ payload: VoipPayload, storeEventsForJs: Bool = true) { + if storeEventsForJs { + storeInitialEvents(payload) + } scheduleIncomingCallTimeout(for: payload) startListeningForCallEnd(payload: payload) } @@ -239,7 +259,7 @@ public final class VoipService: NSObject { private static func handleIncomingCallTimeout(for payload: VoipPayload) { incomingCallTimeouts.removeValue(forKey: payload.callId) clearTrackedIncomingCall(for: payload.callUUID) - stopDDPClientInternal() + stopDDPClientInternal(callId: payload.callId) clearNativeAcceptDedupe(for: payload.callId) let callId = payload.callId @@ -259,10 +279,12 @@ public final class VoipService: NSObject { // MARK: - Native DDP Listener (Call End Detection) + private static func isLiveClient(callId: String, client: DDPClient) -> Bool { + ddpRegistry.clientFor(callId: callId) === client + } + /// Opens a lightweight DDP WebSocket to detect call hangup before JS boots. private static func startListeningForCallEnd(payload: VoipPayload) { - stopDDPClientInternal() - let credentialStorage = Storage() guard let credentials = credentialStorage.getCredentials(server: payload.host.removeTrailingSlash()) else { #if DEBUG @@ -275,8 +297,7 @@ public final class VoipService: NSObject { let userId = credentials.userId let deviceId = DeviceUID.uid() let client = DDPClient() - ddpClient = client - isDdpLoggedIn = false + ddpRegistry.putClient(callId: callId, client: client) trackIncomingCall(payload) #if DEBUG @@ -284,7 +305,7 @@ public final class VoipService: NSObject { #endif client.onCollectionMessage = { message in - guard ddpClient === client else { + guard isLiveClient(callId: callId, client: client) else { return } guard let fields = message["fields"] as? [String: Any], @@ -315,43 +336,43 @@ public final class VoipService: NSObject { #endif DispatchQueue.main.async { - guard ddpClient === client else { + guard isLiveClient(callId: callId, client: client) else { return } clearTrackedIncomingCall(for: payload.callUUID) clearNativeAcceptDedupe(for: callId) RNCallKeep.endCall(withUUID: callId, reason: 3) cancelIncomingCallTimeout(for: callId) - stopDDPClientInternal() + stopDDPClientInternal(callId: callId) } } client.connect(host: payload.host) { connected in - guard ddpClient === client else { + guard isLiveClient(callId: callId, client: client) else { return } guard connected else { #if DEBUG print("[\(TAG)] DDP connection failed") #endif - stopDDPClientInternal() + stopDDPClientInternal(callId: callId) return } client.login(token: credentials.userToken) { loggedIn in - guard ddpClient === client else { + guard isLiveClient(callId: callId, client: client) else { return } guard loggedIn else { #if DEBUG print("[\(TAG)] DDP login failed") #endif - stopDDPClientInternal() + stopDDPClientInternal(callId: callId) return } - isDdpLoggedIn = true - if flushPendingQueuedSignalsIfNeeded() { + ddpRegistry.markLoggedIn(callId: callId) + if flushPendingQueuedSignalsIfNeeded(callId: callId) { return } @@ -361,14 +382,14 @@ public final class VoipService: NSObject { ] client.subscribe(name: "stream-notify-user", params: params) { subscribed in - guard ddpClient === client else { + guard isLiveClient(callId: callId, client: client) else { return } #if DEBUG print("[\(TAG)] DDP subscribe result: \(subscribed)") #endif if !subscribed { - stopDDPClientInternal() + stopDDPClientInternal(callId: callId) } } } @@ -384,12 +405,12 @@ public final class VoipService: NSObject { stopDDPClientInternal() } + private static func stopDDPClientInternal(callId: String) { + ddpRegistry.stopClient(callId: callId) + } + private static func stopDDPClientInternal() { - isDdpLoggedIn = false - observedIncomingCall = nil - ddpClient?.clearQueuedMethodCalls() - ddpClient?.disconnect() - ddpClient = nil + ddpRegistry.stopAllClients() } // MARK: - Native DDP signaling (accept / reject) @@ -401,7 +422,7 @@ public final class VoipService: NSObject { #if DEBUG print("[\(TAG)] Missing credentials, cannot build media-call answer params for \(payload.callId)") #endif - stopDDPClientInternal() + stopDDPClientInternal(callId: payload.callId) return nil } @@ -419,7 +440,7 @@ public final class VoipService: NSObject { let signalData = try? JSONSerialization.data(withJSONObject: signal), let signalString = String(data: signalData, encoding: .utf8) else { - stopDDPClientInternal() + stopDDPClientInternal(callId: payload.callId) return nil } @@ -436,7 +457,7 @@ public final class VoipService: NSObject { cancelIncomingCallTimeout(for: payload.callId) let finishAccept: (Bool) -> Void = { success in - stopDDPClientInternal() + stopDDPClientInternal(callId: payload.callId) if success { storeInitialEvents(payload) clearNativeAcceptDedupe(for: payload.callId) @@ -485,7 +506,7 @@ public final class VoipService: NSObject { } } - guard let client = ddpClient else { + guard let client = ddpRegistry.clientFor(callId: payload.callId) else { #if DEBUG print("[\(TAG)] Native DDP client unavailable for accept \(payload.callId); relying on JS") #endif @@ -498,7 +519,7 @@ public final class VoipService: NSObject { return } - if isDdpLoggedIn { + if ddpRegistry.isLoggedIn(callId: payload.callId) { client.callMethod("stream-notify-user", params: params) { success in #if DEBUG print("[\(TAG)] Native accept signal result for \(payload.callId): \(success)") @@ -518,8 +539,37 @@ public final class VoipService: NSObject { } } + /// Rejects an incoming call because the user is already on another call. + /// Must be called **after** `reportNewIncomingCall` (PushKit requirement). + /// + /// **Call-waiting:** `AppDelegate+Voip` does **not** invoke this; second rings are shown in CallKit + /// instead of auto-rejecting. Same rationale as `hasActiveCall()` — API remains for Android-aligned + /// flows, `storeEventsForJs: false` cleanup, and future wiring. + public static func rejectBusyCall(_ payload: VoipPayload) { + cancelIncomingCallTimeout(for: payload.callId) + clearTrackedIncomingCall(for: payload.callUUID) + + if initialEventsData?.callId == payload.callId { + clearInitialEventsInternal() + } + + // End the just-reported CallKit call immediately (reason 2 = unanswered / declined). + RNCallKeep.endCall(withUUID: payload.callId, reason: 2) + + // Send reject signal via native DDP if available, otherwise queue it. + if ddpRegistry.isLoggedIn(callId: payload.callId) { + sendRejectSignal(payload: payload) + } else { + queueRejectSignal(payload: payload) + } + + #if DEBUG + print("[\(TAG)] Rejected busy call \(payload.callId) — user already on a call") + #endif + } + private static func sendRejectSignal(payload: VoipPayload) { - guard let client = ddpClient else { + guard let client = ddpRegistry.clientFor(callId: payload.callId) else { #if DEBUG print("[\(TAG)] Native DDP client unavailable, cannot send reject for \(payload.callId)") #endif @@ -534,12 +584,12 @@ public final class VoipService: NSObject { #if DEBUG print("[\(TAG)] Native reject signal result for \(payload.callId): \(success)") #endif - stopDDPClientInternal() + stopDDPClientInternal(callId: payload.callId) } } private static func queueRejectSignal(payload: VoipPayload) { - guard let client = ddpClient else { + guard let client = ddpRegistry.clientFor(callId: payload.callId) else { #if DEBUG print("[\(TAG)] Native DDP client unavailable, cannot queue reject for \(payload.callId)") #endif @@ -554,12 +604,12 @@ public final class VoipService: NSObject { #if DEBUG print("[\(TAG)] Queued native reject signal result for \(payload.callId): \(success)") #endif - stopDDPClientInternal() + stopDDPClientInternal(callId: payload.callId) } } - private static func flushPendingQueuedSignalsIfNeeded() -> Bool { - guard let client = ddpClient, client.hasQueuedMethodCalls() else { + private static func flushPendingQueuedSignalsIfNeeded(callId: String) -> Bool { + guard let client = ddpRegistry.clientFor(callId: callId), client.hasQueuedMethodCalls() else { return false } @@ -579,7 +629,7 @@ public final class VoipService: NSObject { private static func trackIncomingCall(_ payload: VoipPayload) { let trackCall = { configureCallObserverIfNeeded() - observedIncomingCall = ObservedIncomingCall(payload: payload) + observedIncomingCalls[payload.callUUID] = ObservedIncomingCall(payload: payload) } if Thread.isMainThread { @@ -590,12 +640,8 @@ public final class VoipService: NSObject { } private static func clearTrackedIncomingCall(for callUUID: UUID) { - let clearCall = { - guard observedIncomingCall?.payload.callUUID == callUUID else { - return - } - - observedIncomingCall = nil + let clearCall: () -> Void = { + _ = observedIncomingCalls.removeValue(forKey: callUUID) } if Thread.isMainThread { @@ -606,14 +652,13 @@ public final class VoipService: NSObject { } private static func handleObservedCallChanged(_ call: CXCall) { - guard let observedCall = observedIncomingCall, observedCall.payload.callUUID == call.uuid else { + guard let observedCall = observedIncomingCalls[call.uuid] else { return } if call.hasConnected { - let payload = observedCall.payload - observedIncomingCall = nil - handleNativeAccept(payload: payload) + observedIncomingCalls.removeValue(forKey: call.uuid) + handleNativeAccept(payload: observedCall.payload) return } @@ -621,11 +666,12 @@ public final class VoipService: NSObject { return } - observedIncomingCall = nil + observedIncomingCalls.removeValue(forKey: call.uuid) cancelIncomingCallTimeout(for: observedCall.payload.callId) clearNativeAcceptDedupe(for: observedCall.payload.callId) - if isDdpLoggedIn { + let endedCallId = observedCall.payload.callId + if ddpRegistry.isLoggedIn(callId: endedCallId) { sendRejectSignal(payload: observedCall.payload) } else { queueRejectSignal(payload: observedCall.payload) diff --git a/ios/RocketChatRN.xcodeproj/project.pbxproj b/ios/RocketChatRN.xcodeproj/project.pbxproj index 139b1bb5d7f..8f027a9d52d 100644 --- a/ios/RocketChatRN.xcodeproj/project.pbxproj +++ b/ios/RocketChatRN.xcodeproj/project.pbxproj @@ -305,6 +305,8 @@ 7A1B58422F5F58FF002A6BDE /* VoipPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B58402F5F58FF002A6BDE /* VoipPayload.swift */; }; 7A1B58442F5F63DB002A6BDE /* AppDelegate+Voip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B58432F5F63DB002A6BDE /* AppDelegate+Voip.swift */; }; 7A1B58452F5F63DB002A6BDE /* AppDelegate+Voip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B58432F5F63DB002A6BDE /* AppDelegate+Voip.swift */; }; + 7A3704562F7DB36E009085FC /* VoipPerCallDdpRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3704552F7DB36E009085FC /* VoipPerCallDdpRegistry.swift */; }; + 7A3704572F7DB36E009085FC /* VoipPerCallDdpRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3704552F7DB36E009085FC /* VoipPerCallDdpRegistry.swift */; }; 7A610CD227ECE38100B8ABDD /* custom.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7A610CD127ECE38100B8ABDD /* custom.ttf */; }; 7A610CD427ECE38100B8ABDD /* custom.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7A610CD127ECE38100B8ABDD /* custom.ttf */; }; 7A610CD527ECE38100B8ABDD /* custom.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7A610CD127ECE38100B8ABDD /* custom.ttf */; }; @@ -640,6 +642,7 @@ 7A14FCF3257FEB59005BDCD4 /* Official.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Official.xcassets; sourceTree = ""; }; 7A1B58402F5F58FF002A6BDE /* VoipPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoipPayload.swift; sourceTree = ""; }; 7A1B58432F5F63DB002A6BDE /* AppDelegate+Voip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Voip.swift"; sourceTree = ""; }; + 7A3704552F7DB36E009085FC /* VoipPerCallDdpRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoipPerCallDdpRegistry.swift; sourceTree = ""; }; 7A610CD127ECE38100B8ABDD /* custom.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = custom.ttf; sourceTree = ""; }; 7A8B30742BCD9D3F00146A40 /* SSLPinning.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SSLPinning.h; sourceTree = ""; }; 7A8B30752BCD9D3F00146A40 /* SSLPinning.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = SSLPinning.mm; sourceTree = ""; }; @@ -1146,6 +1149,7 @@ 7A76DEE42F1AA6EF00750653 /* Libraries */ = { isa = PBXGroup; children = ( + 7A3704552F7DB36E009085FC /* VoipPerCallDdpRegistry.swift */, 7A1B58432F5F63DB002A6BDE /* AppDelegate+Voip.swift */, 7A1B58402F5F58FF002A6BDE /* VoipPayload.swift */, 7A0000042F1BAFA700B6B4BD /* VoipModule.mm */, @@ -1783,7 +1787,7 @@ inputFileListPaths = ( ); inputPaths = ( - "$TARGET_BUILD_DIR/$INFOPLIST_PATH", + $TARGET_BUILD_DIR/$INFOPLIST_PATH, ); name = "Upload source maps to Bugsnag"; outputFileListPaths = ( @@ -1803,7 +1807,7 @@ inputFileListPaths = ( ); inputPaths = ( - "$TARGET_BUILD_DIR/$INFOPLIST_PATH", + $TARGET_BUILD_DIR/$INFOPLIST_PATH, ); name = "Upload source maps to Bugsnag"; outputFileListPaths = ( @@ -2080,6 +2084,7 @@ 1E76CBD125152C710067298C /* Date+Extensions.swift in Sources */, 1E76CBD425152C790067298C /* Database.swift in Sources */, 7ACFE7DA2DDE48760090D9BC /* AppDelegate.swift in Sources */, + 7A3704562F7DB36E009085FC /* VoipPerCallDdpRegistry.swift in Sources */, 1E9A71742B59F36E00477BA2 /* ClientSSL.swift in Sources */, A48B46D92D3FFBD200945489 /* A11yFlowModule.m in Sources */, 3F56D232A9EBA1C9C749F15F /* MMKVBridge.mm in Sources */, @@ -2359,6 +2364,7 @@ 7AAB3E24257E6A6E00707CF6 /* Date+Extensions.swift in Sources */, 7AAB3E25257E6A6E00707CF6 /* Database.swift in Sources */, 7ACFE7D92DDE48760090D9BC /* AppDelegate.swift in Sources */, + 7A3704572F7DB36E009085FC /* VoipPerCallDdpRegistry.swift in Sources */, 1E9A71752B59F36E00477BA2 /* ClientSSL.swift in Sources */, A48B46DA2D3FFBD200945489 /* A11yFlowModule.m in Sources */, 3F56D232A9EBA1C9C749F160 /* MMKVBridge.mm in Sources */, @@ -2619,7 +2625,7 @@ "$(inherited)", "$(SRCROOT)/../node_modules/rn-extensions-share/ios/**", "$(SRCROOT)/../node_modules/react-native-firebase/ios/RNFirebase/**", - "$PODS_CONFIGURATION_BUILD_DIR/Firebase", + $PODS_CONFIGURATION_BUILD_DIR/Firebase, ); INFOPLIST_FILE = ShareRocketChatRN/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -2695,7 +2701,7 @@ "$(inherited)", "$(SRCROOT)/../node_modules/rn-extensions-share/ios/**", "$(SRCROOT)/../node_modules/react-native-firebase/ios/RNFirebase/**", - "$PODS_CONFIGURATION_BUILD_DIR/Firebase", + $PODS_CONFIGURATION_BUILD_DIR/Firebase, ); INFOPLIST_FILE = ShareRocketChatRN/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -3220,10 +3226,7 @@ ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -3287,10 +3290,7 @@ MTL_ENABLE_DEBUG_INFO = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule;