diff --git a/.cursor/skills/agent-skills b/.cursor/skills/agent-skills
new file mode 160000
index 00000000000..a4f602ffb4a
--- /dev/null
+++ b/.cursor/skills/agent-skills
@@ -0,0 +1 @@
+Subproject commit a4f602ffb4aeaf4199fa97b7162f9c9d1f655904
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index ca983f7a6e1..ab1f838aa9f 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -133,11 +133,6 @@
android:enabled="true"
android:exported="false" />
-
-
= Build.VERSION_CODES.O_MR1) {
- context.setShowWhenLocked(true)
- context.setTurnScreenOn(true)
- val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
- keyguardManager.requestDismissKeyguard(context, null)
- }
- }
-
- // Clear the voip flag to prevent re-processing
- intent.removeExtra("voipAction")
-
- return true
- }
-
/**
* Handles video conference notification Intent.
* @return true if this was a video conf intent, false otherwise
diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.kt b/android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.kt
index 46253da2788..1fdfacc21c4 100644
--- a/android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.kt
+++ b/android/app/src/main/java/chat/rocket/reactnative/notification/RCFirebaseMessagingService.kt
@@ -4,7 +4,6 @@ import android.os.Bundle
import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
-import com.google.gson.Gson
import chat.rocket.reactnative.voip.VoipNotification
import chat.rocket.reactnative.voip.VoipPayload
@@ -19,7 +18,6 @@ class RCFirebaseMessagingService : FirebaseMessagingService() {
companion object {
private const val TAG = "RocketChat.FCM"
- private val gson = Gson()
}
override fun onMessageReceived(remoteMessage: RemoteMessage) {
@@ -53,22 +51,6 @@ class RCFirebaseMessagingService : FirebaseMessagingService() {
}
}
- /**
- * Safely parses ejson string to Ejson object.
- */
- private fun parseEjson(ejsonStr: String?): Ejson? {
- if (ejsonStr.isNullOrEmpty() || ejsonStr == "{}") {
- return null
- }
-
- return try {
- gson.fromJson(ejsonStr, Ejson::class.java)
- } catch (e: Exception) {
- Log.e(TAG, "Failed to parse ejson", e)
- null
- }
- }
-
override fun onNewToken(token: String) {
Log.d(TAG, "FCM token refreshed")
// Token handling is done by expo-notifications JS layer
diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/IncomingCallActivity.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/IncomingCallActivity.kt
index f74d24a06b7..1c5ee3633c8 100644
--- a/android/app/src/main/java/chat/rocket/reactnative/voip/IncomingCallActivity.kt
+++ b/android/app/src/main/java/chat/rocket/reactnative/voip/IncomingCallActivity.kt
@@ -24,7 +24,6 @@ import android.widget.FrameLayout
import android.util.Log
import android.view.ViewOutlineProvider
import com.bumptech.glide.Glide
-import chat.rocket.reactnative.MainActivity
import chat.rocket.reactnative.R
import android.graphics.Typeface
import chat.rocket.reactnative.notification.Ejson
@@ -283,15 +282,8 @@ class IncomingCallActivity : Activity() {
clearTimeout()
VoipNotification.cancelTimeout(payload.callId)
stopRingtone()
-
- // Launch MainActivity with call data
- val launchIntent = Intent(this, MainActivity::class.java).apply {
- flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
- putExtras(payload.toBundle())
- }
- startActivity(launchIntent)
-
- finish()
+ VoipNotification.handleAcceptAction(this, payload)
+ // Activity finishes when ACTION_DISMISS is broadcast from handleAcceptAction (async DDP).
}
private fun handleDecline(payload: VoipPayload) {
diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt
index 0862c42a9df..8e67e69fa9a 100644
--- a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt
+++ b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt
@@ -17,6 +17,7 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo
companion object {
private const val TAG = "RocketChat.VoipModule"
private const val EVENT_INITIAL_EVENTS = "VoipPushInitialEvents"
+ private const val EVENT_VOIP_ACCEPT_FAILED = "VoipAcceptFailed"
private var reactContextRef: WeakReference? = null
private var initialEventsData: VoipPayload? = null
@@ -57,6 +58,30 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo
emitInitialEventsEvent(voipPayload)
}
+ /**
+ * Stash native accept failure for cold start [getInitialEvents] and emit [EVENT_VOIP_ACCEPT_FAILED] when JS is running.
+ */
+ @JvmStatic
+ fun storeAcceptFailureForJs(payload: VoipPayload) {
+ val failed = payload.copy(voipAcceptFailed = true)
+ initialEventsData = failed
+ emitVoipAcceptFailedEvent(failed)
+ }
+
+ private fun emitVoipAcceptFailedEvent(voipPayload: VoipPayload) {
+ try {
+ reactContextRef?.get()?.let { context ->
+ if (context.hasActiveReactInstance()) {
+ context
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
+ .emit(EVENT_VOIP_ACCEPT_FAILED, voipPayload.toWritableMap())
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to emit VoipAcceptFailed", e)
+ }
+ }
+
@JvmStatic
fun clearInitialEventsInternal() {
try {
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 96a0d5e1bcd..f2fa81ffede 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
@@ -25,10 +25,13 @@ import android.telecom.PhoneAccountHandle
import android.telecom.TelecomManager
import io.wazo.callkeep.VoiceConnection
import io.wazo.callkeep.VoiceConnectionService
+import android.app.Activity
+import android.app.KeyguardManager
import chat.rocket.reactnative.MainActivity
import chat.rocket.reactnative.notification.Ejson
import org.json.JSONArray
import org.json.JSONObject
+import java.util.concurrent.atomic.AtomicBoolean
/**
* Handles VoIP call notifications using Android's Telecom framework via CallKeep.
@@ -48,12 +51,25 @@ class VoipNotification(private val context: Context) {
const val ACTION_ACCEPT = "chat.rocket.reactnative.ACTION_VOIP_ACCEPT"
const val ACTION_DECLINE = "chat.rocket.reactnative.ACTION_VOIP_DECLINE"
+
+ /**
+ * Set on the heads-up Accept action [PendingIntent] ([PendingIntent.getActivity] → MainActivity).
+ * Android 12+ blocks starting an activity from a notification [BroadcastReceiver] trampoline;
+ * MainActivity opens first, then [handleMainActivityVoipIntent] runs accept with
+ * [handleAcceptAction] and `skipLaunchMainActivity = true`.
+ */
+ const val ACTION_VOIP_ACCEPT_HEADS_UP = "chat.rocket.reactnative.ACTION_VOIP_ACCEPT_HEADS_UP"
const val ACTION_TIMEOUT = "chat.rocket.reactnative.ACTION_VOIP_TIMEOUT"
const val ACTION_DISMISS = "chat.rocket.reactnative.ACTION_VOIP_DISMISS"
// react-native-callkeep's ConnectionService class name
private const val CALLKEEP_CONNECTION_SERVICE_CLASS = "io.wazo.callkeep.VoiceConnectionService"
private const val DISCONNECT_REASON_MISSED = 6
+
+ private data class VoipMediaCallIdentity(val userId: String, val deviceId: String)
+
+ /** Keep in sync with MediaSessionStore features (audio-only today). */
+ private val SUPPORTED_VOIP_FEATURES = JSONArray().apply { put("audio") }
private val timeoutHandler = Handler(Looper.getMainLooper())
private val timeoutCallbacks = mutableMapOf()
private var ddpClient: DDPClient? = null
@@ -141,6 +157,151 @@ class VoipNotification(private val context: Context) {
)
}
+ /**
+ * Routes VoIP-related intents delivered to [MainActivity] (cold start or [Activity.onNewIntent]).
+ *
+ * @return `true` if the intent was handled as VoIP and downstream handlers should not process it.
+ */
+ @JvmStatic
+ fun handleMainActivityVoipIntent(context: Context, intent: Intent): Boolean {
+ val payload = VoipPayload.fromBundle(intent.extras)
+ if (payload == null || !payload.isVoipIncomingCall()) {
+ return false
+ }
+
+ val headsUpAccept = intent.action == ACTION_VOIP_ACCEPT_HEADS_UP
+ if (headsUpAccept) {
+ intent.action = Intent.ACTION_MAIN
+ prepareMainActivityForIncomingVoip(context, payload, storePayloadForJs = false)
+ handleAcceptAction(context, payload, skipLaunchMainActivity = true)
+ intent.removeExtra("voipAction")
+ return true
+ }
+
+ if (intent.getBooleanExtra("voipAction", false)) {
+ prepareMainActivityForIncomingVoip(context, payload)
+ intent.removeExtra("voipAction")
+ return true
+ }
+
+ return false
+ }
+
+ /**
+ * Prepares MainActivity after launch with incoming-call context: cancel notification and timeout,
+ * stash payload for JS, and unlock/show above keyguard when [context] is an [Activity].
+ */
+ private fun prepareMainActivityForIncomingVoip(
+ context: Context,
+ payload: VoipPayload,
+ storePayloadForJs: Boolean = true
+ ) {
+ Log.d(TAG, "prepareMainActivityForIncomingVoip — callId: ${payload.callId}")
+ cancelById(context, payload.notificationId)
+ cancelTimeout(payload.callId)
+ if (storePayloadForJs) {
+ VoipModule.storeInitialEvents(payload)
+ }
+
+ if (context is Activity && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
+ context.setShowWhenLocked(true)
+ context.setTurnScreenOn(true)
+ val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
+ keyguardManager.requestDismissKeyguard(context, null)
+ }
+ }
+
+ /**
+ * Accept from notification or IncomingCallActivity: send accept over native DDP, sync Telecom,
+ * dismiss UI, then open MainActivity (unless [skipLaunchMainActivity] — already in MainActivity
+ * from heads-up Accept [PendingIntent.getActivity]). JS still runs answerCall afterward.
+ *
+ * The DDP call is asynchronous; [VoipModule.storeInitialEvents], notification cancel, Telecom
+ * answer, and [ACTION_DISMISS] run from an internal completion callback. [IncomingCallActivity]
+ * stays open until that broadcast is received.
+ */
+ @JvmStatic
+ @JvmOverloads
+ fun handleAcceptAction(context: Context, payload: VoipPayload, skipLaunchMainActivity: Boolean = false) {
+ Log.d(TAG, "Accept action triggered for callId: ${payload.callId}")
+ cancelTimeout(payload.callId)
+
+ val appCtx = context.applicationContext
+ // Guard so finish() is called at most once, whether by the DDP callback or the timeout.
+ val finished = AtomicBoolean(false)
+ val timeoutHandler = Handler(Looper.getMainLooper())
+ val timeoutRunnable = Runnable {
+ if (finished.compareAndSet(false, true)) {
+ Log.w(TAG, "Native accept timed out for ${payload.callId}; falling back to JS recovery")
+ finish(false)
+ }
+ }
+ timeoutHandler.postDelayed(timeoutRunnable, 10_000L)
+
+ fun finish(ddpSuccess: Boolean) {
+ if (!finished.compareAndSet(false, true)) return
+ timeoutHandler.removeCallbacks(timeoutRunnable)
+ stopDDPClientInternal()
+ if (ddpSuccess) {
+ answerIncomingCall(payload.callId)
+ VoipModule.storeInitialEvents(payload)
+ } else {
+ Log.d(TAG, "Native accept did not succeed over DDP for ${payload.callId}; opening app for JS recovery")
+ disconnectIncomingCall(payload.callId, false)
+ VoipModule.storeAcceptFailureForJs(payload)
+ }
+ cancelById(appCtx, payload.notificationId)
+ LocalBroadcastManager.getInstance(appCtx).sendBroadcast(
+ Intent(ACTION_DISMISS).apply {
+ putExtras(payload.toBundle())
+ }
+ )
+ if (!skipLaunchMainActivity) {
+ launchMainActivityForVoip(context, payload)
+ }
+ }
+
+ val client = ddpClient
+ if (client == null) {
+ Log.d(TAG, "Native DDP client unavailable for accept ${payload.callId}")
+ finish(false)
+ return
+ }
+
+ if (isDdpLoggedIn) {
+ sendAcceptSignal(context, payload) { success ->
+ finish(success)
+ }
+ } else {
+ queueAcceptSignal(context, payload) { success ->
+ finish(success)
+ }
+ }
+ }
+
+ private fun launchMainActivityForVoip(context: Context, payload: VoipPayload) {
+ val intent = Intent(context, MainActivity::class.java).apply {
+ putExtras(payload.toBundle())
+ if (context is Activity) {
+ flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
+ } else {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or
+ Intent.FLAG_ACTIVITY_CLEAR_TOP or
+ Intent.FLAG_ACTIVITY_SINGLE_TOP
+ }
+ }
+ context.startActivity(intent)
+ }
+
+ private fun answerIncomingCall(callId: String) {
+ val connection = VoiceConnectionService.getConnection(callId)
+ when (connection) {
+ is VoiceConnection -> connection.onAnswer()
+ null -> Log.d(TAG, "No active VoiceConnection found for accepted call: $callId")
+ else -> Log.d(TAG, "Non-VoiceConnection for accept, callId: $callId")
+ }
+ }
+
// TODO: unify these three functions and check VoiceConnectionService
private fun disconnectTimedOutCall(callId: String) {
val connection = VoiceConnectionService.getConnection(callId)
@@ -206,7 +367,7 @@ class VoipNotification(private val context: Context) {
Log.d(TAG, "Queued native reject signal for ${payload.callId}")
}
- private fun flushPendingRejectSignalIfNeeded(): Boolean {
+ private fun flushPendingQueuedSignalsIfNeeded(): Boolean {
val client = ddpClient ?: return false
if (!client.hasQueuedMethodCalls()) {
return false
@@ -216,33 +377,101 @@ class VoipNotification(private val context: Context) {
return true
}
- private fun buildRejectSignalParams(context: Context, payload: VoipPayload): JSONArray? {
+ private fun sendAcceptSignal(
+ context: Context,
+ payload: VoipPayload,
+ onComplete: (Boolean) -> Unit
+ ) {
+ val client = ddpClient
+ if (client == null) {
+ Log.d(TAG, "Native DDP client unavailable, cannot send accept for ${payload.callId}")
+ onComplete(false)
+ return
+ }
+
+ val params = buildAcceptSignalParams(context, payload) ?: run {
+ onComplete(false)
+ return
+ }
+
+ client.callMethod("stream-notify-user", params) { success ->
+ Log.d(TAG, "Native accept signal result for ${payload.callId}: $success")
+ onComplete(success)
+ }
+ }
+
+ private fun queueAcceptSignal(
+ context: Context,
+ payload: VoipPayload,
+ onComplete: (Boolean) -> Unit
+ ) {
+ val client = ddpClient
+ if (client == null) {
+ Log.d(TAG, "Native DDP client unavailable, cannot queue accept for ${payload.callId}")
+ onComplete(false)
+ return
+ }
+
+ val params = buildAcceptSignalParams(context, payload) ?: run {
+ onComplete(false)
+ return
+ }
+
+ client.queueMethodCall("stream-notify-user", params) { success ->
+ Log.d(TAG, "Queued native accept signal result for ${payload.callId}: $success")
+ onComplete(success)
+ }
+ Log.d(TAG, "Queued native accept signal for ${payload.callId}")
+ }
+
+ /**
+ * Resolves user id for this host and Android [Settings.Secure.ANDROID_ID] as media-signaling contractId.
+ * Must match JS `getUniqueIdSync()` from react-native-device-info (iOS native code uses `DeviceUID`).
+ */
+ private fun resolveVoipMediaCallIdentity(context: Context, payload: VoipPayload): VoipMediaCallIdentity? {
val ejson = Ejson().apply {
host = payload.host
}
val userId = ejson.userId()
if (userId.isNullOrEmpty()) {
- Log.d(TAG, "Missing userId, cannot send reject for ${payload.callId}")
+ Log.d(TAG, "Missing userId, cannot build stream-notify-user params for ${payload.callId}")
stopDDPClientInternal()
return null
}
-
val deviceId = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
if (deviceId.isNullOrEmpty()) {
- Log.d(TAG, "Missing deviceId, cannot send reject for ${payload.callId}")
+ Log.d(TAG, "Missing deviceId, cannot build stream-notify-user params for ${payload.callId}")
stopDDPClientInternal()
return null
}
+ return VoipMediaCallIdentity(userId, deviceId)
+ }
+ private fun buildAcceptSignalParams(context: Context, payload: VoipPayload): JSONArray? {
+ val ids = resolveVoipMediaCallIdentity(context, payload) ?: return null
val signal = JSONObject().apply {
put("callId", payload.callId)
- put("contractId", deviceId)
+ put("contractId", ids.deviceId)
put("type", "answer")
- put("answer", "reject")
+ put("answer", "accept")
+ put("supportedFeatures", SUPPORTED_VOIP_FEATURES)
}
+ return JSONArray().apply {
+ put("${ids.userId}/media-calls")
+ put(signal.toString())
+ }
+ }
+ private fun buildRejectSignalParams(context: Context, payload: VoipPayload): JSONArray? {
+ val ids = resolveVoipMediaCallIdentity(context, payload) ?: return null
+ val signal = JSONObject().apply {
+ put("callId", payload.callId)
+ put("contractId", ids.deviceId)
+ put("type", "answer")
+ put("answer", "reject")
+ }
return JSONArray().apply {
- put("$userId/media-calls")
+ put("${ids.userId}/media-calls")
put(signal.toString())
}
}
@@ -327,7 +556,7 @@ class VoipNotification(private val context: Context) {
}
isDdpLoggedIn = true
- if (flushPendingRejectSignalIfNeeded()) {
+ if (flushPendingQueuedSignalsIfNeeded()) {
return@login
}
@@ -535,12 +764,31 @@ class VoipNotification(private val context: Context) {
}
val fullScreenPendingIntent = createPendingIntent(notificationId, fullScreenIntent)
- // Create Accept action
+ // Accept: must use getActivity — Android 12+ blocks starting MainActivity from a
+ // notification BroadcastReceiver ("trampoline"). MainActivity runs native accept with
+ // skipLaunchMainActivity after opening.
val acceptIntent = Intent(context, MainActivity::class.java).apply {
- flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
+ action = ACTION_VOIP_ACCEPT_HEADS_UP
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or
+ Intent.FLAG_ACTIVITY_CLEAR_TOP or
+ Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtras(voipPayload.toBundle())
}
- val acceptPendingIntent = createPendingIntent(notificationId + 1, acceptIntent)
+ val acceptPendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ PendingIntent.getActivity(
+ context,
+ notificationId + 1,
+ acceptIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ } else {
+ PendingIntent.getActivity(
+ context,
+ notificationId + 1,
+ acceptIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT
+ )
+ }
// Create Decline action
val declineIntent = Intent(context, DeclineReceiver::class.java).apply {
diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipPayload.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipPayload.kt
index d361f711f30..beaa441871c 100644
--- a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipPayload.kt
+++ b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipPayload.kt
@@ -41,6 +41,8 @@ data class VoipPayload(
@SerializedName("createdAt")
val createdAt: String?,
+
+ val voipAcceptFailed: Boolean = false,
) {
val notificationId: Int = callId.hashCode()
val pushType: VoipPushType?
@@ -86,6 +88,9 @@ data class VoipPayload(
putString("avatarUrl", avatarUrl)
putString("createdAt", createdAt)
putInt("notificationId", notificationId)
+ if (voipAcceptFailed) {
+ putBoolean("voipAcceptFailed", true)
+ }
}
}
@@ -164,6 +169,7 @@ data class VoipPayload(
hostName = hostName.orEmpty(),
avatarUrl = caller?.avatarUrl,
createdAt = payloadCreatedAt,
+ voipAcceptFailed = false,
)
}
}
@@ -188,7 +194,8 @@ data class VoipPayload(
return null
}
- return VoipPayload(callId, caller, username, host, type, hostName, avatarUrl, createdAt)
+ val voipAcceptFailed = bundle.getBoolean("voipAcceptFailed", false)
+ return VoipPayload(callId, caller, username, host, type, hostName, avatarUrl, createdAt, voipAcceptFailed)
}
private fun parseRemotePayload(data: Map): RemoteVoipPayload? {
diff --git a/app/actions/actionsTypes.ts b/app/actions/actionsTypes.ts
index 996edcd8a2f..938ff7b9d5d 100644
--- a/app/actions/actionsTypes.ts
+++ b/app/actions/actionsTypes.ts
@@ -59,7 +59,7 @@ export const METEOR = createRequestTypes('METEOR_CONNECT', [...defaultTypes, 'DI
export const LOGOUT = 'LOGOUT'; // logout is always success
export const DELETE_ACCOUNT = 'DELETE_ACCOUNT';
export const SNIPPETED_MESSAGES = createRequestTypes('SNIPPETED_MESSAGES', ['OPEN', 'READY', 'CLOSE', 'MESSAGES_RECEIVED']);
-export const DEEP_LINKING = createRequestTypes('DEEP_LINKING', ['OPEN', 'OPEN_VIDEO_CONF', 'VOIP_CALL']);
+export const DEEP_LINKING = createRequestTypes('DEEP_LINKING', ['OPEN', 'OPEN_VIDEO_CONF']);
export const SORT_PREFERENCES = createRequestTypes('SORT_PREFERENCES', ['SET_ALL', 'SET']);
export const SET_CUSTOM_EMOJIS = 'SET_CUSTOM_EMOJIS';
export const ACTIVE_USERS = createRequestTypes('ACTIVE_USERS', ['SET', 'CLEAR']);
diff --git a/app/actions/deepLinking.ts b/app/actions/deepLinking.ts
index d55de045aa3..3ff1e22fed9 100644
--- a/app/actions/deepLinking.ts
+++ b/app/actions/deepLinking.ts
@@ -10,21 +10,15 @@ interface IParams {
fullURL: string;
type: string;
token: string;
+ callId?: string;
+ username?: string;
+ voipAcceptFailed?: boolean;
}
interface IDeepLinkingOpen extends Action {
params: Partial;
}
-interface IVoipCallParams {
- callId: string;
- host: string;
-}
-
-interface IVoipCallOpen extends Action {
- params: IVoipCallParams;
-}
-
export function deepLinkingOpen(params: Partial): IDeepLinkingOpen {
return {
type: DEEP_LINKING.OPEN,
@@ -38,14 +32,3 @@ export function deepLinkingClickCallPush(params: any): IDeepLinkingOpen {
params
};
}
-
-/**
- * Action to handle VoIP call from push notification.
- * Triggers server switching if needed and processes the incoming call.
- */
-export function voipCallOpen(params: IVoipCallParams): IVoipCallOpen {
- return {
- type: DEEP_LINKING.VOIP_CALL,
- params
- };
-}
diff --git a/app/definitions/Voip.ts b/app/definitions/Voip.ts
index 72df8d1a870..e815bc8d3ed 100644
--- a/app/definitions/Voip.ts
+++ b/app/definitions/Voip.ts
@@ -14,4 +14,5 @@ export interface VoipPayload {
readonly avatarUrl?: string | null;
readonly createdAt?: string | null;
readonly notificationId: number;
+ readonly voipAcceptFailed?: boolean;
}
diff --git a/app/i18n/locales/ar.json b/app/i18n/locales/ar.json
index 21e8858388d..05ea6661682 100644
--- a/app/i18n/locales/ar.json
+++ b/app/i18n/locales/ar.json
@@ -621,6 +621,7 @@
"Version_no": "إصدار التطبيق: {{version}}",
"View_Original": "عرض المحتوى الأصلي",
"View_Thread": "عرض الموضوع",
+ "VoIP_Call_Issue": "حدثت مشكلة في المكالمة، حاول مرة أخرى لاحقًا.",
"Wait_activation_warning": "يحب تفعيل حسابك من المشرف قبل تسجيل الدخول",
"Waiting_for_network": "بانتظار توفر شبكة...",
"Websocket_disabled": "تم تعطيل Websocket لهذا الخادم.\n{{contact}}",
diff --git a/app/i18n/locales/bn-IN.json b/app/i18n/locales/bn-IN.json
index 410bf5ee2a7..fe56fb6199d 100644
--- a/app/i18n/locales/bn-IN.json
+++ b/app/i18n/locales/bn-IN.json
@@ -874,6 +874,7 @@
"video-conf-provider-not-configured-header": "কনফারেন্স কল সক্ষম হয়নি",
"View_Original": "মৌলিক দেখুন",
"View_Thread": "থ্রেড দেখুন",
+ "VoIP_Call_Issue": "কলে একটি সমস্যা হয়েছে, পরে আবার চেষ্টা করুন।",
"Wait_activation_warning": "আপনি লগ ইন করতে প্রারম্ভ করতে, আপনার অ্যাকাউন্টটি প্রশাসক দ্বারা ম্যানুয়ালি অ্যাক্টিভেট হতে হবে।",
"Waiting_for_answer": "উত্তরের জন্য অপেক্ষা করছি",
"Waiting_for_network": "নেটওয়ার্কের জন্য অপেক্ষা করছে...",
diff --git a/app/i18n/locales/cs.json b/app/i18n/locales/cs.json
index 6718cc41ef5..e6a450ac36e 100644
--- a/app/i18n/locales/cs.json
+++ b/app/i18n/locales/cs.json
@@ -945,6 +945,7 @@
"video-conf-provider-not-configured-header": "Konferenční hovor není povolen",
"View_Original": "Zobrazit originál",
"View_Thread": "Zobrazit vlákno",
+ "VoIP_Call_Issue": "Došlo k problému s hovorem, zkuste to znovu později.",
"Wait_activation_warning": "Než se budete moci přihlásit, váš účet musí být ručně aktivován administrátorem.",
"Waiting_for_answer": "Čekání na odpověď",
"Waiting_for_network": "Čekání na síť...",
diff --git a/app/i18n/locales/de.json b/app/i18n/locales/de.json
index 4c2ba85c2ef..6f5fcb3cf2a 100644
--- a/app/i18n/locales/de.json
+++ b/app/i18n/locales/de.json
@@ -867,6 +867,7 @@
"video-conf-provider-not-configured-header": "Telefonkonferenz nicht aktiviert",
"View_Original": "Original anzeigen",
"View_Thread": "Thread anzeigen",
+ "VoIP_Call_Issue": "Bei dem Anruf ist ein Problem aufgetreten. Bitte versuchen Sie es später erneut.",
"Wait_activation_warning": "Bevor Sie sich anmelden können, muss Ihr Konto durch einen Administrator freigeschaltet werden.",
"Waiting_for_answer": "Warten auf Antwort",
"Waiting_for_network": "Warte auf das Netzwerk …",
diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json
index 9aa2c054952..b6fc371d6de 100644
--- a/app/i18n/locales/en.json
+++ b/app/i18n/locales/en.json
@@ -984,6 +984,7 @@
"View_Original": "View original",
"View_Thread": "View thread",
"Voice_call": "Voice call",
+ "VoIP_Call_Issue": "There was an issue with the call, try again later.",
"Wait_activation_warning": "Before you can login, your account must be manually activated by an administrator.",
"Waiting_for_answer": "Waiting for answer",
"Waiting_for_network": "Waiting for network...",
diff --git a/app/i18n/locales/es.json b/app/i18n/locales/es.json
index 72e0d6b62ff..bc9252ed24b 100644
--- a/app/i18n/locales/es.json
+++ b/app/i18n/locales/es.json
@@ -460,6 +460,7 @@
"Version_no": "Versión de la aplicación: {{version}}",
"View_Original": "Ver original",
"View_Thread": "Ver hilo",
+ "VoIP_Call_Issue": "Hubo un problema con la llamada, inténtelo de nuevo más tarde.",
"Websocket_disabled": "Websocket está deshabilitado para este servidor.\n{{contact}}",
"Whats_the_password_for_your_certificate": "¿Cuál es la contraseña de tu certificado?",
"Without_Servers": "Sin servidores",
diff --git a/app/i18n/locales/fi.json b/app/i18n/locales/fi.json
index 377f38d5c34..74766801202 100644
--- a/app/i18n/locales/fi.json
+++ b/app/i18n/locales/fi.json
@@ -839,6 +839,7 @@
"Version_no": "Sovelluksen versio: {{version}}",
"View_Original": "Näytä alkuperäinen",
"View_Thread": "Katsele säiettä",
+ "VoIP_Call_Issue": "Puhelussa ilmeni ongelma, yritä myöhemmin uudelleen.",
"Wait_activation_warning": "Ennen kuin voit kirjautua, järjestelmänvalvojan on aktivoitava tilisi manuaalisesti.",
"Waiting_for_answer": "Odotetaan vastausta",
"Waiting_for_network": "Odotetaan verkkoa...",
diff --git a/app/i18n/locales/fr.json b/app/i18n/locales/fr.json
index eb65e6e5ab8..b0be369a630 100644
--- a/app/i18n/locales/fr.json
+++ b/app/i18n/locales/fr.json
@@ -761,6 +761,7 @@
"Version_no": "Version de l'application : {{version}}",
"View_Original": "Voir l'original",
"View_Thread": "Afficher le fil",
+ "VoIP_Call_Issue": "Un problème est survenu avec l'appel, veuillez réessayer plus tard.",
"Wait_activation_warning": "Avant de pouvoir vous connecter, votre compte doit être activé manuellement par un administrateur.",
"Waiting_for_network": "En attente du réseau...",
"Websocket_disabled": "Le Websocket est désactivé pour ce serveur.\n{{contact}}",
diff --git a/app/i18n/locales/hi-IN.json b/app/i18n/locales/hi-IN.json
index 78bb09a910e..110f1725d9a 100644
--- a/app/i18n/locales/hi-IN.json
+++ b/app/i18n/locales/hi-IN.json
@@ -874,6 +874,7 @@
"video-conf-provider-not-configured-header": "कॉन्फ़्रेंस कॉल सक्षम नहीं है",
"View_Original": "मूल देखें",
"View_Thread": "थ्रेड देखें",
+ "VoIP_Call_Issue": "कॉल में समस्या आई, कृपया बाद में पुनः प्रयास करें।",
"Wait_activation_warning": "आप लॉगिन करने से पहले, आपका खाता प्रशासक द्वारा मैन्युअल रूप से सक्रिय किया जाना चाहिए।",
"Waiting_for_answer": "उत्तर का इंतजार",
"Waiting_for_network": "नेटवर्क के लिए प्रतीक्षा कर रहा है...",
diff --git a/app/i18n/locales/hu.json b/app/i18n/locales/hu.json
index 6839e2c30ba..e43c6178dad 100644
--- a/app/i18n/locales/hu.json
+++ b/app/i18n/locales/hu.json
@@ -876,6 +876,7 @@
"video-conf-provider-not-configured-header": "Konferenciahívás nem engedélyezett",
"View_Original": "Eredeti megtekintése",
"View_Thread": "Szál megtekintése",
+ "VoIP_Call_Issue": "Probléma történt a hívással, próbálja újra később.",
"Wait_activation_warning": "Mielőtt bejelentkezhetne, a fiókját kézileg kell aktiválnia egy adminisztrátornak.",
"Waiting_for_answer": "Várakozás válaszra",
"Waiting_for_network": "Hálózatra várva...",
diff --git a/app/i18n/locales/it.json b/app/i18n/locales/it.json
index 4344347fc2f..0255e51c013 100644
--- a/app/i18n/locales/it.json
+++ b/app/i18n/locales/it.json
@@ -668,6 +668,7 @@
"Version_no": "Versione dell'app: {{version}}",
"View_Original": "Mostra originale",
"View_Thread": "Visualizza thread",
+ "VoIP_Call_Issue": "Si è verificato un problema con la chiamata, riprova più tardi.",
"Wait_activation_warning": "Prima di poter accedere, il tuo account deve essere attivato manualmente da un amministratore.",
"Waiting_for_network": "In attesa di connessione ...",
"Websocket_disabled": "Websocket disabilitata per questo server.\n{{contact}}",
diff --git a/app/i18n/locales/ja.json b/app/i18n/locales/ja.json
index dda45aad4d9..50bf3af873e 100644
--- a/app/i18n/locales/ja.json
+++ b/app/i18n/locales/ja.json
@@ -551,6 +551,7 @@
"Version_no": "アプリバージョン: {{version}}",
"View_Original": "オリジナルを見る",
"View_Thread": "スレッドを表示します",
+ "VoIP_Call_Issue": "通話に問題が発生しました。しばらくしてからもう一度お試しください。",
"Websocket_disabled": "Websocketはこのサーバーでは無効化されています。\n{{contact}}",
"Whats_the_password_for_your_certificate": "証明書のパスワードはなんですか?",
"Without_Servers": "サーバーを除く",
diff --git a/app/i18n/locales/nl.json b/app/i18n/locales/nl.json
index 522d23cc72d..69500f02958 100644
--- a/app/i18n/locales/nl.json
+++ b/app/i18n/locales/nl.json
@@ -761,6 +761,7 @@
"Version_no": "App-versie: {{version}}",
"View_Original": "Bekijk origineel",
"View_Thread": "Bekijk thread",
+ "VoIP_Call_Issue": "Er was een probleem met het gesprek, probeer het later opnieuw.",
"Wait_activation_warning": "Voordat u kunt inloggen, moet uw account handmatig worden geactiveerd door een beheerder.",
"Waiting_for_network": "Wachten op netwerk...",
"Websocket_disabled": "Websocket is uitgeschakeld voor deze server.\n{{contact}}",
diff --git a/app/i18n/locales/nn.json b/app/i18n/locales/nn.json
index d171c7c1707..830c4bfc86c 100644
--- a/app/i18n/locales/nn.json
+++ b/app/i18n/locales/nn.json
@@ -414,6 +414,7 @@
"Verify": "Bekreft",
"Verify_email_desc": "Vi har sendt deg en e-post for å bekrefte din registrering. Hvis du ikke mottar en epost, vennligst kom tilbake og prøv igjen.",
"View_Thread": "Se tråden",
+ "VoIP_Call_Issue": "Det oppstod eit problem med samtalen, prøv igjen seinare.",
"Wait_activation_warning": "Før du kan logge inn, må kontoen din aktiveres manuelt av en administrator.",
"What_are_you_doing_right_now": "Hva gjør du akkurat nå?",
"Why_do_you_want_to_report": "Hvorfor vil du rapportere?",
diff --git a/app/i18n/locales/no.json b/app/i18n/locales/no.json
index 2d5c014ce43..a520e093f8e 100644
--- a/app/i18n/locales/no.json
+++ b/app/i18n/locales/no.json
@@ -919,6 +919,7 @@
"video-conf-provider-not-configured-header": "Konferansesamtale ikke aktivert",
"View_Original": "Se originalen",
"View_Thread": "Se tråden",
+ "VoIP_Call_Issue": "Det oppstod et problem med samtalen, prøv igjen senere.",
"Wait_activation_warning": "Før du kan logge på, må kontoen din aktiveres manuelt av en administrator.",
"Waiting_for_answer": "Venter på svar",
"Waiting_for_network": "Venter på nettverket ...",
diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json
index cbd4dfb4eae..fd7286bad25 100644
--- a/app/i18n/locales/pt-BR.json
+++ b/app/i18n/locales/pt-BR.json
@@ -955,6 +955,7 @@
"video-conf-provider-not-configured-header": "Video conferência não ativada",
"View_Original": "Visualizar original",
"View_Thread": "Exibir thread",
+ "VoIP_Call_Issue": "Houve um problema com a chamada, tente novamente mais tarde.",
"Wait_activation_warning": "Antes que você possa fazer o login, sua conta deve ser manualmente ativada por um administrador.",
"Waiting_for_answer": "Esperando por resposta",
"Waiting_for_network": "Aguardando rede...",
diff --git a/app/i18n/locales/pt-PT.json b/app/i18n/locales/pt-PT.json
index 4f71d5315db..f854037fcb0 100644
--- a/app/i18n/locales/pt-PT.json
+++ b/app/i18n/locales/pt-PT.json
@@ -523,6 +523,7 @@
"Username_or_email": "Nome de utilizador ou e-mail",
"Username_required": "Nome de utilizador necessário",
"View_Thread": "Exibir thread",
+ "VoIP_Call_Issue": "Ocorreu um problema com a chamada, tente novamente mais tarde.",
"Whats_the_password_for_your_certificate": "Qual é a palavra-passe para o seu certificado?",
"Workspace_URL": "URL do espaço de trabalho",
"Workspace_URL_Example": "open.rocket.chat",
diff --git a/app/i18n/locales/ru.json b/app/i18n/locales/ru.json
index faded076a86..9a065cbcd3d 100644
--- a/app/i18n/locales/ru.json
+++ b/app/i18n/locales/ru.json
@@ -807,6 +807,7 @@
"Version_no": "Версия приложения: {{version}}",
"View_Original": "Посмотреть оригинал",
"View_Thread": "Посмотреть тему",
+ "VoIP_Call_Issue": "Возникла проблема со звонком, повторите попытку позже.",
"Wait_activation_warning": "До того как вы сможете войти, ваш аккаунт должен быть вручную активирован администратором сервера.",
"Waiting_for_answer": "Ожидание ответа",
"Waiting_for_network": "Ожидание сети...",
diff --git a/app/i18n/locales/sl-SI.json b/app/i18n/locales/sl-SI.json
index 68e8aa5caf7..a1c6d1e7112 100644
--- a/app/i18n/locales/sl-SI.json
+++ b/app/i18n/locales/sl-SI.json
@@ -776,6 +776,7 @@
"Version_no": "Različica aplikacije: {{version}}",
"View_Original": "Pogled original",
"View_Thread": "Pogled nit",
+ "VoIP_Call_Issue": "Pri klicu je prišlo do težave, poskusite znova pozneje.",
"Wait_activation_warning": "Preden se lahko prijavite, mora skrbnik ročno aktivirati vaš račun.",
"Waiting_for_network": "Čakanje na omrežje ...",
"Websocket_disabled": "WebSocket je onemogočen za ta strežnik.\n{{contact}}",
diff --git a/app/i18n/locales/sv.json b/app/i18n/locales/sv.json
index f22df03c7be..94945091575 100644
--- a/app/i18n/locales/sv.json
+++ b/app/i18n/locales/sv.json
@@ -837,6 +837,7 @@
"Version_no": "Appversion: {{version}}",
"View_Original": "Visa original",
"View_Thread": "Visa tråd",
+ "VoIP_Call_Issue": "Ett problem uppstod med samtalet, försök igen senare.",
"Wait_activation_warning": "Innan du kan logga in måste ditt konto aktiveras manuellt av en administratör.",
"Waiting_for_answer": "Väntar på svar",
"Waiting_for_network": "Väntar på nätverket...",
diff --git a/app/i18n/locales/ta-IN.json b/app/i18n/locales/ta-IN.json
index a7c1dc4da6a..bcfeacc4834 100644
--- a/app/i18n/locales/ta-IN.json
+++ b/app/i18n/locales/ta-IN.json
@@ -874,6 +874,7 @@
"video-conf-provider-not-configured-header": "காந்ஃபரன்ஸ் கால் கொள்ளை இயக்கப்படவில்லை",
"View_Original": "மூலத்தைக் காண்",
"View_Thread": "நூலைக் காண்க",
+ "VoIP_Call_Issue": "அழைப்பில் சிக்கல் ஏற்பட்டது, பின்னர் மீண்டும் முயற்சிக்கவும்.",
"Wait_activation_warning": "உள்நுழைவு செய்ய முன்வந்து, உங்கள் கணக்கு ஒரு நிர்வாகியால் கையாளப்பட வேண்டும்.",
"Waiting_for_answer": "பதிலை காத்திருக்கின்றன",
"Waiting_for_network": "பிணையத்தைக் காத்திருக்கின்றது...",
diff --git a/app/i18n/locales/te-IN.json b/app/i18n/locales/te-IN.json
index f209d932ed9..b3413bb9253 100644
--- a/app/i18n/locales/te-IN.json
+++ b/app/i18n/locales/te-IN.json
@@ -873,6 +873,7 @@
"video-conf-provider-not-configured-header": "కాన్ఫరెన్స్ కాల్ అనేకంగా లేదు",
"View_Original": "అసలు చూడండి",
"View_Thread": "థ్రెడ్ను వీక్షించండి",
+ "VoIP_Call_Issue": "కాల్లో సమస్య ఉంది, తర్వాత మళ్లీ ప్రయత్నించండి.",
"Wait_activation_warning": "మీరు లాగిన్ చేయడానికి మొదటి స్థాయింలో మీ ఖాతాను ఒక అడ్మినిస్ట్రేటర్ మానవారం ప్రత్యామ్నాయం చేయాలి.",
"Waiting_for_answer": "జవాబు కోసం ఎదురు ఉంది",
"Waiting_for_network": "నెట్వర్క్ కోసం వేచి ఉండి...",
diff --git a/app/i18n/locales/tr.json b/app/i18n/locales/tr.json
index 051fc752a8c..20cd7c83df6 100644
--- a/app/i18n/locales/tr.json
+++ b/app/i18n/locales/tr.json
@@ -651,6 +651,7 @@
"Version_no": "Uygulama sürümü: {{version}}",
"View_Original": "Orijinali Görüntüle",
"View_Thread": "İpliği görüntüleyin",
+ "VoIP_Call_Issue": "Görüşmede bir sorun oluştu, daha sonra tekrar deneyin.",
"Wait_activation_warning": "Giriş yapmadan önce, hesabınız bir yönetici tarafından manuel olarak etkinleştirilmelidir.",
"Waiting_for_network": "Ağ bağlantısı bekleniyor ...",
"Websocket_disabled": "Bu sunucu için Websocket devre dışı bırakıldı.\n{{contact}}",
diff --git a/app/i18n/locales/zh-CN.json b/app/i18n/locales/zh-CN.json
index 95d7ee7684c..1141065daa5 100644
--- a/app/i18n/locales/zh-CN.json
+++ b/app/i18n/locales/zh-CN.json
@@ -612,6 +612,7 @@
"Version_no": "应用版本: {{version}}",
"View_Original": "检视原文",
"View_Thread": "查看线程",
+ "VoIP_Call_Issue": "通话出现问题,请稍后再试。",
"Wait_activation_warning": "您的帐号必须由管理员手动启用后才能登入。",
"Waiting_for_network": "等待网路连接",
"Websocket_disabled": "Websocket 已于此伺服器上禁用。 \\n{{contact}}",
diff --git a/app/i18n/locales/zh-TW.json b/app/i18n/locales/zh-TW.json
index b66d5dee1c8..6bd89bdc169 100644
--- a/app/i18n/locales/zh-TW.json
+++ b/app/i18n/locales/zh-TW.json
@@ -641,6 +641,7 @@
"Version_no": "應用程式版本: {{version}}",
"View_Original": "檢視原文",
"View_Thread": "查看線程",
+ "VoIP_Call_Issue": "通話發生問題,請稍後再試。",
"Wait_activation_warning": "您的帳號必須由管理員手動啟用後才能登入。",
"Waiting_for_network": "等待網路連線",
"Websocket_disabled": "Websocket 已於此伺服器上禁用。\\n{{contact}}",
diff --git a/app/index.tsx b/app/index.tsx
index 87ee0fb5c82..8ff8e9d1d1b 100644
--- a/app/index.tsx
+++ b/app/index.tsx
@@ -153,9 +153,9 @@ export default class Root extends React.Component<{}, IState> {
return;
}
- // TODO: change name
- const handledVoipCall = await getInitialMediaCallEvents();
- if (handledVoipCall) {
+ const voipInitialHandled = await getInitialMediaCallEvents();
+ if (voipInitialHandled) {
+ // VoIP path already dispatched navigation (or will via deep linking); do not call appInit() in parallel
return;
}
diff --git a/app/lib/services/voip/MediaCallEvents.ts b/app/lib/services/voip/MediaCallEvents.ts
index 141dcbfdd24..127cbef46e6 100644
--- a/app/lib/services/voip/MediaCallEvents.ts
+++ b/app/lib/services/voip/MediaCallEvents.ts
@@ -3,7 +3,7 @@ import { DeviceEventEmitter, NativeEventEmitter } from 'react-native';
import { isIOS } from '../../methods/helpers';
import store from '../../store';
-import { voipCallOpen } from '../../../actions/deepLinking';
+import { deepLinkingOpen } from '../../../actions/deepLinking';
import { useCallStore } from './useCallStore';
import { mediaSessionInstance } from './MediaSessionInstance';
import type { VoipPayload } from '../../../definitions/Voip';
@@ -14,6 +14,30 @@ const Emitter = isIOS ? new NativeEventEmitter(NativeVoipModule) : DeviceEventEm
const platform = isIOS ? 'iOS' : 'Android';
const TAG = `[MediaCallEvents][${platform}]`;
+const EVENT_VOIP_ACCEPT_FAILED = 'VoipAcceptFailed';
+
+/** Dedupe native emit + stash replay for the same failed accept. */
+let lastHandledVoipAcceptFailureCallId: string | null = null;
+
+function dispatchVoipAcceptFailureFromNative(raw: VoipPayload & { voipAcceptFailed?: boolean }) {
+ if (!raw.voipAcceptFailed) {
+ return;
+ }
+ const { callId } = raw;
+ if (callId && lastHandledVoipAcceptFailureCallId === callId) {
+ return;
+ }
+ lastHandledVoipAcceptFailureCallId = callId;
+ store.dispatch(
+ deepLinkingOpen({
+ host: raw.host,
+ callId: raw.callId,
+ username: raw.username,
+ voipAcceptFailed: true
+ })
+ );
+}
+
/**
* Sets up listeners for media call events.
* @returns Cleanup function to remove listeners
@@ -32,25 +56,28 @@ export const setupMediaCallEvents = (): (() => void) => {
})
);
- subscriptions.push(
- RNCallKeep.addEventListener('answerCall', ({ callUUID }) => {
- console.log(`${TAG} Answer call event listener:`, callUUID);
- mediaSessionInstance.answerCall(callUUID);
- NativeVoipModule.clearInitialEvents();
- RNCallKeep.clearInitialEvents();
- })
- );
subscriptions.push(
RNCallKeep.addEventListener('endCall', ({ callUUID }) => {
console.log(`${TAG} End call event listener:`, callUUID);
mediaSessionInstance.endCall(callUUID);
})
);
+
+ // Note: there is intentionally no 'answerCall' listener here.
+ // VoipService.swift handles accept natively: handleObservedCallChanged detects
+ // hasConnected = true and calls handleNativeAccept(), which sends the DDP accept
+ // signal before JS runs. JS only reads the stored initialEventsData payload after the fact.
} else {
// Android listens for media call events from VoipModule
subscriptions.push(
- Emitter.addListener('VoipPushInitialEvents', async (data: VoipPayload) => {
+ Emitter.addListener('VoipPushInitialEvents', async (data: VoipPayload & { voipAcceptFailed?: boolean }) => {
try {
+ if (data.voipAcceptFailed) {
+ console.log(`${TAG} Accept failed initial event`);
+ dispatchVoipAcceptFailureFromNative(data);
+ NativeVoipModule.clearInitialEvents();
+ return;
+ }
if (data.type !== 'incoming_call') {
console.log(`${TAG} Not an incoming call`);
return;
@@ -59,7 +86,7 @@ export const setupMediaCallEvents = (): (() => void) => {
NativeVoipModule.clearInitialEvents();
useCallStore.getState().setCallId(data.callId);
store.dispatch(
- voipCallOpen({
+ deepLinkingOpen({
callId: data.callId,
host: data.host
})
@@ -72,20 +99,26 @@ export const setupMediaCallEvents = (): (() => void) => {
);
}
- // Return cleanup function
+ subscriptions.push(
+ Emitter.addListener(EVENT_VOIP_ACCEPT_FAILED, (data: VoipPayload & { voipAcceptFailed?: boolean }) => {
+ console.log(`${TAG} VoipAcceptFailed event:`, data);
+ dispatchVoipAcceptFailureFromNative({ ...data, voipAcceptFailed: true });
+ NativeVoipModule.clearInitialEvents();
+ })
+ );
+
return () => {
subscriptions.forEach(sub => sub.remove());
};
};
/**
- * Handles initial media call events.
- * @returns true if the call was answered, false otherwise
+ * Handles initial media call events (cold start).
+ * @returns true if startup should skip the default `appInit()` path (answered call, or accept failure handed to deep linking)
*/
export const getInitialMediaCallEvents = async (): Promise => {
try {
- // Get initial events from native module
- const initialEvents = NativeVoipModule.getInitialEvents() as VoipPayload | null;
+ const initialEvents = NativeVoipModule.getInitialEvents() as (VoipPayload & { voipAcceptFailed?: boolean }) | null;
if (!initialEvents) {
console.log(`${TAG} No initial events from native module`);
RNCallKeep.clearInitialEvents();
@@ -93,6 +126,14 @@ export const getInitialMediaCallEvents = async (): Promise => {
}
console.log(`${TAG} Found initial events:`, initialEvents);
+ if (initialEvents.voipAcceptFailed && initialEvents.callId && initialEvents.host) {
+ dispatchVoipAcceptFailureFromNative(initialEvents);
+ RNCallKeep.clearInitialEvents();
+ NativeVoipModule.clearInitialEvents();
+ // Avoid racing `appInit()` with the deep-linking saga that handles the failure
+ return true;
+ }
+
if (!initialEvents.callId || !initialEvents.host || initialEvents.type !== 'incoming_call') {
console.log(`${TAG} Missing required call data`);
RNCallKeep.clearInitialEvents();
@@ -101,12 +142,12 @@ export const getInitialMediaCallEvents = async (): Promise => {
let wasAnswered = false;
+ // iOS loops through the events and checks if the call was already answered
if (isIOS) {
const callKeepInitialEvents = await RNCallKeep.getInitialEvents();
RNCallKeep.clearInitialEvents();
console.log(`${TAG} CallKeep initial events:`, JSON.stringify(callKeepInitialEvents, null, 2));
- // iOS loops through the events and checks if the call was already answered
for (const event of callKeepInitialEvents) {
const { name, data } = event;
if (name === 'RNCallKeepPerformAnswerCallAction') {
@@ -127,12 +168,12 @@ export const getInitialMediaCallEvents = async (): Promise => {
useCallStore.getState().setCallId(initialEvents.callId);
store.dispatch(
- voipCallOpen({
+ deepLinkingOpen({
callId: initialEvents.callId,
host: initialEvents.host
})
);
- console.log(`${TAG} Dispatched voipCallOpen action`);
+ console.log(`${TAG} Dispatched deepLinkingOpen for VoIP`);
}
return Promise.resolve(wasAnswered);
diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts
index 640d499b427..ca475453b75 100644
--- a/app/lib/services/voip/MediaSessionInstance.test.ts
+++ b/app/lib/services/voip/MediaSessionInstance.test.ts
@@ -45,7 +45,8 @@ jest.mock('react-native-webrtc', () => ({
jest.mock('react-native-callkeep', () => ({}));
jest.mock('react-native-device-info', () => ({
- getUniqueId: jest.fn(() => 'test-device-id')
+ getUniqueId: jest.fn(() => 'test-device-id'),
+ getUniqueIdSync: jest.fn(() => 'test-device-id')
}));
jest.mock('../../native/NativeVoip', () => ({
diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts
index 772086438d0..0c066d037ad 100644
--- a/app/lib/services/voip/MediaSessionInstance.ts
+++ b/app/lib/services/voip/MediaSessionInstance.ts
@@ -8,7 +8,7 @@ import {
} from '@rocket.chat/media-signaling';
import RNCallKeep from 'react-native-callkeep';
import { registerGlobals } from 'react-native-webrtc';
-import { getUniqueId } from 'react-native-device-info';
+import { getUniqueIdSync } from 'react-native-device-info';
import { mediaSessionStore } from './MediaSessionStore';
import { useCallStore } from './useCallStore';
@@ -16,7 +16,6 @@ import { store } from '../../store/auxStore';
import sdk from '../sdk';
import Navigation from '../../navigation/appNavigation';
import { parseStringToIceServers } from './parseStringToIceServers';
-import NativeVoipModule from '../../native/NativeVoip';
import type { IceServer } from '../../../definitions/Voip';
import type { IDDPMessage } from '../../../definitions/IDDPMessage';
import type { ISubscription, TSubscriptionModel } from '../../../definitions';
@@ -35,8 +34,6 @@ class MediaSessionInstance {
this.reset();
registerGlobals();
this.configureIceServers();
- // prevent JS and native DDP clients from interfering with each other
- NativeVoipModule.stopNativeDDPClient();
mediaSessionStore.setWebRTCProcessorFactory(
(config: WebRTCProcessorConfig) =>
@@ -65,26 +62,27 @@ class MediaSessionInstance {
const signal = ddpMessage.fields.args[0];
this.instance.processSignal(signal);
- // If the call was accepted from another device, end the call
- if (signal.type === 'notification' && signal.notification === 'accepted' && signal.signedContractId !== getUniqueId()) {
- // TODO: pop from call view, end callkeep and remove incoming call notification
+ console.log('🤙 [VoIP] Processed signal:', signal);
+
+ // If the call was accepted from this device, answer it
+ if (signal.type === 'notification' && signal.notification === 'accepted' && signal.signedContractId === getUniqueIdSync()) {
+ this.answerCall(signal.callId).catch(error => {
+ console.error('[VoIP] Error answering call :', error);
+ });
}
});
+ this.instance?.on('registered', ({ activeCalls }) => {
+ console.log('[VoIP] Media session registered, activeCalls:', activeCalls);
+ });
+
this.instance?.on('newCall', ({ call }: { call: IClientMediaCall }) => {
if (call && !call.hidden) {
call.emitter.on('stateChange', oldState => {
console.log(`📊 ${oldState} → ${call.state}`);
+ console.log('🤙 [VoIP] New call data:', call);
});
- const existingCallId = useCallStore.getState().callId;
- console.log('[VoIP] Existing call Id:', existingCallId);
- // // TODO: need to answer the call here?
- if (existingCallId) {
- this.answerCall(existingCallId);
- return;
- }
-
if (call.role === 'caller') {
useCallStore.getState().setCall(call);
Navigation.navigate('CallView');
@@ -111,7 +109,7 @@ class MediaSessionInstance {
Navigation.navigate('CallView');
} else {
RNCallKeep.endCall(callId);
- alert('Call not found'); // TODO: Show error message?
+ console.warn('[VoIP] Call not found:', callId); // TODO: Show error message?
}
};
diff --git a/app/lib/services/voip/MediaSessionStore.ts b/app/lib/services/voip/MediaSessionStore.ts
index 8ccc513999c..ede6e89063a 100644
--- a/app/lib/services/voip/MediaSessionStore.ts
+++ b/app/lib/services/voip/MediaSessionStore.ts
@@ -7,6 +7,7 @@ import type {
MediaCallWebRTCProcessor
} from '@rocket.chat/media-signaling';
import { mediaDevices } from 'react-native-webrtc';
+import { getUniqueIdSync } from 'react-native-device-info';
import { MediaCallLogger } from './MediaCallLogger';
@@ -52,6 +53,9 @@ class MediaSessionStore extends Emitter<{ change: void }> {
throw new Error('WebRTC processor factory and send signal function must be set');
}
+ // Must match native VoIP DDP contractId: iOS `DeviceUID`, Android `Settings.Secure.ANDROID_ID` (see native VoipService / VoipNotification).
+ const mobileDeviceId = getUniqueIdSync();
+ console.log('[VoIP] Mobile device ID:', mobileDeviceId);
this.sessionInstance = new MediaSignalingSession({
userId,
transport: (signal: ClientMediaSignal) => this.sendSignal(signal),
@@ -59,9 +63,11 @@ class MediaSessionStore extends Emitter<{ change: void }> {
webrtc: (config: WebRTCProcessorConfig) => this.webrtcProcessorFactory(config)
},
mediaStreamFactory: (constraints: any) => mediaDevices.getUserMedia(constraints) as unknown as Promise,
+ displayMediaFactory: (constraints: any) => mediaDevices.getUserMedia(constraints) as unknown as Promise,
randomStringFactory,
logger: new MediaCallLogger(),
- features: ['audio']
+ features: ['audio'],
+ mobileDeviceId
});
this.change();
diff --git a/app/lib/services/voip/useCallStore.ts b/app/lib/services/voip/useCallStore.ts
index bcd78534605..028cfb1ce49 100644
--- a/app/lib/services/voip/useCallStore.ts
+++ b/app/lib/services/voip/useCallStore.ts
@@ -29,6 +29,7 @@ interface CallStoreState {
interface CallStoreActions {
setCallId: (callId: string | null) => void;
setCall: (call: IClientMediaCall) => void;
+ _cleanupCallListeners: () => void;
toggleMute: () => void;
toggleHold: () => void;
toggleSpeaker: () => void;
@@ -40,6 +41,8 @@ interface CallStoreActions {
export type CallStore = CallStoreState & CallStoreActions;
+let callListenersCleanup: (() => void) | null = null;
+
const initialState: CallStoreState = {
call: null,
callId: null,
@@ -62,7 +65,13 @@ export const useCallStore = create((set, get) => ({
set({ callId });
},
+ _cleanupCallListeners: () => {
+ callListenersCleanup?.();
+ callListenersCleanup = null;
+ },
+
setCall: (call: IClientMediaCall) => {
+ get()._cleanupCallListeners();
// Update state with call info
set({
call,
@@ -121,6 +130,12 @@ export const useCallStore = create((set, get) => ({
call.emitter.on('stateChange', handleStateChange);
call.emitter.on('trackStateChange', handleTrackStateChange);
call.emitter.on('ended', handleEnded);
+
+ callListenersCleanup = () => {
+ call.emitter.off('stateChange', handleStateChange);
+ call.emitter.off('trackStateChange', handleTrackStateChange);
+ call.emitter.off('ended', handleEnded);
+ };
},
toggleMute: () => {
@@ -189,6 +204,7 @@ export const useCallStore = create((set, get) => ({
},
reset: () => {
+ get()._cleanupCallListeners();
try {
InCallManager.stop();
} catch (error) {
diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js
index 6b0aca2235a..54452e83711 100644
--- a/app/sagas/deepLinking.js
+++ b/app/sagas/deepLinking.js
@@ -1,3 +1,6 @@
+import { InteractionManager } from 'react-native';
+import RNCallKeep from 'react-native-callkeep';
+import I18n from 'i18n-js';
import { all, call, delay, put, select, take, takeLatest } from 'redux-saga/effects';
import { shareSetParams } from '../actions/share';
@@ -17,12 +20,14 @@ import EventEmitter from '../lib/methods/helpers/events';
import { goRoom, navigateToRoom } from '../lib/methods/helpers/goRoom';
import { localAuthenticate } from '../lib/methods/helpers/localAuthentication';
import log from '../lib/methods/helpers/log';
+import { showToast } from '../lib/methods/helpers/showToast';
import UserPreferences from '../lib/methods/userPreferences';
import { videoConfJoin } from '../lib/methods/videoConf';
import { loginOAuthOrSso } from '../lib/services/connect';
import { notifyUser } from '../lib/services/restApi';
import sdk from '../lib/services/sdk';
import Navigation from '../lib/navigation/appNavigation';
+import { useCallStore } from '../lib/services/voip/useCallStore';
const roomTypes = {
channel: 'c',
@@ -87,6 +92,47 @@ const navigate = function* navigate({ params }) {
yield put(appStart({ root: RootEnum.ROOT_INSIDE }));
};
+/**
+ * After native VoIP accept fails: reset call state, end CallKit session, land inside root,
+ * optionally open DM via same pipeline as deep links (`direct/username`), then toast/dialog per a11y.
+ */
+const handleVoipAcceptFailed = function* handleVoipAcceptFailed(params) {
+ try {
+ const { callId, username } = params;
+ useCallStore.getState().reset();
+ if (callId) {
+ RNCallKeep.endCall(callId);
+ }
+
+ yield call(waitForNavigation);
+
+ const navigateParams = {
+ ...params,
+ path: username ? `direct/${username}` : params.path
+ };
+ yield navigate({ params: navigateParams });
+
+ yield call(
+ () =>
+ new Promise((resolve) => {
+ InteractionManager.runAfterInteractions(() => resolve());
+ })
+ );
+
+ showToast(I18n.t('VoIP_Call_Issue'));
+ } catch (e) {
+ log(e);
+ }
+};
+
+const completeDeepLinkNavigation = function* completeDeepLinkNavigation(params) {
+ if (params.voipAcceptFailed) {
+ yield call(handleVoipAcceptFailed, params);
+ } else {
+ yield navigate({ params });
+ }
+};
+
const fallbackNavigation = function* fallbackNavigation() {
const currentRoot = yield select(state => state.app.root);
if (currentRoot) {
@@ -140,6 +186,10 @@ const handleOpen = function* handleOpen({ params }) {
// If there's no host on the deep link params and the app is opened, just call appInit()
let { host } = params;
if (!host) {
+ if (params.voipAcceptFailed) {
+ yield call(handleVoipAcceptFailed, params);
+ return;
+ }
yield fallbackNavigation();
return;
}
@@ -176,7 +226,7 @@ const handleOpen = function* handleOpen({ params }) {
yield put(selectServerRequest(host, serverRecord.version, true));
yield take(types.LOGIN.SUCCESS);
}
- yield navigate({ params });
+ yield completeDeepLinkNavigation(params);
} else {
// search if deep link's server already exists
try {
@@ -184,7 +234,7 @@ const handleOpen = function* handleOpen({ params }) {
yield localAuthenticate(host);
yield put(selectServerRequest(host, serverRecord.version, true, true));
yield take(types.LOGIN.SUCCESS);
- yield navigate({ params });
+ yield completeDeepLinkNavigation(params);
return;
}
} catch (e) {
@@ -193,6 +243,10 @@ const handleOpen = function* handleOpen({ params }) {
// if deep link is from a different server
const result = yield getServerInfo(host);
if (!result.success) {
+ if (params.voipAcceptFailed) {
+ yield call(handleVoipAcceptFailed, params);
+ return;
+ }
// Fallback to prevent the app from being stuck on splash screen
yield fallbackNavigation();
return;
@@ -207,7 +261,7 @@ const handleOpen = function* handleOpen({ params }) {
yield put(loginRequest({ resume: params.token }, true));
yield take(types.LOGIN.SUCCESS);
yield put(appReady({}));
- yield navigate({ params });
+ yield completeDeepLinkNavigation(params);
} else {
yield handleInviteLink({ params, requireLogin: true });
}
@@ -297,18 +351,8 @@ const handleClickCallPush = function* handleClickCallPush({ params }) {
}
};
-/**
- * Handle VoIP call from push notification.
- * Ensures the app is connected to the correct server before the call can be processed.
- * The actual call handling is done by MediaSessionInstance via the pending call store.
- */
-const handleVoipCall = function* handleVoipCall({ params }) {
- yield handleOpen({ params });
-};
-
const root = function* root() {
yield takeLatest(types.DEEP_LINKING.OPEN, handleOpen);
yield takeLatest(types.DEEP_LINKING.OPEN_VIDEO_CONF, handleClickCallPush);
- yield takeLatest(types.DEEP_LINKING.VOIP_CALL, handleVoipCall);
};
export default root;
diff --git a/ios/Libraries/AppDelegate+Voip.swift b/ios/Libraries/AppDelegate+Voip.swift
index 6ceb69013a0..81b7f7ccc3c 100644
--- a/ios/Libraries/AppDelegate+Voip.swift
+++ b/ios/Libraries/AppDelegate+Voip.swift
@@ -1,5 +1,7 @@
import PushKit
+fileprivate let voipAppDelegateLogTag = "RocketChat.AppDelegate+Voip"
+
// MARK: - PKPushRegistryDelegate
extension AppDelegate: PKPushRegistryDelegate {
@@ -22,7 +24,7 @@ extension AppDelegate: PKPushRegistryDelegate {
guard let voipPayload = VoipPayload.fromDictionary(payloadDict) else {
#if DEBUG
- print("[\(TAG)] Failed to parse incoming VoIP payload")
+ print("[\(voipAppDelegateLogTag)] Failed to parse incoming VoIP payload: \(payloadDict)")
#endif
completion()
return
@@ -32,7 +34,7 @@ extension AppDelegate: PKPushRegistryDelegate {
let caller = voipPayload.caller
guard !voipPayload.isExpired() else {
#if DEBUG
- print("[\(TAG)] Skipping expired or invalid VoIP payload for callId: \(callId)")
+ print("[\(voipAppDelegateLogTag)] Skipping expired or invalid VoIP payload for callId: \(callId): \(voipPayload)")
#endif
completion()
return
diff --git a/ios/Libraries/VoipModule.mm b/ios/Libraries/VoipModule.mm
index bb2f6953305..4fb3a329e19 100644
--- a/ios/Libraries/VoipModule.mm
+++ b/ios/Libraries/VoipModule.mm
@@ -35,7 +35,7 @@ - (instancetype)init {
}
- (NSArray *)supportedEvents {
- return @[@"VoipPushTokenRegistered"];
+ return @[@"VoipPushTokenRegistered", @"VoipAcceptFailed"];
}
- (void)startObserving {
@@ -46,6 +46,11 @@ - (void)startObserving {
name:@"VoipPushTokenRegistered"
object:nil];
+ [[NSNotificationCenter defaultCenter] addObserver:self
+ selector:@selector(handleVoipAcceptFailed:)
+ name:@"VoipAcceptFailed"
+ object:nil];
+
// Send any delayed events
for (NSDictionary *event in _delayedEvents) {
NSString *name = event[@"name"];
@@ -67,6 +72,10 @@ - (void)handleVoipTokenRegistered:(NSNotification *)notification {
[self sendEventWrapper:@"VoipPushTokenRegistered" body:notification.userInfo];
}
+- (void)handleVoipAcceptFailed:(NSNotification *)notification {
+ [self sendEventWrapper:@"VoipAcceptFailed" body:notification.userInfo];
+}
+
- (void)sendEventWrapper:(NSString *)name body:(id)body {
if (_hasListeners) {
[self sendEventWithName:name body:body];
@@ -99,12 +108,14 @@ - (void)stopNativeDDPClient {
[VoipService stopDDPClient];
}
+// TurboModule codegen calls these on VoipModule directly. Empty implementations replaced
+// RCTEventEmitter's logic, so startObserving/stopObserving never ran and no events reached JS.
- (void)addListener:(NSString *)eventName {
- // Required for NativeEventEmitter - starts observing
+ [super addListener:eventName];
}
- (void)removeListeners:(double)count {
- // Required for NativeEventEmitter - stops observing
+ [super removeListeners:count];
}
#pragma mark - TurboModule
diff --git a/ios/Libraries/VoipPayload.swift b/ios/Libraries/VoipPayload.swift
index f7769e97222..c70a0bdf571 100644
--- a/ios/Libraries/VoipPayload.swift
+++ b/ios/Libraries/VoipPayload.swift
@@ -67,7 +67,8 @@ private struct RemoteVoipPayload {
type: payloadType,
hostName: payloadHostName,
avatarUrl: caller?.avatarUrl,
- createdAt: payloadCreatedAt
+ createdAt: payloadCreatedAt,
+ voipAcceptFailed: false
)
}
}
@@ -87,6 +88,7 @@ public class VoipPayload: NSObject {
@objc public let hostName: String
@objc public let avatarUrl: String?
@objc public let createdAt: String?
+ @objc public let voipAcceptFailed: Bool
private var createdAtDate: Date? {
return Self.parseCreatedAt(createdAt)
@@ -124,7 +126,18 @@ public class VoipPayload: NSObject {
return Int(hash)
}
- init(callId: String, callUUID: UUID, caller: String, username: String, host: String, type: String, hostName: String, avatarUrl: String?, createdAt: String?) {
+ init(
+ callId: String,
+ callUUID: UUID,
+ caller: String,
+ username: String,
+ host: String,
+ type: String,
+ hostName: String,
+ avatarUrl: String?,
+ createdAt: String?,
+ voipAcceptFailed: Bool = false
+ ) {
self.callId = callId
self.callUUID = callUUID
self.caller = caller
@@ -134,6 +147,7 @@ public class VoipPayload: NSObject {
self.hostName = hostName
self.avatarUrl = avatarUrl
self.createdAt = createdAt
+ self.voipAcceptFailed = voipAcceptFailed
super.init()
}
@@ -144,7 +158,7 @@ public class VoipPayload: NSObject {
@objc
public func toDictionary() -> [String: Any] {
- return [
+ var dict: [String: Any] = [
"callId": callId,
"caller": caller,
"username": username,
@@ -155,6 +169,10 @@ public class VoipPayload: NSObject {
"createdAt": createdAt ?? NSNull(),
"notificationId": notificationId
]
+ if voipAcceptFailed {
+ dict["voipAcceptFailed"] = true
+ }
+ return dict
}
public func remainingLifetime(now: Date = Date()) -> TimeInterval? {
diff --git a/ios/Libraries/VoipService.swift b/ios/Libraries/VoipService.swift
index 8db99f60731..e0903696c36 100644
--- a/ios/Libraries/VoipService.swift
+++ b/ios/Libraries/VoipService.swift
@@ -42,7 +42,25 @@ public final class VoipService: NSObject {
private static var isCallObserverConfigured = false
private static var observedIncomingCall: ObservedIncomingCall?
private static var isDdpLoggedIn = false
-
+ /// 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.
+ ///
+ /// Lifecycle:
+ /// 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).
+ ///
+ /// Memory: One entry only while a native accept is in flight; cleared when the DDP accept finishes or other exit paths run.
+ private static var nativeAcceptHandledCallIds = Set()
+
+ private enum VoipMediaCallAnswerKind {
+ case accept
+ case reject
+ }
+
// MARK: - Static Methods (Called from VoipModule.mm and AppDelegate)
/// Registers for VoIP push notifications via PushKit
@@ -214,10 +232,15 @@ public final class VoipService: NSObject {
incomingCallTimeouts.removeValue(forKey: callId)?.cancel()
}
+ private static func clearNativeAcceptDedupe(for callId: String) {
+ nativeAcceptHandledCallIds.remove(callId)
+ }
+
private static func handleIncomingCallTimeout(for payload: VoipPayload) {
incomingCallTimeouts.removeValue(forKey: payload.callId)
clearTrackedIncomingCall(for: payload.callUUID)
stopDDPClientInternal()
+ clearNativeAcceptDedupe(for: payload.callId)
let callId = payload.callId
let callUUID = payload.callUUID
@@ -296,6 +319,7 @@ public final class VoipService: NSObject {
return
}
clearTrackedIncomingCall(for: payload.callUUID)
+ clearNativeAcceptDedupe(for: callId)
RNCallKeep.endCall(withUUID: callId, reason: 3)
cancelIncomingCallTimeout(for: callId)
stopDDPClientInternal()
@@ -327,7 +351,7 @@ public final class VoipService: NSObject {
}
isDdpLoggedIn = true
- if flushPendingRejectSignalIfNeeded() {
+ if flushPendingQueuedSignalsIfNeeded() {
return
}
@@ -368,22 +392,28 @@ public final class VoipService: NSObject {
ddpClient = nil
}
- private static func buildRejectMethodParams(payload: VoipPayload) -> [Any]? {
+ // MARK: - Native DDP signaling (accept / reject)
+
+ /// `contractId` must match JS `getUniqueIdSync()` from react-native-device-info (`DeviceUID` on iOS; Android uses `Settings.Secure.ANDROID_ID` in VoipNotification).
+ private static func buildMediaCallAnswerParams(payload: VoipPayload, kind: VoipMediaCallAnswerKind) -> [Any]? {
let credentialStorage = Storage()
guard let credentials = credentialStorage.getCredentials(server: payload.host.removeTrailingSlash()) else {
#if DEBUG
- print("[\(TAG)] Missing credentials, cannot send reject for \(payload.callId)")
+ print("[\(TAG)] Missing credentials, cannot build media-call answer params for \(payload.callId)")
#endif
stopDDPClientInternal()
return nil
}
- let signal: [String: Any] = [
+ var signal: [String: Any] = [
"callId": payload.callId,
"contractId": DeviceUID.uid(),
"type": "answer",
- "answer": "reject"
+ "answer": kind == .accept ? "accept" : "reject"
]
+ if kind == .accept {
+ signal["supportedFeatures"] = ["audio"]
+ }
guard
let signalData = try? JSONSerialization.data(withJSONObject: signal),
@@ -396,6 +426,93 @@ public final class VoipService: NSObject {
return ["\(credentials.userId)/media-calls", signalString]
}
+ /// Native DDP accept when the user answers via CallKit (parity with Android `VoipNotification.handleAcceptAction`).
+ private static func handleNativeAccept(payload: VoipPayload) {
+ if nativeAcceptHandledCallIds.contains(payload.callId) {
+ return
+ }
+ nativeAcceptHandledCallIds.insert(payload.callId)
+
+ cancelIncomingCallTimeout(for: payload.callId)
+
+ let finishAccept: (Bool) -> Void = { success in
+ stopDDPClientInternal()
+ if success {
+ storeInitialEvents(payload)
+ clearNativeAcceptDedupe(for: payload.callId)
+ } else {
+ clearNativeAcceptDedupe(for: payload.callId)
+ RNCallKeep.endCall(withUUID: payload.callId, reason: 6)
+ let failedPayload = VoipPayload(
+ callId: payload.callId,
+ callUUID: payload.callUUID,
+ caller: payload.caller,
+ username: payload.username,
+ host: payload.host,
+ type: payload.type,
+ hostName: payload.hostName,
+ avatarUrl: payload.avatarUrl,
+ createdAt: payload.createdAt,
+ voipAcceptFailed: true
+ )
+ storeInitialEvents(failedPayload)
+ var acceptFailedUserInfo: [String: Any] = [
+ "callId": failedPayload.callId,
+ "caller": failedPayload.caller,
+ "username": failedPayload.username,
+ "host": failedPayload.host,
+ "type": failedPayload.type,
+ "hostName": failedPayload.hostName,
+ "notificationId": failedPayload.notificationId,
+ "voipAcceptFailed": true
+ ]
+ if let avatarUrl = failedPayload.avatarUrl {
+ acceptFailedUserInfo["avatarUrl"] = avatarUrl
+ }
+ if let createdAt = failedPayload.createdAt {
+ acceptFailedUserInfo["createdAt"] = createdAt
+ }
+ NotificationCenter.default.post(
+ name: NSNotification.Name("VoipAcceptFailed"),
+ object: nil,
+ userInfo: acceptFailedUserInfo
+ )
+ }
+ }
+
+ guard let client = ddpClient else {
+ #if DEBUG
+ print("[\(TAG)] Native DDP client unavailable for accept \(payload.callId); relying on JS")
+ #endif
+ finishAccept(false)
+ return
+ }
+
+ guard let params = buildMediaCallAnswerParams(payload: payload, kind: .accept) else {
+ finishAccept(false)
+ return
+ }
+
+ if isDdpLoggedIn {
+ client.callMethod("stream-notify-user", params: params) { success in
+ #if DEBUG
+ print("[\(TAG)] Native accept signal result for \(payload.callId): \(success)")
+ #endif
+ finishAccept(success)
+ }
+ } else {
+ client.queueMethodCall("stream-notify-user", params: params) { success in
+ #if DEBUG
+ print("[\(TAG)] Queued native accept signal result for \(payload.callId): \(success)")
+ #endif
+ finishAccept(success)
+ }
+ #if DEBUG
+ print("[\(TAG)] Queued native accept signal for \(payload.callId)")
+ #endif
+ }
+ }
+
private static func sendRejectSignal(payload: VoipPayload) {
guard let client = ddpClient else {
#if DEBUG
@@ -404,7 +521,7 @@ public final class VoipService: NSObject {
return
}
- guard let params = buildRejectMethodParams(payload: payload) else {
+ guard let params = buildMediaCallAnswerParams(payload: payload, kind: .reject) else {
return
}
@@ -424,7 +541,7 @@ public final class VoipService: NSObject {
return
}
- guard let params = buildRejectMethodParams(payload: payload) else {
+ guard let params = buildMediaCallAnswerParams(payload: payload, kind: .reject) else {
return
}
@@ -436,7 +553,7 @@ public final class VoipService: NSObject {
}
}
- private static func flushPendingRejectSignalIfNeeded() -> Bool {
+ private static func flushPendingQueuedSignalsIfNeeded() -> Bool {
guard let client = ddpClient, client.hasQueuedMethodCalls() else {
return false
}
@@ -489,7 +606,9 @@ public final class VoipService: NSObject {
}
if call.hasConnected {
+ let payload = observedCall.payload
observedIncomingCall = nil
+ handleNativeAccept(payload: payload)
return
}
@@ -499,6 +618,7 @@ public final class VoipService: NSObject {
observedIncomingCall = nil
cancelIncomingCallTimeout(for: observedCall.payload.callId)
+ clearNativeAcceptDedupe(for: observedCall.payload.callId)
if isDdpLoggedIn {
sendRejectSignal(payload: observedCall.payload)
diff --git a/package.json b/package.json
index f263e21bcc3..3e1adde3de5 100644
--- a/package.json
+++ b/package.json
@@ -47,7 +47,7 @@
"@react-navigation/elements": "^2.6.1",
"@react-navigation/native": "^7.1.16",
"@react-navigation/native-stack": "^7.3.23",
- "@rocket.chat/media-signaling": "file:./packages/rocket.chat-media-signaling-0.1.1.tgz",
+ "@rocket.chat/media-signaling": "file:./packages/rocket.chat-media-signaling-0.1.3.tgz",
"@rocket.chat/message-parser": "^0.31.31",
"@rocket.chat/mobile-crypto": "RocketChat/rocket.chat-mobile-crypto",
"@rocket.chat/sdk": "RocketChat/Rocket.Chat.js.SDK#mobile",
diff --git a/packages/rocket.chat-media-signaling-0.1.1.tgz b/packages/rocket.chat-media-signaling-0.1.1.tgz
deleted file mode 100644
index 52ffe0f9308..00000000000
Binary files a/packages/rocket.chat-media-signaling-0.1.1.tgz and /dev/null differ
diff --git a/packages/rocket.chat-media-signaling-0.1.3.tgz b/packages/rocket.chat-media-signaling-0.1.3.tgz
new file mode 100644
index 00000000000..77146990c5e
Binary files /dev/null and b/packages/rocket.chat-media-signaling-0.1.3.tgz differ
diff --git a/yarn.lock b/yarn.lock
index 47c478e3563..132ba5a32c9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5016,9 +5016,9 @@
dependencies:
eslint-plugin-import "^2.17.2"
-"@rocket.chat/media-signaling@file:./packages/rocket.chat-media-signaling-0.1.1.tgz":
- version "0.1.1"
- resolved "file:./packages/rocket.chat-media-signaling-0.1.1.tgz#b9859941d78af41e1fd82d2e0cf53d119aeaac9b"
+"@rocket.chat/media-signaling@file:./packages/rocket.chat-media-signaling-0.1.3.tgz":
+ version "0.1.3"
+ resolved "file:./packages/rocket.chat-media-signaling-0.1.3.tgz#d120c37812a26c2223a53761c7936938ad05ccfb"
dependencies:
"@rocket.chat/emitter" "^0.32.0"
ajv "^8.17.1"