Add WakeLock to dismiss ringing call screen when call is cancelled (#4478)

* Add `WakeLock` to dismiss ringing call screen when call is cancelled

We had already some checks in place to automatically cancel a ringing call notification/screen when the call was no longer active, but the `RoomInfo` updates weren't being processed because the app was 'paused'.

The partial wakelock should ensure these room info updates are handled.

* Add mutual exclusion to `ActiveCallManager` methods to improve thread safety
This commit is contained in:
Jorge Martin Espinosa 2025-04-02 11:51:39 +02:00 committed by GitHub
parent f52f24b464
commit e45151d9d1
12 changed files with 152 additions and 48 deletions

View file

@ -32,7 +32,7 @@ interface ElementCallEntryPoint {
* @param notificationChannelId The id of the notification channel to use for the call notification. * @param notificationChannelId The id of the notification channel to use for the call notification.
* @param textContent The text content of the notification. If null the default content from the system will be used. * @param textContent The text content of the notification. If null the default content from the system will be used.
*/ */
fun handleIncomingCall( suspend fun handleIncomingCall(
callType: CallType.RoomCall, callType: CallType.RoomCall,
eventId: EventId, eventId: EventId,
senderId: UserId, senderId: UserId,

View file

@ -34,7 +34,7 @@ class DefaultElementCallEntryPoint @Inject constructor(
context.startActivity(IntentProvider.createIntent(context, callType)) context.startActivity(IntentProvider.createIntent(context, callType))
} }
override fun handleIncomingCall( override suspend fun handleIncomingCall(
callType: CallType.RoomCall, callType: CallType.RoomCall,
eventId: EventId, eventId: EventId,
senderId: UserId, senderId: UserId,

View file

@ -16,6 +16,8 @@ import io.element.android.features.call.impl.di.CallBindings
import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCallManager import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -27,10 +29,16 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() {
} }
@Inject @Inject
lateinit var activeCallManager: ActiveCallManager lateinit var activeCallManager: ActiveCallManager
@Inject
lateinit var appCoroutineScope: CoroutineScope
override fun onReceive(context: Context, intent: Intent?) { override fun onReceive(context: Context, intent: Intent?) {
val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) } val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) }
?: return ?: return
context.bindings<CallBindings>().inject(this) context.bindings<CallBindings>().inject(this)
activeCallManager.hungUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) appCoroutineScope.launch {
activeCallManager.hungUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
}
} }
} }

View file

