diff --git a/features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt b/features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt index 9229681966..53e1b8c846 100644 --- a/features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt +++ b/features/call/api/src/main/kotlin/io/element/android/features/call/api/ElementCallEntryPoint.kt @@ -32,7 +32,7 @@ interface ElementCallEntryPoint { * @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. */ - fun handleIncomingCall( + suspend fun handleIncomingCall( callType: CallType.RoomCall, eventId: EventId, senderId: UserId, diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt index 290bfe0824..009840743c 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/DefaultElementCallEntryPoint.kt @@ -34,7 +34,7 @@ class DefaultElementCallEntryPoint @Inject constructor( context.startActivity(IntentProvider.createIntent(context, callType)) } - override fun handleIncomingCall( + override suspend fun handleIncomingCall( callType: CallType.RoomCall, eventId: EventId, senderId: UserId, diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt index ab6f07ce49..bbc0611083 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/receivers/DeclineCallBroadcastReceiver.kt @@ -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.utils.ActiveCallManager import io.element.android.libraries.architecture.bindings +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import javax.inject.Inject /** @@ -27,10 +29,16 @@ class DeclineCallBroadcastReceiver : BroadcastReceiver() { } @Inject lateinit var activeCallManager: ActiveCallManager + + @Inject + lateinit var appCoroutineScope: CoroutineScope + override fun onReceive(context: Context, intent: Intent?) { val notificationData = intent?.let { IntentCompat.getParcelableExtra(it, EXTRA_NOTIFICATION_DATA, CallNotificationData::class.java) } ?: return context.bindings().inject(this) - activeCallManager.hungUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) + appCoroutineScope.launch { + activeCallManager.hungUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) + } } } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt index 47f5682f4c..6baffd8143 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/CallScreenPresenter.kt @@ -62,6 +62,7 @@ class CallScreenPresenter @AssistedInject constructor( private val activeCallManager: ActiveCallManager, private val languageTagProvider: LanguageTagProvider, private val appForegroundStateService: AppForegroundStateService, + private val appCoroutineScope: CoroutineScope, ) : Presenter { @AssistedFactory interface Factory { @@ -87,7 +88,7 @@ class CallScreenPresenter @AssistedInject constructor( coroutineScope.launch { // Sets the call as joined activeCallManager.joinedCall(callType) - loadUrl( + fetchRoomCallUrl( inputs = callType, urlState = urlState, callWidgetDriver = callWidgetDriver, @@ -96,7 +97,7 @@ class CallScreenPresenter @AssistedInject constructor( ) } 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, urlState: MutableState>, callWidgetDriver: MutableState, diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt index 9669285ce0..c5162d9a9e 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/ui/IncomingCallActivity.kt @@ -24,9 +24,11 @@ import io.element.android.libraries.architecture.bindings import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.designsystem.theme.ElementThemeApp import io.element.android.libraries.preferences.api.store.AppPreferencesStore +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import javax.inject.Inject /** @@ -55,6 +57,9 @@ class IncomingCallActivity : AppCompatActivity() { @Inject lateinit var buildMeta: BuildMeta + @Inject + lateinit var appCoroutineScope: CoroutineScope + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -102,6 +107,8 @@ class IncomingCallActivity : AppCompatActivity() { private fun onCancel() { val activeCall = activeCallManager.activeCall.value ?: return - activeCallManager.hungUpCall(callType = activeCall.callType) + appCoroutineScope.launch { + activeCallManager.hungUpCall(callType = activeCall.callType) + } } } diff --git a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt index 2a12117a48..fe266d1cf6 100644 --- a/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt +++ b/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/ActiveCallManager.kt @@ -8,8 +8,11 @@ package io.element.android.features.call.impl.utils import android.annotation.SuppressLint +import android.content.Context +import android.os.PowerManager import androidx.annotation.VisibleForTesting import androidx.core.app.NotificationManagerCompat +import androidx.core.content.getSystemService import com.squareup.anvil.annotations.ContributesBinding import io.element.android.appconfig.ElementCallConfig 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.RingingCallNotificationCreator 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.matrix.api.MatrixClientProvider 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.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import timber.log.Timber import javax.inject.Inject 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. * @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. * @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]. * * @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) @ContributesBinding(AppScope::class) class DefaultActiveCallManager @Inject constructor( + @ApplicationContext context: Context, private val coroutineScope: CoroutineScope, private val onMissedCallNotificationHandler: OnMissedCallNotificationHandler, private val ringingCallNotificationCreator: RingingCallNotificationCreator, @@ -83,33 +90,47 @@ class DefaultActiveCallManager @Inject constructor( ) : ActiveCallManager { private var timedOutCallJob: Job? = null + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal val activeWakeLock: PowerManager.WakeLock? = context.getSystemService() + ?.takeIf { it.isWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK) } + ?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "${context.packageName}:IncomingCallWakeLock") + override val activeCall = MutableStateFlow(null) + private val mutex = Mutex() + init { observeRingingCall() observeCurrentCall() } - override fun registerIncomingCall(notificationData: CallNotificationData) { - if (activeCall.value != null) { - displayMissedCallNotification(notificationData) - Timber.w("Already have an active call, ignoring incoming call: $notificationData") - return - } - activeCall.value = ActiveCall( - callType = CallType.RoomCall( - sessionId = notificationData.sessionId, - roomId = notificationData.roomId, - ), - callState = CallState.Ringing(notificationData), - ) + override suspend fun registerIncomingCall(notificationData: CallNotificationData) { + mutex.withLock { + if (activeCall.value != null) { + displayMissedCallNotification(notificationData) + Timber.w("Already have an active call, ignoring incoming call: $notificationData") + return + } + activeCall.value = ActiveCall( + callType = CallType.RoomCall( + sessionId = notificationData.sessionId, + roomId = notificationData.roomId, + ), + callState = CallState.Ringing(notificationData), + ) - timedOutCallJob = coroutineScope.launch { - showIncomingCallNotification(notificationData) + timedOutCallJob = coroutineScope.launch { + showIncomingCallNotification(notificationData) - // Wait for the ringing call to time out - delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds) - incomingCallTimedOut(displayMissedCallNotification = true) + // Wait for the ringing call to time out + delay(ElementCallConfig.RINGING_CALL_DURATION_SECONDS.seconds) + 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. */ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - fun incomingCallTimedOut(displayMissedCallNotification: Boolean) { + suspend fun incomingCallTimedOut(displayMissedCallNotification: Boolean) = mutex.withLock { val previousActiveCall = activeCall.value ?: return val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return activeCall.value = null + if (activeWakeLock?.isHeld == true) { + activeWakeLock.release() + } 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) { Timber.w("Call type $callType does not match the active call type, ignoring") return } cancelIncomingCallNotification() + if (activeWakeLock?.isHeld == true) { + activeWakeLock.release() + } timedOutCallJob?.cancel() activeCall.value = null } - override fun joinedCall(callType: CallType) { + override suspend fun joinedCall(callType: CallType) = mutex.withLock { cancelIncomingCallNotification() + if (activeWakeLock?.isHeld == true) { + activeWakeLock.release() + } timedOutCallJob?.cancel() activeCall.value = ActiveCall( @@ -201,6 +231,7 @@ class DefaultActiveCallManager @Inject constructor( ?.getRoom(callType.roomId) ?.roomInfoFlow ?.map { + Timber.d("Has room call status changed for ringing call: ${it.hasRoomCall}") it.hasRoomCall to (callType.sessionId in it.activeRoomCallParticipants) } ?: flowOf() diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt index 97d6dac427..86d9b80d65 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/DefaultElementCallEntryPointTest.kt @@ -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_USER_ID_2 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.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.Shadows.shadowOf +import kotlin.time.Duration.Companion.seconds @RunWith(RobolectricTestRunner::class) class DefaultElementCallEntryPointTest { @Test - fun `startCall - starts ElementCallActivity setup with the needed extras`() { + fun `startCall - starts ElementCallActivity setup with the needed extras`() = runTest { val entryPoint = createEntryPoint() entryPoint.startCall(CallType.RoomCall(A_SESSION_ID, A_ROOM_ID)) @@ -39,8 +44,9 @@ class DefaultElementCallEntryPointTest { assertThat(intent.extras?.containsKey("EXTRA_CALL_TYPE")).isTrue() } + @OptIn(ExperimentalCoroutinesApi::class) @Test - fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() { + fun `handleIncomingCall - registers the incoming call using ActiveCallManager`() = runTest { val registerIncomingCallLambda = lambdaRecorder {} val activeCallManager = FakeActiveCallManager(registerIncomingCallResult = registerIncomingCallLambda) val entryPoint = createEntryPoint(activeCallManager = activeCallManager) @@ -57,10 +63,12 @@ class DefaultElementCallEntryPointTest { textContent = "textContent", ) + advanceTimeBy(1.seconds) + registerIncomingCallLambda.assertions().isCalledOnce() } - private fun createEntryPoint( + private fun TestScope.createEntryPoint( activeCallManager: FakeActiveCallManager = FakeActiveCallManager(), ) = DefaultElementCallEntryPoint( context = InstrumentationRegistry.getInstrumentation().targetContext, diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt index ce185bcdfc..5ec48ce6a1 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt @@ -44,12 +44,14 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +import kotlin.time.Duration.Companion.seconds -class CallScreenPresenterTest { +@OptIn(ExperimentalCoroutinesApi::class) class CallScreenPresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -66,7 +68,8 @@ class CallScreenPresenterTest { presenter.present() }.test { // Wait until the URL is loaded - skipItems(1) + advanceTimeBy(1.seconds) + skipItems(2) val initialState = awaitItem() assertThat(initialState.urlState).isEqualTo(AsyncData.Success("https://call.element.io")) assertThat(initialState.webViewError).isNull() @@ -101,16 +104,23 @@ class CallScreenPresenterTest { presenter.present() }.test { // Wait until the URL is loaded + advanceTimeBy(1.seconds) skipItems(1) + joinedCallLambda.assertions().isCalledOnce() 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.isInWidgetMode).isTrue() assertThat(widgetProvider.getWidgetCalled).isTrue() assertThat(widgetDriver.runCalledCount).isEqualTo(1) analyticsLambda.assertions().isCalledOnce().with(value(MobileScreen.ScreenName.RoomCall)) 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) { presenter.present() }.test { + // Give it time to load the URL and WidgetDriver + advanceTimeBy(1.seconds) + val initialState = awaitItem() initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) @@ -141,7 +154,6 @@ class CallScreenPresenterTest { } } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun `present - hang up event closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) { val navigator = FakeCallScreenNavigator() @@ -158,11 +170,15 @@ class CallScreenPresenterTest { presenter.present() }.test { val initialState = awaitItem() + + // Give it time to load the URL and WidgetDriver + advanceTimeBy(1.seconds) + initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) initialState.eventSink(CallScreenEvents.Hangup) - // Let background coroutines run + // Let background coroutines run and the widget drive be received runCurrent() assertThat(navigator.closeCalled).isTrue() @@ -172,7 +188,6 @@ class CallScreenPresenterTest { } } - @OptIn(ExperimentalCoroutinesApi::class) @Test fun `present - a received close message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) { val navigator = FakeCallScreenNavigator() @@ -189,11 +204,16 @@ class CallScreenPresenterTest { presenter.present() }.test { val initialState = awaitItem() + + // Give it time to load the URL and WidgetDriver + advanceTimeBy(1.seconds) + initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) messageInterceptor.givenInterceptedMessage("""{"action":"io.element.close","api":"fromWidget","widgetId":"1","requestId":"1"}""") // Let background coroutines run + advanceTimeBy(1.seconds) runCurrent() assertThat(navigator.closeCalled).isTrue() @@ -218,7 +238,9 @@ class CallScreenPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) + // Give it time to load the URL and WidgetDriver + advanceTimeBy(1.seconds) + skipItems(2) val initialState = awaitItem() assertThat(initialState.isCallActive).isFalse() initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor)) @@ -235,7 +257,7 @@ class CallScreenPresenterTest { } """.trimIndent() ) - skipItems(1) + skipItems(2) val finalState = awaitItem() assertThat(finalState.isCallActive).isTrue() } @@ -300,7 +322,8 @@ class CallScreenPresenterTest { presenter.present() }.test { // Wait until the URL is loaded - skipItems(1) + advanceTimeBy(1.seconds) + skipItems(2) val initialState = awaitItem() initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error")) val finalState = awaitItem() @@ -329,6 +352,8 @@ class CallScreenPresenterTest { initialState.eventSink(CallScreenEvents.OnWebViewError("A Webview error")) val finalState = awaitItem() assertThat(finalState.webViewError).isNull() + + cancelAndIgnoreRemainingEvents() } } @@ -361,6 +386,7 @@ class CallScreenPresenterTest { screenTracker = screenTracker, languageTagProvider = FakeLanguageTagProvider("en-US"), appForegroundStateService = appForegroundStateService, + appCoroutineScope = backgroundScope, ) } } diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt index ea1357c0e9..ff53b1ff77 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/DefaultActiveCallManagerTest.kt @@ -7,7 +7,9 @@ package io.element.android.features.call.utils +import android.os.PowerManager import androidx.core.app.NotificationManagerCompat +import androidx.core.content.getSystemService import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat import io.element.android.features.call.api.CallType @@ -49,6 +51,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf @RunWith(RobolectricTestRunner::class) class DefaultActiveCallManagerTest { @@ -57,10 +60,12 @@ class DefaultActiveCallManagerTest { @OptIn(ExperimentalCoroutinesApi::class) @Test fun `registerIncomingCall - sets the incoming call as active`() = runTest { + setupShadowPowerManager() val notificationManagerCompat = mockk(relaxed = true) inCancellableScope { val manager = createActiveCallManager(notificationManagerCompat = notificationManagerCompat) + assertThat(manager.activeWakeLock?.isHeld).isFalse() assertThat(manager.activeCall.value).isNull() val callNotificationData = aCallNotificationData() @@ -78,6 +83,7 @@ class DefaultActiveCallManagerTest { runCurrent() + assertThat(manager.activeWakeLock?.isHeld).isTrue() verify { notificationManagerCompat.notify(notificationId, any()) } } } @@ -128,6 +134,7 @@ class DefaultActiveCallManagerTest { @OptIn(ExperimentalCoroutinesApi::class) @Test fun `incomingCallTimedOut - when there is an active call removes it and adds a missed call notification`() = runTest { + setupShadowPowerManager() val notificationManagerCompat = mockk(relaxed = true) val addMissedCallNotificationLambda = lambdaRecorder { _, _, _ -> } inCancellableScope { @@ -138,11 +145,13 @@ class DefaultActiveCallManagerTest { manager.registerIncomingCall(aCallNotificationData()) assertThat(manager.activeCall.value).isNotNull() + assertThat(manager.activeWakeLock?.isHeld).isTrue() manager.incomingCallTimedOut(displayMissedCallNotification = true) advanceTimeBy(1) assertThat(manager.activeCall.value).isNull() + assertThat(manager.activeWakeLock?.isHeld).isFalse() addMissedCallNotificationLambda.assertions().isCalledOnce() verify { notificationManagerCompat.cancel(notificationId) } } @@ -150,6 +159,7 @@ class DefaultActiveCallManagerTest { @Test fun `hungUpCall - removes existing call if the CallType matches`() = runTest { + setupShadowPowerManager() val notificationManagerCompat = mockk(relaxed = true) // Create a cancellable coroutine scope to cancel the test when needed inCancellableScope { @@ -158,9 +168,11 @@ class DefaultActiveCallManagerTest { val notificationData = aCallNotificationData() manager.registerIncomingCall(notificationData) assertThat(manager.activeCall.value).isNotNull() + assertThat(manager.activeWakeLock?.isHeld).isTrue() manager.hungUpCall(CallType.RoomCall(notificationData.sessionId, notificationData.roomId)) assertThat(manager.activeCall.value).isNull() + assertThat(manager.activeWakeLock?.isHeld).isFalse() verify { notificationManagerCompat.cancel(notificationId) } } @@ -168,6 +180,7 @@ class DefaultActiveCallManagerTest { @Test fun `hungUpCall - does nothing if the CallType doesn't match`() = runTest { + setupShadowPowerManager() val notificationManagerCompat = mockk(relaxed = true) // Create a cancellable coroutine scope to cancel the test when needed inCancellableScope { @@ -175,9 +188,11 @@ class DefaultActiveCallManagerTest { manager.registerIncomingCall(aCallNotificationData()) assertThat(manager.activeCall.value).isNotNull() + assertThat(manager.activeWakeLock?.isHeld).isTrue() manager.hungUpCall(CallType.ExternalUrl("https://example.com")) assertThat(manager.activeCall.value).isNotNull() + assertThat(manager.activeWakeLock?.isHeld).isTrue() verify(exactly = 0) { notificationManagerCompat.cancel(notificationId) } } @@ -284,12 +299,19 @@ class DefaultActiveCallManagerTest { } } + private fun setupShadowPowerManager() { + shadowOf(InstrumentationRegistry.getInstrumentation().targetContext.getSystemService()).apply { + setIsWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK, true) + } + } + private fun CoroutineScope.createActiveCallManager( matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), onMissedCallNotificationHandler: FakeOnMissedCallNotificationHandler = FakeOnMissedCallNotificationHandler(), notificationManagerCompat: NotificationManagerCompat = mockk(relaxed = true), coroutineScope: CoroutineScope = this, ) = DefaultActiveCallManager( + context = InstrumentationRegistry.getInstrumentation().targetContext, coroutineScope = coroutineScope, onMissedCallNotificationHandler = onMissedCallNotificationHandler, ringingCallNotificationCreator = RingingCallNotificationCreator( diff --git a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt index 3024ce9f2f..90527370e7 100644 --- a/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt +++ b/features/call/impl/src/test/kotlin/io/element/android/features/call/utils/FakeActiveCallManager.kt @@ -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.utils.ActiveCall import io.element.android.features.call.impl.utils.ActiveCallManager +import io.element.android.tests.testutils.simulateLongTask import kotlinx.coroutines.flow.MutableStateFlow class FakeActiveCallManager( @@ -20,15 +21,15 @@ class FakeActiveCallManager( ) : ActiveCallManager { override val activeCall = MutableStateFlow(null) - override fun registerIncomingCall(notificationData: CallNotificationData) { + override suspend fun registerIncomingCall(notificationData: CallNotificationData) = simulateLongTask { registerIncomingCallResult(notificationData) } - override fun hungUpCall(callType: CallType) { + override suspend fun hungUpCall(callType: CallType) = simulateLongTask { hungUpCallResult(callType) } - override fun joinedCall(callType: CallType) { + override suspend fun joinedCall(callType: CallType) = simulateLongTask { joinedCallResult(callType) } diff --git a/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt index bbd990df27..09a1269259 100644 --- a/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt +++ b/features/call/test/src/main/kotlin/io/element/android/features/call/test/FakeElementCallEntryPoint.kt @@ -30,7 +30,7 @@ class FakeElementCallEntryPoint( startCallResult(callType) } - override fun handleIncomingCall( + override suspend fun handleIncomingCall( callType: CallType.RoomCall, eventId: EventId, senderId: UserId, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt index 66742652bf..dd2214650e 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt @@ -118,7 +118,7 @@ class DefaultPushHandler @Inject constructor( } } - private fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) { + private suspend fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) { Timber.i("## handleInternal() : Incoming call.") elementCallEntryPoint.handleIncomingCall( callType = CallType.RoomCall(notifiableEvent.sessionId, notifiableEvent.roomId),