Skip to content

refactor(voip): extract interfaces and add unit tests for Android DDP layer#7143

Open
diegolmello wants to merge 17 commits intofeat.voip-lib-newfrom
voip-ddp-android-refactor-review-fixes
Open

refactor(voip): extract interfaces and add unit tests for Android DDP layer#7143
diegolmello wants to merge 17 commits intofeat.voip-lib-newfrom
voip-ddp-android-refactor-review-fixes

Conversation

@diegolmello
Copy link
Copy Markdown
Member

@diegolmello diegolmello commented Apr 13, 2026

Proposed changes

Refactor the Android native VoIP DDP signaling code behind injectable interfaces with unit test coverage. The remaining WebSocket-based call-end listener (startListeningForCallEnd) is now clean, testable, and maintainable.

Production code:

  • Extract DdpClient interface from DDPClient (renamed to DdpClientImpl)
  • Extract VoipCredentialsProvider interface + EjsonVoipCredentialsProvider impl from inline Ejson access
  • Extract CallEndSignalDetector from inline signal parsing in onCollectionMessage
  • Create DdpClientFactory for OkHttpClient/DdpClientImpl instantiation
  • Wire all components into VoipNotification via constructor injection with Kotlin default params
  • Remove dead DDP accept/reject/busy methods (now REST-only via MediaCallsAnswerRequest)

Test code (67 tests across 7 classes):

  • DdpClientImplTest — 26 tests covering DDP protocol (connect, login, subscribe, callMethod, ping/pong, disconnect, queue)
  • CallEndSignalDetectorTest — 9 tests covering signal parsing edge cases
  • MediaCallsAnswerRequestTest — 9 tests covering REST accept/reject
  • VoipPerCallDdpRegistryTest — 11 tests including thread safety
  • DdpClientFactoryTest — 4 tests covering factory creation and custom client injection
  • EjsonVoipCredentialsProviderTest — 4 tests covering credential delegation
  • VoipIncomingCallDispatchTest — 4 tests (pre-existing, unchanged)

Note: flushPendingQueuedSignalsIfNeeded removal is intentional — confirmed zero production callers of queueMethodCall.

Issue(s)

https://rocketchat.atlassian.net/browse/VMUX-67

How to test or reproduce

Run Android VoIP unit tests:
```bash
cd android && ./gradlew testOfficialDebugUnitTest --tests "chat.rocket.reactnative.voip.*"
```

No behavioral changes — all refactoring is behind interfaces with default production implementations. Incoming call flow (accept, reject, caller hangup, other-device accept) should work identically.

Screenshots

N/A — no UI changes

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • Improvement (non-breaking change which improves a current function)
  • New feature (non-breaking change which adds functionality)
  • Documentation update (if none of the other choices apply)

Checklist

  • I have read the CONTRIBUTING doc
  • I have signed the CLA
  • Lint and unit tests pass locally with my changes
  • I have added tests that prove my fix is effective or that my feature works (if applicable)
  • I have added necessary documentation (if applicable)
  • Any dependent changes have been merged and published in downstream modules

Further comments

This is the testability/maintainability layer on top of PR #7127 (REST migration). The dependency chain is:

```
feat.voip-lib-new
← refactor.ddp-android-2 (#7127 — REST migration)
← this PR (interface extraction + test coverage)
```

All extracted components use Kotlin default parameter values for DI — no framework needed. In tests, every dependency can be replaced with mocks/fakes.

Summary by CodeRabbit

Release Notes

  • New Features

    • Enhanced detection of call termination scenarios, including when another device accepts the call.
  • Refactor

    • Improved internal architecture for VOIP client management and credential handling.
  • Tests

    • Extended test coverage for VOIP components to improve reliability.

Adds android equivalent of ios/Shared/RocketChat/API/MediaCallsAnswerRequest.swift.
POST /api/v1/media-calls.answer with { callId, contractId, type, answer, supportedFeatures }.
Auth via Ejson (x-user-id / x-auth-token) — same pattern as ReplyBroadcast.
Unblocks Slices 2, 3, 4.
…swerRequest

