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" /> + + - putString(key, value) - } - } - 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 } // 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/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/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..66511265fe3 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt @@ -0,0 +1,308 @@ +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 + +class DDPClient { + private data class QueuedMethodCall( + val method: String, + val params: JSONArray, + val callback: (Boolean) -> Unit + ) + + 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 val queuedMethodCalls = mutableListOf() + 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}") + isConnected = false + mainHandler.post { callback(false) } + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Log.d(TAG, "WebSocket closed: $code $reason") + isConnected = false + } + }) + } + + 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] = { data -> + synchronized(pendingCallbacks) { pendingCallbacks.remove(msgId) } + 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) } + } + } + + fun disconnect() { + Log.d(TAG, "Disconnecting") + isConnected = false + synchronized(pendingCallbacks) { pendingCallbacks.clear() } + clearQueuedMethodCalls() + 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()) + } + + 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({ + 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") + 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) + } + } + } + + "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/IncomingCallActivity.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/IncomingCallActivity.kt index 0cf6b64a9d8..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 @@ -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 @@ -21,8 +26,8 @@ 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 +import chat.rocket.reactnative.notification.Ejson /** * Full-screen Activity displayed when an incoming VoIP call arrives. @@ -36,6 +41,21 @@ class IncomingCallActivity : Activity() { private var ringtone: Ringtone? = null private var voipPayload: VoipPayload? = null + private var isCallStateReceiverRegistered = false + private val timeoutHandler = Handler(Looper.getMainLooper()) + private var timeoutRunnable: Runnable? = null + 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() + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -75,11 +95,18 @@ 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() setupButtons(voipPayload) + scheduleTimeout(voipPayload) + val intentFilter = IntentFilter().apply { + addAction(VoipNotification.ACTION_TIMEOUT) + addAction(VoipNotification.ACTION_DISMISS) + } + LocalBroadcastManager.getInstance(this).registerReceiver(callStateReceiver, intentFilter) + isCallStateReceiverRegistered = true } private fun applyNavigationBar() { @@ -152,8 +179,6 @@ 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) @@ -232,8 +257,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 - callUUID: ${payload.callUUID}") + Log.d(TAG, "Call accepted - callId: ${payload.callId}") + clearTimeout() + VoipNotification.cancelTimeout(payload.callId) stopRingtone() // Launch MainActivity with call data @@ -247,19 +295,22 @@ class IncomingCallActivity : Activity() { } private fun handleDecline(payload: VoipPayload) { - Log.d(TAG, "Call declined - callUUID: ${payload.callUUID}") + 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 (isCallStateReceiverRegistered) { + LocalBroadcastManager.getInstance(this).unregisterReceiver(callStateReceiver) + isCallStateReceiverRegistered = 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 099238129c0..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 @@ -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,9 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo clearInitialEventsInternal() } + // No-op on Android - FCM handles push notifications + override fun getLastVoipToken(): String = "" + /** * Registers for VoIP push token. * No-op on Android - uses FCM for push notifications. @@ -110,6 +111,11 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo Log.d(TAG, "registerVoipToken called (no-op on Android)") } + override fun stopNativeDDPClient() { + Log.d(TAG, "stopNativeDDPClient called, stopping native DDP client") + VoipNotification.stopDDPClient() + } + /** * Required for NativeEventEmitter in TurboModules. * Called when JS starts listening to events. 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..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 @@ -12,14 +12,23 @@ 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.provider.Settings 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 +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. @@ -39,9 +48,16 @@ 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" + private const val DISCONNECT_REASON_MISSED = 6 + 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. @@ -53,14 +69,298 @@ 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()) + } + ) + stopDDPClientInternal() + 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. */ @JvmStatic fun handleDeclineAction(context: Context, payload: VoipPayload) { - Log.d(TAG, "Decline action triggered for callUUID: ${payload.callUUID}") - // TODO: call restapi to decline the call + 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) + LocalBroadcastManager.getInstance(context).sendBroadcast( + Intent(ACTION_DISMISS).apply { + putExtras(payload.toBundle()) + } + ) + } + + // TODO: unify these three functions and check VoiceConnectionService + 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() + } + } + + 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() + } + } + + 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 + 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 + isDdpLoggedIn = false + + 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 signalNotification = firstArg.optString("notification") + val signedContractId = firstArg.optString("signedContractId") + + 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() + } + } + } + } + } + } + } + + 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 + } + + isDdpLoggedIn = true + if (flushPendingRejectSignalIfNeeded()) { + 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() + } + } + } + } + + } + + @JvmStatic + fun stopDDPClient() { + Log.d(TAG, "stopDDPClient called from JS") + stopDDPClientInternal() + } + + private fun stopDDPClientInternal() { + isDdpLoggedIn = false + ddpClient?.clearQueuedMethodCalls() + ddpClient?.disconnect() + ddpClient = null } } @@ -81,6 +381,13 @@ class VoipNotification(private val context: Context) { createNotificationChannel() } + fun onMessageReceived(voipPayload: VoipPayload) { + when { + voipPayload.isVoipIncomingCall() -> showIncomingCall(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. */ @@ -91,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) @@ -119,16 +427,26 @@ class VoipNotification(private val context: Context) { fun showIncomingCall(voipPayload: VoipPayload) { val callId = voipPayload.callId val caller = voipPayload.caller - val callUUID = voipPayload.callUUID + if (voipPayload.getRemainingLifetimeMs() == null) { + Log.w(TAG, "Skipping incoming VoIP call without a valid createdAt timestamp - callId: $callId") + return + } - Log.d(TAG, "Showing incoming VoIP call - callId: $callId, callUUID: $callUUID, caller: $caller") + 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") // 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) + scheduleTimeout(context, voipPayload) + startListeningForCallEnd(context, voipPayload) } /** @@ -139,11 +457,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 +487,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) { @@ -197,6 +513,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") @@ -259,6 +580,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 e9971eca91a..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 @@ -1,10 +1,21 @@ 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 -import chat.rocket.reactnative.utils.CallIdUUID +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +enum class VoipPushType(val value: String) { + INCOMING_CALL("incoming_call"); + + companion object { + fun from(value: String?): VoipPushType? = entries.firstOrNull { it.value == value } + } +} data class VoipPayload( @SerializedName("callId") @@ -24,12 +35,28 @@ data class VoipPayload( @SerializedName("hostName") val hostName: String, + + @SerializedName("avatarUrl") + val avatarUrl: String?, + + @SerializedName("createdAt") + val createdAt: String?, ) { val notificationId: Int = callId.hashCode() - val callUUID: String = CallIdUUID.generateUUIDv5(callId) + val pushType: VoipPushType? + get() = VoipPushType.from(type) + + 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() + return pushType == VoipPushType.INCOMING_CALL && + callId.isNotBlank() && + caller.isNotBlank() && + host.isNotBlank() } fun toBundle(): Bundle { @@ -40,7 +67,8 @@ data class VoipPayload( putString("host", host) putString("type", type) putString("hostName", hostName) - putString("callUUID", callUUID) + putString("avatarUrl", avatarUrl) + putString("createdAt", createdAt) putInt("notificationId", notificationId) // Useful flag for MainActivity to know it's handling a VoIP action putBoolean("voipAction", true) @@ -55,34 +83,142 @@ data class VoipPayload( putString("host", host) putString("type", type) putString("hostName", hostName) - putString("callUUID", callUUID) + 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 remainingLifetimeMs?.let { it <= 0L } ?: true + } + 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( + 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") + val name: String? = null, + + @SerializedName("username") + val username: 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, + + @SerializedName("createdAt") + val createdAt: String? = null, + ) { + fun toVoipPayload(): 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, + caller = caller?.name.orEmpty(), + username = caller?.username ?: username.orEmpty(), + host = host.orEmpty(), + type = payloadType, + hostName = hostName.orEmpty(), + avatarUrl = caller?.avatarUrl, + createdAt = payloadCreatedAt, + ) + } + } + 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? { 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 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 hostName = bundle.getString("hostName") ?: return null + val avatarUrl = bundle.getString("avatarUrl") + val createdAt = bundle.getString("createdAt")?.takeUnless { it.isBlank() } ?: return null + + if (VoipPushType.from(type) == null) { + return null + } + + return VoipPayload(callId, caller, username, host, type, hostName, avatarUrl, createdAt) + } + + private fun parseRemotePayload(data: Map): RemoteVoipPayload? { + val rawPayload = data["ejson"] + if (rawPayload.isNullOrBlank() || rawPayload == "{}") { + return null + } + + return try { + gson.fromJson(rawPayload, RemoteVoipPayload::class.java) + } catch (_: Exception) { + null + } + } + + private fun parseCreatedAtMs(value: String?): Long? { + if (value.isNullOrBlank()) { + return null + } + + for (formatter in isoDateFormats) { + val parsed = synchronized(formatter) { + runCatching { formatter.parse(value) }.getOrNull() + } + if (parsed != null) { + return parsed.time + } + } - return VoipPayload(callId, caller, username, host, type, hostName) + return null } } } \ No newline at end of file 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 { + POST: (params: { id: string; value: string; type: string; appName: string; voipToken?: string }) => { result: { id: string; token: string; 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' } 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/native/NativeVoip.ts b/app/lib/native/NativeVoip.ts index 2b39ea631dd..d47ba83ec0c 100644 --- a/app/lib/native/NativeVoip.ts +++ b/app/lib/native/NativeVoip.ts @@ -21,6 +21,20 @@ 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; + + /** + * 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. @@ -36,4 +50,16 @@ 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: () => '', + stopNativeDDPClient: () => undefined, + 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.ts b/app/lib/services/restApi.ts index 9f543c0bbbf..cc529d0a6b4 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,41 +1002,68 @@ export const editMessage = async (message: Pick - new Promise(async resolve => { - const token = getDeviceToken(); - if (token) { - const type = isIOS ? 'apn' : 'gcm'; - const data = { - 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); - } - } - return resolve(); - }); +let lastToken = ''; +let lastVoipToken = ''; + +type TRegisterPushTokenData = { + id: string; + value: string; + type: string; + appName: string; + voipToken?: string; +}; +export const registerPushToken = async (): Promise => { + const token = getDeviceToken(); + // Always returns an empty string on Android + const voipToken = NativeVoipModule.getLastVoipToken(); + + if (!token) { + return; + } + + if (token === lastToken && voipToken === lastVoipToken) { + return; + } + + // TODO: voice permission check and retry to avoid race condition + if (isIOS && (!token || !voipToken)) { + return; + } + + let data: TRegisterPushTokenData = { + id: await getUniqueId(), + value: '', + type: '', + appName: getBundleId + }; + if (token) { + const type = isIOS ? 'apn' : 'gcm'; + data = { + ...data, + value: token, + type + }; + } + if (voipToken) { + data.voipToken = voipToken; + } + + 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 }); } diff --git a/app/lib/services/voip/MediaCallEvents.ts b/app/lib/services/voip/MediaCallEvents.ts index d04733c33d4..141dcbfdd24 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); + }); }) ); @@ -55,15 +57,14 @@ export const setupMediaCallEvents = (): (() => 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 +111,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 === callUUID) { wasAnswered = true; console.log(`${TAG} Call was already answered via CallKit`); break; @@ -123,12 +124,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..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'; @@ -15,7 +16,7 @@ import { store } from '../../store/auxStore'; import sdk from '../sdk'; import Navigation from '../../navigation/appNavigation'; import { parseStringToIceServers } from './parseStringToIceServers'; -import CallIdUUIDModule from '../../native/NativeCallIdUUID'; +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 { this.stop(); registerGlobals(); this.configureIceServers(); + // prevent JS and native DDP clients from interfering with each other + NativeVoipModule.stopNativeDDPClient(); mediaSessionStore.setWebRTCProcessorFactory( (config: WebRTCProcessorConfig) => @@ -59,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 }) => { @@ -67,43 +75,40 @@ 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); - 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 +125,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/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/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/app/lib/services/voip/useCallStore.ts b/app/lib/services/voip/useCallStore.ts index 56bb1c19717..bcd78534605 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'); } }, @@ -172,15 +172,16 @@ export const useCallStore = create((set, get) => ({ set({ dialpadValue: newValue }); }, + // TODO: do it here or in MediaSessionInstance? 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 Void) { - let callId = payload.dictionaryPayload["callId"] as? String - let caller = payload.dictionaryPayload["caller"] as? String - - guard let callId = callId else { - completion() - return - } - - // Convert callId to deterministic UUID v5 for CallKit - let callIdUUID = CallIdUUID.generateUUIDv5(from: callId) - - // Store pending call data in our native module - VoipService.didReceiveIncomingPush(with: payload, forType: type.rawValue) - - RNCallKeep.reportNewIncomingCall( - callIdUUID, - 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..6ceb69013a0 --- /dev/null +++ b/ios/Libraries/AppDelegate+Voip.swift @@ -0,0 +1,59 @@ +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 { + #if DEBUG + print("[\(TAG)] Failed to parse incoming VoIP payload") + #endif + completion() + return + } + + let callId = voipPayload.callId + let caller = voipPayload.caller + guard !voipPayload.isExpired() else { + #if DEBUG + print("[\(TAG)] Skipping expired or invalid VoIP payload for callId: \(callId)") + #endif + completion() + return + } + + VoipService.prepareIncomingCall(voipPayload) + + RNCallKeep.reportNewIncomingCall( + callId, + handle: caller, + handleType: "generic", + hasVideo: false, + localizedCallerName: caller, + supportsHolding: true, + supportsDTMF: true, + supportsGrouping: false, + supportsUngrouping: false, + fromPushKit: true, + payload: payloadDict, + withCompletionHandler: {} + ) + completion() + } +} 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/Libraries/DDPClient.swift b/ios/Libraries/DDPClient.swift new file mode 100644 index 00000000000..ffebef6df48 --- /dev/null +++ b/ios/Libraries/DDPClient.swift @@ -0,0 +1,391 @@ +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? + 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)? + + init() { + stateQueue.setSpecific(key: stateQueueKey, value: ()) + } + + // MARK: - Connect + + func connect(host: String, completion: @escaping (Bool) -> Void) { + 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)] Connecting to \(wsUrl)") + #endif + + 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) + } + } + } + } + + // MARK: - Login + + func login(token: String, completion: @escaping (Bool) -> Void) { + 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) + } + } + } + } + } + + // MARK: - Subscribe + + func subscribe(name: String, params: [Any], completion: @escaping (Bool) -> Void) { + 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() { + stateQueue.async { + #if DEBUG + print("[\(Self.TAG)] Disconnecting") + #endif + self.isConnected = false + self.pendingCallbacks.removeAll() + self.clearQueuedMethodCalls() + self.connectedCallback = nil + self.onCollectionMessage = nil + self.webSocketTask?.cancel(with: .normalClosure, reason: nil) + self.webSocketTask = nil + self.urlSession?.invalidateAndCancel() + self.urlSession = nil + } + } + + // 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] { + 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 + } + + guard let webSocketTask 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) + } + } + } + + 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 + DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self] in + 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(task: URLSessionWebSocketTask) { + task.receive { [weak self] result in + self?.stateQueue.async { + guard let self = self, let currentTask = self.webSocketTask, task === currentTask else { return } + + 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 + } + } + } + } + + 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] { + subs.forEach { subId in + pendingCallbacks[subId]?(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 2c3ff8cda89..bb2f6953305 100644 --- a/ios/Libraries/VoipModule.mm +++ b/ios/Libraries/VoipModule.mm @@ -11,6 +11,8 @@ @interface VoipService : NSObject + (void)voipRegistration; + (NSDictionary * _Nullable)getInitialEvents; + (void)clearInitialEvents; ++ (NSString * _Nonnull)getLastVoipToken; ++ (void)stopDDPClient; @end @implementation VoipModule { @@ -89,6 +91,14 @@ - (void)clearInitialEvents { [VoipService clearInitialEvents]; } +- (NSString * _Nonnull)getLastVoipToken { + return [VoipService getLastVoipToken]; +} + +- (void)stopNativeDDPClient { + [VoipService stopDDPClient]; +} + - (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..f7769e97222 --- /dev/null +++ b/ios/Libraries/VoipPayload.swift @@ -0,0 +1,210 @@ +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 + ) + } +} + +private struct RemoteVoipPayload { + let callId: String? + let caller: RemoteCaller? + let username: String? + let host: String? + 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) + + 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, + createdAt: payload["createdAt"] as? String + ) + } + + func toVoipPayload() -> VoipPayload? { + guard notificationType == "voip" else { + return nil + } + + guard + let payloadCallId = callId, + let payloadCallUUID = UUID(uuidString: payloadCallId), + let payloadCaller = caller?.name, + let payloadUsername = caller?.username ?? username, + let payloadHost = host, + let payloadType = type, + let payloadHostName = hostName, + let payloadCreatedAt = createdAt, + !payloadCreatedAt.isEmpty + else { + return nil + } + + return VoipPayload( + callId: payloadCallId, + callUUID: payloadCallUUID, + caller: payloadCaller, + username: payloadUsername, + host: payloadHost, + type: payloadType, + hostName: payloadHostName, + avatarUrl: caller?.avatarUrl, + createdAt: payloadCreatedAt + ) + } +} + +/// Data structure for initial events payload +@objc(VoipPayload) +public class VoipPayload: NSObject { + // 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 + 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 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?) { + 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() + } + + @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(), + "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() { + 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) + } + + 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 05510d27a83..8db99f60731 100644 --- a/ios/Libraries/VoipService.swift +++ b/ios/Libraries/VoipService.swift @@ -1,3 +1,4 @@ +import CallKit import Foundation import PushKit @@ -12,18 +13,35 @@ 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 - private static let TAG = "RocketChat.VoipModule" + private static let TAG = "RocketChat.VoipService" + 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? + 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) @@ -64,7 +82,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,35 +104,37 @@ public final class VoipService: NSObject { userInfo: ["token": token] ) } - - /// Called from AppDelegate when a VoIP push initial events are received + + /// 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 didReceiveIncomingPush(with payload: PKPushPayload, forType type: String) { + public static func invalidatePushToken() { + lastVoipToken = "" + storage.removeValue(forKey: voipTokenStorageKey) + #if DEBUG - print("[\(TAG)] didReceiveIncomingPush payload: \(payload.dictionaryPayload)") + print("[\(TAG)] Invalidated VoIP token") #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) + startListeningForCallEnd(payload: 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]? { @@ -113,9 +142,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 } @@ -135,75 +162,348 @@ 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 { + if lastVoipToken.isEmpty { + lastVoipToken = loadPersistedVoipToken() + } return lastVoipToken } -} -// MARK: - VoipPayload + private static func loadPersistedVoipToken() -> String { + return storage.string(forKey: voipTokenStorageKey) ?? "" + } -/// 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 persistVoipToken(_ token: String) { + storage.setString(token, forKey: voipTokenStorageKey) } - - @objc public var callUUID: String { - return CallIdUUID.generateUUIDv5(from: callId) + + // 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 } - - @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() + + private static func cancelIncomingCallTimeout(for callId: String) { + incomingCallTimeouts.removeValue(forKey: callId)?.cancel() } - - @objc - public func isVoipIncomingCall() -> Bool { - return type == "incoming_call" && !callId.isEmpty && !caller.isEmpty && !host.isEmpty + + 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 + + configureCallObserverIfNeeded() + 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) } - - @objc - public func toDictionary() -> [String: Any] { - return [ - "callId": callId, - "caller": caller, - "host": host, - "type": type, - "callUUID": callUUID, - "notificationId": notificationId - ] + + // 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 deviceId = DeviceUID.uid() + let client = DDPClient() + ddpClient = client + isDdpLoggedIn = false + trackIncomingCall(payload) + + #if DEBUG + print("[\(TAG)] Starting DDP listener for call \(callId)") + #endif + + client.onCollectionMessage = { message in + guard ddpClient === client else { + return + } + 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 signalCallId = firstArg["callId"] as? String, + 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 or accepted from another device \(callId)") + #endif + + DispatchQueue.main.async { + guard ddpClient === client else { + return + } + clearTrackedIncomingCall(for: payload.callUUID) + RNCallKeep.endCall(withUUID: callId, reason: 3) + cancelIncomingCallTimeout(for: callId) + stopDDPClientInternal() + } + } + + client.connect(host: payload.host) { connected in + guard ddpClient === client else { + return + } + guard connected else { + #if DEBUG + print("[\(TAG)] DDP connection failed") + #endif + stopDDPClientInternal() + return + } + + client.login(token: credentials.userToken) { loggedIn in + guard ddpClient === client else { + return + } + guard loggedIn else { + #if DEBUG + print("[\(TAG)] DDP login failed") + #endif + stopDDPClientInternal() + return + } + + isDdpLoggedIn = true + if flushPendingRejectSignalIfNeeded() { + return + } + + let params: [Any] = [ + "\(userId)/media-signal", + ["useCollection": false, "args": [false]] + ] + + client.subscribe(name: "stream-notify-user", params: params) { subscribed in + guard ddpClient === client else { + return + } + #if DEBUG + print("[\(TAG)] DDP subscribe result: \(subscribed)") + #endif + if !subscribed { + stopDDPClientInternal() + } + } + } + } } - + + /// Stops the native DDP listener. Called from JS when it takes over signaling. @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 { + public static func stopDDPClient() { + #if DEBUG + print("[\(TAG)] stopDDPClient called from JS") + #endif + stopDDPClientInternal() + } + + 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 caller = dict["caller"] as? String ?? "" - let host = dict["host"] as? String ?? "" - - return VoipPayload(callId: callId, caller: caller, host: host, type: type) + + 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) + } } } 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-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 diff --git a/ios/RocketChatRN.xcodeproj/project.pbxproj b/ios/RocketChatRN.xcodeproj/project.pbxproj index 8e0336f1f18..139b1bb5d7f 100644 --- a/ios/RocketChatRN.xcodeproj/project.pbxproj +++ b/ios/RocketChatRN.xcodeproj/project.pbxproj @@ -301,10 +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 */; }; - 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 */; }; + 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 */; }; 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 */; }; @@ -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 */ @@ -636,8 +638,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 = ""; }; - 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 = ""; }; + 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 = ""; }; 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 = ""; }; @@ -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 = ""; }; @@ -1143,8 +1146,8 @@ 7A76DEE42F1AA6EF00750653 /* Libraries */ = { isa = PBXGroup; children = ( - 7A3F4C692F1AAFA700B6B4BD /* CallIdUUID.m */, - 7A3F4C6A2F1AAFA700B6B4BD /* CallIdUUID.swift */, + 7A1B58432F5F63DB002A6BDE /* AppDelegate+Voip.swift */, + 7A1B58402F5F58FF002A6BDE /* VoipPayload.swift */, 7A0000042F1BAFA700B6B4BD /* VoipModule.mm */, 7A0000032F1BAFA700B6B4BD /* VoipService.swift */, B179038FDD7AAF285047814B /* SecureStorage.h */, @@ -1155,6 +1158,7 @@ 66C2701A2EBBCB570062725F /* MMKVKeyManager.mm */, 7A8B30742BCD9D3F00146A40 /* SSLPinning.h */, 7A8B30752BCD9D3F00146A40 /* SSLPinning.mm */, + 9BE2F3DC02A264F204E3EDE3 /* DDPClient.swift */, ); path = Libraries; sourceTree = ""; @@ -2059,8 +2063,7 @@ 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 */, 1ED00BB12513E04400A1331F /* ReplyNotification.swift in Sources */, @@ -2094,6 +2097,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 */, @@ -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; }; @@ -2337,8 +2342,7 @@ 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 */, 7AAB3E17257E6A6E00707CF6 /* ReplyNotification.swift in Sources */, @@ -2372,6 +2376,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 */, @@ -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; };