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;
};