Address CodeRabbit resource leak warning: reuse the OkHttpClient singleton
instead of creating a new instance per request, and wrap the Response in
response.use {} to ensure the body is closed after use.
Add 5 new tests to VoipPerCallDdpRegistryTest: thread safety with
concurrent put/stop, clientCount/clientIds correctness, stop
non-existent on empty registry, stop non-existent with other clients,
and stopAllClients clears loggedIn state.
Add mockk 1.13.16 test dependency. Remove dead SUPPORTED_VOIP_FEATURES
constant and flushPendingQueuedSignalsIfNeeded() method — both orphaned
after REST migration of accept/reject.
Extract media-signal parsing from VoipNotification.onCollectionMessage
into CallEndSignalDetector with evaluate() API returning NOT_CALL_END,
OTHER_DEVICE_ACCEPTED, or CALLER_HUNG_UP. Add 9 unit tests.
Extract credential resolution from VoipNotification into
VoipCredentialsProvider interface + EjsonVoipCredentialsProvider impl.
startListeningForCallEnd accepts provider param with default.
Extract DdpClient interface from DDPClient class. Rename class to
DdpClientImpl implementing DdpClient. Both in DDPClient.kt to avoid
macOS case-insensitive filesystem collision. Update VoipNotification
references to use interface type.
Add 9 unit tests for MediaCallsAnswerRequest covering accept/reject
body shape, auth headers, success/failure callbacks, missing credentials,
and URL normalization. Make buildBody() and httpClient internal for
test access.
Add 26 unit tests for DdpClientImpl covering connect/login/subscribe/
callMethod, queue+flush+clear, ping/pong, disconnect, onCollectionMessage,
and buildWebSocketURL. Uses mockk for OkHttp/Handler mocking.
Add DdpClientFactory with injectable OkHttpClient. DdpClientImpl now
accepts OkHttpClient constructor param. Wire factory into
startListeningForCallEnd via Kotlin default parameter. Add 4 factory
unit tests.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 13, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 536e4223-3ef4-434b-b04b-8a9419af5f98

📥 Commits

Reviewing files that changed from the base of the PR and between 6fdff77 and c246cf5.

📒 Files selected for processing (1)
  • .github/actions/build-android/action.yml
📜 Recent review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: ESLint and Test / run-eslint-and-test
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T17:49:17.538Z
Learning: Applies to app/lib/services/voip/**/*.{ts,tsx} : Implement VoIP with WebRTC peer-to-peer audio calls in app/lib/services/voip/ using Zustand stores instead of Redux, with native CallKit (iOS) and Telecom (Android) integration; keep VoIP and VideoConf separate
🔇 Additional comments (1)
.github/actions/build-android/action.yml (1)

122-132: Flavor-specific unit test step is correctly wired.

Running testExperimentalDebugUnitTest / testOfficialDebugUnitTest based on inputs.type is consistent with the defined Android flavors and is a good fail-fast addition before AAB build.


Walkthrough

Refactors native DDP client into an interface + implementation, introduces credential provider and factory patterns, adds call-end signal detector, adjusts VoIP notification wiring to use injectable providers, exposes some MediaCallsAnswerRequest internals for testing, and adds extensive unit tests and test deps.

Changes

Cohort / File(s) Summary
Build Configuration
android/app/build.gradle
Added test dependencies: io.mockk:mockk:1.13.16 and org.json:json:20231013.
Call End Signal Detection
android/app/src/main/java/.../CallEndSignalDetector.kt, android/app/src/test/.../CallEndSignalDetectorTest.kt
New CallEndSignalDetector evaluates media-signal JSON messages to determine call termination reasons; unit tests cover hangup, other-device acceptance, and malformed/edge cases.
DDP Client Architecture
android/app/src/main/java/.../DDPClient.kt, android/app/src/main/java/.../DdpClientFactory.kt, android/app/src/test/.../DdpClientFactoryTest.kt, android/app/src/test/.../DdpClientImplTest.kt
Replaced concrete DDPClient with DdpClient interface and DdpClientImpl; introduced DdpClientFactory; made OkHttpClient injectable with internal default; updated onCollectionMessage and queueMethodCall signatures; extensive tests for connection, login, subs, methods, queueing, ping/pong, URL building, and disconnect behavior.
VoIP Credentials Management
android/app/src/main/java/.../VoipCredentialsProvider.kt, android/app/src/main/java/.../EjsonVoipCredentialsProvider.kt, android/app/src/test/.../EjsonVoipCredentialsProviderTest.kt
Added VoipCredentialsProvider interface and EjsonVoipCredentialsProvider implementation (delegates to Ejson, reads Android ID); tests validate delegation and deviceId retrieval.
VoIP Integration & Registry
android/app/src/main/java/.../VoipNotification.kt, android/app/src/main/java/.../VoipPerCallDdpRegistry.kt, android/app/src/test/.../VoipPerCallDdpRegistryTest.kt
startListeningForCallEnd now accepts injectable credentialsProvider and ddpClientFactory; replaces inline parsing with CallEndSignalDetector; removed queued-signal flush; updated types from DDPClientDdpClient; added concurrency and registry tests.
HTTP Request Handling
android/app/src/main/java/.../MediaCallsAnswerRequest.kt, android/app/src/test/.../MediaCallsAnswerRequestTest.kt
Changed companion httpClient to @JvmStatic internal var and buildBody() to internal for module reuse; tests validate request body, auth headers, URL normalization, and response handling.
Tests & CI
.github/actions/build-android/action.yml, android/app/src/test/.../*
Added GitHub composite action step to run Android unit tests (experimental/official branches); added many unit tests across VoIP/Ddp components covering behavior, edge cases, and concurrency.

Sequence Diagram(s)

sequenceDiagram
    participant VN as VoipNotification
    participant CP as VoipCredentialsProvider (Ejson)
    participant DF as DdpClientFactory
    participant DC as DdpClient (WebSocket)
    participant DET as CallEndSignalDetector

    VN->>CP: request token/userId/deviceId
    CP-->>VN: returns token, userId, deviceId
    VN->>DF: create()
    DF-->>VN: returns DdpClientImpl
    VN->>DC: connect(host)
    DC-->>VN: connected event
    VN->>DC: login(token)
    VN->>DC: subscribe("stream-room-messages", ...)
    DC-->>VN: onCollectionMessage(message)
    VN->>DET: evaluate(message) with callId, deviceId
    DET-->>VN: Result (NOT_CALL_END / OTHER_DEVICE_ACCEPTED / CALLER_HUNG_UP)
    alt CALL_END detected
        VN->>VN: cancel timeouts, disconnect call, cancel notification
        VN->>DC: stop/cleanup client
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested labels

type: chore

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.48% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: extracting interfaces and adding unit tests for the Android DDP layer, which is the core focus of this refactoring PR.
Linked Issues check ✅ Passed The PR successfully addresses VMUX-67 by extracting DdpClient interface, introducing VoipCredentialsProvider, CallEndSignalDetector, and DdpClientFactory, while adding 67 unit tests across 7 test classes covering the refactored code.
Out of Scope Changes check ✅ Passed All changes align with the stated objectives of refactoring the VoIP DDP layer and adding unit tests; dependency additions (mockk, json library), CI workflow updates, and removal of dead code are all in-scope for this refactoring effort.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@diegolmello diegolmello changed the title fix(voip): review fixes for Android DDP refactor refactor(voip): extract interfaces and add unit tests for Android DDP layer Apr 13, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt (1)

55-84: ⚠️ Potential issue | 🟠 Major

Only complete the connect callback while the handshake is still pending.

onFailure() currently posts callback(false) unconditionally. That can double-fire the initial connect callback when the socket dies after onOpen() but before "connected", and it can also report a failed connect after a previously successful handshake.

Proposed fix
         webSocket = client.newWebSocket(request, object : WebSocketListener() {
             override fun onOpen(webSocket: WebSocket, response: Response) {
                 Log.d(TAG, "WebSocket opened")
                 val connectMsg = JSONObject().apply {
@@
             override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
                 Log.e(TAG, "WebSocket failure: ${t.message}")
-                isConnected = false
-                mainHandler.post { callback(false) }
+                val wasConnected = isConnected
+                isConnected = false
+                val pendingConnectCallback = connectedCallback
+                connectedCallback = null
+                if (!wasConnected) {
+                    mainHandler.post { (pendingConnectCallback ?: callback)(false) }
+                }
             }
🤖 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/DDPClient.kt` around
lines 55 - 84, The onFailure handler currently always posts callback(false)
which can double-fire the initial connect callback or report failure after a
successful handshake; change onFailure in the WebSocketListener so it only
invokes mainHandler.post { callback(false) } when the handshake hasn't completed
(e.g., guard with if (!isConnected) before posting), keep setting isConnected =
false as needed, and rely on waitForConnected/onOpen to complete the callback
when the handshake succeeds.
🧹 Nitpick comments (2)
android/app/src/test/java/chat/rocket/reactnative/voip/VoipPerCallDdpRegistryTest.kt (1)

85-111: Make executor shutdown failure-safe in concurrency test.

If an assertion fails before shutdown(), threads can leak and affect subsequent tests. Move pool shutdown into finally (or use shutdownNow() in failure paths).

Safer teardown pattern
-        repeat(threads) { t ->
-            pool.submit {
-                try {
-                    for (i in 0 until iterations) {
-                        val callId = "call-${t}-${i}"
-                        reg.putClient(callId, "client-${t}-${i}")
-                        if (i % 3 == 0) reg.stopClient(callId)
-                    }
-                } finally {
-                    latch.countDown()
-                }
-            }
-        }
-
-        assertTrue(latch.await(10, TimeUnit.SECONDS))
-        pool.shutdown()
+        try {
+            repeat(threads) { t ->
+                pool.submit {
+                    try {
+                        for (i in 0 until iterations) {
+                            val callId = "call-${t}-${i}"
+                            reg.putClient(callId, "client-${t}-${i}")
+                            if (i % 3 == 0) reg.stopClient(callId)
+                        }
+                    } finally {
+                        latch.countDown()
+                    }
+                }
+            }
+            assertTrue(latch.await(10, TimeUnit.SECONDS))
+        } finally {
+            pool.shutdownNow()
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@android/app/src/test/java/chat/rocket/reactnative/voip/VoipPerCallDdpRegistryTest.kt`
around lines 85 - 111, The test VoipPerCallDdpRegistryTest::`concurrent put and
stop does not crash or corrupt state` can leak threads if an assertion fails
before pool.shutdown(); wrap the executor lifecycle in a try/finally so
pool.shutdown()/pool.shutdownNow() is always called: move pool.shutdown() into a
finally block, call pool.shutdownNow() if awaitTermination times out or an
exception occurs, and still assert latch.await(...) and perform
pool.awaitTermination(...) to ensure threads are terminated; reference the local
variables pool, latch, threads, iterations, and the test method name when
applying the change.
android/app/src/main/java/chat/rocket/reactnative/voip/MediaCallsAnswerRequest.kt (1)

39-40: Prefer avoiding mutable global httpClient state.

internal var in companion creates cross-call shared mutable state. Consider injecting client per request/factory with an immutable default to keep tests isolated without widening production mutability.

Refactor sketch (immutable default + injectable override)
 class MediaCallsAnswerRequest(
     private val callId: String,
     private val contractId: String,
     private val answer: String,
-    private val supportedFeatures: List<String>? = null
+    private val supportedFeatures: List<String>? = null,
+    private val httpClient: OkHttpClient = defaultHttpClient
 ) {
     companion object {
         private const val TAG = "RocketChat.MediaCallsAnswerRequest"
         private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
-        `@JvmStatic`
-        internal var httpClient: OkHttpClient = OkHttpClient()
+        `@JvmStatic`
+        internal val defaultHttpClient: OkHttpClient = OkHttpClient()

         `@JvmStatic`
         fun fetch(
             context: android.content.Context,
             host: String,
             callId: String,
             contractId: String,
             answer: String,
             supportedFeatures: List<String>? = null,
+            httpClient: OkHttpClient = defaultHttpClient,
             onResult: (Boolean) -> Unit
         ) {
-            val request = MediaCallsAnswerRequest(callId, contractId, answer, supportedFeatures)
+            val request = MediaCallsAnswerRequest(callId, contractId, answer, supportedFeatures, httpClient)
             request.execute(context, host, onResult)
         }
     }
🤖 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/MediaCallsAnswerRequest.kt`
around lines 39 - 40, The companion-level mutable httpClient (internal var
httpClient) creates shared mutable state; change it to an immutable default and
allow injection per request: make the companion hold a private val
DEFAULT_HTTP_CLIENT = OkHttpClient() and update MediaCallsAnswerRequest (or its
request factory/method) to accept an OkHttpClient parameter (optional,
defaulting to DEFAULT_HTTP_CLIENT) so callers/tests can provide a custom client
without widening global mutability.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@android/app/src/main/java/chat/rocket/reactnative/voip/VoipCredentialsProvider.kt`:
- Around line 3-6: The interface VoipCredentialsProvider declares deviceId():
String but Android's Settings.Secure.getString(...) can return null, causing a
contract violation; update the contract by making deviceId() nullable
(deviceId(): String?) or explicitly document and implement a guaranteed fallback
inside implementations (e.g., generate/persist a UUID) and adjust all usages of
VoipCredentialsProvider.deviceId to handle null or the fallback; reference the
VoipCredentialsProvider interface and Settings.Secure.getString when applying
the change.

In
`@android/app/src/test/java/chat/rocket/reactnative/voip/DdpClientFactoryTest.kt`:
- Around line 6-11: The test imports for MockK are missing the any() matcher;
update the imports in DdpClientFactoryTest.kt to include io.mockk.any so the
stubs and verifications that reference any() (used in the mock setups and verify
calls) compile and run correctly—look for usages of any() in the test stubs and
verify blocks and add the import alongside other io.mockk imports.

In
`@android/app/src/test/java/chat/rocket/reactnative/voip/MediaCallsAnswerRequestTest.kt`:
- Around line 6-12: The test file MediaCallsAnswerRequestTest.kt is missing
MockK helper imports causing compilation failures; add the missing imports for
any, anyConstructed, and capture from MockK (e.g., import io.mockk.any, import
io.mockk.anyConstructed, import io.mockk.capture) so usages like any(),
anyConstructed<Ejson>(), and capture(slot) resolve; update the import block
alongside the existing mockk imports (mockk, mockkConstructor, mockkStatic,
slot, unmockkAll, verify, every) to include these helpers.

---

Outside diff comments:
In `@android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt`:
- Around line 55-84: The onFailure handler currently always posts
callback(false) which can double-fire the initial connect callback or report
failure after a successful handshake; change onFailure in the WebSocketListener
so it only invokes mainHandler.post { callback(false) } when the handshake
hasn't completed (e.g., guard with if (!isConnected) before posting), keep
setting isConnected = false as needed, and rely on waitForConnected/onOpen to
complete the callback when the handshake succeeds.

---

Nitpick comments:
In
`@android/app/src/main/java/chat/rocket/reactnative/voip/MediaCallsAnswerRequest.kt`:
- Around line 39-40: The companion-level mutable httpClient (internal var
httpClient) creates shared mutable state; change it to an immutable default and
allow injection per request: make the companion hold a private val
DEFAULT_HTTP_CLIENT = OkHttpClient() and update MediaCallsAnswerRequest (or its
request factory/method) to accept an OkHttpClient parameter (optional,
defaulting to DEFAULT_HTTP_CLIENT) so callers/tests can provide a custom client
without widening global mutability.

In
`@android/app/src/test/java/chat/rocket/reactnative/voip/VoipPerCallDdpRegistryTest.kt`:
- Around line 85-111: The test VoipPerCallDdpRegistryTest::`concurrent put and
stop does not crash or corrupt state` can leak threads if an assertion fails
before pool.shutdown(); wrap the executor lifecycle in a try/finally so
pool.shutdown()/pool.shutdownNow() is always called: move pool.shutdown() into a
finally block, call pool.shutdownNow() if awaitTermination times out or an
exception occurs, and still assert latch.await(...) and perform
pool.awaitTermination(...) to ensure threads are terminated; reference the local
variables pool, latch, threads, iterations, and the test method name when
applying the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f460a446-780e-421e-8dbc-6f1a7a90d131

📥 Commits

Reviewing files that changed from the base of the PR and between b85201a and 6fdff77.

📒 Files selected for processing (15)
  • android/app/build.gradle
  • android/app/src/main/java/chat/rocket/reactnative/voip/CallEndSignalDetector.kt
  • android/app/src/main/java/chat/rocket/reactnative/voip/DDPClient.kt
  • android/app/src/main/java/chat/rocket/reactnative/voip/DdpClientFactory.kt
  • android/app/src/main/java/chat/rocket/reactnative/voip/EjsonVoipCredentialsProvider.kt
  • android/app/src/main/java/chat/rocket/reactnative/voip/MediaCallsAnswerRequest.kt
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipCredentialsProvider.kt
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipPerCallDdpRegistry.kt
  • android/app/src/test/java/chat/rocket/reactnative/voip/CallEndSignalDetectorTest.kt
  • android/app/src/test/java/chat/rocket/reactnative/voip/DdpClientFactoryTest.kt
  • android/app/src/test/java/chat/rocket/reactnative/voip/DdpClientImplTest.kt
  • android/app/src/test/java/chat/rocket/reactnative/voip/EjsonVoipCredentialsProviderTest.kt
  • android/app/src/test/java/chat/rocket/reactnative/voip/MediaCallsAnswerRequestTest.kt
  • android/app/src/test/java/chat/rocket/reactnative/voip/VoipPerCallDdpRegistryTest.kt
📜 Review details
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T17:49:17.538Z
Learning: Applies to app/lib/services/voip/**/*.{ts,tsx} : Implement VoIP with WebRTC peer-to-peer audio calls in app/lib/services/voip/ using Zustand stores instead of Redux, with native CallKit (iOS) and Telecom (Android) integration; keep VoIP and VideoConf separate
📚 Learning: 2026-03-31T11:59:31.061Z
Learnt from: Rohit3523
Repo: RocketChat/Rocket.Chat.ReactNative PR: 6875
File: android/build.gradle:3-8
Timestamp: 2026-03-31T11:59:31.061Z
Learning: In the RocketChat/Rocket.Chat.ReactNative repository, the React Native upgrade helper (https://react-native-community.github.io/upgrade-helper/?from=0.79.4&to=0.81.5) recommends kotlinVersion = "2.1.20", compileSdkVersion = 36, targetSdkVersion = 36, and buildToolsVersion = "36.0.0" in android/build.gradle for the RN 0.79.4 → 0.81.5 upgrade. These are the sanctioned values for this upgrade path and should not be flagged as compatibility concerns.

Applied to files:

  • android/app/build.gradle
📚 Learning: 2026-04-07T17:49:17.538Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T17:49:17.538Z
Learning: Applies to app/lib/services/voip/**/*.{ts,tsx} : Implement VoIP with WebRTC peer-to-peer audio calls in app/lib/services/voip/ using Zustand stores instead of Redux, with native CallKit (iOS) and Telecom (Android) integration; keep VoIP and VideoConf separate

Applied to files:

  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipCredentialsProvider.kt
  • android/app/src/test/java/chat/rocket/reactnative/voip/VoipPerCallDdpRegistryTest.kt
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt
🔇 Additional comments (5)
android/app/src/main/java/chat/rocket/reactnative/voip/VoipPerCallDdpRegistry.kt (1)

4-4: KDoc type reference fix is correct.

Nice cleanup — the symbol now matches the actual DdpClient type name.

android/app/build.gradle (1)

158-159: Test dependency additions are appropriately scoped.

Using testImplementation here keeps runtime APK surface unchanged while enabling the new unit tests.

android/app/src/main/java/chat/rocket/reactnative/voip/DdpClientFactory.kt (1)

5-8: Factory injection pattern looks solid.

This is a clean testability/DI improvement and keeps construction concerns centralized.

android/app/src/test/java/chat/rocket/reactnative/voip/EjsonVoipCredentialsProviderTest.kt (1)

33-55: Coverage is well-targeted for provider behavior.

Good set of tests for delegation and null propagation, including the Android ID branch.

android/app/src/main/java/chat/rocket/reactnative/voip/EjsonVoipCredentialsProvider.kt (1)

12-16: Helper extraction is a nice cleanup.

Centralizing Ejson creation in ejson() makes the delegation methods simpler and less repetitive.

Comment on lines +3 to +6
interface VoipCredentialsProvider {
fun userId(): String?
fun token(): String?
fun deviceId(): String
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 | 🟠 Major

🧩 Analysis chain

🌐 Web query:

According to official Android documentation, can Settings.Secure.getString(ContentResolver, Settings.Secure.ANDROID_ID) return null? If yes, list the documented conditions.

💡 Result:

Yes, according to official Android documentation, Settings.Secure.getString(ContentResolver, Settings.Secure.ANDROID_ID) can return null. The documented condition is when the setting is not present in the database.

Citations:


🏁 Script executed:

# Find and inspect the VoipCredentialsProvider file
find . -type f -name "VoipCredentialsProvider.kt" | head -20

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 161


🏁 Script executed:

# Also search for any implementations of VoipCredentialsProvider
rg "implements VoipCredentialsProvider|: VoipCredentialsProvider" -t kt -A 10

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 104


🏁 Script executed:

# Read the VoipCredentialsProvider.kt file
cat -n ./android/app/src/main/java/chat/rocket/reactnative/voip/VoipCredentialsProvider.kt

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 280


🏁 Script executed:

# Search for implementations using broader pattern matching
rg "VoipCredentialsProvider" --type java -A 15 | head -100

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 60


🏁 Script executed:

# Search for implementations in broader scope
rg "class.*implements|: VoipCredentialsProvider" --type java -A 20 -B 2

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 10521


🏁 Script executed:

# Also search within voip directory for deviceId implementations
find ./android/app/src/main/java/chat/rocket/reactnative/voip -type f -name "*.java" -o -name "*.kt" | xargs grep -l "deviceId" 2>/dev/null

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 409


🏁 Script executed:

# Read the implementation that handles deviceId
cat -n ./android/app/src/main/java/chat/rocket/reactnative/voip/EjsonVoipCredentialsProvider.kt

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 842


deviceId() contract is too strict for Android ID source.

Returning non-null String here creates a type contract violation: Settings.Secure.getString() is documented to return null when the setting is absent, yet the implementation assigns it directly without null handling. This forces unsafe behavior or crashes at runtime. Make this nullable or define a guaranteed fallback policy.

Proposed contract adjustment
 interface VoipCredentialsProvider {
     fun userId(): String?
     fun token(): String?
-    fun deviceId(): String
+    fun deviceId(): String?
 }
📝 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
interface VoipCredentialsProvider {
fun userId(): String?
fun token(): String?
fun deviceId(): String
interface VoipCredentialsProvider {
fun userId(): String?
fun token(): String?
fun deviceId(): String?
}
🤖 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/VoipCredentialsProvider.kt`
around lines 3 - 6, The interface VoipCredentialsProvider declares deviceId():
String but Android's Settings.Secure.getString(...) can return null, causing a
contract violation; update the contract by making deviceId() nullable
(deviceId(): String?) or explicitly document and implement a guaranteed fallback
inside implementations (e.g., generate/persist a UUID) and adjust all usages of
VoipCredentialsProvider.deviceId to handle null or the fallback; reference the
VoipCredentialsProvider interface and Settings.Secure.getString when applying
the change.

Comment on lines +6 to +11
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
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:

#!/bin/bash
file="android/app/src/test/java/chat/rocket/reactnative/voip/DdpClientFactoryTest.kt"
# Expect: `any(` usages are present, but no `import io.mockk.any`.
rg -n 'import io\.mockk\.|\bany\(' "$file"

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 410


Add missing import for any() matcher.

The file uses explicit MockK imports but references any() in the stubs (lines 30–31) and verification (line 63) without importing it. Add import io.mockk.any to the import block.

Proposed fix
+import io.mockk.any
 import io.mockk.every
 import io.mockk.mockk
 import io.mockk.mockkConstructor
 import io.mockk.mockkStatic
 import io.mockk.unmockkAll
 import io.mockk.verify
📝 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
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import io.mockk.any
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@android/app/src/test/java/chat/rocket/reactnative/voip/DdpClientFactoryTest.kt`
around lines 6 - 11, The test imports for MockK are missing the any() matcher;
update the imports in DdpClientFactoryTest.kt to include io.mockk.any so the
stubs and verifications that reference any() (used in the mock setups and verify
calls) compile and run correctly—look for usages of any() in the test stubs and
verify blocks and add the import alongside other io.mockk imports.

Comment on lines +6 to +12
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkAll
import io.mockk.verify
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:

#!/bin/bash
file="android/app/src/test/java/chat/rocket/reactnative/voip/MediaCallsAnswerRequestTest.kt"
# Expect: helper usages below without matching imports for `any`, `capture`, and `anyConstructed`.
rg -n 'import io\.mockk\.|anyConstructed<|capture\(|\bany\(' "$file"

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 1103


Add the missing MockK imports or this test file won't compile.

The test uses any(), anyConstructed<Ejson>(), and capture(...) at lines 42–216, but these helpers are not imported. Add:

Proposed fix
+import io.mockk.any
+import io.mockk.anyConstructed
+import io.mockk.capture
 import io.mockk.every
 import io.mockk.mockk
 import io.mockk.mockkConstructor
 import io.mockk.mockkStatic
 import io.mockk.slot
 import io.mockk.unmockkAll
 import io.mockk.verify
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@android/app/src/test/java/chat/rocket/reactnative/voip/MediaCallsAnswerRequestTest.kt`
around lines 6 - 12, The test file MediaCallsAnswerRequestTest.kt is missing
MockK helper imports causing compilation failures; add the missing imports for
any, anyConstructed, and capture from MockK (e.g., import io.mockk.any, import
io.mockk.anyConstructed, import io.mockk.capture) so usages like any(),
anyConstructed<Ejson>(), and capture(slot) resolve; update the import block
alongside the existing mockk imports (mockk, mockkConstructor, mockkStatic,
slot, unmockkAll, verify, every) to include these helpers.

@diegolmello diegolmello requested a deployment to approve_e2e_testing April 13, 2026 16:47 — with GitHub Actions Waiting
@diegolmello diegolmello requested a deployment to official_android_build April 13, 2026 16:51 — with GitHub Actions Waiting
@diegolmello diegolmello requested a deployment to experimental_ios_build April 13, 2026 16:51 — with GitHub Actions Waiting
@diegolmello diegolmello requested a deployment to experimental_android_build April 13, 2026 16:51 — with GitHub Actions Waiting
@diegolmello
Copy link
Copy Markdown
Member Author

CI Flow — Android Unit Tests

The Android unit tests (67 tests across 7 classes in chat.rocket.reactnative.voip) now run in CI as part of the build pipeline:

┌─────────────────────────────────────────────────────────┐
│                    build-android action                  │
│                   (.github/actions/build-android)          │
└─────────────────────────────────────────────────────────┘
                            │
        ┌───────────────────┼───────────────────┐
        ▼                   ▼                   ▼
   Checkout +         Set up Gradle +        Decode
   Setup Node         Cache (~4s)          Keystore
        │                   │                   │
        ▼                   └───────────────────┘
   yarn lint                                    │
   yarn test                                    │
   (JS/TS only)                                 │
        │                                       │
        ▼                                       ▼
┌─────────────────────────────────────────────────────────┐
│  Run Android Unit Tests (NEW)                          │
│  ./gradlew test<Flavor>DebugUnitTest                 │
│                                                         │
│  experimental → testExperimentalDebugUnitTest         │
│  official    → testOfficialDebugUnitTest              │
│                                                         │
│  7 test files, 67 tests, ~seconds                      │
│  Fail → build stops here, no AAB built               │
└─────────────────────────────────────────────────────────┘
        │
        ▼
┌─────────────────────────────────────────────────────────┐
│  Build Android Release AAB                             │
│  ./gradlew bundle<Flavor>Release                       │
└─────────────────────────────────────────────────────────┘
        │
        ▼
  Upload Artifact + Bugsnag

Key points:

  • Tests run before AAB build — fail fast
  • Flavor-matched (experimental / official) to align with build variant
  • Reuses existing Gradle/JDK/cache — no new infra
  • Debug variant (no ProGuard, no signing) — fast compilation

Test files added in this PR:

  • CallEndSignalDetectorTest.kt (9 tests)
  • DdpClientFactoryTest.kt (4 tests)
  • DdpClientImplTest.kt (26 tests)
  • EjsonVoipCredentialsProviderTest.kt (4 tests)
  • MediaCallsAnswerRequestTest.kt (9 tests)
  • VoipPerCallDdpRegistryTest.kt (11 tests)
  • VoipIncomingCallDispatchTest.kt (4 tests, pre-existing)

@diegolmello
Copy link
Copy Markdown
Member Author

Architecture Flow

flowchart TB
    subgraph VoipNotification["VoipNotification"]
        A([onMessageReceived]) --> B{decideIncomingVoipPushAction}
        B -->|STALE| B1([skip])
        B -->|REJECT_BUSY| C1[rejectBusyCall]
        B -->|SHOW_INCOMING| D[showIncomingCall]

        D --> D1[registerCallWithTelecomManager]
        D --> D2[showIncomingCallNotification]
        D --> D3[scheduleTimeout]
        D --> D4[startListeningForCallEnd]
    end

    subgraph startListeningForCallEnd["startListeningForCallEnd"]
        E1[VoipCredentialsProvider<br/>EjsonVoipCredentialsProvider]
        E2[DdpClientFactory<br/>creates DdpClientImpl]
        E3[DdpClient<br/>interface]
        E4[VoipPerCallDdpRegistry<br/>per-call client slots]

        E1 -->|userId / token / deviceId| E3
        E2 -->|create| E3
        E3 -->|putClient| E4

        E3 -->|onCollectionMessage| E5[CallEndSignalDetector<br/>evaluate]
        E5 -->|Result| E6{Result?}
        E6 -->|OTHER_DEVICE_ACCEPTED<br/>or CALLER_HUNG_UP| E7[disconnectIncomingCall<br/>cancelTimeout<br/>cancelNotification<br/>ACTION_DISMISS<br/>stopClient]
    end

    subgraph DDP_Lifecycle["DDP Lifecycle (inside startListeningForCallEnd)"]
        F1[connect] -->|WebSocket open| F2[login<br/>resume token]
        F2 -->|loggedIn| F3[subscribe<br/>stream-notify-user]
        F3 -->|onCollectionMessage| F4[handleMessage<br/>ping/pong, result, ready<br/>changed/added/removed]
        F4 -->|collection msg| F5[CallEndSignalDetector]
    end

    subgraph Actions["Actions (from notification/activity)"]
        G1[handleDeclineAction] -->|REST| H1[MediaCallsAnswerRequest<br/>answer: reject]
        G2[handleAcceptAction] -->|REST| H2[MediaCallsAnswerRequest<br/>answer: accept]
        H2 -->|success| H3[answerIncomingCall<br/>VoiceConnection.onAnswer]
        G3[rejectBusyCall] -->|REST| H4[MediaCallsAnswerRequest<br/>answer: reject]
    end

    subgraph Interfaces["Extracted Interfaces (this PR)"]
        I1[DdpClient<br/>interface]
        I2[VoipCredentialsProvider<br/>interface]
        I3[CallEndSignalDetector<br/>extracted from onMessage parsing]
        I4[DdpClientFactory<br/>factory for OkHttpClient/DdpClientImpl]
    end

    style I1 fill:#bbf,stroke:#0066cc,stroke-width:2px
    style I2 fill:#bbf,stroke:#0066cc,stroke-width:2px
    style I3 fill:#bbf,stroke:#0066cc,stroke-width:2px
    style I4 fill:#bbf,stroke:#0066cc,stroke-width:2px
Loading

Key design decisions

Constructor injection with defaults — every injectable dep has a Kotlin default param in startListeningForCallEnd:

credentialsProvider = EjsonVoipCredentialsProvider(...)
ddpClientFactory = DdpClientFactory()

No DI framework needed. Tests swap any dep with mocks/fakes.

VoipPerCallDdpRegistry — isolates teardown per callId. A busy-call reject disconnects only its own DDP client; in-progress calls are unaffected.

MediaCallsAnswerRequest — replaces dead DDP accept/reject/busy methods. REST-only via POST /api/v1/media-calls.answer with auth from Ejson.

CallEndSignalDetector — pure parsing logic extracted from onCollectionMessage. Evaluates stream-notify-user messages for notification=hangup or signedContractId != deviceId (other device accepted).

Test coverage (67 tests, 7 classes)

Class Tests What it covers
DdpClientImplTest 26 connect, login, subscribe, callMethod, ping/pong, disconnect, queue
CallEndSignalDetectorTest 9 signal parsing edge cases
MediaCallsAnswerRequestTest 9 REST accept/reject
VoipPerCallDdpRegistryTest 11 thread safety, per-call isolation
DdpClientFactoryTest 4 factory creation, custom client injection
EjsonVoipCredentialsProviderTest 4 credential delegation
VoipIncomingCallDispatchTest 4 pre-existing, unchanged

All deps injectable → every class fully testable without framework.

Base automatically changed from refactor.ddp-android-2 to feat.voip-lib-new April 16, 2026 17:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant