Make sure declining a call stops observing the ringing call state (#5563)
* Add shared `removeCurrentCall` function to `DefaultActiveCallManager` This centralises the shared call cancellation logic * Add regression test for the issue * Make sure the existing iterations of `flatMapLatest` in `observeRingingCall` get cancelled when the active call is null or not ringing anymore by passing null values, then filtering them out Previously these kept running even if the `activeCall` was no longer valid * Move the `timedOutCallJob` cancellation inside `removeCurrentCall` too
This commit is contained in:
committed by
GitHub
parent
cbeb58f00e
commit
10bf5f1c8c
@@ -28,7 +28,9 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions
|
|||||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||||
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
import io.element.android.libraries.matrix.api.MatrixClientProvider
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
import io.element.android.libraries.matrix.api.core.SessionId
|
||||||
|
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||||
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
|
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
|
||||||
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
import io.element.android.libraries.push.api.notifications.ForegroundServiceType
|
||||||
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
|
||||||
@@ -39,16 +41,17 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.drop
|
import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.mapLatest
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
@@ -180,13 +183,7 @@ class DefaultActiveCallManager(
|
|||||||
|
|
||||||
val previousActiveCall = activeCall.value ?: return
|
val previousActiveCall = activeCall.value ?: return
|
||||||
val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return
|
val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return
|
||||||
activeCall.value = null
|
removeCurrentCall()
|
||||||
if (activeWakeLock?.isHeld == true) {
|
|
||||||
Timber.tag(tag).d("Releasing partial wakelock after timeout")
|
|
||||||
activeWakeLock.release()
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelIncomingCallNotification()
|
|
||||||
|
|
||||||
if (displayMissedCallNotification) {
|
if (displayMissedCallNotification) {
|
||||||
displayMissedCallNotification(notificationData)
|
displayMissedCallNotification(notificationData)
|
||||||
@@ -211,24 +208,16 @@ class DefaultActiveCallManager(
|
|||||||
?.declineCall(notificationData.eventId)
|
?.declineCall(notificationData.eventId)
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelIncomingCallNotification()
|
removeCurrentCall()
|
||||||
if (activeWakeLock?.isHeld == true) {
|
|
||||||
Timber.tag(tag).d("Releasing partial wakelock after hang up")
|
|
||||||
activeWakeLock.release()
|
|
||||||
}
|
|
||||||
timedOutCallJob?.cancel()
|
|
||||||
activeCall.value = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the current active call and any associated UI, cancelling the timeouts and wakelocks.
|
||||||
|
*/
|
||||||
override suspend fun joinedCall(callType: CallType) = mutex.withLock {
|
override suspend fun joinedCall(callType: CallType) = mutex.withLock {
|
||||||
Timber.tag(tag).d("Joined call: $callType")
|
Timber.tag(tag).d("Joined call: $callType")
|
||||||
|
|
||||||
cancelIncomingCallNotification()
|
removeCurrentCall()
|
||||||
if (activeWakeLock?.isHeld == true) {
|
|
||||||
Timber.tag(tag).d("Releasing partial wakelock after joining call")
|
|
||||||
activeWakeLock.release()
|
|
||||||
}
|
|
||||||
timedOutCallJob?.cancel()
|
|
||||||
|
|
||||||
activeCall.value = ActiveCall(
|
activeCall.value = ActiveCall(
|
||||||
callType = callType,
|
callType = callType,
|
||||||
@@ -236,6 +225,23 @@ class DefaultActiveCallManager(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||||
|
internal fun removeCurrentCall() {
|
||||||
|
// Cancel and remove the timeout call job, if any
|
||||||
|
timedOutCallJob?.cancel()
|
||||||
|
timedOutCallJob = null
|
||||||
|
|
||||||
|
// Remove the active call and cancel the notification
|
||||||
|
activeCall.value = null
|
||||||
|
cancelIncomingCallNotification()
|
||||||
|
|
||||||
|
// Also remove any wake locks that may be held
|
||||||
|
if (activeWakeLock?.isHeld == true) {
|
||||||
|
Timber.tag(tag).d("Releasing partial wakelock after call declined from another session")
|
||||||
|
activeWakeLock.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
private suspend fun showIncomingCallNotification(notificationData: CallNotificationData) {
|
private suspend fun showIncomingCallNotification(notificationData: CallNotificationData) {
|
||||||
Timber.tag(tag).d("Displaying ringing call notification")
|
Timber.tag(tag).d("Displaying ringing call notification")
|
||||||
@@ -281,73 +287,75 @@ class DefaultActiveCallManager(
|
|||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private fun observeRingingCall() {
|
private fun observeRingingCall() {
|
||||||
activeCall
|
val roomForActiveCallFlow: Flow<Pair<BaseRoom, EventId>?> = activeCall.mapLatest { activeCall ->
|
||||||
.filterNotNull()
|
val callType = activeCall?.callType as? CallType.RoomCall ?: return@mapLatest null
|
||||||
.filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall }
|
val ringingInfo = activeCall.callState as? CallState.Ringing ?: return@mapLatest null
|
||||||
.flatMapLatest { activeCall ->
|
val client = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull() ?: run {
|
||||||
val callType = activeCall.callType as CallType.RoomCall
|
Timber.tag(tag).d("Couldn't find session for incoming call: $activeCall")
|
||||||
val ringingInfo = activeCall.callState as CallState.Ringing
|
return@mapLatest null
|
||||||
val client = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull() ?: run {
|
}
|
||||||
Timber.tag(tag).d("Couldn't find session for incoming call: $activeCall")
|
val room = client.getRoom(callType.roomId) ?: run {
|
||||||
return@flatMapLatest flowOf()
|
Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
|
||||||
}
|
return@mapLatest null
|
||||||
val room = client.getRoom(callType.roomId) ?: run {
|
}
|
||||||
Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
|
|
||||||
return@flatMapLatest flowOf()
|
|
||||||
}
|
|
||||||
|
|
||||||
Timber.tag(tag).d("Found room for ringing call: ${room.roomId}")
|
Timber.tag(tag).d("Found room for ringing call: ${room.roomId}")
|
||||||
|
|
||||||
|
val eventId = ringingInfo.notificationData.eventId
|
||||||
|
room to eventId
|
||||||
|
}
|
||||||
|
|
||||||
|
roomForActiveCallFlow
|
||||||
|
.flatMapLatest { pair ->
|
||||||
|
val (room, eventId) = pair
|
||||||
|
// This will cancel the previous iteration of flatMapLatest if the active call is now null
|
||||||
|
?: return@flatMapLatest flowOf()
|
||||||
|
|
||||||
// If we have declined from another phone we want to stop ringing.
|
// If we have declined from another phone we want to stop ringing.
|
||||||
room.subscribeToCallDecline(ringingInfo.notificationData.eventId)
|
room.subscribeToCallDecline(eventId)
|
||||||
.filter { decliner ->
|
.filter { decliner ->
|
||||||
Timber.tag(tag).d("Call: $activeCall was declined by $decliner")
|
Timber.tag(tag).d("Call: $activeCall was declined by $decliner")
|
||||||
// only want to listen if the call was declined from another of my sessions,
|
// only want to listen if the call was declined from another of my sessions,
|
||||||
// (we are ringing for an incoming call in a DM)
|
// (we are ringing for an incoming call in a DM)
|
||||||
decliner == client.sessionId
|
decliner == room.sessionId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onEach { decliner ->
|
.onEach { decliner ->
|
||||||
Timber.tag(tag).d("Call: $activeCall was declined by user from another session")
|
Timber.tag(tag).d("Call: $activeCall was declined by user from another session")
|
||||||
// Remove the active call and cancel the notification
|
removeCurrentCall()
|
||||||
activeCall.value = null
|
|
||||||
if (activeWakeLock?.isHeld == true) {
|
|
||||||
Timber.tag(tag).d("Releasing partial wakelock after call declined from another session")
|
|
||||||
activeWakeLock.release()
|
|
||||||
}
|
|
||||||
cancelIncomingCallNotification()
|
|
||||||
}
|
}
|
||||||
.launchIn(coroutineScope)
|
.launchIn(coroutineScope)
|
||||||
|
|
||||||
// This will observe ringing calls and ensure they're terminated if the room call is cancelled or if the user
|
// This will observe ringing calls and ensure they're terminated if the room call is cancelled or if the user
|
||||||
// has joined the call from another session.
|
// has joined the call from another session.
|
||||||
activeCall
|
roomForActiveCallFlow
|
||||||
.filterNotNull()
|
.flatMapLatest { pair ->
|
||||||
.filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall }
|
val (room, _) = pair
|
||||||
.flatMapLatest { activeCall ->
|
// This will cancel the previous iteration of flatMapLatest if the active call is now null
|
||||||
val callType = activeCall.callType as CallType.RoomCall
|
?: return@flatMapLatest flowOf()
|
||||||
// Get a flow of updated `hasRoomCall` and `activeRoomCallParticipants` values for the room
|
|
||||||
val room = matrixClientProvider.getOrRestore(callType.sessionId).getOrNull()?.getRoom(callType.roomId) ?: run {
|
// We now observe the room info for changes to the active call state and the call participants
|
||||||
Timber.tag(tag).d("Couldn't find room for incoming call: $activeCall")
|
|
||||||
return@flatMapLatest flowOf()
|
|
||||||
}
|
|
||||||
room.roomInfoFlow.map {
|
room.roomInfoFlow.map {
|
||||||
Timber.tag(tag).d("Has room call status changed for ringing call: ${it.hasRoomCall}")
|
val participants = it.activeRoomCallParticipants
|
||||||
it.hasRoomCall to (callType.sessionId in it.activeRoomCallParticipants)
|
Timber.tag(tag).d("Room call status changed for ringing call | hasRoomCall: ${it.hasRoomCall} | participants: $participants")
|
||||||
|
val userIsInTheCall = room.sessionId in participants
|
||||||
|
it.hasRoomCall to userIsInTheCall
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// We only want to check if the room active call status changes
|
// Filter out duplicate values
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
// Skip the first one, we're not interested in it (if the check below passes, it had to be active anyway)
|
// Skip the first one, we're not interested in it (if the check below passes, it had to be active anyway)
|
||||||
.drop(1)
|
.drop(1)
|
||||||
.onEach { (roomHasActiveCall, userIsInTheCall) ->
|
.onEach { (roomHasActiveCall, userIsInTheCall) ->
|
||||||
if (!roomHasActiveCall) {
|
if (!roomHasActiveCall) {
|
||||||
// The call was cancelled
|
val notificationData = (activeCall.value?.callState as? CallState.Ringing)?.notificationData
|
||||||
timedOutCallJob?.cancel()
|
removeCurrentCall()
|
||||||
incomingCallTimedOut(displayMissedCallNotification = true)
|
|
||||||
|
if (notificationData != null) {
|
||||||
|
displayMissedCallNotification(notificationData)
|
||||||
|
}
|
||||||
} else if (userIsInTheCall) {
|
} else if (userIsInTheCall) {
|
||||||
// The user joined the call from another session
|
removeCurrentCall()
|
||||||
timedOutCallJob?.cancel()
|
|
||||||
incomingCallTimedOut(displayMissedCallNotification = false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.launchIn(coroutineScope)
|
.launchIn(coroutineScope)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
|||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
|
||||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
|
||||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||||
@@ -46,6 +47,7 @@ import io.element.android.tests.testutils.lambda.value
|
|||||||
import io.element.android.tests.testutils.plantTestTimber
|
import io.element.android.tests.testutils.plantTestTimber
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.spyk
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
@@ -331,6 +333,49 @@ class DefaultActiveCallManagerTest {
|
|||||||
assertThat(manager.activeCall.value).isNull()
|
assertThat(manager.activeCall.value).isNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@Test
|
||||||
|
fun `observeRingingCalls - declining won't do anything if the call was already cancelled`() = runTest {
|
||||||
|
val room = FakeBaseRoom().apply {
|
||||||
|
givenRoomInfo(aRoomInfo())
|
||||||
|
}
|
||||||
|
val client = FakeMatrixClient().apply {
|
||||||
|
givenGetRoomResult(A_ROOM_ID, room)
|
||||||
|
}
|
||||||
|
val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) })
|
||||||
|
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
|
||||||
|
val manager = spyk<DefaultActiveCallManager>(
|
||||||
|
createActiveCallManager(
|
||||||
|
matrixClientProvider = matrixClientProvider,
|
||||||
|
notificationManagerCompat = notificationManagerCompat,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.registerIncomingCall(aCallNotificationData())
|
||||||
|
|
||||||
|
// Call is active (the other user join the call)
|
||||||
|
room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
|
||||||
|
advanceTimeBy(1)
|
||||||
|
|
||||||
|
// Call is cancelled by us, hanging up
|
||||||
|
manager.hungUpCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
|
||||||
|
advanceTimeBy(1)
|
||||||
|
|
||||||
|
verify(exactly = 1) { notificationManagerCompat.cancel(any()) }
|
||||||
|
verify(exactly = 1) { manager.removeCurrentCall() }
|
||||||
|
assertThat(manager.activeCall.value).isNull()
|
||||||
|
assertThat(manager.activeWakeLock?.isHeld).isNull()
|
||||||
|
|
||||||
|
// Simulate that another user declined the call
|
||||||
|
room.givenDecliner(A_USER_ID_2, AN_EVENT_ID)
|
||||||
|
advanceTimeBy(1)
|
||||||
|
|
||||||
|
// Check everything stays the same, no extra call to cancelling notifications
|
||||||
|
verify(exactly = 1) { notificationManagerCompat.cancel(any()) }
|
||||||
|
verify(exactly = 1) { manager.removeCurrentCall() }
|
||||||
|
assertThat(manager.activeWakeLock?.isHeld).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@Test
|
@Test
|
||||||
fun `observeRingingCalls - will do nothing if either the session or the room are not found`() = runTest {
|
fun `observeRingingCalls - will do nothing if either the session or the room are not found`() = runTest {
|
||||||
|
|||||||
Reference in New Issue
Block a user