diff --git a/app/src/main/kotlin/com/wire/android/GlobalObserversManager.kt b/app/src/main/kotlin/com/wire/android/GlobalObserversManager.kt index b63a9da0626..f01f1c93c9d 100644 --- a/app/src/main/kotlin/com/wire/android/GlobalObserversManager.kt +++ b/app/src/main/kotlin/com/wire/android/GlobalObserversManager.kt @@ -33,6 +33,7 @@ import com.wire.kalium.logic.feature.user.webSocketStatus.ObservePersistentWebSo import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine @@ -67,8 +68,10 @@ class GlobalObserversManager @Inject constructor( scope.launch { setUpNotifications() } scope.launch { coreLogic.getGlobalScope().observeValidAccounts().distinctUntilChanged().collectLatest { - if (it.isNotEmpty()) { - coreLogic.getSessionScope(it.first().first.id).calls.endCallOnConversationChange() + coroutineScope { + it.forEach { + launch { coreLogic.getSessionScope(it.first.id).calls.endCallOnConversationChange() } + } } } } @@ -107,8 +110,15 @@ class GlobalObserversManager @Inject constructor( .distinctUntilChanged() .collectLatest { // create notification channels for all valid users + appLogger.i("GlobalObserversManager: creating notification channels for users: ${it.map { it.first.id }}") notificationChannelsManager.createUserNotificationChannels(it.map { it.first }) + if (it.isEmpty()) { + appLogger.i("GlobalObserversManager: no valid users, stopping observing notifications") + notificationManager.clearWhenNoUsers() + return@collectLatest + } + // do not observe notifications for users with PersistentWebSocketEnabled, it will be done in PersistentWebSocketService it.filter { (_, isPersistentWebSocketEnabled) -> !isPersistentWebSocketEnabled } .map { (selfUser, _) -> selfUser.id } diff --git a/app/src/main/kotlin/com/wire/android/notification/CallNotificationManager.kt b/app/src/main/kotlin/com/wire/android/notification/CallNotificationManager.kt index 9072b009876..f3107d26db5 100644 --- a/app/src/main/kotlin/com/wire/android/notification/CallNotificationManager.kt +++ b/app/src/main/kotlin/com/wire/android/notification/CallNotificationManager.kt @@ -24,6 +24,7 @@ import android.app.Notification import android.content.Context import android.os.Build import android.service.notification.StatusBarNotification +import androidx.annotation.StringRes import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat.CallStyle import androidx.core.app.NotificationManagerCompat @@ -127,14 +128,17 @@ class CallNotificationManager @Inject constructor( } } - fun hideAllIncomingCallNotifications() = hideIncomingCallNotifications { _, _ -> true } - fun hideAllIncomingCallNotificationsForUser(userId: UserId) = hideIncomingCallNotifications { tag, _ -> tag == NotificationConstants.getIncomingCallTag(userId.toString()) } fun hideIncomingCallNotification(userIdString: String, conversationIdString: String) = hideIncomingCallNotifications { _, id -> id == NotificationConstants.getIncomingCallId(userIdString, conversationIdString) } + fun hideAllCallNotifications() { + hideIncomingCallNotifications { _, _ -> true } + notificationManager.cancel(NotificationIds.CALL_OUTGOING_ONGOING_NOTIFICATION_ID.ordinal) + } + fun bringBackIncomingCallNotification(userIdString: String, conversationIdString: String) { scope.launch { val userId = qualifiedIdMapper.fromStringToQualifiedID(userIdString) @@ -213,14 +217,15 @@ class CallNotificationBuilder @Inject constructor( val userIdString = data.userId.toString() val conversationIdString = data.conversationId.toString() val channelId = NotificationConstants.getOutgoingChannelId(data.userId) - val person = Person.Builder().setName(data.conversationName ?: "").build() + val title = getNotificationTitle(data = data, defaultGroupName = R.string.notification_outgoing_call_default_group_name) + val person = Person.Builder().setName(title).build() val notificationBuilder = NotificationCompat.Builder(context, channelId) return notificationBuilder .setPriority(NotificationCompat.PRIORITY_LOW) .setCategory(NotificationCompat.CATEGORY_CALL) .setSmallIcon(NR.drawable.notification_icon_small) - .setContentTitle(data.conversationName) + .setContentTitle(title) .setContentText(context.getString(R.string.notification_outgoing_call_tap_to_return)) .setSubText(data.userName) .setAutoCancel(false) @@ -241,7 +246,7 @@ class CallNotificationBuilder @Inject constructor( fun getIncomingCallNotification(data: CallNotificationData, asFullScreenIntent: Boolean): Notification { val conversationIdString = data.conversationId.toString() val userIdString = data.userId.toString() - val title = getNotificationTitle(data) + val title = getNotificationTitle(data = data, defaultGroupName = R.string.notification_incoming_call_default_group_name) val content = getNotificationBody(data) val channelId = NotificationConstants.getIncomingChannelId(data.userId) val person = Person.Builder().setName(title).build() @@ -287,7 +292,7 @@ class CallNotificationBuilder @Inject constructor( val channelId = NotificationConstants.ONGOING_CALL_CHANNEL_ID val conversationIdString = data.conversationId.toString() val userIdString = data.userId.toString() - val title = getNotificationTitle(data) + val title = getNotificationTitle(data = data, defaultGroupName = R.string.notification_ongoing_call_default_group_name) val person = Person.Builder().setName(title).build() return NotificationCompat.Builder(context, channelId) @@ -315,41 +320,55 @@ class CallNotificationBuilder @Inject constructor( /** * @return placeholder Notification for CallService, that can be shown immediately after starting the Service * (e.g. in [android.app.Service.onCreate]). It has no any [NotificationCompat.Action], on click - just opens the app. - * This notification should be replace by the user-specific notification (with corresponding [NotificationCompat.Action], + * This notification should be replaced by the user-specific notification (with corresponding [NotificationCompat.Action], * [android.content.Intent] and title) once it's possible (e.g. in [android.app.Service.onStartCommand]) */ fun getCallServicePlaceholderNotification(): Notification { val channelId = NotificationConstants.ONGOING_CALL_CHANNEL_ID return NotificationCompat.Builder(context, channelId) + .setContentTitle(context.getString(R.string.app_name)) .setContentText(context.getString(R.string.notification_outgoing_call_tap_to_return)) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setCategory(NotificationCompat.CATEGORY_CALL) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setSmallIcon(NR.drawable.notification_icon_small) - .setAutoCancel(true) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setAutoCancel(false) .setOngoing(true) + .setOnlyAlertOnce(true) + .setSilent(true) .setContentIntent(openAppPendingIntent(context)) .build() } // Notifications content - private fun getNotificationBody(data: CallNotificationData) = + private fun getNotificationBody( + data: CallNotificationData, + @StringRes defaultCallerName: Int = R.string.notification_call_default_caller_name + ) = when (data.conversationType) { is Conversation.Type.Group -> { - val name = data.callerName ?: context.getString(R.string.notification_call_default_caller_name) - (data.callerTeamName?.let { "$name @$it" } ?: name) + val name = data.callerName.orIfNullOrBlank(context.getString(defaultCallerName)) + (data.callerTeamName.takeUnless { it.isNullOrBlank() }?.let { "$name @$it" } ?: name) .let { context.getString(R.string.notification_group_channel_call_content, it) } } else -> context.getString(R.string.notification_incoming_call_content) } - fun getNotificationTitle(data: CallNotificationData): String = + fun getNotificationTitle( + data: CallNotificationData, + @StringRes defaultGroupName: Int = R.string.notification_incoming_call_default_group_name, + @StringRes defaultCallerName: Int = R.string.notification_call_default_caller_name + ): String = when (data.conversationType) { - is Conversation.Type.Group -> data.conversationName ?: context.getString(R.string.notification_call_default_group_name) + is Conversation.Type.Group -> { + data.conversationName.orIfNullOrBlank(context.getString(defaultGroupName)) + } + else -> { - val name = data.callerName ?: context.getString(R.string.notification_call_default_caller_name) - data.callerTeamName?.let { "$name @$it" } ?: name + val name = data.callerName.orIfNullOrBlank(context.getString(defaultCallerName)) + data.callerTeamName.takeUnless { it.isNullOrBlank() }?.let { "$name @$it" } ?: name } } @@ -358,6 +377,8 @@ class CallNotificationBuilder @Inject constructor( } } +private fun String?.orIfNullOrBlank(defaultValue: String): String = takeUnless { it.isNullOrBlank() } ?: defaultValue + data class IncomingCallsForUser(val userId: UserId, val userName: String, val incomingCalls: List) data class CallNotificationData( diff --git a/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt b/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt index 386a4fea6ba..55651979b54 100644 --- a/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt +++ b/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt @@ -53,6 +53,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest @@ -117,6 +118,18 @@ class WireNotificationManager @Inject constructor( scope: CoroutineScope ) = observeNotificationsAndCalls(userIds, scope, observingPersistentlyJobs) + /** + * When there are no valid users with active sessions, we want to stop all the notifications and calls observing jobs, + * hide all the notifications and stop the call service, because they are not relevant anymore. + */ + fun clearWhenNoUsers() { + observingWhileRunningJobs.cancelAndClearAll() + observingPersistentlyJobs.cancelAndClearAll() + messagesNotificationManager.hideAllNotifications() + callNotificationManager.hideAllCallNotifications() + servicesManager.stopCallService() + } + /** * Become online, process all the Pending events, * and display notifications for new events. @@ -305,14 +318,6 @@ class WireNotificationManager @Inject constructor( .forEach { userId -> stopObservingForUser(userId, observingJobs) } if (userIds.isEmpty()) { - // userIds.isEmpty() means there is no current user (logged out e.g.) - // so we need to unsubscribe from the notification changes (done by canceling all the jobs above) - // and remove the notifications that were displayed previously - appLogger.i("$TAG no Users -> hide all the notifications") - messagesNotificationManager.hideAllNotifications() - callNotificationManager.hideAllIncomingCallNotifications() - servicesManager.stopCallService() - return } @@ -498,10 +503,17 @@ class WireNotificationManager @Inject constructor( coreLogic.getGlobalScope().session.currentSessionFlow() .flatMapLatest { if (it is CurrentSessionResult.Success && it.accountInfo.isValid()) { - combine( - coreLogic.getSessionScope(it.accountInfo.userId).calls.establishedCall(), - coreLogic.getSessionScope(it.accountInfo.userId).calls.observeOutgoingCall() - ) { establishedCalls, outgoingCalls -> (establishedCalls + outgoingCalls).isNotEmpty() } + val sessionScope = coreLogic.getSessionScope(it.accountInfo.userId) + // wait for the initial cleanup of stale open calls to be completed before starting to observe the calls, + // to avoid starting the service by mistake for the calls that are already stale + sessionScope.calls.observeStaleOpenCallsCleanup() + .dropWhile { completed -> !completed } + .flatMapLatest { + combine( + sessionScope.calls.establishedCall(), + sessionScope.calls.observeOutgoingCall() + ) { establishedCalls, outgoingCalls -> (establishedCalls + outgoingCalls).isNotEmpty() } + } } else { flowOf(null) } @@ -593,7 +605,14 @@ class WireNotificationManager @Inject constructor( private data class ObservingJobs( val outgoingOngoingCallJob: AtomicReference = AtomicReference(), val userJobs: ConcurrentHashMap = ConcurrentHashMap() - ) + ) { + fun cancelAndClearAll() { + outgoingOngoingCallJob.get()?.cancel() + outgoingOngoingCallJob.set(null) + userJobs.values.forEach { it.cancelAll() } + userJobs.clear() + } + } companion object { private const val TAG = "WireNotificationManager" diff --git a/app/src/main/kotlin/com/wire/android/services/CallService.kt b/app/src/main/kotlin/com/wire/android/services/CallService.kt index 4fe78858383..95742811731 100644 --- a/app/src/main/kotlin/com/wire/android/services/CallService.kt +++ b/app/src/main/kotlin/com/wire/android/services/CallService.kt @@ -18,18 +18,23 @@ package com.wire.android.services +import android.Manifest.permission.RECORD_AUDIO import android.app.Notification import android.app.Service import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.os.IBinder import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat import com.wire.android.appLogger import com.wire.android.notification.CallNotificationData import com.wire.android.notification.CallNotificationManager +import com.wire.android.notification.NotificationChannelsManager import com.wire.android.notification.NotificationIds import com.wire.android.services.CallService.Action +import com.wire.android.util.CurrentScreenManager import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.common.functional.fold import com.wire.kalium.logic.data.call.CallStatus @@ -59,6 +64,12 @@ class CallService : Service() { @Inject lateinit var callNotificationManager: CallNotificationManager + @Inject + lateinit var notificationChannelsManager: NotificationChannelsManager + + @Inject + lateinit var currentScreenManager: CurrentScreenManager + @Inject lateinit var dispatcherProvider: DispatcherProvider @@ -79,8 +90,8 @@ class CallService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { appLogger.i("$TAG: onStartCommand") + startForegroundWithPlaceholderNotification() val action = intent?.getActionTypeExtra(EXTRA_ACTION_TYPE) ?: Action.Start.Default - generatePlaceholderForegroundNotification() _serviceState.value = ServiceState.FOREGROUND scope.launch { lifecycleManager.handleAction(action) @@ -123,18 +134,40 @@ class CallService : Service() { } } - private fun generatePlaceholderForegroundNotification() { + private fun startForegroundWithPlaceholderNotification() { appLogger.i("$TAG: generating foregroundNotification placeholder...") - val notification: Notification = - callNotificationManager.builder.getCallServicePlaceholderNotification() - ServiceCompat.startForeground( - this, - NotificationIds.CALL_OUTGOING_ONGOING_NOTIFICATION_ID.ordinal, - notification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE - ) - - appLogger.i("$TAG: started foreground with placeholder notification") + notificationChannelsManager.createOngoingCallNotificationChannel() + val notification: Notification = callNotificationManager.builder.getCallServicePlaceholderNotification() + val hasRecordAudioPermission = ContextCompat.checkSelfPermission(this, RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + val isAppVisible = currentScreenManager.isAppVisibleFlow().value + try { + ServiceCompat.startForeground( + this, + NotificationIds.CALL_OUTGOING_ONGOING_NOTIFICATION_ID.ordinal, + notification, + when (hasRecordAudioPermission) { + true -> ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + + // Safety measure, we don't have the required permission, but startForeground still needs to be called to avoid crash. + // In that case, we start it with the type that doesn't require the runtime permission to avoid potential crash, + // and then the service is being stopped right after starting foreground. + false -> ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL + } + ) + appLogger.i("$TAG: started foreground with placeholder notification, isAppVisible:$isAppVisible") + if (!hasRecordAudioPermission) { + scope.launch { + appLogger.i("$TAG: Stopping service started without RECORD_AUDIO permission") + lifecycleManager.handleAction(Action.Stop) + } + } + } catch (e: SecurityException) { + throw StartForegroundException( + message = "$TAG: Unable to start foreground service, " + + "RECORD_AUDIO permission: $hasRecordAudioPermission, isAppVisible:$isAppVisible, original message: ${e.message}", + cause = e, + ) + } } companion object { @@ -174,3 +207,5 @@ private fun Intent.putExtra(name: String, actionType: Action): Intent = putExtra private fun Intent.getActionTypeExtra(name: String): Action? = getBundleExtra(name)?.let { Bundlizer.unbundle(Action.serializer(), it) } + +private class StartForegroundException(override val message: String, override val cause: Throwable?) : RuntimeException(message, cause) diff --git a/app/src/main/kotlin/com/wire/android/services/CallServiceManager.kt b/app/src/main/kotlin/com/wire/android/services/CallServiceManager.kt index 3df3cb219fc..2fb03813134 100644 --- a/app/src/main/kotlin/com/wire/android/services/CallServiceManager.kt +++ b/app/src/main/kotlin/com/wire/android/services/CallServiceManager.kt @@ -61,7 +61,11 @@ class CallServiceManager @Inject constructor(@KaliumCoreLogic val coreLogic: Cor internal fun handleActionsFlow(): Flow> = actions.receiveAsFlow() .flatMapLatest { action -> when (action) { - is Action.Stop -> flowOf(Either.Left(StopReason.ACTION_STOP_CALLED)) + is Action.Stop -> { + closeActiveCall() + flowOf(Either.Left(StopReason.ACTION_STOP_CALLED)) + } + is Action.Start -> { if (action is Action.Start.AnswerCall) answerCall(action) startedServiceLifecycleFlow(action) @@ -88,6 +92,28 @@ class CallServiceManager @Inject constructor(@KaliumCoreLogic val coreLogic: Cor } }) + /** Closes the active call for the current session if it exists, logging if it does not. */ + private suspend fun closeActiveCall() = + coreLogic.getGlobalScope().session.currentSessionFlow().firstOrNull().let { + if (it is CurrentSessionResult.Success && it.accountInfo.isValid()) { + val userId = it.accountInfo.userId + val userSessionScope = coreLogic.getSessionScope(userId) + combine( + userSessionScope.calls.observeOutgoingCall(), + userSessionScope.calls.establishedCall(), + { outgoingCalls, establishedCalls -> outgoingCalls + establishedCalls } + ).firstOrNull()?.forEach { + appLogger.i( + "$TAG: Ending call for user ${userId.toLogString()}" + + " and conversation ${it.conversationId.toLogString()}" + ) + userSessionScope.calls.endCall(it.conversationId) + } + } else { + appLogger.i("$TAG: Cannot end call, no valid current session") + } + } + /** * Returns a flow representing the lifecycle of the service for the given start action. * Collects the current session and its active calls and emits [CallNotificationData] to update the foreground notification. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d8e8577518c..d1d3cf702fb 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1025,7 +1025,7 @@ Aktiver Anruf… %s ruft an... Jemand - Eingehender Gruppenanruf + Eingehender Gruppenanruf Audionachricht Unterhaltung wird gelöscht… Dateien werden hochgeladen… diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 62e6394f624..fc9111e30bc 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -653,7 +653,7 @@ Un mensaje eliminado no puede ser restaurado. Llamando... Llamada en curso... Alguien - Llamada en grupo entrante + Llamada en grupo entrante Bit Rate constante Micrófono diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index abc6be135eb..627b4a230f9 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -285,7 +285,7 @@ Helistab… Käimasolev kõne… Keegi - Sissetulev grupikõne + Sissetulev grupikõne Ühtlane bitisagedus Mikrofon diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index b6c9c4d61d4..b7daf8cf469 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -917,7 +917,7 @@ Folyamatban lévő hívás… %s hívja... Valaki - Bejövő csoportos hívás + Bejövő csoportos hívás Koppintásra visszatér a híváshoz - Hívás... Hangüzenet Beszélgetés törlése… diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b2a5e1dc996..730da6a10d9 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -676,7 +676,7 @@ Un messaggio eliminato non può essere ripristinato. Chiamata in arrivo... Chiamata in corso... Qualcuno - Chiamata di gruppo in entrata + Chiamata di gruppo in entrata Bit Rate Costante Microfono diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 851c630a735..9ff8f6e44c8 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -577,7 +577,7 @@ Usunięta wiadomość nie może zostać przywrócona.Dzwoni... Trwa połączenie... Ktoś - Połączenie grupowe przychodzące + Połączenie grupowe przychodzące Mikrofon Kamera diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 3797c339955..04a9ece462b 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -870,7 +870,7 @@ Uma mensagem excluída não pode ser restaurada. Chamando… Chamada em andamento… Alguém - Chamada em Grupo Recebida + Chamada em Grupo Recebida Toque para retornar à chamada - Chamando... Mensagens de áudio Excluindo conversa… diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 264a779a350..c612efc69d8 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1112,7 +1112,7 @@ Исходящий вызов… %s вызывает... Кто-то - Входящий групповой вызов + Входящий групповой вызов Нажмите, чтобы вернуться к вызову - Вызов... Аудиосообщение Удаление беседы… diff --git a/app/src/main/res/values-si/strings.xml b/app/src/main/res/values-si/strings.xml index 9503f76c96f..eb4796d1de7 100644 --- a/app/src/main/res/values-si/strings.xml +++ b/app/src/main/res/values-si/strings.xml @@ -933,7 +933,7 @@ පවතින ඇමතුම… %s අමතමින්... යමෙක් - ලැබෙන සමූහ ඇමතුම + ලැබෙන සමූහ ඇමතුම ඇමතුමට යාමට ඔබන්න - අමතමින්... ශ්‍රව්‍ය පණිවිඩය …සංවාදය මකා දමමින් diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6acb07c85ab..42a37dde7b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1119,7 +1119,9 @@ Ongoing call… %s calling... Someone - Incoming group call + Incoming group call + Ongoing group call + Outgoing group call Tap to return to call - Calling... Audio message Deleting conversation… diff --git a/app/src/test/kotlin/com/wire/android/GlobalObserversManagerTest.kt b/app/src/test/kotlin/com/wire/android/GlobalObserversManagerTest.kt index f64215311af..daea238ada6 100644 --- a/app/src/test/kotlin/com/wire/android/GlobalObserversManagerTest.kt +++ b/app/src/test/kotlin/com/wire/android/GlobalObserversManagerTest.kt @@ -17,6 +17,7 @@ */ package com.wire.android +import com.wire.android.common.runTestWithCancellation import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri @@ -44,11 +45,16 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.verify +import junit.framework.TestCase.assertEquals import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runCurrent import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import java.util.concurrent.atomic.AtomicInteger @OptIn(ExperimentalCoroutinesApi::class) @ExtendWith(CoroutineTestExtension::class) @@ -98,6 +104,28 @@ class GlobalObserversManagerTest { } } + @Test + fun `given no valid accounts, when starting observing accounts, then clear notifications`() { + val statuses = listOf( + PersistentWebSocketStatus(TestUser.SELF_USER.id, false), + PersistentWebSocketStatus(TestUser.USER_ID.copy(value = "something else"), true) + ) + val (arrangement, manager) = Arrangement() + .withValidAccounts(emptyList()) + .withPersistentWebSocketConnectionStatuses(statuses) + .arrange() + + manager.observe() + + verify(exactly = 1) { + arrangement.notificationManager.clearWhenNoUsers() + } + coVerify(exactly = 0) { + arrangement.notificationManager.observeNotificationsAndCallsWhileRunning(any(), any()) + arrangement.notificationManager.observeNotificationsAndCallsPersistently(any(), any()) + } + } + @Test fun `given app visible and valid session, when handling ephemeral messages, then call deleteEphemeralMessageEndDate`() { val (arrangement, manager) = Arrangement() @@ -165,6 +193,34 @@ class GlobalObserversManagerTest { } } + @Test + fun `given valid account observer is running, when accounts become empty, then cancel conversation change observer`() = + runTestWithCancellation { + val accountsFlow = MutableStateFlow>>(listOf(TestUser.SELF_USER to null)) + val startedObservers = AtomicInteger(0) + val cancelledObservers = AtomicInteger(0) + val (arrangement, manager) = Arrangement() + .withValidAccountsFlow(accountsFlow) + .arrange() + coEvery { arrangement.endCallOnConversationChangeUseCase.invoke() } coAnswers { + startedObservers.incrementAndGet() + try { + awaitCancellation() + } finally { + cancelledObservers.incrementAndGet() + } + } + + manager.observe() + runCurrent() + assertEquals(1, startedObservers.get()) + + accountsFlow.value = emptyList() + runCurrent() + + assertEquals(1, cancelledObservers.get()) + } + private class Arrangement { @MockK @@ -232,6 +288,10 @@ class GlobalObserversManagerTest { coEvery { coreLogic.getGlobalScope().observeValidAccounts() } returns flowOf(list) } + fun withValidAccountsFlow(flow: MutableStateFlow>>): Arrangement = apply { + coEvery { coreLogic.getGlobalScope().observeValidAccounts() } returns flow + } + fun withPersistentWebSocketConnectionStatuses(list: List): Arrangement = apply { coEvery { coreLogic.getGlobalScope().observePersistentWebSocketConnectionStatus() } returns ObservePersistentWebSocketConnectionStatusUseCase.Result.Success(flowOf(list)) diff --git a/app/src/test/kotlin/com/wire/android/notification/CallNotificationBuilderTest.kt b/app/src/test/kotlin/com/wire/android/notification/CallNotificationBuilderTest.kt new file mode 100644 index 00000000000..a178051eeb6 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/notification/CallNotificationBuilderTest.kt @@ -0,0 +1,80 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +package com.wire.android.notification + +import android.app.Application +import android.app.Notification +import androidx.test.core.app.ApplicationProvider +import com.wire.android.R +import com.wire.kalium.logic.data.call.CallStatus +import com.wire.kalium.logic.data.conversation.Conversation +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.QualifiedID +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class CallNotificationBuilderTest { + + private val context = ApplicationProvider.getApplicationContext() + private val builder = CallNotificationBuilder(context) + + @Test + fun `given outgoing group call with blank conversation name when building notification then use default group title`() { + val notification = builder.getOutgoingCallNotification( + callNotificationData(conversationName = "", conversationType = Conversation.Type.Group.Regular) + ) + + assertEquals( + context.getString(R.string.notification_outgoing_call_default_group_name), + notification.extras.getCharSequence(Notification.EXTRA_TITLE) + ) + } + + @Test + fun `given ongoing one to one call with blank caller name when building notification then use default caller title`() { + val notification = builder.getOngoingCallNotification( + callNotificationData(conversationType = Conversation.Type.OneOnOne, callerName = "") + ) + + assertEquals( + context.getString(R.string.notification_call_default_caller_name), + notification.extras.getCharSequence(Notification.EXTRA_TITLE) + ) + } + + private fun callNotificationData( + conversationName: String? = "Conversation name", + conversationType: Conversation.Type = Conversation.Type.Group.Regular, + callerName: String? = "Caller name", + ) = CallNotificationData( + userId = QualifiedID("user", "example.com"), + userName = "User name", + conversationId = ConversationId("conversation", "example.com"), + conversationName = conversationName, + conversationType = conversationType, + callerName = callerName, + callerTeamName = null, + callStatus = CallStatus.STARTED + ) +} diff --git a/app/src/test/kotlin/com/wire/android/notification/WireNotificationManagerTest.kt b/app/src/test/kotlin/com/wire/android/notification/WireNotificationManagerTest.kt index f59110c50d7..09a2e252589 100644 --- a/app/src/test/kotlin/com/wire/android/notification/WireNotificationManagerTest.kt +++ b/app/src/test/kotlin/com/wire/android/notification/WireNotificationManagerTest.kt @@ -133,7 +133,7 @@ class WireNotificationManagerTest { } @Test - fun givenNotAuthenticatedUser_whenObserveCalled_thenNothingHappenAndCallNotificationHides() = + fun givenNotAuthenticatedUser_whenObserveCalled_thenNothingHappens() = runTestWithCancellation(dispatcherProvider.main()) { val (arrangement, manager) = Arrangement() .withCurrentScreen(CurrentScreen.SomeOther()) @@ -142,8 +142,14 @@ class WireNotificationManagerTest { manager.observeNotificationsAndCallsWhileRunning(listOf(), this) advanceUntilIdle() - verify(exactly = 0) { arrangement.coreLogic.getSessionScope(any()) } - verify(exactly = 1) { arrangement.callNotificationManager.hideAllIncomingCallNotifications() } + verify(exactly = 0) { + arrangement.coreLogic.getSessionScope(any()) + arrangement.callNotificationManager.handleIncomingCalls(any(), any(), any()) + arrangement.servicesManager.startCallService() + } + coVerify(exactly = 0) { + arrangement.messageNotificationManager.handleNotification(any(), any(), any()) + } } @Test @@ -212,7 +218,6 @@ class WireNotificationManagerTest { userName = TestUser.SELF_USER.handle!! ) } - verify(exactly = 1) { arrangement.callNotificationManager.hideAllIncomingCallNotifications() } } @Test @@ -241,7 +246,6 @@ class WireNotificationManagerTest { TestUser.SELF_USER.handle!! ) } - verify(exactly = 1) { arrangement.callNotificationManager.hideAllIncomingCallNotifications() } } @Test @@ -452,7 +456,8 @@ class WireNotificationManagerTest { } @Test - fun givenAppInBackground_withNoUsers_whenObserving_thenStopCallService() = runTestWithCancellation(dispatcherProvider.main()) { + fun givenSomeUsers_whenAllUsersBecomeInvalid_thenClearEverything() = runTestWithCancellation(dispatcherProvider.main()) { + val userId = provideUserId() val (arrangement, manager) = Arrangement() .withIncomingCalls(listOf()) .withOutgoingCalls(listOf()) @@ -462,30 +467,44 @@ class WireNotificationManagerTest { .withCurrentUserSession(CurrentSessionResult.Failure.SessionNotFound) .arrange() - manager.observeNotificationsAndCallsWhileRunning(listOf(), this) + // first there is a valid user so that the job is started for that user + manager.observeNotificationsAndCallsWhileRunning(listOf(userId), this) + advanceUntilIdle() + + // then it logs out, which should trigger stopping job for that user, clearing notifications and stopping call service + manager.clearWhenNoUsers() advanceUntilIdle() + verify(exactly = 1) { arrangement.callNotificationManager.hideAllCallNotifications() } + verify(exactly = 1) { arrangement.messageNotificationManager.hideAllNotifications() } verify(exactly = 0) { arrangement.servicesManager.startCallService() } verify(exactly = 1) { arrangement.servicesManager.stopCallService() } } @Test - fun givenAppInForeground_withNoUsers_whenObserving_thenStopCallService() = runTestWithCancellation(dispatcherProvider.main()) { - val (arrangement, manager) = Arrangement() - .withIncomingCalls(listOf()) - .withOutgoingCalls(listOf()) - .withMessageNotifications(listOf()) - .withCurrentScreen(CurrentScreen.Home) - .withEstablishedCall(listOf()) - .withCurrentUserSession(CurrentSessionResult.Failure.SessionNotFound) - .arrange() + fun givenSomeUsers_whenAllUsersBecomeInvalid_thenOutgoingOngoingCallObserverIsStopped() = + runTestWithCancellation(dispatcherProvider.main()) { + val userId = provideUserId() + val call = provideCall().copy(status = CallStatus.ESTABLISHED) + val (arrangement, manager) = Arrangement() + .withIncomingCalls(listOf()) + .withOutgoingCalls(listOf()) + .withMessageNotifications(listOf()) + .withCurrentScreen(CurrentScreen.InBackground) + .withEstablishedCall(listOf(call)) + .arrange() - manager.observeNotificationsAndCallsWhileRunning(listOf(), this) - advanceUntilIdle() + manager.observeNotificationsAndCallsWhileRunning(listOf(userId), this) + runCurrent() - verify(exactly = 0) { arrangement.servicesManager.startCallService() } - verify(exactly = 1) { arrangement.servicesManager.stopCallService() } - } + manager.clearWhenNoUsers() + arrangement.clearRecordedCallsForServicesManager() + + arrangement.withCurrentUserSession(CurrentSessionResult.Success(provideAccountInfo(userId.value))) + runCurrent() + + verify(exactly = 0) { arrangement.servicesManager.startCallService() } + } @Test fun givenAppInBackground_withValidCurrentAccountAndOngoingCall_whenObserving_thenStartCallService() = @@ -1001,6 +1020,35 @@ class WireNotificationManagerTest { verify(exactly = 0) { arrangement.servicesManager.stopCallService() } } + @Test + fun givenAppInForeground_withValidCurrentAccountAndOngoingCall_whenStaleOpenCallsCleanupRunning_thenOnlyStartCallServiceAfterwards() = + runTestWithCancellation(dispatcherProvider.main()) { + val staleOpenCallsCleanupFlow = MutableStateFlow(false) + val userId = provideUserId() + val call = provideCall().copy(status = CallStatus.ESTABLISHED) + val (arrangement, manager) = Arrangement() + .withIncomingCalls(listOf()) + .withOutgoingCalls(listOf(call)) + .withMessageNotifications(listOf()) + .withCurrentScreen(CurrentScreen.Home) + .withEstablishedCall(listOf()) + .withCurrentUserSession(CurrentSessionResult.Success(provideAccountInfo(userId.value))) + .withObserveStaleOpenCallsCleanup(staleOpenCallsCleanupFlow) + .arrange() + + manager.observeNotificationsAndCallsWhileRunning(listOf(userId), this) + runCurrent() + + // should not start call service until stale open calls cleanup finishes + verify(exactly = 0) { arrangement.servicesManager.startCallService() } + + staleOpenCallsCleanupFlow.value = true + runCurrent() + + // should start call service after cleanup finishes + verify(exactly = 1) { arrangement.servicesManager.startCallService() } + } + @Test fun givenSomeNotificationsAndUserBlockedByE2EIRequired_whenObserveCalled_thenNotificationIsNotShowed() = runTestWithCancellation(dispatcherProvider.main()) { @@ -1200,6 +1248,7 @@ class WireNotificationManagerTest { every { servicesManager.stopCallService() } returns Unit every { pingRinger.ping(any(), any()) } returns Unit coEvery { globalKaliumScope.doesValidSessionExist.invoke(any()) } returns DoesValidSessionExistResult.Success(true) + every { callsScope.observeStaleOpenCallsCleanup() } returns flowOf(true) // by default, assume that cleanup is finished } @Suppress("LongParameterList") @@ -1207,6 +1256,7 @@ class WireNotificationManagerTest { incomingCalls: List = emptyList(), establishedCalls: List = emptyList(), outgoingCalls: List = emptyList(), + staleOpenCallsCleanupFinished: Boolean = true, notifications: List = emptyList(), selfUser: SelfUser = TestUser.SELF_USER, userId: MockKMatcherScope.() -> UserId @@ -1220,6 +1270,7 @@ class WireNotificationManagerTest { coEvery { establishedCall() } returns flowOf(establishedCalls) coEvery { getIncomingCalls() } returns flowOf(incomingCalls) coEvery { observeOutgoingCall() } returns flowOf(outgoingCalls) + every { observeStaleOpenCallsCleanup() } returns flowOf(staleOpenCallsCleanupFinished) } coEvery { messages } returns mockk { coEvery { getNotifications() } returns flowOf(notifications) @@ -1272,10 +1323,19 @@ class WireNotificationManagerTest { incomingCalls: List = emptyList(), establishedCalls: List = emptyList(), outgoingCalls: List = emptyList(), + staleOpenCallsCleanupFinished: Boolean = true, notifications: List = emptyList(), selfUser: SelfUser = TestUser.SELF_USER, ): Arrangement = apply { - mockSpecificUserSession(incomingCalls, establishedCalls, outgoingCalls, notifications, selfUser) { eq(userId) } + mockSpecificUserSession( + incomingCalls = incomingCalls, + establishedCalls = establishedCalls, + outgoingCalls = outgoingCalls, + staleOpenCallsCleanupFinished = staleOpenCallsCleanupFinished, + notifications = notifications, + selfUser = selfUser, + userId = { eq(userId) } + ) } fun withCurrentScreen(screen: CurrentScreen): Arrangement { @@ -1299,6 +1359,10 @@ class WireNotificationManagerTest { coEvery { globalKaliumScope.doesValidSessionExist.invoke(userId) } returns result } + fun withObserveStaleOpenCallsCleanup(flow: Flow) = apply { + every { callsScope.observeStaleOpenCallsCleanup() } returns flow + } + fun clearRecordedCallsForServicesManager() = clearMocks( servicesManager, answers = false, diff --git a/app/src/test/kotlin/com/wire/android/services/CallServiceManagerTest.kt b/app/src/test/kotlin/com/wire/android/services/CallServiceManagerTest.kt index ea19244a43a..445ee04eca2 100644 --- a/app/src/test/kotlin/com/wire/android/services/CallServiceManagerTest.kt +++ b/app/src/test/kotlin/com/wire/android/services/CallServiceManagerTest.kt @@ -32,6 +32,7 @@ import com.wire.kalium.logic.data.logout.LogoutReason import com.wire.kalium.logic.data.user.SelfUser import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.call.usecase.AnswerCallUseCase +import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.session.DoesValidSessionExistResult import io.mockk.MockKAnnotations @@ -50,13 +51,31 @@ class CallServiceManagerTest { @Test fun `given action stop, when handling, then emit proper stop`() = runTest { - val (_, callServiceManager) = Arrangement().arrange() + val (_, callServiceManager) = Arrangement() + .withCurrentSession(flowOf(CurrentSessionResult.Success(AccountInfo.Valid(selfUser.id)))) + .arrange() + callServiceManager.handleAction(CallService.Action.Stop) + callServiceManager.handleActionsFlow().test { + assertIsStop(awaitItem()).also { + assertEquals(CallServiceManager.StopReason.ACTION_STOP_CALLED, it) + } + } + } + + @Test + fun `given action stop & call still ongoing, when handling, then end ongoing call`() = runTest { + val establishedCall = call.copy(status = CallStatus.ESTABLISHED) + val (arrangement, callServiceManager) = Arrangement() + .withCurrentSession(flowOf(CurrentSessionResult.Success(AccountInfo.Valid(selfUser.id)))) + .withSpecificUserSession(selfUser = selfUser, established = flowOf(listOf(establishedCall))) + .arrange() callServiceManager.handleAction(CallService.Action.Stop) callServiceManager.handleActionsFlow().test { assertIsStop(awaitItem()).also { assertEquals(CallServiceManager.StopReason.ACTION_STOP_CALLED, it) } } + coVerify(exactly = 1) { arrangement.endCallForUser(selfUser.id)(conversationId) } } @Test @@ -279,6 +298,7 @@ class CallServiceManagerTest { @MockK lateinit var coreLogic: CoreLogic private val answerCallForUser: MutableMap = mutableMapOf() + private val endCallForUser: MutableMap = mutableMapOf() init { MockKAnnotations.init(this) @@ -287,6 +307,7 @@ class CallServiceManagerTest { fun arrange() = this to CallServiceManager(coreLogic) fun answerCallForUser(userId: UserId): AnswerCallUseCase = answerCallForUser.getOrPut(userId) { mockk(relaxed = true) } + fun endCallForUser(userId: UserId): EndCallUseCase = endCallForUser.getOrPut(userId) { mockk(relaxed = true) } fun withCurrentSession(result: Flow) = apply { coEvery { coreLogic.getGlobalScope().session.currentSessionFlow() } returns (result) } @@ -305,6 +326,7 @@ class CallServiceManagerTest { coEvery { coreLogic.getSessionScope(selfUser.id).calls.observeOutgoingCall() } returns outgoing coEvery { coreLogic.getSessionScope(selfUser.id).calls.establishedCall() } returns established coEvery { coreLogic.getSessionScope(selfUser.id).calls.answerCall } returns answerCallForUser(selfUser.id) + coEvery { coreLogic.getSessionScope(selfUser.id).calls.endCall } returns endCallForUser(selfUser.id) } } diff --git a/core/notification/src/main/kotlin/com/wire/android/notification/NotificationChannelsManager.kt b/core/notification/src/main/kotlin/com/wire/android/notification/NotificationChannelsManager.kt index 6aa0d551b7c..f2c40eac608 100644 --- a/core/notification/src/main/kotlin/com/wire/android/notification/NotificationChannelsManager.kt +++ b/core/notification/src/main/kotlin/com/wire/android/notification/NotificationChannelsManager.kt @@ -69,7 +69,7 @@ class NotificationChannelsManager @Inject constructor( } // OngoingCall is not user specific channel, but common for all users. - createOngoingNotificationChannel() + createOngoingCallNotificationChannel() createPlayingAudioMessageNotificationChannel() @@ -136,7 +136,7 @@ class NotificationChannelsManager @Inject constructor( notificationManagerCompat.createNotificationChannel(notificationChannel) } - private fun createOngoingNotificationChannel() { + fun createOngoingCallNotificationChannel() { val channelId = NotificationConstants.ONGOING_CALL_CHANNEL_ID val notificationChannel = NotificationChannelCompat .Builder(channelId, NotificationManagerCompat.IMPORTANCE_DEFAULT) diff --git a/core/notification/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt b/core/notification/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt index ce8f402ee72..d6db391acfa 100644 --- a/core/notification/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt +++ b/core/notification/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt @@ -24,7 +24,7 @@ import com.wire.kalium.logic.data.user.UserId object NotificationConstants { const val INCOMING_CALL_CHANNEL_ID = "com.wire.android.notification_incoming_call_channel" - private const val OUTGOING_CALL_CHANNEL_ID = "com.wire.android.notification_outgoing_call_channel" + const val OUTGOING_CALL_CHANNEL_ID = "com.wire.android.notification_outgoing_call_channel" const val INCOMING_CALL_CHANNEL_NAME = "Incoming calls" const val OUTGOING_CALL_CHANNEL_NAME = "Outgoing call" const val ONGOING_CALL_CHANNEL_ID = "com.wire.android.notification_ongoing_call_channel"