From caf333807f64fa2b885eaa70b4e121694177a8b0 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 9 Mar 2026 14:07:21 -0300 Subject: [PATCH 01/23] Receive voip push on Android --- .../reactnative/notification/Ejson.java | 33 ++------ .../RCFirebaseMessagingService.kt | 14 ++-- .../reactnative/voip/IncomingCallActivity.kt | 6 +- .../rocket/reactnative/voip/VoipPayload.kt | 83 ++++++++++++++++--- app/definitions/Voip.ts | 4 + 5 files changed, 89 insertions(+), 51 deletions(-) diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java index 43489b5dd2f..1bf94a90d6c 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java @@ -57,7 +57,7 @@ private MMKV getMMKV() { * Helper method to build avatar URI from avatar path. * Validates server URL and credentials, then constructs the full URI. */ - private String buildAvatarUri(String avatarPath, String errorContext, int sizePx) { + private String buildAvatarUri(String avatarPath, String errorContext) { String server = serverURL(); if (server == null || server.isEmpty()) { Log.w(TAG, "Cannot generate " + errorContext + " avatar URI: serverURL is null"); @@ -67,7 +67,7 @@ private String buildAvatarUri(String avatarPath, String errorContext, int sizePx String userToken = token(); String uid = userId(); - String finalUri = server + avatarPath + "?format=png&size=" + sizePx; + String finalUri = server + avatarPath + "?format=png&size=100"; if (!userToken.isEmpty() && !uid.isEmpty()) { finalUri += "&rc_token=" + userToken + "&rc_uid=" + uid; } @@ -102,37 +102,14 @@ public String getAvatarUri() { } } - return buildAvatarUri(avatarPath, "", 100); + return buildAvatarUri(avatarPath, ""); } /** - * Factory for building caller avatar URIs from host + username (e.g. VoIP payload). - * Caller is package-private, so this is the only way to get avatar URI from outside the package. - */ - public static Ejson forCallerAvatar(String host, String username) { - if (host == null || host.isEmpty() || username == null || username.isEmpty()) { - return null; - } - Ejson ejson = new Ejson(); - ejson.host = host; - ejson.caller = new Caller(); - ejson.caller.username = username; - return ejson; - } - - /** - * Generates avatar URI for video conference caller (default size 100). + * Generates avatar URI for video conference caller. * Returns null if caller username is not available (username is required for avatar endpoint). */ public String getCallerAvatarUri() { - return getCallerAvatarUri(100); - } - - /** - * Generates avatar URI for video conference caller with custom size. - * Returns null if caller username is not available. - */ - public String getCallerAvatarUri(int sizePx) { if (caller == null || caller.username == null || caller.username.isEmpty()) { Log.w(TAG, "Cannot generate caller avatar URI: caller or username is null"); return null; @@ -140,7 +117,7 @@ public String getCallerAvatarUri(int sizePx) { try { String avatarPath = "/avatar/" + URLEncoder.encode(caller.username, "UTF-8"); - return buildAvatarUri(avatarPath, "caller", sizePx); + return buildAvatarUri(avatarPath, "caller"); } catch (UnsupportedEncodingException e) { Log.e(TAG, "Failed to encode caller username", e); return null; 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 417028e659e..b0eb2d02946 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 @@ -23,7 +23,7 @@ class RCFirebaseMessagingService : FirebaseMessagingService() { } override fun onMessageReceived(remoteMessage: RemoteMessage) { - Log.d(TAG, "FCM message received from: ${remoteMessage.from}") + Log.d(TAG, "FCM message received from: ${remoteMessage.from} data: ${remoteMessage.data}") val data = remoteMessage.data if (data.isEmpty()) { @@ -31,13 +31,6 @@ class RCFirebaseMessagingService : FirebaseMessagingService() { return } - // Convert FCM data to Bundle for processing - val bundle = Bundle().apply { - data.forEach { (key, value) -> - putString(key, value) - } - } - val voipPayload = VoipPayload.fromMap(data) if (voipPayload != null) { Log.d(TAG, "Detected VoIP incoming call payload, routing to VoipNotification handler") @@ -47,6 +40,11 @@ class RCFirebaseMessagingService : FirebaseMessagingService() { // Process regular notifications via CustomPushNotification try { + val bundle = Bundle().apply { + data.forEach { (key, value) -> + putString(key, value) + } + } val notification = CustomPushNotification(this, bundle) notification.onReceived() } catch (e: Exception) { 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 0cf6b64a9d8..88e9dc69a9b 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 @@ -21,7 +21,6 @@ import android.view.ViewOutlineProvider import com.bumptech.glide.Glide import chat.rocket.reactnative.MainActivity import chat.rocket.reactnative.R -import chat.rocket.reactnative.notification.Ejson import android.graphics.Typeface /** @@ -152,13 +151,10 @@ class IncomingCallActivity : Activity() { } private fun loadAvatar(payload: VoipPayload) { - if (payload.host.isBlank() || payload.username.isBlank()) return - val container = findViewById(R.id.avatar_container) val imageView = findViewById(R.id.avatar) val sizePx = (120 * resources.displayMetrics.density).toInt().coerceIn(120, 480) - val avatarUrl = Ejson.forCallerAvatar(payload.host, payload.username)?.getCallerAvatarUri(sizePx) - ?: return + val avatarUrl = payload.avatarUrl?.takeIf { it.isNotBlank() } ?: return val cornerRadiusPx = (8 * resources.displayMetrics.density).toFloat() Glide.with(this) 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 e9971eca91a..f3958a393ee 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 @@ -1,6 +1,7 @@ package chat.rocket.reactnative.voip import android.os.Bundle +import com.google.gson.Gson import com.google.gson.annotations.SerializedName import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.WritableMap @@ -24,6 +25,9 @@ data class VoipPayload( @SerializedName("hostName") val hostName: String, + + @SerializedName("avatarUrl") + val avatarUrl: String?, ) { val notificationId: Int = callId.hashCode() val callUUID: String = CallIdUUID.generateUUIDv5(callId) @@ -40,6 +44,7 @@ data class VoipPayload( putString("host", host) putString("type", type) putString("hostName", hostName) + putString("avatarUrl", avatarUrl) putString("callUUID", callUUID) putInt("notificationId", notificationId) // Useful flag for MainActivity to know it's handling a VoIP action @@ -55,22 +60,66 @@ data class VoipPayload( putString("host", host) putString("type", type) putString("hostName", hostName) + putString("avatarUrl", avatarUrl) putString("callUUID", callUUID) putInt("notificationId", notificationId) } } companion object { + private val gson = Gson() + + private data class RemoteCaller( + @SerializedName("name") + val name: String? = null, + + @SerializedName("avatarUrl") + val avatarUrl: String? = null, + ) + + private data class RemoteVoipPayload( + @SerializedName("callId") + val callId: String? = null, + + @SerializedName("caller") + val caller: RemoteCaller? = null, + + @SerializedName("username") + val username: String? = null, + + @SerializedName("host") + val host: String? = null, + + @SerializedName("type") + val type: String? = null, + + @SerializedName("hostName") + val hostName: String? = null, + + @SerializedName("notificationType") + val notificationType: String? = null, + ) { + fun toVoipPayload(): VoipPayload? { + if (notificationType != "voip") return null + + val payloadType = type ?: return null + if (payloadType != "incoming_call") return null + + return VoipPayload( + callId = callId ?: return null, + caller = caller?.name ?: return null, + username = username ?: return null, + host = host ?: return null, + type = payloadType, + hostName = hostName ?: return null, + avatarUrl = caller?.avatarUrl, + ) + } + } + fun fromMap(data: Map): VoipPayload? { - val type = data["type"] ?: return null - val callId = data["callId"] ?: return null - val caller = data["caller"] ?: return null - val username = data["username"] ?: return null - val host = data["host"] ?: return null - val hostName = data["hostName"] ?: return null - if (type != "incoming_call") return null - - return VoipPayload(callId, caller, username, host, type, hostName) + val payload = parseRemotePayload(data) ?: return null + return payload.toVoipPayload() } fun fromBundle(bundle: Bundle?): VoipPayload? { @@ -81,8 +130,22 @@ data class VoipPayload( val host = bundle.getString("host") ?: return null val type = bundle.getString("type") ?: return null val hostName = bundle.getString("hostName") ?: return null + val avatarUrl = bundle.getString("avatarUrl") + + return VoipPayload(callId, caller, username, host, type, hostName, avatarUrl) + } + + private fun parseRemotePayload(data: Map): RemoteVoipPayload? { + val rawPayload = data["ejson"] + if (rawPayload.isNullOrBlank() || rawPayload == "{}") { + return null + } - return VoipPayload(callId, caller, username, host, type, hostName) + return try { + gson.fromJson(rawPayload, RemoteVoipPayload::class.java) + } catch (_: Exception) { + null + } } } } \ No newline at end of file diff --git a/app/definitions/Voip.ts b/app/definitions/Voip.ts index 203d8701849..6fe918ddb6d 100644 --- a/app/definitions/Voip.ts +++ b/app/definitions/Voip.ts @@ -7,7 +7,11 @@ export type IceServer = { export interface VoipPayload { readonly callId: string; readonly caller: string; + readonly username: string; readonly host: string; + readonly hostName: string; readonly type: string; + readonly avatarUrl?: string | null; readonly callUUID: string; + readonly notificationId: number; } From cef0d271502c4856b0b5a532803ee078ca356726 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 9 Mar 2026 14:12:33 -0300 Subject: [PATCH 02/23] callUUID -> callId --- .../reactnative/voip/IncomingCallActivity.kt | 6 +-- .../reactnative/voip/VoipNotification.kt | 21 ++++------ .../rocket/reactnative/voip/VoipPayload.kt | 4 -- app/actions/deepLinking.ts | 1 - .../MediaCallHeader.stories.tsx | 1 - .../MediaCallHeader/MediaCallHeader.test.tsx | 1 - app/definitions/Voip.ts | 1 - app/lib/services/voip/MediaCallEvents.ts | 10 ++--- app/lib/services/voip/MediaSessionInstance.ts | 42 +++++++++---------- app/lib/services/voip/useCallStore.ts | 28 ++++++------- app/stacks/types.ts | 4 +- app/views/CallView/CallView.stories.tsx | 2 +- .../components/CallerInfo.stories.tsx | 2 +- .../CallView/components/CallerInfo.test.tsx | 2 +- .../components/Dialpad/Dialpad.stories.tsx | 2 +- .../components/Dialpad/Dialpad.test.tsx | 2 +- app/views/CallView/index.test.tsx | 2 +- ios/AppDelegate.swift | 5 +-- ios/Libraries/VoipService.swift | 5 --- 19 files changed, 59 insertions(+), 82 deletions(-) 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 88e9dc69a9b..e075f764f97 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 @@ -74,7 +74,7 @@ class IncomingCallActivity : Activity() { } this.voipPayload = voipPayload - Log.d(TAG, "IncomingCallActivity created - callUUID: ${voipPayload.callUUID}, caller: ${voipPayload.caller}") + Log.d(TAG, "IncomingCallActivity created - callId: ${voipPayload.callId}, caller: ${voipPayload.caller}") updateUI(voipPayload) startRingtone() @@ -229,7 +229,7 @@ class IncomingCallActivity : Activity() { } private fun handleAccept(payload: VoipPayload) { - Log.d(TAG, "Call accepted - callUUID: ${payload.callUUID}") + Log.d(TAG, "Call accepted - callId: ${payload.callId}") stopRingtone() // Launch MainActivity with call data @@ -243,7 +243,7 @@ class IncomingCallActivity : Activity() { } private fun handleDecline(payload: VoipPayload) { - Log.d(TAG, "Call declined - callUUID: ${payload.callUUID}") + Log.d(TAG, "Call declined - callId: ${payload.callId}") stopRingtone() VoipNotification.cancelById(this, payload.notificationId) 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 644d912a51e..fc65542beb4 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 @@ -59,7 +59,7 @@ class VoipNotification(private val context: Context) { */ @JvmStatic fun handleDeclineAction(context: Context, payload: VoipPayload) { - Log.d(TAG, "Decline action triggered for callUUID: ${payload.callUUID}") + Log.d(TAG, "Decline action triggered for callId: ${payload.callId}") // TODO: call restapi to decline the call } } @@ -119,13 +119,12 @@ class VoipNotification(private val context: Context) { fun showIncomingCall(voipPayload: VoipPayload) { val callId = voipPayload.callId val caller = voipPayload.caller - val callUUID = voipPayload.callUUID - Log.d(TAG, "Showing incoming VoIP call - callId: $callId, callUUID: $callUUID, caller: $caller") + Log.d(TAG, "Showing incoming VoIP call - callId: $callId, caller: $caller") // CRITICAL: Register call with TelecomManager FIRST (required for audio focus, Bluetooth, priority, FSI exemption) // This triggers react-native-callkeep's ConnectionService - registerCallWithTelecomManager(callUUID, caller) + registerCallWithTelecomManager(callId, caller) // Show notification with full-screen intent showIncomingCallNotification(voipPayload) @@ -139,11 +138,11 @@ class VoipNotification(private val context: Context) { * 3. Higher process priority * 4. FSI exemption on Play Store */ - private fun registerCallWithTelecomManager(callUUID: String, caller: String) { + private fun registerCallWithTelecomManager(callId: String, caller: String) { try { // Validate inputs - if (callUUID.isNullOrEmpty() || caller.isNullOrEmpty()) { - Log.e(TAG, "Cannot register call with TelecomManager: callUUID is null or empty") + if (callId.isNullOrEmpty() || caller.isNullOrEmpty()) { + Log.e(TAG, "Cannot register call with TelecomManager: callId is null or empty") return } @@ -169,19 +168,17 @@ class VoipNotification(private val context: Context) { val extras = Bundle().apply { val callerUri = Uri.fromParts(PhoneAccount.SCHEME_TEL, caller, null) putParcelable(TelecomManager.EXTRA_INCOMING_CALL_ADDRESS, callerUri) - putString("EXTRA_CALL_UUID", callUUID) + putString("EXTRA_CALL_UUID", callId) putString("EXTRA_CALLER_NAME", caller) - // Legacy keys for backward compatibility - putString("callUUID", callUUID) putString("name", caller) putString("handle", caller) } - Log.d(TAG, "Registering call with TelecomManager - callUUID: $callUUID, caller: $caller, extras keys: ${extras.keySet()}") + Log.d(TAG, "Registering call with TelecomManager - callId: $callId, caller: $caller, extras keys: ${extras.keySet()}") // Register the incoming call with the OS telecomManager.addNewIncomingCall(phoneAccountHandle, extras) - Log.d(TAG, "Successfully registered incoming call with TelecomManager: $callUUID") + Log.d(TAG, "Successfully registered incoming call with TelecomManager: $callId") } catch (e: SecurityException) { Log.e(TAG, "SecurityException registering call with TelecomManager. MANAGE_OWN_CALLS permission may be missing.", e) } catch (e: Exception) { 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 f3958a393ee..5c07bde33f9 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 @@ -5,7 +5,6 @@ import com.google.gson.Gson import com.google.gson.annotations.SerializedName import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.WritableMap -import chat.rocket.reactnative.utils.CallIdUUID data class VoipPayload( @SerializedName("callId") @@ -30,7 +29,6 @@ data class VoipPayload( val avatarUrl: String?, ) { val notificationId: Int = callId.hashCode() - val callUUID: String = CallIdUUID.generateUUIDv5(callId) fun isVoipIncomingCall(): Boolean { return type == "incoming_call" && callId.isNotEmpty() && caller.isNotEmpty() && host.isNotEmpty() @@ -45,7 +43,6 @@ data class VoipPayload( putString("type", type) putString("hostName", hostName) putString("avatarUrl", avatarUrl) - putString("callUUID", callUUID) putInt("notificationId", notificationId) // Useful flag for MainActivity to know it's handling a VoIP action putBoolean("voipAction", true) @@ -61,7 +58,6 @@ data class VoipPayload( putString("type", type) putString("hostName", hostName) putString("avatarUrl", avatarUrl) - putString("callUUID", callUUID) putInt("notificationId", notificationId) } } diff --git a/app/actions/deepLinking.ts b/app/actions/deepLinking.ts index 548aad8769a..d55de045aa3 100644 --- a/app/actions/deepLinking.ts +++ b/app/actions/deepLinking.ts @@ -18,7 +18,6 @@ interface IDeepLinkingOpen extends Action { interface IVoipCallParams { callId: string; - callUUID: string; host: string; } diff --git a/app/containers/MediaCallHeader/MediaCallHeader.stories.tsx b/app/containers/MediaCallHeader/MediaCallHeader.stories.tsx index ea8b5618ae2..4b3e9519fb4 100644 --- a/app/containers/MediaCallHeader/MediaCallHeader.stories.tsx +++ b/app/containers/MediaCallHeader/MediaCallHeader.stories.tsx @@ -35,7 +35,6 @@ const setStoreState = (overrides: Partial void) => { } console.log(`${TAG} Initial events event:`, data); NativeVoipModule.clearInitialEvents(); - useCallStore.getState().setCallUUID(data.callUUID); + useCallStore.getState().setCallId(data.callId); store.dispatch( voipCallOpen({ callId: data.callId, - callUUID: data.callUUID, host: data.host }) ); - await mediaSessionInstance.answerCall(data.callUUID); + await mediaSessionInstance.answerCall(data.callId); } catch (error) { console.error(`${TAG} Error handling initial events event:`, error); } @@ -110,7 +109,7 @@ export const getInitialMediaCallEvents = async (): Promise => { const { name, data } = event; if (name === 'RNCallKeepPerformAnswerCallAction') { const { callUUID } = data; - if (initialEvents.callUUID.toLowerCase() === callUUID.toLowerCase()) { + if (initialEvents.callId.toLowerCase() === callUUID.toLowerCase()) { wasAnswered = true; console.log(`${TAG} Call was already answered via CallKit`); break; @@ -123,12 +122,11 @@ export const getInitialMediaCallEvents = async (): Promise => { } if (wasAnswered) { - useCallStore.getState().setCallUUID(initialEvents.callUUID); + useCallStore.getState().setCallId(initialEvents.callId); store.dispatch( voipCallOpen({ callId: initialEvents.callId, - callUUID: initialEvents.callUUID, host: initialEvents.host }) ); diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index 7b01092196b..a8a59fdcf21 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -67,43 +67,43 @@ class MediaSessionInstance { console.log(`📊 ${oldState} → ${call.state}`); }); - const existingCallUUID = useCallStore.getState().callUUID; - console.log('[VoIP] Existing call UUID:', existingCallUUID); + const existingCallId = useCallStore.getState().callId; + console.log('[VoIP] Existing call Id:', existingCallId); // // TODO: need to answer the call here? - if (existingCallUUID) { - this.answerCall(existingCallUUID); + if (existingCallId) { + this.answerCall(existingCallId); return; } - const callUUID = CallIdUUIDModule.toUUID(call.callId); - console.log('[VoIP] New call UUID:', callUUID); + // const callUUID = CallIdUUIDModule.toUUID(call.callId); + // console.log('[VoIP] New call UUID:', callUUID); if (call.role === 'caller') { - useCallStore.getState().setCall(call, callUUID); - Navigation.navigate('CallView', { callUUID }); + useCallStore.getState().setCall(call); + Navigation.navigate('CallView'); } call.emitter.on('ended', () => { - RNCallKeep.endCall(callUUID); + RNCallKeep.endCall(call.callId); }); } }); } - public answerCall = async (callUUID: string) => { - console.log('[VoIP] Answering call:', callUUID); + public answerCall = async (callId: string) => { + console.log('[VoIP] Answering call:', callId); const mainCall = this.instance?.getMainCall(); console.log('[VoIP] Main call:', mainCall); // Compare using deterministic UUID conversion - if (mainCall && CallIdUUIDModule.toUUID(mainCall.callId) === callUUID) { - console.log('[VoIP] Accepting call:', callUUID); + if (mainCall && mainCall.callId === callId) { + console.log('[VoIP] Accepting call:', callId); await mainCall.accept(); - console.log('[VoIP] Setting current call active:', callUUID); - RNCallKeep.setCurrentCallActive(callUUID); - useCallStore.getState().setCall(mainCall, callUUID); - Navigation.navigate('CallView', { callUUID }); + console.log('[VoIP] Setting current call active:', callId); + RNCallKeep.setCurrentCallActive(callId); + useCallStore.getState().setCall(mainCall); + Navigation.navigate('CallView'); } else { - RNCallKeep.endCall(callUUID); + RNCallKeep.endCall(callId); alert('Call not found'); // TODO: Show error message? } }; @@ -120,17 +120,17 @@ class MediaSessionInstance { this.instance?.startCall(actor, userId); }; - public endCall = (callUUID: string) => { + public endCall = (callId: string) => { const mainCall = this.instance?.getMainCall(); // Compare using deterministic UUID conversion - if (mainCall && CallIdUUIDModule.toUUID(mainCall.callId) === callUUID) { + if (mainCall && mainCall.callId === callId) { if (mainCall.state === 'ringing') { mainCall.reject(); } else { mainCall.hangup(); } } - RNCallKeep.endCall(callUUID); + RNCallKeep.endCall(callId); RNCallKeep.setCurrentCallActive(''); RNCallKeep.setAvailable(true); // Reset Zustand store diff --git a/app/lib/services/voip/useCallStore.ts b/app/lib/services/voip/useCallStore.ts index 56bb1c19717..abf2e1eb586 100644 --- a/app/lib/services/voip/useCallStore.ts +++ b/app/lib/services/voip/useCallStore.ts @@ -9,7 +9,7 @@ import { hideActionSheetRef } from '../../../containers/ActionSheet'; interface CallStoreState { // Call reference call: IClientMediaCall | null; - callUUID: string | null; + callId: string | null; // Call state callState: CallState; @@ -27,8 +27,8 @@ interface CallStoreState { } interface CallStoreActions { - setCallUUID: (callUUID: string | null) => void; - setCall: (call: IClientMediaCall, callUUID: string) => void; + setCallId: (callId: string | null) => void; + setCall: (call: IClientMediaCall) => void; toggleMute: () => void; toggleHold: () => void; toggleSpeaker: () => void; @@ -42,7 +42,7 @@ export type CallStore = CallStoreState & CallStoreActions; const initialState: CallStoreState = { call: null, - callUUID: null, + callId: null, callState: 'none', isMuted: false, isOnHold: false, @@ -58,15 +58,15 @@ const initialState: CallStoreState = { export const useCallStore = create((set, get) => ({ ...initialState, - setCallUUID: (callUUID: string | null) => { - set({ callUUID }); + setCallId: (callId: string | null) => { + set({ callId }); }, - setCall: (call: IClientMediaCall, callUUID: string) => { + setCall: (call: IClientMediaCall) => { // Update state with call info set({ call, - callUUID, + callId: call.callId, callState: call.state, isMuted: call.muted, isOnHold: call.held, @@ -140,8 +140,8 @@ export const useCallStore = create((set, get) => ({ }, toggleSpeaker: async () => { - const { callUUID, isSpeakerOn } = get(); - if (!callUUID) return; + const { callId, isSpeakerOn } = get(); + if (!callId) return; const newSpeakerOn = !isSpeakerOn; @@ -159,7 +159,7 @@ export const useCallStore = create((set, get) => ({ if (isFocused) { Navigation.back(); } else { - Navigation.navigate('CallView', { callUUID: get().callUUID }); + Navigation.navigate('CallView'); } }, @@ -173,14 +173,14 @@ export const useCallStore = create((set, get) => ({ }, endCall: () => { - const { call, callUUID } = get(); + const { call, callId } = get(); if (call) { call.hangup(); } - if (callUUID) { - RNCallKeep.endCall(callUUID); + if (callId) { + RNCallKeep.endCall(callId); } // Navigation.back(); // TODO: It could be collapsed, so going back woudln't make sense diff --git a/app/stacks/types.ts b/app/stacks/types.ts index 02d606a5833..6996ae91c4f 100644 --- a/app/stacks/types.ts +++ b/app/stacks/types.ts @@ -296,9 +296,7 @@ export type InsideStackParamList = { ModalBlockView: { data: any; // TODO: Change; }; - CallView: { - callUUID: string; - }; + CallView: undefined; }; export type OutsideParamList = { diff --git a/app/views/CallView/CallView.stories.tsx b/app/views/CallView/CallView.stories.tsx index 1fe80b664df..e4a4a18a5ac 100644 --- a/app/views/CallView/CallView.stories.tsx +++ b/app/views/CallView/CallView.stories.tsx @@ -41,7 +41,7 @@ const setStoreState = (overrides: Partial Date: Mon, 9 Mar 2026 15:16:18 -0300 Subject: [PATCH 03/23] Voip push working on iOS --- .../rocket/reactnative/voip/VoipModule.kt | 2 + app/definitions/rest/v1/push.ts | 2 +- app/lib/native/NativeVoip.ts | 20 ++- app/lib/notifications/push.ts | 14 +- app/lib/services/restApi.test.ts | 87 +++++++++++ app/lib/services/restApi.ts | 69 ++++++--- app/lib/services/voip/MediaCallEvents.ts | 8 +- app/lib/services/voip/pushTokenAux.ts | 8 -- ios/AppDelegate.swift | 47 ------ ios/Libraries/AppDelegate+Voip.swift | 51 +++++++ ios/Libraries/VoipModule.mm | 5 + ios/Libraries/VoipPayload.swift | 135 ++++++++++++++++++ ios/Libraries/VoipService.swift | 85 +++++------ ios/RocketChatRN.xcodeproj/project.pbxproj | 30 ++-- 14 files changed, 410 insertions(+), 153 deletions(-) create mode 100644 app/lib/services/restApi.test.ts delete mode 100644 app/lib/services/voip/pushTokenAux.ts create mode 100644 ios/Libraries/AppDelegate+Voip.swift create mode 100644 ios/Libraries/VoipPayload.swift 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 099238129c0..fcf48fecf17 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 @@ -101,6 +101,8 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo clearInitialEventsInternal() } + override fun getLastVoipToken(): String = "" + /** * Registers for VoIP push token. * No-op on Android - uses FCM for push notifications. diff --git a/app/definitions/rest/v1/push.ts b/app/definitions/rest/v1/push.ts index f00f737bb0d..d28a8371893 100644 --- a/app/definitions/rest/v1/push.ts +++ b/app/definitions/rest/v1/push.ts @@ -6,7 +6,7 @@ type TPushInfo = { export type PushEndpoints = { 'push.token': { - POST: (params: { value: string; type: string; appName: string }) => { + POST: (params: { value: string; type: string; appName: string; voipToken?: string }) => { result: { id: string; token: string; diff --git a/app/lib/native/NativeVoip.ts b/app/lib/native/NativeVoip.ts index 2b39ea631dd..aea902d4e27 100644 --- a/app/lib/native/NativeVoip.ts +++ b/app/lib/native/NativeVoip.ts @@ -21,6 +21,13 @@ export interface Spec extends TurboModule { */ clearInitialEvents(): void; + /** + * Gets the last known VoIP push token. + * iOS: Returns the cached PushKit token. + * Android: Returns an empty string. + */ + getLastVoipToken(): string; + /** * Required for NativeEventEmitter in TurboModules. * Called when JS starts listening to events. @@ -36,4 +43,15 @@ export interface Spec extends TurboModule { removeListeners(count: number): void; } -export default TurboModuleRegistry.getEnforcing('VoipModule'); +const NativeVoipModule = + TurboModuleRegistry.get('VoipModule') ?? + ({ + registerVoipToken: () => undefined, + getInitialEvents: () => null, + clearInitialEvents: () => undefined, + getLastVoipToken: () => '', + addListener: () => undefined, + removeListeners: () => undefined + } as Spec); + +export default NativeVoipModule; diff --git a/app/lib/notifications/push.ts b/app/lib/notifications/push.ts index dc6858b25c2..95207db2724 100644 --- a/app/lib/notifications/push.ts +++ b/app/lib/notifications/push.ts @@ -186,19 +186,19 @@ export const pushNotificationConfigure = (onNotification: (notification: INotifi if (token) { deviceToken = token; console.log('[push.ts] Registered for push notifications:', token); + + registerPushToken().catch(e => { + console.log('[push.ts] Failed to register push token after initial acquisition:', e); + }); } }); // Listen for token updates (FCM can refresh tokens at any time) Notifications.addPushTokenListener(tokenData => { deviceToken = tokenData.data; - // Re-register with server if user is logged in - const { isAuthenticated } = reduxStore.getState().login; - if (isAuthenticated) { - registerPushToken().catch(e => { - console.log('Failed to re-register push token after refresh:', e); - }); - } + registerPushToken().catch(e => { + console.log('[push.ts] Failed to re-register push token after refresh:', e); + }); }); // Listen for notification responses (when user taps on notification) diff --git a/app/lib/services/restApi.test.ts b/app/lib/services/restApi.test.ts new file mode 100644 index 00000000000..37985fcafa7 --- /dev/null +++ b/app/lib/services/restApi.test.ts @@ -0,0 +1,87 @@ +import { getDeviceToken } from '../notifications'; +import NativeVoipModule from '../native/NativeVoip'; +import { store as reduxStore } from '../store/auxStore'; +import sdk from './sdk'; +import { registerPushToken } from './restApi'; + +jest.mock('../notifications', () => ({ + getDeviceToken: jest.fn() +})); + +jest.mock('../native/NativeVoip', () => ({ + __esModule: true, + default: { + getLastVoipToken: jest.fn() + } +})); + +jest.mock('../store/auxStore', () => ({ + store: { + getState: jest.fn() + } +})); + +jest.mock('../methods/helpers', () => ({ + compareServerVersion: jest.fn(), + getBundleId: 'chat.rocket.reactnative', + isIOS: true +})); + +jest.mock('./sdk', () => ({ + __esModule: true, + default: { + post: jest.fn(), + current: { + client: { host: 'https://chat.example.com' }, + currentLogin: { authToken: 'auth-token' } + } + } +})); + +describe('registerPushToken', () => { + const mockedGetDeviceToken = getDeviceToken as jest.Mock; + const mockedGetLastVoipToken = NativeVoipModule.getLastVoipToken as jest.Mock; + const mockedGetState = reduxStore.getState as jest.Mock; + const mockedPost = sdk.post as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + mockedGetState.mockReturnValue({ login: { isAuthenticated: true } }); + mockedGetDeviceToken.mockReturnValue('apn-token'); + mockedGetLastVoipToken.mockReturnValue('voip-token'); + mockedPost.mockResolvedValue({}); + sdk.current.client.host = 'https://chat.example.com'; + sdk.current.currentLogin = { authToken: 'auth-token' }; + }); + + it('waits for both iOS tokens before registering', async () => { + mockedGetLastVoipToken.mockReturnValue(''); + + await registerPushToken(); + + expect(mockedPost).not.toHaveBeenCalled(); + }); + + it('skips duplicate successful iOS registrations', async () => { + await registerPushToken(); + await registerPushToken(); + + expect(mockedPost).toHaveBeenCalledTimes(1); + expect(mockedPost).toHaveBeenCalledWith('push.token', { + value: 'apn-token', + type: 'apn', + appName: 'chat.rocket.reactnative', + voipToken: 'voip-token' + }); + }); + + it('retries the same payload after a failed request', async () => { + mockedPost.mockRejectedValueOnce(new Error('network')); + + await registerPushToken(); + await registerPushToken(); + + expect(mockedPost).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/lib/services/restApi.ts b/app/lib/services/restApi.ts index 9f543c0bbbf..b2a0d764041 100644 --- a/app/lib/services/restApi.ts +++ b/app/lib/services/restApi.ts @@ -1,3 +1,5 @@ +import { getUniqueId } from 'react-native-device-info'; + import { type IAvatarSuggestion, type IMessage, @@ -23,10 +25,11 @@ import { type RoomTypes, roomTypeToApiType } from '../methods/roomTypeToApiType' import { unsubscribeRooms } from '../methods/subscribeRooms'; import { compareServerVersion, getBundleId, isIOS } from '../methods/helpers'; import { getDeviceToken } from '../notifications'; +import NativeVoipModule from '../native/NativeVoip'; import { store as reduxStore } from '../store/auxStore'; import sdk from './sdk'; import fetch from '../methods/helpers/fetch'; -import { getVoipPushToken } from './voip/pushTokenAux'; +import log from '../methods/helpers/log'; export const createChannel = ({ name, @@ -999,38 +1002,62 @@ export const editMessage = async (message: Pick +let lastToken = ''; +let lastVoipToken = ''; + +type TRegisterPushTokenData = { + id: string; + value: string; + type: string; + appName: string; + voipToken?: string; +}; +export const registerPushToken = (): Promise => new Promise(async resolve => { const token = getDeviceToken(); + const voipToken = isIOS ? NativeVoipModule.getLastVoipToken() : ''; + + if (token === lastToken && voipToken === lastVoipToken) { + return resolve(); + } + + // TODO: server version + if (isIOS && (!token || !voipToken)) { + return resolve(); + } + + let data: TRegisterPushTokenData = { + id: '', + value: '', + type: '', + appName: '' + }; if (token) { const type = isIOS ? 'apn' : 'gcm'; - const data = { + data = { + id: await getUniqueId(), value: token, type, appName: getBundleId }; - try { - if (isIOS) { - const voipToken = getVoipPushToken(); - if (voipToken) { - // TODO: this is temp only for VoIP push token - await sdk.post('push.token', { - type: 'gcm', - value: voipToken, - appName: getBundleId - }); - } - } - - // RC 0.60.0 - await sdk.post('push.token', data); - } catch (error) { - console.log(error); - } + } + if (isIOS && voipToken) { + data.voipToken = voipToken; + } + + try { + // RC 0.60.0 + await sdk.post('push.token', data); + console.log('registerPushToken success', data); + lastToken = token; + lastVoipToken = voipToken; + } catch (e) { + log(e); } return resolve(); }); +// TODO: add voip token removal export const removePushToken = (): Promise => { const token = getDeviceToken(); if (token) { diff --git a/app/lib/services/voip/MediaCallEvents.ts b/app/lib/services/voip/MediaCallEvents.ts index 5a2f542ee83..778b2858334 100644 --- a/app/lib/services/voip/MediaCallEvents.ts +++ b/app/lib/services/voip/MediaCallEvents.ts @@ -4,11 +4,11 @@ import { DeviceEventEmitter, NativeEventEmitter } from 'react-native'; import { isIOS } from '../../methods/helpers'; import store from '../../store'; import { voipCallOpen } from '../../../actions/deepLinking'; -import { setVoipPushToken } from './pushTokenAux'; import { useCallStore } from './useCallStore'; import { mediaSessionInstance } from './MediaSessionInstance'; import type { VoipPayload } from '../../../definitions/Voip'; import NativeVoipModule from '../../native/NativeVoip'; +import { registerPushToken } from '../restApi'; const Emitter = isIOS ? new NativeEventEmitter(NativeVoipModule) : DeviceEventEmitter; const platform = isIOS ? 'iOS' : 'Android'; @@ -24,9 +24,11 @@ export const setupMediaCallEvents = (): (() => void) => { // iOS listens for VoIP push token registration and CallKeep events if (isIOS) { subscriptions.push( - Emitter.addListener('VoipPushTokenRegistered', (token: string) => { + Emitter.addListener('VoipPushTokenRegistered', ({ token }: { token: string }) => { console.log(`${TAG} Registered VoIP push token:`, token); - setVoipPushToken(token); + registerPushToken().catch(error => { + console.log(`${TAG} Failed to register push token after VoIP update:`, error); + }); }) ); diff --git a/app/lib/services/voip/pushTokenAux.ts b/app/lib/services/voip/pushTokenAux.ts deleted file mode 100644 index b369f770aa1..00000000000 --- a/app/lib/services/voip/pushTokenAux.ts +++ /dev/null @@ -1,8 +0,0 @@ -// TODO: find a better place for this -let voipPushToken: string | null = null; - -export const getVoipPushToken = (): string | null => voipPushToken; - -export const setVoipPushToken = (token: string): void => { - voipPushToken = token; -}; diff --git a/ios/AppDelegate.swift b/ios/AppDelegate.swift index 9fe6734f132..43cd51d2c7c 100644 --- a/ios/AppDelegate.swift +++ b/ios/AppDelegate.swift @@ -94,50 +94,3 @@ class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { #endif } } - -// MARK: - PKPushRegistryDelegate - -extension AppDelegate: PKPushRegistryDelegate { - // Handle updated push credentials - public func pushRegistry(_ registry: PKPushRegistry, didUpdate credentials: PKPushCredentials, for type: PKPushType) { - // Register VoIP push token (a property of PKPushCredentials) with server - VoipService.didUpdatePushCredentials(credentials, forType: type.rawValue) - } - - public func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) { - // The system calls this method when a previously provided push token is no longer valid for use. - // No action is necessary on your part to reregister the push type. - // Instead, use this method to notify your server not to send push notifications using the matching push token. - // TODO: call restapi to unregister the push token - } - - public func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { - let callId = payload.dictionaryPayload["callId"] as? String - let caller = payload.dictionaryPayload["caller"] as? String - - guard let callId = callId else { - completion() - return - } - - // Store pending call data in our native module - VoipService.didReceiveIncomingPush(with: payload, forType: type.rawValue) - - RNCallKeep.reportNewIncomingCall( - callId, - handle: caller, - handleType: "generic", - hasVideo: true, - localizedCallerName: caller, - supportsHolding: true, - supportsDTMF: true, - supportsGrouping: true, - supportsUngrouping: true, - fromPushKit: true, - payload: payload.dictionaryPayload, - withCompletionHandler: nil - ) - - completion() - } -} diff --git a/ios/Libraries/AppDelegate+Voip.swift b/ios/Libraries/AppDelegate+Voip.swift new file mode 100644 index 00000000000..aab65d57a2f --- /dev/null +++ b/ios/Libraries/AppDelegate+Voip.swift @@ -0,0 +1,51 @@ +import PushKit + +// MARK: - PKPushRegistryDelegate + +extension AppDelegate: PKPushRegistryDelegate { + + // Handle updated push credentials + public func pushRegistry(_ registry: PKPushRegistry, didUpdate credentials: PKPushCredentials, for type: PKPushType) { + // Register VoIP push token (a property of PKPushCredentials) with server + VoipService.didUpdatePushCredentials(credentials, forType: type.rawValue) + } + + public func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) { + // The system calls this method when a previously provided push token is no longer valid for use. + // No action is necessary on your part to reregister the push type. + // Instead, use this method to notify your server not to send push notifications using the matching push token. + VoipService.invalidatePushToken() + } + + public func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { + let payloadDict = payload.dictionaryPayload + + guard let voipPayload = VoipPayload.fromDictionary(payloadDict) else { + print("Failed to parse incoming VoIP payload") + completion() + return + } + + let callId = voipPayload.callId.lowercased() + let caller = voipPayload.caller + + // Store pending call data in our native module + VoipService.storeInitialEvents(voipPayload) + + RNCallKeep.reportNewIncomingCall( + callId, + handle: caller, + handleType: "generic", + hasVideo: true, + localizedCallerName: caller, + supportsHolding: true, + supportsDTMF: true, + supportsGrouping: true, + supportsUngrouping: true, + fromPushKit: true, + payload: payloadDict, + withCompletionHandler: {} + ) + completion() + } +} diff --git a/ios/Libraries/VoipModule.mm b/ios/Libraries/VoipModule.mm index 2c3ff8cda89..c3f558f981d 100644 --- a/ios/Libraries/VoipModule.mm +++ b/ios/Libraries/VoipModule.mm @@ -11,6 +11,7 @@ @interface VoipService : NSObject + (void)voipRegistration; + (NSDictionary * _Nullable)getInitialEvents; + (void)clearInitialEvents; ++ (NSString * _Nonnull)getLastVoipToken; @end @implementation VoipModule { @@ -89,6 +90,10 @@ - (void)clearInitialEvents { [VoipService clearInitialEvents]; } +- (NSString * _Nonnull)getLastVoipToken { + return [VoipService getLastVoipToken]; +} + - (void)addListener:(NSString *)eventName { // Required for NativeEventEmitter - starts observing } diff --git a/ios/Libraries/VoipPayload.swift b/ios/Libraries/VoipPayload.swift new file mode 100644 index 00000000000..7ccce48a3df --- /dev/null +++ b/ios/Libraries/VoipPayload.swift @@ -0,0 +1,135 @@ +import Foundation + +private struct RemoteCaller { + let name: String? + let avatarUrl: String? + + static func fromDictionary(_ payload: [AnyHashable: Any]) -> RemoteCaller { + RemoteCaller( + name: payload["name"] as? String, + avatarUrl: payload["avatarUrl"] as? String + ) + } +} + +private struct RemoteVoipPayload { + let callId: String? + let caller: RemoteCaller? + let username: String? + let host: String? + let type: String? + let hostName: String? + let notificationType: String? + + static func fromDictionary(_ payload: [AnyHashable: Any]) -> RemoteVoipPayload { + let caller = (payload["caller"] as? [AnyHashable: Any]).map(RemoteCaller.fromDictionary) + + return RemoteVoipPayload( + callId: payload["callId"] as? String, + caller: caller, + username: payload["username"] as? String, + host: payload["host"] as? String, + type: payload["type"] as? String, + hostName: payload["hostName"] as? String, + notificationType: payload["notificationType"] as? String + ) + } + + func toVoipPayload() -> VoipPayload? { + guard notificationType == "voip" else { + return nil + } + + guard + let payloadCallId = callId, + let payloadCaller = caller?.name, + let payloadUsername = username, + let payloadHost = host, + let payloadType = type, + payloadType == "incoming_call", + let payloadHostName = hostName + else { + return nil + } + + return VoipPayload( + callId: payloadCallId, + caller: payloadCaller, + username: payloadUsername, + host: payloadHost, + type: payloadType, + hostName: payloadHostName, + avatarUrl: caller?.avatarUrl + ) + } +} + +/// Data structure for initial events payload +@objc(VoipPayload) +public class VoipPayload: NSObject { + @objc public let callId: String + @objc public let caller: String + @objc public let username: String + @objc public let host: String + @objc public let type: String + @objc public let hostName: String + @objc public let avatarUrl: String? + + @objc public var notificationId: Int { + return callId.hashValue + } + + @objc + public init(callId: String, caller: String, username: String, host: String, type: String, hostName: String, avatarUrl: String?) { + self.callId = callId + self.caller = caller + self.username = username + self.host = host + self.type = type + self.hostName = hostName + self.avatarUrl = avatarUrl + super.init() + } + + @objc + public func isVoipIncomingCall() -> Bool { + return type == "incoming_call" && !callId.isEmpty && !caller.isEmpty && !host.isEmpty + } + + @objc + public func toDictionary() -> [String: Any] { + return [ + "callId": callId, + "caller": caller, + "username": username, + "host": host, + "type": type, + "hostName": hostName, + "avatarUrl": avatarUrl ?? NSNull(), + "notificationId": notificationId + ] + } + + @objc + public static func fromDictionary(_ dict: [AnyHashable: Any]) -> VoipPayload? { + if let parsedPayload = parseRemotePayload(from: dict).toVoipPayload() { + return parsedPayload + } + + guard + let ejsonString = dict["ejson"] as? String, + !ejsonString.isEmpty, + ejsonString != "{}", + let data = ejsonString.data(using: .utf8), + let ejsonPayload = (try? JSONSerialization.jsonObject(with: data)) as? [AnyHashable: Any] + else { + return nil + } + + return parseRemotePayload(from: ejsonPayload).toVoipPayload() + } + + private static func parseRemotePayload(from payload: [AnyHashable: Any]) -> RemoteVoipPayload { + return RemoteVoipPayload.fromDictionary(payload) + } +} diff --git a/ios/Libraries/VoipService.swift b/ios/Libraries/VoipService.swift index a972dc7f504..9ad1fbb4b8a 100644 --- a/ios/Libraries/VoipService.swift +++ b/ios/Libraries/VoipService.swift @@ -16,13 +16,15 @@ public final class VoipService: NSObject { // MARK: - Constants private static let TAG = "RocketChat.VoipModule" + private static let voipTokenStorageKey = "RCVoipPushToken" + private static let storage = MMKVBridge.build() // MARK: - Static Properties private static var initialEventsData: VoipPayload? private static var initialEventsTimestamp: TimeInterval = 0 private static var isVoipRegistered = false - private static var lastVoipToken: String = "" + private static var lastVoipToken: String = loadPersistedVoipToken() private static var voipRegistry: PKPushRegistry? // MARK: - Static Methods (Called from VoipModule.mm and AppDelegate) @@ -64,7 +66,16 @@ public final class VoipService: NSObject { // Convert token data to hex string let token = credentials.token.map { String(format: "%02x", $0) }.joined() + + if lastVoipToken == token { + #if DEBUG + print("[\(TAG)] VoIP token unchanged") + #endif + return + } + lastVoipToken = token + persistVoipToken(token) #if DEBUG print("[\(TAG)] VoIP token: \(token)") @@ -77,6 +88,18 @@ public final class VoipService: NSObject { userInfo: ["token": token] ) } + + /// Called from AppDelegate when a previously registered token is invalidated + // TODO: remove voip token from all logged in workspaces, since they share the same token + @objc + public static func invalidatePushToken() { + lastVoipToken = "" + storage.removeValue(forKey: voipTokenStorageKey) + + #if DEBUG + print("[\(TAG)] Invalidated VoIP token") + #endif + } /// Called from AppDelegate when a VoIP push initial events are received @objc @@ -144,61 +167,17 @@ public final class VoipService: NSObject { /// Returns the last registered VoIP token @objc public static func getLastVoipToken() -> String { + if lastVoipToken.isEmpty { + lastVoipToken = loadPersistedVoipToken() + } return lastVoipToken } -} -// MARK: - VoipPayload - -/// Data structure for initial events payload -@objc(VoipPayload) -public class VoipPayload: NSObject { - @objc public let callId: String - @objc public let caller: String - @objc public let host: String - @objc public let type: String - - @objc public var notificationId: Int { - return callId.hashValue + private static func loadPersistedVoipToken() -> String { + return storage.string(forKey: voipTokenStorageKey) ?? "" } - - @objc - public init(callId: String, caller: String, host: String, type: String) { - self.callId = callId - self.caller = caller - self.host = host - self.type = type - super.init() - } - - @objc - public func isVoipIncomingCall() -> Bool { - return type == "incoming_call" && !callId.isEmpty && !caller.isEmpty && !host.isEmpty - } - - @objc - public func toDictionary() -> [String: Any] { - return [ - "callId": callId, - "caller": caller, - "host": host, - "type": type, - "notificationId": notificationId - ] - } - - @objc - public static func fromDictionary(_ dict: [AnyHashable: Any]) -> VoipPayload? { - guard let type = dict["type"] as? String, - let callId = dict["callId"] as? String, - type == "incoming_call", - !callId.isEmpty else { - return nil - } - - let caller = dict["caller"] as? String ?? "" - let host = dict["host"] as? String ?? "" - - return VoipPayload(callId: callId, caller: caller, host: host, type: type) + + private static func persistVoipToken(_ token: String) { + storage.setString(token, forKey: voipTokenStorageKey) } } diff --git a/ios/RocketChatRN.xcodeproj/project.pbxproj b/ios/RocketChatRN.xcodeproj/project.pbxproj index 8e0336f1f18..817d4e7344b 100644 --- a/ios/RocketChatRN.xcodeproj/project.pbxproj +++ b/ios/RocketChatRN.xcodeproj/project.pbxproj @@ -301,6 +301,10 @@ 7A0D62D2242AB187006D5C06 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 7A0D62D1242AB187006D5C06 /* LaunchScreen.storyboard */; }; 7A14FCED257FEB3A005BDCD4 /* Experimental.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7A14FCEC257FEB3A005BDCD4 /* Experimental.xcassets */; }; 7A14FCF4257FEB59005BDCD4 /* Official.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7A14FCF3257FEB59005BDCD4 /* Official.xcassets */; }; + 7A1B58412F5F58FF002A6BDE /* VoipPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B58402F5F58FF002A6BDE /* VoipPayload.swift */; }; + 7A1B58422F5F58FF002A6BDE /* VoipPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B58402F5F58FF002A6BDE /* VoipPayload.swift */; }; + 7A1B58442F5F63DB002A6BDE /* AppDelegate+Voip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B58432F5F63DB002A6BDE /* AppDelegate+Voip.swift */; }; + 7A1B58452F5F63DB002A6BDE /* AppDelegate+Voip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B58432F5F63DB002A6BDE /* AppDelegate+Voip.swift */; }; 7A3F4C6B2F1AAFA700B6B4BD /* CallIdUUID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3F4C6A2F1AAFA700B6B4BD /* CallIdUUID.swift */; }; 7A3F4C6C2F1AAFA700B6B4BD /* CallIdUUID.m in Sources */ = {isa = PBXBuildFile; fileRef = 7A3F4C692F1AAFA700B6B4BD /* CallIdUUID.m */; }; 7A3F4C6D2F1AAFA700B6B4BD /* CallIdUUID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3F4C6A2F1AAFA700B6B4BD /* CallIdUUID.swift */; }; @@ -636,6 +640,8 @@ 7A0D62D1242AB187006D5C06 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; 7A14FCEC257FEB3A005BDCD4 /* Experimental.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Experimental.xcassets; sourceTree = ""; }; 7A14FCF3257FEB59005BDCD4 /* Official.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Official.xcassets; sourceTree = ""; }; + 7A1B58402F5F58FF002A6BDE /* VoipPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoipPayload.swift; sourceTree = ""; }; + 7A1B58432F5F63DB002A6BDE /* AppDelegate+Voip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Voip.swift"; sourceTree = ""; }; 7A3F4C692F1AAFA700B6B4BD /* CallIdUUID.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CallIdUUID.m; sourceTree = ""; }; 7A3F4C6A2F1AAFA700B6B4BD /* CallIdUUID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallIdUUID.swift; sourceTree = ""; }; 7A610CD127ECE38100B8ABDD /* custom.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = custom.ttf; sourceTree = ""; }; @@ -1143,6 +1149,8 @@ 7A76DEE42F1AA6EF00750653 /* Libraries */ = { isa = PBXGroup; children = ( + 7A1B58432F5F63DB002A6BDE /* AppDelegate+Voip.swift */, + 7A1B58402F5F58FF002A6BDE /* VoipPayload.swift */, 7A3F4C692F1AAFA700B6B4BD /* CallIdUUID.m */, 7A3F4C6A2F1AAFA700B6B4BD /* CallIdUUID.swift */, 7A0000042F1BAFA700B6B4BD /* VoipModule.mm */, @@ -1779,7 +1787,7 @@ inputFileListPaths = ( ); inputPaths = ( - "$TARGET_BUILD_DIR/$INFOPLIST_PATH", + $TARGET_BUILD_DIR/$INFOPLIST_PATH, ); name = "Upload source maps to Bugsnag"; outputFileListPaths = ( @@ -1799,7 +1807,7 @@ inputFileListPaths = ( ); inputPaths = ( - "$TARGET_BUILD_DIR/$INFOPLIST_PATH", + $TARGET_BUILD_DIR/$INFOPLIST_PATH, ); name = "Upload source maps to Bugsnag"; outputFileListPaths = ( @@ -2061,6 +2069,7 @@ 66C2701B2EBBCB570062725F /* MMKVKeyManager.mm in Sources */, 7A3F4C6B2F1AAFA700B6B4BD /* CallIdUUID.swift in Sources */, 7A3F4C6C2F1AAFA700B6B4BD /* CallIdUUID.m in Sources */, + 7A1B58422F5F58FF002A6BDE /* VoipPayload.swift in Sources */, 7A0000012F1BAFA700B6B4BD /* VoipService.swift in Sources */, 7A0000022F1BAFA700B6B4BD /* VoipModule.mm in Sources */, 1ED00BB12513E04400A1331F /* ReplyNotification.swift in Sources */, @@ -2094,6 +2103,7 @@ 1E068D0124FD2E0500A0FFC1 /* AppGroup.m in Sources */, 1ED038C42B50A1F500C007D4 /* WatchMessage.swift in Sources */, 1E76CBD025152C6E0067298C /* URL+Extensions.swift in Sources */, + 7A1B58442F5F63DB002A6BDE /* AppDelegate+Voip.swift in Sources */, 1E068CFE24FD2DC700A0FFC1 /* AppGroup.swift in Sources */, 1E76CBCE25152C2F0067298C /* RoomKey.swift in Sources */, 1E76CBCC25152C290067298C /* Message.swift in Sources */, @@ -2339,6 +2349,7 @@ 66C2701C2EBBCB570062725F /* MMKVKeyManager.mm in Sources */, 7A3F4C6D2F1AAFA700B6B4BD /* CallIdUUID.swift in Sources */, 7A3F4C6E2F1AAFA700B6B4BD /* CallIdUUID.m in Sources */, + 7A1B58412F5F58FF002A6BDE /* VoipPayload.swift in Sources */, 7A0000052F1BAFA700B6B4BD /* VoipService.swift in Sources */, 7A0000062F1BAFA700B6B4BD /* VoipModule.mm in Sources */, 7AAB3E17257E6A6E00707CF6 /* ReplyNotification.swift in Sources */, @@ -2372,6 +2383,7 @@ 7AAB3E2A257E6A6E00707CF6 /* AppGroup.m in Sources */, 1ED038C52B50A1F500C007D4 /* WatchMessage.swift in Sources */, 7AAB3E2C257E6A6E00707CF6 /* URL+Extensions.swift in Sources */, + 7A1B58452F5F63DB002A6BDE /* AppDelegate+Voip.swift in Sources */, 7AAB3E2D257E6A6E00707CF6 /* AppGroup.swift in Sources */, 7AAB3E2E257E6A6E00707CF6 /* RoomKey.swift in Sources */, 7AAB3E2F257E6A6E00707CF6 /* Message.swift in Sources */, @@ -2613,7 +2625,7 @@ "$(inherited)", "$(SRCROOT)/../node_modules/rn-extensions-share/ios/**", "$(SRCROOT)/../node_modules/react-native-firebase/ios/RNFirebase/**", - "$PODS_CONFIGURATION_BUILD_DIR/Firebase", + $PODS_CONFIGURATION_BUILD_DIR/Firebase, ); INFOPLIST_FILE = ShareRocketChatRN/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -2689,7 +2701,7 @@ "$(inherited)", "$(SRCROOT)/../node_modules/rn-extensions-share/ios/**", "$(SRCROOT)/../node_modules/react-native-firebase/ios/RNFirebase/**", - "$PODS_CONFIGURATION_BUILD_DIR/Firebase", + $PODS_CONFIGURATION_BUILD_DIR/Firebase, ); INFOPLIST_FILE = ShareRocketChatRN/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -3214,10 +3226,7 @@ ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -3281,10 +3290,7 @@ MTL_ENABLE_DEBUG_INFO = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - OTHER_LDFLAGS = ( - "$(inherited)", - " ", - ); + OTHER_LDFLAGS = "$(inherited) "; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; From 066afbb52d67e385373455c53cd416f6493416af Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 9 Mar 2026 17:37:51 -0300 Subject: [PATCH 04/23] Remove CallIdUUID module --- .../rocket/reactnative/MainApplication.kt | 2 - .../reactnative/utils/CallIdUUIDModule.kt | 72 ------------ .../utils/CallIdUUIDTurboPackage.kt | 30 ----- .../reactnative/utils/NativeCallIdUUIDSpec.kt | 19 ---- app/lib/native/NativeCallIdUUID.ts | 8 -- app/lib/services/voip/MediaSessionInstance.ts | 3 - app/lib/services/voip/simulateCall.ts | 106 ------------------ ios/Libraries/CallIdUUID.m | 7 -- ios/Libraries/CallIdUUID.swift | 67 ----------- ios/RocketChatRN.xcodeproj/project.pbxproj | 12 -- 10 files changed, 326 deletions(-) delete mode 100644 android/app/src/main/java/chat/rocket/reactnative/utils/CallIdUUIDModule.kt delete mode 100644 android/app/src/main/java/chat/rocket/reactnative/utils/CallIdUUIDTurboPackage.kt delete mode 100644 android/app/src/main/java/chat/rocket/reactnative/utils/NativeCallIdUUIDSpec.kt delete mode 100644 app/lib/native/NativeCallIdUUID.ts delete mode 100644 app/lib/services/voip/simulateCall.ts delete mode 100644 ios/Libraries/CallIdUUID.m delete mode 100644 ios/Libraries/CallIdUUID.swift diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt index 1035e46107a..c90ea649c0e 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt @@ -21,7 +21,6 @@ import chat.rocket.reactnative.storage.SecureStoragePackage; import chat.rocket.reactnative.notification.VideoConfTurboPackage import chat.rocket.reactnative.notification.PushNotificationTurboPackage import chat.rocket.reactnative.VoipTurboPackage -import chat.rocket.reactnative.utils.CallIdUUIDTurboPackage /** * Main Application class. @@ -46,7 +45,6 @@ open class MainApplication : Application(), ReactApplication { add(VideoConfTurboPackage()) add(PushNotificationTurboPackage()) add(VoipTurboPackage()) - add(CallIdUUIDTurboPackage()) add(SecureStoragePackage()) } diff --git a/android/app/src/main/java/chat/rocket/reactnative/utils/CallIdUUIDModule.kt b/android/app/src/main/java/chat/rocket/reactnative/utils/CallIdUUIDModule.kt deleted file mode 100644 index aeb86eba722..00000000000 --- a/android/app/src/main/java/chat/rocket/reactnative/utils/CallIdUUIDModule.kt +++ /dev/null @@ -1,72 +0,0 @@ -package chat.rocket.reactnative.utils - -import com.facebook.react.bridge.ReactApplicationContext -import java.security.MessageDigest - -/** - * CallIdUUID - Converts a callId string to a deterministic UUID v5. - * This is used by CallKeep which requires UUIDs, while the server sends random callId strings. - * - * The algorithm matches the iOS implementation in CallIdUUID.swift to ensure - * consistency across platforms. - */ -object CallIdUUID { - - // Fixed namespace UUID for VoIP calls (RFC 4122 URL namespace) - // Using the standard URL namespace UUID: 6ba7b811-9dad-11d1-80b4-00c04fd430c8 - private val NAMESPACE_UUID = byteArrayOf( - 0x6b.toByte(), 0xa7.toByte(), 0xb8.toByte(), 0x11.toByte(), - 0x9d.toByte(), 0xad.toByte(), - 0x11.toByte(), 0xd1.toByte(), - 0x80.toByte(), 0xb4.toByte(), - 0x00.toByte(), 0xc0.toByte(), 0x4f.toByte(), 0xd4.toByte(), 0x30.toByte(), 0xc8.toByte() - ) - - /** - * Generates a UUID v5 from a callId string. - * Uses SHA-1 hash of namespace + callId, then formats as UUID v5. - * - * @param callId The call ID string to convert - * @return A deterministic UUID string in the format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - */ - @JvmStatic - fun generateUUIDv5(callId: String): String { - // Concatenate namespace UUID bytes with callId UTF-8 bytes - val callIdBytes = callId.toByteArray(Charsets.UTF_8) - val data = ByteArray(NAMESPACE_UUID.size + callIdBytes.size) - System.arraycopy(NAMESPACE_UUID, 0, data, 0, NAMESPACE_UUID.size) - System.arraycopy(callIdBytes, 0, data, NAMESPACE_UUID.size, callIdBytes.size) - - // SHA-1 hash - val md = MessageDigest.getInstance("SHA-1") - val hash = md.digest(data) - - // Set version (4 bits) to 5 (0101) - hash[6] = ((hash[6].toInt() and 0x0F) or 0x50).toByte() - - // Set variant (2 bits) to 10 - hash[8] = ((hash[8].toInt() and 0x3F) or 0x80).toByte() - - // Format as UUID string (only use first 16 bytes) - return String.format( - "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", - hash[0].toInt() and 0xFF, hash[1].toInt() and 0xFF, hash[2].toInt() and 0xFF, hash[3].toInt() and 0xFF, - hash[4].toInt() and 0xFF, hash[5].toInt() and 0xFF, - hash[6].toInt() and 0xFF, hash[7].toInt() and 0xFF, - hash[8].toInt() and 0xFF, hash[9].toInt() and 0xFF, - hash[10].toInt() and 0xFF, hash[11].toInt() and 0xFF, hash[12].toInt() and 0xFF, - hash[13].toInt() and 0xFF, hash[14].toInt() and 0xFF, hash[15].toInt() and 0xFF - ) - } -} - -/** - * React Native TurboModule implementation for CallIdUUID. - * Exposes the CallIdUUID functionality to JavaScript. - */ -class CallIdUUIDModule(reactContext: ReactApplicationContext) : NativeCallIdUUIDSpec(reactContext) { - - override fun getName() = NativeCallIdUUIDSpec.NAME - - override fun toUUID(callId: String): String = CallIdUUID.generateUUIDv5(callId) -} \ No newline at end of file diff --git a/android/app/src/main/java/chat/rocket/reactnative/utils/CallIdUUIDTurboPackage.kt b/android/app/src/main/java/chat/rocket/reactnative/utils/CallIdUUIDTurboPackage.kt deleted file mode 100644 index 33ef27b99b7..00000000000 --- a/android/app/src/main/java/chat/rocket/reactnative/utils/CallIdUUIDTurboPackage.kt +++ /dev/null @@ -1,30 +0,0 @@ -package chat.rocket.reactnative.utils - -import com.facebook.react.BaseReactPackage -import com.facebook.react.bridge.NativeModule -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.module.model.ReactModuleInfo -import com.facebook.react.module.model.ReactModuleInfoProvider - -class CallIdUUIDTurboPackage : BaseReactPackage() { - - override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = - if (name == NativeCallIdUUIDSpec.NAME) { - CallIdUUIDModule(reactContext) - } else { - null - } - - override fun getReactModuleInfoProvider() = ReactModuleInfoProvider { - mapOf( - NativeCallIdUUIDSpec.NAME to ReactModuleInfo( - name = NativeCallIdUUIDSpec.NAME, - className = NativeCallIdUUIDSpec.NAME, - canOverrideExistingModule = false, - needsEagerInit = false, - isCxxModule = false, - isTurboModule = true - ) - ) - } -} diff --git a/android/app/src/main/java/chat/rocket/reactnative/utils/NativeCallIdUUIDSpec.kt b/android/app/src/main/java/chat/rocket/reactnative/utils/NativeCallIdUUIDSpec.kt deleted file mode 100644 index 9da6aa0fe58..00000000000 --- a/android/app/src/main/java/chat/rocket/reactnative/utils/NativeCallIdUUIDSpec.kt +++ /dev/null @@ -1,19 +0,0 @@ -package chat.rocket.reactnative.utils - -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.bridge.ReactContextBaseJavaModule -import com.facebook.react.bridge.ReactMethod -import com.facebook.react.turbomodule.core.interfaces.TurboModule - -abstract class NativeCallIdUUIDSpec(reactContext: ReactApplicationContext) : - ReactContextBaseJavaModule(reactContext), TurboModule { - - companion object { - const val NAME = "CallIdUUID" - } - - override fun getName(): String = NAME - - @ReactMethod(isBlockingSynchronousMethod = true) - abstract fun toUUID(callId: String): String -} diff --git a/app/lib/native/NativeCallIdUUID.ts b/app/lib/native/NativeCallIdUUID.ts deleted file mode 100644 index 4dcaec05919..00000000000 --- a/app/lib/native/NativeCallIdUUID.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { TurboModule } from 'react-native'; -import { TurboModuleRegistry } from 'react-native'; - -export interface Spec extends TurboModule { - toUUID(callId: string): string; -} - -export default TurboModuleRegistry.getEnforcing('CallIdUUID'); diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index a8a59fdcf21..095e3134a66 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -75,9 +75,6 @@ class MediaSessionInstance { return; } - // const callUUID = CallIdUUIDModule.toUUID(call.callId); - // console.log('[VoIP] New call UUID:', callUUID); - if (call.role === 'caller') { useCallStore.getState().setCall(call); Navigation.navigate('CallView'); diff --git a/app/lib/services/voip/simulateCall.ts b/app/lib/services/voip/simulateCall.ts deleted file mode 100644 index 5ce82715db6..00000000000 --- a/app/lib/services/voip/simulateCall.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { CallState, IClientMediaCall } from '@rocket.chat/media-signaling'; -import { randomUuid } from '@rocket.chat/mobile-crypto'; - -// import Navigation from '../../navigation/appNavigation'; -import { useCallStore } from './useCallStore'; - -interface SimulateCallOptions { - callState?: CallState; - contact?: { - displayName?: string; - username?: string; - sipExtension?: string; - }; - isMuted?: boolean; - isOnHold?: boolean; -} - -const defaultContact = { - displayName: 'Bob Burnquist', - username: 'bob.burnquist', - sipExtension: '2244' -}; - -/** - * Simulates a call for UI development purposes. - * Creates a mock IClientMediaCall object, sets it in the call store, and navigates to CallView. - */ -export const simulateCall = async (options: SimulateCallOptions = {}): Promise => { - const { callState = 'active', contact = defaultContact, isMuted = false, isOnHold = false } = options; - - // Create a simple event emitter for the mock call - const listeners: Record void>> = {}; - - const emitter = { - on: (event: string, callback: (...args: unknown[]) => void) => { - if (!listeners[event]) { - listeners[event] = new Set(); - } - listeners[event].add(callback); - }, - off: (event: string, callback: (...args: unknown[]) => void) => { - listeners[event]?.delete(callback); - }, - emit: (event: string, ...args: unknown[]) => { - listeners[event]?.forEach(cb => cb(...args)); - } - }; - - // Track mutable state for the mock - let currentMuted = isMuted; - let currentHeld = isOnHold; - let currentState: CallState = callState; - - const mockCall = { - callId: `mock-call-${Date.now()}`, - state: currentState, - get muted() { - return currentMuted; - }, - get held() { - return currentHeld; - }, - contact: { - displayName: contact.displayName, - username: contact.username, - sipExtension: contact.sipExtension - }, - setMuted: (muted: boolean) => { - currentMuted = muted; - emitter.emit('trackStateChange'); - }, - setHeld: (held: boolean) => { - currentHeld = held; - emitter.emit('trackStateChange'); - }, - hangup: () => { - currentState = 'ended' as CallState; - emitter.emit('ended'); - }, - reject: () => { - currentState = 'ended' as CallState; - emitter.emit('ended'); - }, - sendDTMF: (_digits: string) => { - // No-op for simulation - }, - emitter - } as unknown as IClientMediaCall; - - // Generate a unique callUUID - const callUUID = (await randomUuid()).toLowerCase(); - - // Set up the call store - useCallStore.getState().setCall(mockCall, callUUID); - - // If simulating a specific initial state, update the store - if (isMuted || isOnHold) { - useCallStore.setState({ - isMuted, - isOnHold - }); - } - - // Navigate to CallView - // Navigation.navigate('CallView', { callUUID }); -}; diff --git a/ios/Libraries/CallIdUUID.m b/ios/Libraries/CallIdUUID.m deleted file mode 100644 index 019b69f4938..00000000000 --- a/ios/Libraries/CallIdUUID.m +++ /dev/null @@ -1,7 +0,0 @@ -#import - -@interface RCT_EXTERN_MODULE(CallIdUUID, NSObject) - -RCT_EXTERN__BLOCKING_SYNCHRONOUS_METHOD(toUUID:(NSString *)callId) - -@end diff --git a/ios/Libraries/CallIdUUID.swift b/ios/Libraries/CallIdUUID.swift deleted file mode 100644 index 8318e45d36b..00000000000 --- a/ios/Libraries/CallIdUUID.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation -import CommonCrypto - -/** - * CallIdUUID - Converts a callId string to a deterministic UUID v5. - * This is used by CallKit which requires UUIDs, while the server sends random callId strings. - */ -@objc(CallIdUUID) -final class CallIdUUID: NSObject { - - // Fixed namespace UUID for VoIP calls (RFC 4122 URL namespace) - // Using the standard URL namespace UUID: 6ba7b811-9dad-11d1-80b4-00c04fd430c8 - private static let namespaceUUID: [UInt8] = [ - 0x6b, 0xa7, 0xb8, 0x11, - 0x9d, 0xad, - 0x11, 0xd1, - 0x80, 0xb4, - 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8 - ] - - @objc - static func requiresMainQueueSetup() -> Bool { - return false - } - - /** - * Converts a callId string to a deterministic UUID v5 string. - * Uses SHA-1 hash of namespace + callId, then formats as UUID v5. - * This is a synchronous method for use from JavaScript. - */ - @objc - func toUUID(_ callId: String) -> String { - return CallIdUUID.generateUUIDv5(from: callId) - } - - /** - * Static method for use in AppDelegate and other native code. - * Generates a UUID v5 from a callId string. - */ - static func generateUUIDv5(from callId: String) -> String { - // Concatenate namespace UUID bytes with callId UTF-8 bytes - var data = Data(namespaceUUID) - data.append(callId.data(using: .utf8) ?? Data()) - - // SHA-1 hash - var hash = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH)) - data.withUnsafeBytes { dataBytes in - _ = CC_SHA1(dataBytes.baseAddress, CC_LONG(data.count), &hash) - } - - // Set version (4 bits) to 5 (0101) - hash[6] = (hash[6] & 0x0F) | 0x50 - - // Set variant (2 bits) to 10 - hash[8] = (hash[8] & 0x3F) | 0x80 - - // Format as UUID string (only use first 16 bytes) - let uuid = String(format: "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", - hash[0], hash[1], hash[2], hash[3], - hash[4], hash[5], - hash[6], hash[7], - hash[8], hash[9], - hash[10], hash[11], hash[12], hash[13], hash[14], hash[15]) - - return uuid - } -} diff --git a/ios/RocketChatRN.xcodeproj/project.pbxproj b/ios/RocketChatRN.xcodeproj/project.pbxproj index 817d4e7344b..35eaf4200c5 100644 --- a/ios/RocketChatRN.xcodeproj/project.pbxproj +++ b/ios/RocketChatRN.xcodeproj/project.pbxproj @@ -305,10 +305,6 @@ 7A1B58422F5F58FF002A6BDE /* VoipPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B58402F5F58FF002A6BDE /* VoipPayload.swift */; }; 7A1B58442F5F63DB002A6BDE /* AppDelegate+Voip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B58432F5F63DB002A6BDE /* AppDelegate+Voip.swift */; }; 7A1B58452F5F63DB002A6BDE /* AppDelegate+Voip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1B58432F5F63DB002A6BDE /* AppDelegate+Voip.swift */; }; - 7A3F4C6B2F1AAFA700B6B4BD /* CallIdUUID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3F4C6A2F1AAFA700B6B4BD /* CallIdUUID.swift */; }; - 7A3F4C6C2F1AAFA700B6B4BD /* CallIdUUID.m in Sources */ = {isa = PBXBuildFile; fileRef = 7A3F4C692F1AAFA700B6B4BD /* CallIdUUID.m */; }; - 7A3F4C6D2F1AAFA700B6B4BD /* CallIdUUID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3F4C6A2F1AAFA700B6B4BD /* CallIdUUID.swift */; }; - 7A3F4C6E2F1AAFA700B6B4BD /* CallIdUUID.m in Sources */ = {isa = PBXBuildFile; fileRef = 7A3F4C692F1AAFA700B6B4BD /* CallIdUUID.m */; }; 7A610CD227ECE38100B8ABDD /* custom.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7A610CD127ECE38100B8ABDD /* custom.ttf */; }; 7A610CD427ECE38100B8ABDD /* custom.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7A610CD127ECE38100B8ABDD /* custom.ttf */; }; 7A610CD527ECE38100B8ABDD /* custom.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 7A610CD127ECE38100B8ABDD /* custom.ttf */; }; @@ -642,8 +638,6 @@ 7A14FCF3257FEB59005BDCD4 /* Official.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Official.xcassets; sourceTree = ""; }; 7A1B58402F5F58FF002A6BDE /* VoipPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoipPayload.swift; sourceTree = ""; }; 7A1B58432F5F63DB002A6BDE /* AppDelegate+Voip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Voip.swift"; sourceTree = ""; }; - 7A3F4C692F1AAFA700B6B4BD /* CallIdUUID.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CallIdUUID.m; sourceTree = ""; }; - 7A3F4C6A2F1AAFA700B6B4BD /* CallIdUUID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallIdUUID.swift; sourceTree = ""; }; 7A610CD127ECE38100B8ABDD /* custom.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = custom.ttf; sourceTree = ""; }; 7A8B30742BCD9D3F00146A40 /* SSLPinning.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SSLPinning.h; sourceTree = ""; }; 7A8B30752BCD9D3F00146A40 /* SSLPinning.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = SSLPinning.mm; sourceTree = ""; }; @@ -1151,8 +1145,6 @@ children = ( 7A1B58432F5F63DB002A6BDE /* AppDelegate+Voip.swift */, 7A1B58402F5F58FF002A6BDE /* VoipPayload.swift */, - 7A3F4C692F1AAFA700B6B4BD /* CallIdUUID.m */, - 7A3F4C6A2F1AAFA700B6B4BD /* CallIdUUID.swift */, 7A0000042F1BAFA700B6B4BD /* VoipModule.mm */, 7A0000032F1BAFA700B6B4BD /* VoipService.swift */, B179038FDD7AAF285047814B /* SecureStorage.h */, @@ -2067,8 +2059,6 @@ 1E76CBD825152C870067298C /* Request.swift in Sources */, 1E51411C2B85683C007BE94A /* SSLPinning.m in Sources */, 66C2701B2EBBCB570062725F /* MMKVKeyManager.mm in Sources */, - 7A3F4C6B2F1AAFA700B6B4BD /* CallIdUUID.swift in Sources */, - 7A3F4C6C2F1AAFA700B6B4BD /* CallIdUUID.m in Sources */, 7A1B58422F5F58FF002A6BDE /* VoipPayload.swift in Sources */, 7A0000012F1BAFA700B6B4BD /* VoipService.swift in Sources */, 7A0000022F1BAFA700B6B4BD /* VoipModule.mm in Sources */, @@ -2347,8 +2337,6 @@ 7AAB3E16257E6A6E00707CF6 /* Request.swift in Sources */, 1E51411D2B85683C007BE94A /* SSLPinning.m in Sources */, 66C2701C2EBBCB570062725F /* MMKVKeyManager.mm in Sources */, - 7A3F4C6D2F1AAFA700B6B4BD /* CallIdUUID.swift in Sources */, - 7A3F4C6E2F1AAFA700B6B4BD /* CallIdUUID.m in Sources */, 7A1B58412F5F58FF002A6BDE /* VoipPayload.swift in Sources */, 7A0000052F1BAFA700B6B4BD /* VoipService.swift in Sources */, 7A0000062F1BAFA700B6B4BD /* VoipModule.mm in Sources */, From 054a809b4cf3e4208a36b95b3fcd07844d03b450 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 9 Mar 2026 18:26:39 -0300 Subject: [PATCH 05/23] Timeout working on both platforms --- .../notification/NotificationIntentHandler.kt | 1 + .../reactnative/voip/IncomingCallActivity.kt | 59 +++++++++++- .../rocket/reactnative/voip/VoipModule.kt | 7 +- .../reactnative/voip/VoipNotification.kt | 95 +++++++++++++++++++ .../rocket/reactnative/voip/VoipPayload.kt | 57 ++++++++++- app/definitions/Voip.ts | 1 + ios/Libraries/AppDelegate+Voip.swift | 10 +- ios/Libraries/VoipPayload.swift | 70 +++++++++++++- ios/Libraries/VoipService.swift | 83 +++++++++++----- 9 files changed, 342 insertions(+), 41 deletions(-) diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt b/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt index 2a3a19187e3..060d4792077 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt @@ -58,6 +58,7 @@ class NotificationIntentHandler { Log.d(TAG, "Handling VoIP intent - voipPayload: $voipPayload") VoipNotification.cancelById(context, voipPayload.notificationId) + VoipNotification.cancelTimeout(voipPayload.callId) VoipModule.storeInitialEvents(voipPayload) if (context is Activity) { 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 e075f764f97..1593efed0c0 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 @@ -2,17 +2,22 @@ package chat.rocket.reactnative.voip import android.app.Activity import android.app.KeyguardManager +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.graphics.drawable.GradientDrawable import android.media.Ringtone import android.media.RingtoneManager import android.os.Build import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.view.WindowManager import android.view.View import android.widget.ImageView import androidx.core.content.ContextCompat +import androidx.localbroadcastmanager.content.LocalBroadcastManager import android.widget.LinearLayout import android.widget.TextView import android.widget.FrameLayout @@ -35,6 +40,20 @@ class IncomingCallActivity : Activity() { private var ringtone: Ringtone? = null private var voipPayload: VoipPayload? = null + private var isTimeoutReceiverRegistered = false + private val timeoutHandler = Handler(Looper.getMainLooper()) + private var timeoutRunnable: Runnable? = null + private val timeoutReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val payload = VoipPayload.fromBundle(intent?.extras) ?: return + if (payload.callId != voipPayload?.callId) { + return + } + + stopRingtone() + finish() + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -79,6 +98,10 @@ class IncomingCallActivity : Activity() { updateUI(voipPayload) startRingtone() setupButtons(voipPayload) + scheduleTimeout(voipPayload) + LocalBroadcastManager.getInstance(this) + .registerReceiver(timeoutReceiver, IntentFilter(VoipNotification.ACTION_TIMEOUT)) + isTimeoutReceiverRegistered = true } private fun applyNavigationBar() { @@ -228,8 +251,31 @@ class IncomingCallActivity : Activity() { } } + private fun scheduleTimeout(payload: VoipPayload) { + val remainingLifetimeMs = payload.getRemainingLifetimeMs() + if (remainingLifetimeMs == null || remainingLifetimeMs <= 0L) { + stopRingtone() + finish() + return + } + + clearTimeout() + timeoutRunnable = Runnable { + stopRingtone() + VoipNotification.handleTimeout(this, payload) + finish() + }.also { timeoutHandler.postDelayed(it, remainingLifetimeMs) } + } + + private fun clearTimeout() { + timeoutRunnable?.let(timeoutHandler::removeCallbacks) + timeoutRunnable = null + } + private fun handleAccept(payload: VoipPayload) { Log.d(TAG, "Call accepted - callId: ${payload.callId}") + clearTimeout() + VoipNotification.cancelTimeout(payload.callId) stopRingtone() // Launch MainActivity with call data @@ -244,18 +290,21 @@ class IncomingCallActivity : Activity() { private fun handleDecline(payload: VoipPayload) { Log.d(TAG, "Call declined - callId: ${payload.callId}") + clearTimeout() + VoipNotification.cancelTimeout(payload.callId) stopRingtone() - - VoipNotification.cancelById(this, payload.notificationId) - - // Emit event to JS - // TODO: call restapi to decline the call + VoipNotification.handleDeclineAction(this, payload) finish() } override fun onDestroy() { super.onDestroy() + clearTimeout() + if (isTimeoutReceiverRegistered) { + LocalBroadcastManager.getInstance(this).unregisterReceiver(timeoutReceiver) + isTimeoutReceiverRegistered = false + } stopRingtone() } 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 fcf48fecf17..16a2d61185e 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 @@ -20,7 +20,6 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo private var reactContextRef: WeakReference? = null private var initialEventsData: VoipPayload? = null - private var initialEventsTimestamp: Long = 0 /** * Sets the React context reference for event emission. @@ -55,7 +54,6 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo @JvmStatic fun storeInitialEvents(voipPayload: VoipPayload) { initialEventsData = voipPayload - initialEventsTimestamp = System.currentTimeMillis() emitInitialEventsEvent(voipPayload) } @@ -63,7 +61,6 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo fun clearInitialEventsInternal() { try { initialEventsData = null - initialEventsTimestamp = 0 Log.d(TAG, "Cleared initial events") } catch (e: Exception) { Log.e(TAG, "Error clearing initial events", e) @@ -83,7 +80,8 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo override fun getInitialEvents(): WritableMap? { val data = initialEventsData ?: return null - if (System.currentTimeMillis() - initialEventsTimestamp > 5 * 60 * 1000) { + if (data.isExpired()) { + Log.d(TAG, "Discarding expired VoIP initial event: ${data.callId}") clearInitialEventsInternal() return null } @@ -101,6 +99,7 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo clearInitialEventsInternal() } + // No-op on Android - FCM handles push notifications override fun getLastVoipToken(): String = "" /** 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 fc65542beb4..d968f2bb640 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 @@ -12,13 +12,18 @@ import android.media.AudioAttributes import android.media.RingtoneManager import android.os.Build import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.util.Log import androidx.core.app.NotificationCompat +import androidx.localbroadcastmanager.content.LocalBroadcastManager import android.content.ComponentName import android.net.Uri import android.telecom.PhoneAccount import android.telecom.PhoneAccountHandle import android.telecom.TelecomManager +import io.wazo.callkeep.VoiceConnection +import io.wazo.callkeep.VoiceConnectionService import chat.rocket.reactnative.MainActivity /** @@ -39,9 +44,13 @@ 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" + const val ACTION_TIMEOUT = "chat.rocket.reactnative.ACTION_VOIP_TIMEOUT" // 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 val timeoutHandler = Handler(Looper.getMainLooper()) + private val timeoutCallbacks = mutableMapOf() /** * Cancels a VoIP notification by ID. @@ -53,6 +62,55 @@ class VoipNotification(private val context: Context) { Log.d(TAG, "VoIP notification cancelled with ID: $notificationId") } + @JvmStatic + fun scheduleTimeout(context: Context, payload: VoipPayload) { + val delayMs = payload.getRemainingLifetimeMs() + if (delayMs == null || delayMs <= 0L) { + Log.d(TAG, "Skipping timeout scheduling for expired or invalid call: ${payload.callId}") + return + } + + cancelTimeout(payload.callId) + + val applicationContext = context.applicationContext + val timeoutRunnable = Runnable { + synchronized(timeoutCallbacks) { + timeoutCallbacks.remove(payload.callId) + } + handleTimeout(applicationContext, payload) + } + + synchronized(timeoutCallbacks) { + timeoutCallbacks[payload.callId] = timeoutRunnable + } + timeoutHandler.postDelayed(timeoutRunnable, delayMs) + Log.d(TAG, "Scheduled VoIP timeout for ${payload.callId} in ${delayMs}ms") + } + + @JvmStatic + fun cancelTimeout(callId: String) { + val timeoutRunnable = synchronized(timeoutCallbacks) { + timeoutCallbacks.remove(callId) + } + if (timeoutRunnable != null) { + timeoutHandler.removeCallbacks(timeoutRunnable) + Log.d(TAG, "Cancelled VoIP timeout for $callId") + } + } + + @JvmStatic + fun handleTimeout(context: Context, payload: VoipPayload) { + cancelTimeout(payload.callId) + disconnectTimedOutCall(payload.callId) + cancelById(context, payload.notificationId) + LocalBroadcastManager.getInstance(context).sendBroadcast( + Intent(ACTION_TIMEOUT).apply { + putExtras(payload.toBundle()) + } + ) + Log.d(TAG, "Timed out incoming VoIP call: ${payload.callId}") + } + /** * Handles decline action for VoIP call. * Logs the decline action and clears stored call data. @@ -60,8 +118,29 @@ class VoipNotification(private val context: Context) { @JvmStatic fun handleDeclineAction(context: Context, payload: VoipPayload) { Log.d(TAG, "Decline action triggered for callId: ${payload.callId}") + cancelTimeout(payload.callId) + rejectIncomingCall(payload.callId) + cancelById(context, payload.notificationId) // TODO: call restapi to decline the call } + + private fun disconnectTimedOutCall(callId: String) { + val connection = VoiceConnectionService.getConnection(callId) + when (connection) { + is VoiceConnection -> connection.reportDisconnect(DISCONNECT_REASON_MISSED) + null -> Log.d(TAG, "No active VoiceConnection found for timed out call: $callId") + else -> connection.onDisconnect() + } + } + + private fun rejectIncomingCall(callId: String) { + val connection = VoiceConnectionService.getConnection(callId) + when (connection) { + is VoiceConnection -> connection.onReject() + null -> Log.d(TAG, "No active VoiceConnection found for declined call: $callId") + else -> connection.onDisconnect() + } + } } /** @@ -119,6 +198,15 @@ class VoipNotification(private val context: Context) { fun showIncomingCall(voipPayload: VoipPayload) { val callId = voipPayload.callId val caller = voipPayload.caller + if (voipPayload.getRemainingLifetimeMs() == null) { + Log.w(TAG, "Skipping incoming VoIP call without a valid createdAt timestamp - callId: $callId") + return + } + + if (voipPayload.isExpired()) { + Log.d(TAG, "Skipping expired incoming VoIP call - callId: $callId") + return + } Log.d(TAG, "Showing incoming VoIP call - callId: $callId, caller: $caller") @@ -128,6 +216,7 @@ class VoipNotification(private val context: Context) { // Show notification with full-screen intent showIncomingCallNotification(voipPayload) + scheduleTimeout(context, voipPayload) } /** @@ -194,6 +283,11 @@ class VoipNotification(private val context: Context) { private fun showIncomingCallNotification(voipPayload: VoipPayload) { val caller = voipPayload.caller val notificationId = voipPayload.notificationId + val remainingLifetimeMs = voipPayload.getRemainingLifetimeMs() + if (remainingLifetimeMs == null || remainingLifetimeMs <= 0L) { + Log.d(TAG, "Skipping notification for expired or invalid call: ${voipPayload.callId}") + return + } Log.d(TAG, "Showing incoming call notification for VoIP call from: $caller") @@ -256,6 +350,7 @@ class VoipNotification(private val context: Context) { setVisibility(NotificationCompat.VISIBILITY_PUBLIC) setAutoCancel(false) setOngoing(true) + setTimeoutAfter(remainingLifetimeMs) addAction(0, "Decline", declinePendingIntent) addAction(0, "Accept", acceptPendingIntent) 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 5c07bde33f9..ed39d42cbe5 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 @@ -5,6 +5,9 @@ import com.google.gson.Gson import com.google.gson.annotations.SerializedName import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.WritableMap +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone data class VoipPayload( @SerializedName("callId") @@ -27,8 +30,16 @@ data class VoipPayload( @SerializedName("avatarUrl") val avatarUrl: String?, + + @SerializedName("createdAt") + val createdAt: String?, ) { val notificationId: Int = callId.hashCode() + private val createdAtMs: Long? + get() = parseCreatedAtMs(createdAt) + + private val expiresAtMs: Long? + get() = createdAtMs?.plus(INCOMING_CALL_LIFETIME_MS) fun isVoipIncomingCall(): Boolean { return type == "incoming_call" && callId.isNotEmpty() && caller.isNotEmpty() && host.isNotEmpty() @@ -43,6 +54,7 @@ data class VoipPayload( putString("type", type) putString("hostName", hostName) putString("avatarUrl", avatarUrl) + putString("createdAt", createdAt) putInt("notificationId", notificationId) // Useful flag for MainActivity to know it's handling a VoIP action putBoolean("voipAction", true) @@ -58,12 +70,33 @@ data class VoipPayload( putString("type", type) putString("hostName", hostName) putString("avatarUrl", avatarUrl) + putString("createdAt", createdAt) putInt("notificationId", notificationId) } } + fun getRemainingLifetimeMs(): Long? { + val expiresAtMs = expiresAtMs ?: return null + val nowMs = System.currentTimeMillis() + return (expiresAtMs - nowMs).coerceAtLeast(0L) + } + + fun isExpired(): Boolean { + val remainingLifetimeMs = getRemainingLifetimeMs() ?: return true + return remainingLifetimeMs <= 0L + } + companion object { private val gson = Gson() + // the amount of time in milliseconds that an incoming call will be kept alive + private const val INCOMING_CALL_LIFETIME_MS = 60_000L + private val isoDateFormats = listOf( + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSX", Locale.US), + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssX", Locale.US), + ).onEach { formatter -> + formatter.timeZone = TimeZone.getTimeZone("UTC") + formatter.isLenient = false + } private data class RemoteCaller( @SerializedName("name") @@ -94,6 +127,9 @@ data class VoipPayload( @SerializedName("notificationType") val notificationType: String? = null, + + @SerializedName("createdAt") + val createdAt: String? = null, ) { fun toVoipPayload(): VoipPayload? { if (notificationType != "voip") return null @@ -109,6 +145,7 @@ data class VoipPayload( type = payloadType, hostName = hostName ?: return null, avatarUrl = caller?.avatarUrl, + createdAt = createdAt, ) } } @@ -127,8 +164,9 @@ data class VoipPayload( val type = bundle.getString("type") ?: return null val hostName = bundle.getString("hostName") ?: return null val avatarUrl = bundle.getString("avatarUrl") + val createdAt = bundle.getString("createdAt") - return VoipPayload(callId, caller, username, host, type, hostName, avatarUrl) + return VoipPayload(callId, caller, username, host, type, hostName, avatarUrl, createdAt) } private fun parseRemotePayload(data: Map): RemoteVoipPayload? { @@ -143,5 +181,22 @@ data class VoipPayload( null } } + + private fun parseCreatedAtMs(value: String?): Long? { + if (value.isNullOrBlank()) { + return null + } + + isoDateFormats.forEach { formatter -> + synchronized(formatter) { + val parsed = formatter.parse(value) + if (parsed != null) { + return parsed.time + } + } + } + + return null + } } } \ No newline at end of file diff --git a/app/definitions/Voip.ts b/app/definitions/Voip.ts index d9c7020790f..72df8d1a870 100644 --- a/app/definitions/Voip.ts +++ b/app/definitions/Voip.ts @@ -12,5 +12,6 @@ export interface VoipPayload { readonly hostName: string; readonly type: string; readonly avatarUrl?: string | null; + readonly createdAt?: string | null; readonly notificationId: number; } diff --git a/ios/Libraries/AppDelegate+Voip.swift b/ios/Libraries/AppDelegate+Voip.swift index aab65d57a2f..d5661a68f1d 100644 --- a/ios/Libraries/AppDelegate+Voip.swift +++ b/ios/Libraries/AppDelegate+Voip.swift @@ -26,11 +26,15 @@ extension AppDelegate: PKPushRegistryDelegate { return } - let callId = voipPayload.callId.lowercased() + let callId = voipPayload.callId let caller = voipPayload.caller + guard !voipPayload.isExpired() else { + print("Skipping expired or invalid VoIP payload for callId: \(callId)") + completion() + return + } - // Store pending call data in our native module - VoipService.storeInitialEvents(voipPayload) + VoipService.prepareIncomingCall(voipPayload) RNCallKeep.reportNewIncomingCall( callId, diff --git a/ios/Libraries/VoipPayload.swift b/ios/Libraries/VoipPayload.swift index 7ccce48a3df..0d25acba400 100644 --- a/ios/Libraries/VoipPayload.swift +++ b/ios/Libraries/VoipPayload.swift @@ -20,6 +20,7 @@ private struct RemoteVoipPayload { let type: String? let hostName: String? let notificationType: String? + let createdAt: String? static func fromDictionary(_ payload: [AnyHashable: Any]) -> RemoteVoipPayload { let caller = (payload["caller"] as? [AnyHashable: Any]).map(RemoteCaller.fromDictionary) @@ -31,7 +32,8 @@ private struct RemoteVoipPayload { host: payload["host"] as? String, type: payload["type"] as? String, hostName: payload["hostName"] as? String, - notificationType: payload["notificationType"] as? String + notificationType: payload["notificationType"] as? String, + createdAt: payload["createdAt"] as? String ) } @@ -42,6 +44,7 @@ private struct RemoteVoipPayload { guard let payloadCallId = callId, + let payloadCallUUID = UUID(uuidString: payloadCallId), let payloadCaller = caller?.name, let payloadUsername = username, let payloadHost = host, @@ -54,12 +57,14 @@ private struct RemoteVoipPayload { return VoipPayload( callId: payloadCallId, + callUUID: payloadCallUUID, caller: payloadCaller, username: payloadUsername, host: payloadHost, type: payloadType, hostName: payloadHostName, - avatarUrl: caller?.avatarUrl + avatarUrl: caller?.avatarUrl, + createdAt: createdAt ) } } @@ -67,27 +72,55 @@ private struct RemoteVoipPayload { /// Data structure for initial events payload @objc(VoipPayload) public class VoipPayload: NSObject { + // the amount of time in milliseconds that an incoming call will be kept alive + @objc public static let INCOMING_CALL_LIFETIME_SEC: TimeInterval = 60 + @objc public let callId: String + let callUUID: UUID @objc public let caller: String @objc public let username: String @objc public let host: String @objc public let type: String @objc public let hostName: String @objc public let avatarUrl: String? + @objc public let createdAt: String? + + private var createdAtDate: Date? { + return Self.parseCreatedAt(createdAt) + } + + private var expiresAt: Date? { + return createdAtDate?.addingTimeInterval(Self.INCOMING_CALL_LIFETIME_SEC) + } + + private static let iso8601FormatterWithFractionalSeconds: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + + private static let iso8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() @objc public var notificationId: Int { return callId.hashValue } - @objc - public init(callId: String, caller: String, username: String, host: String, type: String, hostName: String, avatarUrl: String?) { + init(callId: String, callUUID: UUID, caller: String, username: String, host: String, type: String, hostName: String, avatarUrl: String?, createdAt: String?) { self.callId = callId + self.callUUID = callUUID self.caller = caller self.username = username self.host = host self.type = type self.hostName = hostName self.avatarUrl = avatarUrl + self.createdAt = createdAt super.init() } @@ -106,10 +139,27 @@ public class VoipPayload: NSObject { "type": type, "hostName": hostName, "avatarUrl": avatarUrl ?? NSNull(), + "createdAt": createdAt ?? NSNull(), "notificationId": notificationId ] } + public func remainingLifetime(now: Date = Date()) -> TimeInterval? { + guard let expiresAt else { + return nil + } + + return max(0, expiresAt.timeIntervalSince(now)) + } + + public func isExpired(now: Date = Date()) -> Bool { + guard let remainingLifetime = remainingLifetime(now: now) else { + return true + } + + return remainingLifetime <= 0 + } + @objc public static func fromDictionary(_ dict: [AnyHashable: Any]) -> VoipPayload? { if let parsedPayload = parseRemotePayload(from: dict).toVoipPayload() { @@ -132,4 +182,16 @@ public class VoipPayload: NSObject { private static func parseRemotePayload(from payload: [AnyHashable: Any]) -> RemoteVoipPayload { return RemoteVoipPayload.fromDictionary(payload) } + + private static func parseCreatedAt(_ value: String?) -> Date? { + guard let value, !value.isEmpty else { + return nil + } + + if let parsed = iso8601FormatterWithFractionalSeconds.date(from: value) { + return parsed + } + + return iso8601Formatter.date(from: value) + } } diff --git a/ios/Libraries/VoipService.swift b/ios/Libraries/VoipService.swift index 9ad1fbb4b8a..8ff4a92509e 100644 --- a/ios/Libraries/VoipService.swift +++ b/ios/Libraries/VoipService.swift @@ -1,3 +1,4 @@ +import CallKit import Foundation import PushKit @@ -22,10 +23,10 @@ public final class VoipService: NSObject { // MARK: - Static Properties private static var initialEventsData: VoipPayload? - private static var initialEventsTimestamp: TimeInterval = 0 private static var isVoipRegistered = false private static var lastVoipToken: String = loadPersistedVoipToken() private static var voipRegistry: PKPushRegistry? + private static var incomingCallTimeouts: [String: DispatchWorkItem] = [:] // MARK: - Static Methods (Called from VoipModule.mm and AppDelegate) @@ -100,35 +101,24 @@ public final class VoipService: NSObject { print("[\(TAG)] Invalidated VoIP token") #endif } - - /// Called from AppDelegate when a VoIP push initial events are received - @objc - public static func didReceiveIncomingPush(with payload: PKPushPayload, forType type: String) { - #if DEBUG - print("[\(TAG)] didReceiveIncomingPush payload: \(payload.dictionaryPayload)") - #endif - - guard let voipPayload = VoipPayload.fromDictionary(payload.dictionaryPayload) else { - #if DEBUG - print("[\(TAG)] Failed to parse VoIP payload") - #endif - return - } - - storeInitialEvents(voipPayload) + + public static func prepareIncomingCall(_ payload: VoipPayload) { + storeInitialEvents(payload) + scheduleIncomingCallTimeout(for: payload) } + + // MARK: - Initial Events /// Stores initial events for JS to retrieve. @objc public static func storeInitialEvents(_ payload: VoipPayload) { initialEventsData = payload - initialEventsTimestamp = Date().timeIntervalSince1970 #if DEBUG print("[\(TAG)] Stored initial events: \(payload.callId)") #endif } - + /// Gets any initial events. Returns nil if no initial events. @objc public static func getInitialEvents() -> [String: Any]? { @@ -136,9 +126,7 @@ public final class VoipService: NSObject { return nil } - // Check if data is older than 5 minutes - let now = Date().timeIntervalSince1970 - if now - initialEventsTimestamp > 5 * 60 { + if data.isExpired() { clearInitialEventsInternal() return nil } @@ -158,12 +146,13 @@ public final class VoipService: NSObject { /// Clears initial events (internal) private static func clearInitialEventsInternal() { initialEventsData = nil - initialEventsTimestamp = 0 #if DEBUG print("[\(TAG)] Cleared initial events") #endif } - + + // MARK: - VoIP Token + /// Returns the last registered VoIP token @objc public static func getLastVoipToken() -> String { @@ -180,4 +169,50 @@ public final class VoipService: NSObject { private static func persistVoipToken(_ token: String) { storage.setString(token, forKey: voipTokenStorageKey) } + + // MARK: - Incoming Call Timeout + + public static func scheduleIncomingCallTimeout(for payload: VoipPayload) { + guard let delay = payload.remainingLifetime(), delay > 0 else { + #if DEBUG + print("[\(TAG)] Skipping incoming call timeout for expired or invalid payload: \(payload.callId)") + #endif + return + } + + cancelIncomingCallTimeout(for: payload.callId) + + let workItem = DispatchWorkItem { + handleIncomingCallTimeout(for: payload) + } + + incomingCallTimeouts[payload.callId] = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + + #if DEBUG + print("[\(TAG)] Scheduled incoming call timeout for \(payload.callId) in \(delay)s") + #endif + } + + private static func cancelIncomingCallTimeout(for callId: String) { + incomingCallTimeouts.removeValue(forKey: callId)?.cancel() + } + + private static func handleIncomingCallTimeout(for payload: VoipPayload) { + incomingCallTimeouts.removeValue(forKey: payload.callId) + + let callId = payload.callId + let callUUID = payload.callUUID + + let callObserver = CXCallObserver() + guard let call = callObserver.calls.first(where: { $0.uuid == callUUID }) else { + return + } + + guard !call.hasConnected, !call.hasEnded else { + return + } + + RNCallKeep.endCall(withUUID: callId, reason: 3) + } } From 91fbd979b4a8abef18b841bb7ba9ac64dd3f79cc Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 10 Mar 2026 11:57:21 -0300 Subject: [PATCH 06/23] Rollback avatarUrl logic on incoming call --- .../reactnative/notification/Ejson.java | 35 +++++++++++++++---- .../reactnative/voip/IncomingCallActivity.kt | 4 ++- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java index 1bf94a90d6c..b01994f7451 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java @@ -57,7 +57,7 @@ private MMKV getMMKV() { * Helper method to build avatar URI from avatar path. * Validates server URL and credentials, then constructs the full URI. */ - private String buildAvatarUri(String avatarPath, String errorContext) { + private String buildAvatarUri(String avatarPath, String errorContext, int sizePx) { String server = serverURL(); if (server == null || server.isEmpty()) { Log.w(TAG, "Cannot generate " + errorContext + " avatar URI: serverURL is null"); @@ -67,7 +67,7 @@ private String buildAvatarUri(String avatarPath, String errorContext) { String userToken = token(); String uid = userId(); - String finalUri = server + avatarPath + "?format=png&size=100"; + String finalUri = server + avatarPath + "?format=png&size=" + sizePx; if (!userToken.isEmpty() && !uid.isEmpty()) { finalUri += "&rc_token=" + userToken + "&rc_uid=" + uid; } @@ -102,14 +102,37 @@ public String getAvatarUri() { } } - return buildAvatarUri(avatarPath, ""); + return buildAvatarUri(avatarPath, "", 100); } /** - * Generates avatar URI for video conference caller. + * Factory for building caller avatar URIs from host + username (e.g. VoIP payload). + * Caller is package-private, so this is the only way to get avatar URI from outside the package. + */ + public static Ejson forCallerAvatar(String host, String username) { + if (host == null || host.isEmpty() || username == null || username.isEmpty()) { + return null; + } + Ejson ejson = new Ejson(); + ejson.host = host; + ejson.caller = new Caller(); + ejson.caller.username = username; + return ejson; + } + + /** + * Generates avatar URI for video conference caller (default size 100). * Returns null if caller username is not available (username is required for avatar endpoint). */ public String getCallerAvatarUri() { + return getCallerAvatarUri(100); + } + + /** + * Generates avatar URI for video conference caller with custom size. + * Returns null if caller username is not available. + */ + public String getCallerAvatarUri(int sizePx) { if (caller == null || caller.username == null || caller.username.isEmpty()) { Log.w(TAG, "Cannot generate caller avatar URI: caller or username is null"); return null; @@ -117,7 +140,7 @@ public String getCallerAvatarUri() { try { String avatarPath = "/avatar/" + URLEncoder.encode(caller.username, "UTF-8"); - return buildAvatarUri(avatarPath, "caller"); + return buildAvatarUri(avatarPath, "caller", sizePx); } catch (UnsupportedEncodingException e) { Log.e(TAG, "Failed to encode caller username", e); return null; @@ -241,4 +264,4 @@ static class Content { String kid; String iv; } -} +} \ No newline at end of file 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 1593efed0c0..feefc732944 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 @@ -27,6 +27,7 @@ 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 /** * Full-screen Activity displayed when an incoming VoIP call arrives. @@ -177,7 +178,8 @@ class IncomingCallActivity : Activity() { val container = findViewById(R.id.avatar_container) val imageView = findViewById(R.id.avatar) val sizePx = (120 * resources.displayMetrics.density).toInt().coerceIn(120, 480) - val avatarUrl = payload.avatarUrl?.takeIf { it.isNotBlank() } ?: return + val avatarUrl = Ejson.forCallerAvatar(payload.host, payload.username)?.getCallerAvatarUri(sizePx) + ?: return val cornerRadiusPx = (8 * resources.displayMetrics.density).toFloat() Glide.with(this) From 27100dbf8aefb597e6dc643301ccb4bde99a7a0c Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 10 Mar 2026 11:57:40 -0300 Subject: [PATCH 07/23] Update VoipPayload to match new definition --- .../main/java/chat/rocket/reactnative/voip/VoipPayload.kt | 7 +++++-- ios/Libraries/VoipPayload.swift | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) 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 ed39d42cbe5..1c15c900732 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 @@ -102,6 +102,9 @@ data class VoipPayload( @SerializedName("name") val name: String? = null, + @SerializedName("username") + val username: String? = null, + @SerializedName("avatarUrl") val avatarUrl: String? = null, ) @@ -140,7 +143,7 @@ data class VoipPayload( return VoipPayload( callId = callId ?: return null, caller = caller?.name ?: return null, - username = username ?: return null, + username = caller?.username ?: username ?: return null, host = host ?: return null, type = payloadType, hostName = hostName ?: return null, @@ -161,8 +164,8 @@ data class VoipPayload( val caller = bundle.getString("caller") ?: return null val username = bundle.getString("username") ?: return null val host = bundle.getString("host") ?: return null - val type = bundle.getString("type") ?: return null val hostName = bundle.getString("hostName") ?: return null + val type = bundle.getString("type") ?: return null val avatarUrl = bundle.getString("avatarUrl") val createdAt = bundle.getString("createdAt") diff --git a/ios/Libraries/VoipPayload.swift b/ios/Libraries/VoipPayload.swift index 0d25acba400..288afac8a6b 100644 --- a/ios/Libraries/VoipPayload.swift +++ b/ios/Libraries/VoipPayload.swift @@ -2,11 +2,13 @@ import Foundation private struct RemoteCaller { let name: String? + let username: String? let avatarUrl: String? static func fromDictionary(_ payload: [AnyHashable: Any]) -> RemoteCaller { RemoteCaller( name: payload["name"] as? String, + username: payload["username"] as? String, avatarUrl: payload["avatarUrl"] as? String ) } @@ -46,7 +48,7 @@ private struct RemoteVoipPayload { let payloadCallId = callId, let payloadCallUUID = UUID(uuidString: payloadCallId), let payloadCaller = caller?.name, - let payloadUsername = username, + let payloadUsername = caller?.username ?? username, let payloadHost = host, let payloadType = type, payloadType == "incoming_call", From 53059a75c3e3f088038bb422205941e4e1aee0f6 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 10 Mar 2026 11:57:55 -0300 Subject: [PATCH 08/23] Update report call props --- ios/Libraries/AppDelegate+Voip.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ios/Libraries/AppDelegate+Voip.swift b/ios/Libraries/AppDelegate+Voip.swift index d5661a68f1d..084254a1002 100644 --- a/ios/Libraries/AppDelegate+Voip.swift +++ b/ios/Libraries/AppDelegate+Voip.swift @@ -40,12 +40,12 @@ extension AppDelegate: PKPushRegistryDelegate { callId, handle: caller, handleType: "generic", - hasVideo: true, + hasVideo: false, localizedCallerName: caller, supportsHolding: true, supportsDTMF: true, - supportsGrouping: true, - supportsUngrouping: true, + supportsGrouping: false, + supportsUngrouping: false, fromPushKit: true, payload: payloadDict, withCompletionHandler: {} From 83aa200d4652eb36e06d987e38c57a885c0739dd Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Tue, 10 Mar 2026 15:13:31 -0300 Subject: [PATCH 09/23] Implement push events on Android --- .../RCFirebaseMessagingService.kt | 4 +- .../reactnative/voip/IncomingCallActivity.kt | 20 +++-- .../reactnative/voip/VoipNotification.kt | 73 +++++++++++++++++++ .../rocket/reactnative/voip/VoipPayload.kt | 64 +++++++++++++--- app/lib/services/voip/MediaSessionInstance.ts | 1 - ios/Libraries/VoipPayload.swift | 1 - 6 files changed, 139 insertions(+), 24 deletions(-) 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 b0eb2d02946..e3c2d9c5b64 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 @@ -33,8 +33,8 @@ class RCFirebaseMessagingService : FirebaseMessagingService() { val voipPayload = VoipPayload.fromMap(data) if (voipPayload != null) { - Log.d(TAG, "Detected VoIP incoming call payload, routing to VoipNotification handler") - VoipNotification(this).showIncomingCall(voipPayload) + Log.d(TAG, "Detected VoIP payload of type ${voipPayload.type}, routing to VoipNotification handler") + VoipNotification(this).onMessageReceived(voipPayload) return } 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 feefc732944..f74d24a06b7 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 @@ -41,16 +41,17 @@ class IncomingCallActivity : Activity() { private var ringtone: Ringtone? = null private var voipPayload: VoipPayload? = null - private var isTimeoutReceiverRegistered = false + private var isCallStateReceiverRegistered = false private val timeoutHandler = Handler(Looper.getMainLooper()) private var timeoutRunnable: Runnable? = null - private val timeoutReceiver = object : BroadcastReceiver() { + private val callStateReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { val payload = VoipPayload.fromBundle(intent?.extras) ?: return if (payload.callId != voipPayload?.callId) { return } + clearTimeout() stopRingtone() finish() } @@ -100,9 +101,12 @@ class IncomingCallActivity : Activity() { startRingtone() setupButtons(voipPayload) scheduleTimeout(voipPayload) - LocalBroadcastManager.getInstance(this) - .registerReceiver(timeoutReceiver, IntentFilter(VoipNotification.ACTION_TIMEOUT)) - isTimeoutReceiverRegistered = true + val intentFilter = IntentFilter().apply { + addAction(VoipNotification.ACTION_TIMEOUT) + addAction(VoipNotification.ACTION_DISMISS) + } + LocalBroadcastManager.getInstance(this).registerReceiver(callStateReceiver, intentFilter) + isCallStateReceiverRegistered = true } private fun applyNavigationBar() { @@ -303,9 +307,9 @@ class IncomingCallActivity : Activity() { override fun onDestroy() { super.onDestroy() clearTimeout() - if (isTimeoutReceiverRegistered) { - LocalBroadcastManager.getInstance(this).unregisterReceiver(timeoutReceiver) - isTimeoutReceiverRegistered = false + if (isCallStateReceiverRegistered) { + LocalBroadcastManager.getInstance(this).unregisterReceiver(callStateReceiver) + isCallStateReceiverRegistered = false } stopRingtone() } 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 d968f2bb640..535a8a90e1d 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 @@ -45,6 +45,7 @@ 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" 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" @@ -141,6 +142,21 @@ class VoipNotification(private val context: Context) { else -> connection.onDisconnect() } } + + private fun disconnectIncomingCall(callId: String, reportAsMissed: Boolean) { + val connection = VoiceConnectionService.getConnection(callId) + when (connection) { + is VoiceConnection -> { + if (reportAsMissed) { + connection.reportDisconnect(DISCONNECT_REASON_MISSED) + } else { + connection.onDisconnect() + } + } + null -> Log.d(TAG, "No active VoiceConnection found for dismissed call: $callId") + else -> connection.onDisconnect() + } + } } /** @@ -160,6 +176,14 @@ class VoipNotification(private val context: Context) { createNotificationChannel() } + fun onMessageReceived(voipPayload: VoipPayload) { + when { + voipPayload.isVoipIncomingCall() -> showIncomingCall(voipPayload) + voipPayload.shouldHideIncomingCall() -> dismissIncomingCall(voipPayload) + else -> Log.w(TAG, "Ignoring unsupported VoIP payload type: ${voipPayload.type}") + } + } + /** * Creates the notification channel for VoIP calls with high importance and ringtone sound. */ @@ -219,6 +243,27 @@ class VoipNotification(private val context: Context) { scheduleTimeout(context, voipPayload) } + private fun dismissIncomingCall(voipPayload: VoipPayload) { + cancelTimeout(voipPayload.callId) + val showMissedCallNotification = voipPayload.shouldShowMissedCallNotification() + disconnectIncomingCall( + callId = voipPayload.callId, + reportAsMissed = showMissedCallNotification + ) + cancelById(context, voipPayload.notificationId) + LocalBroadcastManager.getInstance(context).sendBroadcast( + Intent(ACTION_DISMISS).apply { + putExtras(voipPayload.toBundle()) + } + ) + + if (showMissedCallNotification) { + showMissedCallNotification(voipPayload) + } + + Log.d(TAG, "Dismissed incoming VoIP call for type ${voipPayload.type}: ${voipPayload.callId}") + } + /** * Registers the incoming call with TelecomManager using react-native-callkeep's ConnectionService. * This is REQUIRED for: @@ -380,6 +425,34 @@ class VoipNotification(private val context: Context) { Log.d(TAG, "VoIP notification displayed with ID: $notificationId") } + private fun showMissedCallNotification(voipPayload: VoipPayload) { + val packageName = context.packageName + val smallIconResId = context.resources.getIdentifier("ic_notification", "drawable", packageName) + val launchIntent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtras(voipPayload.toBundle()) + } + val contentIntent = createPendingIntent(voipPayload.notificationId + 3, launchIntent) + val caller = voipPayload.caller + + val builder = NotificationCompat.Builder(context, CHANNEL_ID).apply { + setSmallIcon(smallIconResId) + setContentTitle("Missed call") + if (caller.isNotBlank()) { + setContentText("Call from $caller") + } + setCategory(NotificationCompat.CATEGORY_MISSED_CALL) + setPriority(NotificationCompat.PRIORITY_HIGH) + setAutoCancel(true) + setOngoing(false) + setSilent(true) + setContentIntent(contentIntent) + } + + notificationManager?.notify(voipPayload.notificationId, builder.build()) + Log.d(TAG, "Missed VoIP call notification displayed with ID: ${voipPayload.notificationId}") + } + /** * Creates a PendingIntent with appropriate flags for the Android version. */ 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 1c15c900732..b227798a2d4 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 @@ -9,6 +9,18 @@ import java.text.SimpleDateFormat import java.util.Locale import java.util.TimeZone +enum class VoipPushType(val value: String) { + INCOMING_CALL("incoming_call"), + ANSWERED_ELSEWHERE("answeredElsewhere"), + DECLINED_ELSEWHERE("declinedElsewhere"), + REMOTE_ENDED("remoteEnded"), + UNANSWERED("unanswered"); + + companion object { + fun from(value: String?): VoipPushType? = entries.firstOrNull { it.value == value } + } +} + data class VoipPayload( @SerializedName("callId") val callId: String, @@ -35,6 +47,9 @@ data class VoipPayload( val createdAt: String?, ) { val notificationId: Int = callId.hashCode() + val pushType: VoipPushType? + get() = VoipPushType.from(type) + private val createdAtMs: Long? get() = parseCreatedAtMs(createdAt) @@ -42,7 +57,28 @@ data class VoipPayload( get() = createdAtMs?.plus(INCOMING_CALL_LIFETIME_MS) fun isVoipIncomingCall(): Boolean { - return type == "incoming_call" && callId.isNotEmpty() && caller.isNotEmpty() && host.isNotEmpty() + return pushType == VoipPushType.INCOMING_CALL && + callId.isNotBlank() && + caller.isNotBlank() && + host.isNotBlank() + } + + fun shouldHideIncomingCall(): Boolean { + return when (pushType) { + VoipPushType.ANSWERED_ELSEWHERE, + VoipPushType.DECLINED_ELSEWHERE, + VoipPushType.REMOTE_ENDED, + VoipPushType.UNANSWERED -> true + else -> false + } + } + + fun shouldShowMissedCallNotification(): Boolean { + return when (pushType) { + VoipPushType.REMOTE_ENDED, + VoipPushType.UNANSWERED -> true + else -> false + } } fun toBundle(): Bundle { @@ -88,6 +124,7 @@ data class VoipPayload( companion object { private val gson = Gson() + private const val VOIP_NOTIFICATION_TYPE = "voip" // the amount of time in milliseconds that an incoming call will be kept alive private const val INCOMING_CALL_LIFETIME_MS = 60_000L private val isoDateFormats = listOf( @@ -135,18 +172,17 @@ data class VoipPayload( val createdAt: String? = null, ) { fun toVoipPayload(): VoipPayload? { - if (notificationType != "voip") return null + if (notificationType != VOIP_NOTIFICATION_TYPE) return null - val payloadType = type ?: return null - if (payloadType != "incoming_call") return null + val payloadType = VoipPushType.from(type)?.value ?: return null return VoipPayload( callId = callId ?: return null, - caller = caller?.name ?: return null, - username = caller?.username ?: username ?: return null, - host = host ?: return null, + caller = caller?.name.orEmpty(), + username = caller?.username ?: username.orEmpty(), + host = host.orEmpty(), type = payloadType, - hostName = hostName ?: return null, + hostName = hostName.orEmpty(), avatarUrl = caller?.avatarUrl, createdAt = createdAt, ) @@ -161,14 +197,18 @@ data class VoipPayload( fun fromBundle(bundle: Bundle?): VoipPayload? { if (bundle == null) return null val callId = bundle.getString("callId") ?: return null - val caller = bundle.getString("caller") ?: return null - val username = bundle.getString("username") ?: return null - val host = bundle.getString("host") ?: return null - val hostName = bundle.getString("hostName") ?: return null + val caller = bundle.getString("caller").orEmpty() + val username = bundle.getString("username").orEmpty() + val host = bundle.getString("host").orEmpty() + val hostName = bundle.getString("hostName").orEmpty() val type = bundle.getString("type") ?: return null val avatarUrl = bundle.getString("avatarUrl") val createdAt = bundle.getString("createdAt") + if (VoipPushType.from(type) == null) { + return null + } + return VoipPayload(callId, caller, username, host, type, hostName, avatarUrl, createdAt) } diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index 095e3134a66..7df881904fa 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -15,7 +15,6 @@ import { store } from '../../store/auxStore'; import sdk from '../sdk'; import Navigation from '../../navigation/appNavigation'; import { parseStringToIceServers } from './parseStringToIceServers'; -import CallIdUUIDModule from '../../native/NativeCallIdUUID'; import type { IceServer } from '../../../definitions/Voip'; import type { IDDPMessage } from '../../../definitions/IDDPMessage'; import type { ISubscription, TSubscriptionModel } from '../../../definitions'; diff --git a/ios/Libraries/VoipPayload.swift b/ios/Libraries/VoipPayload.swift index 288afac8a6b..deb304b1c44 100644 --- a/ios/Libraries/VoipPayload.swift +++ b/ios/Libraries/VoipPayload.swift @@ -51,7 +51,6 @@ private struct RemoteVoipPayload { let payloadUsername = caller?.username ?? username, let payloadHost = host, let payloadType = type, - payloadType == "incoming_call", let payloadHostName = hostName else { return nil From 58c686d8e5464e1863014f9de8ba8179d226814e Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 11 Mar 2026 15:55:33 -0300 Subject: [PATCH 10/23] Android DDP --- .../rocket/reactnative/voip/VoipModule.kt | 5 + app/lib/native/NativeVoip.ts | 8 + app/lib/services/voip/MediaSessionInstance.ts | 3 + ios/Libraries/DDPClient.swift | 264 ++++++++++++++++++ ios/Libraries/VoipModule.mm | 5 + ios/Libraries/VoipService.swift | 126 ++++++++- ios/Podfile.lock | 2 +- ios/RocketChatRN.xcodeproj/project.pbxproj | 24 +- 8 files changed, 429 insertions(+), 8 deletions(-) create mode 100644 ios/Libraries/DDPClient.swift 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 16a2d61185e..ce85c2f58b5 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 @@ -111,6 +111,11 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo Log.d(TAG, "registerVoipToken called (no-op on Android)") } + // No-op on Android - native DDP listener is iOS only + override fun stopNativeDDPClient() { + Log.d(TAG, "stopNativeDDPClient called (no-op on Android)") + } + /** * Required for NativeEventEmitter in TurboModules. * Called when JS starts listening to events. diff --git a/app/lib/native/NativeVoip.ts b/app/lib/native/NativeVoip.ts index aea902d4e27..d47ba83ec0c 100644 --- a/app/lib/native/NativeVoip.ts +++ b/app/lib/native/NativeVoip.ts @@ -28,6 +28,13 @@ export interface Spec extends TurboModule { */ getLastVoipToken(): string; + /** + * Stops the native DDP WebSocket listener used for early call-end detection. + * iOS: Disconnects the native DDP client that monitors media-signal hangup events. + * Android: No-op. + */ + stopNativeDDPClient(): void; + /** * Required for NativeEventEmitter in TurboModules. * Called when JS starts listening to events. @@ -50,6 +57,7 @@ const NativeVoipModule = getInitialEvents: () => null, clearInitialEvents: () => undefined, getLastVoipToken: () => '', + stopNativeDDPClient: () => undefined, addListener: () => undefined, removeListeners: () => undefined } as Spec); diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index 7df881904fa..4570a14a987 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -15,6 +15,7 @@ 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'; @@ -34,6 +35,8 @@ class MediaSessionInstance { registerGlobals(); this.configureIceServers(); + NativeVoipModule.stopNativeDDPClient(); + mediaSessionStore.setWebRTCProcessorFactory( (config: WebRTCProcessorConfig) => new MediaCallWebRTCProcessor({ diff --git a/ios/Libraries/DDPClient.swift b/ios/Libraries/DDPClient.swift new file mode 100644 index 00000000000..3dcf598a7f6 --- /dev/null +++ b/ios/Libraries/DDPClient.swift @@ -0,0 +1,264 @@ +import Foundation + +/// Minimal DDP WebSocket client for listening to Rocket.Chat media-signal events from native iOS. +/// Only implements the subset needed to detect call hangup: connect, login, subscribe, and ping/pong. +final class DDPClient { + + private static let TAG = "RocketChat.DDPClient" + + private var webSocketTask: URLSessionWebSocketTask? + private var urlSession: URLSession? + private var sendCounter = 0 + private var isConnected = false + + /// Called for every incoming DDP collection message (e.g. stream-notify-user). + var onCollectionMessage: (([String: Any]) -> Void)? + + // MARK: - Connect + + func connect(host: String, completion: @escaping (Bool) -> Void) { + let wsUrl = Self.buildWebSocketURL(host: host) + + guard let url = URL(string: wsUrl) else { + #if DEBUG + print("[\(Self.TAG)] Invalid WebSocket URL: \(wsUrl)") + #endif + completion(false) + return + } + + #if DEBUG + print("[\(Self.TAG)] Connecting to \(wsUrl)") + #endif + + let session = URLSession(configuration: .default) + let task = session.webSocketTask(with: url) + + self.urlSession = session + self.webSocketTask = task + task.resume() + + listenForMessages() + + let connectMsg: [String: Any] = [ + "msg": "connect", + "version": "1", + "support": ["1", "pre2", "pre1"] + ] + + send(connectMsg) { [weak self] success in + if success { + self?.waitForConnected(timeout: 10.0, completion: completion) + } else { + completion(false) + } + } + } + + // MARK: - Login + + func login(token: String, completion: @escaping (Bool) -> Void) { + let msg = nextMessage(msg: "method", extra: [ + "method": "login", + "params": [["resume": token]] + ]) + + let msgId = msg["id"] as? String + + pendingCallbacks[msgId ?? ""] = { [weak self] data in + self?.pendingCallbacks.removeValue(forKey: msgId ?? "") + let hasError = data["error"] != nil + #if DEBUG + if hasError { + print("[\(Self.TAG)] Login failed: \(data["error"] ?? "unknown")") + } else { + print("[\(Self.TAG)] Login succeeded") + } + #endif + completion(!hasError) + } + + send(msg) { success in + if !success { completion(false) } + } + } + + // MARK: - Subscribe + + func subscribe(name: String, params: [Any], completion: @escaping (Bool) -> Void) { + let msg = nextMessage(msg: "sub", extra: [ + "name": name, + "params": params + ]) + + let msgId = msg["id"] as? String + + pendingCallbacks[msgId ?? ""] = { [weak self] _ in + self?.pendingCallbacks.removeValue(forKey: msgId ?? "") + #if DEBUG + print("[\(Self.TAG)] Subscribed to \(name)") + #endif + completion(true) + } + + send(msg) { success in + if !success { completion(false) } + } + } + + // MARK: - Disconnect + + func disconnect() { + #if DEBUG + print("[\(Self.TAG)] Disconnecting") + #endif + isConnected = false + pendingCallbacks.removeAll() + connectedCallback = nil + onCollectionMessage = nil + webSocketTask?.cancel(with: .normalClosure, reason: nil) + webSocketTask = nil + urlSession?.invalidateAndCancel() + urlSession = nil + } + + // MARK: - Private + + private var pendingCallbacks: [String: ([String: Any]) -> Void] = [:] + private var connectedCallback: ((Bool) -> Void)? + + private func nextMessage(msg: String, extra: [String: Any] = [:]) -> [String: Any] { + sendCounter += 1 + var dict: [String: Any] = ["msg": msg, "id": "ddp-\(sendCounter)"] + for (key, value) in extra { + dict[key] = value + } + return dict + } + + private func send(_ dict: [String: Any], completion: @escaping (Bool) -> Void) { + guard let data = try? JSONSerialization.data(withJSONObject: dict), + let string = String(data: data, encoding: .utf8) else { + completion(false) + return + } + + webSocketTask?.send(.string(string)) { error in + if let error = error { + #if DEBUG + print("[\(Self.TAG)] Send error: \(error.localizedDescription)") + #endif + completion(false) + } else { + completion(true) + } + } + } + + private func waitForConnected(timeout: TimeInterval, completion: @escaping (Bool) -> Void) { + connectedCallback = completion + DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self] in + guard let self = self, let cb = self.connectedCallback else { return } + self.connectedCallback = nil + #if DEBUG + print("[\(Self.TAG)] Connect timeout") + #endif + cb(false) + } + } + + private func listenForMessages() { + webSocketTask?.receive { [weak self] result in + guard let self = self else { return } + + switch result { + case .success(let message): + switch message { + case .string(let text): + self.handleMessage(text) + default: + break + } + self.listenForMessages() + + case .failure(let error): + #if DEBUG + print("[\(Self.TAG)] Receive error: \(error.localizedDescription)") + #endif + } + } + } + + private func handleMessage(_ text: String) { + guard let data = text.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return + } + + let msg = json["msg"] as? String + + switch msg { + case "connected": + isConnected = true + if let cb = connectedCallback { + connectedCallback = nil + cb(true) + } + + case "ping": + send(["msg": "pong"]) { _ in } + + case "result": + if let id = json["id"] as? String, let cb = pendingCallbacks[id] { + cb(json) + } + + case "ready": + if let subs = json["subs"] as? [String], let first = subs.first, let cb = pendingCallbacks[first] { + cb(json) + } + + case "changed", "added", "removed": + if let collection = json["collection"] as? String { + var message = json + message["collection"] = collection + onCollectionMessage?(message) + } + + case "nosub": + if let id = json["id"] as? String, let cb = pendingCallbacks[id] { + cb(json) + } + + default: + if let collection = json["collection"] as? String { + onCollectionMessage?(json) + _ = collection + } + } + } + + // MARK: - URL Helpers + + private static func buildWebSocketURL(host: String) -> String { + var cleaned = host + + if cleaned.hasSuffix("/") { + cleaned = String(cleaned.dropLast()) + } + + let useSsl: Bool + if cleaned.hasPrefix("https://") { + useSsl = true + cleaned = String(cleaned.dropFirst("https://".count)) + } else if cleaned.hasPrefix("http://") { + useSsl = false + cleaned = String(cleaned.dropFirst("http://".count)) + } else { + useSsl = true + } + + let scheme = useSsl ? "wss" : "ws" + return "\(scheme)://\(cleaned)/websocket" + } +} diff --git a/ios/Libraries/VoipModule.mm b/ios/Libraries/VoipModule.mm index c3f558f981d..bb2f6953305 100644 --- a/ios/Libraries/VoipModule.mm +++ b/ios/Libraries/VoipModule.mm @@ -12,6 +12,7 @@ + (void)voipRegistration; + (NSDictionary * _Nullable)getInitialEvents; + (void)clearInitialEvents; + (NSString * _Nonnull)getLastVoipToken; ++ (void)stopDDPClient; @end @implementation VoipModule { @@ -94,6 +95,10 @@ - (NSString * _Nonnull)getLastVoipToken { return [VoipService getLastVoipToken]; } +- (void)stopNativeDDPClient { + [VoipService stopDDPClient]; +} + - (void)addListener:(NSString *)eventName { // Required for NativeEventEmitter - starts observing } diff --git a/ios/Libraries/VoipService.swift b/ios/Libraries/VoipService.swift index 8ff4a92509e..4e24acad30a 100644 --- a/ios/Libraries/VoipService.swift +++ b/ios/Libraries/VoipService.swift @@ -16,7 +16,7 @@ public final class VoipService: NSObject { // MARK: - Constants - private static let TAG = "RocketChat.VoipModule" + private static let TAG = "RocketChat.VoipService" private static let voipTokenStorageKey = "RCVoipPushToken" private static let storage = MMKVBridge.build() @@ -27,6 +27,8 @@ public final class VoipService: NSObject { private static var lastVoipToken: String = loadPersistedVoipToken() private static var voipRegistry: PKPushRegistry? private static var incomingCallTimeouts: [String: DispatchWorkItem] = [:] + private static var ddpClient: DDPClient? + private static var ddpDisconnectWorkItem: DispatchWorkItem? // MARK: - Static Methods (Called from VoipModule.mm and AppDelegate) @@ -105,6 +107,7 @@ public final class VoipService: NSObject { public static func prepareIncomingCall(_ payload: VoipPayload) { storeInitialEvents(payload) scheduleIncomingCallTimeout(for: payload) + startListeningForCallEnd(payload: payload) } // MARK: - Initial Events @@ -215,4 +218,125 @@ public final class VoipService: NSObject { RNCallKeep.endCall(withUUID: callId, reason: 3) } + + // MARK: - Native DDP Listener (Call End Detection) + + /// Opens a lightweight DDP WebSocket to detect call hangup before JS boots. + private static func startListeningForCallEnd(payload: VoipPayload) { + stopDDPClientInternal() + + let credentialStorage = Storage() + guard let credentials = credentialStorage.getCredentials(server: payload.host.removeTrailingSlash()) else { + #if DEBUG + print("[\(TAG)] No credentials for \(payload.host), skipping DDP listener") + #endif + return + } + + let callId = payload.callId + let userId = credentials.userId + let client = DDPClient() + ddpClient = client + + #if DEBUG + print("[\(TAG)] Starting DDP listener for call \(callId)") + #endif + + client.onCollectionMessage = { message in + print("[\(TAG)] DDP received collection message: \(message)") + guard let fields = message["fields"] as? [String: Any], + let eventName = fields["eventName"] as? String, + eventName.hasSuffix("/media-signal"), + let args = fields["args"] as? [Any], + let firstArg = args.first as? [String: Any], + let signalType = firstArg["type"] as? String, + signalType == "notification", + let notification = firstArg["notification"] as? String, + notification == "hangup", + let signalCallId = firstArg["callId"] as? String, + signalCallId == callId + else { + return + } + + #if DEBUG + print("[\(TAG)] DDP received hangup for call \(callId)") + #endif + + DispatchQueue.main.async { + RNCallKeep.endCall(withUUID: callId, reason: 3) + cancelIncomingCallTimeout(for: callId) + stopDDPClientInternal() + } + } + + client.connect(host: payload.host) { connected in + guard connected else { + #if DEBUG + print("[\(TAG)] DDP connection failed") + #endif + stopDDPClientInternal() + return + } + + client.login(token: credentials.userToken) { loggedIn in + guard loggedIn else { + #if DEBUG + print("[\(TAG)] DDP login failed") + #endif + stopDDPClientInternal() + return + } + + let params: [Any] = [ + "\(userId)/media-signal", + ["useCollection": false, "args": [false]] + ] + + client.subscribe(name: "stream-notify-user", params: params) { subscribed in + #if DEBUG + print("[\(TAG)] DDP subscribe result: \(subscribed)") + #endif + if !subscribed { + stopDDPClientInternal() + } + } + } + } + + scheduleDDPSafetyTimeout() + } + + /// Stops the native DDP listener. Called from JS when it takes over signaling. + @objc + public static func stopDDPClient() { + #if DEBUG + print("[\(TAG)] stopDDPClient called from JS") + #endif + stopDDPClientInternal() + } + + private static func stopDDPClientInternal() { + ddpDisconnectWorkItem?.cancel() + ddpDisconnectWorkItem = nil + ddpClient?.disconnect() + ddpClient = nil + } + + private static func scheduleDDPSafetyTimeout() { + ddpDisconnectWorkItem?.cancel() + + let workItem = DispatchWorkItem { + #if DEBUG + print("[\(TAG)] DDP safety timeout reached, disconnecting") + #endif + stopDDPClientInternal() + } + + ddpDisconnectWorkItem = workItem + DispatchQueue.main.asyncAfter( + deadline: .now() + VoipPayload.INCOMING_CALL_LIFETIME_SEC, + execute: workItem + ) + } } diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 3c31f0c4908..b5ba945299a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3177,7 +3177,7 @@ SPEC CHECKSUMS: React-timing: 2d07431f1c1203c5b0aaa6dc7b5f503704519218 React-utils: 67cf7dcfc18aa4c56bec19e11886033bb057d9fa ReactAppDependencyProvider: bf62814e0fde923f73fc64b7e82d76c63c284da9 - ReactCodegen: 5c6b68a6cfdfce6b69a8b12d2eb93e1117d96113 + ReactCodegen: 5e787c775647706c2d66bf5a77bcdd49624aaf34 ReactCommon: 177fca841e97b2c0e288e86097b8be04c6e7ae36 ReactNativeIncallManager: dccd3e7499caa3bb73d3acfedf4fb0360f1a87d5 RNBootSplash: 1280eeb18d887de0a45bb4923d4fc56f25c8b99c diff --git a/ios/RocketChatRN.xcodeproj/project.pbxproj b/ios/RocketChatRN.xcodeproj/project.pbxproj index 35eaf4200c5..139b1bb5d7f 100644 --- a/ios/RocketChatRN.xcodeproj/project.pbxproj +++ b/ios/RocketChatRN.xcodeproj/project.pbxproj @@ -371,7 +371,9 @@ A48B46D92D3FFBD200945489 /* A11yFlowModule.m in Sources */ = {isa = PBXBuildFile; fileRef = A48B46D82D3FFBD200945489 /* A11yFlowModule.m */; }; A48B46DA2D3FFBD200945489 /* A11yFlowModule.m in Sources */ = {isa = PBXBuildFile; fileRef = A48B46D82D3FFBD200945489 /* A11yFlowModule.m */; }; AC6086DB073443D98330ED08 /* Pods_defaults_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 112EC394C611BEEA2867A6D3 /* Pods_defaults_NotificationService.framework */; }; + AE692FD072A44EA955D0C0D8 /* DDPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BE2F3DC02A264F204E3EDE3 /* DDPClient.swift */; }; BC404914E86821389EEB543D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 391C4F7AA7023CD41EEBD106 /* ExpoModulesProvider.swift */; }; + CE4453310C9A08AB0DAC2307 /* DDPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BE2F3DC02A264F204E3EDE3 /* DDPClient.swift */; }; DD2BA30A89E64F189C2C24AC /* libWatermelonDB.a in Frameworks */ = {isa = PBXBuildFile; fileRef = BA7E862283664608B3894E34 /* libWatermelonDB.a */; }; /* End PBXBuildFile section */ @@ -652,6 +654,7 @@ 9B215A42CFB843397273C7EA /* SecureStorage.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = SecureStorage.m; sourceTree = ""; }; 9B215A44CFB843397273C7EC /* MMKVBridge.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; name = MMKVBridge.mm; path = Shared/RocketChat/MMKVBridge.mm; sourceTree = ""; }; 9BD1145A1612F5D6A655D75A /* Pods-defaults-RocketChatRN.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-defaults-RocketChatRN.release.xcconfig"; path = "Target Support Files/Pods-defaults-RocketChatRN/Pods-defaults-RocketChatRN.release.xcconfig"; sourceTree = ""; }; + 9BE2F3DC02A264F204E3EDE3 /* DDPClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DDPClient.swift; sourceTree = ""; }; A3FFA83FC7CA4F1C7C42F2A8 /* Pods-defaults-NotificationService.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-defaults-NotificationService.release.xcconfig"; path = "Target Support Files/Pods-defaults-NotificationService/Pods-defaults-NotificationService.release.xcconfig"; sourceTree = ""; }; A48B46D72D3FFBD200945489 /* A11yFlowModule.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = A11yFlowModule.h; sourceTree = ""; }; A48B46D82D3FFBD200945489 /* A11yFlowModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = A11yFlowModule.m; sourceTree = ""; }; @@ -1155,6 +1158,7 @@ 66C2701A2EBBCB570062725F /* MMKVKeyManager.mm */, 7A8B30742BCD9D3F00146A40 /* SSLPinning.h */, 7A8B30752BCD9D3F00146A40 /* SSLPinning.mm */, + 9BE2F3DC02A264F204E3EDE3 /* DDPClient.swift */, ); path = Libraries; sourceTree = ""; @@ -1779,7 +1783,7 @@ inputFileListPaths = ( ); inputPaths = ( - $TARGET_BUILD_DIR/$INFOPLIST_PATH, + "$TARGET_BUILD_DIR/$INFOPLIST_PATH", ); name = "Upload source maps to Bugsnag"; outputFileListPaths = ( @@ -1799,7 +1803,7 @@ inputFileListPaths = ( ); inputPaths = ( - $TARGET_BUILD_DIR/$INFOPLIST_PATH, + "$TARGET_BUILD_DIR/$INFOPLIST_PATH", ); name = "Upload source maps to Bugsnag"; outputFileListPaths = ( @@ -2102,6 +2106,7 @@ 1E76CBDA25152C8E0067298C /* SendMessage.swift in Sources */, 4C4C8603EF082F0A33A95522 /* ExpoModulesProvider.swift in Sources */, A2C6E2DD38F8BEE19BFB2E1D /* SecureStorage.m in Sources */, + AE692FD072A44EA955D0C0D8 /* DDPClient.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2380,6 +2385,7 @@ 7AAB3E31257E6A6E00707CF6 /* SendMessage.swift in Sources */, BC404914E86821389EEB543D /* ExpoModulesProvider.swift in Sources */, 79D8C97F8CE2EC1B6882826B /* SecureStorage.m in Sources */, + CE4453310C9A08AB0DAC2307 /* DDPClient.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2613,7 +2619,7 @@ "$(inherited)", "$(SRCROOT)/../node_modules/rn-extensions-share/ios/**", "$(SRCROOT)/../node_modules/react-native-firebase/ios/RNFirebase/**", - $PODS_CONFIGURATION_BUILD_DIR/Firebase, + "$PODS_CONFIGURATION_BUILD_DIR/Firebase", ); INFOPLIST_FILE = ShareRocketChatRN/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -2689,7 +2695,7 @@ "$(inherited)", "$(SRCROOT)/../node_modules/rn-extensions-share/ios/**", "$(SRCROOT)/../node_modules/react-native-firebase/ios/RNFirebase/**", - $PODS_CONFIGURATION_BUILD_DIR/Firebase, + "$PODS_CONFIGURATION_BUILD_DIR/Firebase", ); INFOPLIST_FILE = ShareRocketChatRN/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -3214,7 +3220,10 @@ ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -3278,7 +3287,10 @@ MTL_ENABLE_DEBUG_INFO = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; From 343508eb05425d169b9cfbb866f21fdc84747d30 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 12 Mar 2026 13:37:24 -0300 Subject: [PATCH 11/23] Android DDP --- .../reactnative/notification/Ejson.java | 2 +- .../chat/rocket/reactnative/voip/DDPClient.kt | 235 ++++++++++++++++++ .../rocket/reactnative/voip/VoipModule.kt | 4 +- .../reactnative/voip/VoipNotification.kt | 172 +++++++++---- .../rocket/reactnative/voip/VoipPayload.kt | 24 +- 5 files changed, 361 insertions(+), 76 deletions(-) create mode 100644 android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java index b01994f7451..eb180312a1b 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java @@ -25,7 +25,7 @@ public class Ejson { private static final String TAG = "RocketChat.Ejson"; private static final String TOKEN_KEY = "reactnativemeteor_usertoken-"; - String host; + public String host; String rid; String type; Sender sender; diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt new file mode 100644 index 00000000000..32c7a49f9e6 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt @@ -0,0 +1,235 @@ +package chat.rocket.reactnative.voip + +import android.os.Handler +import android.os.Looper +import android.util.Log +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +/** + * Minimal DDP WebSocket client for listening to Rocket.Chat media-signal events from native Android. + * Only implements the subset needed to detect call hangup: connect, login, subscribe, and ping/pong. + */ +class DDPClient { + + companion object { + private const val TAG = "RocketChat.DDPClient" + } + + private var webSocket: WebSocket? = null + private var client: OkHttpClient? = null + private var sendCounter = 0 + private var isConnected = false + private val mainHandler = Handler(Looper.getMainLooper()) + + private val pendingCallbacks = mutableMapOf Unit>() + private var connectedCallback: ((Boolean) -> Unit)? = null + + var onCollectionMessage: ((JSONObject) -> Unit)? = null + + fun connect(host: String, callback: (Boolean) -> Unit) { + val wsUrl = buildWebSocketURL(host) + + Log.d(TAG, "Connecting to $wsUrl") + + val httpClient = OkHttpClient.Builder() + .pingInterval(30, TimeUnit.SECONDS) + .build() + client = httpClient + + val request = Request.Builder().url(wsUrl).build() + + webSocket = httpClient.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + Log.d(TAG, "WebSocket opened") + val connectMsg = JSONObject().apply { + put("msg", "connect") + put("version", "1") + put("support", JSONArray().apply { + put("1"); put("pre2"); put("pre1") + }) + } + webSocket.send(connectMsg.toString()) + waitForConnected(10_000L, callback) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + handleMessage(text) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.e(TAG, "WebSocket failure: ${t.message}") + mainHandler.post { callback(false) } + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Log.d(TAG, "WebSocket closed: $code $reason") + } + }) + } + + fun login(token: String, callback: (Boolean) -> Unit) { + val msg = nextMessage("method").apply { + put("method", "login") + put("params", JSONArray().apply { + put(JSONObject().apply { put("resume", token) }) + }) + } + + val msgId = msg.getString("id") + + synchronized(pendingCallbacks) { + pendingCallbacks[msgId] = { data -> + synchronized(pendingCallbacks) { pendingCallbacks.remove(msgId) } + val hasError = data.has("error") + if (hasError) { + Log.e(TAG, "Login failed: ${data.opt("error")}") + } else { + Log.d(TAG, "Login succeeded") + } + mainHandler.post { callback(!hasError) } + } + } + + if (!send(msg)) { + mainHandler.post { callback(false) } + } + } + + fun subscribe(name: String, params: JSONArray, callback: (Boolean) -> Unit) { + val msg = nextMessage("sub").apply { + put("name", name) + put("params", params) + } + + val msgId = msg.getString("id") + + synchronized(pendingCallbacks) { + pendingCallbacks[msgId] = { + synchronized(pendingCallbacks) { pendingCallbacks.remove(msgId) } + Log.d(TAG, "Subscribed to $name") + mainHandler.post { callback(true) } + } + } + + if (!send(msg)) { + mainHandler.post { callback(false) } + } + } + + fun disconnect() { + Log.d(TAG, "Disconnecting") + isConnected = false + synchronized(pendingCallbacks) { pendingCallbacks.clear() } + connectedCallback = null + onCollectionMessage = null + webSocket?.close(1000, null) + webSocket = null + client?.dispatcher?.executorService?.shutdown() + client = null + } + + private fun nextMessage(msg: String): JSONObject { + sendCounter++ + return JSONObject().apply { + put("msg", msg) + put("id", "ddp-$sendCounter") + } + } + + private fun send(json: JSONObject): Boolean { + val ws = webSocket ?: return false + return ws.send(json.toString()) + } + + private fun waitForConnected(timeoutMs: Long, callback: (Boolean) -> Unit) { + connectedCallback = callback + mainHandler.postDelayed({ + val cb = connectedCallback ?: return@postDelayed + connectedCallback = null + Log.e(TAG, "Connect timeout") + cb(false) + }, timeoutMs) + } + + private fun handleMessage(text: String) { + val json = try { + JSONObject(text) + } catch (e: Exception) { + return + } + + when (json.optString("msg")) { + "connected" -> { + isConnected = true + mainHandler.removeCallbacksAndMessages(null) + val cb = connectedCallback + connectedCallback = null + cb?.let { mainHandler.post { it(true) } } + } + + "ping" -> { + send(JSONObject().apply { put("msg", "pong") }) + } + + "result" -> { + val id = json.optString("id") + val cb = synchronized(pendingCallbacks) { pendingCallbacks[id] } + cb?.invoke(json) + } + + "ready" -> { + val subs = json.optJSONArray("subs") + val first = subs?.optString(0) + if (first != null) { + val cb = synchronized(pendingCallbacks) { pendingCallbacks[first] } + cb?.invoke(json) + } + } + + "changed", "added", "removed" -> { + onCollectionMessage?.invoke(json) + } + + "nosub" -> { + val id = json.optString("id") + val cb = synchronized(pendingCallbacks) { pendingCallbacks[id] } + cb?.invoke(json) + } + + else -> { + if (json.has("collection")) { + onCollectionMessage?.invoke(json) + } + } + } + } + + private fun buildWebSocketURL(host: String): String { + var normalizedHost = host.trimEnd('/') + + val useSsl: Boolean + when { + normalizedHost.startsWith("https://") -> { + useSsl = true + normalizedHost = normalizedHost.removePrefix("https://") + } + normalizedHost.startsWith("http://") -> { + useSsl = false + normalizedHost = normalizedHost.removePrefix("http://") + } + else -> { + useSsl = true + } + } + + val scheme = if (useSsl) "wss" else "ws" + return "$scheme://$normalizedHost/websocket" + } +} 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 ce85c2f58b5..0862c42a9df 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 @@ -111,9 +111,9 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo Log.d(TAG, "registerVoipToken called (no-op on Android)") } - // No-op on Android - native DDP listener is iOS only override fun stopNativeDDPClient() { - Log.d(TAG, "stopNativeDDPClient called (no-op on Android)") + Log.d(TAG, "stopNativeDDPClient called, stopping native DDP client") + VoipNotification.stopDDPClient() } /** 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 535a8a90e1d..f38896bff11 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 @@ -14,6 +14,7 @@ import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper +import android.provider.Settings import android.util.Log import androidx.core.app.NotificationCompat import androidx.localbroadcastmanager.content.LocalBroadcastManager @@ -25,6 +26,9 @@ import android.telecom.TelecomManager import io.wazo.callkeep.VoiceConnection import io.wazo.callkeep.VoiceConnectionService import chat.rocket.reactnative.MainActivity +import chat.rocket.reactnative.notification.Ejson +import org.json.JSONArray +import org.json.JSONObject /** * Handles VoIP call notifications using Android's Telecom framework via CallKeep. @@ -50,8 +54,11 @@ class VoipNotification(private val context: Context) { // 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 const val INCOMING_CALL_LIFETIME_MS = 60_000L private val timeoutHandler = Handler(Looper.getMainLooper()) private val timeoutCallbacks = mutableMapOf() + private var ddpClient: DDPClient? = null + private var ddpDisconnectRunnable: Runnable? = null /** * Cancels a VoIP notification by ID. @@ -157,6 +164,120 @@ class VoipNotification(private val context: Context) { else -> connection.onDisconnect() } } + + // -- Native DDP Listener (Call End Detection) -- + + @JvmStatic + fun startListeningForCallEnd(context: Context, payload: VoipPayload) { + stopDDPClientInternal() + + val ejson = Ejson() + ejson.host = payload.host + val userId = ejson.userId() + val token = ejson.token() + + if (userId.isNullOrEmpty() || token.isNullOrEmpty()) { + Log.d(TAG, "No credentials for ${payload.host}, skipping DDP listener") + return + } + + val deviceId = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) + val callId = payload.callId + val client = DDPClient() + ddpClient = client + + Log.d(TAG, "Starting DDP listener for call $callId") + + client.onCollectionMessage = { message -> + Log.d(TAG, "DDP received message: $message") + val fields = message.optJSONObject("fields") + if (fields != null) { + val eventName = fields.optString("eventName") + if (eventName.endsWith("/media-signal")) { + val args = fields.optJSONArray("args") + val firstArg = args?.optJSONObject(0) + if (firstArg != null) { + val signalType = firstArg.optString("type") + val signalCallId = firstArg.optString("callId") + val signedContractId = firstArg.optString("signedContractId") + + if (signalType == "notification" && signalCallId == callId && signedContractId != null && signedContractId != deviceId) { + Log.d(TAG, "DDP received hangup for call $callId") + val appContext = context.applicationContext + Handler(Looper.getMainLooper()).post { + cancelTimeout(callId) + disconnectIncomingCall(callId, true) + cancelById(appContext, payload.notificationId) + LocalBroadcastManager.getInstance(appContext).sendBroadcast( + Intent(ACTION_DISMISS).apply { + putExtras(payload.toBundle()) + } + ) + stopDDPClientInternal() + } + } + } + } + } + } + + client.connect(payload.host) { connected -> + if (!connected) { + Log.d(TAG, "DDP connection failed") + stopDDPClientInternal() + return@connect + } + + client.login(token) { loggedIn -> + if (!loggedIn) { + Log.d(TAG, "DDP login failed") + stopDDPClientInternal() + return@login + } + + val params = JSONArray().apply { + put("$userId/media-signal") + put(JSONObject().apply { + put("useCollection", false) + put("args", JSONArray().apply { put(false) }) + }) + } + + client.subscribe("stream-notify-user", params) { subscribed -> + Log.d(TAG, "DDP subscribe result: $subscribed") + if (!subscribed) { + stopDDPClientInternal() + } + } + } + } + + scheduleDDPSafetyTimeout() + } + + @JvmStatic + fun stopDDPClient() { + Log.d(TAG, "stopDDPClient called from JS") + stopDDPClientInternal() + } + + private fun stopDDPClientInternal() { + ddpDisconnectRunnable?.let { timeoutHandler.removeCallbacks(it) } + ddpDisconnectRunnable = null + ddpClient?.disconnect() + ddpClient = null + } + + private fun scheduleDDPSafetyTimeout() { + ddpDisconnectRunnable?.let { timeoutHandler.removeCallbacks(it) } + + val runnable = Runnable { + Log.d(TAG, "DDP safety timeout reached, disconnecting") + stopDDPClientInternal() + } + ddpDisconnectRunnable = runnable + timeoutHandler.postDelayed(runnable, INCOMING_CALL_LIFETIME_MS) + } } /** @@ -179,7 +300,6 @@ class VoipNotification(private val context: Context) { fun onMessageReceived(voipPayload: VoipPayload) { when { voipPayload.isVoipIncomingCall() -> showIncomingCall(voipPayload) - voipPayload.shouldHideIncomingCall() -> dismissIncomingCall(voipPayload) else -> Log.w(TAG, "Ignoring unsupported VoIP payload type: ${voipPayload.type}") } } @@ -241,27 +361,7 @@ class VoipNotification(private val context: Context) { // Show notification with full-screen intent showIncomingCallNotification(voipPayload) scheduleTimeout(context, voipPayload) - } - - private fun dismissIncomingCall(voipPayload: VoipPayload) { - cancelTimeout(voipPayload.callId) - val showMissedCallNotification = voipPayload.shouldShowMissedCallNotification() - disconnectIncomingCall( - callId = voipPayload.callId, - reportAsMissed = showMissedCallNotification - ) - cancelById(context, voipPayload.notificationId) - LocalBroadcastManager.getInstance(context).sendBroadcast( - Intent(ACTION_DISMISS).apply { - putExtras(voipPayload.toBundle()) - } - ) - - if (showMissedCallNotification) { - showMissedCallNotification(voipPayload) - } - - Log.d(TAG, "Dismissed incoming VoIP call for type ${voipPayload.type}: ${voipPayload.callId}") + startListeningForCallEnd(context, voipPayload) } /** @@ -425,34 +525,6 @@ class VoipNotification(private val context: Context) { Log.d(TAG, "VoIP notification displayed with ID: $notificationId") } - private fun showMissedCallNotification(voipPayload: VoipPayload) { - val packageName = context.packageName - val smallIconResId = context.resources.getIdentifier("ic_notification", "drawable", packageName) - val launchIntent = Intent(context, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP - putExtras(voipPayload.toBundle()) - } - val contentIntent = createPendingIntent(voipPayload.notificationId + 3, launchIntent) - val caller = voipPayload.caller - - val builder = NotificationCompat.Builder(context, CHANNEL_ID).apply { - setSmallIcon(smallIconResId) - setContentTitle("Missed call") - if (caller.isNotBlank()) { - setContentText("Call from $caller") - } - setCategory(NotificationCompat.CATEGORY_MISSED_CALL) - setPriority(NotificationCompat.PRIORITY_HIGH) - setAutoCancel(true) - setOngoing(false) - setSilent(true) - setContentIntent(contentIntent) - } - - notificationManager?.notify(voipPayload.notificationId, builder.build()) - Log.d(TAG, "Missed VoIP call notification displayed with ID: ${voipPayload.notificationId}") - } - /** * Creates a PendingIntent with appropriate flags for the Android version. */ 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 b227798a2d4..3d2cbd1d78c 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 @@ -10,11 +10,7 @@ import java.util.Locale import java.util.TimeZone enum class VoipPushType(val value: String) { - INCOMING_CALL("incoming_call"), - ANSWERED_ELSEWHERE("answeredElsewhere"), - DECLINED_ELSEWHERE("declinedElsewhere"), - REMOTE_ENDED("remoteEnded"), - UNANSWERED("unanswered"); + INCOMING_CALL("incoming_call"); companion object { fun from(value: String?): VoipPushType? = entries.firstOrNull { it.value == value } @@ -63,24 +59,6 @@ data class VoipPayload( host.isNotBlank() } - fun shouldHideIncomingCall(): Boolean { - return when (pushType) { - VoipPushType.ANSWERED_ELSEWHERE, - VoipPushType.DECLINED_ELSEWHERE, - VoipPushType.REMOTE_ENDED, - VoipPushType.UNANSWERED -> true - else -> false - } - } - - fun shouldShowMissedCallNotification(): Boolean { - return when (pushType) { - VoipPushType.REMOTE_ENDED, - VoipPushType.UNANSWERED -> true - else -> false - } - } - fun toBundle(): Bundle { return Bundle().apply { putString("callId", callId) From cf09468ce28a3edb8cb9afb28e83882aba4454f1 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 12 Mar 2026 13:57:53 -0300 Subject: [PATCH 12/23] DeviceId check on iOS --- ios/Libraries/VoipService.swift | 7 ++++--- ios/RocketChatRN-Bridging-Header.h | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/ios/Libraries/VoipService.swift b/ios/Libraries/VoipService.swift index 4e24acad30a..3bda9d7f5f5 100644 --- a/ios/Libraries/VoipService.swift +++ b/ios/Libraries/VoipService.swift @@ -235,6 +235,7 @@ public final class VoipService: NSObject { let callId = payload.callId let userId = credentials.userId + let deviceId = DeviceUID.uid() let client = DDPClient() ddpClient = client @@ -251,10 +252,10 @@ public final class VoipService: NSObject { let firstArg = args.first as? [String: Any], let signalType = firstArg["type"] as? String, signalType == "notification", - let notification = firstArg["notification"] as? String, - notification == "hangup", let signalCallId = firstArg["callId"] as? String, - signalCallId == callId + signalCallId == callId, + let signedContractId = firstArg["signedContractId"] as? String, + signedContractId != deviceId else { return } diff --git a/ios/RocketChatRN-Bridging-Header.h b/ios/RocketChatRN-Bridging-Header.h index a4434b7fd77..0c10d8e72ff 100644 --- a/ios/RocketChatRN-Bridging-Header.h +++ b/ios/RocketChatRN-Bridging-Header.h @@ -7,6 +7,7 @@ #import "MMKVKeyManager.h" #import "Shared/RocketChat/MMKVBridge.h" #import +#import #import #import #import From 8b9c0f8a4c78dfb4209d4fdb14c3e4a783ae457b Mon Sep 17 00:00:00 2001 From: diegolmello Date: Thu, 12 Mar 2026 17:05:36 +0000 Subject: [PATCH 13/23] chore: format code and fix lint issues --- app/lib/hooks/useSubscription.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/lib/hooks/useSubscription.test.ts b/app/lib/hooks/useSubscription.test.ts index 4d93b1a6164..94711d391ff 100644 --- a/app/lib/hooks/useSubscription.test.ts +++ b/app/lib/hooks/useSubscription.test.ts @@ -9,7 +9,7 @@ jest.mock('../database/services/Subscription', () => ({ const mockedGetSubscriptionByRoomId = jest.mocked(getSubscriptionByRoomId); -const createDeferred = () => { +const createDeferred = () => { let resolve!: (value: T) => void; const promise = new Promise(res => { resolve = res; @@ -45,9 +45,7 @@ describe('useSubscription', () => { const secondSubscription = { id: 'sub-2', rid: 'room-2' } as any; const secondRequest = createDeferred(); - mockedGetSubscriptionByRoomId - .mockResolvedValueOnce(firstSubscription) - .mockImplementationOnce(() => secondRequest.promise); + mockedGetSubscriptionByRoomId.mockResolvedValueOnce(firstSubscription).mockImplementationOnce(() => secondRequest.promise); const { result, rerender } = renderHook(({ rid }: { rid?: string }) => useSubscription(rid), { initialProps: { rid: 'room-1' } From 6ab4026be9a32e8085eee101ead19bb8b629e554 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 12 Mar 2026 14:19:09 -0300 Subject: [PATCH 14/23] Improve registerPushToken --- app/lib/services/restApi.ts | 78 ++++++++++++++++++------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/app/lib/services/restApi.ts b/app/lib/services/restApi.ts index b2a0d764041..392d363b2a6 100644 --- a/app/lib/services/restApi.ts +++ b/app/lib/services/restApi.ts @@ -1012,55 +1012,55 @@ type TRegisterPushTokenData = { appName: string; voipToken?: string; }; -export const registerPushToken = (): Promise => - new Promise(async resolve => { - const token = getDeviceToken(); - const voipToken = isIOS ? NativeVoipModule.getLastVoipToken() : ''; +export const registerPushToken = async (): Promise => { + const token = getDeviceToken(); + // Always returns an empty string on Android + const voipToken = NativeVoipModule.getLastVoipToken(); - if (token === lastToken && voipToken === lastVoipToken) { - return resolve(); - } + if (token === lastToken && voipToken === lastVoipToken) { + return; + } - // TODO: server version - if (isIOS && (!token || !voipToken)) { - return resolve(); - } + // TODO: server version + if (isIOS && (!token || !voipToken)) { + return; + } - let data: TRegisterPushTokenData = { - id: '', - value: '', - type: '', - appName: '' + let data: TRegisterPushTokenData = { + id: '', + value: '', + type: '', + appName: '' + }; + if (token) { + const type = isIOS ? 'apn' : 'gcm'; + data = { + id: await getUniqueId(), + value: token, + type, + appName: getBundleId }; - if (token) { - const type = isIOS ? 'apn' : 'gcm'; - data = { - id: await getUniqueId(), - value: token, - type, - appName: getBundleId - }; - } - if (isIOS && voipToken) { - data.voipToken = voipToken; - } + } + if (voipToken) { + data.voipToken = voipToken; + } - try { - // RC 0.60.0 - await sdk.post('push.token', data); - console.log('registerPushToken success', data); - lastToken = token; - lastVoipToken = voipToken; - } catch (e) { - log(e); - } - return resolve(); - }); + try { + // RC 0.60.0 + await sdk.post('push.token', data); + lastToken = token; + lastVoipToken = voipToken; + } catch (e) { + log(e); + } +}; // TODO: add voip token removal export const removePushToken = (): Promise => { const token = getDeviceToken(); if (token) { + lastToken = ''; + lastVoipToken = ''; // RC 0.60.0 return sdk.current.del('push.token', { token }); } From 0b50e5c2426859a6f820444d56e28efb8bce3edc Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 12 Mar 2026 14:19:18 -0300 Subject: [PATCH 15/23] Use stable notification id on iOS --- ios/Libraries/VoipPayload.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/ios/Libraries/VoipPayload.swift b/ios/Libraries/VoipPayload.swift index deb304b1c44..55b51e3e85b 100644 --- a/ios/Libraries/VoipPayload.swift +++ b/ios/Libraries/VoipPayload.swift @@ -109,7 +109,17 @@ public class VoipPayload: NSObject { }() @objc public var notificationId: Int { - return callId.hashValue + return Self.stableNotificationId(for: callId) + } + + /// Deterministic hash for consistent notification IDs across app launches. + /// Matches Java/Kotlin String.hashCode() semantics over UTF-16 code units. + private static func stableNotificationId(for value: String) -> Int { + var hash: Int32 = 0 + for codeUnit in value.utf16 { + hash = (31 &* hash) &+ Int32(codeUnit) + } + return Int(hash) } init(callId: String, callUUID: UUID, caller: String, username: String, host: String, type: String, hostName: String, avatarUrl: String?, createdAt: String?) { From b7ede6498c659b76bac9502e54a1c4c595f5888f Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 12 Mar 2026 15:22:39 -0300 Subject: [PATCH 16/23] Enhance VoIP handling by refining DDP client connection logic, improving payload validation, and adjusting call handling conditions. Unify function calls and ensure proper state management across platforms. --- .../reactnative/voip/VoipNotification.kt | 7 +- .../rocket/reactnative/voip/VoipPayload.kt | 21 +- app/lib/services/voip/MediaCallEvents.ts | 2 +- app/lib/services/voip/MediaSessionInstance.ts | 4 +- ios/Libraries/DDPClient.swift | 240 ++++++++++-------- ios/Libraries/VoipPayload.swift | 6 +- ios/Libraries/VoipService.swift | 16 +- 7 files changed, 173 insertions(+), 123 deletions(-) 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 f38896bff11..7ad399866bd 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 @@ -97,6 +97,7 @@ class VoipNotification(private val context: Context) { @JvmStatic fun cancelTimeout(callId: String) { + stopDDPClientInternal(); val timeoutRunnable = synchronized(timeoutCallbacks) { timeoutCallbacks.remove(callId) } @@ -132,6 +133,7 @@ class VoipNotification(private val context: Context) { // TODO: call restapi to decline the call } + // TODO: unify these three functions and check VoiceConnectionService private fun disconnectTimedOutCall(callId: String) { val connection = VoiceConnectionService.getConnection(callId) when (connection) { @@ -201,12 +203,11 @@ class VoipNotification(private val context: Context) { val signalCallId = firstArg.optString("callId") val signedContractId = firstArg.optString("signedContractId") - if (signalType == "notification" && signalCallId == callId && signedContractId != null && signedContractId != deviceId) { - Log.d(TAG, "DDP received hangup for call $callId") + if (signalType == "notification" && signalCallId == callId && signedContractId.isNullOrEmpty() && signedContractId != deviceId) { val appContext = context.applicationContext Handler(Looper.getMainLooper()).post { cancelTimeout(callId) - disconnectIncomingCall(callId, true) + disconnectIncomingCall(callId, false) cancelById(appContext, payload.notificationId) LocalBroadcastManager.getInstance(appContext).sendBroadcast( Intent(ACTION_DISMISS).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 3d2cbd1d78c..d361f711f30 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 @@ -96,8 +96,8 @@ data class VoipPayload( } fun isExpired(): Boolean { - val remainingLifetimeMs = getRemainingLifetimeMs() ?: return true - return remainingLifetimeMs <= 0L + val remainingLifetimeMs = getRemainingLifetimeMs() + return remainingLifetimeMs?.let { it <= 0L } ?: true } companion object { @@ -153,6 +153,7 @@ data class VoipPayload( if (notificationType != VOIP_NOTIFICATION_TYPE) return null val payloadType = VoipPushType.from(type)?.value ?: return null + val payloadCreatedAt = createdAt?.takeUnless { it.isBlank() } ?: return null return VoipPayload( callId = callId ?: return null, @@ -162,7 +163,7 @@ data class VoipPayload( type = payloadType, hostName = hostName.orEmpty(), avatarUrl = caller?.avatarUrl, - createdAt = createdAt, + createdAt = payloadCreatedAt, ) } } @@ -181,7 +182,7 @@ data class VoipPayload( val hostName = bundle.getString("hostName").orEmpty() val type = bundle.getString("type") ?: return null val avatarUrl = bundle.getString("avatarUrl") - val createdAt = bundle.getString("createdAt") + val createdAt = bundle.getString("createdAt")?.takeUnless { it.isBlank() } ?: return null if (VoipPushType.from(type) == null) { return null @@ -208,12 +209,12 @@ data class VoipPayload( return null } - isoDateFormats.forEach { formatter -> - synchronized(formatter) { - val parsed = formatter.parse(value) - if (parsed != null) { - return parsed.time - } + for (formatter in isoDateFormats) { + val parsed = synchronized(formatter) { + runCatching { formatter.parse(value) }.getOrNull() + } + if (parsed != null) { + return parsed.time } } diff --git a/app/lib/services/voip/MediaCallEvents.ts b/app/lib/services/voip/MediaCallEvents.ts index 778b2858334..141dcbfdd24 100644 --- a/app/lib/services/voip/MediaCallEvents.ts +++ b/app/lib/services/voip/MediaCallEvents.ts @@ -111,7 +111,7 @@ export const getInitialMediaCallEvents = async (): Promise => { const { name, data } = event; if (name === 'RNCallKeepPerformAnswerCallAction') { const { callUUID } = data; - if (initialEvents.callId.toLowerCase() === callUUID.toLowerCase()) { + if (initialEvents.callId === callUUID) { wasAnswered = true; console.log(`${TAG} Call was already answered via CallKit`); break; diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index 4570a14a987..3dade6a770d 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -93,7 +93,7 @@ class MediaSessionInstance { console.log('[VoIP] Answering call:', callId); const mainCall = this.instance?.getMainCall(); console.log('[VoIP] Main call:', mainCall); - // Compare using deterministic UUID conversion + if (mainCall && mainCall.callId === callId) { console.log('[VoIP] Accepting call:', callId); await mainCall.accept(); @@ -121,7 +121,7 @@ class MediaSessionInstance { public endCall = (callId: string) => { const mainCall = this.instance?.getMainCall(); - // Compare using deterministic UUID conversion + if (mainCall && mainCall.callId === callId) { if (mainCall.state === 'ringing') { mainCall.reject(); diff --git a/ios/Libraries/DDPClient.swift b/ios/Libraries/DDPClient.swift index 3dcf598a7f6..395666368cd 100644 --- a/ios/Libraries/DDPClient.swift +++ b/ios/Libraries/DDPClient.swift @@ -5,6 +5,7 @@ import Foundation final class DDPClient { private static let TAG = "RocketChat.DDPClient" + private let stateQueue = DispatchQueue(label: "chat.rocket.reactnative.ddp-client") private var webSocketTask: URLSessionWebSocketTask? private var urlSession: URLSession? @@ -17,40 +18,44 @@ final class DDPClient { // MARK: - Connect func connect(host: String, completion: @escaping (Bool) -> Void) { - let wsUrl = Self.buildWebSocketURL(host: host) - - guard let url = URL(string: wsUrl) else { + stateQueue.async { + let wsUrl = Self.buildWebSocketURL(host: host) + + guard let url = URL(string: wsUrl) else { + #if DEBUG + print("[\(Self.TAG)] Invalid WebSocket URL: \(wsUrl)") + #endif + completion(false) + return + } + #if DEBUG - print("[\(Self.TAG)] Invalid WebSocket URL: \(wsUrl)") + print("[\(Self.TAG)] Connecting to \(wsUrl)") #endif - completion(false) - return - } - - #if DEBUG - print("[\(Self.TAG)] Connecting to \(wsUrl)") - #endif - - let session = URLSession(configuration: .default) - let task = session.webSocketTask(with: url) - - self.urlSession = session - self.webSocketTask = task - task.resume() - - listenForMessages() - - let connectMsg: [String: Any] = [ - "msg": "connect", - "version": "1", - "support": ["1", "pre2", "pre1"] - ] - - send(connectMsg) { [weak self] success in - if success { - self?.waitForConnected(timeout: 10.0, completion: completion) - } else { - completion(false) + + let session = URLSession(configuration: .default) + let task = session.webSocketTask(with: url) + + self.urlSession = session + self.webSocketTask = task + self.isConnected = false + task.resume() + + self.listenForMessages(task: task) + + let connectMsg: [String: Any] = [ + "msg": "connect", + "version": "1", + "support": ["1", "pre2", "pre1"] + ] + + self.send(connectMsg) { [weak self] success in + guard let self else { return } + if success { + self.waitForConnected(timeout: 10.0, completion: completion) + } else { + completion(false) + } } } } @@ -58,68 +63,91 @@ final class DDPClient { // MARK: - Login func login(token: String, completion: @escaping (Bool) -> Void) { - let msg = nextMessage(msg: "method", extra: [ - "method": "login", - "params": [["resume": token]] - ]) - - let msgId = msg["id"] as? String - - pendingCallbacks[msgId ?? ""] = { [weak self] data in - self?.pendingCallbacks.removeValue(forKey: msgId ?? "") - let hasError = data["error"] != nil - #if DEBUG - if hasError { - print("[\(Self.TAG)] Login failed: \(data["error"] ?? "unknown")") - } else { - print("[\(Self.TAG)] Login succeeded") + stateQueue.async { + let msg = self.nextMessage(msg: "method", extra: [ + "method": "login", + "params": [["resume": token]] + ]) + + let msgId = msg["id"] as? String + + self.pendingCallbacks[msgId ?? ""] = { [weak self] data in + self?.pendingCallbacks.removeValue(forKey: msgId ?? "") + let hasError = data["error"] != nil + #if DEBUG + if hasError { + print("[\(Self.TAG)] Login failed: \(data["error"] ?? "unknown")") + } else { + print("[\(Self.TAG)] Login succeeded") + } + #endif + completion(!hasError) + } + + self.send(msg) { [weak self] success in + guard let self else { return } + if !success { + self.stateQueue.async { + self.pendingCallbacks.removeValue(forKey: msgId ?? "") + completion(false) + } + } } - #endif - completion(!hasError) - } - - send(msg) { success in - if !success { completion(false) } } } // MARK: - Subscribe func subscribe(name: String, params: [Any], completion: @escaping (Bool) -> Void) { - let msg = nextMessage(msg: "sub", extra: [ - "name": name, - "params": params - ]) - - let msgId = msg["id"] as? String - - pendingCallbacks[msgId ?? ""] = { [weak self] _ in - self?.pendingCallbacks.removeValue(forKey: msgId ?? "") - #if DEBUG - print("[\(Self.TAG)] Subscribed to \(name)") - #endif - completion(true) - } - - send(msg) { success in - if !success { completion(false) } + stateQueue.async { + let msg = self.nextMessage(msg: "sub", extra: [ + "name": name, + "params": params + ]) + + let msgId = msg["id"] as? String + + self.pendingCallbacks[msgId ?? ""] = { [weak self] data in + self?.pendingCallbacks.removeValue(forKey: msgId ?? "") + let didSubscribe = (data["msg"] as? String) == "ready" && data["error"] == nil + #if DEBUG + if didSubscribe { + print("[\(Self.TAG)] Subscribed to \(name)") + } else { + print("[\(Self.TAG)] Failed to subscribe to \(name): \(data["error"] ?? "nosub")") + } + #endif + completion(didSubscribe) + } + + self.send(msg) { [weak self] success in + guard let self else { return } + if !success { + self.stateQueue.async { + self.pendingCallbacks.removeValue(forKey: msgId ?? "") + completion(false) + } + } + } } } // MARK: - Disconnect func disconnect() { - #if DEBUG - print("[\(Self.TAG)] Disconnecting") - #endif - isConnected = false - pendingCallbacks.removeAll() - connectedCallback = nil - onCollectionMessage = nil - webSocketTask?.cancel(with: .normalClosure, reason: nil) - webSocketTask = nil - urlSession?.invalidateAndCancel() - urlSession = nil + stateQueue.async { + #if DEBUG + print("[\(Self.TAG)] Disconnecting") + #endif + self.isConnected = false + self.pendingCallbacks.removeAll() + self.connectedCallback = nil + self.onCollectionMessage = nil + self.webSocketTask?.cancel(with: .normalClosure, reason: nil) + self.webSocketTask = nil + self.urlSession?.invalidateAndCancel() + self.urlSession = nil + } } // MARK: - Private @@ -158,33 +186,37 @@ final class DDPClient { private func waitForConnected(timeout: TimeInterval, completion: @escaping (Bool) -> Void) { connectedCallback = completion DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self] in - guard let self = self, let cb = self.connectedCallback else { return } - self.connectedCallback = nil - #if DEBUG - print("[\(Self.TAG)] Connect timeout") - #endif - cb(false) + self?.stateQueue.async { + guard let self = self, let cb = self.connectedCallback else { return } + self.connectedCallback = nil + #if DEBUG + print("[\(Self.TAG)] Connect timeout") + #endif + cb(false) + } } } - private func listenForMessages() { - webSocketTask?.receive { [weak self] result in - guard let self = self else { return } - - switch result { - case .success(let message): - switch message { - case .string(let text): - self.handleMessage(text) - default: - break - } - self.listenForMessages() + private func listenForMessages(task: URLSessionWebSocketTask) { + task.receive { [weak self] result in + self?.stateQueue.async { + guard let self = self, let currentTask = self.webSocketTask, task === currentTask else { return } - case .failure(let error): - #if DEBUG - print("[\(Self.TAG)] Receive error: \(error.localizedDescription)") - #endif + switch result { + case .success(let message): + switch message { + case .string(let text): + self.handleMessage(text) + default: + break + } + self.listenForMessages(task: task) + + case .failure(let error): + #if DEBUG + print("[\(Self.TAG)] Receive error: \(error.localizedDescription)") + #endif + } } } } diff --git a/ios/Libraries/VoipPayload.swift b/ios/Libraries/VoipPayload.swift index 55b51e3e85b..5631bc3187f 100644 --- a/ios/Libraries/VoipPayload.swift +++ b/ios/Libraries/VoipPayload.swift @@ -51,7 +51,9 @@ private struct RemoteVoipPayload { let payloadUsername = caller?.username ?? username, let payloadHost = host, let payloadType = type, - let payloadHostName = hostName + let payloadHostName = hostName, + let payloadCreatedAt = createdAt, + !payloadCreatedAt.isEmpty else { return nil } @@ -65,7 +67,7 @@ private struct RemoteVoipPayload { type: payloadType, hostName: payloadHostName, avatarUrl: caller?.avatarUrl, - createdAt: createdAt + createdAt: payloadCreatedAt ) } } diff --git a/ios/Libraries/VoipService.swift b/ios/Libraries/VoipService.swift index 3bda9d7f5f5..924ef777261 100644 --- a/ios/Libraries/VoipService.swift +++ b/ios/Libraries/VoipService.swift @@ -244,7 +244,9 @@ public final class VoipService: NSObject { #endif client.onCollectionMessage = { message in - print("[\(TAG)] DDP received collection message: \(message)") + guard ddpClient === client else { + return + } guard let fields = message["fields"] as? [String: Any], let eventName = fields["eventName"] as? String, eventName.hasSuffix("/media-signal"), @@ -265,6 +267,9 @@ public final class VoipService: NSObject { #endif DispatchQueue.main.async { + guard ddpClient === client else { + return + } RNCallKeep.endCall(withUUID: callId, reason: 3) cancelIncomingCallTimeout(for: callId) stopDDPClientInternal() @@ -272,6 +277,9 @@ public final class VoipService: NSObject { } client.connect(host: payload.host) { connected in + guard ddpClient === client else { + return + } guard connected else { #if DEBUG print("[\(TAG)] DDP connection failed") @@ -281,6 +289,9 @@ public final class VoipService: NSObject { } client.login(token: credentials.userToken) { loggedIn in + guard ddpClient === client else { + return + } guard loggedIn else { #if DEBUG print("[\(TAG)] DDP login failed") @@ -295,6 +306,9 @@ public final class VoipService: NSObject { ] client.subscribe(name: "stream-notify-user", params: params) { subscribed in + guard ddpClient === client else { + return + } #if DEBUG print("[\(TAG)] DDP subscribe result: \(subscribed)") #endif From 159ffb6072fa3b692647542f1bd5943fabdb76c8 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 12 Mar 2026 15:31:31 -0300 Subject: [PATCH 17/23] add comments --- app/lib/services/restApi.ts | 2 +- app/lib/services/voip/MediaSessionInstance.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/lib/services/restApi.ts b/app/lib/services/restApi.ts index 392d363b2a6..fa9be470b4b 100644 --- a/app/lib/services/restApi.ts +++ b/app/lib/services/restApi.ts @@ -1021,7 +1021,7 @@ export const registerPushToken = async (): Promise => { return; } - // TODO: server version + // TODO: voice permission check and retry to avoid race condition if (isIOS && (!token || !voipToken)) { return; } diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index 3dade6a770d..750a3d9d7e2 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -34,7 +34,7 @@ class MediaSessionInstance { this.stop(); registerGlobals(); this.configureIceServers(); - + // prevent JS and native DDP clients from interfering with each other NativeVoipModule.stopNativeDDPClient(); mediaSessionStore.setWebRTCProcessorFactory( From 5bc5e828041d15ff2a40977c4c681ed4696ba5be Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 12 Mar 2026 16:18:18 -0300 Subject: [PATCH 18/23] Remove unnecessary DDP timeout and use call timeout instead --- .../reactnative/voip/VoipNotification.kt | 17 -------------- ios/Libraries/VoipService.swift | 23 +------------------ 2 files changed, 1 insertion(+), 39 deletions(-) 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 7ad399866bd..37ca6a63d51 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 @@ -54,11 +54,9 @@ class VoipNotification(private val context: Context) { // 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 const val INCOMING_CALL_LIFETIME_MS = 60_000L private val timeoutHandler = Handler(Looper.getMainLooper()) private val timeoutCallbacks = mutableMapOf() private var ddpClient: DDPClient? = null - private var ddpDisconnectRunnable: Runnable? = null /** * Cancels a VoIP notification by ID. @@ -214,7 +212,6 @@ class VoipNotification(private val context: Context) { putExtras(payload.toBundle()) } ) - stopDDPClientInternal() } } } @@ -253,7 +250,6 @@ class VoipNotification(private val context: Context) { } } - scheduleDDPSafetyTimeout() } @JvmStatic @@ -263,22 +259,9 @@ class VoipNotification(private val context: Context) { } private fun stopDDPClientInternal() { - ddpDisconnectRunnable?.let { timeoutHandler.removeCallbacks(it) } - ddpDisconnectRunnable = null ddpClient?.disconnect() ddpClient = null } - - private fun scheduleDDPSafetyTimeout() { - ddpDisconnectRunnable?.let { timeoutHandler.removeCallbacks(it) } - - val runnable = Runnable { - Log.d(TAG, "DDP safety timeout reached, disconnecting") - stopDDPClientInternal() - } - ddpDisconnectRunnable = runnable - timeoutHandler.postDelayed(runnable, INCOMING_CALL_LIFETIME_MS) - } } /** diff --git a/ios/Libraries/VoipService.swift b/ios/Libraries/VoipService.swift index 924ef777261..a0db913ee89 100644 --- a/ios/Libraries/VoipService.swift +++ b/ios/Libraries/VoipService.swift @@ -28,7 +28,6 @@ public final class VoipService: NSObject { private static var voipRegistry: PKPushRegistry? private static var incomingCallTimeouts: [String: DispatchWorkItem] = [:] private static var ddpClient: DDPClient? - private static var ddpDisconnectWorkItem: DispatchWorkItem? // MARK: - Static Methods (Called from VoipModule.mm and AppDelegate) @@ -203,6 +202,7 @@ public final class VoipService: NSObject { private static func handleIncomingCallTimeout(for payload: VoipPayload) { incomingCallTimeouts.removeValue(forKey: payload.callId) + stopDDPClientInternal() let callId = payload.callId let callUUID = payload.callUUID @@ -318,8 +318,6 @@ public final class VoipService: NSObject { } } } - - scheduleDDPSafetyTimeout() } /// Stops the native DDP listener. Called from JS when it takes over signaling. @@ -332,26 +330,7 @@ public final class VoipService: NSObject { } private static func stopDDPClientInternal() { - ddpDisconnectWorkItem?.cancel() - ddpDisconnectWorkItem = nil ddpClient?.disconnect() ddpClient = nil } - - private static func scheduleDDPSafetyTimeout() { - ddpDisconnectWorkItem?.cancel() - - let workItem = DispatchWorkItem { - #if DEBUG - print("[\(TAG)] DDP safety timeout reached, disconnecting") - #endif - stopDDPClientInternal() - } - - ddpDisconnectWorkItem = workItem - DispatchQueue.main.asyncAfter( - deadline: .now() + VoipPayload.INCOMING_CALL_LIFETIME_SEC, - execute: workItem - ) - } } From 366814f065653519f3bc5c24d272d7386a6eccd6 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 12 Mar 2026 17:30:33 -0300 Subject: [PATCH 19/23] Decline from notification on Android --- android/app/src/main/AndroidManifest.xml | 5 + .../chat/rocket/reactnative/voip/DDPClient.kt | 67 ++++++++++++- .../reactnative/voip/VoipNotification.kt | 95 ++++++++++++++++++- 3 files changed, 161 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2a4643d0d36..ca983f7a6e1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -128,6 +128,11 @@ android:excludeFromRecents="true" android:taskAffinity="chat.rocket.reactnative.voip" /> + + Unit + ) companion object { private const val TAG = "RocketChat.DDPClient" @@ -29,6 +30,7 @@ class DDPClient { private val mainHandler = Handler(Looper.getMainLooper()) private val pendingCallbacks = mutableMapOf Unit>() + private val queuedMethodCalls = mutableListOf() private var connectedCallback: ((Boolean) -> Unit)? = null var onCollectionMessage: ((JSONObject) -> Unit)? = null @@ -127,6 +129,7 @@ class DDPClient { Log.d(TAG, "Disconnecting") isConnected = false synchronized(pendingCallbacks) { pendingCallbacks.clear() } + clearQueuedMethodCalls() connectedCallback = null onCollectionMessage = null webSocket?.close(1000, null) @@ -148,6 +151,62 @@ class DDPClient { return ws.send(json.toString()) } + fun callMethod(method: String, params: JSONArray, callback: (Boolean) -> Unit) { + val msg = nextMessage("method").apply { + put("method", method) + put("params", params) + } + + val msgId = msg.getString("id") + + synchronized(pendingCallbacks) { + pendingCallbacks[msgId] = { data -> + synchronized(pendingCallbacks) { pendingCallbacks.remove(msgId) } + val hasError = data.has("error") + if (hasError) { + Log.e(TAG, "Method $method failed: ${data.opt("error")}") + } + mainHandler.post { callback(!hasError) } + } + } + + if (!send(msg)) { + synchronized(pendingCallbacks) { pendingCallbacks.remove(msgId) } + mainHandler.post { callback(false) } + } + } + + fun queueMethodCall(method: String, params: JSONArray, callback: (Boolean) -> Unit = {}) { + synchronized(queuedMethodCalls) { + queuedMethodCalls.add( + QueuedMethodCall( + method = method, + params = params, + callback = callback + ) + ) + } + } + + fun hasQueuedMethodCalls(): Boolean = + synchronized(queuedMethodCalls) { queuedMethodCalls.isNotEmpty() } + + fun flushQueuedMethodCalls() { + val queuedCalls = synchronized(queuedMethodCalls) { + queuedMethodCalls.toList().also { queuedMethodCalls.clear() } + } + + queuedCalls.forEach { queuedCall -> + callMethod(queuedCall.method, queuedCall.params, queuedCall.callback) + } + } + + fun clearQueuedMethodCalls() { + synchronized(queuedMethodCalls) { + queuedMethodCalls.clear() + } + } + private fun waitForConnected(timeoutMs: Long, callback: (Boolean) -> Unit) { connectedCallback = callback mainHandler.postDelayed({ 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 37ca6a63d51..0cb2098d614 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 @@ -57,6 +57,7 @@ class VoipNotification(private val context: Context) { private val timeoutHandler = Handler(Looper.getMainLooper()) private val timeoutCallbacks = mutableMapOf() private var ddpClient: DDPClient? = null + private var isDdpLoggedIn = false /** * Cancels a VoIP notification by ID. @@ -95,7 +96,6 @@ class VoipNotification(private val context: Context) { @JvmStatic fun cancelTimeout(callId: String) { - stopDDPClientInternal(); val timeoutRunnable = synchronized(timeoutCallbacks) { timeoutCallbacks.remove(callId) } @@ -115,6 +115,7 @@ class VoipNotification(private val context: Context) { putExtras(payload.toBundle()) } ) + stopDDPClientInternal() Log.d(TAG, "Timed out incoming VoIP call: ${payload.callId}") } @@ -126,9 +127,18 @@ class VoipNotification(private val context: Context) { fun handleDeclineAction(context: Context, payload: VoipPayload) { Log.d(TAG, "Decline action triggered for callId: ${payload.callId}") cancelTimeout(payload.callId) + if (isDdpLoggedIn) { + sendRejectSignal(context, payload) + } else { + queueRejectSignal(context, payload) + } rejectIncomingCall(payload.callId) cancelById(context, payload.notificationId) - // TODO: call restapi to decline the call + LocalBroadcastManager.getInstance(context).sendBroadcast( + Intent(ACTION_DISMISS).apply { + putExtras(payload.toBundle()) + } + ) } // TODO: unify these three functions and check VoiceConnectionService @@ -165,6 +175,78 @@ class VoipNotification(private val context: Context) { } } + private fun sendRejectSignal(context: Context, payload: VoipPayload) { + val client = ddpClient + if (client == null) { + Log.d(TAG, "Native DDP client unavailable, cannot send reject for ${payload.callId}") + return + } + + val params = buildRejectSignalParams(context, payload) ?: return + + client.callMethod("stream-notify-user", params) { success -> + Log.d(TAG, "Native reject signal result for ${payload.callId}: $success") + stopDDPClientInternal() + } + } + + private fun queueRejectSignal(context: Context, payload: VoipPayload) { + val client = ddpClient + if (client == null) { + Log.d(TAG, "Native DDP client unavailable, cannot queue reject for ${payload.callId}") + return + } + + val params = buildRejectSignalParams(context, payload) ?: return + + client.queueMethodCall("stream-notify-user", params) { success -> + Log.d(TAG, "Queued native reject signal result for ${payload.callId}: $success") + stopDDPClientInternal() + } + Log.d(TAG, "Queued native reject signal for ${payload.callId}") + } + + private fun flushPendingRejectSignalIfNeeded(): Boolean { + val client = ddpClient ?: return false + if (!client.hasQueuedMethodCalls()) { + return false + } + + client.flushQueuedMethodCalls() + return true + } + + private fun buildRejectSignalParams(context: Context, payload: VoipPayload): JSONArray? { + 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}") + 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}") + stopDDPClientInternal() + return null + } + + val signal = JSONObject().apply { + put("callId", payload.callId) + put("contractId", deviceId) + put("type", "answer") + put("answer", "reject") + } + + return JSONArray().apply { + put("$userId/media-calls") + put(signal.toString()) + } + } + // -- Native DDP Listener (Call End Detection) -- @JvmStatic @@ -185,6 +267,7 @@ class VoipNotification(private val context: Context) { val callId = payload.callId val client = DDPClient() ddpClient = client + isDdpLoggedIn = false Log.d(TAG, "Starting DDP listener for call $callId") @@ -212,6 +295,7 @@ class VoipNotification(private val context: Context) { putExtras(payload.toBundle()) } ) + stopDDPClientInternal() } } } @@ -233,6 +317,11 @@ class VoipNotification(private val context: Context) { return@login } + isDdpLoggedIn = true + if (flushPendingRejectSignalIfNeeded()) { + return@login + } + val params = JSONArray().apply { put("$userId/media-signal") put(JSONObject().apply { @@ -259,6 +348,8 @@ class VoipNotification(private val context: Context) { } private fun stopDDPClientInternal() { + isDdpLoggedIn = false + ddpClient?.clearQueuedMethodCalls() ddpClient?.disconnect() ddpClient = null } From 1c7d589a931f9b51cea79e512b224ac359140fc5 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 13 Mar 2026 09:59:47 -0300 Subject: [PATCH 20/23] Decline from iOS --- ios/Libraries/DDPClient.swift | 88 +++++++++++++++++ ios/Libraries/VoipService.swift | 168 +++++++++++++++++++++++++++++++- 2 files changed, 255 insertions(+), 1 deletion(-) diff --git a/ios/Libraries/DDPClient.swift b/ios/Libraries/DDPClient.swift index 395666368cd..0c1a2ffd634 100644 --- a/ios/Libraries/DDPClient.swift +++ b/ios/Libraries/DDPClient.swift @@ -3,8 +3,14 @@ import Foundation /// Minimal DDP WebSocket client for listening to Rocket.Chat media-signal events from native iOS. /// Only implements the subset needed to detect call hangup: connect, login, subscribe, and ping/pong. final class DDPClient { + private struct QueuedMethodCall { + let method: String + let params: [Any] + let completion: (Bool) -> Void + } private static let TAG = "RocketChat.DDPClient" + private let stateQueueKey = DispatchSpecificKey() private let stateQueue = DispatchQueue(label: "chat.rocket.reactnative.ddp-client") private var webSocketTask: URLSessionWebSocketTask? @@ -14,6 +20,10 @@ final class DDPClient { /// Called for every incoming DDP collection message (e.g. stream-notify-user). var onCollectionMessage: (([String: Any]) -> Void)? + + init() { + stateQueue.setSpecific(key: stateQueueKey, value: ()) + } // MARK: - Connect @@ -141,6 +151,7 @@ final class DDPClient { #endif self.isConnected = false self.pendingCallbacks.removeAll() + self.clearQueuedMethodCalls() self.connectedCallback = nil self.onCollectionMessage = nil self.webSocketTask?.cancel(with: .normalClosure, reason: nil) @@ -153,6 +164,7 @@ final class DDPClient { // MARK: - Private private var pendingCallbacks: [String: ([String: Any]) -> Void] = [:] + private var queuedMethodCalls: [QueuedMethodCall] = [] private var connectedCallback: ((Bool) -> Void)? private func nextMessage(msg: String, extra: [String: Any] = [:]) -> [String: Any] { @@ -182,6 +194,82 @@ final class DDPClient { } } } + + func callMethod(_ method: String, params: [Any], completion: @escaping (Bool) -> Void) { + stateQueue.async { + let msg = self.nextMessage(msg: "method", extra: [ + "method": method, + "params": params + ]) + + let msgId = msg["id"] as? String + + self.pendingCallbacks[msgId ?? ""] = { [weak self] data in + self?.pendingCallbacks.removeValue(forKey: msgId ?? "") + let hasError = data["error"] != nil + #if DEBUG + if hasError { + print("[\(Self.TAG)] Method \(method) failed: \(data["error"] ?? "unknown")") + } + #endif + completion(!hasError) + } + + self.send(msg) { [weak self] success in + guard let self else { return } + if !success { + self.stateQueue.async { + self.pendingCallbacks.removeValue(forKey: msgId ?? "") + completion(false) + } + } + } + } + } + + func queueMethodCall(_ method: String, params: [Any], completion: @escaping (Bool) -> Void = { _ in }) { + stateQueue.async { + self.queuedMethodCalls.append( + QueuedMethodCall( + method: method, + params: params, + completion: completion + ) + ) + } + } + + func hasQueuedMethodCalls() -> Bool { + if DispatchQueue.getSpecific(key: stateQueueKey) != nil { + return !queuedMethodCalls.isEmpty + } + + return stateQueue.sync { + !queuedMethodCalls.isEmpty + } + } + + func flushQueuedMethodCalls() { + stateQueue.async { + let queuedCalls = self.queuedMethodCalls + self.queuedMethodCalls.removeAll() + + queuedCalls.forEach { queuedCall in + self.callMethod(queuedCall.method, params: queuedCall.params, completion: queuedCall.completion) + } + } + } + + func clearQueuedMethodCalls() { + if DispatchQueue.getSpecific(key: stateQueueKey) != nil { + queuedMethodCalls.removeAll() + return + } + + stateQueue.async { + self.queuedMethodCalls.removeAll() + } + } private func waitForConnected(timeout: TimeInterval, completion: @escaping (Bool) -> Void) { connectedCallback = completion diff --git a/ios/Libraries/VoipService.swift b/ios/Libraries/VoipService.swift index a0db913ee89..e7463d24fa3 100644 --- a/ios/Libraries/VoipService.swift +++ b/ios/Libraries/VoipService.swift @@ -13,6 +13,15 @@ import PushKit */ @objc(VoipService) public final class VoipService: NSObject { + private struct ObservedIncomingCall { + let payload: VoipPayload + } + + private final class IncomingCallObserver: NSObject, CXCallObserverDelegate { + func callObserver(_ callObserver: CXCallObserver, callChanged call: CXCall) { + VoipService.handleObservedCallChanged(call) + } + } // MARK: - Constants @@ -28,6 +37,11 @@ public final class VoipService: NSObject { private static var voipRegistry: PKPushRegistry? private static var incomingCallTimeouts: [String: DispatchWorkItem] = [:] private static var ddpClient: DDPClient? + private static let callObserver = CXCallObserver() + private static let incomingCallObserver = IncomingCallObserver() + private static var isCallObserverConfigured = false + private static var observedIncomingCall: ObservedIncomingCall? + private static var isDdpLoggedIn = false // MARK: - Static Methods (Called from VoipModule.mm and AppDelegate) @@ -202,12 +216,13 @@ public final class VoipService: NSObject { private static func handleIncomingCallTimeout(for payload: VoipPayload) { incomingCallTimeouts.removeValue(forKey: payload.callId) + clearTrackedIncomingCall(for: payload.callUUID) stopDDPClientInternal() let callId = payload.callId let callUUID = payload.callUUID - let callObserver = CXCallObserver() + configureCallObserverIfNeeded() guard let call = callObserver.calls.first(where: { $0.uuid == callUUID }) else { return } @@ -238,6 +253,8 @@ public final class VoipService: NSObject { let deviceId = DeviceUID.uid() let client = DDPClient() ddpClient = client + isDdpLoggedIn = false + trackIncomingCall(payload) #if DEBUG print("[\(TAG)] Starting DDP listener for call \(callId)") @@ -270,6 +287,7 @@ public final class VoipService: NSObject { guard ddpClient === client else { return } + clearTrackedIncomingCall(for: payload.callUUID) RNCallKeep.endCall(withUUID: callId, reason: 3) cancelIncomingCallTimeout(for: callId) stopDDPClientInternal() @@ -277,6 +295,7 @@ public final class VoipService: NSObject { } client.connect(host: payload.host) { connected in + print("[\(TAG)] DDP connection callback") guard ddpClient === client else { return } @@ -300,6 +319,11 @@ public final class VoipService: NSObject { return } + isDdpLoggedIn = true + if flushPendingRejectSignalIfNeeded() { + return + } + let params: [Any] = [ "\(userId)/media-signal", ["useCollection": false, "args": [false]] @@ -330,7 +354,149 @@ public final class VoipService: NSObject { } private static func stopDDPClientInternal() { + isDdpLoggedIn = false + observedIncomingCall = nil + ddpClient?.clearQueuedMethodCalls() ddpClient?.disconnect() ddpClient = nil } + + private static func buildRejectMethodParams(payload: VoipPayload) -> [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)") + #endif + stopDDPClientInternal() + return nil + } + + let signal: [String: Any] = [ + "callId": payload.callId, + "contractId": DeviceUID.uid(), + "type": "answer", + "answer": "reject" + ] + + guard + let signalData = try? JSONSerialization.data(withJSONObject: signal), + let signalString = String(data: signalData, encoding: .utf8) + else { + stopDDPClientInternal() + return nil + } + + return ["\(credentials.userId)/media-calls", signalString] + } + + private static func sendRejectSignal(payload: VoipPayload) { + guard let client = ddpClient else { + #if DEBUG + print("[\(TAG)] Native DDP client unavailable, cannot send reject for \(payload.callId)") + #endif + return + } + + guard let params = buildRejectMethodParams(payload: payload) else { + return + } + + client.callMethod("stream-notify-user", params: params) { success in + #if DEBUG + print("[\(TAG)] Native reject signal result for \(payload.callId): \(success)") + #endif + stopDDPClientInternal() + } + } + + private static func queueRejectSignal(payload: VoipPayload) { + guard let client = ddpClient else { + #if DEBUG + print("[\(TAG)] Native DDP client unavailable, cannot queue reject for \(payload.callId)") + #endif + return + } + + guard let params = buildRejectMethodParams(payload: payload) else { + return + } + + client.queueMethodCall("stream-notify-user", params: params) { success in + #if DEBUG + print("[\(TAG)] Queued native reject signal result for \(payload.callId): \(success)") + #endif + stopDDPClientInternal() + } + } + + private static func flushPendingRejectSignalIfNeeded() -> Bool { + guard let client = ddpClient, client.hasQueuedMethodCalls() else { + return false + } + + client.flushQueuedMethodCalls() + return true + } + + private static func configureCallObserverIfNeeded() { + guard !isCallObserverConfigured else { + return + } + + callObserver.setDelegate(incomingCallObserver, queue: .main) + isCallObserverConfigured = true + } + + private static func trackIncomingCall(_ payload: VoipPayload) { + let trackCall = { + configureCallObserverIfNeeded() + observedIncomingCall = ObservedIncomingCall(payload: payload) + } + + if Thread.isMainThread { + trackCall() + } else { + DispatchQueue.main.async(execute: trackCall) + } + } + + private static func clearTrackedIncomingCall(for callUUID: UUID) { + let clearCall = { + guard observedIncomingCall?.payload.callUUID == callUUID else { + return + } + + observedIncomingCall = nil + } + + if Thread.isMainThread { + clearCall() + } else { + DispatchQueue.main.async(execute: clearCall) + } + } + + private static func handleObservedCallChanged(_ call: CXCall) { + guard let observedCall = observedIncomingCall, observedCall.payload.callUUID == call.uuid else { + return + } + + if call.hasConnected { + observedIncomingCall = nil + return + } + + guard call.hasEnded else { + return + } + + observedIncomingCall = nil + cancelIncomingCallTimeout(for: observedCall.payload.callId) + + if isDdpLoggedIn { + sendRejectSignal(payload: observedCall.payload) + } else { + queueRejectSignal(payload: observedCall.payload) + } + } } From 106ec8022bc16a32b805a9b1a621f17690e6f649 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 13 Mar 2026 12:01:18 -0300 Subject: [PATCH 21/23] Review --- .../chat/rocket/reactnative/voip/DDPClient.kt | 28 ++++-- app/lib/services/restApi.test.ts | 87 ------------------- ios/Libraries/DDPClient.swift | 13 ++- ios/Libraries/VoipPayload.swift | 2 +- 4 files changed, 32 insertions(+), 98 deletions(-) delete mode 100644 app/lib/services/restApi.test.ts diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt index 0edbee8fe36..66511265fe3 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt @@ -67,11 +67,13 @@ class DDPClient { override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { Log.e(TAG, "WebSocket failure: ${t.message}") + isConnected = false mainHandler.post { callback(false) } } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { Log.d(TAG, "WebSocket closed: $code $reason") + isConnected = false } }) } @@ -113,14 +115,20 @@ class DDPClient { val msgId = msg.getString("id") synchronized(pendingCallbacks) { - pendingCallbacks[msgId] = { + pendingCallbacks[msgId] = { data -> synchronized(pendingCallbacks) { pendingCallbacks.remove(msgId) } - Log.d(TAG, "Subscribed to $name") - mainHandler.post { callback(true) } + val didSubscribe = data.optString("msg") == "ready" && !data.has("error") + if (didSubscribe) { + Log.d(TAG, "Subscribed to $name") + } else { + Log.e(TAG, "Failed to subscribe to $name: ${data.opt("error") ?: "nosub"}") + } + mainHandler.post { callback(didSubscribe) } } } if (!send(msg)) { + synchronized(pendingCallbacks) { pendingCallbacks.remove(msgId) } mainHandler.post { callback(false) } } } @@ -245,10 +253,16 @@ class DDPClient { "ready" -> { val subs = json.optJSONArray("subs") - val first = subs?.optString(0) - if (first != null) { - val cb = synchronized(pendingCallbacks) { pendingCallbacks[first] } - cb?.invoke(json) + if (subs != null) { + for (index in 0 until subs.length()) { + val subId = subs.optString(index) + if (subId.isEmpty()) { + continue + } + + val cb = synchronized(pendingCallbacks) { pendingCallbacks[subId] } + cb?.invoke(json) + } } } diff --git a/app/lib/services/restApi.test.ts b/app/lib/services/restApi.test.ts deleted file mode 100644 index 37985fcafa7..00000000000 --- a/app/lib/services/restApi.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { getDeviceToken } from '../notifications'; -import NativeVoipModule from '../native/NativeVoip'; -import { store as reduxStore } from '../store/auxStore'; -import sdk from './sdk'; -import { registerPushToken } from './restApi'; - -jest.mock('../notifications', () => ({ - getDeviceToken: jest.fn() -})); - -jest.mock('../native/NativeVoip', () => ({ - __esModule: true, - default: { - getLastVoipToken: jest.fn() - } -})); - -jest.mock('../store/auxStore', () => ({ - store: { - getState: jest.fn() - } -})); - -jest.mock('../methods/helpers', () => ({ - compareServerVersion: jest.fn(), - getBundleId: 'chat.rocket.reactnative', - isIOS: true -})); - -jest.mock('./sdk', () => ({ - __esModule: true, - default: { - post: jest.fn(), - current: { - client: { host: 'https://chat.example.com' }, - currentLogin: { authToken: 'auth-token' } - } - } -})); - -describe('registerPushToken', () => { - const mockedGetDeviceToken = getDeviceToken as jest.Mock; - const mockedGetLastVoipToken = NativeVoipModule.getLastVoipToken as jest.Mock; - const mockedGetState = reduxStore.getState as jest.Mock; - const mockedPost = sdk.post as jest.Mock; - - beforeEach(() => { - jest.clearAllMocks(); - - mockedGetState.mockReturnValue({ login: { isAuthenticated: true } }); - mockedGetDeviceToken.mockReturnValue('apn-token'); - mockedGetLastVoipToken.mockReturnValue('voip-token'); - mockedPost.mockResolvedValue({}); - sdk.current.client.host = 'https://chat.example.com'; - sdk.current.currentLogin = { authToken: 'auth-token' }; - }); - - it('waits for both iOS tokens before registering', async () => { - mockedGetLastVoipToken.mockReturnValue(''); - - await registerPushToken(); - - expect(mockedPost).not.toHaveBeenCalled(); - }); - - it('skips duplicate successful iOS registrations', async () => { - await registerPushToken(); - await registerPushToken(); - - expect(mockedPost).toHaveBeenCalledTimes(1); - expect(mockedPost).toHaveBeenCalledWith('push.token', { - value: 'apn-token', - type: 'apn', - appName: 'chat.rocket.reactnative', - voipToken: 'voip-token' - }); - }); - - it('retries the same payload after a failed request', async () => { - mockedPost.mockRejectedValueOnce(new Error('network')); - - await registerPushToken(); - await registerPushToken(); - - expect(mockedPost).toHaveBeenCalledTimes(2); - }); -}); diff --git a/ios/Libraries/DDPClient.swift b/ios/Libraries/DDPClient.swift index 0c1a2ffd634..ffebef6df48 100644 --- a/ios/Libraries/DDPClient.swift +++ b/ios/Libraries/DDPClient.swift @@ -182,8 +182,13 @@ final class DDPClient { completion(false) return } + + guard let webSocketTask else { + completion(false) + return + } - webSocketTask?.send(.string(string)) { error in + webSocketTask.send(.string(string)) { error in if let error = error { #if DEBUG print("[\(Self.TAG)] Send error: \(error.localizedDescription)") @@ -334,8 +339,10 @@ final class DDPClient { } case "ready": - if let subs = json["subs"] as? [String], let first = subs.first, let cb = pendingCallbacks[first] { - cb(json) + if let subs = json["subs"] as? [String] { + subs.forEach { subId in + pendingCallbacks[subId]?(json) + } } case "changed", "added", "removed": diff --git a/ios/Libraries/VoipPayload.swift b/ios/Libraries/VoipPayload.swift index 5631bc3187f..f7769e97222 100644 --- a/ios/Libraries/VoipPayload.swift +++ b/ios/Libraries/VoipPayload.swift @@ -75,7 +75,7 @@ private struct RemoteVoipPayload { /// Data structure for initial events payload @objc(VoipPayload) public class VoipPayload: NSObject { - // the amount of time in milliseconds that an incoming call will be kept alive + // the amount of time in seconds that an incoming call will be kept alive @objc public static let INCOMING_CALL_LIFETIME_SEC: TimeInterval = 60 @objc public let callId: String From ecb67b3d745f7ed1330ead8dbe6cc818709498b2 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 13 Mar 2026 15:00:51 -0300 Subject: [PATCH 22/23] Fix hangup native socket --- .../RCFirebaseMessagingService.kt | 1 + .../reactnative/voip/VoipNotification.kt | 34 ++++++++++++------- ios/Libraries/VoipService.swift | 16 ++++++--- 3 files changed, 35 insertions(+), 16 deletions(-) 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 e3c2d9c5b64..46253da2788 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 @@ -23,6 +23,7 @@ class RCFirebaseMessagingService : FirebaseMessagingService() { } override fun onMessageReceived(remoteMessage: RemoteMessage) { + // TODO: remove data Log.d(TAG, "FCM message received from: ${remoteMessage.from} data: ${remoteMessage.data}") val data = remoteMessage.data 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 0cb2098d614..96a0d5e1bcd 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 @@ -282,20 +282,29 @@ class VoipNotification(private val context: Context) { if (firstArg != null) { val signalType = firstArg.optString("type") val signalCallId = firstArg.optString("callId") + val signalNotification = firstArg.optString("notification") val signedContractId = firstArg.optString("signedContractId") - if (signalType == "notification" && signalCallId == callId && signedContractId.isNullOrEmpty() && signedContractId != deviceId) { - val appContext = context.applicationContext - Handler(Looper.getMainLooper()).post { - cancelTimeout(callId) - disconnectIncomingCall(callId, false) - cancelById(appContext, payload.notificationId) - LocalBroadcastManager.getInstance(appContext).sendBroadcast( - Intent(ACTION_DISMISS).apply { - putExtras(payload.toBundle()) - } - ) - stopDDPClientInternal() + if (signalCallId == callId) { + if (signalType == "notification" && + ( + // accepted from other device + (!signedContractId.isNullOrEmpty() && signedContractId != deviceId) || + // hung up by other device + (signalNotification == "hangup") + )) { + val appContext = context.applicationContext + Handler(Looper.getMainLooper()).post { + cancelTimeout(callId) + disconnectIncomingCall(callId, false) + cancelById(appContext, payload.notificationId) + LocalBroadcastManager.getInstance(appContext).sendBroadcast( + Intent(ACTION_DISMISS).apply { + putExtras(payload.toBundle()) + } + ) + stopDDPClientInternal() + } } } } @@ -389,6 +398,7 @@ class VoipNotification(private val context: Context) { CHANNEL_NAME, NotificationManager.IMPORTANCE_HIGH ).apply { + // TODO: i18n description = "Incoming VoIP calls" enableLights(true) enableVibration(true) diff --git a/ios/Libraries/VoipService.swift b/ios/Libraries/VoipService.swift index e7463d24fa3..8932ba6ab82 100644 --- a/ios/Libraries/VoipService.swift +++ b/ios/Libraries/VoipService.swift @@ -272,15 +272,23 @@ public final class VoipService: NSObject { let signalType = firstArg["type"] as? String, signalType == "notification", let signalCallId = firstArg["callId"] as? String, - signalCallId == callId, - let signedContractId = firstArg["signedContractId"] as? String, - signedContractId != deviceId + signalCallId == callId else { return } + let signalNotification = firstArg["notification"] as? String + let signedContractId = firstArg["signedContractId"] as? String + + let isHangup = signalNotification == "hangup" + let isAcceptedOnAnotherDevice = signedContractId != nil && signedContractId != deviceId + + guard isHangup || isAcceptedOnAnotherDevice else { + return + } + #if DEBUG - print("[\(TAG)] DDP received hangup for call \(callId)") + print("[\(TAG)] DDP received hangup for call or accepted from another device \(callId)") #endif DispatchQueue.main.async { From 7f2e4fb3675fc75088701c00ebf96116d920fb48 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 13 Mar 2026 17:51:16 -0300 Subject: [PATCH 23/23] Minor fixes --- app/definitions/rest/v1/push.ts | 2 +- app/lib/services/restApi.ts | 13 ++++++++----- app/lib/services/voip/MediaSessionInstance.ts | 6 ++++++ app/lib/services/voip/useCallStore.ts | 1 + ios/Libraries/AppDelegate+Voip.swift | 8 ++++++-- ios/Libraries/VoipService.swift | 1 - 6 files changed, 22 insertions(+), 9 deletions(-) diff --git a/app/definitions/rest/v1/push.ts b/app/definitions/rest/v1/push.ts index d28a8371893..fce3e86cd02 100644 --- a/app/definitions/rest/v1/push.ts +++ b/app/definitions/rest/v1/push.ts @@ -6,7 +6,7 @@ type TPushInfo = { export type PushEndpoints = { 'push.token': { - POST: (params: { value: string; type: string; appName: string; voipToken?: string }) => { + POST: (params: { id: string; value: string; type: string; appName: string; voipToken?: string }) => { result: { id: string; token: string; diff --git a/app/lib/services/restApi.ts b/app/lib/services/restApi.ts index fa9be470b4b..cc529d0a6b4 100644 --- a/app/lib/services/restApi.ts +++ b/app/lib/services/restApi.ts @@ -1017,6 +1017,10 @@ export const registerPushToken = async (): Promise => { // Always returns an empty string on Android const voipToken = NativeVoipModule.getLastVoipToken(); + if (!token) { + return; + } + if (token === lastToken && voipToken === lastVoipToken) { return; } @@ -1027,18 +1031,17 @@ export const registerPushToken = async (): Promise => { } let data: TRegisterPushTokenData = { - id: '', + id: await getUniqueId(), value: '', type: '', - appName: '' + appName: getBundleId }; if (token) { const type = isIOS ? 'apn' : 'gcm'; data = { - id: await getUniqueId(), + ...data, value: token, - type, - appName: getBundleId + type }; } if (voipToken) { diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index 750a3d9d7e2..02e32e961a1 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -8,6 +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 { mediaSessionStore } from './MediaSessionStore'; import { useCallStore } from './useCallStore'; @@ -61,6 +62,11 @@ 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 + } }); this.instance?.on('newCall', ({ call }: { call: IClientMediaCall }) => { diff --git a/app/lib/services/voip/useCallStore.ts b/app/lib/services/voip/useCallStore.ts index abf2e1eb586..bcd78534605 100644 --- a/app/lib/services/voip/useCallStore.ts +++ b/app/lib/services/voip/useCallStore.ts @@ -172,6 +172,7 @@ export const useCallStore = create((set, get) => ({ set({ dialpadValue: newValue }); }, + // TODO: do it here or in MediaSessionInstance? endCall: () => { const { call, callId } = get(); diff --git a/ios/Libraries/AppDelegate+Voip.swift b/ios/Libraries/AppDelegate+Voip.swift index 084254a1002..6ceb69013a0 100644 --- a/ios/Libraries/AppDelegate+Voip.swift +++ b/ios/Libraries/AppDelegate+Voip.swift @@ -21,7 +21,9 @@ extension AppDelegate: PKPushRegistryDelegate { let payloadDict = payload.dictionaryPayload guard let voipPayload = VoipPayload.fromDictionary(payloadDict) else { - print("Failed to parse incoming VoIP payload") + #if DEBUG + print("[\(TAG)] Failed to parse incoming VoIP payload") + #endif completion() return } @@ -29,7 +31,9 @@ extension AppDelegate: PKPushRegistryDelegate { let callId = voipPayload.callId let caller = voipPayload.caller guard !voipPayload.isExpired() else { - print("Skipping expired or invalid VoIP payload for callId: \(callId)") + #if DEBUG + print("[\(TAG)] Skipping expired or invalid VoIP payload for callId: \(callId)") + #endif completion() return } diff --git a/ios/Libraries/VoipService.swift b/ios/Libraries/VoipService.swift index 8932ba6ab82..8db99f60731 100644 --- a/ios/Libraries/VoipService.swift +++ b/ios/Libraries/VoipService.swift @@ -303,7 +303,6 @@ public final class VoipService: NSObject { } client.connect(host: payload.host) { connected in - print("[\(TAG)] DDP connection callback") guard ddpClient === client else { return }