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..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 @@ -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 @@ -44,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" @@ -70,19 +159,28 @@ 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) + + // 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 + private fun isLiveClient(callId: String, client: DdpClient) = ddpRegistry.clientFor(callId) === client /** * Cancels a VoIP notification by ID. @@ -347,44 +445,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 +461,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 +469,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 +558,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 +604,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..cd3c159ce27 --- /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.ContentResolver +import android.provider.Settings +import chat.rocket.reactnative.notification.Ejson + +open class MMKVVoipCredentialsProvider( + private val contentResolver: ContentResolver, + 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(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..6c90b2c16d3 --- /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, + private val paramsBuilder: SignalParamsBuilder = DefaultSignalParamsBuilder() +) : CallSignalBuilder { + + companion object { + private const val SUPPORTED_VOIP_FEATURES = "audio" + } + + override fun buildAcceptSignal(context: Context, payload: VoipPayload): JSONArray? { + val identity = resolveIdentity(payload) ?: return null + return paramsBuilder.buildParams( + userId = identity.userId, + callId = payload.callId, + contractId = identity.deviceId, + answer = "accept", + supportedFeatures = listOf(SUPPORTED_VOIP_FEATURES) + ) + } + + override fun buildRejectSignal(context: Context, payload: VoipPayload): JSONArray? { + val identity = resolveIdentity(payload) ?: return null + return paramsBuilder.buildParams( + userId = identity.userId, + callId = payload.callId, + contractId = identity.deviceId, + answer = "reject", + supportedFeatures = null + ) + } + + 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..914535d74c4 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/signaling/DefaultCallSignalSender.kt @@ -0,0 +1,139 @@ +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 + +internal class DefaultCallSignalSender( + private val ddpRegistry: VoipPerCallDdpRegistry, + private val credentialsProvider: VoipCredentialsProvider, + private val paramsBuilder: SignalParamsBuilder = DefaultSignalParamsBuilder() +) : 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 + return paramsBuilder.buildParams( + userId = ids.userId, + callId = payload.callId, + contractId = ids.deviceId, + answer = "accept", + supportedFeatures = listOf(SUPPORTED_VOIP_FEATURES) + ) + } + + private fun buildRejectSignalParams(context: Context, payload: VoipPayload): JSONArray? { + val ids = resolveMediaCallIdentity(context, payload) ?: return null + return paramsBuilder.buildParams( + userId = ids.userId, + callId = payload.callId, + contractId = ids.deviceId, + answer = "reject", + supportedFeatures = null + ) + } + + 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/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/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/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/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..0985b834c00 --- /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(mockContentResolver, 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(mockContentResolver, 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(mockContentResolver, 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(mockContentResolver, 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(mockContentResolver, 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(mockContentResolver, 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..725d04f18e5 --- /dev/null +++ b/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalBuilderTest.kt @@ -0,0 +1,208 @@ +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.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.verify +import android.util.Log +import org.json.JSONArray +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 + + @MockK + private lateinit var mockParamsBuilder: SignalParamsBuilder + + 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" + ) + + 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) + + mockkStatic(Settings.Secure::class) + mockkStatic(Log::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 + + // 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) + verify { + mockParamsBuilder.buildParams( + userId = testUserId, + callId = "call_abc", + contractId = testDeviceId, + answer = "accept", + supportedFeatures = listOf("audio") + ) + } + } + + @Test + fun `accept signal includes supportedFeatures only on accept`() { + val payload = createPayload() + + val result = builder.buildAcceptSignal(mockContext, payload) + + assertNotNull(result) + verify { + mockParamsBuilder.buildParams( + userId = testUserId, + callId = "call_abc", + contractId = testDeviceId, + answer = "accept", + supportedFeatures = listOf("audio") + ) + } + } + + @Test + fun `reject signal has correct JSON structure`() { + val payload = createPayload() + + val result = builder.buildRejectSignal(mockContext, payload) + + assertNotNull(result) + verify { + mockParamsBuilder.buildParams( + userId = testUserId, + callId = "call_abc", + contractId = testDeviceId, + answer = "reject", + supportedFeatures = null + ) + } + } + + @Test + fun `reject signal does not include supportedFeatures`() { + val payload = createPayload() + + val result = builder.buildRejectSignal(mockContext, payload) + + assertNotNull(result) + verify { + mockParamsBuilder.buildParams( + userId = testUserId, + callId = "call_abc", + contractId = testDeviceId, + answer = "reject", + supportedFeatures = null + ) + } + } + + @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..9aa1f8a8497 --- /dev/null +++ b/android/app/src/test/java/chat/rocket/reactnative/voip/signaling/CallSignalSenderTest.kt @@ -0,0 +1,234 @@ +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 android.util.Log +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 lateinit var mockParamsBuilder: SignalParamsBuilder + + 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" + ) + + 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) + + 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) + } 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) + mockParamsBuilder = mockk(relaxed = true) + + every { mockRegistry.clientFor(testCallId) } returns mockClient + every { mockRegistry.isLoggedIn(testCallId) } returns true + + // 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 + 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