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 1517e534a7
commit 2e191de343
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 152 additions and 48 deletions

View file

@ -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,

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.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))
}
}
}

View file

@ -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?>,

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.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)
}
}
}

View file

@ -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()