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:
committed by
GitHub
parent
f52f24b464
commit
e45151d9d1
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CallBindings>().inject(this)
|
||||
activeCallManager.hungUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
|
||||
appCoroutineScope.launch {
|
||||
activeCallManager.hungUpCall(callType = CallType.RoomCall(notificationData.sessionId, notificationData.roomId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CallScreenState> {
|
||||
@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<AsyncData<String>>,
|
||||
callWidgetDriver: MutableState<MatrixWidgetDriver?>,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PowerManager>()
|
||||
?.takeIf { it.isWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK) }
|
||||
?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "${context.packageName}:IncomingCallWakeLock")
|
||||
|
||||
override val activeCall = MutableStateFlow<ActiveCall?>(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()
|
||||
|
||||
@@ -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<CallNotificationData, Unit> {}
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NotificationManagerCompat>(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<NotificationManagerCompat>(relaxed = true)
|
||||
val addMissedCallNotificationLambda = lambdaRecorder<SessionId, RoomId, EventId, Unit> { _, _, _ -> }
|
||||
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<NotificationManagerCompat>(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<NotificationManagerCompat>(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<PowerManager>()).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(
|
||||
|
||||
@@ -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<ActiveCall?>(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)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class FakeElementCallEntryPoint(
|
||||
startCallResult(callType)
|
||||
}
|
||||
|
||||
override fun handleIncomingCall(
|
||||
override suspend fun handleIncomingCall(
|
||||
callType: CallType.RoomCall,
|
||||
eventId: EventId,
|
||||
senderId: UserId,
|
||||
|
||||
Reference in New Issue
Block a user