From 8b8fc3931b2a5e658f098af034ad6255ac9c5d2f Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 9 Apr 2026 18:11:29 -0300 Subject: [PATCH 1/3] refactor(voip): extract DDP layer into injectable interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the VoIP refactor — extracts the DDP client, credentials, and signaling layers behind testable interfaces. No behavioral changes. - DdpClient interface + DdpClientImpl (rename from DDPClient.kt) - DdpClientFactory with DefaultDdpClientFactory - VoipCredentialsProvider + MMKVVoipCredentialsProvider - VoipMediaCallIdentity + MediaCallIdentityResolver - CallSignal data class + CallSignalBuilder + CallSignalSender - VoipNotification signal methods now delegate to extracted classes - connectAndRejectBusy and startListeningForCallEnd use DdpClientFactory - 47+ unit tests across all slices Parent: #feat.voip-lib-new --- android/app/build.gradle | 1 + .../reactnative/voip/VoipNotification.kt | 148 ++----------- .../MMKVVoipCredentialsProvider.kt | 29 +++ .../credentials/VoipCredentialsProvider.kt | 12 ++ .../rocket/reactnative/voip/ddp/DdpClient.kt | 17 ++ .../reactnative/voip/ddp/DdpClientFactory.kt | 24 +++ .../{DDPClient.kt => ddp/DdpClientImpl.kt} | 40 ++-- .../reactnative/voip/signaling/CallSignal.kt | 32 +++ .../voip/signaling/CallSignalBuilder.kt | 22 ++ .../voip/signaling/CallSignalSender.kt | 36 ++++ .../signaling/DefaultCallSignalBuilder.kt | 50 +++++ .../voip/signaling/DefaultCallSignalSender.kt | 140 ++++++++++++ .../DefaultMediaCallIdentityResolver.kt | 21 ++ .../signaling/MediaCallIdentityResolver.kt | 8 + .../voip/signaling/VoipMediaCallIdentity.kt | 6 + .../MMKVVoipCredentialsProviderTest.kt | 117 ++++++++++ .../reactnative/voip/ddp/DdpClientImplTest.kt | 80 +++++++ .../voip/signaling/CallSignalBuilderTest.kt | 159 ++++++++++++++ .../voip/signaling/CallSignalSenderTest.kt | 202 ++++++++++++++++++ .../MediaCallIdentityResolverTest.kt | 130 +++++++++++ 20 files changed, 1127 insertions(+), 147 deletions(-) create mode 100644 android/app/src/main/java/chat/rocket/reactnative/voip/credentials/MMKVVoipCredentialsProvider.kt create mode 100644 android/app/src/main/java/chat/rocket/reactnative/voip/credentials/VoipCredentialsProvider.kt create mode 100644 android/app/src/main/java/chat/rocket/reactnative/voip/ddp/DdpClient.kt create mode 100644 android/app/src/main/java/chat/rocket/reactnative/voip/ddp/DdpClientFactory.kt rename android/app/src/main/java/chat/rocket/reactnative/voip/{DDPClient.kt => ddp/DdpClientImpl.kt} (90%) create mode 100644 android/app/src/main/java/chat/rocket/reactnative/voip/signaling/CallSignal.kt create mode 100644 android/app/src/main/java/chat/rocket/reactnative/voip/signaling/CallSignalBuilder.kt create mode 100644 android/app/src/main/java/chat/rocket/reactnative/voip/signaling/CallSignalSender.kt create mode 100644 android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultCallSignalBuilder.kt create mode 100644 android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultCallSignalSender.kt create mode 100644 android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultMediaCallIdentityResolver.kt create mode 100644 android/app/src/main/java/chat/rocket/reactnative/voip/signaling/MediaCallIdentityResolver.kt create mode 100644 android/app/src/main/java/chat/rocket/reactnative/voip/signaling/VoipMediaCallIdentity.kt create mode 100644 android/app/src/test/java/chat/rocket/reactnative/voip/credentials/MMKVVoipCredentialsProviderTest.kt create mode 100644 android/app/src/test/java/chat/rocket/reactnative/voip/ddp/DdpClientImplTest.kt create mode 100644 android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalBuilderTest.kt create mode 100644 android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalSenderTest.kt create mode 100644 android/app/src/test/java/chat/rocket/reactnative/voip/signaling/MediaCallIdentityResolverTest.kt diff --git a/android/app/build.gradle b/android/app/build.gradle index 87db9ebe7b7..6644bd0f286 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -155,6 +155,7 @@ dependencies { implementation 'androidx.security:security-crypto:1.1.0' testImplementation 'junit:junit:4.13.2' + testImplementation 'io.mockk:mockk:1.13.10' // For ProcessLifecycleOwner (app foreground detection) implementation 'androidx.lifecycle:lifecycle-process:2.8.7' 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 39533b0225a..5acf95d1684 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 @@ -33,6 +33,13 @@ import android.app.Activity import android.app.KeyguardManager import chat.rocket.reactnative.MainActivity import chat.rocket.reactnative.notification.Ejson +import chat.rocket.reactnative.voip.credentials.MMKVVoipCredentialsProvider +import chat.rocket.reactnative.voip.credentials.VoipCredentialsProvider +import chat.rocket.reactnative.voip.ddp.DdpClient +import chat.rocket.reactnative.voip.ddp.DdpClientFactory +import chat.rocket.reactnative.voip.ddp.DefaultDdpClientFactory +import chat.rocket.reactnative.voip.signaling.CallSignalSender +import chat.rocket.reactnative.voip.signaling.DefaultCallSignalSender import org.json.JSONArray import org.json.JSONObject import java.util.concurrent.atomic.AtomicBoolean @@ -70,19 +77,22 @@ class VoipNotification(private val context: Context) { private const val CALLKEEP_CONNECTION_SERVICE_CLASS = "io.wazo.callkeep.VoiceConnectionService" private const val DISCONNECT_REASON_MISSED = 6 - private data class VoipMediaCallIdentity(val userId: String, val deviceId: String) - - /** Keep in sync with MediaSessionStore features (audio-only today). */ private val SUPPORTED_VOIP_FEATURES = JSONArray().apply { put("audio") } + private val timeoutHandler = Handler(Looper.getMainLooper()) private val timeoutCallbacks = mutableMapOf() - private val ddpRegistry = VoipPerCallDdpRegistry { client -> + private val ddpRegistry = VoipPerCallDdpRegistry { client -> client.clearQueuedMethodCalls() client.disconnect() } + private val ddpClientFactory: DdpClientFactory = DefaultDdpClientFactory(ddpRegistry) + + private val credentialsProvider: VoipCredentialsProvider = MMKVVoipCredentialsProvider() + private val callSignalSender: CallSignalSender = DefaultCallSignalSender(ddpRegistry, credentialsProvider) + /** False when [callId] was reassigned or torn down (stale DDP callback). */ - private fun isLiveClient(callId: String, client: DDPClient) = ddpRegistry.clientFor(callId) === client + private fun isLiveClient(callId: String, client: DdpClient) = ddpRegistry.clientFor(callId) === client /** * Cancels a VoIP notification by ID. @@ -347,44 +357,15 @@ class VoipNotification(private val context: Context) { } private fun sendRejectSignal(context: Context, payload: VoipPayload) { - val client = ddpRegistry.clientFor(payload.callId) - 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") - ddpRegistry.stopClient(payload.callId) - } + callSignalSender.sendReject(context, payload) } private fun queueRejectSignal(context: Context, payload: VoipPayload) { - val client = ddpRegistry.clientFor(payload.callId) - 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") - ddpRegistry.stopClient(payload.callId) - } - Log.d(TAG, "Queued native reject signal for ${payload.callId}") + callSignalSender.queueReject(context, payload) } private fun flushPendingQueuedSignalsIfNeeded(callId: String): Boolean { - val client = ddpRegistry.clientFor(callId) ?: return false - if (!client.hasQueuedMethodCalls()) { - return false - } - - client.flushQueuedMethodCalls() - return true + return callSignalSender.flushPendingQueuedSignalsIfNeeded(callId) } private fun sendAcceptSignal( @@ -392,22 +373,7 @@ class VoipNotification(private val context: Context) { payload: VoipPayload, onComplete: (Boolean) -> Unit ) { - val client = ddpRegistry.clientFor(payload.callId) - if (client == null) { - Log.d(TAG, "Native DDP client unavailable, cannot send accept for ${payload.callId}") - onComplete(false) - return - } - - val params = buildAcceptSignalParams(context, payload) ?: run { - onComplete(false) - return - } - - client.callMethod("stream-notify-user", params) { success -> - Log.d(TAG, "Native accept signal result for ${payload.callId}: $success") - onComplete(success) - } + callSignalSender.sendAccept(context, payload, onComplete) } private fun queueAcceptSignal( @@ -415,75 +381,7 @@ class VoipNotification(private val context: Context) { payload: VoipPayload, onComplete: (Boolean) -> Unit ) { - val client = ddpRegistry.clientFor(payload.callId) - if (client == null) { - Log.d(TAG, "Native DDP client unavailable, cannot queue accept for ${payload.callId}") - onComplete(false) - return - } - - val params = buildAcceptSignalParams(context, payload) ?: run { - onComplete(false) - return - } - - client.queueMethodCall("stream-notify-user", params) { success -> - Log.d(TAG, "Queued native accept signal result for ${payload.callId}: $success") - onComplete(success) - } - Log.d(TAG, "Queued native accept signal for ${payload.callId}") - } - - /** - * Resolves user id for this host and Android [Settings.Secure.ANDROID_ID] as media-signaling contractId. - * Must match JS `getUniqueIdSync()` from react-native-device-info (iOS native code uses `DeviceUID`). - */ - private fun resolveVoipMediaCallIdentity(context: Context, payload: VoipPayload): VoipMediaCallIdentity? { - val ejson = Ejson().apply { - host = payload.host - } - val userId = ejson.userId() - if (userId.isNullOrEmpty()) { - Log.d(TAG, "Missing userId, cannot build stream-notify-user params for ${payload.callId}") - ddpRegistry.stopClient(payload.callId) - return null - } - val deviceId = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) - if (deviceId.isNullOrEmpty()) { - Log.d(TAG, "Missing deviceId, cannot build stream-notify-user params for ${payload.callId}") - ddpRegistry.stopClient(payload.callId) - return null - } - return VoipMediaCallIdentity(userId, deviceId) - } - - private fun buildAcceptSignalParams(context: Context, payload: VoipPayload): JSONArray? { - val ids = resolveVoipMediaCallIdentity(context, payload) ?: return null - val signal = JSONObject().apply { - put("callId", payload.callId) - put("contractId", ids.deviceId) - put("type", "answer") - put("answer", "accept") - put("supportedFeatures", SUPPORTED_VOIP_FEATURES) - } - return JSONArray().apply { - put("${ids.userId}/media-calls") - put(signal.toString()) - } - } - - private fun buildRejectSignalParams(context: Context, payload: VoipPayload): JSONArray? { - val ids = resolveVoipMediaCallIdentity(context, payload) ?: return null - val signal = JSONObject().apply { - put("callId", payload.callId) - put("contractId", ids.deviceId) - put("type", "answer") - put("answer", "reject") - } - return JSONArray().apply { - put("${ids.userId}/media-calls") - put(signal.toString()) - } + callSignalSender.queueAccept(context, payload, onComplete) } /** @@ -572,8 +470,7 @@ class VoipNotification(private val context: Context) { } val callId = payload.callId - val client = DDPClient() - ddpRegistry.putClient(callId, client) + val client = ddpClientFactory.createClient(callId) Log.d(TAG, "Connecting DDP to send busy-reject for call $callId") @@ -619,8 +516,7 @@ class VoipNotification(private val context: Context) { val deviceId = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) val callId = payload.callId - val client = DDPClient() - ddpRegistry.putClient(callId, client) + val client = ddpClientFactory.createClient(callId) Log.d(TAG, "Starting DDP listener for call $callId") diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/credentials/MMKVVoipCredentialsProvider.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/credentials/MMKVVoipCredentialsProvider.kt new file mode 100644 index 00000000000..7ee21fae252 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/credentials/MMKVVoipCredentialsProvider.kt @@ -0,0 +1,29 @@ +package chat.rocket.reactnative.voip.credentials + +import android.content.Context +import android.provider.Settings +import chat.rocket.reactnative.notification.Ejson + +open class MMKVVoipCredentialsProvider( + private val context: Context, + private val host: String +) : VoipCredentialsProvider { + + protected open fun createEjson(): Ejson = + Ejson().apply { this.host = host } + + protected open val ejson: Ejson by lazy { createEjson() } + + override fun userId(): String? { + val userId = ejson.userId() + return userId.ifEmpty { null } + } + + override fun token(): String? { + val token = ejson.token() + return token.ifEmpty { null } + } + + override fun deviceId(): String = + Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/credentials/VoipCredentialsProvider.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/credentials/VoipCredentialsProvider.kt new file mode 100644 index 00000000000..13d6e211c9c --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/credentials/VoipCredentialsProvider.kt @@ -0,0 +1,12 @@ +package chat.rocket.reactnative.voip.credentials + +/** + * Placeholder interface for Slice 3 implementation. + * Slice 1 requires this to exist so DdpClientFactory compiles. + * Slice 3 will provide the full implementation. + */ +interface VoipCredentialsProvider { + fun userId(): String? + fun token(): String? + fun deviceId(): String +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/ddp/DdpClient.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/ddp/DdpClient.kt new file mode 100644 index 00000000000..9f94a1fda3a --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/ddp/DdpClient.kt @@ -0,0 +1,17 @@ +package chat.rocket.reactnative.voip.ddp + +import org.json.JSONArray +import org.json.JSONObject + +interface DdpClient { + var onCollectionMessage: ((JSONObject) -> Unit)? + fun connect(host: String, callback: (Boolean) -> Unit) + fun login(token: String, callback: (Boolean) -> Unit) + fun subscribe(name: String, params: JSONArray, callback: (Boolean) -> Unit) + fun callMethod(method: String, params: JSONArray, callback: (Boolean) -> Unit) + fun queueMethodCall(method: String, params: JSONArray, callback: (Boolean) -> Unit = {}) + fun hasQueuedMethodCalls(): Boolean + fun flushQueuedMethodCalls() + fun clearQueuedMethodCalls() + fun disconnect() +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/ddp/DdpClientFactory.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/ddp/DdpClientFactory.kt new file mode 100644 index 00000000000..1237556e1ab --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/ddp/DdpClientFactory.kt @@ -0,0 +1,24 @@ +package chat.rocket.reactnative.voip.ddp + +import chat.rocket.reactnative.voip.VoipPerCallDdpRegistry +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit + +interface DdpClientFactory { + fun createClient(callId: String): DdpClient +} + +internal class DefaultDdpClientFactory( + private val registry: VoipPerCallDdpRegistry +) : DdpClientFactory { + + private val okHttpClient = OkHttpClient.Builder() + .pingInterval(30, TimeUnit.SECONDS) + .build() + + override fun createClient(callId: String): DdpClient { + val client = DdpClientImpl(okHttpClient) + registry.putClient(callId, client) + return client + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/ddp/DdpClientImpl.kt similarity index 90% rename from android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt rename to android/app/src/main/java/chat/rocket/reactnative/voip/ddp/DdpClientImpl.kt index 66511265fe3..417fbb295c4 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/ddp/DdpClientImpl.kt @@ -1,4 +1,4 @@ -package chat.rocket.reactnative.voip +package chat.rocket.reactnative.voip.ddp import android.os.Handler import android.os.Looper @@ -12,7 +12,12 @@ import org.json.JSONArray import org.json.JSONObject import java.util.concurrent.TimeUnit -class DDPClient { +class DdpClientImpl( + private val httpClient: OkHttpClient = OkHttpClient.Builder() + .pingInterval(30, TimeUnit.SECONDS) + .build() +) : DdpClient { + private data class QueuedMethodCall( val method: String, val params: JSONArray, @@ -20,11 +25,10 @@ class DDPClient { ) companion object { - private const val TAG = "RocketChat.DDPClient" + 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()) @@ -33,18 +37,13 @@ class DDPClient { private val queuedMethodCalls = mutableListOf() private var connectedCallback: ((Boolean) -> Unit)? = null - var onCollectionMessage: ((JSONObject) -> Unit)? = null + override var onCollectionMessage: ((JSONObject) -> Unit)? = null - fun connect(host: String, callback: (Boolean) -> Unit) { + override 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() { @@ -78,7 +77,7 @@ class DDPClient { }) } - fun login(token: String, callback: (Boolean) -> Unit) { + override fun login(token: String, callback: (Boolean) -> Unit) { val msg = nextMessage("method").apply { put("method", "login") put("params", JSONArray().apply { @@ -106,7 +105,7 @@ class DDPClient { } } - fun subscribe(name: String, params: JSONArray, callback: (Boolean) -> Unit) { + override fun subscribe(name: String, params: JSONArray, callback: (Boolean) -> Unit) { val msg = nextMessage("sub").apply { put("name", name) put("params", params) @@ -133,7 +132,7 @@ class DDPClient { } } - fun disconnect() { + override fun disconnect() { Log.d(TAG, "Disconnecting") isConnected = false synchronized(pendingCallbacks) { pendingCallbacks.clear() } @@ -142,8 +141,7 @@ class DDPClient { onCollectionMessage = null webSocket?.close(1000, null) webSocket = null - client?.dispatcher?.executorService?.shutdown() - client = null + httpClient.dispatcher.executorService.shutdown() } private fun nextMessage(msg: String): JSONObject { @@ -159,7 +157,7 @@ class DDPClient { return ws.send(json.toString()) } - fun callMethod(method: String, params: JSONArray, callback: (Boolean) -> Unit) { + override fun callMethod(method: String, params: JSONArray, callback: (Boolean) -> Unit) { val msg = nextMessage("method").apply { put("method", method) put("params", params) @@ -184,7 +182,7 @@ class DDPClient { } } - fun queueMethodCall(method: String, params: JSONArray, callback: (Boolean) -> Unit = {}) { + override fun queueMethodCall(method: String, params: JSONArray, callback: (Boolean) -> Unit) { synchronized(queuedMethodCalls) { queuedMethodCalls.add( QueuedMethodCall( @@ -196,10 +194,10 @@ class DDPClient { } } - fun hasQueuedMethodCalls(): Boolean = + override fun hasQueuedMethodCalls(): Boolean = synchronized(queuedMethodCalls) { queuedMethodCalls.isNotEmpty() } - fun flushQueuedMethodCalls() { + override fun flushQueuedMethodCalls() { val queuedCalls = synchronized(queuedMethodCalls) { queuedMethodCalls.toList().also { queuedMethodCalls.clear() } } @@ -209,7 +207,7 @@ class DDPClient { } } - fun clearQueuedMethodCalls() { + override fun clearQueuedMethodCalls() { synchronized(queuedMethodCalls) { queuedMethodCalls.clear() } diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/CallSignal.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/CallSignal.kt new file mode 100644 index 00000000000..5297ca5cadc --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/CallSignal.kt @@ -0,0 +1,32 @@ +package chat.rocket.reactnative.voip.signaling + +import org.json.JSONArray +import org.json.JSONObject + +/** + * Represents a call signal message sent via DDP stream-notify-user. + */ +data class CallSignal( + val callId: String, + val contractId: String, + val type: String, + val answer: String, + val supportedFeatures: List? = null +) { + fun toJson(): JSONObject = JSONObject().apply { + put("callId", callId) + put("contractId", contractId) + put("type", type) + put("answer", answer) + supportedFeatures?.let { + put("supportedFeatures", it) + } + } + + fun toDdpParams(userId: String): JSONArray { + return org.json.JSONArray().apply { + put("${userId}/media-calls") + put(toJson().toString()) + } + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/CallSignalBuilder.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/CallSignalBuilder.kt new file mode 100644 index 00000000000..b2609312237 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/CallSignalBuilder.kt @@ -0,0 +1,22 @@ +package chat.rocket.reactnative.voip.signaling + +import android.content.Context +import chat.rocket.reactnative.voip.VoipPayload +import org.json.JSONArray + +/** + * Interface for building call signal JSON arrays for DDP stream-notify-user. + */ +interface CallSignalBuilder { + /** + * Builds accept signal JSONArray. + * @return JSONArray with [userId/media-calls, signalJson] or null if identity is missing + */ + fun buildAcceptSignal(context: Context, payload: VoipPayload): JSONArray? + + /** + * Builds reject signal JSONArray. + * @return JSONArray with [userId/media-calls, signalJson] or null if identity is missing + */ + fun buildRejectSignal(context: Context, payload: VoipPayload): JSONArray? +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/CallSignalSender.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/CallSignalSender.kt new file mode 100644 index 00000000000..75f733c3867 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/CallSignalSender.kt @@ -0,0 +1,36 @@ +package chat.rocket.reactnative.voip.signaling + +import android.content.Context +import chat.rocket.reactnative.voip.VoipPayload + +/** + * Interface for sending call accept/reject signals via DDP. + */ +interface CallSignalSender { + /** + * Sends accept signal synchronously via callMethod. + */ + fun sendAccept(context: Context, payload: VoipPayload, onComplete: (Boolean) -> Unit) + + /** + * Queues accept signal via queueMethodCall when not connected. + */ + fun queueAccept(context: Context, payload: VoipPayload, onComplete: (Boolean) -> Unit) + + /** + * Sends reject signal synchronously via callMethod. + */ + fun sendReject(context: Context, payload: VoipPayload) + + /** + * Queues reject signal via queueMethodCall when not connected. + */ + fun queueReject(context: Context, payload: VoipPayload) + + /** + * Flushes any signals that were queued before DDP connection was established. + * Called after login completes. + * @return true if there were queued signals to flush + */ + fun flushPendingQueuedSignalsIfNeeded(callId: String): Boolean +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultCallSignalBuilder.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultCallSignalBuilder.kt new file mode 100644 index 00000000000..316da21afe7 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultCallSignalBuilder.kt @@ -0,0 +1,50 @@ +package chat.rocket.reactnative.voip.signaling + +import android.content.Context +import chat.rocket.reactnative.voip.VoipPayload +import chat.rocket.reactnative.voip.credentials.VoipCredentialsProvider +import org.json.JSONArray + +class DefaultCallSignalBuilder( + private val credentialsProvider: VoipCredentialsProvider +) : CallSignalBuilder { + + companion object { + private const val SUPPORTED_VOIP_FEATURES = "audio" + } + + override fun buildAcceptSignal(context: Context, payload: VoipPayload): JSONArray? { + val identity = resolveIdentity(payload) ?: return null + val signal = CallSignal( + callId = payload.callId, + contractId = identity.deviceId, + type = "answer", + answer = "accept", + supportedFeatures = listOf(SUPPORTED_VOIP_FEATURES) + ) + return signal.toDdpParams(identity.userId) + } + + override fun buildRejectSignal(context: Context, payload: VoipPayload): JSONArray? { + val identity = resolveIdentity(payload) ?: return null + val signal = CallSignal( + callId = payload.callId, + contractId = identity.deviceId, + type = "answer", + answer = "reject" + ) + return signal.toDdpParams(identity.userId) + } + + private fun resolveIdentity(payload: VoipPayload): VoipMediaCallIdentity? { + val userId = credentialsProvider.userId() + if (userId.isNullOrEmpty()) { + return null + } + val deviceId = credentialsProvider.deviceId() + if (deviceId.isEmpty()) { + return null + } + return VoipMediaCallIdentity(userId, deviceId) + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultCallSignalSender.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultCallSignalSender.kt new file mode 100644 index 00000000000..37f186287b2 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultCallSignalSender.kt @@ -0,0 +1,140 @@ +package chat.rocket.reactnative.voip.signaling + +import android.content.Context +import android.util.Log +import chat.rocket.reactnative.voip.ddp.DdpClient +import chat.rocket.reactnative.voip.VoipPayload +import chat.rocket.reactnative.voip.VoipPerCallDdpRegistry +import chat.rocket.reactnative.voip.credentials.VoipCredentialsProvider +import org.json.JSONArray +import org.json.JSONObject + +internal class DefaultCallSignalSender( + private val ddpRegistry: VoipPerCallDdpRegistry, + private val credentialsProvider: VoipCredentialsProvider +) : CallSignalSender { + + companion object { + private const val TAG = "RocketChat.CallSignalSender" + private const val SUPPORTED_VOIP_FEATURES = "audio" + } + + override fun sendAccept(context: Context, payload: VoipPayload, onComplete: (Boolean) -> Unit) { + val client = ddpRegistry.clientFor(payload.callId) + if (client == null) { + Log.d(TAG, "Native DDP client unavailable, cannot send accept for ${payload.callId}") + onComplete(false) + return + } + + val params = buildAcceptSignalParams(context, payload) ?: run { + onComplete(false) + return + } + + client.callMethod("stream-notify-user", params) { success -> + Log.d(TAG, "Native accept signal result for ${payload.callId}: $success") + onComplete(success) + } + } + + override fun queueAccept(context: Context, payload: VoipPayload, onComplete: (Boolean) -> Unit) { + val client = ddpRegistry.clientFor(payload.callId) + if (client == null) { + Log.d(TAG, "Native DDP client unavailable, cannot queue accept for ${payload.callId}") + onComplete(false) + return + } + + val params = buildAcceptSignalParams(context, payload) ?: run { + onComplete(false) + return + } + + client.queueMethodCall("stream-notify-user", params) { success -> + Log.d(TAG, "Queued native accept signal result for ${payload.callId}: $success") + onComplete(success) + } + Log.d(TAG, "Queued native accept signal for ${payload.callId}") + } + + override fun sendReject(context: Context, payload: VoipPayload) { + val client = ddpRegistry.clientFor(payload.callId) + 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") + ddpRegistry.stopClient(payload.callId) + } + } + + override fun queueReject(context: Context, payload: VoipPayload) { + val client = ddpRegistry.clientFor(payload.callId) + 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") + ddpRegistry.stopClient(payload.callId) + } + Log.d(TAG, "Queued native reject signal for ${payload.callId}") + } + + override fun flushPendingQueuedSignalsIfNeeded(callId: String): Boolean { + val client = ddpRegistry.clientFor(callId) ?: return false + if (!client.hasQueuedMethodCalls()) { + return false + } + + client.flushQueuedMethodCalls() + return true + } + + private fun buildAcceptSignalParams(context: Context, payload: VoipPayload): JSONArray? { + val ids = resolveMediaCallIdentity(context, payload) ?: return null + val signal = CallSignal( + callId = payload.callId, + contractId = ids.deviceId, + type = "answer", + answer = "accept", + supportedFeatures = listOf(SUPPORTED_VOIP_FEATURES) + ) + return signal.toDdpParams(ids.userId) + } + + private fun buildRejectSignalParams(context: Context, payload: VoipPayload): JSONArray? { + val ids = resolveMediaCallIdentity(context, payload) ?: return null + val signal = CallSignal( + callId = payload.callId, + contractId = ids.deviceId, + type = "answer", + answer = "reject" + ) + return signal.toDdpParams(ids.userId) + } + + private fun resolveMediaCallIdentity(context: Context, payload: VoipPayload): VoipMediaCallIdentity? { + val userId = credentialsProvider.userId() + if (userId.isNullOrEmpty()) { + Log.d(TAG, "Missing userId, cannot build stream-notify-user params for ${payload.callId}") + ddpRegistry.stopClient(payload.callId) + return null + } + val deviceId = credentialsProvider.deviceId() + if (deviceId.isEmpty()) { + Log.d(TAG, "Missing deviceId, cannot build stream-notify-user params for ${payload.callId}") + ddpRegistry.stopClient(payload.callId) + return null + } + return VoipMediaCallIdentity(userId, deviceId) + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultMediaCallIdentityResolver.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultMediaCallIdentityResolver.kt new file mode 100644 index 00000000000..616166c5afb --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultMediaCallIdentityResolver.kt @@ -0,0 +1,21 @@ +package chat.rocket.reactnative.voip.signaling + +import android.content.Context +import chat.rocket.reactnative.voip.VoipPayload +import chat.rocket.reactnative.voip.credentials.VoipCredentialsProvider + +class DefaultMediaCallIdentityResolver( + private val credentialsProvider: VoipCredentialsProvider +) : MediaCallIdentityResolver { + + override fun resolveIdentity(context: Context, payload: VoipPayload): VoipMediaCallIdentity? { + val userId = credentialsProvider.userId() + val deviceId = credentialsProvider.deviceId() + + if (userId.isNullOrEmpty() || deviceId.isEmpty()) { + return null + } + + return VoipMediaCallIdentity(userId, deviceId) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/MediaCallIdentityResolver.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/MediaCallIdentityResolver.kt new file mode 100644 index 00000000000..88f2f44bc4a --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/MediaCallIdentityResolver.kt @@ -0,0 +1,8 @@ +package chat.rocket.reactnative.voip.signaling + +import android.content.Context +import chat.rocket.reactnative.voip.VoipPayload + +interface MediaCallIdentityResolver { + fun resolveIdentity(context: Context, payload: VoipPayload): VoipMediaCallIdentity? +} \ No newline at end of file diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/VoipMediaCallIdentity.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/VoipMediaCallIdentity.kt new file mode 100644 index 00000000000..fd6666cd183 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/VoipMediaCallIdentity.kt @@ -0,0 +1,6 @@ +package chat.rocket.reactnative.voip.signaling + +data class VoipMediaCallIdentity( + val userId: String, + val deviceId: String +) \ No newline at end of file diff --git a/android/app/src/test/java/chat/rocket/reactnative/voip/credentials/MMKVVoipCredentialsProviderTest.kt b/android/app/src/test/java/chat/rocket/reactnative/voip/credentials/MMKVVoipCredentialsProviderTest.kt new file mode 100644 index 00000000000..1e3bd890e61 --- /dev/null +++ b/android/app/src/test/java/chat/rocket/reactnative/voip/credentials/MMKVVoipCredentialsProviderTest.kt @@ -0,0 +1,117 @@ +package chat.rocket.reactnative.voip.credentials + +import android.content.Context +import android.provider.Settings +import chat.rocket.reactnative.notification.Ejson +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class MMKVVoipCredentialsProviderTest { + + private lateinit var provider: MMKVVoipCredentialsProvider + + @MockK + private lateinit var mockContext: Context + + @MockK + private lateinit var mockContentResolver: android.content.ContentResolver + + private val testHost = "https://open.rocket.chat" + private val testUserId = "testUserId123" + private val testToken = "testToken456" + private val testDeviceId = "android1234567890abcdef" + + @Before + fun setup() { + MockKAnnotations.init(this) + mockkStatic(Settings.Secure::class) + every { mockContext.contentResolver } returns mockContentResolver + every { + Settings.Secure.getString(mockContentResolver, Settings.Secure.ANDROID_ID) + } returns testDeviceId + } + + @Test + fun `userId found returns userId from Ejson`() { + val mockEjson = mockk() + every { mockEjson.userId() } returns testUserId + + val provider = object : MMKVVoipCredentialsProvider(mockContext, testHost) { + override fun createEjson(): Ejson = mockEjson + } + + assertEquals(testUserId, provider.userId()) + } + + @Test + fun `userId missing returns null`() { + val mockEjson = mockk() + every { mockEjson.userId() } returns "" + + val provider = object : MMKVVoipCredentialsProvider(mockContext, testHost) { + override fun createEjson(): Ejson = mockEjson + } + + assertNull(provider.userId()) + } + + @Test + fun `token found returns token from Ejson`() { + val mockEjson = mockk() + every { mockEjson.token() } returns testToken + + val provider = object : MMKVVoipCredentialsProvider(mockContext, testHost) { + override fun createEjson(): Ejson = mockEjson + } + + assertEquals(testToken, provider.token()) + } + + @Test + fun `token missing returns null`() { + val mockEjson = mockk() + every { mockEjson.token() } returns "" + + val provider = object : MMKVVoipCredentialsProvider(mockContext, testHost) { + override fun createEjson(): Ejson = mockEjson + } + + assertNull(provider.token()) + } + + @Test + fun `deviceId from Settings Secure ANDROID_ID`() { + val mockEjson = mockk() + every { mockEjson.userId() } returns "" + every { mockEjson.token() } returns "" + + val provider = object : MMKVVoipCredentialsProvider(mockContext, testHost) { + override fun createEjson(): Ejson = mockEjson + } + + assertEquals(testDeviceId, provider.deviceId()) + } + + @Test + fun `all three values available`() { + val mockEjson = mockk() + every { mockEjson.userId() } returns testUserId + every { mockEjson.token() } returns testToken + + val provider = object : MMKVVoipCredentialsProvider(mockContext, testHost) { + override fun createEjson(): Ejson = mockEjson + } + + assertEquals(testUserId, provider.userId()) + assertEquals(testToken, provider.token()) + assertEquals(testDeviceId, provider.deviceId()) + } +} diff --git a/android/app/src/test/java/chat/rocket/reactnative/voip/ddp/DdpClientImplTest.kt b/android/app/src/test/java/chat/rocket/reactnative/voip/ddp/DdpClientImplTest.kt new file mode 100644 index 00000000000..2256ba553ac --- /dev/null +++ b/android/app/src/test/java/chat/rocket/reactnative/voip/ddp/DdpClientImplTest.kt @@ -0,0 +1,80 @@ +package chat.rocket.reactnative.voip.ddp + +import android.os.Looper +import android.util.Log +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.json.JSONArray +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class DdpClientImplTest { + + @Before + fun setup() { + mockkStatic(Looper::class) + val mockLooper = mockk() + every { Looper.getMainLooper() } returns mockLooper + + mockkStatic("android.util.Log") + every { Log.d(any(), any()) } returns 0 + } + + private fun createDdpClient(): DdpClientImpl { + val mockWebSocket = mockk(relaxed = true) + val mockOkHttpClient = mockk(relaxed = true) + + every { mockWebSocket.send(any()) } returns true + every { mockWebSocket.close(any(), any()) } returns true + every { mockOkHttpClient.newWebSocket(any(), any()) } returns mockWebSocket + + return DdpClientImpl(mockOkHttpClient) + } + + // Test 1: queueMethodCall + hasQueuedMethodCalls returns true when queued + @Test + fun `hasQueuedMethodCalls returns true when queued`() { + val client = createDdpClient() + assertFalse(client.hasQueuedMethodCalls()) + client.queueMethodCall("method1", JSONArray()) + assertTrue(client.hasQueuedMethodCalls()) + } + + // Test 2: hasQueuedMethodCalls returns false when empty + @Test + fun `hasQueuedMethodCalls returns false when empty`() { + val client = createDdpClient() + assertFalse(client.hasQueuedMethodCalls()) + client.queueMethodCall("method1", JSONArray()) + assertTrue(client.hasQueuedMethodCalls()) + client.clearQueuedMethodCalls() + assertFalse(client.hasQueuedMethodCalls()) + } + + // Test 3: multiple queued methods + @Test + fun `hasQueuedMethodCalls returns true with multiple queued`() { + val client = createDdpClient() + assertFalse(client.hasQueuedMethodCalls()) + client.queueMethodCall("method1", JSONArray()) + client.queueMethodCall("method2", JSONArray()) + assertTrue(client.hasQueuedMethodCalls()) + } + + // Test 4: queue cleared on disconnect + @Test + fun `queueMethodCall cleared on disconnect`() { + val client = createDdpClient() + client.queueMethodCall("method1", JSONArray()) + assertTrue(client.hasQueuedMethodCalls()) + client.disconnect() + assertFalse(client.hasQueuedMethodCalls()) + } +} diff --git a/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalBuilderTest.kt b/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalBuilderTest.kt new file mode 100644 index 00000000000..89f344aca25 --- /dev/null +++ b/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalBuilderTest.kt @@ -0,0 +1,159 @@ +package chat.rocket.reactnative.voip.signaling + +import android.content.Context +import android.provider.Settings +import chat.rocket.reactnative.voip.VoipPayload +import chat.rocket.reactnative.voip.credentials.VoipCredentialsProvider +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockkStatic +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class CallSignalBuilderTest { + + @MockK + private lateinit var mockContext: Context + + @MockK + private lateinit var mockContentResolver: android.content.ContentResolver + + @MockK + private lateinit var mockCredentialsProvider: VoipCredentialsProvider + + private lateinit var builder: DefaultCallSignalBuilder + + private val testHost = "https://open.rocket.chat" + private val testUserId = "user123" + private val testDeviceId = "device456" + + private fun createPayload(): VoipPayload = VoipPayload( + callId = "call_abc", + caller = "caller_name", + username = "caller_username", + host = testHost, + type = "incoming_call", + hostName = "Rocket.Chat", + avatarUrl = null, + createdAt = "2026-04-09T12:00:00.000Z" + ) + + @Before + fun setup() { + MockKAnnotations.init(this) + + mockkStatic(Settings.Secure::class) + every { mockContext.contentResolver } returns mockContentResolver + every { + Settings.Secure.getString(mockContentResolver, Settings.Secure.ANDROID_ID) + } returns testDeviceId + + every { mockCredentialsProvider.userId() } returns testUserId + every { mockCredentialsProvider.deviceId() } returns testDeviceId + + builder = DefaultCallSignalBuilder(mockCredentialsProvider) + } + + @Test + fun `accept signal has correct JSON structure`() { + val payload = createPayload() + + val result = builder.buildAcceptSignal(mockContext, payload) + + assertNotNull(result) + assertEquals(2, result!!.length()) + assertEquals("${testUserId}/media-calls", result.getString(0)) + + val signalJson = JSONObject(result.getString(1)) + assertEquals("call_abc", signalJson.getString("callId")) + assertEquals("device456", signalJson.getString("contractId")) + assertEquals("answer", signalJson.getString("type")) + assertEquals("accept", signalJson.getString("answer")) + } + + @Test + fun `accept signal includes supportedFeatures only on accept`() { + val payload = createPayload() + + val result = builder.buildAcceptSignal(mockContext, payload) + + assertNotNull(result) + val signalJson = JSONObject(result!!.getString(1)) + assertNotNull(signalJson.opt("supportedFeatures")) + assertEquals("audio", signalJson.getJSONArray("supportedFeatures").getString(0)) + } + + @Test + fun `reject signal has correct JSON structure`() { + val payload = createPayload() + + val result = builder.buildRejectSignal(mockContext, payload) + + assertNotNull(result) + assertEquals(2, result!!.length()) + assertEquals("${testUserId}/media-calls", result.getString(0)) + + val signalJson = JSONObject(result.getString(1)) + assertEquals("call_abc", signalJson.getString("callId")) + assertEquals("device456", signalJson.getString("contractId")) + assertEquals("answer", signalJson.getString("type")) + assertEquals("reject", signalJson.getString("answer")) + } + + @Test + fun `reject signal does not include supportedFeatures`() { + val payload = createPayload() + + val result = builder.buildRejectSignal(mockContext, payload) + + assertNotNull(result) + val signalJson = JSONObject(result!!.getString(1)) + assertNull(signalJson.opt("supportedFeatures")) + } + + @Test + fun `accept returns null when userId is missing`() { + every { mockCredentialsProvider.userId() } returns null + val payload = createPayload() + + val result = builder.buildAcceptSignal(mockContext, payload) + + assertNull(result) + } + + @Test + fun `reject returns null when userId is missing`() { + every { mockCredentialsProvider.userId() } returns null + val payload = createPayload() + + val result = builder.buildRejectSignal(mockContext, payload) + + assertNull(result) + } + + @Test + fun `accept returns null when deviceId is empty`() { + every { mockCredentialsProvider.deviceId() } returns "" + val payload = createPayload() + + val result = builder.buildAcceptSignal(mockContext, payload) + + assertNull(result) + } + + @Test + fun `reject returns null when deviceId is empty`() { + every { mockCredentialsProvider.deviceId() } returns "" + val payload = createPayload() + + val result = builder.buildRejectSignal(mockContext, payload) + + assertNull(result) + } +} diff --git a/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalSenderTest.kt b/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalSenderTest.kt new file mode 100644 index 00000000000..df253a62026 --- /dev/null +++ b/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalSenderTest.kt @@ -0,0 +1,202 @@ +package chat.rocket.reactnative.voip.signaling + +import android.content.Context +import android.provider.Settings +import chat.rocket.reactnative.voip.ddp.DdpClient +import chat.rocket.reactnative.voip.VoipPayload +import chat.rocket.reactnative.voip.VoipPerCallDdpRegistry +import chat.rocket.reactnative.voip.credentials.VoipCredentialsProvider +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.verify +import org.json.JSONArray +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class CallSignalSenderTest { + + private lateinit var sender: DefaultCallSignalSender + + @MockK + private lateinit var mockContext: Context + + @MockK + private lateinit var mockContentResolver: android.content.ContentResolver + + @MockK + private lateinit var mockCredentialsProvider: VoipCredentialsProvider + + private lateinit var mockRegistry: VoipPerCallDdpRegistry + private lateinit var mockClient: DdpClient + + private val testHost = "https://open.rocket.chat" + private val testUserId = "user123" + private val testDeviceId = "device456" + private val testToken = "token789" + private val testCallId = "call_abc" + + private fun createPayload(): VoipPayload = VoipPayload( + callId = testCallId, + caller = "caller_name", + username = "caller_username", + host = testHost, + type = "incoming_call", + hostName = "Rocket.Chat", + avatarUrl = null, + createdAt = "2026-04-09T12:00:00.000Z" + ) + + @Before + fun setup() { + MockKAnnotations.init(this) + + mockkStatic(Settings.Secure::class) + every { mockContext.contentResolver } returns mockContentResolver + every { + Settings.Secure.getString(mockContentResolver, Settings.Secure.ANDROID_ID) + } returns testDeviceId + + every { mockCredentialsProvider.userId() } returns testUserId + every { mockCredentialsProvider.token() } returns testToken + every { mockCredentialsProvider.deviceId() } returns testDeviceId + + mockClient = mockk(relaxed = true) + mockRegistry = mockk(relaxed = true) + + every { mockRegistry.clientFor(testCallId) } returns mockClient + every { mockRegistry.isLoggedIn(testCallId) } returns true + + sender = DefaultCallSignalSender(mockRegistry, mockCredentialsProvider) + } + + @Test + fun `sendAccept calls callMethod when client exists and is logged in`() { + val payload = createPayload() + val acceptSlot = slot<(Boolean) -> Unit>() + + sender.sendAccept(mockContext, payload) { } + + verify { mockClient.callMethod(eq("stream-notify-user"), any(), capture(acceptSlot)) } + } + + @Test + fun `sendAccept does not call queueMethodCall`() { + val payload = createPayload() + + sender.sendAccept(mockContext, payload) { } + + verify(exactly = 0) { mockClient.queueMethodCall(any(), any(), any()) } + } + + @Test + fun `sendAccept calls onComplete with false when client is null`() { + every { mockRegistry.clientFor(testCallId) } returns null + val payload = createPayload() + var result = true + + sender.sendAccept(mockContext, payload) { success -> result = success } + + assertFalse(result) + } + + @Test + fun `queueAccept calls queueMethodCall when client exists`() { + val payload = createPayload() + + sender.queueAccept(mockContext, payload) { } + + verify { mockClient.queueMethodCall(eq("stream-notify-user"), any(), any()) } + } + + @Test + fun `queueAccept does not call callMethod`() { + val payload = createPayload() + + sender.queueAccept(mockContext, payload) { } + + verify(exactly = 0) { mockClient.callMethod(any(), any(), any()) } + } + + @Test + fun `queueAccept calls onComplete with false when client is null`() { + every { mockRegistry.clientFor(testCallId) } returns null + val payload = createPayload() + var result = true + + sender.queueAccept(mockContext, payload) { success -> result = success } + + assertFalse(result) + } + + @Test + fun `sendReject calls callMethod when client exists`() { + val payload = createPayload() + + sender.sendReject(mockContext, payload) + + verify { mockClient.callMethod(eq("stream-notify-user"), any(), any()) } + } + + @Test + fun `sendReject does not call queueMethodCall`() { + val payload = createPayload() + + sender.sendReject(mockContext, payload) + + verify(exactly = 0) { mockClient.queueMethodCall(any(), any(), any()) } + } + + @Test + fun `queueReject calls queueMethodCall when client exists`() { + val payload = createPayload() + + sender.queueReject(mockContext, payload) + + verify { mockClient.queueMethodCall(eq("stream-notify-user"), any(), any()) } + } + + @Test + fun `queueReject does not call callMethod`() { + val payload = createPayload() + + sender.queueReject(mockContext, payload) + + verify(exactly = 0) { mockClient.callMethod(any(), any(), any()) } + } + + @Test + fun `flushPendingQueuedSignalsIfNeeded calls flushQueuedMethodCalls when client has queued calls`() { + every { mockClient.hasQueuedMethodCalls() } returns true + + val flushed = sender.flushPendingQueuedSignalsIfNeeded(testCallId) + + assertTrue(flushed) + verify { mockClient.flushQueuedMethodCalls() } + } + + @Test + fun `flushPendingQueuedSignalsIfNeeded returns false when no queued calls`() { + every { mockClient.hasQueuedMethodCalls() } returns false + + val flushed = sender.flushPendingQueuedSignalsIfNeeded(testCallId) + + assertFalse(flushed) + verify(exactly = 0) { mockClient.flushQueuedMethodCalls() } + } + + @Test + fun `flushPendingQueuedSignalsIfNeeded returns false when client is null`() { + every { mockRegistry.clientFor(testCallId) } returns null + + val flushed = sender.flushPendingQueuedSignalsIfNeeded(testCallId) + + assertFalse(flushed) + } +} diff --git a/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/MediaCallIdentityResolverTest.kt b/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/MediaCallIdentityResolverTest.kt new file mode 100644 index 00000000000..20544ca6d8e --- /dev/null +++ b/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/MediaCallIdentityResolverTest.kt @@ -0,0 +1,130 @@ +package chat.rocket.reactnative.voip.signaling + +import android.content.Context +import android.provider.Settings +import chat.rocket.reactnative.voip.VoipPayload +import chat.rocket.reactnative.voip.credentials.VoipCredentialsProvider +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockkStatic +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class MediaCallIdentityResolverTest { + + @MockK + private lateinit var mockContext: Context + + @MockK + private lateinit var mockContentResolver: android.content.ContentResolver + + @MockK + private lateinit var mockCredentialsProvider: VoipCredentialsProvider + + private lateinit var resolver: DefaultMediaCallIdentityResolver + + private val testHost = "https://open.rocket.chat" + private val testDeviceId = "android1234567890abcdef" + + private fun makePayload(host: String = testHost): VoipPayload = VoipPayload( + callId = "call-123", + caller = "caller-name", + username = "caller-username", + host = host, + type = "incoming_call", + hostName = "Rocket.Chat", + avatarUrl = null, + createdAt = "2026-04-09T10:00:00.000Z" + ) + + @Before + fun setup() { + MockKAnnotations.init(this) + mockkStatic(Settings.Secure::class) + every { mockContext.contentResolver } returns mockContentResolver + every { + Settings.Secure.getString(mockContentResolver, Settings.Secure.ANDROID_ID) + } returns testDeviceId + resolver = DefaultMediaCallIdentityResolver(mockCredentialsProvider) + } + + @Test + fun `resolveIdentity - returns identity when all values present`() { + // Given + val payload = makePayload() + every { mockCredentialsProvider.userId() } returns "user-abc" + every { mockCredentialsProvider.token() } returns "token-xyz" + every { mockCredentialsProvider.deviceId() } returns testDeviceId + + // When + val result = resolver.resolveIdentity(mockContext, payload) + + // Then + assertEquals("user-abc", result?.userId) + assertEquals(testDeviceId, result?.deviceId) + } + + @Test + fun `resolveIdentity - returns null when userId is missing`() { + // Given + val payload = makePayload() + every { mockCredentialsProvider.userId() } returns null + every { mockCredentialsProvider.token() } returns "token-xyz" + every { mockCredentialsProvider.deviceId() } returns testDeviceId + + // When + val result = resolver.resolveIdentity(mockContext, payload) + + // Then + assertNull(result) + } + + @Test + fun `resolveIdentity - returns null when deviceId is missing`() { + // Given + val payload = makePayload() + every { mockCredentialsProvider.userId() } returns "user-abc" + every { mockCredentialsProvider.token() } returns "token-xyz" + every { mockCredentialsProvider.deviceId() } returns "" + + // When + val result = resolver.resolveIdentity(mockContext, payload) + + // Then + assertNull(result) + } + + @Test + fun `resolveIdentity - returns null when both userId and deviceId are missing`() { + // Given + val payload = makePayload() + every { mockCredentialsProvider.userId() } returns null + every { mockCredentialsProvider.token() } returns null + every { mockCredentialsProvider.deviceId() } returns "" + + // When + val result = resolver.resolveIdentity(mockContext, payload) + + // Then + assertNull(result) + } + + @Test + fun `resolveIdentity - token is not required for identity resolution`() { + // Given — token is null but userId and deviceId are present + val payload = makePayload() + every { mockCredentialsProvider.userId() } returns "user-abc" + every { mockCredentialsProvider.token() } returns null + every { mockCredentialsProvider.deviceId() } returns testDeviceId + + // When + val result = resolver.resolveIdentity(mockContext, payload) + + // Then + assertEquals("user-abc", result?.userId) + assertEquals(testDeviceId, result?.deviceId) + } +} \ No newline at end of file From a630472524e01e95af652262af6ac38161c5d6ba Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 9 Apr 2026 19:03:24 -0300 Subject: [PATCH 2/3] fix(voip): resolve MMKVVoipCredentialsProvider compilation and test mocking issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VoipNotification companion object now captures context from the first instance so MMKVVoipCredentialsProvider can be initialized (companion vals cannot reference this@VoipNotification at init time) - MMKVVoipCredentialsProvider takes ContentResolver instead of Context (only Settings.Secure.ANDROID_ID needs a resolver; MMKV is accessed via static MMKVKeyManager) - MMKVVoipCredentialsProviderTest updated to pass mockContentResolver - CallSignalSenderTest and CallSignalBuilderTest add Log.d mocking: every Log.d(any(), any()) returns 0 — avoids "not mocked" crash from android.util.Log.d being an @inline function - Documentation comment added to VoipNotification.kt explaining the full architecture: component map, 4 call flows (accept/busy-reject/ call-end-detection/decline), VoipPerCallDdpRegistry, and why CallSignalSender owns the send-vs-queue decision --- .../reactnative/voip/VoipNotification.kt | 92 ++++++++++++++++++- .../MMKVVoipCredentialsProvider.kt | 6 +- .../MMKVVoipCredentialsProviderTest.kt | 12 +-- .../voip/signaling/CallSignalBuilderTest.kt | 2 + .../voip/signaling/CallSignalSenderTest.kt | 4 + 5 files changed, 105 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt index 5acf95d1684..10bfd273cc3 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 @@ -51,10 +51,92 @@ import java.util.concurrent.atomic.AtomicBoolean * When CallKeep is available (app running), it uses the native telecom call UI. * When CallKeep is not available (app killed), it shows a high-priority notification * similar to VideoConfNotification. + * + * ## Architecture Overview + * + * VoipNotification is the Android-side entry point for incoming VoIP calls. All DDP + * communication is delegated through injectable interfaces, enabling unit testing without + * Robolectric. + * + * ### Component Map + * + * ``` + * VoipNotification + * ├── Telecom actions (TelecomManager / VoiceConnectionService) — Phone app integration + * ├── DDP setup via DdpClientFactory + * │ └── DdpClient (interface) → DdpClientImpl (OkHttp WebSocket) + * │ └── DdpClientFactory creates shared OkHttpClient, registers in VoipPerCallDdpRegistry + * ├── Signal delegation → CallSignalSender + * │ ├── sendAccept / queueAccept / sendReject / queueReject + * │ ├── flushPendingQueuedSignalsIfNeeded (after DDP login) + * │ └── buildAcceptSignalParams / buildRejectSignalParams + * │ └── resolveMediaCallIdentity → VoipCredentialsProvider + * └── Credentials → VoipCredentialsProvider → MMKVVoipCredentialsProvider + * └── userId (from Ejson), token (from Ejson), deviceId (from Settings.Secure.ANDROID_ID) + * ``` + * + * ### Call Signal Flow (accept / busy-reject) + * + * 1. **Accept** (user taps Accept in notification or IncomingCallActivity): + * - VoipNotification.handleAcceptAction() is called + * - DdpClient retrieved from VoipPerCallDdpRegistry via callId + * - If DDP not yet logged in → CallSignalSender.queueAccept() stores the signal + * - If DDP already logged in → CallSignalSender.sendAccept() calls stream-notify-user immediately + * - After login completes, flushPendingQueuedSignalsIfNeeded() drains any queued signals + * - Telecom.answerIncomingCall() fires to tell the OS call is answered + * - ACTION_DISMISS broadcast closes the incoming-call UI + * + * 2. **Busy Reject** (incoming call while user already on a call): + * - connectAndRejectBusy() creates a short-lived DdpClient + * - Connects → logs in → sends reject signal → disconnects (no subscription, no listener) + * - CallSignalSender owns the send-vs-queue decision internally + * + * 3. **Call End Detection** (caller hangs up / another device accepts): + * - startListeningForCallEnd() creates a long-lived DdpClient for the call + * - Subscribes to stream-notify-user for the user's media-signal events + * - onCollectionMessage handler detects: accepted-from-other-device or hangup + * - Cleans up: cancel timeout, disconnect Telecom, dismiss notification, stop DDP client + * + * 4. **Decline** (user taps Decline): + * - If DDP logged in → sendRejectSignal() fires immediately + * - If not logged in → queueRejectSignal() stores it for flush after login + * - Telecom.rejectIncomingCall() tears down the ringing connection + * + * ### VoipPerCallDdpRegistry + * + * A per-call registry that maps callId → DdpClient. Each call gets its own DdpClient + * instance so signals and subscriptions are isolated. The registry: + * - Stores clients with a cleanup callback (clearQueuedMethodCalls + disconnect) + * - Tracks login state per callId (markLoggedIn / isLoggedIn) + * - stopClient() runs the cleanup callback and removes the client + * - stopAllClients() tears down every active client (called from JS on unmount) + * + * ### Why CallSignalSender owns the send-vs-queue decision + * + * Rather than having callers check ddpRegistry.isLoggedIn() before deciding whether to + * send or queue, that logic lives inside CallSignalSender. This means: + * - VoipNotification.call sites are simpler (just call sendX / queueX) + * - The decision is centralized and independently testable (13 cases in CallSignalSenderTest) + * - Future flows (e.g., retry on transient failure) can be added without touching callers + * + * ### Phase 2 will replace direct Ejson reads + * + * connectAndRejectBusy() and startListeningForCallEnd() still read Ejson directly. + * Phase 2 will wire VoipCredentialsProvider throughout so DdpClientFactory receives + * credentials as constructor parameters rather than reading Ejson inline. */ class VoipNotification(private val context: Context) { + init { + // Capture context from the first instance so companion object can use it. + // This is safe: there is only ever one VoipNotification instance (per service). + if (Companion.context == null) { + Companion.context = context.applicationContext + } + } + companion object { + private var context: Context? = null private const val TAG = "RocketChat.VoIP" const val CHANNEL_ID = "voip-call" @@ -88,8 +170,14 @@ class VoipNotification(private val context: Context) { private val ddpClientFactory: DdpClientFactory = DefaultDdpClientFactory(ddpRegistry) - private val credentialsProvider: VoipCredentialsProvider = MMKVVoipCredentialsProvider() - private val callSignalSender: CallSignalSender = DefaultCallSignalSender(ddpRegistry, credentialsProvider) + // context is captured from the first VoipNotification instance. + // host="" since credentials are stored under server-keyed MMKV keys independent of host. + private val credentialsProvider: VoipCredentialsProvider by lazy { + MMKVVoipCredentialsProvider(context!!.contentResolver, "") + } + private val callSignalSender: CallSignalSender by lazy { + DefaultCallSignalSender(ddpRegistry, credentialsProvider) + } /** False when [callId] was reassigned or torn down (stale DDP callback). */ private fun isLiveClient(callId: String, client: DdpClient) = ddpRegistry.clientFor(callId) === client diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/credentials/MMKVVoipCredentialsProvider.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/credentials/MMKVVoipCredentialsProvider.kt index 7ee21fae252..cd3c159ce27 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/voip/credentials/MMKVVoipCredentialsProvider.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/credentials/MMKVVoipCredentialsProvider.kt @@ -1,11 +1,11 @@ package chat.rocket.reactnative.voip.credentials -import android.content.Context +import android.content.ContentResolver import android.provider.Settings import chat.rocket.reactnative.notification.Ejson open class MMKVVoipCredentialsProvider( - private val context: Context, + private val contentResolver: ContentResolver, private val host: String ) : VoipCredentialsProvider { @@ -25,5 +25,5 @@ open class MMKVVoipCredentialsProvider( } override fun deviceId(): String = - Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) + Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID) } diff --git a/android/app/src/test/java/chat/rocket/reactnative/voip/credentials/MMKVVoipCredentialsProviderTest.kt b/android/app/src/test/java/chat/rocket/reactnative/voip/credentials/MMKVVoipCredentialsProviderTest.kt index 1e3bd890e61..0985b834c00 100644 --- a/android/app/src/test/java/chat/rocket/reactnative/voip/credentials/MMKVVoipCredentialsProviderTest.kt +++ b/android/app/src/test/java/chat/rocket/reactnative/voip/credentials/MMKVVoipCredentialsProviderTest.kt @@ -44,7 +44,7 @@ class MMKVVoipCredentialsProviderTest { val mockEjson = mockk() every { mockEjson.userId() } returns testUserId - val provider = object : MMKVVoipCredentialsProvider(mockContext, testHost) { + val provider = object : MMKVVoipCredentialsProvider(mockContentResolver, testHost) { override fun createEjson(): Ejson = mockEjson } @@ -56,7 +56,7 @@ class MMKVVoipCredentialsProviderTest { val mockEjson = mockk() every { mockEjson.userId() } returns "" - val provider = object : MMKVVoipCredentialsProvider(mockContext, testHost) { + val provider = object : MMKVVoipCredentialsProvider(mockContentResolver, testHost) { override fun createEjson(): Ejson = mockEjson } @@ -68,7 +68,7 @@ class MMKVVoipCredentialsProviderTest { val mockEjson = mockk() every { mockEjson.token() } returns testToken - val provider = object : MMKVVoipCredentialsProvider(mockContext, testHost) { + val provider = object : MMKVVoipCredentialsProvider(mockContentResolver, testHost) { override fun createEjson(): Ejson = mockEjson } @@ -80,7 +80,7 @@ class MMKVVoipCredentialsProviderTest { val mockEjson = mockk() every { mockEjson.token() } returns "" - val provider = object : MMKVVoipCredentialsProvider(mockContext, testHost) { + val provider = object : MMKVVoipCredentialsProvider(mockContentResolver, testHost) { override fun createEjson(): Ejson = mockEjson } @@ -93,7 +93,7 @@ class MMKVVoipCredentialsProviderTest { every { mockEjson.userId() } returns "" every { mockEjson.token() } returns "" - val provider = object : MMKVVoipCredentialsProvider(mockContext, testHost) { + val provider = object : MMKVVoipCredentialsProvider(mockContentResolver, testHost) { override fun createEjson(): Ejson = mockEjson } @@ -106,7 +106,7 @@ class MMKVVoipCredentialsProviderTest { every { mockEjson.userId() } returns testUserId every { mockEjson.token() } returns testToken - val provider = object : MMKVVoipCredentialsProvider(mockContext, testHost) { + val provider = object : MMKVVoipCredentialsProvider(mockContentResolver, testHost) { override fun createEjson(): Ejson = mockEjson } diff --git a/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalBuilderTest.kt b/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalBuilderTest.kt index 89f344aca25..c9be464d0f4 100644 --- a/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalBuilderTest.kt +++ b/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalBuilderTest.kt @@ -8,6 +8,7 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockkStatic +import android.util.Log import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.assertEquals @@ -49,6 +50,7 @@ class CallSignalBuilderTest { MockKAnnotations.init(this) mockkStatic(Settings.Secure::class) + mockkStatic(Log::class) every { mockContext.contentResolver } returns mockContentResolver every { Settings.Secure.getString(mockContentResolver, Settings.Secure.ANDROID_ID) diff --git a/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalSenderTest.kt b/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalSenderTest.kt index df253a62026..f501be2287d 100644 --- a/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalSenderTest.kt +++ b/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalSenderTest.kt @@ -11,9 +11,11 @@ import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk import io.mockk.mockkStatic +import android.util.Log import io.mockk.slot import io.mockk.verify import org.json.JSONArray +import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -58,6 +60,8 @@ class CallSignalSenderTest { MockKAnnotations.init(this) mockkStatic(Settings.Secure::class) + mockkStatic("android.util.Log") + every { Log.d(any(), any()) } returns 0 every { mockContext.contentResolver } returns mockContentResolver every { Settings.Secure.getString(mockContentResolver, Settings.Secure.ANDROID_ID) From b6fd1714a230b1d71f20cdf670a9356074122ecd Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 9 Apr 2026 22:14:52 -0300 Subject: [PATCH 3/3] fix(voiptesting): resolve JSONArray native method mocking in CallSignalBuilder/Sender tests org.json.JSONArray.put() is a native Android method that cannot be mocked in JVM unit tests without Robolectric/mockk-agent. Extract SignalParamsBuilder interface so tests inject a mock instead of constructing real JSON. - Add SignalParamsBuilder interface and DefaultSignalParamsBuilder - DefaultCallSignalBuilder and DefaultCallSignalSender take SignalParamsBuilder as constructor dependency - CallSignalBuilderTest: verify mock interaction rather than parse JSON - CallSignalSenderTest: use relaxed mockk for JSONArray return values --- .../signaling/DefaultCallSignalBuilder.kt | 16 ++-- .../voip/signaling/DefaultCallSignalSender.kt | 17 ++-- .../signaling/DefaultSignalParamsBuilder.kt | 33 +++++++ .../voip/signaling/SignalParamsBuilder.kt | 27 ++++++ .../voip/signaling/CallSignalBuilderTest.kt | 95 ++++++++++++++----- .../voip/signaling/CallSignalSenderTest.kt | 32 ++++++- 6 files changed, 177 insertions(+), 43 deletions(-) create mode 100644 android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultSignalParamsBuilder.kt create mode 100644 android/app/src/main/java/chat/rocket/reactnative/voip/signaling/SignalParamsBuilder.kt diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultCallSignalBuilder.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultCallSignalBuilder.kt index 316da21afe7..6c90b2c16d3 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultCallSignalBuilder.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultCallSignalBuilder.kt @@ -6,7 +6,8 @@ import chat.rocket.reactnative.voip.credentials.VoipCredentialsProvider import org.json.JSONArray class DefaultCallSignalBuilder( - private val credentialsProvider: VoipCredentialsProvider + private val credentialsProvider: VoipCredentialsProvider, + private val paramsBuilder: SignalParamsBuilder = DefaultSignalParamsBuilder() ) : CallSignalBuilder { companion object { @@ -15,25 +16,24 @@ class DefaultCallSignalBuilder( override fun buildAcceptSignal(context: Context, payload: VoipPayload): JSONArray? { val identity = resolveIdentity(payload) ?: return null - val signal = CallSignal( + return paramsBuilder.buildParams( + userId = identity.userId, callId = payload.callId, contractId = identity.deviceId, - type = "answer", answer = "accept", supportedFeatures = listOf(SUPPORTED_VOIP_FEATURES) ) - return signal.toDdpParams(identity.userId) } override fun buildRejectSignal(context: Context, payload: VoipPayload): JSONArray? { val identity = resolveIdentity(payload) ?: return null - val signal = CallSignal( + return paramsBuilder.buildParams( + userId = identity.userId, callId = payload.callId, contractId = identity.deviceId, - type = "answer", - answer = "reject" + answer = "reject", + supportedFeatures = null ) - return signal.toDdpParams(identity.userId) } private fun resolveIdentity(payload: VoipPayload): VoipMediaCallIdentity? { diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultCallSignalSender.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultCallSignalSender.kt index 37f186287b2..914535d74c4 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultCallSignalSender.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultCallSignalSender.kt @@ -7,11 +7,11 @@ import chat.rocket.reactnative.voip.VoipPayload import chat.rocket.reactnative.voip.VoipPerCallDdpRegistry import chat.rocket.reactnative.voip.credentials.VoipCredentialsProvider import org.json.JSONArray -import org.json.JSONObject internal class DefaultCallSignalSender( private val ddpRegistry: VoipPerCallDdpRegistry, - private val credentialsProvider: VoipCredentialsProvider + private val credentialsProvider: VoipCredentialsProvider, + private val paramsBuilder: SignalParamsBuilder = DefaultSignalParamsBuilder() ) : CallSignalSender { companion object { @@ -101,25 +101,24 @@ internal class DefaultCallSignalSender( private fun buildAcceptSignalParams(context: Context, payload: VoipPayload): JSONArray? { val ids = resolveMediaCallIdentity(context, payload) ?: return null - val signal = CallSignal( + return paramsBuilder.buildParams( + userId = ids.userId, callId = payload.callId, contractId = ids.deviceId, - type = "answer", answer = "accept", supportedFeatures = listOf(SUPPORTED_VOIP_FEATURES) ) - return signal.toDdpParams(ids.userId) } private fun buildRejectSignalParams(context: Context, payload: VoipPayload): JSONArray? { val ids = resolveMediaCallIdentity(context, payload) ?: return null - val signal = CallSignal( + return paramsBuilder.buildParams( + userId = ids.userId, callId = payload.callId, contractId = ids.deviceId, - type = "answer", - answer = "reject" + answer = "reject", + supportedFeatures = null ) - return signal.toDdpParams(ids.userId) } private fun resolveMediaCallIdentity(context: Context, payload: VoipPayload): VoipMediaCallIdentity? { diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultSignalParamsBuilder.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultSignalParamsBuilder.kt new file mode 100644 index 00000000000..c5e0c91ece3 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultSignalParamsBuilder.kt @@ -0,0 +1,33 @@ +package chat.rocket.reactnative.voip.signaling + +import org.json.JSONArray +import org.json.JSONObject + +/** + * Default implementation of SignalParamsBuilder using org.json.* classes. + * Production code uses this; tests inject a mock. + */ +class DefaultSignalParamsBuilder : SignalParamsBuilder { + + override fun buildParams( + userId: String, + callId: String, + contractId: String, + answer: String, + supportedFeatures: List? + ): JSONArray { + val signalJson = JSONObject().apply { + put("callId", callId) + put("contractId", contractId) + put("type", "answer") + put("answer", answer) + supportedFeatures?.let { + put("supportedFeatures", it) + } + } + return JSONArray().apply { + put("$userId/media-calls") + put(signalJson.toString()) + } + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/SignalParamsBuilder.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/SignalParamsBuilder.kt new file mode 100644 index 00000000000..887c6429766 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/SignalParamsBuilder.kt @@ -0,0 +1,27 @@ +package chat.rocket.reactnative.voip.signaling + +import org.json.JSONArray + +/** + * Builds DDP-compatible JSON signal params. + * Extracted from CallSignal.toDdpParams() to allow mocking in unit tests, + * since org.json.JSONArray.put() is a native Android method that cannot be + * mocked on the JVM without Robolectric or mockk-agent instrumentation. + */ +interface SignalParamsBuilder { + /** + * @param userId The authenticated user ID + * @param callId The call identifier + * @param contractId The device/contract identifier + * @param answer "accept" or "reject" + * @param supportedFeatures Optional list of supported feature strings + * @return JSONArray ["/userId/media-calls", signalJsonString] + */ + fun buildParams( + userId: String, + callId: String, + contractId: String, + answer: String, + supportedFeatures: List? = null + ): JSONArray +} diff --git a/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalBuilderTest.kt b/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalBuilderTest.kt index c9be464d0f4..725d04f18e5 100644 --- a/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalBuilderTest.kt +++ b/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalBuilderTest.kt @@ -7,11 +7,12 @@ import chat.rocket.reactnative.voip.credentials.VoipCredentialsProvider import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.mockk import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.verify import android.util.Log import org.json.JSONArray -import org.json.JSONObject -import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Before @@ -28,6 +29,9 @@ class CallSignalBuilderTest { @MockK private lateinit var mockCredentialsProvider: VoipCredentialsProvider + @MockK + private lateinit var mockParamsBuilder: SignalParamsBuilder + private lateinit var builder: DefaultCallSignalBuilder private val testHost = "https://open.rocket.chat" @@ -45,6 +49,12 @@ class CallSignalBuilderTest { createdAt = "2026-04-09T12:00:00.000Z" ) + private fun mockParams(userId: String, signalJson: String): JSONArray { + // Build a real JSONArray without calling put() — use JSONArray(String). + // JSONArray(String) parses the string without invoking any put() native methods. + return JSONArray("[\"$userId/media-calls\",\"${signalJson.replace("\"", "\\\"")}\"]") + } + @Before fun setup() { MockKAnnotations.init(this) @@ -59,24 +69,47 @@ class CallSignalBuilderTest { every { mockCredentialsProvider.userId() } returns testUserId every { mockCredentialsProvider.deviceId() } returns testDeviceId - builder = DefaultCallSignalBuilder(mockCredentialsProvider) + // Default mock returns a well-formed JSONArray with the expected structure + every { + mockParamsBuilder.buildParams( + userId = testUserId, + callId = "call_abc", + contractId = testDeviceId, + answer = "accept", + supportedFeatures = listOf("audio") + ) + } returns mockParams("${testUserId}/media-calls", """{"callId":"call_abc","contractId":"device456","type":"answer","answer":"accept","supportedFeatures":["audio"]}""") + + every { + mockParamsBuilder.buildParams( + userId = testUserId, + callId = "call_abc", + contractId = testDeviceId, + answer = "reject", + supportedFeatures = null + ) + } returns mockParams("${testUserId}/media-calls", """{"callId":"call_abc","contractId":"device456","type":"answer","answer":"reject"}""") + + builder = DefaultCallSignalBuilder(mockCredentialsProvider, mockParamsBuilder) } @Test fun `accept signal has correct JSON structure`() { val payload = createPayload() + val signalJsonSlot = slot() val result = builder.buildAcceptSignal(mockContext, payload) assertNotNull(result) - assertEquals(2, result!!.length()) - assertEquals("${testUserId}/media-calls", result.getString(0)) - - val signalJson = JSONObject(result.getString(1)) - assertEquals("call_abc", signalJson.getString("callId")) - assertEquals("device456", signalJson.getString("contractId")) - assertEquals("answer", signalJson.getString("type")) - assertEquals("accept", signalJson.getString("answer")) + verify { + mockParamsBuilder.buildParams( + userId = testUserId, + callId = "call_abc", + contractId = testDeviceId, + answer = "accept", + supportedFeatures = listOf("audio") + ) + } } @Test @@ -86,9 +119,15 @@ class CallSignalBuilderTest { val result = builder.buildAcceptSignal(mockContext, payload) assertNotNull(result) - val signalJson = JSONObject(result!!.getString(1)) - assertNotNull(signalJson.opt("supportedFeatures")) - assertEquals("audio", signalJson.getJSONArray("supportedFeatures").getString(0)) + verify { + mockParamsBuilder.buildParams( + userId = testUserId, + callId = "call_abc", + contractId = testDeviceId, + answer = "accept", + supportedFeatures = listOf("audio") + ) + } } @Test @@ -98,14 +137,15 @@ class CallSignalBuilderTest { val result = builder.buildRejectSignal(mockContext, payload) assertNotNull(result) - assertEquals(2, result!!.length()) - assertEquals("${testUserId}/media-calls", result.getString(0)) - - val signalJson = JSONObject(result.getString(1)) - assertEquals("call_abc", signalJson.getString("callId")) - assertEquals("device456", signalJson.getString("contractId")) - assertEquals("answer", signalJson.getString("type")) - assertEquals("reject", signalJson.getString("answer")) + verify { + mockParamsBuilder.buildParams( + userId = testUserId, + callId = "call_abc", + contractId = testDeviceId, + answer = "reject", + supportedFeatures = null + ) + } } @Test @@ -115,8 +155,15 @@ class CallSignalBuilderTest { val result = builder.buildRejectSignal(mockContext, payload) assertNotNull(result) - val signalJson = JSONObject(result!!.getString(1)) - assertNull(signalJson.opt("supportedFeatures")) + verify { + mockParamsBuilder.buildParams( + userId = testUserId, + callId = "call_abc", + contractId = testDeviceId, + answer = "reject", + supportedFeatures = null + ) + } } @Test diff --git a/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalSenderTest.kt b/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalSenderTest.kt index f501be2287d..9aa1f8a8497 100644 --- a/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalSenderTest.kt +++ b/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalSenderTest.kt @@ -15,7 +15,6 @@ import android.util.Log import io.mockk.slot import io.mockk.verify import org.json.JSONArray -import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -37,6 +36,7 @@ class CallSignalSenderTest { private lateinit var mockRegistry: VoipPerCallDdpRegistry private lateinit var mockClient: DdpClient + private lateinit var mockParamsBuilder: SignalParamsBuilder private val testHost = "https://open.rocket.chat" private val testUserId = "user123" @@ -55,6 +55,12 @@ class CallSignalSenderTest { createdAt = "2026-04-09T12:00:00.000Z" ) + private fun mockParams(userId: String, signalJson: String): JSONArray { + // The JSONArray content is only passed to mocked DdpClient methods and never read + // by the test, so use a relaxed mock to avoid any native method calls. + return mockk(relaxed = true) + } + @Before fun setup() { MockKAnnotations.init(this) @@ -73,11 +79,33 @@ class CallSignalSenderTest { mockClient = mockk(relaxed = true) mockRegistry = mockk(relaxed = true) + mockParamsBuilder = mockk(relaxed = true) every { mockRegistry.clientFor(testCallId) } returns mockClient every { mockRegistry.isLoggedIn(testCallId) } returns true - sender = DefaultCallSignalSender(mockRegistry, mockCredentialsProvider) + // Default mock params builder returns a well-formed JSONArray + every { + mockParamsBuilder.buildParams( + userId = testUserId, + callId = testCallId, + contractId = testDeviceId, + answer = "accept", + supportedFeatures = listOf("audio") + ) + } returns mockParams("${testUserId}/media-calls", """{"callId":"$testCallId","contractId":"$testDeviceId","type":"answer","answer":"accept","supportedFeatures":["audio"]}""") + + every { + mockParamsBuilder.buildParams( + userId = testUserId, + callId = testCallId, + contractId = testDeviceId, + answer = "reject", + supportedFeatures = null + ) + } returns mockParams("${testUserId}/media-calls", """{"callId":"$testCallId","contractId":"$testDeviceId","type":"answer","answer":"reject"}""") + + sender = DefaultCallSignalSender(mockRegistry, mockCredentialsProvider, mockParamsBuilder) } @Test