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 e0d9e4f2c52..57f57a3dd0d 100644 --- a/app/src/main/kotlin/com/wire/android/notification/CallNotificationManager.kt +++ b/app/src/main/kotlin/com/wire/android/notification/CallNotificationManager.kt @@ -26,26 +26,59 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.wire.android.R import com.wire.android.appLogger +import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.call.Call +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jetbrains.annotations.VisibleForTesting import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @Singleton @Suppress("TooManyFunctions") -class CallNotificationManager @Inject constructor(private val context: Context) { +class CallNotificationManager @Inject constructor( + context: Context, + dispatcherProvider: DispatcherProvider, + val builder: CallNotificationBuilder, +) { private val notificationManager = NotificationManagerCompat.from(context) + private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.default()) + private val incomingCallsForUsers = MutableStateFlow>>(emptyList()) + + init { + scope.launch { + incomingCallsForUsers + .debounce { if (it.isEmpty()) 0L else DEBOUNCE_TIME } // debounce to avoid showing and hiding notification too fast + .distinctUntilChanged() + .collectLatest { + if (it.isEmpty()) { + hideIncomingCallNotification() + } else { + it.first().let { (userId, call) -> + appLogger.i("$TAG: showing incoming call") + showIncomingCallNotification(call, userId) + } + } + } + } + } - fun handleIncomingCallNotifications(calls: List, userId: QualifiedID?) { - if (calls.isEmpty() || userId == null) { - hideIncomingCallNotification() + fun handleIncomingCallNotifications(calls: List, userId: UserId) { + if (calls.isEmpty()) { + incomingCallsForUsers.update { it.filter { it.first != userId } } } else { - appLogger.i("$TAG: showing incoming call") - showIncomingCallNotification(calls.first(), userId) + incomingCallsForUsers.update { it.filter { it.first != userId } + (userId to calls.first()) } } } @@ -64,13 +97,32 @@ class CallNotificationManager @Inject constructor(private val context: Context) notificationManager.cancel(NotificationConstants.CALL_INCOMING_NOTIFICATION_ID) } - private fun showIncomingCallNotification(call: Call, userId: QualifiedID) { - val notification = getIncomingCallNotification(call, userId) + @VisibleForTesting + internal fun showIncomingCallNotification(call: Call, userId: QualifiedID) { + appLogger.i("$TAG: showing incoming call for user ${userId.toLogString()}") + val notification = builder.getIncomingCallNotification(call, userId) notificationManager.notify(NotificationConstants.CALL_INCOMING_NOTIFICATION_ID, notification) } // Notifications - private fun getIncomingCallNotification(call: Call, userId: QualifiedID): Notification { + + companion object { + private const val TAG = "CallNotificationManager" + private const val CANCEL_CALL_NOTIFICATION_DELAY = 300L + @VisibleForTesting + internal const val DEBOUNCE_TIME = 200L + + fun hideIncomingCallNotification(context: Context) { + NotificationManagerCompat.from(context).cancel(NotificationConstants.CALL_INCOMING_NOTIFICATION_ID) + } + } +} + +@Singleton +class CallNotificationBuilder @Inject constructor( + private val context: Context, +) { + fun getIncomingCallNotification(call: Call, userId: QualifiedID): Notification { val conversationIdString = call.conversationId.toString() val userIdString = userId.toString() val title = getNotificationTitle(call) @@ -106,7 +158,7 @@ class CallNotificationManager @Inject constructor(private val context: Context) return NotificationCompat.Builder(context, channelId) .setContentTitle(callName) .setContentText(context.getString(R.string.notification_ongoing_call_content)) - .setPriority(NotificationCompat.PRIORITY_HIGH) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setCategory(NotificationCompat.CATEGORY_CALL) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setSmallIcon(R.drawable.notification_icon_small) @@ -128,7 +180,7 @@ class CallNotificationManager @Inject constructor(private val context: Context) val channelId = NotificationConstants.ONGOING_CALL_CHANNEL_ID return NotificationCompat.Builder(context, channelId) .setContentText(context.getString(R.string.notification_ongoing_call_content)) - .setPriority(NotificationCompat.PRIORITY_HIGH) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setCategory(NotificationCompat.CATEGORY_CALL) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setSmallIcon(R.drawable.notification_icon_small) @@ -160,13 +212,7 @@ class CallNotificationManager @Inject constructor(private val context: Context) } companion object { - private const val TAG = "CallNotificationManager" private const val INCOMING_CALL_TIMEOUT: Long = 30 * 1000 private val VIBRATE_PATTERN = longArrayOf(0, 1000, 1000) - private const val CANCEL_CALL_NOTIFICATION_DELAY = 300L - - fun hideIncomingCallNotification(context: Context) { - NotificationManagerCompat.from(context).cancel(NotificationConstants.CALL_INCOMING_NOTIFICATION_ID) - } } } diff --git a/app/src/main/kotlin/com/wire/android/notification/NotificationChannelsManager.kt b/app/src/main/kotlin/com/wire/android/notification/NotificationChannelsManager.kt index 74e7326f67a..b275d13d0bb 100644 --- a/app/src/main/kotlin/com/wire/android/notification/NotificationChannelsManager.kt +++ b/app/src/main/kotlin/com/wire/android/notification/NotificationChannelsManager.kt @@ -118,10 +118,10 @@ class NotificationChannelsManager @Inject constructor( private fun createOngoingNotificationChannel() { val channelId = NotificationConstants.ONGOING_CALL_CHANNEL_ID val notificationChannel = NotificationChannelCompat - .Builder(channelId, NotificationManagerCompat.IMPORTANCE_MAX) + .Builder(channelId, NotificationManagerCompat.IMPORTANCE_DEFAULT) .setName(NotificationConstants.ONGOING_CALL_CHANNEL_NAME) .setVibrationEnabled(false) - .setImportance(NotificationManagerCompat.IMPORTANCE_MAX) + .setImportance(NotificationManagerCompat.IMPORTANCE_DEFAULT) .setSound(null, null) .build() 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 c72e8024604..113542f5721 100644 --- a/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt +++ b/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt @@ -37,6 +37,7 @@ import com.wire.kalium.logic.data.notification.LocalNotificationConversation import com.wire.kalium.logic.data.notification.LocalNotificationMessage import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.message.MarkMessagesAsNotifiedUseCase +import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.session.GetAllSessionsResult import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart @@ -51,10 +52,13 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import javax.inject.Singleton @@ -75,8 +79,8 @@ class WireNotificationManager @Inject constructor( private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.default()) private val fetchOnceMutex = Mutex() private val fetchOnceJobs = hashMapOf() - private val observingWhileRunningJobs = hashMapOf() - private val observingPersistentlyJobs = hashMapOf() + private var observingWhileRunningJobs = ObservingJobs() + private var observingPersistentlyJobs = ObservingJobs() /** * Stops all the ObservingNotifications jobs that are currently running, for a specific User. @@ -164,8 +168,8 @@ class WireNotificationManager @Inject constructor( private suspend fun observeMessageNotificationsOnceJob(userId: UserId): Job? { val isMessagesAlreadyObserving = - observingWhileRunningJobs[userId]?.run { messagesJob.isActive } - ?: observingPersistentlyJobs[userId]?.run { messagesJob.isActive } + observingWhileRunningJobs.userJobs[userId]?.run { messagesJob.isActive } + ?: observingPersistentlyJobs.userJobs[userId]?.run { messagesJob.isActive } ?: false return if (isMessagesAlreadyObserving) { @@ -179,8 +183,8 @@ class WireNotificationManager @Inject constructor( private suspend fun observeCallNotificationsOnceJob(userId: UserId): Job? { val isCallsAlreadyObserving = - observingWhileRunningJobs[userId]?.run { incomingCallsJob.isActive } - ?: observingPersistentlyJobs[userId]?.run { incomingCallsJob.isActive } + observingWhileRunningJobs.userJobs[userId]?.run { incomingCallsJob.isActive } + ?: observingPersistentlyJobs.userJobs[userId]?.run { incomingCallsJob.isActive } ?: false return if (isCallsAlreadyObserving) { @@ -220,12 +224,12 @@ class WireNotificationManager @Inject constructor( private suspend fun observeNotificationsAndCalls( userIds: List, scope: CoroutineScope, - observingJobs: HashMap + observingJobs: ObservingJobs ) { val currentScreenState = currentScreenManager.observeCurrentScreen(scope) // removing notifications and stop observing it for the users that are not logged in anymore - observingJobs.keys.filter { !userIds.contains(it) } + observingJobs.userJobs.keys.filter { !userIds.contains(it) } .forEach { userId -> stopObservingForUser(userId, observingJobs) } if (userIds.isEmpty()) { @@ -242,9 +246,9 @@ class WireNotificationManager @Inject constructor( // start observing notifications only for new users userIds - .filter { observingJobs[it]?.isAllActive() != true } + .filter { observingJobs.userJobs[it]?.isAllActive() != true } .forEach { userId -> - val jobs = ObservingJobs( + val jobs = UserObservingJobs( currentScreenJob = scope.launch(dispatcherProvider.default()) { observeCurrentScreenAndHideNotifications(currentScreenState, userId) }, @@ -254,18 +258,23 @@ class WireNotificationManager @Inject constructor( messagesJob = scope.launch(dispatcherProvider.default()) { observeMessageNotifications(userId, currentScreenState) }, - ongoingCallJob = scope.launch(dispatcherProvider.default()) { - observeOngoingCalls(currentScreenState, userId) - } ) - observingJobs[userId] = jobs + observingJobs.userJobs[userId] = jobs } + + // start observing ongoing calls for all users, but only if not yet started + if (observingJobs.ongoingCallJob.get().let { it == null || !it.isActive }) { + val job = scope.launch(dispatcherProvider.default()) { + observeOngoingCalls(currentScreenState) + } + observingJobs.ongoingCallJob.set(job) + } } - private fun stopObservingForUser(userId: UserId, observingJobs: HashMap) { + private fun stopObservingForUser(userId: UserId, observingJobs: ObservingJobs) { messagesNotificationManager.hideAllNotificationsForUser(userId) - observingJobs[userId]?.cancelAll() - observingJobs.remove(userId) + observingJobs.userJobs[userId]?.cancelAll() + observingJobs.userJobs.remove(userId) } /** @@ -355,45 +364,37 @@ class WireNotificationManager @Inject constructor( } /** - * Infinitely listen for the established calls and run OngoingCall foreground Service + * Infinitely listen for the established calls of a current user and run OngoingCall foreground Service * to show corresponding notification and do not lose a call. - * @param userId QualifiedID of User that we want to observe for * @param currentScreenState StateFlow that informs which screen is currently visible, * so we can listen established calls only when the app is in background. */ - private suspend fun observeOngoingCalls( - currentScreenState: StateFlow, - userId: UserId - ) { + private suspend fun observeOngoingCalls(currentScreenState: StateFlow) { currentScreenState .flatMapLatest { currentScreen -> if (currentScreen !is CurrentScreen.InBackground) { flowOf(null) } else { - try { - coreLogic.getSessionScope(userId).calls - .establishedCall() - .map { - it.firstOrNull()?.let { call -> - OngoingCallData(callNotificationManager.getNotificationTitle(call), call.conversationId, userId) - } + coreLogic.getGlobalScope().session.currentSessionFlow() + .flatMapLatest { + if (it is CurrentSessionResult.Success && it.accountInfo.isValid()) { + coreLogic.getSessionScope(it.accountInfo.userId).calls.establishedCall() + .map { + it.firstOrNull() + } + } else { + flowOf(null) } - } catch (e: IllegalStateException) { - flowOf(null) - } + } } } .distinctUntilChanged() - .collect { ongoingCallData -> - if (ongoingCallData == null) { - servicesManager.stopOngoingCallService() - } else { - servicesManager.startOngoingCallService( - ongoingCallData.notificationTitle, - ongoingCallData.conversationId, - ongoingCallData.userId - ) - } + .onCompletion { + servicesManager.stopOngoingCallService() + } + .collect { call -> + if (call != null) servicesManager.startOngoingCallService() + else servicesManager.stopOngoingCallService() } } @@ -453,25 +454,26 @@ class WireNotificationManager @Inject constructor( val userName: String ) - private data class OngoingCallData(val notificationTitle: String, val conversationId: ConversationId, val userId: UserId) - - private data class ObservingJobs( + private data class UserObservingJobs( val currentScreenJob: Job, val incomingCallsJob: Job, val messagesJob: Job, - val ongoingCallJob: Job, ) { fun cancelAll() { currentScreenJob.cancel() incomingCallsJob.cancel() messagesJob.cancel() - ongoingCallJob.cancel() } fun isAllActive(): Boolean = - currentScreenJob.isActive && incomingCallsJob.isActive && messagesJob.isActive && ongoingCallJob.isActive + currentScreenJob.isActive && incomingCallsJob.isActive && messagesJob.isActive } + private data class ObservingJobs( + val ongoingCallJob: AtomicReference = AtomicReference(), + val userJobs: ConcurrentHashMap = ConcurrentHashMap() + ) + companion object { private const val TAG = "WireNotificationManager" private const val STAY_ALIVE_TIME_ON_PUSH_MS = 1000L diff --git a/app/src/main/kotlin/com/wire/android/services/OngoingCallService.kt b/app/src/main/kotlin/com/wire/android/services/OngoingCallService.kt index 6b49308f6a3..ced18cc0882 100644 --- a/app/src/main/kotlin/com/wire/android/services/OngoingCallService.kt +++ b/app/src/main/kotlin/com/wire/android/services/OngoingCallService.kt @@ -31,15 +31,23 @@ import com.wire.android.di.NoSession import com.wire.android.notification.CallNotificationManager import com.wire.android.notification.NotificationConstants.CALL_ONGOING_NOTIFICATION_ID import com.wire.android.util.dispatchers.DispatcherProvider -import com.wire.kalium.logger.obfuscateId import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.id.QualifiedIdMapper import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.session.CurrentSessionResult +import com.wire.kalium.logic.functional.Either +import com.wire.kalium.logic.functional.fold import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject @AndroidEntryPoint @@ -65,6 +73,7 @@ class OngoingCallService : Service() { } override fun onCreate() { + serviceState.set(ServiceState.STARTED) super.onCreate() generatePlaceholderForegroundNotification() } @@ -74,61 +83,92 @@ class OngoingCallService : Service() { } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val userIdString = intent?.getStringExtra(EXTRA_USER_ID) - val conversationIdString = intent?.getStringExtra(EXTRA_CONVERSATION_ID) - val callName = intent?.getStringExtra(EXTRA_CALL_NAME) - if (userIdString != null && conversationIdString != null && callName != null) { - val userId = qualifiedIdMapper.fromStringToQualifiedID(userIdString) - generateForegroundNotification(callName, conversationIdString, userId) + appLogger.i("$TAG: onStartCommand") + val stopService = intent?.getBooleanExtra(EXTRA_STOP_SERVICE, false) + generatePlaceholderForegroundNotification() + serviceState.set(ServiceState.FOREGROUND) + if (stopService == true) { + appLogger.i("$TAG: stopSelf. Reason: stopService was called") + stopSelf() + } else { scope.launch { - coreLogic.getSessionScope(userId).calls.establishedCall().collect { establishedCall -> - if (establishedCall.isEmpty()) { - appLogger.i("$TAG: stopSelf. Reason: call was ended") - stopSelf() + coreLogic.getGlobalScope().session.currentSessionFlow() + .flatMapLatest { + if (it is CurrentSessionResult.Success && it.accountInfo.isValid()) { + val userId = it.accountInfo.userId + coreLogic.getSessionScope(userId).calls.establishedCall().map { + it.firstOrNull()?.let { call -> + Either.Right( + OngoingCallData( + userId = userId, + conversationId = call.conversationId, + notificationTitle = callNotificationManager.builder.getNotificationTitle(call) + ) + ) + } ?: Either.Left("no ongoing calls") + } + } else { + flowOf(Either.Left("no valid current session")) + } + } + .collectLatest { + it.fold( + { reason -> + appLogger.i("$TAG: stopSelf. Reason: $reason") + stopSelf() + }, { + generateForegroundNotification(it.notificationTitle, it.conversationId.toString(), it.userId) + } + ) } - } } - } else { - appLogger.w( - "$TAG: stopSelf. Reason: some of the parameter is absent. " + - "userIdString: ${userIdString?.obfuscateId()}, " + - "conversationIdString: ${conversationIdString?.obfuscateId()}, " + - "callName: $callName" - ) - stopSelf() } return START_STICKY } override fun onDestroy() { + appLogger.i("$TAG: onDestroy") + serviceState.set(ServiceState.NOT_STARTED) super.onDestroy() scope.cancel() - appLogger.i("$TAG: onDestroy") } private fun generateForegroundNotification(callName: String, conversationId: String, userId: UserId) { - appLogger.i("generating foregroundNotification for OngoingCallService..") - val notification: Notification = callNotificationManager.getOngoingCallNotification(callName, conversationId, userId) + appLogger.i("$TAG: generating foregroundNotification...") + val notification: Notification = callNotificationManager.builder.getOngoingCallNotification(callName, conversationId, userId) startForeground(CALL_ONGOING_NOTIFICATION_ID, notification) + appLogger.i("$TAG: started foreground with proper notification") } private fun generatePlaceholderForegroundNotification() { - appLogger.i("generating foregroundNotification placeholder for OngoingCallService..") - val notification: Notification = callNotificationManager.getOngoingCallPlaceholderNotification() + appLogger.i("$TAG: generating foregroundNotification placeholder...") + val notification: Notification = callNotificationManager.builder.getOngoingCallPlaceholderNotification() startForeground(CALL_ONGOING_NOTIFICATION_ID, notification) + appLogger.i("$TAG: started foreground with placeholder notification") } companion object { private const val TAG = "OngoingCallService" - private const val EXTRA_USER_ID = "user_id_extra" - private const val EXTRA_CONVERSATION_ID = "conversation_id_extra" - private const val EXTRA_CALL_NAME = "call_name_extra" + private const val EXTRA_STOP_SERVICE = "stop_service" + + fun newIntent(context: Context): Intent = Intent(context, OngoingCallService::class.java) - fun newIntent(context: Context, userId: String, conversationId: String, callName: String): Intent = + fun newIntentToStop(context: Context): Intent = Intent(context, OngoingCallService::class.java).apply { - putExtra(EXTRA_USER_ID, userId) - putExtra(EXTRA_CONVERSATION_ID, conversationId) - putExtra(EXTRA_CALL_NAME, callName) + putExtra(EXTRA_STOP_SERVICE, true) } + + var serviceState: AtomicReference = AtomicReference(ServiceState.NOT_STARTED) + private set + } + + enum class ServiceState { + NOT_STARTED, STARTED, FOREGROUND } } + +data class OngoingCallData( + val userId: UserId, + val conversationId: ConversationId, + val notificationTitle: String +) diff --git a/app/src/main/kotlin/com/wire/android/services/ServicesManager.kt b/app/src/main/kotlin/com/wire/android/services/ServicesManager.kt index d1dbc904b8c..9efec505463 100644 --- a/app/src/main/kotlin/com/wire/android/services/ServicesManager.kt +++ b/app/src/main/kotlin/com/wire/android/services/ServicesManager.kt @@ -25,9 +25,17 @@ import android.content.Context import android.content.Intent import android.os.Build import com.wire.android.appLogger -import com.wire.kalium.logic.data.id.ConversationId -import com.wire.kalium.logic.data.user.UserId +import com.wire.android.util.dispatchers.DispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import org.jetbrains.annotations.VisibleForTesting import javax.inject.Inject +import javax.inject.Singleton import kotlin.reflect.KClass /** @@ -35,16 +43,64 @@ import kotlin.reflect.KClass * The idea is that we don't want to inject, or provide any context into ViewModel, * but to have an ability start Service from it. */ -class ServicesManager @Inject constructor(private val context: Context) { +@Singleton +class ServicesManager @Inject constructor( + private val context: Context, + dispatcherProvider: DispatcherProvider, +) { + private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.default()) + private val ongoingCallServiceEvents = MutableStateFlow(false) + + init { + scope.launch { + ongoingCallServiceEvents + .debounce { if (!it) 0L else DEBOUNCE_TIME } // debounce to avoid starting and stopping service too fast + .distinctUntilChanged() + .collectLatest { shouldBeStarted -> + if (!shouldBeStarted) { + appLogger.i("ServicesManager: stopping OngoingCallService because there are no ongoing calls") + when (OngoingCallService.serviceState.get()) { + OngoingCallService.ServiceState.STARTED -> { + // Instead of simply calling stopService(OngoingCallService::class), which can end up with a crash if it + // happens before the service calls startForeground, we call the startService command with an empty data + // or some specific argument that tells the service that it should stop itself right after startForeground. + // This way, when this service is killed and recreated by the system, it will stop itself right after + // recreating so it won't cause any problems. + startService(OngoingCallService.newIntentToStop(context)) + appLogger.i("ServicesManager: OngoingCallService stopped by passing stop argument") + } + + OngoingCallService.ServiceState.FOREGROUND -> { + // we can just stop the service, because it's already in foreground + context.stopService(OngoingCallService.newIntent(context)) + appLogger.i("ServicesManager: OngoingCallService stopped by calling stopService") + } + + else -> { + appLogger.i("ServicesManager: OngoingCallService not running, nothing to stop") + } + } + } else { + appLogger.i("ServicesManager: starting OngoingCallService") + startService(OngoingCallService.newIntent(context)) + } + } + } + } // Ongoing call - fun startOngoingCallService(notificationTitle: String, conversationId: ConversationId, userId: UserId) { - val onGoingCallService = OngoingCallService.newIntent(context, userId.toString(), conversationId.toString(), notificationTitle) - startService(onGoingCallService) + fun startOngoingCallService() { + appLogger.i("ServicesManager: start OngoingCallService event") + scope.launch { + ongoingCallServiceEvents.emit(true) + } } fun stopOngoingCallService() { - stopService(OngoingCallService::class) + appLogger.i("ServicesManager: stop OngoingCallService event") + scope.launch { + ongoingCallServiceEvents.emit(false) + } } // Persistent WebSocket @@ -71,4 +127,9 @@ class ServicesManager @Inject constructor(private val context: Context) { private fun stopService(serviceClass: KClass) { context.stopService(Intent(context, serviceClass.java)) } + + companion object { + @VisibleForTesting + internal const val DEBOUNCE_TIME = 200L + } } diff --git a/app/src/test/kotlin/com/wire/android/notification/CallNotificationManagerTest.kt b/app/src/test/kotlin/com/wire/android/notification/CallNotificationManagerTest.kt new file mode 100644 index 00000000000..dd118b5f5bc --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/notification/CallNotificationManagerTest.kt @@ -0,0 +1,242 @@ +/* + * Wire + * Copyright (C) 2023 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.Notification +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import com.wire.android.config.TestDispatcherProvider +import com.wire.android.notification.CallNotificationManager.Companion.DEBOUNCE_TIME +import com.wire.kalium.logic.data.conversation.Conversation +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.call.Call +import com.wire.kalium.logic.feature.call.CallStatus +import io.mockk.MockKAnnotations +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalCoroutinesApi::class) +class CallNotificationManagerTest { + + val dispatcherProvider = TestDispatcherProvider() + + @Test + fun `given no incoming calls, then hide notification`() = + runTest(dispatcherProvider.main()) { + // given + val (arrangement, callNotificationManager) = Arrangement() + .arrange() + callNotificationManager.handleIncomingCallNotifications(listOf(), TEST_USER_ID1) + advanceUntilIdle() + // then + verify(exactly = 0) { arrangement.notificationManager.notify(any(), any()) } + verify(exactly = 1) { arrangement.notificationManager.cancel(any()) } + } + + @Test + fun `given an incoming call for one user, then show notification for that call`() = + runTest(dispatcherProvider.main()) { + // given + val notification = mockk() + val (arrangement, callNotificationManager) = Arrangement() + .withIncomingNotificationForUserAndCall(notification, TEST_USER_ID1, TEST_CALL1) + .arrange() + arrangement.clearRecordedCallsForNotificationManager() // clear first empty list recorded call + callNotificationManager.handleIncomingCallNotifications(listOf(TEST_CALL1), TEST_USER_ID1) + advanceUntilIdle() + // then + verify(exactly = 1) { arrangement.notificationManager.notify(any(), notification) } + verify(exactly = 0) { arrangement.notificationManager.cancel(any()) } + } + + @Test + fun `given incoming calls for two users, then show notification for the first call`() = + runTest(dispatcherProvider.main()) { + // given + val notification1 = mockk() + val notification2 = mockk() + val (arrangement, callNotificationManager) = Arrangement() + .withIncomingNotificationForUserAndCall(notification1, TEST_USER_ID1, TEST_CALL1) + .withIncomingNotificationForUserAndCall(notification2, TEST_USER_ID2, TEST_CALL2) + .arrange() + arrangement.clearRecordedCallsForNotificationManager() // clear first empty list recorded call + callNotificationManager.handleIncomingCallNotifications(listOf(TEST_CALL1), TEST_USER_ID1) + callNotificationManager.handleIncomingCallNotifications(listOf(TEST_CALL2), TEST_USER_ID2) + advanceUntilIdle() + // then + verify(exactly = 1) { arrangement.notificationManager.notify(any(), notification1) } + verify(exactly = 0) { arrangement.notificationManager.notify(any(), notification2) } + verify(exactly = 0) { arrangement.notificationManager.cancel(any()) } + } + + @Test + fun `given incoming calls for two users, when one call ends, then show notification for another call`() = + runTest(dispatcherProvider.main()) { + // given + val notification1 = mockk() + val notification2 = mockk() + val (arrangement, callNotificationManager) = Arrangement() + .withIncomingNotificationForUserAndCall(notification1, TEST_USER_ID1, TEST_CALL1) + .withIncomingNotificationForUserAndCall(notification2, TEST_USER_ID2, TEST_CALL2) + .arrange() + callNotificationManager.handleIncomingCallNotifications(listOf(TEST_CALL1), TEST_USER_ID1) + callNotificationManager.handleIncomingCallNotifications(listOf(TEST_CALL2), TEST_USER_ID2) + advanceUntilIdle() + arrangement.clearRecordedCallsForNotificationManager() // clear calls recorded when initializing the state + // when + callNotificationManager.handleIncomingCallNotifications(listOf(), TEST_USER_ID1) + advanceUntilIdle() + // then + verify(exactly = 0) { arrangement.notificationManager.notify(any(), notification1) } + verify(exactly = 1) { arrangement.notificationManager.notify(any(), notification2) } + verify(exactly = 0) { arrangement.notificationManager.cancel(any()) } + } + + @Test + fun `given incoming calls for two users, when both call ends, then hide notification`() = + runTest(dispatcherProvider.main()) { + // given + val notification1 = mockk() + val notification2 = mockk() + val (arrangement, callNotificationManager) = Arrangement() + .withIncomingNotificationForUserAndCall(notification1, TEST_USER_ID1, TEST_CALL1) + .withIncomingNotificationForUserAndCall(notification2, TEST_USER_ID2, TEST_CALL2) + .arrange() + callNotificationManager.handleIncomingCallNotifications(listOf(TEST_CALL1), TEST_USER_ID1) + callNotificationManager.handleIncomingCallNotifications(listOf(TEST_CALL2), TEST_USER_ID2) + advanceUntilIdle() + arrangement.clearRecordedCallsForNotificationManager() // clear calls recorded when initializing the state + // when + callNotificationManager.handleIncomingCallNotifications(listOf(), TEST_USER_ID1) + callNotificationManager.handleIncomingCallNotifications(listOf(), TEST_USER_ID2) + // then + verify(exactly = 0) { arrangement.notificationManager.notify(any(), notification1) } + verify(exactly = 0) { arrangement.notificationManager.notify(any(), notification2) } + verify(exactly = 1) { arrangement.notificationManager.cancel(any()) } + } + + @Test + fun `given incoming call, when end call comes instantly after start, then do not even show notification`() = + runTest(dispatcherProvider.main()) { + // given + val notification = mockk() + val (arrangement, callNotificationManager) = Arrangement() + .withIncomingNotificationForUserAndCall(notification, TEST_USER_ID1, TEST_CALL1) + .arrange() + // when + callNotificationManager.handleIncomingCallNotifications(listOf(TEST_CALL1), TEST_USER_ID1) + advanceTimeBy((DEBOUNCE_TIME - 50).milliseconds) + callNotificationManager.handleIncomingCallNotifications(listOf(), TEST_USER_ID1) + // then + verify(exactly = 0) { arrangement.notificationManager.notify(any(), notification) } + verify(exactly = 1) { arrangement.notificationManager.cancel(any()) } + } + + @Test + fun `given incoming call, when end call comes some time after start, then first show notification and then hide`() = + runTest(dispatcherProvider.main()) { + // given + val notification = mockk() + val (arrangement, callNotificationManager) = Arrangement() + .withIncomingNotificationForUserAndCall(notification, TEST_USER_ID1, TEST_CALL1) + .arrange() + arrangement.clearRecordedCallsForNotificationManager() // clear first empty list recorded call + // when + callNotificationManager.handleIncomingCallNotifications(listOf(TEST_CALL1), TEST_USER_ID1) + advanceTimeBy((DEBOUNCE_TIME + 50).milliseconds) + callNotificationManager.handleIncomingCallNotifications(listOf(), TEST_USER_ID1) + // then + verify(exactly = 1) { arrangement.notificationManager.notify(any(), notification) } + verify(exactly = 1) { arrangement.notificationManager.cancel(any()) } + } + + private inner class Arrangement { + + @MockK + lateinit var context: Context + + @MockK + lateinit var notificationManager: NotificationManagerCompat + + @MockK + lateinit var callNotificationBuilder: CallNotificationBuilder + + private var callNotificationManager: CallNotificationManager + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + mockkStatic(NotificationManagerCompat::from) + every { NotificationManagerCompat.from(any()) } returns notificationManager + callNotificationManager = CallNotificationManager(context, dispatcherProvider, callNotificationBuilder) + } + + fun clearRecordedCallsForNotificationManager() { + clearMocks( + notificationManager, + answers = false, + recordedCalls = true, + childMocks = false, + verificationMarks = false, + exclusionRules = false + ) + } + + fun withIncomingNotificationForUserAndCall(notification: Notification, forUser: UserId, forCall: Call) = apply { + every { callNotificationBuilder.getIncomingCallNotification(eq(forCall), eq(forUser)) } returns notification + } + + fun arrange() = this to callNotificationManager + } + + companion object { + private val TEST_USER_ID1 = UserId("user1", "domain") + private val TEST_USER_ID2 = UserId("user2", "domain") + private val TEST_CONVERSATION_ID1 = ConversationId("conversation1", "conversationDomain") + private val TEST_CONVERSATION_ID2 = ConversationId("conversation2", "conversationDomain") + private val TEST_CALL1 = provideCall(TEST_CONVERSATION_ID1) + private val TEST_CALL2 = provideCall(TEST_CONVERSATION_ID2) + private fun provideCall( + conversationId: ConversationId = TEST_CONVERSATION_ID1, + status: CallStatus = CallStatus.INCOMING, + ) = Call( + conversationId = conversationId, + status = status, + callerId = UserId("caller", "domain").toString(), + participants = listOf(), + isMuted = true, + isCameraOn = false, + isCbrEnabled = false, + maxParticipants = 0, + conversationName = "ONE_ON_ONE Name", + conversationType = Conversation.Type.ONE_ON_ONE, + callerName = "otherUsername", + callerTeamName = "team_1" + ) + } +} 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 c15205a5236..999a70400c2 100644 --- a/app/src/test/kotlin/com/wire/android/notification/WireNotificationManagerTest.kt +++ b/app/src/test/kotlin/com/wire/android/notification/WireNotificationManagerTest.kt @@ -34,6 +34,7 @@ import com.wire.kalium.logic.GlobalKaliumScope import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.logout.LogoutReason import com.wire.kalium.logic.data.notification.LocalNotificationConversation import com.wire.kalium.logic.data.notification.LocalNotificationMessage import com.wire.kalium.logic.data.notification.LocalNotificationMessageAuthor @@ -53,21 +54,27 @@ import com.wire.kalium.logic.feature.message.GetNotificationsUseCase import com.wire.kalium.logic.feature.message.MarkMessagesAsNotifiedUseCase import com.wire.kalium.logic.feature.message.MessageScope import com.wire.kalium.logic.feature.message.Result +import com.wire.kalium.logic.feature.session.CurrentSessionFlowUseCase +import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.feature.session.GetAllSessionsResult import com.wire.kalium.logic.feature.session.GetSessionsUseCase import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import com.wire.kalium.logic.feature.user.UserScope import com.wire.kalium.logic.sync.SyncManager import io.mockk.MockKAnnotations +import io.mockk.MockKMatcherScope import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy @@ -135,10 +142,10 @@ class WireNotificationManagerTest { @Test fun givenSomeIncomingCalls_whenObserving_thenCallNotificationShowed() = runTestWithCancellation(dispatcherProvider.main()) { val (arrangement, manager) = Arrangement() - .withIncomingCalls(listOf(provideCall())) + .withIncomingCalls(listOf()) .withMessageNotifications(listOf()) - .withCurrentScreen(CurrentScreen.InBackground) - .withEstablishedCall(listOf()) + .withCurrentScreen(CurrentScreen.SomeOther) + .withCurrentUserSession(CurrentSessionResult.Success(AccountInfo.Valid(provideUserId()))) .arrange() manager.observeNotificationsAndCallsWhileRunning(listOf(provideUserId()), this) @@ -148,6 +155,26 @@ class WireNotificationManagerTest { verify(exactly = 1) { arrangement.callNotificationManager.handleIncomingCallNotifications(any(), any()) } } + @Test + fun givenSomeIncomingCall_whenCurrentUserIsDifferentFromCallReceiver_thenCallNotificationIsShown() = + runTestWithCancellation(dispatcherProvider.main()) { + val user1 = provideUserId("user1") + val user2 = provideUserId("user2") + val call = provideCall() + val (arrangement, manager) = Arrangement() + .withSpecificUserSession(userId = user1, incomingCalls = listOf()) + .withSpecificUserSession(userId = user2, incomingCalls = listOf(call)) + .withMessageNotifications(listOf()) + .withCurrentScreen(CurrentScreen.SomeOther) + .withCurrentUserSession(CurrentSessionResult.Success(provideAccountInfo(user1.value))) + .arrange() + + manager.observeNotificationsAndCallsWhileRunning(listOf(user1, user2), this) + runCurrent() + + verify(exactly = 1) { arrangement.callNotificationManager.handleIncomingCallNotifications(any(), user2) } + } + @Test fun givenSomeNotifications_whenAppIsInForegroundAndNoUserLoggedIn_thenMessageNotificationNotShowed() = runTestWithCancellation(dispatcherProvider.main()) { @@ -373,22 +400,6 @@ class WireNotificationManagerTest { coVerify(exactly = 1) { arrangement.connectionPolicyManager.handleConnectionOnPushNotification(userId, any()) } } - @Test - fun givenSomeEstablishedCalls_whenAppIsNotVisible_thenOngoingCallServiceRun() = runTestWithCancellation(dispatcherProvider.main()) { - val (arrangement, manager) = Arrangement() - .withIncomingCalls(listOf()) - .withMessageNotifications(listOf()) - .withCurrentScreen(CurrentScreen.InBackground).withEstablishedCall( - listOf(provideCall().copy(status = CallStatus.ESTABLISHED)) - ) - .arrange() - - manager.observeNotificationsAndCallsWhileRunning(listOf(provideUserId()), this) - runCurrent() - - verify(exactly = 1) { arrangement.servicesManager.startOngoingCallService(any(), any(), any()) } - } - @Test fun givenPingNotification_whenObserveCalled_thenPingSoundIsPlayed() = runTestWithCancellation(dispatcherProvider.main()) { val conversationId = ConversationId("conversation_value", "conversation_domain") @@ -415,6 +426,247 @@ class WireNotificationManagerTest { } } + @Test + fun givenAppInBackground_withValidCurrentAccountAndOngoingCall_whenObserving_thenStartOngoingCallService() = + runTestWithCancellation(dispatcherProvider.main()) { + val userId = provideUserId() + val call = provideCall().copy(status = CallStatus.ESTABLISHED) + val (arrangement, manager) = Arrangement() + .withIncomingCalls(listOf()) + .withMessageNotifications(listOf()) + .withCurrentScreen(CurrentScreen.InBackground) + .withEstablishedCall(listOf(call)) + .withCurrentUserSession(CurrentSessionResult.Success(TEST_AUTH_TOKEN)) + .arrange() + + manager.observeNotificationsAndCallsWhileRunning(listOf(userId), this) + advanceUntilIdle() + + verify(exactly = 1) { arrangement.servicesManager.startOngoingCallService() } + verify(exactly = 0) { arrangement.servicesManager.stopOngoingCallService() } + } + + @Test + fun givenAppInBackground_withValidCurrentAccountAndNoOngoingCall_whenObserving_thenStopOngoingCallService() = + runTestWithCancellation(dispatcherProvider.main()) { + val userId = provideUserId() + val (arrangement, manager) = Arrangement() + .withIncomingCalls(listOf()) + .withMessageNotifications(listOf()) + .withCurrentScreen(CurrentScreen.InBackground) + .withEstablishedCall(listOf()) + .withCurrentUserSession(CurrentSessionResult.Success(TEST_AUTH_TOKEN)) + .arrange() + + manager.observeNotificationsAndCallsWhileRunning(listOf(userId), this) + runCurrent() + + verify(exactly = 0) { arrangement.servicesManager.startOngoingCallService() } + verify(exactly = 1) { arrangement.servicesManager.stopOngoingCallService() } + } + + @Test + fun givenAppInBackground_withInvalidCurrentAccountAndOngoingCall_whenObserving_thenStopOngoingCallService() = + runTestWithCancellation(dispatcherProvider.main()) { + val userId = provideUserId() + val call = provideCall().copy(status = CallStatus.ESTABLISHED) + val (arrangement, manager) = Arrangement() + .withIncomingCalls(listOf()) + .withMessageNotifications(listOf()) + .withCurrentScreen(CurrentScreen.InBackground) + .withEstablishedCall(listOf(call)) + .withCurrentUserSession(CurrentSessionResult.Success(provideInvalidAccountInfo(userId.value))) + .arrange() + + manager.observeNotificationsAndCallsWhileRunning(listOf(userId), this) + runCurrent() + + verify(exactly = 0) { arrangement.servicesManager.startOngoingCallService() } + verify(exactly = 1) { arrangement.servicesManager.stopOngoingCallService() } + } + + @Test + fun givenAppInBackground_withInvalidCurrentAccountAndNoOngoingCall_whenObserving_thenStopOngoingCallService() = + runTestWithCancellation(dispatcherProvider.main()) { + val userId = provideUserId() + val (arrangement, manager) = Arrangement() + .withIncomingCalls(listOf()) + .withMessageNotifications(listOf()) + .withCurrentScreen(CurrentScreen.InBackground) + .withEstablishedCall(listOf()) + .withCurrentUserSession(CurrentSessionResult.Success(provideInvalidAccountInfo(userId.value))) + .arrange() + + manager.observeNotificationsAndCallsWhileRunning(listOf(userId), this) + runCurrent() + + verify(exactly = 0) { arrangement.servicesManager.startOngoingCallService() } + verify(exactly = 1) { arrangement.servicesManager.stopOngoingCallService() } + } + + @Test + fun givenAppInForeground_withValidCurrentAccountAndOngoingCall_whenObserving_thenStopOngoingCallService() = + runTestWithCancellation(dispatcherProvider.main()) { + val userId = provideUserId() + val call = provideCall().copy(status = CallStatus.ESTABLISHED) + val (arrangement, manager) = Arrangement() + .withIncomingCalls(listOf()) + .withMessageNotifications(listOf()) + .withCurrentScreen(CurrentScreen.Home) + .withEstablishedCall(listOf(call)) + .withCurrentUserSession(CurrentSessionResult.Success(provideAccountInfo(userId.value))) + .arrange() + + manager.observeNotificationsAndCallsWhileRunning(listOf(userId), this) + runCurrent() + + verify(exactly = 0) { arrangement.servicesManager.startOngoingCallService() } + verify(exactly = 1) { arrangement.servicesManager.stopOngoingCallService() } + } + + @Test + fun givenAppInForeground_withValidCurrentAccountAndNoOngoingCall_whenObserving_thenStopOngoingCallService() = + runTestWithCancellation(dispatcherProvider.main()) { + val userId = provideUserId() + val (arrangement, manager) = Arrangement() + .withIncomingCalls(listOf()) + .withMessageNotifications(listOf()) + .withCurrentScreen(CurrentScreen.Home) + .withEstablishedCall(listOf()) + .withCurrentUserSession(CurrentSessionResult.Success(provideAccountInfo(userId.value))) + .arrange() + + manager.observeNotificationsAndCallsWhileRunning(listOf(userId), this) + runCurrent() + + verify(exactly = 0) { arrangement.servicesManager.startOngoingCallService() } + verify(exactly = 1) { arrangement.servicesManager.stopOngoingCallService() } + } + + @Test + fun givenAppInForeground_withInvalidCurrentAccountAndOngoingCall_whenObserving_thenStopOngoingCallService() = + runTestWithCancellation(dispatcherProvider.main()) { + val userId = provideUserId() + val call = provideCall().copy(status = CallStatus.ESTABLISHED) + val (arrangement, manager) = Arrangement() + .withIncomingCalls(listOf()) + .withMessageNotifications(listOf()) + .withCurrentScreen(CurrentScreen.Home) + .withEstablishedCall(listOf(call)) + .withCurrentUserSession(CurrentSessionResult.Success(provideInvalidAccountInfo(userId.value))) + .arrange() + + manager.observeNotificationsAndCallsWhileRunning(listOf(userId), this) + runCurrent() + + verify(exactly = 0) { arrangement.servicesManager.startOngoingCallService() } + verify(exactly = 1) { arrangement.servicesManager.stopOngoingCallService() } + } + + @Test + fun givenAppInForeground_withInvalidCurrentAccountAndNoOngoingCall_whenObserving_thenStopOngoingCallService() = + runTestWithCancellation(dispatcherProvider.main()) { + val userId = provideUserId() + val (arrangement, manager) = Arrangement() + .withIncomingCalls(listOf()) + .withMessageNotifications(listOf()) + .withCurrentScreen(CurrentScreen.Home) + .withEstablishedCall(listOf()) + .withCurrentUserSession(CurrentSessionResult.Success(provideInvalidAccountInfo(userId.value))) + .arrange() + + manager.observeNotificationsAndCallsWhileRunning(listOf(userId), this) + runCurrent() + + verify(exactly = 0) { arrangement.servicesManager.startOngoingCallService() } + verify(exactly = 1) { arrangement.servicesManager.stopOngoingCallService() } + } + + @Test + fun givenAppInBackground_withTwoValidAccountsAndOngoingCallForNotCurrentOne_whenObserving_thenStopOngoingCallService() = + runTestWithCancellation(dispatcherProvider.main()) { + val userId1 = UserId("value1", "domain") + val userId2 = UserId("value2", "domain") + val call = provideCall().copy(status = CallStatus.ESTABLISHED) + val (arrangement, manager) = Arrangement() + .withCurrentScreen(CurrentScreen.InBackground) + .withSpecificUserSession(userId = userId1, establishedCalls = listOf()) + .withSpecificUserSession(userId = userId2, establishedCalls = listOf(call)) + .withCurrentUserSession(CurrentSessionResult.Success(provideAccountInfo(userId1.value))) + .arrange() + + manager.observeNotificationsAndCallsWhileRunning(listOf(userId1, userId2), this) + advanceUntilIdle() + + verify(exactly = 1) { arrangement.servicesManager.stopOngoingCallService() } + } + + @Test + fun givenAppInBackground_withTwoValidAccountsAndOngoingCallForNotCurrentOne_whenCurrentAccountChanges_thenStartOngoingCallService() = + runTestWithCancellation(dispatcherProvider.main()) { + val userId1 = UserId("value1", "domain") + val userId2 = UserId("value2", "domain") + val call = provideCall().copy(status = CallStatus.ESTABLISHED) + val (arrangement, manager) = Arrangement() + .withCurrentScreen(CurrentScreen.InBackground) + .withSpecificUserSession(userId = userId1, establishedCalls = listOf()) + .withSpecificUserSession(userId = userId2, establishedCalls = listOf(call)) + .withCurrentUserSession(CurrentSessionResult.Success(provideAccountInfo(userId1.value))) + .arrange() + + manager.observeNotificationsAndCallsWhileRunning(listOf(userId1, userId2), this) + advanceUntilIdle() + + arrangement.withCurrentUserSession(CurrentSessionResult.Success(provideAccountInfo(userId2.value))) + advanceUntilIdle() + + verify(exactly = 1) { arrangement.servicesManager.startOngoingCallService() } + } + + @Test + fun givenAppInBackground_withTwoValidAccountsAndOngoingCallForCurrentOne_whenCurrentAccountChanges_thenStopOngoingCallService() = + runTestWithCancellation(dispatcherProvider.main()) { + val userId1 = UserId("value1", "domain") + val userId2 = UserId("value2", "domain") + val call = provideCall().copy(status = CallStatus.ESTABLISHED) + val (arrangement, manager) = Arrangement() + .withCurrentScreen(CurrentScreen.InBackground) + .withSpecificUserSession(userId = userId1, establishedCalls = listOf(call)) + .withSpecificUserSession(userId = userId2, establishedCalls = listOf()) + .withCurrentUserSession(CurrentSessionResult.Success(provideAccountInfo(userId1.value))) + .arrange() + + manager.observeNotificationsAndCallsWhileRunning(listOf(userId1, userId2), this) + advanceUntilIdle() + + arrangement.withCurrentUserSession(CurrentSessionResult.Success(provideAccountInfo(userId2.value))) + advanceUntilIdle() + + verify(exactly = 1) { arrangement.servicesManager.stopOngoingCallService() } + } + + @Test + fun givenAppInBackground_withValidCurrentAccountAndOngoingCall_whenAccountBecomesInvalid_thenStopOngoingCallService() = + runTestWithCancellation(dispatcherProvider.main()) { + val userId = provideUserId() + val call = provideCall().copy(status = CallStatus.ESTABLISHED) + val (arrangement, manager) = Arrangement() + .withIncomingCalls(listOf()) + .withMessageNotifications(listOf()) + .withCurrentScreen(CurrentScreen.InBackground) + .withEstablishedCall(listOf(call)) + .withCurrentUserSession(CurrentSessionResult.Success(provideAccountInfo(userId.value))) + .arrange() + + manager.observeNotificationsAndCallsWhileRunning(listOf(userId), this) + advanceUntilIdle() + + arrangement.withCurrentUserSession(CurrentSessionResult.Success(provideInvalidAccountInfo(userId.value))) + advanceUntilIdle() + + verify(exactly = 1) { arrangement.servicesManager.stopOngoingCallService() } + } + private inner class Arrangement { @MockK lateinit var coreLogic: CoreLogic @@ -476,9 +728,14 @@ class WireNotificationManagerTest { @MockK lateinit var getSelfUser: GetSelfUserUseCase + @MockK + lateinit var currentSessionFlowUseCase: CurrentSessionFlowUseCase + @MockK lateinit var pingRinger: PingRinger + private val currentSessionChannel = Channel(capacity = Channel.UNLIMITED) + val wireNotificationManager by lazy { WireNotificationManager( coreLogic, @@ -512,21 +769,54 @@ class WireNotificationManagerTest { coEvery { callsScope.establishedCall } returns establishedCall coEvery { callNotificationManager.handleIncomingCallNotifications(any(), any()) } returns Unit coEvery { callNotificationManager.hideIncomingCallNotification() } returns Unit - coEvery { callNotificationManager.getNotificationTitle(any()) } returns "Test title" + coEvery { callNotificationManager.builder.getNotificationTitle(any()) } returns "Test title" coEvery { messageScope.getNotifications } returns getNotificationsUseCase coEvery { messageScope.markMessagesAsNotified } returns markMessagesAsNotified coEvery { markMessagesAsNotified(any()) } returns Result.Success + coEvery { globalKaliumScope.session.currentSessionFlow } returns currentSessionFlowUseCase + coEvery { currentSessionFlowUseCase() } returns currentSessionChannel.consumeAsFlow() coEvery { getSelfUser.invoke() } returns flowOf(TestUser.SELF_USER) - every { servicesManager.startOngoingCallService(any(), any(), any()) } returns Unit + every { servicesManager.startOngoingCallService() } returns Unit every { servicesManager.stopOngoingCallService() } returns Unit every { pingRinger.ping(any(), any()) } returns Unit } + private fun mockSpecificUserSession( + incomingCalls: List = emptyList(), + establishedCalls: List = emptyList(), + notifications: List = emptyList(), + selfUser: SelfUser = TestUser.SELF_USER, + userId: MockKMatcherScope.() -> UserId, + ) { + coEvery { coreLogic.getSessionScope(userId()) } returns mockk { + coEvery { syncManager } returns this@Arrangement.syncManager + coEvery { conversations } returns mockk { + coEvery { markConnectionRequestAsNotified } returns this@Arrangement.markConnectionRequestAsNotified + } + coEvery { calls } returns mockk { + coEvery { establishedCall() } returns flowOf(establishedCalls) + coEvery { getIncomingCalls() } returns flowOf(incomingCalls) + } + coEvery { messages } returns mockk { + coEvery { getNotifications() } returns flowOf(notifications) + coEvery { markMessagesAsNotified } returns this@Arrangement.markMessagesAsNotified + } + coEvery { users } returns mockk { + coEvery { getSelfUser() } returns flowOf(selfUser) + } + } + } + fun withSession(session: GetAllSessionsResult): Arrangement { coEvery { getSessionsUseCase() } returns session return this } + suspend fun withCurrentUserSession(session: CurrentSessionResult): Arrangement { + currentSessionChannel.send(session) + return this + } + fun withMessageNotifications(notifications: List): Arrangement { coEvery { getNotificationsUseCase() } returns flowOf(notifications) return this @@ -542,6 +832,16 @@ class WireNotificationManagerTest { return this } + fun withSpecificUserSession( + userId: UserId, + incomingCalls: List = emptyList(), + establishedCalls: List = emptyList(), + notifications: List = emptyList(), + selfUser: SelfUser = TestUser.SELF_USER, + ): Arrangement = apply { + mockSpecificUserSession(incomingCalls, establishedCalls, notifications, selfUser) { eq(userId) } + } + fun withCurrentScreen(screen: CurrentScreen): Arrangement { coEvery { currentScreenManager.observeCurrentScreen(any()) } returns MutableStateFlow(screen) return this @@ -558,11 +858,13 @@ class WireNotificationManagerTest { private val TEST_SERVER_CONFIG: ServerConfig = newServerConfig(1) private val TEST_AUTH_TOKEN = provideAccountInfo() - private fun provideAccountInfo(userId: String = "user_id"): AccountInfo { - return AccountInfo.Valid( - userId = UserId(userId, "domain.de") - ) - } + private fun provideAccountInfo(userId: String = "user_id"): AccountInfo = AccountInfo.Valid( + userId = provideUserId(userId) + ) + private fun provideInvalidAccountInfo(userId: String = "user_id"): AccountInfo = AccountInfo.Invalid( + userId = provideUserId(userId), + logoutReason = LogoutReason.SESSION_EXPIRED + ) private fun provideCall(id: ConversationId = ConversationId("conversation_value", "conversation_domain")) = Call( conversationId = id, @@ -595,6 +897,6 @@ class WireNotificationManagerTest { time = Instant.DISTANT_FUTURE ) - private fun provideUserId() = UserId("value", "domain") + private fun provideUserId(value: String = "user_id") = UserId(value, "domain") } } diff --git a/app/src/test/kotlin/com/wire/android/services/ServicesManagerTest.kt b/app/src/test/kotlin/com/wire/android/services/ServicesManagerTest.kt new file mode 100644 index 00000000000..531051dbc62 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/services/ServicesManagerTest.kt @@ -0,0 +1,163 @@ +/* + * Wire + * Copyright (C) 2023 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.services + +import android.content.Context +import android.content.Intent +import com.wire.android.config.TestDispatcherProvider +import io.mockk.MockKAnnotations +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockkObject +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import kotlin.time.Duration.Companion.milliseconds + +@OptIn(ExperimentalCoroutinesApi::class) +class ServicesManagerTest { + + val dispatcherProvider = TestDispatcherProvider() + + @Test + fun `given ongoing call service running, when stop comes instantly after start, then do not even start the service`() = + runTest(dispatcherProvider.main()) { + // given + val (arrangement, servicesManager) = Arrangement() + .withServiceState(OngoingCallService.ServiceState.FOREGROUND) + .arrange() + // when + servicesManager.startOngoingCallService() + advanceTimeBy((ServicesManager.DEBOUNCE_TIME - 50).milliseconds) + servicesManager.stopOngoingCallService() + // then + verify(exactly = 0) { arrangement.context.startService(arrangement.ongoingCallServiceIntent) } + verify(exactly = 1) { arrangement.context.stopService(arrangement.ongoingCallServiceIntent) } + } + + @Test + fun `given ongoing call, when stop comes some time after start, then start the service and stop it after that`() = + runTest(dispatcherProvider.main()) { + // given + val (arrangement, servicesManager) = Arrangement() + .withServiceState(OngoingCallService.ServiceState.FOREGROUND) + .arrange() + arrangement.clearRecordedCallsForContext() // clear calls recorded when initializing the state + // when + servicesManager.startOngoingCallService() + advanceTimeBy((ServicesManager.DEBOUNCE_TIME + 50).milliseconds) + servicesManager.stopOngoingCallService() + // then + verify(exactly = 1) { arrangement.context.startService(arrangement.ongoingCallServiceIntent) } + verify(exactly = 1) { arrangement.context.stopService(arrangement.ongoingCallServiceIntent) } + } + + @Test + fun `given ongoing call service in foreground, when needs to be stopped, then call stopService`() = + runTest(dispatcherProvider.main()) { + // given + val (arrangement, servicesManager) = Arrangement() + .withServiceState(OngoingCallService.ServiceState.FOREGROUND) + .arrange() + servicesManager.startOngoingCallService() + advanceUntilIdle() + arrangement.clearRecordedCallsForContext() // clear calls recorded when initializing the state + // when + servicesManager.stopOngoingCallService() + // then + verify(exactly = 0) { arrangement.context.startService(arrangement.ongoingCallServiceIntentWithStopArgument) } + verify(exactly = 1) { arrangement.context.stopService(arrangement.ongoingCallServiceIntent) } + } + + @Test + fun `given ongoing call service not yet in foreground, when needs to be stopped, then call startService with stop service argument`() = + runTest(dispatcherProvider.main()) { + // given + val (arrangement, servicesManager) = Arrangement() + .withServiceState(OngoingCallService.ServiceState.STARTED) + .arrange() + servicesManager.startOngoingCallService() + advanceUntilIdle() + arrangement.clearRecordedCallsForContext() // clear calls recorded when initializing the state + // when + servicesManager.stopOngoingCallService() + // then + verify(exactly = 1) { arrangement.context.startService(arrangement.ongoingCallServiceIntentWithStopArgument) } + verify(exactly = 0) { arrangement.context.stopService(arrangement.ongoingCallServiceIntent) } + } + + @Test + fun `given ongoing call service not even started, when needs to be stopped, then do nothing`() = + runTest(dispatcherProvider.main()) { + // given + val (arrangement, servicesManager) = Arrangement() + .withServiceState(OngoingCallService.ServiceState.NOT_STARTED) + .arrange() + servicesManager.startOngoingCallService() + advanceUntilIdle() + arrangement.clearRecordedCallsForContext() // clear calls recorded when initializing the state + // when + servicesManager.startOngoingCallService() + // then + verify(exactly = 0) { arrangement.context.startService(arrangement.ongoingCallServiceIntentWithStopArgument) } + verify(exactly = 0) { arrangement.context.stopService(arrangement.ongoingCallServiceIntent) } + } + + private inner class Arrangement { + + @MockK(relaxed = true) + lateinit var context: Context + + private val servicesManager: ServicesManager by lazy { ServicesManager(context, dispatcherProvider) } + + @MockK + lateinit var ongoingCallServiceIntent: Intent + + @MockK + lateinit var ongoingCallServiceIntentWithStopArgument: Intent + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + mockkObject(OngoingCallService.Companion) + every { OngoingCallService.Companion.newIntent(context) } returns ongoingCallServiceIntent + every { OngoingCallService.Companion.newIntentToStop(context) } returns ongoingCallServiceIntentWithStopArgument + } + + fun clearRecordedCallsForContext() { + clearMocks( + context, + answers = false, + recordedCalls = true, + childMocks = false, + verificationMarks = false, + exclusionRules = false + ) + } + + fun withServiceState(state: OngoingCallService.ServiceState) = apply { + every { OngoingCallService.Companion.serviceState.get() } returns state + every { OngoingCallService.serviceState.get() } returns state + } + + fun arrange() = this to servicesManager + } +}