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"