@ -62,6 +62,7 @@ class CallScreenPresenter @AssistedInject constructor(
private val activeCallManager: ActiveCallManager, private val activeCallManager: ActiveCallManager,
private val languageTagProvider: LanguageTagProvider, private val languageTagProvider: LanguageTagProvider,
private val appForegroundStateService: AppForegroundStateService, private val appForegroundStateService: AppForegroundStateService,
private val appCoroutineScope: CoroutineScope,
) : Presenter<CallScreenState> { ) : Presenter<CallScreenState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -87,7 +88,7 @@ class CallScreenPresenter @AssistedInject constructor(
coroutineScope.launch { coroutineScope.launch {
// Sets the call as joined // Sets the call as joined
activeCallManager.joinedCall(callType) activeCallManager.joinedCall(callType)
loadUrl( fetchRoomCallUrl(
inputs = callType, inputs = callType,
urlState = urlState, urlState = urlState,
callWidgetDriver = callWidgetDriver, callWidgetDriver = callWidgetDriver,
@ -96,7 +97,7 @@ class CallScreenPresenter @AssistedInject constructor(
) )
} }
onDispose { onDispose {
activeCallManager.hungUpCall(callType) appCoroutineScope.launch { activeCallManager.hungUpCall(callType) }
} }
} }
@ -187,7 +188,7 @@ class CallScreenPresenter @AssistedInject constructor(
) )
} }
private suspend fun loadUrl( private suspend fun fetchRoomCallUrl(
inputs: CallType, inputs: CallType,
urlState: MutableState<AsyncData<String>>, urlState: MutableState<AsyncData<String>>,
callWidgetDriver: MutableState<MatrixWidgetDriver?>, callWidgetDriver: MutableState<MatrixWidgetDriver?>,

View file

@ -24,9 +24,11 @@ import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.theme.ElementThemeApp import io.element.android.libraries.designsystem.theme.ElementThemeApp
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -55,6 +57,9 @@ class IncomingCallActivity : AppCompatActivity() {
@Inject @Inject
lateinit var buildMeta: BuildMeta lateinit var buildMeta: BuildMeta
@Inject
lateinit var appCoroutineScope: CoroutineScope
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -102,6 +107,8 @@ class IncomingCallActivity : AppCompatActivity() {
private fun onCancel() { private fun onCancel() {
val activeCall = activeCallManager.activeCall.value ?: return val activeCall = activeCallManager.activeCall.value ?: return
activeCallManager.hungUpCall(callType = activeCall.callType) appCoroutineScope.launch {
activeCallManager.hungUpCall(callType = activeCall.callType)
}
} }
} }

View file

@ -8,8 +8,11 @@
package io.element.android.features.call.impl.utils package io.element.android.features.call.impl.utils
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.os.PowerManager
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.appconfig.ElementCallConfig import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallType
@ -17,6 +20,7 @@ import io.element.android.features.call.api.CurrentCall
import io.element.android.features.call.impl.notifications.CallNotificationData 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.ApplicationContext
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.matrix.api.MatrixClientProvider
import io.element.android.libraries.push.api.notifications.ForegroundServiceType import io.element.android.libraries.push.api.notifications.ForegroundServiceType
@ -38,6 +42,8 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
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.withLock
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -55,25 +61,26 @@ interface ActiveCallManager {
* Registers an incoming call if there isn't an existing active call and posts a [CallState.Ringing] notification. * Registers an incoming call if there isn't an existing active call and posts a [CallState.Ringing] notification.
* @param notificationData The data for the incoming call notification. * @param notificationData The data for the incoming call notification.
*/ */
fun registerIncomingCall(notificationData: CallNotificationData) suspend fun registerIncomingCall(notificationData: CallNotificationData)
/** /**
* Called when the active call has been hung up. It will remove any existing UI and the active call. * Called when the active call has been hung up. It will remove any existing UI and the active call.
* @param callType The type of call that the user hung up, either an external url one or a room one. * @param callType The type of call that the user hung up, either an external url one or a room one.
*/ */
fun hungUpCall(callType: CallType) suspend fun hungUpCall(callType: CallType)
/** /**
* Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall]. * Called after the user joined a call. It will remove any existing UI and set the call state as [CallState.InCall].
* *
* @param callType The type of call that the user joined, either an external url one or a room one. * @param callType The type of call that the user joined, either an external url one or a room one.
*/ */
fun joinedCall(callType: CallType) suspend fun joinedCall(callType: CallType)
} }
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultActiveCallManager @Inject constructor( class DefaultActiveCallManager @Inject constructor(
@ApplicationContext context: Context,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler, private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler,
private val ringingCallNotificationCreator: RingingCallNotificationCreator, private val ringingCallNotificationCreator: RingingCallNotificationCreator,
@ -83,33 +90,47 @@ class DefaultActiveCallManager @Inject constructor(
) : ActiveCallManager { ) : ActiveCallManager {
private var timedOutCallJob: Job? = null private var timedOutCallJob: Job? = null
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal val activeWakeLock: PowerManager.WakeLock? = context.getSystemService<PowerManager>()
?.takeIf { it.isWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK) }
?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "${context.packageName}:IncomingCallWakeLock")
override val activeCall = MutableStateFlow<ActiveCall?>(null) override val activeCall = MutableStateFlow<ActiveCall?>(null)
private val mutex = Mutex()
init { init {
observeRingingCall() observeRingingCall()
observeCurrentCall() observeCurrentCall()
} }
override fun registerIncomingCall(notificationData: CallNotificationData) { override suspend fun registerIncomingCall(notificationData: CallNotificationData) {
if (activeCall.value != null) { mutex.withLock {
displayMissedCallNotification(notificationData) if (activeCall.value != null) {
Timber.w("Already have an active call, ignoring incoming call: $notificationData") displayMissedCallNotification(notificationData)
return Timber.w("Already have an active call, ignoring incoming call: $notificationData")
} return
activeCall.value = ActiveCall( }
callType = CallType.RoomCall( activeCall.value = ActiveCall(
sessionId = notificationData.sessionId, callType = CallType.RoomCall(
roomId = notificationData.roomId, sessionId = notificationData.sessionId,
), roomId = notificationData.roomId,
callState = CallState.Ringing(notificationData), ),
) callState = CallState.Ringing(notificationData),
)
timedOutCallJob = coroutineScope.launch { timedOutCallJob = coroutineScope.launch {
showIncomingCallNotification(notificationData) showIncomingCallNotification(notificationData)
// Wait for the ringing call to time out // Wait for the ringing call to time out
delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds) delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds)
incomingCallTimedOut(displayMissedCallNotification = true) incomingCallTimedOut(displayMissedCallNotification = true)
}
// Acquire a wake lock to keep the device awake during the incoming call, so we can process the room info data
if (activeWakeLock?.isHeld == false) {
activeWakeLock.acquire(ElementCallConfig.RINGING_CALL_DURATION_SECONDS * 1000L)
}
} }
} }
@ -117,10 +138,13 @@ class DefaultActiveCallManager @Inject constructor(
* Called when the incoming call timed out. It will remove the active call and remove any associated UI, adding a 'missed call' notification. * Called when the incoming call timed out. It will remove the active call and remove any associated UI, adding a 'missed call' notification.
*/ */
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
fun incomingCallTimedOut(displayMissedCallNotification: Boolean) { suspend fun incomingCallTimedOut(displayMissedCallNotification: Boolean) = mutex.withLock {
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 activeCall.value = null
if (activeWakeLock?.isHeld == true) {
activeWakeLock.release()
}
cancelIncomingCallNotification() cancelIncomingCallNotification()
@ -129,18 +153,24 @@ class DefaultActiveCallManager @Inject constructor(
} }
} }
override fun hungUpCall(callType: CallType) { override suspend fun hungUpCall(callType: CallType) = mutex.withLock {
if (activeCall.value?.callType != callType) { if (activeCall.value?.callType != callType) {
Timber.w("Call type $callType does not match the active call type, ignoring") Timber.w("Call type $callType does not match the active call type, ignoring")
return return
} }
cancelIncomingCallNotification() cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) {
activeWakeLock.release()
}
timedOutCallJob?.cancel() timedOutCallJob?.cancel()
activeCall.value = null activeCall.value = null
} }
override fun joinedCall(callType: CallType) { override suspend fun joinedCall(callType: CallType) = mutex.withLock {
cancelIncomingCallNotification() cancelIncomingCallNotification()
if (activeWakeLock?.isHeld == true) {
activeWakeLock.release()
}
timedOutCallJob?.cancel() timedOutCallJob?.cancel()
activeCall.value = ActiveCall( activeCall.value = ActiveCall(
@ -201,6 +231,7 @@ class DefaultActiveCallManager @Inject constructor(
?.getRoom(callType.roomId) ?.getRoom(callType.roomId)
?.roomInfoFlow ?.roomInfoFlow
?.map { ?.map {
Timber.d("Has room call status changed for ringing call: ${it.hasRoomCall}")
it.hasRoomCall to (callType.sessionId in it.activeRoomCallParticipants) it.hasRoomCall to (callType.sessionId in it.activeRoomCallParticipants)
} }
?: flowOf() ?: flowOf()

View file

@ -20,16 +20,21 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
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.A_USER_ID_2
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf import org.robolectric.Shadows.shadowOf
import kotlin.time.Duration.Companion.seconds
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class DefaultElementCallEntryPointTest { class DefaultElementCallEntryPointTest {
@Test @Test
fun `startCall - starts ElementCallActivity setup with the needed extras`() { fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest {
val entryPoint = createEntryPoint() val entryPoint = createEntryPoint()
entryPoint.startCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID)) entryPoint.startCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID))
@ -39,8 +44,9 @@ class DefaultElementCallEntryPointTest {
assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue() assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue()
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() { fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() = runTest {
val registerIncomingCallLambda = lambdaRecorder<CallNotificationData, Unit> {} val registerIncomingCallLambda = lambdaRecorder<CallNotificationData, Unit> {}
val activeCallManager = FakeActiveCallManager(registerIncomingCallResult = registerIncomingCallLambda) val activeCallManager = FakeActiveCallManager(registerIncomingCallResult = registerIncomingCallLambda)
val entryPoint = createEntryPoint(activeCallManager = activeCallManager) val entryPoint = createEntryPoint(activeCallManager = activeCallManager)
@ -57,10 +63,12 @@ class DefaultElementCallEntryPointTest {
textContent = "textContent", textContent = "textContent",
) )
advanceTimeBy(1.seconds)
registerIncomingCallLambda.assertions().isCalledOnce() registerIncomingCallLambda.assertions().isCalledOnce()
} }
private fun createEntryPoint( private fun TestScope.createEntryPoint(
activeCallManager: FakeActiveCallManager = FakeActiveCallManager(), activeCallManager: FakeActiveCallManager = FakeActiveCallManager(),
) = DefaultElementCallEntryPoint( ) = DefaultElementCallEntryPoint(
context = InstrumentationRegistry.getInstrumentation().targetContext, context = InstrumentationRegistry.getInstrumentation().targetContext,

View file

@ -44,12 +44,14 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.seconds
class CallScreenPresenterTest { @OptIn(ExperimentalCoroutinesApi::class) class CallScreenPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
@ -66,7 +68,8 @@ class CallScreenPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
// Wait until the URL is loaded // Wait until the URL is loaded
skipItems(1) advanceTimeBy(1.seconds)
skipItems(2)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io")) assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io"))
assertThat(initialState.webViewError).isNull() assertThat(initialState.webViewError).isNull()
@ -101,16 +104,23 @@ class CallScreenPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
// Wait until the URL is loaded // Wait until the URL is loaded
advanceTimeBy(1.seconds)
skipItems(1) skipItems(1)
joinedCallLambda.assertions().isCalledOnce() joinedCallLambda.assertions().isCalledOnce()
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.urlState).isInstanceOf(AsyncData.Success::class.java) assertThat(initialState.urlState).isInstanceOf(AsyncData.Loading::class.java)
assertThat(initialState.isCallActive).isFalse() assertThat(initialState.isCallActive).isFalse()
assertThat(initialState.isInWidgetMode).isTrue() assertThat(initialState.isInWidgetMode).isTrue()
assertThat(widgetProvider.getWidgetCalled).isTrue() assertThat(widgetProvider.getWidgetCalled).isTrue()
assertThat(widgetDriver.runCalledCount).isEqualTo(1) assertThat(widgetDriver.runCalledCount).isEqualTo(1)
analyticsLambda.assertions().isCalledOnce().with(value(MobileScreen.ScreenName.RoomCall)) analyticsLambda.assertions().isCalledOnce().with(value(MobileScreen.ScreenName.RoomCall))
sendCallNotificationIfNeededLambda.assertions().isCalledOnce() sendCallNotificationIfNeededLambda.assertions().isCalledOnce()
// Wait until the WidgetDriver is loaded
skipItems(1)
assertThat(awaitItem().urlState).isInstanceOf(AsyncData.Success::class.java)
} }
} }
@ -126,6 +136,9 @@ class CallScreenPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
@ -141,7 +154,6 @@ class CallScreenPresenterTest {
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `present - hang up event closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) { fun `present - hang up event closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
val navigator = FakeCallScreenNavigator() val navigator = FakeCallScreenNavigator()
@ -158,11 +170,15 @@ class CallScreenPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
initialState.eventSink(CallScreenEvents.Hangup) initialState.eventSink(CallScreenEvents.Hangup)
// Let background coroutines run // Let background coroutines run and the widget drive be received
runCurrent() runCurrent()
assertThat(navigator.closeCalled).isTrue() assertThat(navigator.closeCalled).isTrue()
@ -172,7 +188,6 @@ class CallScreenPresenterTest {
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `present - a received close message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) { fun `present - a received close message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
val navigator = FakeCallScreenNavigator() val navigator = FakeCallScreenNavigator()
@ -189,11 +204,16 @@ class CallScreenPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
// Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
messageInterceptor.givenInterceptedMessage("""{"action":"io.element.close","api":"fromWidget","widgetId":"1","requestId":"1"}""") messageInterceptor.givenInterceptedMessage("""{"action":"io.element.close","api":"fromWidget","widgetId":"1","requestId":"1"}""")
// Let background coroutines run // Let background coroutines run
advanceTimeBy(1.seconds)
runCurrent() runCurrent()
assertThat(navigator.closeCalled).isTrue() assertThat(navigator.closeCalled).isTrue()
@ -218,7 +238,9 @@ class CallScreenPresenterTest {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) // Give it time to load the URL and WidgetDriver
advanceTimeBy(1.seconds)
skipItems(2)
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.isCallActive).isFalse() assertThat(initialState.isCallActive).isFalse()
initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
@ -235,7 +257,7 @@ class CallScreenPresenterTest {
} }
""".trimIndent() """.trimIndent()
) )
skipItems(1) skipItems(2)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.isCallActive).isTrue() assertThat(finalState.isCallActive).isTrue()
} }
@ -300,7 +322,8 @@ class CallScreenPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
// Wait until the URL is loaded // Wait until the URL is loaded
skipItems(1) advanceTimeBy(1.seconds)
skipItems(2)
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error")) initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
val finalState = awaitItem() val finalState = awaitItem()
@ -329,6 +352,8 @@ class CallScreenPresenterTest {
initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error")) initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error"))
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.webViewError).isNull() assertThat(finalState.webViewError).isNull()
cancelAndIgnoreRemainingEvents()
} }
} }
@ -361,6 +386,7 @@ class CallScreenPresenterTest {
screenTracker = screenTracker, screenTracker = screenTracker,
languageTagProvider = FakeLanguageTagProvider("en-US"), languageTagProvider = FakeLanguageTagProvider("en-US"),
appForegroundStateService = appForegroundStateService, appForegroundStateService = appForegroundStateService,
appCoroutineScope = backgroundScope,
) )
} }
} }

