Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}
Comment on lines +130 to +136
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Avoid a companion-scoped Context for signaling state.

Static flows like DeclineReceiver.onReceive() and handleAcceptAction() can run after a process restart, before any VoipNotification instance executes init. In that case the first accept/reject signal path dereferences context!! here and fails instead of sending the DDP message.

🐛 Proposed fix
-    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"
@@
-        // 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)
-        }
+        private fun callSignalSender(context: Context): CallSignalSender {
+            val appContext = context.applicationContext
+            val credentialsProvider = MMKVVoipCredentialsProvider(appContext.contentResolver, "")
+            return DefaultCallSignalSender(ddpRegistry, credentialsProvider)
+        }

Then update the companion helpers to call callSignalSender(context) instead of the global lazy instance.

Also applies to: 139-180

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt`
around lines 130 - 136, The companion currently stores a Context in
VoipNotification.init (Companion.context) which can be null after a process
restart, causing DeclineReceiver.onReceive() and handleAcceptAction() to
dereference context!! and crash; remove the companion-scoped Context and update
all companion helper methods (including DeclineReceiver.onReceive,
handleAcceptAction and any other companion helpers referenced between lines
~139-180) to accept a Context parameter and call callSignalSender(context) with
that passed context instead of using the global lazy instance or
Companion.context.


companion object {
private var context: Context? = null
private const val TAG = "RocketChat.VoIP"

const val CHANNEL_ID = "voip-call"
Expand All @@ -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<String, Runnable>()
private val ddpRegistry = VoipPerCallDdpRegistry<DDPClient> { client ->
private val ddpRegistry = VoipPerCallDdpRegistry<DdpClient> { 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.
Expand Down Expand Up @@ -347,143 +445,31 @@ 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(
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(
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)
}

/**
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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")

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Comment on lines +27 to +29
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "MMKVVoipCredentialsProvider.kt" | head -5

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 177


🏁 Script executed:

fd "MMKVVoipCredentialsProvider" --type f

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 277


🏁 Script executed:

find . -type f -path "*voip/credentials*" | head -20

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 377


🏁 Script executed:

git ls-files | grep -i "voip\|credentials" | head -20

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 1715


🏁 Script executed:

git ls-files | head -30

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 1103


🏁 Script executed:

rg "MMKVVoipCredentialsProvider" --type kt -l

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 104


🏁 Script executed:

rg "Settings\.Secure\.getString" -l

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 629


🏁 Script executed:

ls -la

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 3421


🏁 Script executed:

rg "fun deviceId" --type kt

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 104


🌐 Web query:

Settings.Secure.getString return type Android documentation nullable

💡 Result:

Settings.Secure.getString(ContentResolver resolver, String name) has a Java return type of String, and the official Android reference explicitly states it may return null (“the corresponding value, or null if not present”). [1]

The AOSP framework source for the same method also documents “or null if not present”. [2]

So, in practice (and in Kotlin) you should treat it as nullable (i.e., String?) and handle the null case. [1][2]


🏁 Script executed:

cat -n android/app/src/main/java/chat/rocket/reactnative/voip/credentials/MMKVVoipCredentialsProvider.kt

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 1109


🏁 Script executed:

cat -n android/app/src/test/java/chat/rocket/reactnative/voip/credentials/MMKVVoipCredentialsProviderTest.kt

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 4528


🏁 Script executed:

cat -n android/app/src/main/java/chat/rocket/reactnative/voip/credentials/VoipCredentialsProvider.kt

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 501


🏁 Script executed:

rg "deviceId()" --context 3

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 28327


Handle nullable return from Settings.Secure.getString() before returning as non-null String.

Settings.Secure.getString() explicitly documents it can return null when ANDROID_ID is not present. The function signature promises a non-null String, creating a contract violation that will cause NullPointerException at runtime. Callers like DefaultMediaCallIdentityResolver, DefaultCallSignalBuilder, and DefaultCallSignalSender call .isEmpty() on the result without null guards. The test suite currently only covers the happy path.

Suggested fix
override fun deviceId(): String =
    Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
+       ?.takeIf { it.isNotBlank() }
+       ?: ""
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
override fun deviceId(): String =
Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
}
override fun deviceId(): String =
Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
?.takeIf { it.isNotBlank() }
?: ""
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@android/app/src/main/java/chat/rocket/reactnative/voip/credentials/MMKVVoipCredentialsProvider.kt`
around lines 27 - 29, MMKVVoipCredentialsProvider.deviceId() currently returns
Settings.Secure.getString(...) which can be null; change deviceId() to handle
the nullable result and return a non-null String (e.g., use a safe fallback like
empty string or a generated fallback ID) so callers
(DefaultMediaCallIdentityResolver, DefaultCallSignalBuilder,
DefaultCallSignalSender) can safely call .isEmpty() without NPE; update
deviceId() to call Settings.Secure.getString(...), check for null, and return
the chosen non-null fallback.

Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading