Skip to content

Commit d43faa0

Browse files
authored
feat(voip): call waiting, busy detection, and videoconf blocking (#7077)
1 parent 81dba28 commit d43faa0

44 files changed

Lines changed: 1298 additions & 107 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

android/app/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ dependencies {
153153

154154
// For SecureKeystore (EncryptedSharedPreferences)
155155
implementation 'androidx.security:security-crypto:1.1.0'
156+
157+
testImplementation 'junit:junit:4.13.2'
156158
}
157159

158160
apply plugin: 'com.google.gms.google-services'
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package chat.rocket.reactnative.voip
2+
3+
/**
4+
* Pure routing for an incoming VoIP FCM push after [VoipPayload.isVoipIncomingCall] is true.
5+
* Stale (invalid or expired lifetime) pushes must not reach busy vs show branching.
6+
*/
7+
internal enum class VoipIncomingPushAction {
8+
STALE,
9+
REJECT_BUSY,
10+
SHOW_INCOMING
11+
}
12+
13+
internal fun decideIncomingVoipPushAction(
14+
isValidForIncomingHandling: Boolean,
15+
hasActiveCall: Boolean
16+
): VoipIncomingPushAction {
17+
if (!isValidForIncomingHandling) {
18+
return VoipIncomingPushAction.STALE
19+
}
20+
return if (hasActiveCall) {
21+
VoipIncomingPushAction.REJECT_BUSY
22+
} else {
23+
VoipIncomingPushAction.SHOW_INCOMING
24+
}
25+
}

android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt

Lines changed: 185 additions & 37 deletions
Large diffs are not rendered by default.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package chat.rocket.reactnative.voip
2+
3+
/**
4+
* Per-call DDP client slots: each [callId] maps to at most one [DDPClient] in production.
5+
* Isolates teardown so a busy-call reject (call B) does not disconnect call A's listener.
6+
*/
7+
internal class VoipPerCallDdpRegistry<T : Any>(
8+
private val releaseClient: (T) -> Unit
9+
) {
10+
private val lock = Any()
11+
private val clients = mutableMapOf<String, T>()
12+
private val loggedInCallIds = mutableSetOf<String>()
13+
14+
fun clientFor(callId: String): T? = synchronized(lock) { clients[callId] }
15+
16+
fun isLoggedIn(callId: String): Boolean = synchronized(lock) { loggedInCallIds.contains(callId) }
17+
18+
fun putClient(callId: String, client: T) {
19+
synchronized(lock) {
20+
clients.remove(callId)?.let(releaseClient)
21+
clients[callId] = client
22+
loggedInCallIds.remove(callId)
23+
}
24+
}
25+
26+
fun markLoggedIn(callId: String) {
27+
synchronized(lock) {
28+
loggedInCallIds.add(callId)
29+
}
30+
}
31+
32+
fun stopClient(callId: String) {
33+
synchronized(lock) {
34+
loggedInCallIds.remove(callId)
35+
clients.remove(callId)?.let(releaseClient)
36+
}
37+
}
38+
39+
fun stopAllClients() {
40+
synchronized(lock) {
41+
loggedInCallIds.clear()
42+
clients.values.forEach(releaseClient)
43+
clients.clear()
44+
}
45+
}
46+
47+
fun clientCount(): Int = synchronized(lock) { clients.size }
48+
49+
fun clientIds(): Set<String> = synchronized(lock) { clients.keys.toSet() }
50+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package chat.rocket.reactnative.voip
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Test
5+
6+
class VoipIncomingCallDispatchTest {
7+
8+
@Test
9+
fun `stale push with active call does not route to reject busy`() {
10+
assertEquals(
11+
VoipIncomingPushAction.STALE,
12+
decideIncomingVoipPushAction(isValidForIncomingHandling = false, hasActiveCall = true)
13+
)
14+
}
15+
16+
@Test
17+
fun `stale push without active call does not route to show incoming`() {
18+
assertEquals(
19+
VoipIncomingPushAction.STALE,
20+
decideIncomingVoipPushAction(isValidForIncomingHandling = false, hasActiveCall = false)
21+
)
22+
}
23+
24+
@Test
25+
fun `valid push with active call rejects busy`() {
26+
assertEquals(
27+
VoipIncomingPushAction.REJECT_BUSY,
28+
decideIncomingVoipPushAction(isValidForIncomingHandling = true, hasActiveCall = true)
29+
)
30+
}
31+
32+
@Test
33+
fun `valid push without active call shows incoming`() {
34+
assertEquals(
35+
VoipIncomingPushAction.SHOW_INCOMING,
36+
decideIncomingVoipPushAction(isValidForIncomingHandling = true, hasActiveCall = false)
37+
)
38+
}
39+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package chat.rocket.reactnative.voip
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Assert.assertFalse
5+
import org.junit.Assert.assertNull
6+
import org.junit.Assert.assertTrue
7+
import org.junit.Test
8+
9+
class VoipPerCallDdpRegistryTest {
10+
11+
private fun registry(): Pair<MutableList<String>, VoipPerCallDdpRegistry<String>> {
12+
val released = mutableListOf<String>()
13+
return released to VoipPerCallDdpRegistry { released.add(it) }
14+
}
15+
16+
@Test
17+
fun `stopClient removes only the named callId`() {
18+
val (released, reg) = registry()
19+
reg.putClient("callA", "clientA")
20+
reg.putClient("callB", "clientB")
21+
reg.stopClient("callA")
22+
assertEquals(listOf("clientA"), released)
23+
assertEquals(setOf("callB"), reg.clientIds())
24+
assertEquals("clientB", reg.clientFor("callB"))
25+
assertNull(reg.clientFor("callA"))
26+
}
27+
28+
@Test
29+
fun `stopAllClients disconnects every entry`() {
30+
val (released, reg) = registry()
31+
reg.putClient("callA", "clientA")
32+
reg.putClient("callB", "clientB")
33+
reg.stopAllClients()
34+
assertEquals(2, released.size)
35+
assertTrue(released.containsAll(listOf("clientA", "clientB")))
36+
assertEquals(0, reg.clientCount())
37+
assertTrue(reg.clientIds().isEmpty())
38+
}
39+
40+
@Test
41+
fun `starting a second listener for another callId does not release the first`() {
42+
val (released, reg) = registry()
43+
reg.putClient("callA", "clientA")
44+
reg.putClient("callB", "clientB")
45+
assertTrue(released.isEmpty())
46+
assertEquals(setOf("callA", "callB"), reg.clientIds())
47+
}
48+
49+
@Test
50+
fun `putClient for the same callId releases the previous client`() {
51+
val (released, reg) = registry()
52+
reg.putClient("callA", "first")
53+
reg.putClient("callA", "second")
54+
assertEquals(listOf("first"), released)
55+
assertEquals("second", reg.clientFor("callA"))
56+
}
57+
58+
@Test
59+
fun `loggedIn state is per callId`() {
60+
val (_, reg) = registry()
61+
reg.putClient("callA", "a")
62+
reg.putClient("callB", "b")
63+
assertFalse(reg.isLoggedIn("callA"))
64+
reg.markLoggedIn("callA")
65+
assertTrue(reg.isLoggedIn("callA"))
66+
assertFalse(reg.isLoggedIn("callB"))
67+
}
68+
69+
@Test
70+
fun `stopClient clears loggedIn for that callId`() {
71+
val (_, reg) = registry()
72+
reg.putClient("callA", "a")
73+
reg.markLoggedIn("callA")
74+
reg.stopClient("callA")
75+
assertFalse(reg.isLoggedIn("callA"))
76+
}
77+
}

app/i18n/locales/ar.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,8 @@
425425
"Pause": "توقف مؤقت",
426426
"Permalink_copied_to_clipboard": "تم نسخ الرابط الثابت إلى الحافظة!",
427427
"Phone": "الهاتف",
428+
"Phone_state_permission_message": "يتيح ذلك لـ Rocket.Chat معرفة ما إذا كنت في مكالمة هاتفية أو VoIP حاليًا كي تُعالج المكالمات الواردة بشكل صحيح.",
429+
"Phone_state_permission_title": "السماح بالوصول إلى حالة الهاتف",
428430
"Pin": "ثبت",
429431
"Pinned": "مثبت",
430432
"Play": "لعب",

app/i18n/locales/bn-IN.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,8 @@
595595
"Permalink_copied_to_clipboard": "পারমালিঙ্ক ক্লিপবোর্ডে কপি করা হয়েছে!",
596596
"Person_or_channel": "ব্যক্তি বা চ্যানেল",
597597
"Phone": "ফোন",
598+
"Phone_state_permission_message": "এটি Rocket.Chat-কে আপনি ইতিমধ্যে কোনও ফোন বা VoIP কলে আছেন কিনা তা শনাক্ত করতে দেয়, যাতে ইনকামিং কলগুলি সঠিকভাবে পরিচালনা করা যায়।",
599+
"Phone_state_permission_title": "ফোনের অবস্থা অ্যাক্সেসের অনুমতি দিন",
598600
"Pin": "পিন",
599601
"Pinned": "পিনকরা",
600602
"Pinned_a_message": "একটি বার্তা পিন করা হয়েছে:",

app/i18n/locales/cs.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,8 @@
634634
"Permalink_copied_to_clipboard": "Trvalý odkaz zkopírován do schránky!",
635635
"Person_or_channel": "Osoba nebo kanál",
636636
"Phone": "Telefon",
637+
"Phone_state_permission_message": "Rocket.Chat tak může zjistit, zda již probíhá telefonní nebo hlasový (VoIP) hovor, a správně zpracovat příchozí hovory.",
638+
"Phone_state_permission_title": "Povolit přístup ke stavu telefonu",
637639
"Pin": "Kolík",
638640
"Pinned": "Připnuto",
639641
"Pinned_a_message": "Připnuta zpráva:",

app/i18n/locales/de.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,8 @@
583583
"Pause": "Pause",
584584
"Permalink_copied_to_clipboard": "Permalink in die Zwischenablage kopiert!",
585585
"Phone": "Telefon",
586+
"Phone_state_permission_message": "Damit kann Rocket.Chat erkennen, ob Sie bereits in einem Telefon- oder VoIP-Gespräch sind, damit eingehende Anrufe korrekt behandelt werden können.",
587+
"Phone_state_permission_title": "Zugriff auf den Telefonstatus erlauben",
586588
"Pin": "Anheften",
587589
"Pinned": "Angeheftet",
588590
"Place_chat_on_hold": "Chat in die Warteschleife stellen",

0 commit comments

Comments
 (0)