View file

@ -7,7 +7,9 @@
package io.element.android.features.call.utils package io.element.android.features.call.utils
import android.os.PowerManager
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.call.api.CallType import io.element.android.features.call.api.CallType
@ -49,6 +51,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
class DefaultActiveCallManagerTest { class DefaultActiveCallManagerTest {
@ -57,10 +60,12 @@ class DefaultActiveCallManagerTest {
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `registerIncomingCall - sets the incoming call as active`() = runTest { fun `registerIncomingCall - sets the incoming call as active`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
inCancellableScope { inCancellableScope {
val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat) val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat)
assertThat(manager.activeWakeLock?.isHeld).isFalse()
assertThat(manager.activeCall.value).isNull() assertThat(manager.activeCall.value).isNull()
val callNotificationData = aCallNotificationData() val callNotificationData = aCallNotificationData()
@ -78,6 +83,7 @@ class DefaultActiveCallManagerTest {
runCurrent() runCurrent()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify { notificationManagerCompat.notify(notificationId, any()) } verify { notificationManagerCompat.notify(notificationId, any()) }
} }
} }
@ -128,6 +134,7 @@ class DefaultActiveCallManagerTest {
@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 {
setupShadowPowerManager()
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 { inCancellableScope {
@ -138,11 +145,13 @@ class DefaultActiveCallManagerTest {
manager.registerIncomingCall(aCallNotificationData()) manager.registerIncomingCall(aCallNotificationData())
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.incomingCallTimedOut(displayMissedCallNotification = true) manager.incomingCallTimedOut(displayMissedCallNotification = true)
advanceTimeBy(1) advanceTimeBy(1)
assertThat(manager.activeCall.value).isNull() assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
addMissedCallNotificationLambda.assertions().isCalledOnce() addMissedCallNotificationLambda.assertions().isCalledOnce()
verify { notificationManagerCompat.cancel(notificationId) } verify { notificationManagerCompat.cancel(notificationId) }
} }
@ -150,6 +159,7 @@ class DefaultActiveCallManagerTest {
@Test @Test
fun `hungUpCall - removes existing call if the CallType matches`() = runTest { fun `hungUpCall - removes existing call if the CallType matches`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
// Create a cancellable coroutine scope to cancel the test when needed // Create a cancellable coroutine scope to cancel the test when needed
inCancellableScope { inCancellableScope {
@ -158,9 +168,11 @@ class DefaultActiveCallManagerTest {
val notificationData = aCallNotificationData() val notificationData = aCallNotificationData()
manager.registerIncomingCall(notificationData) manager.registerIncomingCall(notificationData)
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
assertThat(manager.activeCall.value).isNull() assertThat(manager.activeCall.value).isNull()
assertThat(manager.activeWakeLock?.isHeld).isFalse()
verify { notificationManagerCompat.cancel(notificationId) } verify { notificationManagerCompat.cancel(notificationId) }
} }
@ -168,6 +180,7 @@ class DefaultActiveCallManagerTest {
@Test @Test
fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest { fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest {
setupShadowPowerManager()
val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true) val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
// Create a cancellable coroutine scope to cancel the test when needed // Create a cancellable coroutine scope to cancel the test when needed
inCancellableScope { inCancellableScope {
@ -175,9 +188,11 @@ class DefaultActiveCallManagerTest {
manager.registerIncomingCall(aCallNotificationData()) manager.registerIncomingCall(aCallNotificationData())
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
manager.hungUpCall(CallType.ExternalUrl("https://example.com")) manager.hungUpCall(CallType.ExternalUrl("https://example.com"))
assertThat(manager.activeCall.value).isNotNull() assertThat(manager.activeCall.value).isNotNull()
assertThat(manager.activeWakeLock?.isHeld).isTrue()
verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) } verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) }
} }
@ -284,12 +299,19 @@ class DefaultActiveCallManagerTest {
} }
} }
private fun setupShadowPowerManager() {
shadowOf(InstrumentationRegistry.getInstrumentation().targetContext.getSystemService<PowerManager>()).apply {
setIsWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK, true)
}
}
private fun CoroutineScope.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, coroutineScope: CoroutineScope = this,
) = DefaultActiveCallManager( ) = DefaultActiveCallManager(
context = InstrumentationRegistry.getInstrumentation().targetContext,
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
onMissedCallNotificationHandler = onMissedCallNotificationHandler, onMissedCallNotificationHandler = onMissedCallNotificationHandler,
ringingCallNotificationCreator = RingingCallNotificationCreator( ringingCallNotificationCreator = RingingCallNotificationCreator(

View file

@ -11,6 +11,7 @@ import io.element.android.features.call.api.CallType
import io.element.android.features.call.impl.notifications.CallNotificationData import io.element.android.features.call.impl.notifications.CallNotificationData
import io.element.android.features.call.impl.utils.ActiveCall import io.element.android.features.call.impl.utils.ActiveCall
import io.element.android.features.call.impl.utils.ActiveCallManager import io.element.android.features.call.impl.utils.ActiveCallManager
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
class FakeActiveCallManager( class FakeActiveCallManager(
@ -20,15 +21,15 @@ class FakeActiveCallManager(
) : ActiveCallManager { ) : ActiveCallManager {
override val activeCall = MutableStateFlow<ActiveCall?>(null) override val activeCall = MutableStateFlow<ActiveCall?>(null)
override fun registerIncomingCall(notificationData: CallNotificationData) { override suspend fun registerIncomingCall(notificationData: CallNotificationData) = simulateLongTask {
registerIncomingCallResult(notificationData) registerIncomingCallResult(notificationData)
} }
override fun hungUpCall(callType: CallType) { override suspend fun hungUpCall(callType: CallType) = simulateLongTask {
hungUpCallResult(callType) hungUpCallResult(callType)
} }
override fun joinedCall(callType: CallType) { override suspend fun joinedCall(callType: CallType) = simulateLongTask {
joinedCallResult(callType) joinedCallResult(callType)
} }

View file

@ -30,7 +30,7 @@ class FakeElementCallEntryPoint(
startCallResult(callType) startCallResult(callType)
} }
override fun handleIncomingCall( override suspend fun handleIncomingCall(
callType: CallType.RoomCall, callType: CallType.RoomCall,
eventId: EventId, eventId: EventId,
senderId: UserId, senderId: UserId,

View file

@ -118,7 +118,7 @@ class DefaultPushHandler @Inject constructor(
} }
} }
private fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) { private suspend fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) {
Timber.i("## handleInternal() : Incoming call.") Timber.i("## handleInternal() : Incoming call.")
elementCallEntryPoint.handleIncomingCall( elementCallEntryPoint.handleIncomingCall(
callType = CallType.RoomCall(notifiableEvent.sessionId, notifiableEvent.roomId), callType = CallType.RoomCall(notifiableEvent.sessionId, notifiableEvent.roomId),