From c8d9030f80e8dc7c4026a40c8ae9024fa5a66524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= <30429749+saleniuk@users.noreply.github.com> Date: Thu, 7 Nov 2024 17:06:10 +0100 Subject: [PATCH] fix: showing multiple calls at the same time [WPB-10430] (#3583) --- .../notification/CallNotificationManager.kt | 119 +++++--- .../notification/NotificationConstants.kt | 14 +- .../notification/WireNotificationManager.kt | 38 ++- .../CallNotificationDismissedReceiver.kt | 6 +- .../DeclineIncomingCallReceiver.kt | 39 ++- .../com/wire/android/services/CallService.kt | 19 +- .../ui/calling/StartingCallActivity.kt | 36 ++- .../ui/calling/incoming/IncomingCallScreen.kt | 2 + .../ui/calling/ongoing/OngoingCallActivity.kt | 22 +- .../ui/calling/ongoing/OngoingCallScreen.kt | 2 + .../ui/calling/outgoing/OutgoingCallScreen.kt | 2 + .../ui/common/topappbar/CommonTopAppBar.kt | 182 ++++++++---- .../topappbar/CommonTopAppBarViewModel.kt | 36 ++- .../common/topappbar/ConnectivityUIState.kt | 34 ++- .../kotlin/com/wire/android/util/LogUtil.kt | 27 ++ .../CallNotificationManagerTest.kt | 279 ++++++++++++++---- .../WireNotificationManagerTest.kt | 34 +-- .../topappbar/CommonTopAppBarViewModelTest.kt | 48 ++- 18 files changed, 658 insertions(+), 281 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/util/LogUtil.kt 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 0b34dfa403a..aebd67bb1ed 100644 --- a/app/src/main/kotlin/com/wire/android/notification/CallNotificationManager.kt +++ b/app/src/main/kotlin/com/wire/android/notification/CallNotificationManager.kt @@ -21,10 +21,12 @@ package com.wire.android.notification import android.annotation.SuppressLint import android.app.Notification import android.content.Context +import android.service.notification.StatusBarNotification import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.wire.android.R import com.wire.android.appLogger +import com.wire.android.notification.NotificationConstants.INCOMING_CALL_ID_PREFIX import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.CallStatus @@ -34,7 +36,6 @@ import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.user.UserId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest @@ -42,9 +43,9 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.jetbrains.annotations.VisibleForTesting @@ -62,75 +63,108 @@ class CallNotificationManager @Inject constructor( private val notificationManager = NotificationManagerCompat.from(context) private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.default()) - private val incomingCallsForUsers = MutableStateFlow>(mapOf()) + private val incomingCallsForUsers = MutableStateFlow>(mapOf()) private val reloadCallNotification = MutableSharedFlow() init { scope.launch { incomingCallsForUsers .debounce { if (it.isEmpty()) 0L else DEBOUNCE_TIME } // debounce to avoid showing and hiding notification too fast - .map { it.entries.firstOrNull()?.toCallNotificationData() } + .map { + it.values.map { (userId, userName, calls) -> + calls.map { call -> + CallNotificationData(userId, call, userName) + } + }.flatten() + } + .scan(emptyList() to emptyList()) { (previousCalls, _), currentCalls -> + currentCalls to (currentCalls - previousCalls.toSet()) + } .distinctUntilChanged() - .reloadIfNeeded() - .collectLatest { incomingCallData -> - if (incomingCallData == null) { - hideIncomingCallNotification() - } else { - appLogger.i("$TAG: showing incoming call") - showIncomingCallNotification(incomingCallData) + .flatMapLatest { (allCurrentCalls, newCalls) -> + reloadCallNotification + .map { (userIdString, conversationIdString) -> + allCurrentCalls to allCurrentCalls.filter { // emit call that needs to be reloaded as newOrUpdated + it.userId.toString() == userIdString && it.conversationId.toString() == conversationIdString + } + } + .filter { (_, newCalls) -> newCalls.isNotEmpty() } // only emit if there is something to reload + .onStart { emit(allCurrentCalls to newCalls) } + } + .collectLatest { (allCurrentCalls, newCalls) -> + // remove outdated incoming call notifications + hideOutdatedIncomingCallNotifications(allCurrentCalls) + // show current incoming call notifications + appLogger.i("$TAG: showing ${newCalls.size} new incoming calls (all incoming calls: ${allCurrentCalls.size})") + newCalls.forEach { data -> + showIncomingCallNotification(data) } } } } - fun reloadIfNeeded(data: CallNotificationData): Flow = reloadCallNotification - .filter { reloadCallNotificationIds -> // check if the reload action is for the same call - reloadCallNotificationIds.userIdString == data.userId.toString() - && reloadCallNotificationIds.conversationIdString == data.conversationId.toString() + @VisibleForTesting + internal fun hideOutdatedIncomingCallNotifications(currentIncomingCalls: List) { + val currentIncomingCallNotificationIds = currentIncomingCalls.map { + NotificationConstants.getIncomingCallId(it.userId.toString(), it.conversationId.toString()) } - .map { data } - .onStart { emit(data) } - - private fun Flow.reloadIfNeeded(): Flow = this.flatMapLatest { callEntry -> - callEntry?.let { reloadIfNeeded(it) } ?: flowOf(null) + hideIncomingCallNotifications { _, id -> !currentIncomingCallNotificationIds.contains(id) } } fun reloadCallNotifications(reloadCallNotificationIds: CallNotificationIds) = scope.launch { reloadCallNotification.emit(reloadCallNotificationIds) } - fun handleIncomingCallNotifications(calls: List, userId: UserId) { + fun handleIncomingCalls(calls: List, userId: UserId, userName: String) { if (calls.isEmpty()) { - incomingCallsForUsers.update { it.filter { it.key != userId } } + incomingCallsForUsers.update { + it.minus(userId) + } } else { - incomingCallsForUsers.update { it.filter { it.key != userId } + (userId to calls.first()) } + incomingCallsForUsers.update { + it.plus(userId to IncomingCallsForUser(userId, userName, calls)) + } } } - fun hideAllNotifications() { - hideIncomingCallNotification() + private fun hideIncomingCallNotifications(predicate: (tag: String, id: Int) -> Boolean) { + notificationManager.activeNotifications.filter { + it.tag?.startsWith(INCOMING_CALL_ID_PREFIX) == true && predicate(it.tag, it.id) + }.forEach { + it.hideIncomingCallNotification() + } } - private fun hideIncomingCallNotification() { + 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) } + + private fun StatusBarNotification.hideIncomingCallNotification() { appLogger.i("$TAG: hiding incoming call") // This delay is just so when the user receives two calling signals one straight after the other [INCOMING -> CANCEL] - // Due to the signals being one after the other we are creating a notification when we are trying to cancel it, it wasn't properly - // cancelling vibration as probably when we were cancelling, the vibration object was still being created and started and thus - // never stopped. + // Due to the signals being one after the other we are creating a notification when we are trying to cancel it, it wasn't + // properly cancelling vibration as probably when we were cancelling, the vibration object was still being created and started + // and thus never stopped. TimeUnit.MILLISECONDS.sleep(CANCEL_CALL_NOTIFICATION_DELAY) - notificationManager.cancel(NotificationIds.CALL_INCOMING_NOTIFICATION_ID.ordinal) + notificationManager.cancel(tag, id) } @SuppressLint("MissingPermission") @VisibleForTesting internal fun showIncomingCallNotification(data: CallNotificationData) { - appLogger.i("$TAG: showing incoming call notification for user ${data.userId.toLogString()}") - val notification = builder.getIncomingCallNotification(data) - notificationManager.notify( - NotificationIds.CALL_INCOMING_NOTIFICATION_ID.ordinal, - notification + appLogger.i( + "$TAG: showing incoming call notification for user ${data.userId.toLogString()}" + + " and conversation ${data.conversationId.toLogString()}" ) + val tag = NotificationConstants.getIncomingCallTag(data.userId.toString()) + val id = NotificationConstants.getIncomingCallId(data.userId.toString(), data.conversationId.toString()) + val notification = builder.getIncomingCallNotification(data) + notificationManager.notify(tag, id, notification) } // Notifications @@ -141,10 +175,6 @@ class CallNotificationManager @Inject constructor( @VisibleForTesting internal const val DEBOUNCE_TIME = 200L - - fun hideIncomingCallNotification(context: Context) { - NotificationManagerCompat.from(context).cancel(NotificationIds.CALL_INCOMING_NOTIFICATION_ID.ordinal) - } } } @@ -164,6 +194,7 @@ class CallNotificationBuilder @Inject constructor( .setSmallIcon(R.drawable.notification_icon_small) .setContentTitle(data.conversationName) .setContentText(context.getString(R.string.notification_outgoing_call_tap_to_return)) + .setSubText(data.userName) .setAutoCancel(false) .setOngoing(true) .setSilent(true) @@ -188,6 +219,7 @@ class CallNotificationBuilder @Inject constructor( .setSmallIcon(R.drawable.notification_icon_small) .setContentTitle(title) .setContentText(content) + .setSubText(data.userName) .setAutoCancel(false) .setOngoing(true) .setVibrate(VIBRATE_PATTERN) @@ -214,6 +246,7 @@ class CallNotificationBuilder @Inject constructor( return NotificationCompat.Builder(context, channelId) .setContentTitle(title) .setContentText(context.getString(R.string.notification_ongoing_call_content)) + .setSubText(data.userName) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setCategory(NotificationCompat.CATEGORY_CALL) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) @@ -275,10 +308,13 @@ class CallNotificationBuilder @Inject constructor( } } +data class IncomingCallsForUser(val userId: UserId, val userName: String, val incomingCalls: List) + data class CallNotificationIds(val userIdString: String, val conversationIdString: String) data class CallNotificationData( val userId: QualifiedID, + val userName: String, val conversationId: ConversationId, val conversationName: String?, val conversationType: Conversation.Type, @@ -286,8 +322,9 @@ data class CallNotificationData( val callerTeamName: String?, val callStatus: CallStatus ) { - constructor(userId: UserId, call: Call) : this( + constructor(userId: UserId, call: Call, userName: String) : this( userId, + userName, call.conversationId, call.conversationName, call.conversationType, @@ -296,5 +333,3 @@ data class CallNotificationData( call.status ) } - -fun Map.Entry.toCallNotificationData() = CallNotificationData(userId = key, call = value) diff --git a/app/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt b/app/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt index 186cc6dbf92..fdbe058e373 100644 --- a/app/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt +++ b/app/src/main/kotlin/com/wire/android/notification/NotificationConstants.kt @@ -52,6 +52,9 @@ object NotificationConstants { // MessagesSummaryNotification ID depends on User, use fun getMessagesSummaryId(userId: UserId) to get it private const val MESSAGE_SUMMARY_ID_STRING = "wire_messages_summary_notification" + private const val INCOMING_CALL_TAG_PREFIX = "wire_incoming_call_tag_" + const val INCOMING_CALL_ID_PREFIX = "wire_incoming_call_" + fun getConversationNotificationId(conversationIdString: String, userIdString: String) = (conversationIdString + userIdString).hashCode() fun getMessagesGroupKey(userId: UserId?): String = "$MESSAGE_GROUP_KEY_PREFIX${userId?.toString() ?: ""}" fun getMessagesSummaryId(userId: UserId): Int = "$MESSAGE_SUMMARY_ID_STRING$userId".hashCode() @@ -60,6 +63,10 @@ object NotificationConstants { fun getPingsChannelId(userId: UserId): String = getChanelIdForUser(userId, PING_CHANNEL_ID) fun getIncomingChannelId(userId: UserId): String = getChanelIdForUser(userId, INCOMING_CALL_CHANNEL_ID) fun getOutgoingChannelId(userId: UserId): String = getChanelIdForUser(userId, OUTGOING_CALL_CHANNEL_ID) + fun getIncomingCallId(userIdString: String, conversationIdString: String): Int = + "$INCOMING_CALL_ID_PREFIX${userIdString}_$conversationIdString".hashCode() + + fun getIncomingCallTag(userIdString: String): String = "$INCOMING_CALL_TAG_PREFIX$userIdString" /** * @return NotificationChannelId [String] specific for user, use it to post a notifications. @@ -72,7 +79,12 @@ object NotificationConstants { // Notification IDs (has to be unique!) enum class NotificationIds { - CALL_INCOMING_NOTIFICATION_ID, + @Suppress("unused") + @Deprecated( + message = "Do not use it, it's here just because we use .ordinal as ID and ID for the foreground service notification cannot be 0", + level = DeprecationLevel.ERROR + ) + ZERO_ID, CALL_OUTGOING_ONGOING_NOTIFICATION_ID, PERSISTENT_NOTIFICATION_ID, MESSAGE_SYNC_NOTIFICATION_ID, 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 a2361e0e7ae..85798080259 100644 --- a/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt +++ b/app/src/main/kotlin/com/wire/android/notification/WireNotificationManager.kt @@ -28,6 +28,7 @@ import com.wire.android.util.CurrentScreen import com.wire.android.util.CurrentScreenManager import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.lifecycle.ConnectionPolicyManager +import com.wire.android.util.logIfEmptyUserName import com.wire.kalium.logger.obfuscateId import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.id.ConversationId @@ -51,6 +52,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.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -249,7 +251,7 @@ class WireNotificationManager @Inject constructor( // and remove the notifications that were displayed previously appLogger.i("$TAG no Users -> hide all the notifications") messagesNotificationManager.hideAllNotifications() - callNotificationManager.hideAllNotifications() + callNotificationManager.hideAllIncomingCallNotifications() servicesManager.stopCallService() return @@ -297,6 +299,7 @@ class WireNotificationManager @Inject constructor( private fun stopObservingForUser(userId: UserId, observingJobs: ObservingJobs) { messagesNotificationManager.hideAllNotificationsForUser(userId) + callNotificationManager.hideAllIncomingCallNotificationsForUser(userId) observingJobs.userJobs[userId]?.cancelAll() observingJobs.userJobs.remove(userId) } @@ -336,20 +339,26 @@ class WireNotificationManager @Inject constructor( ) { appLogger.d("$TAG observe incoming calls") - coreLogic.getSessionScope(userId).observeE2EIRequired() - .map { it is E2EIRequiredResult.NoGracePeriod } - .distinctUntilChanged() - .flatMapLatest { isBlockedByE2EIRequired -> - if (isBlockedByE2EIRequired) { - appLogger.d("$TAG calls were blocked as E2EI is required") - flowOf(listOf()) - } else { - coreLogic.getSessionScope(userId).calls.getIncomingCalls() + coreLogic.getSessionScope(userId).let { userSessionScope -> + userSessionScope.observeE2EIRequired() + .map { it is E2EIRequiredResult.NoGracePeriod } + .distinctUntilChanged() + .flatMapLatest { isBlockedByE2EIRequired -> + if (isBlockedByE2EIRequired) { + appLogger.d("$TAG calls were blocked as E2EI is required") + flowOf(listOf()) + } else { + userSessionScope.calls.getIncomingCalls() + }.map { calls -> + userSessionScope.users.getSelfUser().first() + .also { it.logIfEmptyUserName() } + .let { it.handle ?: it.name ?: "" } to calls + } } - } - .collect { calls -> - callNotificationManager.handleIncomingCallNotifications(calls, userId) - } + .collect { (userName, calls) -> + callNotificationManager.handleIncomingCalls(calls, userId, userName) + } + } } /** @@ -366,6 +375,7 @@ class WireNotificationManager @Inject constructor( val selfUserNameState = coreLogic.getSessionScope(userId) .users .getSelfUser() + .onEach { it.logIfEmptyUserName() } .map { it.handle ?: it.name ?: "" } .distinctUntilChanged() .stateIn(scope) diff --git a/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/CallNotificationDismissedReceiver.kt b/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/CallNotificationDismissedReceiver.kt index ea1722efd00..ba618d33abf 100644 --- a/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/CallNotificationDismissedReceiver.kt +++ b/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/CallNotificationDismissedReceiver.kt @@ -24,6 +24,7 @@ import android.content.Intent import com.wire.android.appLogger import com.wire.android.notification.CallNotificationIds import com.wire.android.notification.CallNotificationManager +import com.wire.kalium.logger.obfuscateId import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -36,7 +37,10 @@ class CallNotificationDismissedReceiver : BroadcastReceiver() { // requires zero override fun onReceive(context: Context, intent: Intent) { val conversationIdString: String = intent.getStringExtra(EXTRA_CONVERSATION_ID) ?: return val userIdString: String = intent.getStringExtra(EXTRA_USER_ID) ?: return - appLogger.i("CallNotificationDismissedReceiver: onReceive") + appLogger.i( + "CallNotificationDismissedReceiver: onReceive for user ${userIdString.obfuscateId()}" + + " and conversation ${conversationIdString.obfuscateId()}" + ) callNotificationManager.reloadCallNotifications(CallNotificationIds(userIdString, conversationIdString)) } diff --git a/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/DeclineIncomingCallReceiver.kt b/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/DeclineIncomingCallReceiver.kt index 3837e4212b8..26015086595 100644 --- a/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/DeclineIncomingCallReceiver.kt +++ b/app/src/main/kotlin/com/wire/android/notification/broadcastreceivers/DeclineIncomingCallReceiver.kt @@ -27,11 +27,11 @@ import com.wire.android.di.KaliumCoreLogic import com.wire.android.di.NoSession import com.wire.android.notification.CallNotificationManager 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.QualifiedID import com.wire.kalium.logic.data.id.QualifiedIdMapper import com.wire.kalium.logic.data.id.toQualifiedID -import com.wire.kalium.logic.feature.session.CurrentSessionResult +import com.wire.kalium.logic.data.user.UserId import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -56,27 +56,22 @@ class DeclineIncomingCallReceiver : BroadcastReceiver() { // requires zero argum @ApplicationScope lateinit var coroutineScope: CoroutineScope + @Inject + lateinit var callNotificationManager: CallNotificationManager + override fun onReceive(context: Context, intent: Intent) { - val conversationId: String = intent.getStringExtra(EXTRA_CONVERSATION_ID) ?: return - appLogger.i("CallNotificationDismissReceiver: onReceive, conversationId: $conversationId") + val conversationIdString: String = intent.getStringExtra(EXTRA_CONVERSATION_ID) ?: run { + appLogger.e("CallNotificationDismissReceiver: onReceive, conversation ID is missing") + return + } + appLogger.i("CallNotificationDismissReceiver: onReceive, conversationId: ${conversationIdString.obfuscateId()}") + val userId: UserId = intent.getStringExtra(EXTRA_RECEIVER_USER_ID)?.toQualifiedID(qualifiedIdMapper) ?: run { + appLogger.e("CallNotificationDismissReceiver: onReceive, user ID is missing") + return + } coroutineScope.launch(Dispatchers.Default) { - val userId: QualifiedID? = intent.getStringExtra(EXTRA_RECEIVER_USER_ID)?.toQualifiedID(qualifiedIdMapper) - val sessionScope = - if (userId != null) { - coreLogic.getSessionScope(userId) - } else { - val currentSession = coreLogic.globalScope { session.currentSession() } - if (currentSession is CurrentSessionResult.Success) { - coreLogic.getSessionScope(currentSession.accountInfo.userId) - } else { - null - } - } - - sessionScope?.let { - it.calls.rejectCall(qualifiedIdMapper.fromStringToQualifiedID(conversationId)) - } - CallNotificationManager.hideIncomingCallNotification(context) + coreLogic.getSessionScope(userId).calls.rejectCall(conversationIdString.toQualifiedID(qualifiedIdMapper)) + callNotificationManager.hideIncomingCallNotification(userId.toString(), conversationIdString) } } @@ -84,7 +79,7 @@ class DeclineIncomingCallReceiver : BroadcastReceiver() { // requires zero argum private const val EXTRA_CONVERSATION_ID = "conversation_id_extra" private const val EXTRA_RECEIVER_USER_ID = "user_id_extra" - fun newIntent(context: Context, conversationId: String?, userId: String?): Intent = + fun newIntent(context: Context, conversationId: String, userId: String): Intent = Intent(context, DeclineIncomingCallReceiver::class.java).apply { putExtra(EXTRA_CONVERSATION_ID, conversationId) putExtra(EXTRA_RECEIVER_USER_ID, userId) 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 087a77ac960..1c866bd8793 100644 --- a/app/src/main/kotlin/com/wire/android/services/CallService.kt +++ b/app/src/main/kotlin/com/wire/android/services/CallService.kt @@ -32,12 +32,12 @@ import com.wire.android.notification.CallNotificationData import com.wire.android.notification.CallNotificationManager import com.wire.android.notification.NotificationIds import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.android.util.logIfEmptyUserName import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.call.CallStatus import com.wire.kalium.logic.data.id.QualifiedIdMapper import com.wire.kalium.logic.feature.session.CurrentSessionResult import com.wire.kalium.logic.functional.Either -import com.wire.kalium.logic.functional.flatMapRight import com.wire.kalium.logic.functional.fold import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope @@ -47,6 +47,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch @@ -101,10 +102,9 @@ class CallService : Service() { .flatMapLatest { if (it is CurrentSessionResult.Success && it.accountInfo.isValid()) { val userId = it.accountInfo.userId - val outgoingCallsFlow = - coreLogic.getSessionScope(userId).calls.observeOutgoingCall() - val establishedCallsFlow = - coreLogic.getSessionScope(userId).calls.establishedCall() + val userSessionScope = coreLogic.getSessionScope(userId) + val outgoingCallsFlow = userSessionScope.calls.observeOutgoingCall() + val establishedCallsFlow = userSessionScope.calls.establishedCall() combine( outgoingCallsFlow, @@ -112,7 +112,10 @@ class CallService : Service() { ) { outgoingCalls, establishedCalls -> val calls = outgoingCalls + establishedCalls calls.firstOrNull()?.let { call -> - Either.Right(CallNotificationData(userId, call)) + val userName = userSessionScope.users.getSelfUser().first() + .also { it.logIfEmptyUserName() } + .let { it.handle ?: it.name ?: "" } + Either.Right(CallNotificationData(userId, call, userName)) } ?: Either.Left("no calls") } } else { @@ -120,9 +123,7 @@ class CallService : Service() { } } .distinctUntilChanged() - .flatMapRight { callData -> - callNotificationManager.reloadIfNeeded(callData) - }.debounce { + .debounce { if (it is Either.Left) ServicesManager.DEBOUNCE_TIME else 0L } .collectLatest { diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/StartingCallActivity.kt b/app/src/main/kotlin/com/wire/android/ui/calling/StartingCallActivity.kt index b5dc7d2cb47..56cbe581ffc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/StartingCallActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/StartingCallActivity.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.semantics @@ -62,6 +63,23 @@ class StartingCallActivity : CallActivity() { @Inject lateinit var proximitySensorManager: ProximitySensorManager + var conversationId: String? by mutableStateOf(null) + var userId: String? by mutableStateOf(null) + var screenType: StartingCallScreenType? by mutableStateOf(null) + + private fun handleNewIntent(intent: Intent) { + conversationId = intent.extras?.getString(EXTRA_CONVERSATION_ID) + userId = intent.extras?.getString(EXTRA_USER_ID) + screenType = intent.extras?.getString(EXTRA_SCREEN_TYPE)?.let { StartingCallScreenType.byName(it) } + switchAccountIfNeeded(userId) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleNewIntent(intent) + setIntent(intent) + } + @Suppress("LongMethod") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -71,10 +89,7 @@ class StartingCallActivity : CallActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) - val conversationId = intent.extras?.getString(EXTRA_CONVERSATION_ID) - val screenType = intent.extras?.getString(EXTRA_SCREEN_TYPE) - val userId = intent.extras?.getString(EXTRA_USER_ID) - switchAccountIfNeeded(userId) + handleNewIntent(intent) appLogger.i("$TAG Initializing proximity sensor..") proximitySensorManager.initialize() @@ -86,8 +101,7 @@ class StartingCallActivity : CallActivity() { LocalActivity provides this ) { WireTheme { - val currentCallScreenType by remember { mutableStateOf(StartingCallScreenType.byName(screenType)) } - currentCallScreenType?.let { currentScreenType -> + screenType?.let { currentScreenType -> AnimatedContent( targetState = currentScreenType, transitionSpec = { @@ -102,10 +116,7 @@ class StartingCallActivity : CallActivity() { when (screenType) { StartingCallScreenType.Outgoing -> { OutgoingCallScreen( - conversationId = - qualifiedIdMapper.fromStringToQualifiedID( - it - ) + conversationId = qualifiedIdMapper.fromStringToQualifiedID(it) ) { getOngoingCallIntent(this@StartingCallActivity, it).run { this@StartingCallActivity.startActivity(this) @@ -114,15 +125,16 @@ class StartingCallActivity : CallActivity() { } } - StartingCallScreenType.Incoming -> + StartingCallScreenType.Incoming -> { IncomingCallScreen( - qualifiedIdMapper.fromStringToQualifiedID(it) + conversationId = qualifiedIdMapper.fromStringToQualifiedID(it) ) { this@StartingCallActivity.startActivity( getOngoingCallIntent(this@StartingCallActivity, it) ) this@StartingCallActivity.finishAndRemoveTask() } + } } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/incoming/IncomingCallScreen.kt b/app/src/main/kotlin/com/wire/android/ui/calling/incoming/IncomingCallScreen.kt index f811904738d..43c9138270b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/incoming/IncomingCallScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/incoming/IncomingCallScreen.kt @@ -64,9 +64,11 @@ import com.wire.kalium.logic.data.id.ConversationId fun IncomingCallScreen( conversationId: ConversationId, incomingCallViewModel: IncomingCallViewModel = hiltViewModel( + key = "incoming_$conversationId", creationCallback = { factory -> factory.create(conversationId = conversationId) } ), sharedCallingViewModel: SharedCallingViewModel = hiltViewModel( + key = "shared_$conversationId", creationCallback = { factory -> factory.create(conversationId = conversationId) } ), onCallAccepted: () -> Unit diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallActivity.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallActivity.kt index d318d103310..4a407e84e1d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallActivity.kt @@ -30,7 +30,10 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.animation.togetherWith import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.semantics @@ -66,6 +69,21 @@ class OngoingCallActivity : CallActivity() { @Inject lateinit var proximitySensorManager: ProximitySensorManager + var conversationId: String? by mutableStateOf(null) + var userId: String? by mutableStateOf(null) + + private fun handleNewIntent(intent: Intent) { + conversationId = intent.extras?.getString(EXTRA_CONVERSATION_ID) + userId = intent.extras?.getString(EXTRA_USER_ID) + switchAccountIfNeeded(userId) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleNewIntent(intent) + setIntent(intent) + } + @SuppressLint("UnusedContentLambdaTargetStateParameter") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -74,9 +92,7 @@ class OngoingCallActivity : CallActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) - val conversationId = intent.extras?.getString(EXTRA_CONVERSATION_ID) - val userId = intent.extras?.getString(EXTRA_USER_ID) - switchAccountIfNeeded(userId) + handleNewIntent(intent) appLogger.i("$TAG Initializing proximity sensor..") proximitySensorManager.initialize() diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt index 5b0109825f6..f8b4d03dce3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallScreen.kt @@ -106,9 +106,11 @@ import java.util.Locale fun OngoingCallScreen( conversationId: ConversationId, ongoingCallViewModel: OngoingCallViewModel = hiltViewModel( + key = "ongoing_$conversationId", creationCallback = { factory -> factory.create(conversationId = conversationId) } ), sharedCallingViewModel: SharedCallingViewModel = hiltViewModel( + key = "shared_$conversationId", creationCallback = { factory -> factory.create(conversationId = conversationId) } ) ) { diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/outgoing/OutgoingCallScreen.kt b/app/src/main/kotlin/com/wire/android/ui/calling/outgoing/OutgoingCallScreen.kt index 868b84907d8..b579a7e731a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/outgoing/OutgoingCallScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/outgoing/OutgoingCallScreen.kt @@ -55,9 +55,11 @@ import com.wire.kalium.logic.data.id.ConversationId fun OutgoingCallScreen( conversationId: ConversationId, sharedCallingViewModel: SharedCallingViewModel = hiltViewModel( + key = "shared_$conversationId", creationCallback = { factory -> factory.create(conversationId = conversationId) } ), outgoingCallViewModel: OutgoingCallViewModel = hiltViewModel( + key = "outgoing_$conversationId", creationCallback = { factory -> factory.create(conversationId = conversationId) } ), onCallAccepted: () -> Unit diff --git a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBar.kt b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBar.kt index 29cb00824bd..9aa713aebd8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBar.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBar.kt @@ -30,13 +30,14 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -57,9 +58,9 @@ import com.wire.kalium.logic.data.id.ConversationId fun CommonTopAppBar( themeOption: ThemeOption, commonTopAppBarState: CommonTopAppBarState, - onReturnToCallClick: (ConnectivityUIState.EstablishedCall) -> Unit, - onReturnToIncomingCallClick: (ConnectivityUIState.IncomingCall) -> Unit, - onReturnToOutgoingCallClick: (ConnectivityUIState.OutgoingCall) -> Unit, + onReturnToCallClick: (ConnectivityUIState.Call.Established) -> Unit, + onReturnToIncomingCallClick: (ConnectivityUIState.Call.Incoming) -> Unit, + onReturnToOutgoingCallClick: (ConnectivityUIState.Call.Outgoing) -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { @@ -76,9 +77,7 @@ fun CommonTopAppBar( @Composable fun getBackgroundColor(connectivityInfo: ConnectivityUIState): Color { return when (connectivityInfo) { - is ConnectivityUIState.EstablishedCall, - is ConnectivityUIState.IncomingCall, - is ConnectivityUIState.OutgoingCall -> MaterialTheme.wireColorScheme.positive + is ConnectivityUIState.Calls -> MaterialTheme.wireColorScheme.positive ConnectivityUIState.Connecting, ConnectivityUIState.WaitingConnection -> MaterialTheme.wireColorScheme.primary ConnectivityUIState.None -> MaterialTheme.wireColorScheme.background } @@ -88,9 +87,9 @@ fun getBackgroundColor(connectivityInfo: ConnectivityUIState): Color { private fun ConnectivityStatusBar( themeOption: ThemeOption, connectivityInfo: ConnectivityUIState, - onReturnToCallClick: (ConnectivityUIState.EstablishedCall) -> Unit, - onReturnToIncomingCallClick: (ConnectivityUIState.IncomingCall) -> Unit, - onReturnToOutgoingCallClick: (ConnectivityUIState.OutgoingCall) -> Unit + onReturnToCallClick: (ConnectivityUIState.Call.Established) -> Unit, + onReturnToIncomingCallClick: (ConnectivityUIState.Call.Incoming) -> Unit, + onReturnToOutgoingCallClick: (ConnectivityUIState.Call.Outgoing) -> Unit ) { val isVisible = connectivityInfo !is ConnectivityUIState.None val backgroundColor = getBackgroundColor(connectivityInfo) @@ -112,45 +111,28 @@ private fun ConnectivityStatusBar( ClearStatusBarColor() } - val barModifier = Modifier - .animateContentSize() - .fillMaxWidth() - .height(MaterialTheme.wireDimensions.ongoingCallLabelHeight) - .background(backgroundColor) - .run { - when (connectivityInfo) { - is ConnectivityUIState.EstablishedCall -> - clickable(onClick = { onReturnToCallClick(connectivityInfo) }) - - is ConnectivityUIState.IncomingCall -> - clickable(onClick = { onReturnToIncomingCallClick(connectivityInfo) }) - - is ConnectivityUIState.OutgoingCall -> - clickable(onClick = { onReturnToOutgoingCallClick(connectivityInfo) }) - - else -> this - } - } - AnimatedVisibility( visible = isVisible, enter = expandIn(initialSize = { fullSize -> IntSize(fullSize.width, 0) }), exit = shrinkOut(targetSize = { fullSize -> IntSize(fullSize.width, 0) }) ) { Column( - modifier = barModifier, + modifier = Modifier + .animateContentSize() + .fillMaxWidth() + .heightIn(min = MaterialTheme.wireDimensions.ongoingCallLabelHeight) + .background(backgroundColor), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { when (connectivityInfo) { - is ConnectivityUIState.EstablishedCall -> - OngoingCallContent(connectivityInfo.isMuted) - - is ConnectivityUIState.IncomingCall -> - IncomingCallContent(callerName = connectivityInfo.callerName) - - is ConnectivityUIState.OutgoingCall -> - OutgoingCallContent(conversationName = connectivityInfo.conversationName) + is ConnectivityUIState.Calls -> + CallsContent( + calls = connectivityInfo.calls, + onReturnToCallClick = onReturnToCallClick, + onReturnToIncomingCallClick = onReturnToIncomingCallClick, + onReturnToOutgoingCallClick = onReturnToOutgoingCallClick + ) ConnectivityUIState.Connecting -> StatusLabel( @@ -171,34 +153,102 @@ private fun ConnectivityStatusBar( } @Composable -private fun OngoingCallContent(isMuted: Boolean) { - Row { +private fun CallsContent( + calls: List, + onReturnToCallClick: (ConnectivityUIState.Call.Established) -> Unit, + onReturnToIncomingCallClick: (ConnectivityUIState.Call.Incoming) -> Unit, + onReturnToOutgoingCallClick: (ConnectivityUIState.Call.Outgoing) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.wireDimensions.spacing12x)) { + calls.forEach { call -> + when (call) { + is ConnectivityUIState.Call.Established -> OngoingCallContent( + isMuted = call.isMuted, + modifier = Modifier + .clickable( + onClick = remember(call) { + { + onReturnToCallClick(call) + } + } + ) + .fillMaxWidth() + .heightIn(min = MaterialTheme.wireDimensions.ongoingCallLabelHeight) + ) + + is ConnectivityUIState.Call.Incoming -> IncomingCallContent( + callerName = call.callerName, + modifier = Modifier + .clickable( + onClick = remember(call) { + { + onReturnToIncomingCallClick(call) + } + } + ) + .fillMaxWidth() + .heightIn(min = MaterialTheme.wireDimensions.ongoingCallLabelHeight) + ) + + is ConnectivityUIState.Call.Outgoing -> OutgoingCallContent( + conversationName = call.conversationName, + modifier = Modifier + .clickable( + onClick = remember(call) { + { + onReturnToOutgoingCallClick(call) + } + } + ) + .fillMaxWidth() + .heightIn(min = MaterialTheme.wireDimensions.ongoingCallLabelHeight) + ) + } + } + } +} + +@Composable +private fun OngoingCallContent(isMuted: Boolean, modifier: Modifier = Modifier) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { MicrophoneIcon(isMuted, MaterialTheme.wireColorScheme.onPositive) CameraIcon(MaterialTheme.wireColorScheme.onPositive) StatusLabel( - R.string.connectivity_status_bar_return_to_call, - MaterialTheme.wireColorScheme.onPositive + stringResource = R.string.connectivity_status_bar_return_to_call, + color = MaterialTheme.wireColorScheme.onPositive, ) } } @Composable -private fun IncomingCallContent(callerName: String?) { - Row { +private fun IncomingCallContent(callerName: String?, modifier: Modifier = Modifier) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { StatusLabelWithValue( stringResource = R.string.connectivity_status_bar_return_to_incoming_call, - callerName = callerName, + callerName = callerName ?: stringResource(R.string.username_unavailable_label), color = MaterialTheme.wireColorScheme.onPositive ) } } @Composable -private fun OutgoingCallContent(conversationName: String?) { - Row { +private fun OutgoingCallContent(conversationName: String?, modifier: Modifier = Modifier) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { StatusLabelWithValue( stringResource = R.string.connectivity_status_bar_return_to_outgoing_call, - callerName = conversationName, + callerName = conversationName ?: stringResource(R.string.username_unavailable_label), color = MaterialTheme.wireColorScheme.onPositive ) } @@ -213,6 +263,7 @@ private fun StatusLabel( text = stringResource(id = stringResource).uppercase(), color = color, style = MaterialTheme.wireTypography.title03, + modifier = Modifier.padding(vertical = MaterialTheme.wireDimensions.spacing6x) ) } @@ -227,6 +278,7 @@ private fun StatusLabelWithValue( text = stringResource(id = stringResource, callerName ?: defaultCallerName).uppercase(), color = color, style = MaterialTheme.wireTypography.title03, + modifier = Modifier.padding(vertical = MaterialTheme.wireDimensions.spacing6x) ) } @@ -273,28 +325,40 @@ private fun ClearStatusBarColor() { } @Composable -private fun PreviewCommonTopAppBar(connectivityUIState: ConnectivityUIState) { - WireTheme { - CommonTopAppBar(ThemeOption.SYSTEM, CommonTopAppBarState(connectivityUIState), {}, {}, {}) - } +private fun PreviewCommonTopAppBar(connectivityUIState: ConnectivityUIState) = WireTheme { + CommonTopAppBar(ThemeOption.SYSTEM, CommonTopAppBarState(connectivityUIState), {}, {}, {}) +} + +@PreviewMultipleThemes +@Composable +fun PreviewCommonTopAppBar_ConnectivityEstablishedCallNotMuted() = WireTheme { + PreviewCommonTopAppBar( + ConnectivityUIState.Calls(listOf(ConnectivityUIState.Call.Established(ConversationId("what", "ever"), false))) + ) } @PreviewMultipleThemes @Composable -fun PreviewCommonTopAppBar_ConnectivityCallNotMuted() = +fun PreviewCommonTopAppBar_ConnectivityEstablishedCallAndIncomingCalls() = WireTheme { PreviewCommonTopAppBar( - ConnectivityUIState.EstablishedCall( - ConversationId("what", "ever"), - false + ConnectivityUIState.Calls( + listOf( + ConnectivityUIState.Call.Established(ConversationId("1", "1"), false), + ConnectivityUIState.Call.Incoming(ConversationId("2", "2"), "John Doe"), + ConnectivityUIState.Call.Incoming(ConversationId("3", "3"), "Adam Smith"), + ) ) ) +} @PreviewMultipleThemes @Composable -fun PreviewCommonTopAppBar_ConnectivityConnecting() = +fun PreviewCommonTopAppBar_ConnectivityConnecting() = WireTheme { PreviewCommonTopAppBar(ConnectivityUIState.Connecting) +} @PreviewMultipleThemes @Composable -fun PreviewCommonTopAppBar_ConnectivityNone() = +fun PreviewCommonTopAppBar_ConnectivityNone() = WireTheme { PreviewCommonTopAppBar(ConnectivityUIState.None) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModel.kt index 8b34b8de34a..9bafa87779e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModel.kt @@ -69,7 +69,7 @@ class CommonTopAppBarViewModel @Inject constructor( } @VisibleForTesting - internal suspend fun activeCallFlow(userId: UserId): Flow = + internal suspend fun activeCallsFlow(userId: UserId): Flow> = coreLogic.sessionScope(userId) { combine( calls.establishedCall(), @@ -77,8 +77,6 @@ class CommonTopAppBarViewModel @Inject constructor( calls.observeOutgoingCall(), ) { establishedCall, incomingCalls, outgoingCalls -> establishedCall + incomingCalls + outgoingCalls - }.map { calls -> - calls.firstOrNull() }.distinctUntilChanged() } @@ -95,11 +93,11 @@ class CommonTopAppBarViewModel @Inject constructor( is CurrentSessionResult.Success -> { val userId = it.accountInfo.userId combine( - activeCallFlow(userId), + activeCallsFlow(userId), currentScreenFlow(), connectivityFlow(userId), - ) { activeCall, currentScreen, connectivity -> - mapToConnectivityUIState(currentScreen, connectivity, activeCall) + ) { activeCalls, currentScreen, connectivity -> + mapToConnectivityUIState(currentScreen, connectivity, activeCalls) } } } @@ -111,7 +109,7 @@ class CommonTopAppBarViewModel @Inject constructor( * could be called when the screen is changed, so we delayed * showing the banner until getting the correct calling values */ - if (connectivityUIState is ConnectivityUIState.EstablishedCall) { + if (connectivityUIState is ConnectivityUIState.Calls && connectivityUIState.hasOngoingCall) { delay(WAITING_TIME_TO_SHOW_ONGOING_CALL_BANNER) } state = state.copy(connectivityState = connectivityUIState) @@ -123,19 +121,25 @@ class CommonTopAppBarViewModel @Inject constructor( private fun mapToConnectivityUIState( currentScreen: CurrentScreen, connectivity: Connectivity, - activeCall: Call? + activeCalls: List, ): ConnectivityUIState { val canDisplayConnectivityIssues = currentScreen !is CurrentScreen.AuthRelated - if (activeCall != null) { - return if (activeCall.status == CallStatus.INCOMING) { - ConnectivityUIState.IncomingCall(activeCall.conversationId, activeCall.callerName) - } else if (activeCall.status == CallStatus.STARTED) { - ConnectivityUIState.OutgoingCall(activeCall.conversationId, activeCall.conversationName) - } else { - ConnectivityUIState.EstablishedCall(activeCall.conversationId, activeCall.isMuted) - } + if (activeCalls.isNotEmpty()) { + return ConnectivityUIState.Calls( + calls = activeCalls.partition { it.status != CallStatus.INCOMING } + .let { (outgoingAndEstablished, incoming) -> + // outgoing and established first + (outgoingAndEstablished + incoming).map { call -> + when (call.status) { + CallStatus.INCOMING -> ConnectivityUIState.Call.Incoming(call.conversationId, call.callerName) + CallStatus.STARTED -> ConnectivityUIState.Call.Outgoing(call.conversationId, call.conversationName) + else -> ConnectivityUIState.Call.Established(call.conversationId, call.isMuted) + } + } + } + ) } return if (canDisplayConnectivityIssues) { diff --git a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/ConnectivityUIState.kt b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/ConnectivityUIState.kt index f1ee16660cf..0139d6411aa 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/ConnectivityUIState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/ConnectivityUIState.kt @@ -29,18 +29,24 @@ sealed interface ConnectivityUIState { data object None : ConnectivityUIState - data class EstablishedCall( - val conversationId: ConversationId, - val isMuted: Boolean - ) : ConnectivityUIState - - data class IncomingCall( - val conversationId: ConversationId, - val callerName: String? - ) : ConnectivityUIState - - data class OutgoingCall( - val conversationId: ConversationId, - val conversationName: String? - ) : ConnectivityUIState + data class Calls(val calls: List) : ConnectivityUIState { + val hasOngoingCall: Boolean = calls.any { it is Call.Established } + } + + sealed interface Call { + data class Established( + val conversationId: ConversationId, + val isMuted: Boolean + ) : Call + + data class Incoming( + val conversationId: ConversationId, + val callerName: String? + ) : Call + + data class Outgoing( + val conversationId: ConversationId, + val conversationName: String? + ) : Call + } } diff --git a/app/src/main/kotlin/com/wire/android/util/LogUtil.kt b/app/src/main/kotlin/com/wire/android/util/LogUtil.kt new file mode 100644 index 00000000000..1907488de09 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/LogUtil.kt @@ -0,0 +1,27 @@ +/* + * Wire + * Copyright (C) 2024 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.util + +import com.wire.android.appLogger +import com.wire.kalium.logic.data.user.SelfUser + +fun SelfUser.logIfEmptyUserName() { + if (name.isNullOrBlank() && handle.isNullOrBlank()) { + appLogger.e("Name and handle is empty for self user with id ${id.toLogString()}") + } +} diff --git a/app/src/test/kotlin/com/wire/android/notification/CallNotificationManagerTest.kt b/app/src/test/kotlin/com/wire/android/notification/CallNotificationManagerTest.kt index ca449cd21e1..c98e8ca99ad 100644 --- a/app/src/test/kotlin/com/wire/android/notification/CallNotificationManagerTest.kt +++ b/app/src/test/kotlin/com/wire/android/notification/CallNotificationManagerTest.kt @@ -19,6 +19,7 @@ package com.wire.android.notification import android.app.Notification import android.content.Context +import android.service.notification.StatusBarNotification import androidx.core.app.NotificationManagerCompat import com.wire.android.config.TestDispatcherProvider import com.wire.android.notification.CallNotificationManager.Companion.DEBOUNCE_TIME @@ -47,20 +48,46 @@ class CallNotificationManagerTest { val dispatcherProvider = TestDispatcherProvider() @Test - fun `given no incoming calls, then hide notification`() = + fun `given no incoming calls but when there is still active incoming call notification, then hide that notification`() = runTest(dispatcherProvider.main()) { // given + val id = NotificationConstants.getIncomingCallId(TEST_USER_ID1.toString(), TEST_CALL1.conversationId.toString()) + val tag = NotificationConstants.getIncomingCallTag(TEST_USER_ID1.toString()) + val userName = "user name" val (arrangement, callNotificationManager) = Arrangement() + .withActiveNotifications(listOf(mockStatusBarNotification(id, tag))) .arrange() - callNotificationManager.handleIncomingCallNotifications(listOf(), TEST_USER_ID1) + // when + callNotificationManager.handleIncomingCalls(listOf(), TEST_USER_ID1, userName) advanceUntilIdle() // then - verify(exactly = 0) { - arrangement.notificationManager.notify(NotificationIds.CALL_INCOMING_NOTIFICATION_ID.ordinal, any()) - } - verify(exactly = 1) { - arrangement.notificationManager.cancel(NotificationIds.CALL_INCOMING_NOTIFICATION_ID.ordinal) - } + verify(exactly = 0) { arrangement.notificationManager.notify(any(), any(), any()) } + verify(exactly = 1) { arrangement.notificationManager.cancel(tag, id) } + } + + @Test + fun `given incoming call, when call notification needs to be reloaded, then show that notification again`() = + runTest(dispatcherProvider.main()) { + // given + val notification = mockk() + val userName = "user name" + val callNotificationData = provideCallNotificationData(TEST_USER_ID1, TEST_CALL1, userName) + val id = NotificationConstants.getIncomingCallId(TEST_USER_ID1.toString(), TEST_CALL1.conversationId.toString()) + val tag = NotificationConstants.getIncomingCallTag(TEST_USER_ID1.toString()) + val reloadCallIds = CallNotificationIds(TEST_USER_ID1.toString(), TEST_CALL1.conversationId.toString()) + val (arrangement, callNotificationManager) = Arrangement() + .withIncomingNotificationForUserAndCall(notification, callNotificationData) + .arrange() + callNotificationManager.handleIncomingCalls(listOf(TEST_CALL1), TEST_USER_ID1, userName) + arrangement.withActiveNotifications(listOf(mockStatusBarNotification(id, tag))) + advanceUntilIdle() + arrangement.clearRecordedCallsForNotificationManager() // clear first empty list recorded call + // when + callNotificationManager.reloadCallNotifications(reloadCallIds) + advanceUntilIdle() + // then + verify(exactly = 1) { arrangement.notificationManager.notify(tag, id, notification) } // should be shown again + verify(exactly = 0) { arrangement.notificationManager.cancel(tag, id) } } @Test @@ -68,88 +95,217 @@ class CallNotificationManagerTest { runTest(dispatcherProvider.main()) { // given val notification = mockk() - val callNotificationData = provideCallNotificationData(TEST_USER_ID1, TEST_CALL1) + val userName = "user name" + val callNotificationData = provideCallNotificationData(TEST_USER_ID1, TEST_CALL1, userName) + val tag = NotificationConstants.getIncomingCallTag(TEST_USER_ID1.toString()) + val id = NotificationConstants.getIncomingCallId(TEST_USER_ID1.toString(), TEST_CALL1.conversationId.toString()) val (arrangement, callNotificationManager) = Arrangement() .withIncomingNotificationForUserAndCall(notification, callNotificationData) .arrange() arrangement.clearRecordedCallsForNotificationManager() // clear first empty list recorded call - callNotificationManager.handleIncomingCallNotifications(listOf(TEST_CALL1), TEST_USER_ID1) + callNotificationManager.handleIncomingCalls(listOf(TEST_CALL1), TEST_USER_ID1, userName) advanceUntilIdle() // then - verify(exactly = 1) { arrangement.notificationManager.notify(any(), notification) } - verify(exactly = 0) { arrangement.notificationManager.cancel(any()) } + verify(exactly = 1) { arrangement.notificationManager.notify(tag, id, notification) } } @Test - fun `given incoming calls for two users, then show notification for the first call`() = + fun `given an incoming call for one user, when call is updated, then update notification for that call`() = + runTest(dispatcherProvider.main()) { + // given + val notification = mockk() + val userName = "user name" + val callNotificationData = provideCallNotificationData(TEST_USER_ID1, TEST_CALL1, userName) + val updatedCall = TEST_CALL1.copy(conversationName = "new name") + val updatedCallNotificationData = provideCallNotificationData(TEST_USER_ID1, updatedCall, userName) + val tag = NotificationConstants.getIncomingCallTag(TEST_USER_ID1.toString()) + val id = NotificationConstants.getIncomingCallId(TEST_USER_ID1.toString(), TEST_CALL1.conversationId.toString()) + val (arrangement, callNotificationManager) = Arrangement() + .withIncomingNotificationForUserAndCall(notification, callNotificationData) + .withIncomingNotificationForUserAndCall(notification, updatedCallNotificationData) + .arrange() + callNotificationManager.handleIncomingCalls(listOf(TEST_CALL1), TEST_USER_ID1, userName) + advanceUntilIdle() + arrangement.clearRecordedCallsForNotificationManager() // clear first empty list recorded call + // when + callNotificationManager.handleIncomingCalls(listOf(updatedCall), TEST_USER_ID1, userName) // updated call + advanceUntilIdle() + // then + verify(exactly = 1) { arrangement.notificationManager.notify(tag, id, notification) } // should be updated + } + + @Test + fun `given an incoming call for one user, when call is not updated, then do not update notification for that call`() = + runTest(dispatcherProvider.main()) { + // given + val notification = mockk() + val userName = "user name" + val callNotificationData = provideCallNotificationData(TEST_USER_ID1, TEST_CALL1, userName) + val tag = NotificationConstants.getIncomingCallTag(TEST_USER_ID1.toString()) + val id = NotificationConstants.getIncomingCallId(TEST_USER_ID1.toString(), TEST_CALL1.conversationId.toString()) + val (arrangement, callNotificationManager) = Arrangement() + .withIncomingNotificationForUserAndCall(notification, callNotificationData) + .arrange() + callNotificationManager.handleIncomingCalls(listOf(TEST_CALL1), TEST_USER_ID1, userName) + advanceUntilIdle() + arrangement.clearRecordedCallsForNotificationManager() // clear first empty list recorded call + // when + callNotificationManager.handleIncomingCalls(listOf(TEST_CALL1), TEST_USER_ID1, userName) // same call + advanceUntilIdle() + // then + verify(exactly = 0) { arrangement.notificationManager.notify(tag, id, notification) } // should not be updated + } + + @Test + fun `given an incoming call for one same user, when another incoming call appears, then add notification only for this new call`() = + runTest(dispatcherProvider.main()) { + // given + val notification1 = mockk() + val notification2 = mockk() + val userName1 = "user name 1" + val callNotificationData1 = provideCallNotificationData(TEST_USER_ID1, TEST_CALL1, userName1) + val callNotificationData2 = provideCallNotificationData(TEST_USER_ID1, TEST_CALL2, userName1) + val tag1 = NotificationConstants.getIncomingCallTag(TEST_USER_ID1.toString()) + val tag2 = NotificationConstants.getIncomingCallTag(TEST_USER_ID1.toString()) + val id1 = NotificationConstants.getIncomingCallId(TEST_USER_ID1.toString(), TEST_CALL1.conversationId.toString()) + val id2 = NotificationConstants.getIncomingCallId(TEST_USER_ID1.toString(), TEST_CALL2.conversationId.toString()) + val (arrangement, callNotificationManager) = Arrangement() + .withIncomingNotificationForUserAndCall(notification1, callNotificationData1) + .withIncomingNotificationForUserAndCall(notification2, callNotificationData2) + .arrange() + callNotificationManager.handleIncomingCalls(listOf(TEST_CALL1), TEST_USER_ID1, userName1) + advanceUntilIdle() + arrangement.clearRecordedCallsForNotificationManager() // clear first empty list recorded call + // when + callNotificationManager.handleIncomingCalls(listOf(TEST_CALL1, TEST_CALL2), TEST_USER_ID1, userName1) + advanceUntilIdle() + // then + verify(exactly = 0) { arrangement.notificationManager.notify(tag1, id1, notification1) } // already shown previously + verify(exactly = 1) { arrangement.notificationManager.notify(tag2, id2, notification2) } // should be added now + } + + @Test + fun `given incoming calls for two users, then show notification for the both calls`() = + runTest(dispatcherProvider.main()) { + // given + val notification1 = mockk() + val notification2 = mockk() + val userName1 = "user name 1" + val userName2 = "user name 2" + val callNotificationData1 = provideCallNotificationData(TEST_USER_ID1, TEST_CALL1, userName1) + val callNotificationData2 = provideCallNotificationData(TEST_USER_ID2, TEST_CALL2, userName2) + val tag1 = NotificationConstants.getIncomingCallTag(TEST_USER_ID1.toString()) + val tag2 = NotificationConstants.getIncomingCallTag(TEST_USER_ID2.toString()) + val id1 = NotificationConstants.getIncomingCallId(TEST_USER_ID1.toString(), TEST_CALL1.conversationId.toString()) + val id2 = NotificationConstants.getIncomingCallId(TEST_USER_ID2.toString(), TEST_CALL2.conversationId.toString()) + val (arrangement, callNotificationManager) = Arrangement() + .withIncomingNotificationForUserAndCall(notification1, callNotificationData1) + .withIncomingNotificationForUserAndCall(notification2, callNotificationData2) + .arrange() + arrangement.clearRecordedCallsForNotificationManager() // clear first empty list recorded call + callNotificationManager.handleIncomingCalls(listOf(TEST_CALL1), TEST_USER_ID1, userName1) + callNotificationManager.handleIncomingCalls(listOf(TEST_CALL2), TEST_USER_ID2, userName2) + advanceUntilIdle() + // then + verify(exactly = 1) { arrangement.notificationManager.notify(tag1, id1, notification1) } + verify(exactly = 1) { arrangement.notificationManager.notify(tag2, id2, notification2) } + verify(exactly = 0) { arrangement.notificationManager.cancel(any(), any()) } + } + + @Test + fun `given two incoming calls for the same user, then show notification for the both calls`() = runTest(dispatcherProvider.main()) { // given val notification1 = mockk() val notification2 = mockk() - val callNotificationData1 = provideCallNotificationData(TEST_USER_ID1, TEST_CALL1) - val callNotificationData2 = provideCallNotificationData(TEST_USER_ID2, TEST_CALL2) + val userName1 = "user name 1" + val callNotificationData1 = provideCallNotificationData(TEST_USER_ID1, TEST_CALL1, userName1) + val callNotificationData2 = provideCallNotificationData(TEST_USER_ID1, TEST_CALL2, userName1) + val tag1 = NotificationConstants.getIncomingCallTag(TEST_USER_ID1.toString()) + val tag2 = NotificationConstants.getIncomingCallTag(TEST_USER_ID1.toString()) + val id1 = NotificationConstants.getIncomingCallId(TEST_USER_ID1.toString(), TEST_CALL1.conversationId.toString()) + val id2 = NotificationConstants.getIncomingCallId(TEST_USER_ID1.toString(), TEST_CALL2.conversationId.toString()) val (arrangement, callNotificationManager) = Arrangement() .withIncomingNotificationForUserAndCall(notification1, callNotificationData1) .withIncomingNotificationForUserAndCall(notification2, callNotificationData2) .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) + callNotificationManager.handleIncomingCalls(listOf(TEST_CALL1, TEST_CALL2), TEST_USER_ID1, userName1) advanceUntilIdle() // then - verify(exactly = 1) { arrangement.notificationManager.notify(any(), notification1) } - verify(exactly = 0) { arrangement.notificationManager.notify(any(), notification2) } + verify(exactly = 1) { arrangement.notificationManager.notify(tag1, id1, notification1) } + verify(exactly = 1) { arrangement.notificationManager.notify(tag2, id2, 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`() = + fun `given incoming calls for two users, when one call ends, then do not cancel notification for another call`() = runTest(dispatcherProvider.main()) { // given val notification1 = mockk() val notification2 = mockk() - val callNotificationData1 = provideCallNotificationData(TEST_USER_ID1, TEST_CALL1) - val callNotificationData2 = provideCallNotificationData(TEST_USER_ID2, TEST_CALL2) + val userName1 = "user name 1" + val userName2 = "user name 2" + val callNotificationData1 = provideCallNotificationData(TEST_USER_ID1, TEST_CALL1, userName1) + val callNotificationData2 = provideCallNotificationData(TEST_USER_ID2, TEST_CALL2, userName2) + val tag1 = NotificationConstants.getIncomingCallTag(TEST_USER_ID1.toString()) + val tag2 = NotificationConstants.getIncomingCallTag(TEST_USER_ID2.toString()) + val id1 = NotificationConstants.getIncomingCallId(TEST_USER_ID1.toString(), TEST_CALL1.conversationId.toString()) + val id2 = NotificationConstants.getIncomingCallId(TEST_USER_ID2.toString(), TEST_CALL2.conversationId.toString()) val (arrangement, callNotificationManager) = Arrangement() .withIncomingNotificationForUserAndCall(notification1, callNotificationData1) .withIncomingNotificationForUserAndCall(notification2, callNotificationData2) .arrange() - callNotificationManager.handleIncomingCallNotifications(listOf(TEST_CALL1), TEST_USER_ID1) - callNotificationManager.handleIncomingCallNotifications(listOf(TEST_CALL2), TEST_USER_ID2) + callNotificationManager.handleIncomingCalls(listOf(TEST_CALL1), TEST_USER_ID1, userName1) + callNotificationManager.handleIncomingCalls(listOf(TEST_CALL2), TEST_USER_ID2, userName2) + arrangement.withActiveNotifications(listOf(mockStatusBarNotification(id1, tag1), mockStatusBarNotification(id2, tag2))) advanceUntilIdle() arrangement.clearRecordedCallsForNotificationManager() // clear calls recorded when initializing the state // when - callNotificationManager.handleIncomingCallNotifications(listOf(), TEST_USER_ID1) + callNotificationManager.handleIncomingCalls(listOf(), TEST_USER_ID1, userName1) // first call is ended 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()) } + verify(exactly = 1) { arrangement.notificationManager.cancel(tag1, id1) } + verify(exactly = 0) { arrangement.notificationManager.cancel(tag2, id2) } } @Test - fun `given incoming calls for two users, when both call ends, then hide notification`() = + fun `given incoming calls for two users, when both call ends, then hide all notifications`() = runTest(dispatcherProvider.main()) { // given val notification1 = mockk() val notification2 = mockk() - val callNotificationData1 = provideCallNotificationData(TEST_USER_ID1, TEST_CALL1) - val callNotificationData2 = provideCallNotificationData(TEST_USER_ID2, TEST_CALL2) + val userName1 = "user name 1" + val userName2 = "user name 2" + val callNotificationData1 = provideCallNotificationData(TEST_USER_ID1, TEST_CALL1, userName1) + val callNotificationData2 = provideCallNotificationData(TEST_USER_ID2, TEST_CALL2, userName2) + val tag1 = NotificationConstants.getIncomingCallTag(TEST_USER_ID1.toString()) + val tag2 = NotificationConstants.getIncomingCallTag(TEST_USER_ID2.toString()) + val id1 = NotificationConstants.getIncomingCallId(TEST_USER_ID1.toString(), TEST_CALL1.conversationId.toString()) + val id2 = NotificationConstants.getIncomingCallId(TEST_USER_ID2.toString(), TEST_CALL2.conversationId.toString()) val (arrangement, callNotificationManager) = Arrangement() .withIncomingNotificationForUserAndCall(notification1, callNotificationData1) .withIncomingNotificationForUserAndCall(notification2, callNotificationData2) .arrange() - callNotificationManager.handleIncomingCallNotifications(listOf(TEST_CALL1), TEST_USER_ID1) - callNotificationManager.handleIncomingCallNotifications(listOf(TEST_CALL2), TEST_USER_ID2) + callNotificationManager.handleIncomingCalls(listOf(TEST_CALL1), TEST_USER_ID1, userName1) + callNotificationManager.handleIncomingCalls(listOf(TEST_CALL2), TEST_USER_ID2, userName2) advanceUntilIdle() arrangement.clearRecordedCallsForNotificationManager() // clear calls recorded when initializing the state + arrangement.withActiveNotifications( + listOf( + mockStatusBarNotification(id1, tag1), + mockStatusBarNotification(id2, tag2) + ) + ) + // when - callNotificationManager.handleIncomingCallNotifications(listOf(), TEST_USER_ID1) - callNotificationManager.handleIncomingCallNotifications(listOf(), TEST_USER_ID2) + callNotificationManager.handleIncomingCalls(listOf(), TEST_USER_ID1, userName1) + callNotificationManager.handleIncomingCalls(listOf(), TEST_USER_ID2, userName2) // then - verify(exactly = 0) { arrangement.notificationManager.notify(any(), notification1) } - verify(exactly = 0) { arrangement.notificationManager.notify(any(), notification2) } - verify(exactly = 1) { arrangement.notificationManager.cancel(any()) } + verify(exactly = 0) { arrangement.notificationManager.notify(tag1, id1, notification1) } + verify(exactly = 0) { arrangement.notificationManager.notify(tag2, id2, notification2) } + verify(exactly = 1) { arrangement.notificationManager.cancel(tag1, id1) } + verify(exactly = 1) { arrangement.notificationManager.cancel(tag2, id2) } } @Test @@ -157,36 +313,42 @@ class CallNotificationManagerTest { runTest(dispatcherProvider.main()) { // given val notification = mockk() - val callNotificationData = provideCallNotificationData(TEST_USER_ID1, TEST_CALL1) + val userName = "user name" + val callNotificationData = provideCallNotificationData(TEST_USER_ID1, TEST_CALL1, userName) + val tag = NotificationConstants.getIncomingCallTag(TEST_USER_ID1.toString()) + val id = NotificationConstants.getIncomingCallId(TEST_USER_ID1.toString(), TEST_CALL1.conversationId.toString()) val (arrangement, callNotificationManager) = Arrangement() .withIncomingNotificationForUserAndCall(notification, callNotificationData) .arrange() // when - callNotificationManager.handleIncomingCallNotifications(listOf(TEST_CALL1), TEST_USER_ID1) + callNotificationManager.handleIncomingCalls(listOf(TEST_CALL1), TEST_USER_ID1, userName) advanceTimeBy((DEBOUNCE_TIME - 50).milliseconds) - callNotificationManager.handleIncomingCallNotifications(listOf(), TEST_USER_ID1) + callNotificationManager.handleIncomingCalls(listOf(), TEST_USER_ID1, userName) // then - verify(exactly = 0) { arrangement.notificationManager.notify(any(), notification) } - verify(exactly = 1) { arrangement.notificationManager.cancel(NotificationIds.CALL_INCOMING_NOTIFICATION_ID.ordinal) } + verify(exactly = 0) { arrangement.notificationManager.notify(tag, id, notification) } } @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 userName = "user name" + val callNotificationData = provideCallNotificationData(TEST_USER_ID1, TEST_CALL1, userName) + val tag = NotificationConstants.getIncomingCallTag(TEST_USER_ID1.toString()) + val id = NotificationConstants.getIncomingCallId(TEST_USER_ID1.toString(), TEST_CALL1.conversationId.toString()) val notification = mockk() - val callNotificationData = provideCallNotificationData(TEST_USER_ID1, TEST_CALL1) val (arrangement, callNotificationManager) = Arrangement() .withIncomingNotificationForUserAndCall(notification, callNotificationData) .arrange() arrangement.clearRecordedCallsForNotificationManager() // clear first empty list recorded call // when - callNotificationManager.handleIncomingCallNotifications(listOf(TEST_CALL1), TEST_USER_ID1) + callNotificationManager.handleIncomingCalls(listOf(TEST_CALL1), TEST_USER_ID1, userName) advanceTimeBy((DEBOUNCE_TIME + 50).milliseconds) - callNotificationManager.handleIncomingCallNotifications(listOf(), TEST_USER_ID1) + arrangement.withActiveNotifications(listOf(mockStatusBarNotification(id, tag))) + callNotificationManager.handleIncomingCalls(listOf(), TEST_USER_ID1, userName) // then - verify(exactly = 1) { arrangement.notificationManager.notify(any(), notification) } - verify(exactly = 1) { arrangement.notificationManager.cancel(any()) } + verify(exactly = 1) { arrangement.notificationManager.notify(tag, id, notification) } + verify(exactly = 1) { arrangement.notificationManager.cancel(tag, id) } } private inner class Arrangement { @@ -200,13 +362,11 @@ class CallNotificationManagerTest { @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) + withActiveNotifications(emptyList()) } fun clearRecordedCallsForNotificationManager() { @@ -223,11 +383,12 @@ class CallNotificationManagerTest { fun withIncomingNotificationForUserAndCall(notification: Notification, forCallNotificationData: CallNotificationData) = apply { every { callNotificationBuilder.getIncomingCallNotification(eq(forCallNotificationData)) } returns notification } - fun withOutgoingNotificationForUserAndCall(notification: Notification, forCallNotificationData: CallNotificationData) = apply { - every { callNotificationBuilder.getOutgoingCallNotification(eq(forCallNotificationData)) } returns notification + + fun withActiveNotifications(list: List) = apply { + every { notificationManager.activeNotifications } returns list } - fun arrange() = this to callNotificationManager + fun arrange() = this to CallNotificationManager(context, dispatcherProvider, callNotificationBuilder) } companion object { @@ -256,8 +417,9 @@ class CallNotificationManagerTest { callerTeamName = "team_1" ) - private fun provideCallNotificationData(userId: UserId, call: Call) = CallNotificationData( + private fun provideCallNotificationData(userId: UserId, call: Call, userName: String) = CallNotificationData( userId = userId, + userName = userName, conversationId = call.conversationId, conversationName = call.conversationName, conversationType = call.conversationType, @@ -265,5 +427,12 @@ class CallNotificationManagerTest { callerTeamName = call.callerTeamName, callStatus = call.status ) + + fun mockStatusBarNotification(id: Int, tag: String): StatusBarNotification { + val statusBarNotification = mockk() + every { statusBarNotification.id } returns id + every { statusBarNotification.tag } returns tag + return statusBarNotification + } } } 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 06964715d57..cf0aa3a376e 100644 --- a/app/src/test/kotlin/com/wire/android/notification/WireNotificationManagerTest.kt +++ b/app/src/test/kotlin/com/wire/android/notification/WireNotificationManagerTest.kt @@ -113,7 +113,7 @@ class WireNotificationManagerTest { userName = TestUser.SELF_USER.handle!! ) } - verify(exactly = 0) { arrangement.callNotificationManager.handleIncomingCallNotifications(any(), any()) } + verify(exactly = 0) { arrangement.callNotificationManager.handleIncomingCalls(any(), any(), any()) } } // todo: check later with boris! @@ -142,47 +142,47 @@ class WireNotificationManagerTest { advanceUntilIdle() verify(exactly = 0) { arrangement.coreLogic.getSessionScope(any()) } - verify(exactly = 1) { arrangement.callNotificationManager.hideAllNotifications() } + verify(exactly = 1) { arrangement.callNotificationManager.hideAllIncomingCallNotifications() } } @Test fun givenSomeIncomingCall_whenObserving_thenCallHandleIncomingCallNotifications() = runTestWithCancellation(dispatcherProvider.main()) { - val userId = provideUserId("user1") + val user = TestUser.SELF_USER val incomingCalls = listOf(provideCall()) val (arrangement, manager) = Arrangement() - .withSpecificUserSession(userId = userId, incomingCalls = incomingCalls) + .withSpecificUserSession(userId = user.id, incomingCalls = incomingCalls) .withMessageNotifications(listOf()) .withCurrentScreen(CurrentScreen.SomeOther()) - .withCurrentUserSession(CurrentSessionResult.Success(AccountInfo.Valid(userId))) + .withCurrentUserSession(CurrentSessionResult.Success(AccountInfo.Valid(user.id))) .arrange() - manager.observeNotificationsAndCallsWhileRunning(listOf(userId), this) + manager.observeNotificationsAndCallsWhileRunning(listOf(user.id), this) runCurrent() verify(exactly = 1) { - arrangement.callNotificationManager.handleIncomingCallNotifications(incomingCalls, userId) + arrangement.callNotificationManager.handleIncomingCalls(incomingCalls, user.id, user.handle!!) } } @Test fun givenSomeIncomingCall_whenCurrentUserIsDifferentFromCallReceiver_thenCallHandleIncomingCallNotifications() = runTestWithCancellation(dispatcherProvider.main()) { - val user1 = provideUserId("user1") - val user2 = provideUserId("user2") + val user1 = TestUser.SELF_USER.copy(id = provideUserId("user1")) + val user2 = TestUser.SELF_USER.copy(id = provideUserId("user2")) val incomingCalls = listOf(provideCall()) val (arrangement, manager) = Arrangement() - .withSpecificUserSession(userId = user1, incomingCalls = listOf()) - .withSpecificUserSession(userId = user2, incomingCalls = incomingCalls) + .withSpecificUserSession(userId = user1.id, incomingCalls = listOf()) + .withSpecificUserSession(userId = user2.id, incomingCalls = incomingCalls) .withMessageNotifications(listOf()) .withCurrentScreen(CurrentScreen.SomeOther()) - .withCurrentUserSession(CurrentSessionResult.Success(provideAccountInfo(user1.value))) + .withCurrentUserSession(CurrentSessionResult.Success(provideAccountInfo(user1.id.value))) .arrange() - manager.observeNotificationsAndCallsWhileRunning(listOf(user1, user2), this) + manager.observeNotificationsAndCallsWhileRunning(listOf(user1.id, user2.id), this) runCurrent() verify(exactly = 1) { - arrangement.callNotificationManager.handleIncomingCallNotifications(incomingCalls, user2) + arrangement.callNotificationManager.handleIncomingCalls(incomingCalls, user2.id, user2.handle!!) } } @@ -209,7 +209,7 @@ class WireNotificationManagerTest { newNotifications = any(), userId = any(), userName = TestUser.SELF_USER.handle!! ) } - verify(exactly = 1) { arrangement.callNotificationManager.hideAllNotifications() } + verify(exactly = 1) { arrangement.callNotificationManager.hideAllIncomingCallNotifications() } } @Test @@ -236,7 +236,7 @@ class WireNotificationManagerTest { any(), any(), TestUser.SELF_USER.handle!! ) } - verify(exactly = 1) { arrangement.callNotificationManager.hideAllNotifications() } + verify(exactly = 1) { arrangement.callNotificationManager.hideAllIncomingCallNotifications() } } @Test @@ -1131,7 +1131,7 @@ class WireNotificationManagerTest { coEvery { callsScope.getIncomingCalls } returns getIncomingCallsUseCase coEvery { callsScope.establishedCall } returns establishedCall coEvery { callsScope.observeOutgoingCall } returns observeOutgoingCall - coEvery { callNotificationManager.handleIncomingCallNotifications(any(), any()) } returns Unit + coEvery { callNotificationManager.handleIncomingCalls(any(), any(), any()) } returns Unit coEvery { callNotificationManager.builder.getNotificationTitle(any()) } returns "Test title" coEvery { messageScope.getNotifications } returns getNotificationsUseCase coEvery { messageScope.markMessagesAsNotified } returns markMessagesAsNotified diff --git a/app/src/test/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModelTest.kt index 779213b8078..8353e285632 100644 --- a/app/src/test/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/common/topappbar/CommonTopAppBarViewModelTest.kt @@ -49,6 +49,7 @@ import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBe import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeInstanceOf +import org.amshove.kluent.shouldHaveSize import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @@ -110,9 +111,11 @@ class CommonTopAppBarViewModelTest { val state = commonTopAppBarViewModel.state val info = state.connectivityState - info shouldBeInstanceOf ConnectivityUIState.EstablishedCall::class - info as ConnectivityUIState.EstablishedCall - info.conversationId shouldBeEqualTo ongoingCall.conversationId + info.shouldBeInstanceOf().let { + it.calls.shouldHaveSize(1) + it.calls[0].shouldBeInstanceOf() + .conversationId shouldBeEqualTo ongoingCall.conversationId + } } @Test @@ -130,9 +133,11 @@ class CommonTopAppBarViewModelTest { val state = commonTopAppBarViewModel.state val info = state.connectivityState - info shouldBeInstanceOf ConnectivityUIState.EstablishedCall::class - info as ConnectivityUIState.EstablishedCall - info.isMuted shouldBe true + info.shouldBeInstanceOf().let { + it.calls.shouldHaveSize(1) + it.calls[0].shouldBeInstanceOf() + .isMuted shouldBe true + } } @Test @@ -151,9 +156,11 @@ class CommonTopAppBarViewModelTest { val state = commonTopAppBarViewModel.state val info = state.connectivityState - info shouldBeInstanceOf ConnectivityUIState.EstablishedCall::class - info as ConnectivityUIState.EstablishedCall - info.isMuted shouldBe false + info.shouldBeInstanceOf().let { + it.calls.shouldHaveSize(1) + it.calls[0].shouldBeInstanceOf() + .isMuted shouldBe false + } } @Test @@ -172,7 +179,10 @@ class CommonTopAppBarViewModelTest { val state = commonTopAppBarViewModel.state val info = state.connectivityState - info shouldBeInstanceOf ConnectivityUIState.EstablishedCall::class + info.shouldBeInstanceOf().let { + it.calls.shouldHaveSize(1) + it.calls[0].shouldBeInstanceOf() + } } @Test @@ -190,7 +200,10 @@ class CommonTopAppBarViewModelTest { val state = commonTopAppBarViewModel.state val info = state.connectivityState - info shouldBeInstanceOf ConnectivityUIState.IncomingCall::class + info.shouldBeInstanceOf().let { + it.calls.shouldHaveSize(1) + it.calls[0].shouldBeInstanceOf() + } } @Test @@ -208,7 +221,10 @@ class CommonTopAppBarViewModelTest { val state = commonTopAppBarViewModel.state val info = state.connectivityState - info shouldBeInstanceOf ConnectivityUIState.OutgoingCall::class + info.shouldBeInstanceOf().let { + it.calls.shouldHaveSize(1) + it.calls[0].shouldBeInstanceOf() + } } @Test @@ -224,20 +240,20 @@ class CommonTopAppBarViewModelTest { } @Test - fun givenEstablishedAndIncomingCall_whenActiveCallFlowIsCalled_thenEmitEstablishedCallOnly() = runTest { + fun givenEstablishedAndIncomingCall_whenActiveCallFlowsIsCalled_thenEmitBoth() = runTest { val (_, commonTopAppBarViewModel) = Arrangement() .withCurrentSessionExist() .withOngoingCall(isMuted = true) .withIncomingCall() - .withOutgoingCall() + .withoutOutgoingCall() .withCurrentScreen(CurrentScreen.Home) .withSyncState(SyncState.Waiting) .arrange() - val flow = commonTopAppBarViewModel.activeCallFlow(userId) + val flow = commonTopAppBarViewModel.activeCallsFlow(userId) flow.collect { - it shouldBeEqualTo ongoingCall + it shouldBeEqualTo listOf(ongoingCall, incomingCall) } }