Skip to content

Commit 2e32adf

Browse files
authored
Improve how active calls work (#3029)
* Improve how active calls work: - Sending the `m.call.notify` event is now done in `CallScreenPresenter` once we know the sync is running. - You can mark a call of both external url or room type as joined. - Hanging up checks the current active call type and will only remove it if it matches.
1 parent 2b5ea96 commit 2e32adf

File tree

9 files changed

+128
-86
lines changed

9 files changed

+128
-86
lines changed

changelog.d/3029.misc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Improve how active calls work by also taking into account external url calls and waiting for the sync process to start before sending the `m.call.notify` event.

features/call/impl/src/main/kotlin/io/element/android/features/call/impl/notifications/RingingCallNotificationCreator.kt

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,24 @@ class RingingCallNotificationCreator @Inject constructor(
8484
.build()
8585

8686
val answerIntent = IntentProvider.getPendingIntent(context, CallType.RoomCall(sessionId, roomId))
87+
val notificationData = CallNotificationData(
88+
sessionId = sessionId,
89+
roomId = roomId,
90+
eventId = eventId,
91+
senderId = senderId,
92+
roomName = roomName,
93+
senderName = senderDisplayName,
94+
avatarUrl = roomAvatarUrl,
95+
notificationChannelId = notificationChannelId,
96+
timestamp = timestamp
97+
)
8798

8899
val declineIntent = PendingIntentCompat.getBroadcast(
89100
context,
90101
DECLINE_REQUEST_CODE,
91-
Intent(context, DeclineCallBroadcastReceiver::class.java),
102+
Intent(context, DeclineCallBroadcastReceiver::class.java).apply {
103+
putExtra(DeclineCallBroadcastReceiver.EXTRA_NOTIFICATION_DATA, notificationData)
104+
},
92105
PendingIntent.FLAG_CANCEL_CURRENT,
93106
false,
94107
)!!
@@ -97,10 +110,7 @@ class RingingCallNotificationCreator @Inject constructor(
97110
context,
98111
FULL_SCREEN_INTENT_REQUEST_CODE,
99112
Intent(context, IncomingCallActivity::class.java).apply {
100-
putExtra(
101-
IncomingCallActivity.EXTRA_NOTIFICATION_DATA,
102-
CallNotificationData(sessionId, roomId, eventId, senderId, roomName, senderDisplayName, roomAvatarUrl, notificationChannelId, timestamp)
103-
)
113+
putExtra(IncomingCallActivity.EXTRA_NOTIFICATION_DATA, notificationData)
104114
},
105115
PendingIntent.FLAG_CANCEL_CURRENT,
106116
false

features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ package io.element.android.features.call.impl.receivers
1919
import android.content.BroadcastReceiver
2020
import android.content.Context
2121
import android.content.Intent
22+
import androidx.core.content.IntentCompat
23+
import io.element.android.features.call.api.CallType
2224
import io.element.android.features.call.impl.di.CallBindings
25+
import io.element.android.features.call.impl.notifications.CallNotificationData
2326
import io.element.android.features.call.impl.utils.ActiveCallManager
2427
import io.element.android.libraries.architecture.bindings
2528
import javax.inject.Inject
@@ -28,10 +31,15 @@ import javax.inject.Inject
2831
* Broadcast receiver to decline the incoming call.
2932
*/
3033
class DeclineCallBroadcastReceiver : BroadcastReceiver() {
34+
companion object {
35+
const val EXTRA_NOTIFICATION_DATA = "EXTRA_NOTIFICATION_DATA"
36+
}
3137
@Inject
3238
lateinit var activeCallManager: ActiveCallManager
3339
override fun onReceive(context: Context, intent: Intent?) {
40+
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
41+
?: return
3442
context.bindings<CallBindings>().inject(this)
35-
activeCallManager.hungUpCall()
43+
activeCallManager.hungUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
3644
}
3745
}

features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,15 @@ import io.element.android.libraries.architecture.AsyncData
4040
import io.element.android.libraries.architecture.Presenter
4141
import io.element.android.libraries.architecture.runCatchingUpdatingState
4242
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
43+
import io.element.android.libraries.matrix.api.MatrixClient
4344
import io.element.android.libraries.matrix.api.MatrixClientProvider
45+
import io.element.android.libraries.matrix.api.core.RoomId
4446
import io.element.android.libraries.matrix.api.sync.SyncState
4547
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
4648
import io.element.android.libraries.network.useragent.UserAgentProvider
4749
import io.element.android.services.analytics.api.ScreenTracker
4850
import io.element.android.services.toolbox.api.systemclock.SystemClock
4951
import kotlinx.coroutines.CoroutineScope
50-
import kotlinx.coroutines.flow.collect
5152
import kotlinx.coroutines.flow.launchIn
5253
import kotlinx.coroutines.flow.onEach
5354
import kotlinx.coroutines.launch
@@ -75,6 +76,7 @@ class CallScreenPresenter @AssistedInject constructor(
7576

7677
private val isInWidgetMode = callType is CallType.RoomCall
7778
private val userAgent = userAgentProvider.provide()
79+
private var notifiedCallStart = false
7880

7981
@Composable
8082
override fun present(): CallScreenState {
@@ -84,11 +86,14 @@ class CallScreenPresenter @AssistedInject constructor(
8486
val messageInterceptor = remember { mutableStateOf<WidgetMessageInterceptor?>(null) }
8587
var isJoinedCall by rememberSaveable { mutableStateOf(false) }
8688

87-
LaunchedEffect(Unit) {
88-
loadUrl(callType, urlState, callWidgetDriver)
89-
90-
if (callType is CallType.RoomCall) {
91-
activeCallManager.joinedCall(callType.sessionId, callType.roomId)
89+
DisposableEffect(Unit) {
90+
coroutineScope.launch {
91+
// Sets the call as joined
92+
activeCallManager.joinedCall(callType)
93+
loadUrl(callType, urlState, callWidgetDriver)
94+
}
95+
onDispose {
96+
activeCallManager.hungUpCall(callType)
9297
}
9398
}
9499

@@ -140,14 +145,6 @@ class CallScreenPresenter @AssistedInject constructor(
140145
}
141146
}
142147

143-
DisposableEffect(Unit) {
144-
onDispose {
145-
if (callType is CallType.RoomCall) {
146-
activeCallManager.hungUpCall()
147-
}
148-
}
149-
}
150-
151148
fun handleEvents(event: CallScreenEvents) {
152149
when (event) {
153150
is CallScreenEvents.Hangup -> {
@@ -173,15 +170,15 @@ class CallScreenPresenter @AssistedInject constructor(
173170
urlState = urlState.value,
174171
userAgent = userAgent,
175172
isInWidgetMode = isInWidgetMode,
176-
eventSink = ::handleEvents,
173+
eventSink = { handleEvents(it) },
177174
)
178175
}
179176

180-
private fun CoroutineScope.loadUrl(
177+
private suspend fun loadUrl(
181178
inputs: CallType,
182179
urlState: MutableState<AsyncData<String>>,
183180
callWidgetDriver: MutableState<MatrixWidgetDriver?>,
184-
) = launch {
181+
) {
185182
urlState.runCatchingUpdatingState {
186183
when (inputs) {
187184
is CallType.ExternalUrl -> {
@@ -209,12 +206,13 @@ class CallScreenPresenter @AssistedInject constructor(
209206
} ?: return@DisposableEffect onDispose { }
210207
coroutineScope.launch {
211208
client.syncService().syncState
212-
.onEach { state ->
213-
if (state != SyncState.Running) {
209+
.collect { state ->
210+
if (state == SyncState.Running) {
211+
client.notifyCallStartIfNeeded(callType.roomId)
212+
} else {
214213
client.syncService().startSync()
215214
}
216215
}
217-
.collect()
218216
}
219217
onDispose {
220218
// We can't use the local coroutine scope here because it will be disposed before this effect
@@ -229,6 +227,13 @@ class CallScreenPresenter @AssistedInject constructor(
229227
}
230228
}
231229

230+
private suspend fun MatrixClient.notifyCallStartIfNeeded(roomId: RoomId) {
231+
if (!notifiedCallStart) {
232+
getRoom(roomId)?.sendCallNotificationIfNeeded()
233+
?.onSuccess { notifiedCallStart = true }
234+
}
235+
}
236+
232237
private fun parseMessage(message: String): WidgetMessage? {
233238
return WidgetMessageSerializer.deserialize(message).getOrNull()
234239
}

features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ class IncomingCallActivity : AppCompatActivity() {
9191
}
9292

9393
private fun onCancel() {
94-
activeCallManager.hungUpCall()
94+
val activeCall = activeCallManager.activeCall.value ?: return
95+
activeCallManager.hungUpCall(callType = activeCall.callType)
9596
}
9697
}

features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,11 @@ import android.annotation.SuppressLint
2020
import androidx.core.app.NotificationManagerCompat
2121
import com.squareup.anvil.annotations.ContributesBinding
2222
import io.element.android.appconfig.ElementCallConfig
23+
import io.element.android.features.call.api.CallType
2324
import io.element.android.features.call.impl.notifications.CallNotificationData
2425
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
2526
import io.element.android.libraries.di.AppScope
2627
import io.element.android.libraries.di.SingleIn
27-
import io.element.android.libraries.matrix.api.MatrixClientProvider
28-
import io.element.android.libraries.matrix.api.core.RoomId
29-
import io.element.android.libraries.matrix.api.core.SessionId
3028
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
3129
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
3230
import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler
@@ -61,24 +59,23 @@ interface ActiveCallManager {
6159
fun incomingCallTimedOut()
6260

6361
/**
64-
* Hangs up the active call and removes any associated UI.
62+
* Called when the active call has been hung up. It will remove any existing UI and the active call.
63+
* @param callType The type of call that the user hung up, either an external url one or a room one.
6564
*/
66-
fun hungUpCall()
65+
fun hungUpCall(callType: CallType)
6766

6867
/**
69-
* Called when the user joins a call. It will remove any existing UI and set the call state as [CallState.InCall].
68+
* Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall].
7069
*
71-
* @param sessionId The session ID of the user joining the call.
72-
* @param roomId The room ID of the call.
70+
* @param callType The type of call that the user joined, either an external url one or a room one.
7371
*/
74-
fun joinedCall(sessionId: SessionId, roomId: RoomId)
72+
fun joinedCall(callType: CallType)
7573
}
7674

7775
@SingleIn(AppScope::class)
7876
@ContributesBinding(AppScope::class)
7977
class DefaultActiveCallManager @Inject constructor(
8078
private val coroutineScope: CoroutineScope,
81-
private val matrixClientProvider: MatrixClientProvider,
8279
private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler,
8380
private val ringingCallNotificationCreator: RingingCallNotificationCreator,
8481
private val notificationManagerCompat: NotificationManagerCompat,
@@ -94,15 +91,17 @@ class DefaultActiveCallManager @Inject constructor(
9491
return
9592
}
9693
activeCall.value = ActiveCall(
97-
sessionId = notificationData.sessionId,
98-
roomId = notificationData.roomId,
94+
callType = CallType.RoomCall(
95+
sessionId = notificationData.sessionId,
96+
roomId = notificationData.roomId,
97+
),
9998
callState = CallState.Ringing(notificationData),
10099
)
101100

102101
timedOutCallJob = coroutineScope.launch {
103102
showIncomingCallNotification(notificationData)
104103

105-
// Wait for the call to end
104+
// Wait for the ringing call to time out
106105
delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds)
107106
incomingCallTimedOut()
108107
}
@@ -118,28 +117,24 @@ class DefaultActiveCallManager @Inject constructor(
118117
displayMissedCallNotification(notificationData)
119118
}
120119

121-
override fun hungUpCall() {
120+
override fun hungUpCall(callType: CallType) {
121+
if (activeCall.value?.callType != callType) {
122+
Timber.w("Call type $callType does not match the active call type, ignoring")
123+
return
124+
}
122125
cancelIncomingCallNotification()
123126
timedOutCallJob?.cancel()
124127
activeCall.value = null
125128
}
126129

127-
override fun joinedCall(sessionId: SessionId, roomId: RoomId) {
130+
override fun joinedCall(callType: CallType) {
128131
cancelIncomingCallNotification()
129132
timedOutCallJob?.cancel()
130133

131134
activeCall.value = ActiveCall(
132-
sessionId = sessionId,
133-
roomId = roomId,
135+
callType = callType,
134136
callState = CallState.InCall,
135137
)
136-
// Send call notification to the room
137-
coroutineScope.launch {
138-
matrixClientProvider.getOrRestore(sessionId)
139-
.getOrNull()
140-
?.getRoom(roomId)
141-
?.sendCallNotificationIfNeeded()
142-
}
143138
}
144139

145140
@SuppressLint("MissingPermission")
@@ -184,8 +179,7 @@ class DefaultActiveCallManager @Inject constructor(
184179
* Represents an active call.
185180
*/
186181
data class ActiveCall(
187-
val sessionId: SessionId,
188-
val roomId: RoomId,
182+
val callType: CallType,
189183
val callState: CallState,
190184
)
191185

features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
3535
import io.element.android.libraries.matrix.test.A_SESSION_ID
3636
import io.element.android.libraries.matrix.test.FakeMatrixClient
3737
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
38+
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
3839
import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver
3940
import io.element.android.libraries.network.useragent.UserAgentProvider
4041
import io.element.android.services.analytics.api.ScreenTracker
@@ -61,11 +62,13 @@ class CallScreenPresenterTest {
6162
val warmUpRule = WarmUpRule()
6263

6364
@Test
64-
fun `present - with CallType ExternalUrl just loads the URL`() = runTest {
65-
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> { }
65+
fun `present - with CallType ExternalUrl just loads the URL and sets the call as active`() = runTest {
66+
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> {}
67+
val joinedCallLambda = lambdaRecorder<CallType, Unit> {}
6668
val presenter = createCallScreenPresenter(
6769
callType = CallType.ExternalUrl("https://call.element.io"),
68-
screenTracker = FakeScreenTracker(analyticsLambda)
70+
screenTracker = FakeScreenTracker(analyticsLambda),
71+
activeCallManager = FakeActiveCallManager(joinedCallResult = joinedCallLambda),
6972
)
7073
moleculeFlow(RecompositionMode.Immediate) {
7174
presenter.present()
@@ -76,25 +79,35 @@ class CallScreenPresenterTest {
7679
assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io"))
7780
assertThat(initialState.isInWidgetMode).isFalse()
7881
analyticsLambda.assertions().isNeverCalled()
82+
joinedCallLambda.assertions().isCalledOnce()
7983
}
8084
}
8185

8286
@Test
83-
fun `present - with CallType RoomCall loads URL and runs WidgetDriver`() = runTest {
87+
fun `present - with CallType RoomCall sets call as active, loads URL, runs WidgetDriver and notifies the other clients a call started`() = runTest {
88+
val sendCallNotificationIfNeededLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
89+
val fakeRoom = FakeMatrixRoom(sendCallNotificationIfNeededResult = sendCallNotificationIfNeededLambda)
90+
val client = FakeMatrixClient().apply {
91+
givenGetRoomResult(A_ROOM_ID, fakeRoom)
92+
}
8493
val widgetDriver = FakeMatrixWidgetDriver()
8594
val widgetProvider = FakeCallWidgetProvider(widgetDriver)
86-
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> { }
95+
val analyticsLambda = lambdaRecorder<MobileScreen.ScreenName, Unit> {}
96+
val joinedCallLambda = lambdaRecorder<CallType, Unit> {}
8797
val presenter = createCallScreenPresenter(
98+
matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }),
8899
callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
89100
widgetDriver = widgetDriver,
90101
widgetProvider = widgetProvider,
91-
screenTracker = FakeScreenTracker(analyticsLambda)
102+
screenTracker = FakeScreenTracker(analyticsLambda),
103+
activeCallManager = FakeActiveCallManager(joinedCallResult = joinedCallLambda),
92104
)
93105
moleculeFlow(RecompositionMode.Immediate) {
94106
presenter.present()
95107
}.test {
96108
// Wait until the URL is loaded
97109
skipItems(1)
110+
joinedCallLambda.assertions().isCalledOnce()
98111
val initialState = awaitItem()
99112
assertThat(initialState.urlState).isInstanceOf(AsyncData.Success::class.java)
100113
assertThat(initialState.isInWidgetMode).isTrue()
@@ -106,6 +119,7 @@ class CallScreenPresenterTest {
106119
listOf(value(MobileScreen.ScreenName.RoomCall)),
107120
listOf(value(MobileScreen.ScreenName.RoomCall))
108121
)
122+
sendCallNotificationIfNeededLambda.assertions().isCalledOnce()
109123
}
110124
}
111125

0 commit comments

Comments
 (0)