Browse Source

Cancel ringing call notification on call cancellation (#3047)

* Cancel ringing call notification on call cancellation

* Improve implementation, add some comments to clarify how it works.

* Make sure the call timeout job is cancelled
pull/3221/head
Jorge Martin Espinosa 2 months ago committed by GitHub
parent
commit
344e07bc0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 45
      features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt
  2. 109
      features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt
  3. 2
      libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.kt

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

@ -25,14 +25,25 @@ import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator import io.element.android.features.call.impl.notifications.RingingCallNotificationCreator
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClientProvider
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
import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler import io.element.android.libraries.push.api.notifications.OnMissedCallNotificationHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
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.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -79,11 +90,16 @@ class DefaultActiveCallManager @Inject constructor(
private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler, private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler,
private val ringingCallNotificationCreator: RingingCallNotificationCreator, private val ringingCallNotificationCreator: RingingCallNotificationCreator,
private val notificationManagerCompat: NotificationManagerCompat, private val notificationManagerCompat: NotificationManagerCompat,
private val matrixClientProvider: MatrixClientProvider,
) : ActiveCallManager { ) : ActiveCallManager {
private var timedOutCallJob: Job? = null private var timedOutCallJob: Job? = null
override val activeCall = MutableStateFlow<ActiveCall?>(null) override val activeCall = MutableStateFlow<ActiveCall?>(null)
init {
observeRingingCall()
}
override fun registerIncomingCall(notificationData: CallNotificationData) { override fun registerIncomingCall(notificationData: CallNotificationData) {
if (activeCall.value != null) { if (activeCall.value != null) {
displayMissedCallNotification(notificationData) displayMissedCallNotification(notificationData)
@ -173,6 +189,35 @@ class DefaultActiveCallManager @Inject constructor(
) )
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
private fun observeRingingCall() {
// This will observe ringing calls and ensure they're terminated if the room call is cancelled
activeCall
.filterNotNull()
.filter { it.callState is CallState.Ringing && it.callType is CallType.RoomCall }
.flatMapLatest { activeCall ->
val callType = activeCall.callType as CallType.RoomCall
// Get a flow of updated `hasRoomCall` values for the room
matrixClientProvider.getOrRestore(callType.sessionId).getOrNull()
?.getRoom(callType.roomId)
?.roomInfoFlow
?.map { it.hasRoomCall }
?: flowOf()
}
// We only want to check if the room active call status changes
.distinctUntilChanged()
// Skip the first one, we're not interested in it (if the check below passes, it had to be active anyway)
.drop(1)
.onEach { roomHasActiveCall ->
if (!roomHasActiveCall) {
// The call was cancelled
timedOutCallJob?.cancel()
incomingCallTimedOut()
}
}
.launchIn(coroutineScope)
}
} }
/** /**

109
features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt

@ -32,7 +32,10 @@ import io.element.android.libraries.matrix.test.AN_EVENT_ID
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.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.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
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
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
@ -42,7 +45,11 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
@ -59,6 +66,7 @@ class DefaultActiveCallManagerTest {
@Test @Test
fun `registerIncomingCall - sets the incoming call as active`() = runTest { fun `registerIncomingCall - sets the incoming call as active`() = runTest {
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
inCancellableScope {
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat) val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
assertThat(manager.activeCall.value).isNull() assertThat(manager.activeCall.value).isNull()
@ -80,12 +88,14 @@ class DefaultActiveCallManagerTest {
verify { notificationManagerCompat.notify(notificationId, any()) } verify { notificationManagerCompat.notify(notificationId, any()) }
} }
}
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `registerIncomingCall - when there is an already active call adds missed call notification`() = runTest { fun `registerIncomingCall - when there is an already active call adds missed call notification`() = runTest {
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> } val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
val onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda) val onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
inCancellableScope {
val manager = createActiveCallManager( val manager = createActiveCallManager(
onMissedCallNotificationHandler = onMissedCallNotificationHandler, onMissedCallNotificationHandler = onMissedCallNotificationHandler,
) )
@ -107,10 +117,12 @@ class DefaultActiveCallManagerTest {
.isCalledOnce() .isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID_2), value(AN_EVENT_ID)) .with(value(A_SESSION_ID), value(A_ROOM_ID_2), value(AN_EVENT_ID))
} }
}
@Test @Test
fun `incomingCallTimedOut - when there isn't an active call does nothing`() = runTest { fun `incomingCallTimedOut - when there isn't an active call does nothing`() = runTest {
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> } val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
inCancellableScope {
val manager = createActiveCallManager( val manager = createActiveCallManager(
onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda) onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda)
) )
@ -119,12 +131,14 @@ class DefaultActiveCallManagerTest {
addMissedCallNotificationLambda.assertions().isNeverCalled() addMissedCallNotificationLambda.assertions().isNeverCalled()
} }
}
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `incomingCallTimedOut - when there is an active call removes it and adds a missed call notification`() = runTest { fun `incomingCallTimedOut - when there is an active call removes it and adds a missed call notification`() = runTest {
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> } val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
inCancellableScope {
val manager = createActiveCallManager( val manager = createActiveCallManager(
onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda), onMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(addMissedCallNotificationLambda = addMissedCallNotificationLambda),
notificationManagerCompat = notificationManagerCompat, notificationManagerCompat = notificationManagerCompat,
@ -140,10 +154,13 @@ class DefaultActiveCallManagerTest {
addMissedCallNotificationLambda.assertions().isCalledOnce() addMissedCallNotificationLambda.assertions().isCalledOnce()
verify { notificationManagerCompat.cancel(notificationId) } verify { notificationManagerCompat.cancel(notificationId) }
} }
}
@Test @Test
fun `hungUpCall - removes existing call if the CallType matches`() = runTest { fun `hungUpCall - removes existing call if the CallType matches`() = runTest {
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
// Create a cancellable coroutine scope to cancel the test when needed
inCancellableScope {
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat) val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
val notificationData = aCallNotificationData() val notificationData = aCallNotificationData()
@ -155,10 +172,13 @@ class DefaultActiveCallManagerTest {
verify { notificationManagerCompat.cancel(notificationId) } verify { notificationManagerCompat.cancel(notificationId) }
} }
}
@Test @Test
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest { fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
// Create a cancellable coroutine scope to cancel the test when needed
inCancellableScope {
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat) val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
manager.registerIncomingCall(aCallNotificationData()) manager.registerIncomingCall(aCallNotificationData())
@ -169,14 +189,14 @@ class DefaultActiveCallManagerTest {
verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) } verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
} }
}
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `joinedCall - register an ongoing call and tries sending the call notify event`() = runTest { fun `joinedCall - register an ongoing call and tries sending the call notify event`() = runTest {
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
val manager = createActiveCallManager( inCancellableScope {
notificationManagerCompat = notificationManagerCompat, val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
)
assertThat(manager.activeCall.value).isNull() assertThat(manager.activeCall.value).isNull()
manager.joinedCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID)) manager.joinedCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
@ -194,13 +214,91 @@ class DefaultActiveCallManagerTest {
verify { notificationManagerCompat.cancel(notificationId) } verify { notificationManagerCompat.cancel(notificationId) }
} }
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `observeRingingCalls - will cancel the active ringing call if the call is cancelled`() = runTest {
val room = FakeMatrixRoom().apply {
givenRoomInfo(aRoomInfo())
}
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
// Create a cancellable coroutine scope to cancel the test when needed
inCancellableScope {
val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) })
val manager = createActiveCallManager(matrixClientProvider = matrixClientProvider)
manager.registerIncomingCall(aCallNotificationData())
// Call is active (the other user join the call)
room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
advanceTimeBy(1)
// Call is cancelled (the other user left the call)
room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
advanceTimeBy(1)
assertThat(manager.activeCall.value).isNull()
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `observeRingingCalls - will do nothing if either the session or the room are not found`() = runTest {
val room = FakeMatrixRoom().apply {
givenRoomInfo(aRoomInfo())
}
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
// Create a cancellable coroutine scope to cancel the test when needed
inCancellableScope {
val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(IllegalStateException("Matrix client not found")) })
val manager = createActiveCallManager(matrixClientProvider = matrixClientProvider)
// No matrix client
manager.registerIncomingCall(aCallNotificationData())
room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
advanceTimeBy(1)
room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
advanceTimeBy(1)
// The call should still be active
assertThat(manager.activeCall.value).isNotNull()
// No room
client.givenGetRoomResult(A_ROOM_ID, null)
matrixClientProvider.getClient = { Result.success(client) }
manager.registerIncomingCall(aCallNotificationData())
room.givenRoomInfo(aRoomInfo(hasRoomCall = true))
advanceTimeBy(1)
room.givenRoomInfo(aRoomInfo(hasRoomCall = false))
advanceTimeBy(1)
// The call should still be active
assertThat(manager.activeCall.value).isNotNull()
}
}
private fun TestScope.inCancellableScope(block: suspend CoroutineScope.() -> Unit) {
launch(SupervisorJob()) {
block()
cancel()
}
}
private fun TestScope.createActiveCallManager( private fun CoroutineScope.createActiveCallManager(
matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(), onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(),
notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true), notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true),
coroutineScope: CoroutineScope = this,
) = DefaultActiveCallManager( ) = DefaultActiveCallManager(
coroutineScope = this, coroutineScope = coroutineScope,
onMissedCallNotificationHandler = onMissedCallNotificationHandler, onMissedCallNotificationHandler = onMissedCallNotificationHandler,
ringingCallNotificationCreator = RingingCallNotificationCreator( ringingCallNotificationCreator = RingingCallNotificationCreator(
context = InstrumentationRegistry.getInstrumentation().targetContext, context = InstrumentationRegistry.getInstrumentation().targetContext,
@ -209,5 +307,6 @@ class DefaultActiveCallManagerTest {
notificationBitmapLoader = FakeNotificationBitmapLoader(), notificationBitmapLoader = FakeNotificationBitmapLoader(),
), ),
notificationManagerCompat = notificationManagerCompat, notificationManagerCompat = notificationManagerCompat,
matrixClientProvider = matrixClientProvider,
) )
} }

2
libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClientProvider.kt

@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
class FakeMatrixClientProvider( class FakeMatrixClientProvider(
private val getClient: (SessionId) -> Result<MatrixClient> = { Result.success(FakeMatrixClient()) } var getClient: (SessionId) -> Result<MatrixClient> = { Result.success(FakeMatrixClient()) }
) : MatrixClientProvider { ) : MatrixClientProvider {
override suspend fun getOrRestore(sessionId: SessionId): Result<MatrixClient> = getClient(sessionId) override suspend fun getOrRestore(sessionId: SessionId): Result<MatrixClient> = getClient(sessionId)

Loading…
Cancel
Save