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 fae2471ac08..f9877a1fa6d 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 @@ -107,9 +107,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/textfield/InputTransformations.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/InputTransformations.kt index 08c1e02c4da..e6c31c049ca 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/InputTransformations.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/InputTransformations.kt @@ -90,7 +90,11 @@ fun InputTransformation.forceLowercase(): InputTransformation = class ForceLowercaseTransformation : InputTransformation { override fun TextFieldBuffer.transformInput() { - replace(0, length, asCharSequence().toString().lowercase()) + val currentText = asCharSequence().toString() + val lowercasedText = currentText.lowercase() + if (currentText != lowercasedText) { + replace(0, length, lowercasedText) + } } } 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 2a40d0e0305..c22b5c60996 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,7 +30,7 @@ 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 @@ -64,9 +64,9 @@ import com.wire.kalium.network.NetworkState 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) { @@ -84,9 +84,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 is ConnectivityUIState.WaitingConnection, ConnectivityUIState.Connecting -> MaterialTheme.wireColorScheme.primary @@ -100,9 +98,9 @@ private fun ConnectivityStatusBar( themeOption: ThemeOption, connectivityInfo: ConnectivityUIState, networkState: NetworkState, - 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) @@ -124,54 +122,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( @@ -237,34 +209,102 @@ private fun WaitingStatusLabelInternal( } @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 ) } @@ -272,25 +312,26 @@ private fun OutgoingCallContent(conversationName: String?) { @Composable private fun StatusLabel( - stringResource: Int, + string: String, color: Color = MaterialTheme.wireColorScheme.onPrimary ) { - StatusLabel( - string = stringResource(id = stringResource), + Text( + text = string.uppercase(), color = color, + style = MaterialTheme.wireTypography.title03, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = MaterialTheme.wireDimensions.spacing6x) ) } @Composable private fun StatusLabel( - string: String, + stringResource: Int, color: Color = MaterialTheme.wireColorScheme.onPrimary ) { - Text( - text = string.uppercase(), + StatusLabel( + string = stringResource(id = stringResource), color = color, - style = MaterialTheme.wireTypography.title03, - textAlign = TextAlign.Center, ) } @@ -305,6 +346,7 @@ private fun StatusLabelWithValue( text = stringResource(id = stringResource, callerName ?: defaultCallerName).uppercase(), color = color, style = MaterialTheme.wireTypography.title03, + modifier = Modifier.padding(vertical = MaterialTheme.wireDimensions.spacing6x) ) } @@ -351,44 +393,36 @@ 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_ConnectivityCallNotMuted() = +fun PreviewCommonTopAppBar_ConnectivityEstablishedCallNotMuted() = PreviewCommonTopAppBar( - ConnectivityUIState.EstablishedCall( - ConversationId("what", "ever"), - false - ) + ConnectivityUIState.Calls(listOf(ConnectivityUIState.Call.Established(ConversationId("what", "ever"), false))) ) @PreviewMultipleThemes @Composable -fun PreviewCommonTopAppBar_ConnectivityConnecting() = - PreviewCommonTopAppBar(ConnectivityUIState.Connecting) - -@PreviewMultipleThemes -@Composable -fun PreviewCommonTopAppBar_ConnectivityWaitingConnection() = - PreviewCommonTopAppBar(ConnectivityUIState.WaitingConnection(null, null)) - -@PreviewMultipleThemes -@Composable -fun PreviewCommonTopAppBar_ConnectivityNone() = - PreviewCommonTopAppBar(ConnectivityUIState.None) +fun PreviewCommonTopAppBar_ConnectivityEstablishedCallAndIncomingCalls() = + PreviewCommonTopAppBar( + 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_ConnectivityIncomingCall() = PreviewCommonTopAppBar( - ConnectivityUIState.IncomingCall( - ConversationId("what", "ever"), - "callerName" + ConnectivityUIState.Calls( + listOf(ConnectivityUIState.Call.Incoming(ConversationId("2", "2"), "John Doe")) ) ) @@ -396,8 +430,22 @@ fun PreviewCommonTopAppBar_ConnectivityIncomingCall() = @Composable fun PreviewCommonTopAppBar_ConnectivityOutgoingCall() = PreviewCommonTopAppBar( - ConnectivityUIState.OutgoingCall( - ConversationId("what", "ever"), - "conversationName" + ConnectivityUIState.Calls( + listOf(ConnectivityUIState.Call.Outgoing(ConversationId("2", "2"), "John Doe")) ) ) + +@PreviewMultipleThemes +@Composable +fun PreviewCommonTopAppBar_ConnectivityConnecting() = + PreviewCommonTopAppBar(ConnectivityUIState.Connecting) + +@PreviewMultipleThemes +@Composable +fun PreviewCommonTopAppBar_ConnectivityWaitingConnection() = + PreviewCommonTopAppBar(ConnectivityUIState.WaitingConnection(null, null)) + +@PreviewMultipleThemes +@Composable +fun PreviewCommonTopAppBar_ConnectivityNone() = + 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 d311d288704..0e58a698ce1 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 @@ -70,7 +70,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(), @@ -78,8 +78,6 @@ class CommonTopAppBarViewModel @Inject constructor( calls.observeOutgoingCall(), ) { establishedCall, incomingCalls, outgoingCalls -> establishedCall + incomingCalls + outgoingCalls - }.map { calls -> - calls.firstOrNull() }.distinctUntilChanged() } @@ -96,11 +94,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) } } } @@ -112,7 +110,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) @@ -127,34 +125,24 @@ 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 when (activeCall.status) { - CallStatus.INCOMING -> { - ConnectivityUIState.IncomingCall( - activeCall.conversationId, - activeCall.callerName - ) - } - - 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 0730053e6d3..162ee0d71a0 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 @@ -31,18 +31,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/ui/home/settings/backup/BackupAndRestoreViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreViewModel.kt index c597f60e929..357a47fb1d5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreViewModel.kt @@ -212,7 +212,7 @@ class BackupAndRestoreViewModel restoreFileValidation = RestoreFileValidation.ValidNonEncryptedBackup, backupRestoreProgress = BackupRestoreProgress.InProgress(PROGRESS_75) ) - when (importBackup(importedBackupPath, null)) { + when (val result = importBackup(importedBackupPath, null)) { RestoreBackupResult.Success -> { updateCreationProgress(PROGRESS_75) delay(SMALL_DELAY) @@ -221,10 +221,7 @@ class BackupAndRestoreViewModel } is RestoreBackupResult.Failure -> { - appLogger.e( - "Error when restoring the db file. The format or version of the backup is not compatible with this " + - "version of the app" - ) + appLogger.e("Error when restoring the backup db file caused by: ${result.failure.cause}") state = state.copy( restoreFileValidation = RestoreFileValidation.IncompatibleBackup, backupRestoreProgress = BackupRestoreProgress.Failed diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPicker.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPicker.kt index edcaa666a62..2b87fbc633e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPicker.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPicker.kt @@ -18,8 +18,6 @@ package com.wire.android.ui.userprofile.avatarpicker -import android.content.Context -import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -33,10 +31,8 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.core.net.toUri @@ -65,13 +61,6 @@ import com.wire.android.ui.common.visbility.rememberVisibilityState import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.userprofile.avatarpicker.AvatarPickerViewModel.PictureState -import com.wire.android.util.ImageUtil -import com.wire.android.util.resampleImageAndCopyToTempPath -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okio.Path @RootNavGraph @WireDestination @@ -84,18 +73,15 @@ fun AvatarPickerScreen( val permissionPermanentlyDeniedDialogState = rememberVisibilityState() - val context = LocalContext.current - val targetAvatarPath = viewModel.defaultAvatarPath val targetAvatarUri = viewModel.temporaryAvatarUri - val scope = rememberCoroutineScope() val state = rememberAvatarPickerState( onImageSelected = { originalUri -> - onNewAvatarPicked(originalUri, targetAvatarPath, scope, context, viewModel) + viewModel.updatePickedAvatarUri(originalUri, targetAvatarPath.toFile().toUri()) }, onPictureTaken = { - onNewAvatarPicked(targetAvatarUri, targetAvatarPath, scope, context, viewModel) + viewModel.updatePickedAvatarUri(targetAvatarUri, targetAvatarPath.toFile().toUri()) }, targetPictureFileUri = targetAvatarUri, onGalleryPermissionPermanentlyDenied = { @@ -141,19 +127,6 @@ fun AvatarPickerScreen( ) } -// TODO: Mateusz: I think we should refactor this, it takes some values from the ViewModel, part of the logic is executed inside -// the UI, part of the logic is exectued inside the ViewModel, I see no reasons to handle the logic inside the UI -// personally it was a confusing part for me to read when investing the bugs, unless there is a valid reason to move the logic to the UI -// that I am not aware of ? -fun onNewAvatarPicked(originalUri: Uri, targetAvatarPath: Path, scope: CoroutineScope, context: Context, viewModel: AvatarPickerViewModel) { - scope.launch { - sanitizeAvatarImage(originalUri, targetAvatarPath, context) - withContext(Dispatchers.Main) { - viewModel.updatePickedAvatarUri(targetAvatarPath.toFile().toUri()) - } - } -} - @Composable private fun AvatarPickerContent( pictureState: PictureState, @@ -287,7 +260,3 @@ private fun AvatarPickerTopBar(onCloseClick: () -> Unit) { title = stringResource(R.string.profile_image_top_bar_label), ) } - -private suspend fun sanitizeAvatarImage(originalAvatarUri: Uri, avatarPath: Path, appContext: Context) { - originalAvatarUri.resampleImageAndCopyToTempPath(appContext, avatarPath, ImageUtil.ImageSizeClass.Small) -} diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerViewModel.kt index 1a73eab11c6..c1a0a26bb89 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/avatarpicker/AvatarPickerViewModel.kt @@ -32,7 +32,9 @@ import com.wire.android.appLogger import com.wire.android.datastore.UserDataStore import com.wire.android.model.SnackBarMessage import com.wire.android.util.AvatarImageManager +import com.wire.android.util.ImageUtil import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.android.util.resampleImageAndCopyToTempPath import com.wire.android.util.toByteArray import com.wire.android.util.ui.UIText import com.wire.kalium.logic.NetworkFailure @@ -104,10 +106,24 @@ class AvatarPickerViewModel @Inject constructor( } } - fun updatePickedAvatarUri(updatedUri: Uri) = viewModelScope.launch(dispatchers.main()) { + fun updatePickedAvatarUri(originalUri: Uri, updatedUri: Uri) = viewModelScope.launch { + sanitizeAvatarImage(originalUri, defaultAvatarPath) pictureState = PictureState.Picked(updatedUri) } + /** + * Resamples the image and removes unnecessary metadata before uploading it. + * This to avoid uploading unnecessarily large images for profile pictures and sensitive metadata. + */ + private suspend fun sanitizeAvatarImage(originalAvatarUri: Uri, avatarPath: Path) { + originalAvatarUri.resampleImageAndCopyToTempPath( + context = appContext, + tempCachePath = avatarPath, + sizeClass = ImageUtil.ImageSizeClass.Small, + shouldRemoveMetadata = true + ) + } + fun uploadNewPickedAvatar(onComplete: (avatarAssetId: String?) -> Unit) { val imgUri = pictureState.avatarUri diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModel.kt index 99d9ec2889d..85f41060d40 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModel.kt @@ -23,11 +23,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.wire.android.BuildConfig import com.wire.android.appLogger import com.wire.android.datastore.GlobalDataStore import com.wire.android.datastore.UserDataStore -import com.wire.android.di.AuthServerConfigProvider import com.wire.android.di.CurrentAccount import com.wire.android.feature.AccountSwitchUseCase import com.wire.android.feature.SwitchAccountActions @@ -42,7 +40,6 @@ import com.wire.android.ui.legalhold.banner.LegalHoldUIState import com.wire.android.ui.userprofile.self.dialog.StatusDialogData import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.android.util.ui.WireSessionImageLoader -import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.id.QualifiedIdMapper import com.wire.kalium.logic.data.id.toQualifiedID @@ -61,7 +58,6 @@ import com.wire.kalium.logic.feature.team.GetUpdatedSelfTeamUseCase import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import com.wire.kalium.logic.feature.user.IsReadOnlyAccountUseCase import com.wire.kalium.logic.feature.user.ObserveValidAccountsUseCase -import com.wire.kalium.logic.feature.user.SelfServerConfigUseCase import com.wire.kalium.logic.feature.user.UpdateSelfAvailabilityStatusUseCase import com.wire.kalium.logic.functional.getOrNull import dagger.hilt.android.lifecycle.HiltViewModel @@ -94,8 +90,6 @@ class SelfUserProfileViewModel @Inject constructor( private val observeLegalHoldStatusForSelfUser: ObserveLegalHoldStateForSelfUserUseCase, private val dispatchers: DispatcherProvider, private val wireSessionImageLoader: WireSessionImageLoader, - private val authServerConfigProvider: AuthServerConfigProvider, - private val selfServerLinks: SelfServerConfigUseCase, private val otherAccountMapper: OtherAccountMapper, private val observeEstablishedCalls: ObserveEstablishedCallsUseCase, private val accountSwitch: AccountSwitchUseCase, @@ -266,27 +260,6 @@ class SelfUserProfileViewModel @Inject constructor( } } - // todo. cleanup unused code - fun tryToInitAddingAccount(onSucceeded: () -> Unit) { - viewModelScope.launch { - // the total number of accounts is otherAccounts + 1 for the current account - val canAddNewAccounts: Boolean = (userProfileState.otherAccounts.size + 1) < BuildConfig.MAX_ACCOUNTS - - if (!canAddNewAccounts) { - userProfileState = userProfileState.copy(maxAccountsReached = true) - return@launch - } - - val selfServerLinks: ServerConfig.Links = - when (val result = selfServerLinks()) { - is SelfServerConfigUseCase.Result.Failure -> return@launch - is SelfServerConfigUseCase.Result.Success -> result.serverLinks.links - } - authServerConfigProvider.updateAuthServer(selfServerLinks) - onSucceeded() - } - } - fun dismissStatusDialog() { userProfileState = userProfileState.copy(statusDialogData = null) } @@ -321,11 +294,6 @@ class SelfUserProfileViewModel @Inject constructor( } } - // todo. cleanup unused code - fun onMaxAccountReachedDialogDismissed() { - userProfileState = userProfileState.copy(maxAccountsReached = false) - } - private fun setNotShowStatusRationaleAgainIfNeeded(status: UserAvailabilityStatus) { userProfileState.statusDialogData.let { dialogState -> if (dialogState?.isCheckBoxChecked == true) { diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationState.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationState.kt index c91a99f479c..561ad8f57bd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationState.kt @@ -21,6 +21,5 @@ import androidx.compose.foundation.text.input.TextFieldState data class TeamMigrationState( val teamNameTextState: TextFieldState = TextFieldState(), - val passwordTextState: TextFieldState = TextFieldState(), val shouldShowMigrationLeaveDialog: Boolean = false ) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step3/TeamMigrationConfirmationStepScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step3/TeamMigrationConfirmationStepScreen.kt index cede3cf1474..69fc9ca95e2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step3/TeamMigrationConfirmationStepScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step3/TeamMigrationConfirmationStepScreen.kt @@ -24,9 +24,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -37,8 +34,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle @@ -52,15 +47,13 @@ import com.wire.android.navigation.style.SlideNavigationAnimation import com.wire.android.ui.common.WireCheckbox import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions -import com.wire.android.ui.common.textfield.DefaultPassword -import com.wire.android.ui.common.textfield.WirePasswordTextField import com.wire.android.ui.destinations.TeamMigrationDoneStepScreenDestination import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography -import com.wire.android.ui.userprofile.teammigration.common.BottomLineButtons -import com.wire.android.ui.userprofile.teammigration.common.BulletList import com.wire.android.ui.userprofile.teammigration.PersonalToTeamMigrationNavGraph import com.wire.android.ui.userprofile.teammigration.TeamMigrationViewModel +import com.wire.android.ui.userprofile.teammigration.common.BottomLineButtons +import com.wire.android.ui.userprofile.teammigration.common.BulletList import com.wire.android.util.CustomTabsHelper import com.wire.android.util.ui.PreviewMultipleThemes @@ -81,8 +74,7 @@ fun TeamMigrationConfirmationStepScreen( }, onBackPressed = { navigator.popBackStack() - }, - passwordTextState = teamMigrationViewModel.teamMigrationState.passwordTextState + } ) LaunchedEffect(Unit) { teamMigrationViewModel.sendPersonalTeamCreationFlowStartedEvent(3) @@ -91,7 +83,6 @@ fun TeamMigrationConfirmationStepScreen( @Composable private fun TeamMigrationConfirmationStepScreenContent( - passwordTextState: TextFieldState, modifier: Modifier = Modifier, onContinueButtonClicked: () -> Unit = { }, onBackPressed: () -> Unit = { } @@ -144,16 +135,9 @@ private fun TeamMigrationConfirmationStepScreenContent( ) BulletList(messages) - PasswordInput( - modifier = Modifier - .fillMaxWidth() - .padding( - top = dimensions().spacing56x, - bottom = dimensions().spacing56x - ), - passwordState = passwordTextState, - ) - Row { + Row( + modifier = Modifier.padding(top = dimensions().spacing48x) + ) { WireCheckbox( checked = agreedToMigrationTerms.value, onCheckedChange = { agreedToMigrationTerms.value = it } @@ -173,8 +157,7 @@ private fun TeamMigrationConfirmationStepScreenContent( WireTermsOfUseWithLink() } } - val isContinueButtonEnabled = - passwordTextState.text.isNotEmpty() && agreedToMigrationTerms.value && acceptedWireTermsOfUse.value + val isContinueButtonEnabled = agreedToMigrationTerms.value && acceptedWireTermsOfUse.value BottomLineButtons( isContinueButtonEnabled = isContinueButtonEnabled, onContinue = onContinueButtonClicked, @@ -211,33 +194,10 @@ private fun RowScope.WireTermsOfUseWithLink() { ) } -@Composable -private fun PasswordInput( - passwordState: TextFieldState, - modifier: Modifier = Modifier -) { - val keyboardController = LocalSoftwareKeyboardController.current - - WirePasswordTextField( - textState = passwordState, - labelText = stringResource(R.string.personal_to_team_migration_confirmation_step_password_field_label), - keyboardOptions = KeyboardOptions.DefaultPassword, - placeholderText = stringResource(R.string.personal_to_team_migration_confirmation_step_password_field_placeholder), - onKeyboardAction = { - keyboardController?.hide() - }, - modifier = modifier - .testTag("passwordFieldTeamMigration"), - testTag = "passwordFieldTeamMigration" - ) -} - @PreviewMultipleThemes @Composable private fun TeamMigrationConfirmationStepPreview() { WireTheme { - TeamMigrationConfirmationStepScreenContent( - passwordTextState = rememberTextFieldState() - ) + TeamMigrationConfirmationStepScreenContent() } } diff --git a/app/src/main/kotlin/com/wire/android/util/ExifHandler.kt b/app/src/main/kotlin/com/wire/android/util/ExifHandler.kt new file mode 100644 index 00000000000..eaf8c4b05b5 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/ExifHandler.kt @@ -0,0 +1,218 @@ +/* + * 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 android.graphics.Bitmap +import android.graphics.Matrix +import androidx.exifinterface.media.ExifInterface +import com.wire.android.appLogger + +/** + * Used to remove unnecessary metadata from the image. + */ +fun Bitmap.removeExifMetadata(exif: ExifInterface): Bitmap { + removableExifAttributes.forEach { + exif.setAttribute(it, null) + } + return this +} + +/** + * Rotates the image to its [ExifInterface.ORIENTATION_NORMAL] in case it's rotated with a different orientation than + * landscape or portrait See more about exif interface at: + * https://developer.android.com/reference/androidx/exifinterface/media/ExifInterface + * + * @param exif Exif interface for of the image to rotate + * @return Bitmap the rotated bitmap or the same in case there is no rotation performed + */ +@Suppress("MagicNumber", "TooGenericExceptionCaught") +fun Bitmap.rotateImageToNormalOrientation(exif: ExifInterface?): Bitmap { + val orientation = exif?.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) + else -> return this + } + + return try { + val rotated = Bitmap.createBitmap(this, 0, 0, this.width, this.height, matrix, true) + this.recycle() + rotated + } catch (exception: Exception) { + appLogger.withTextTag("ExifHandler").w("Failed to rotate image to normal orientation", exception) + this + } +} + +private val removableExifAttributes = arrayOf( + // These 3, we don't remove as "might be" reused when resampling. + // ExifInterface.TAG_ORIENTATION + // ExifInterface.TAG_IMAGE_WIDTH, + // ExifInterface.TAG_IMAGE_LENGTH, + ExifInterface.TAG_BITS_PER_SAMPLE, + ExifInterface.TAG_COMPRESSION, + ExifInterface.TAG_PHOTOMETRIC_INTERPRETATION, + ExifInterface.TAG_SAMPLES_PER_PIXEL, + ExifInterface.TAG_PLANAR_CONFIGURATION, + ExifInterface.TAG_Y_CB_CR_SUB_SAMPLING, + ExifInterface.TAG_Y_CB_CR_POSITIONING, + ExifInterface.TAG_X_RESOLUTION, + ExifInterface.TAG_Y_RESOLUTION, + ExifInterface.TAG_RESOLUTION_UNIT, + ExifInterface.TAG_STRIP_OFFSETS, + ExifInterface.TAG_ROWS_PER_STRIP, + ExifInterface.TAG_STRIP_BYTE_COUNTS, + ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT, + ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH, + ExifInterface.TAG_TRANSFER_FUNCTION, + ExifInterface.TAG_WHITE_POINT, + ExifInterface.TAG_PRIMARY_CHROMATICITIES, + ExifInterface.TAG_Y_CB_CR_COEFFICIENTS, + ExifInterface.TAG_REFERENCE_BLACK_WHITE, + ExifInterface.TAG_DATETIME, + ExifInterface.TAG_IMAGE_DESCRIPTION, + ExifInterface.TAG_MAKE, + ExifInterface.TAG_MODEL, + ExifInterface.TAG_SOFTWARE, + ExifInterface.TAG_ARTIST, + ExifInterface.TAG_COPYRIGHT, + ExifInterface.TAG_EXIF_VERSION, + ExifInterface.TAG_FLASHPIX_VERSION, + ExifInterface.TAG_COLOR_SPACE, + ExifInterface.TAG_GAMMA, + ExifInterface.TAG_PIXEL_X_DIMENSION, + ExifInterface.TAG_PIXEL_Y_DIMENSION, + ExifInterface.TAG_COMPONENTS_CONFIGURATION, + ExifInterface.TAG_COMPRESSED_BITS_PER_PIXEL, + ExifInterface.TAG_MAKER_NOTE, + ExifInterface.TAG_USER_COMMENT, + ExifInterface.TAG_RELATED_SOUND_FILE, + ExifInterface.TAG_DATETIME_ORIGINAL, + ExifInterface.TAG_DATETIME_DIGITIZED, + ExifInterface.TAG_OFFSET_TIME, + ExifInterface.TAG_OFFSET_TIME_ORIGINAL, + ExifInterface.TAG_OFFSET_TIME_DIGITIZED, + ExifInterface.TAG_SUBSEC_TIME, + ExifInterface.TAG_SUBSEC_TIME_ORIGINAL, + ExifInterface.TAG_SUBSEC_TIME_DIGITIZED, + ExifInterface.TAG_EXPOSURE_TIME, + ExifInterface.TAG_F_NUMBER, + ExifInterface.TAG_EXPOSURE_PROGRAM, + ExifInterface.TAG_SPECTRAL_SENSITIVITY, + ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY, + ExifInterface.TAG_OECF, + ExifInterface.TAG_SENSITIVITY_TYPE, + ExifInterface.TAG_STANDARD_OUTPUT_SENSITIVITY, + ExifInterface.TAG_RECOMMENDED_EXPOSURE_INDEX, + ExifInterface.TAG_ISO_SPEED, + ExifInterface.TAG_ISO_SPEED_LATITUDE_YYY, + ExifInterface.TAG_ISO_SPEED_LATITUDE_ZZZ, + ExifInterface.TAG_SHUTTER_SPEED_VALUE, + ExifInterface.TAG_APERTURE_VALUE, + ExifInterface.TAG_BRIGHTNESS_VALUE, + ExifInterface.TAG_EXPOSURE_BIAS_VALUE, + ExifInterface.TAG_MAX_APERTURE_VALUE, + ExifInterface.TAG_SUBJECT_DISTANCE, + ExifInterface.TAG_METERING_MODE, + ExifInterface.TAG_LIGHT_SOURCE, + ExifInterface.TAG_FLASH, + ExifInterface.TAG_SUBJECT_AREA, + ExifInterface.TAG_FOCAL_LENGTH, + ExifInterface.TAG_FLASH_ENERGY, + ExifInterface.TAG_SPATIAL_FREQUENCY_RESPONSE, + ExifInterface.TAG_FOCAL_PLANE_X_RESOLUTION, + ExifInterface.TAG_FOCAL_PLANE_Y_RESOLUTION, + ExifInterface.TAG_FOCAL_PLANE_RESOLUTION_UNIT, + ExifInterface.TAG_SUBJECT_LOCATION, + ExifInterface.TAG_EXPOSURE_INDEX, + ExifInterface.TAG_SENSING_METHOD, + ExifInterface.TAG_FILE_SOURCE, + ExifInterface.TAG_SCENE_TYPE, + ExifInterface.TAG_CFA_PATTERN, + ExifInterface.TAG_CUSTOM_RENDERED, + ExifInterface.TAG_EXPOSURE_MODE, + ExifInterface.TAG_WHITE_BALANCE, + ExifInterface.TAG_DIGITAL_ZOOM_RATIO, + ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM, + ExifInterface.TAG_SCENE_CAPTURE_TYPE, + ExifInterface.TAG_GAIN_CONTROL, + ExifInterface.TAG_CONTRAST, + ExifInterface.TAG_SATURATION, + ExifInterface.TAG_SHARPNESS, + ExifInterface.TAG_DEVICE_SETTING_DESCRIPTION, + ExifInterface.TAG_SUBJECT_DISTANCE_RANGE, + ExifInterface.TAG_IMAGE_UNIQUE_ID, + ExifInterface.TAG_CAMERA_OWNER_NAME, + ExifInterface.TAG_BODY_SERIAL_NUMBER, + ExifInterface.TAG_LENS_SPECIFICATION, + ExifInterface.TAG_LENS_MAKE, + ExifInterface.TAG_LENS_MODEL, + ExifInterface.TAG_LENS_SERIAL_NUMBER, + ExifInterface.TAG_GPS_VERSION_ID, + ExifInterface.TAG_GPS_LATITUDE_REF, + ExifInterface.TAG_GPS_LATITUDE, + ExifInterface.TAG_GPS_LONGITUDE_REF, + ExifInterface.TAG_GPS_LONGITUDE, + ExifInterface.TAG_GPS_ALTITUDE_REF, + ExifInterface.TAG_GPS_ALTITUDE, + ExifInterface.TAG_GPS_TIMESTAMP, + ExifInterface.TAG_GPS_SATELLITES, + ExifInterface.TAG_GPS_STATUS, + ExifInterface.TAG_GPS_MEASURE_MODE, + ExifInterface.TAG_GPS_DOP, + ExifInterface.TAG_GPS_SPEED_REF, + ExifInterface.TAG_GPS_SPEED, + ExifInterface.TAG_GPS_TRACK_REF, + ExifInterface.TAG_GPS_TRACK, + ExifInterface.TAG_GPS_IMG_DIRECTION_REF, + ExifInterface.TAG_GPS_IMG_DIRECTION, + ExifInterface.TAG_GPS_MAP_DATUM, + ExifInterface.TAG_GPS_DEST_LATITUDE_REF, + ExifInterface.TAG_GPS_DEST_LATITUDE, + ExifInterface.TAG_GPS_DEST_LONGITUDE_REF, + ExifInterface.TAG_GPS_DEST_LONGITUDE, + ExifInterface.TAG_GPS_DEST_BEARING_REF, + ExifInterface.TAG_GPS_DEST_BEARING, + ExifInterface.TAG_GPS_DEST_DISTANCE_REF, + ExifInterface.TAG_GPS_DEST_DISTANCE, + ExifInterface.TAG_GPS_PROCESSING_METHOD, + ExifInterface.TAG_GPS_AREA_INFORMATION, + ExifInterface.TAG_GPS_DATESTAMP, + ExifInterface.TAG_GPS_DIFFERENTIAL, + ExifInterface.TAG_GPS_H_POSITIONING_ERROR, + ExifInterface.TAG_INTEROPERABILITY_INDEX, + ExifInterface.TAG_THUMBNAIL_IMAGE_LENGTH, + ExifInterface.TAG_THUMBNAIL_IMAGE_WIDTH, + ExifInterface.TAG_DNG_VERSION, + ExifInterface.TAG_DEFAULT_CROP_SIZE, + ExifInterface.TAG_ORF_THUMBNAIL_IMAGE, + ExifInterface.TAG_ORF_PREVIEW_IMAGE_START, + ExifInterface.TAG_ORF_PREVIEW_IMAGE_LENGTH, + ExifInterface.TAG_ORF_ASPECT_FRAME, + ExifInterface.TAG_RW2_SENSOR_BOTTOM_BORDER, + ExifInterface.TAG_RW2_SENSOR_LEFT_BORDER, + ExifInterface.TAG_RW2_SENSOR_RIGHT_BORDER, + ExifInterface.TAG_RW2_SENSOR_TOP_BORDER, + ExifInterface.TAG_RW2_ISO, + ExifInterface.TAG_RW2_JPG_FROM_RAW, + ExifInterface.TAG_XMP, + ExifInterface.TAG_NEW_SUBFILE_TYPE, + ExifInterface.TAG_SUBFILE_TYPE, +) diff --git a/app/src/main/kotlin/com/wire/android/util/FileUtil.kt b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt index 0eb6c8c44fa..64cc1321f3d 100644 --- a/app/src/main/kotlin/com/wire/android/util/FileUtil.kt +++ b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt @@ -195,10 +195,20 @@ fun Uri.getMimeType(context: Context): String? { return mimeType } +/** + * Resamples the images if needed and copies them to the temp path [tempCachePath] + * If desired, the metadata can be removed from the image according to [shouldRemoveMetadata] + * + * @param context the context + * @param tempCachePath the path where the image will be copied + * @param sizeClass the desired size class of the image [ImageSizeClass] + * @param shouldRemoveMetadata whether to remove metadata from the image defaults to false + */ suspend fun Uri.resampleImageAndCopyToTempPath( context: Context, tempCachePath: Path, sizeClass: ImageSizeClass = Medium, + shouldRemoveMetadata: Boolean = false, dispatcher: DispatcherProvider = DefaultDispatcherProvider() ): Long { return withContext(dispatcher.io()) { @@ -211,7 +221,8 @@ suspend fun Uri.resampleImageAndCopyToTempPath( // If the GIF is too large, the user will be informed about that, just like for all other files. originalImage.writeToFile(tempCachePath.toFile()) } else { - ImageUtil.resample(originalImage, sizeClass).writeToFile(tempCachePath.toFile()) + ImageUtil.resample(originalImage, sizeClass, shouldRemoveMetadata) + .writeToFile(tempCachePath.toFile()) } } } diff --git a/app/src/main/kotlin/com/wire/android/util/ImageUtil.kt b/app/src/main/kotlin/com/wire/android/util/ImageUtil.kt index b875aa6d64a..508dd2237db 100644 --- a/app/src/main/kotlin/com/wire/android/util/ImageUtil.kt +++ b/app/src/main/kotlin/com/wire/android/util/ImageUtil.kt @@ -23,7 +23,6 @@ import android.content.ContentResolver.SCHEME_FILE import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory -import android.graphics.Matrix import android.net.Uri import androidx.exifinterface.media.ExifInterface import com.wire.kalium.logic.data.asset.KaliumFileSystem @@ -58,32 +57,67 @@ object ImageUtil { /** * Resamples, downscales and normalizes rotation of an image based on its intended [ImageSizeClass] use. + * Also takes care of removing metadata before resampling if needed. * Works on JPEGS Only. * * @param byteArray the ByteArray representing the image * @param sizeClass the indented size class use case + * @param shouldRemoveMetadata whether to remove metadata before resampling * @return ByteArray the resampled, downscaled and rotation normalized image or the original image if there was no need for downscaling */ - fun resample(byteArray: ByteArray, sizeClass: ImageSizeClass): ByteArray { + fun resample(byteArray: ByteArray, sizeClass: ImageSizeClass, shouldRemoveMetadata: Boolean = false): ByteArray { + return if (shouldRemoveMetadata) { + removeMetadataAndResample(byteArray, sizeClass) + } else { + resample(byteArray, sizeClass) + } + } + + private fun resample(byteArray: ByteArray, sizeClass: ImageSizeClass): ByteArray { val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) val targetDimension = dimensionForSizeClass(sizeClass) if (shouldScale(bitmap, targetDimension)) { val exifInterface = ExifInterface(byteArray.inputStream()) - val size = scaledSizeForBitmap(bitmap, targetDimension) - val resizedImage = Bitmap - .createScaledBitmap(bitmap, size.first.toInt(), size.second.toInt(), true) - .rotateImageToNormalOrientation(exifInterface) - val output = ByteArrayOutputStream() - if (resizedImage.hasAlpha()) { - resizedImage.compress(Bitmap.CompressFormat.PNG, 0, output) - } else { - resizedImage.compress(Bitmap.CompressFormat.JPEG, compressionFactorForSizeClass(sizeClass), output) - } - return output.toByteArray() + return rewriteBitmap(scaleBitmap(bitmap, targetDimension, exifInterface), sizeClass) } return byteArray } + private fun removeMetadataAndResample(byteArray: ByteArray, sizeClass: ImageSizeClass): ByteArray { + val exifInterface = ExifInterface(byteArray.inputStream()) + val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size).removeExifMetadata(exifInterface) + val targetDimension = dimensionForSizeClass(sizeClass) + return if (shouldScale(bitmap, targetDimension)) { + rewriteBitmap(scaleBitmap(bitmap, targetDimension, exifInterface), sizeClass) + } else { + rewriteBitmap(bitmap, sizeClass) + } + } + + private fun rewriteBitmap( + scaledBitmap: Bitmap, + sizeClass: ImageSizeClass + ): ByteArray { + val output = ByteArrayOutputStream() + if (scaledBitmap.hasAlpha()) { + scaledBitmap.compress(Bitmap.CompressFormat.PNG, 0, output) + } else { + scaledBitmap.compress(Bitmap.CompressFormat.JPEG, compressionFactorForSizeClass(sizeClass), output) + } + return output.toByteArray() + } + + private fun scaleBitmap( + bitmap: Bitmap, + targetDimension: Float, + exifInterface: ExifInterface + ): Bitmap { + val size = scaledSizeForBitmap(bitmap, targetDimension) + return Bitmap + .createScaledBitmap(bitmap, size.first.toInt(), size.second.toInt(), true) + .rotateImageToNormalOrientation(exifInterface) + } + // region Private // We will not require scaling if the image is within 30% of the target size @@ -130,11 +164,6 @@ object ImageUtil { // endregion } -/** - * Converts a ByteArray into a Bitmap - */ -fun ByteArray.toBitmap(): Bitmap? = BitmapFactory.decodeByteArray(this, 0, this.size) - /** * Converts a Uri in the formats [SCHEME_CONTENT] or [SCHEME_FILE] into a Bitmap */ @@ -149,31 +178,3 @@ fun Uri.toBitmap(context: Context): Bitmap? { * Checks whether it is the URI of the image */ fun Uri.isImage(context: Context): Boolean = isImageFile(this.getMimeType(context)) - -/** - * Rotates the image to its [ExifInterface.ORIENTATION_NORMAL] in case it's rotated with a different orientation than - * landscape or portrait See more about exif interface at: - * https://developer.android.com/reference/androidx/exifinterface/media/ExifInterface - * - * @param exif Exif interface for of the image to rotate - * @return Bitmap the rotated bitmap or the same in case there is no rotation performed - */ -@Suppress("MagicNumber", "TooGenericExceptionCaught") -fun Bitmap.rotateImageToNormalOrientation(exif: ExifInterface?): Bitmap { - val orientation = exif?.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) - val matrix = Matrix() - when (orientation) { - ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) - ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) - ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) - else -> return this - } - - return try { - val rotated = Bitmap.createBitmap(this, 0, 0, this.width, this.height, matrix, true) - this.recycle() - rotated - } catch (exception: Exception) { - this - } -} 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/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 14350045b4f..b502e66fd1c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1604,8 +1604,6 @@ In group conversations, the group admin can overwrite this setting. I agree to the migration terms and understand that this change is irreversible. I accept Wire\’s Terms of Use. - Password of your personal account - Enter password Congratulations %1$s! You\’re now the owner of the team %1$s. 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 469783fd2e5..634a7f00081 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 @@ -51,6 +51,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 @@ -112,9 +113,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 @@ -132,9 +135,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 @@ -153,9 +158,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 @@ -174,7 +181,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 @@ -192,7 +202,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 @@ -210,7 +223,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 @@ -226,20 +242,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) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/image/AvatarPickerViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/image/AvatarPickerViewModelTest.kt index 058ca5c5102..c5fc365e5ae 100644 --- a/app/src/test/kotlin/com/wire/android/ui/userprofile/image/AvatarPickerViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/image/AvatarPickerViewModelTest.kt @@ -145,7 +145,7 @@ class AvatarPickerViewModelTest { .withSuccessfulInitialAvatarLoad() .arrange() - avatarPickerViewModel.updatePickedAvatarUri(arrangement.mockUri) + avatarPickerViewModel.updatePickedAvatarUri(arrangement.mockOriginalUri, arrangement.mockTargetUri) assertInstanceOf(AvatarPickerViewModel.PictureState.Picked::class.java, avatarPickerViewModel.pictureState) avatarPickerViewModel.loadInitialAvatarState() assertInstanceOf(AvatarPickerViewModel.PictureState.Initial::class.java, avatarPickerViewModel.pictureState) @@ -157,7 +157,7 @@ class AvatarPickerViewModelTest { .withNoInitialAvatar() .arrange() - avatarPickerViewModel.updatePickedAvatarUri(arrangement.mockUri) + avatarPickerViewModel.updatePickedAvatarUri(arrangement.mockOriginalUri, arrangement.mockTargetUri) assertInstanceOf(AvatarPickerViewModel.PictureState.Picked::class.java, avatarPickerViewModel.pictureState) avatarPickerViewModel.loadInitialAvatarState() assertInstanceOf(AvatarPickerViewModel.PictureState.Empty::class.java, avatarPickerViewModel.pictureState) @@ -195,7 +195,8 @@ class AvatarPickerViewModelTest { ) } - val mockUri = mockk() + val mockTargetUri = mockk() + val mockOriginalUri = mockk() init { MockKAnnotations.init(this, relaxUnitFun = true) @@ -206,16 +207,16 @@ class AvatarPickerViewModelTest { mockkStatic(Uri::class) mockkStatic(Uri::resampleImageAndCopyToTempPath) mockkStatic(Uri::toByteArray) - every { Uri.parse(any()) } returns mockUri + every { Uri.parse(any()) } returns mockTargetUri val fakeAvatarData = "some-dummy-avatar".toByteArray() val avatarPath = fakeKaliumFileSystem.selfUserAvatarPath() fakeKaliumFileSystem.sink(avatarPath).buffer().use { it.write(fakeAvatarData) } coEvery { getAvatarAsset(any()) } returns PublicAssetResult.Success(avatarPath) - coEvery { avatarImageManager.getWritableAvatarUri(any()) } returns mockUri - coEvery { avatarImageManager.getShareableTempAvatarUri(any()) } returns mockUri - coEvery { any().resampleImageAndCopyToTempPath(any(), any(), any(), any()) } returns 1L + coEvery { avatarImageManager.getWritableAvatarUri(any()) } returns mockTargetUri + coEvery { avatarImageManager.getShareableTempAvatarUri(any()) } returns mockTargetUri + coEvery { any().resampleImageAndCopyToTempPath(any(), any(), any(), eq(true), any()) } returns 1L coEvery { any().toByteArray(any(), any()) } returns ByteArray(5) every { userDataStore.avatarAssetId } returns flow { emit(avatarAssetId) } every { qualifiedIdMapper.fromStringToQualifiedID(any()) } returns QualifiedID("avatar-value", "avatar-domain") @@ -226,7 +227,7 @@ class AvatarPickerViewModelTest { fun withFailedInitialAvatarLoad(): Arrangement { val avatarAssetId = "avatar-value@avatar-domain" coEvery { getAvatarAsset(any()) } returns PublicAssetResult.Failure(Unknown(RuntimeException("some error")), false) - coEvery { avatarImageManager.getShareableTempAvatarUri(any()) } returns mockUri + coEvery { avatarImageManager.getShareableTempAvatarUri(any()) } returns mockTargetUri every { userDataStore.avatarAssetId } returns flow { emit(avatarAssetId) } every { qualifiedIdMapper.fromStringToQualifiedID(any()) } returns QualifiedID("avatar-value", "avatar-domain") @@ -234,7 +235,7 @@ class AvatarPickerViewModelTest { } fun withNoInitialAvatar(): Arrangement { - coEvery { avatarImageManager.getShareableTempAvatarUri(any()) } returns mockUri + coEvery { avatarImageManager.getShareableTempAvatarUri(any()) } returns mockTargetUri every { userDataStore.avatarAssetId } returns flow { emit(null) } return this diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModelArrangement.kt index 9ea1e9b9259..5c58262e8de 100644 --- a/app/src/test/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileViewModelArrangement.kt @@ -21,7 +21,6 @@ import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri import com.wire.android.datastore.GlobalDataStore import com.wire.android.datastore.UserDataStore -import com.wire.android.di.AuthServerConfigProvider import com.wire.android.feature.AccountSwitchUseCase import com.wire.android.feature.analytics.AnonymousAnalyticsManager import com.wire.android.framework.TestTeam @@ -40,7 +39,6 @@ import com.wire.kalium.logic.feature.team.GetUpdatedSelfTeamUseCase import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import com.wire.kalium.logic.feature.user.IsReadOnlyAccountUseCase import com.wire.kalium.logic.feature.user.ObserveValidAccountsUseCase -import com.wire.kalium.logic.feature.user.SelfServerConfigUseCase import com.wire.kalium.logic.feature.user.UpdateSelfAvailabilityStatusUseCase import com.wire.kalium.logic.functional.Either import io.mockk.MockKAnnotations @@ -76,12 +74,6 @@ class SelfUserProfileViewModelArrangement { @MockK lateinit var wireSessionImageLoader: WireSessionImageLoader - @MockK - lateinit var authServerConfigProvider: AuthServerConfigProvider - - @MockK - lateinit var selfServerLinks: SelfServerConfigUseCase - @MockK lateinit var otherAccountMapper: OtherAccountMapper @@ -121,8 +113,6 @@ class SelfUserProfileViewModelArrangement { observeLegalHoldStatusForSelfUser = observeLegalHoldStatusForSelfUser, dispatchers = TestDispatcherProvider(), wireSessionImageLoader = wireSessionImageLoader, - authServerConfigProvider = authServerConfigProvider, - selfServerLinks = selfServerLinks, otherAccountMapper = otherAccountMapper, observeEstablishedCalls = observeEstablishedCalls, accountSwitch = accountSwitch, diff --git a/app/src/test/kotlin/com/wire/android/util/ImageUtilTest.kt b/app/src/test/kotlin/com/wire/android/util/ImageUtilTest.kt new file mode 100644 index 00000000000..e0c63763c91 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/util/ImageUtilTest.kt @@ -0,0 +1,42 @@ +package com.wire.android.util + +import android.app.Application +import androidx.exifinterface.media.ExifInterface +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class ImageUtilTest { + + @Test + fun `given an image with exif metadata, when resampling and removal marked, then output should not contain metadata`() { + val originalImage = File(javaClass.getResource("/rich-exif-sample.jpg")!!.path) + + // when + val resampledImage = ImageUtil.resample(originalImage.readBytes(), ImageUtil.ImageSizeClass.Medium, shouldRemoveMetadata = true) + val exif = ExifInterface(resampledImage.inputStream()) + + // then + assertTrue(exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE) == null) + assertTrue(exif.getAttribute(ExifInterface.TAG_DATETIME) == null) + } + + @Test + fun `given an image with exif metadata, when resampling and removal not marked, then output should contain metadata`() { + // given + val originalImage = File(javaClass.getResource("/rich-exif-sample.jpg")!!.path) + + // when + val resampledImage = ImageUtil.resample(originalImage.readBytes(), ImageUtil.ImageSizeClass.Medium, shouldRemoveMetadata = false) + val exif = ExifInterface(resampledImage.inputStream()) + + // then + assertTrue(exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE) != null) + assertTrue(exif.getAttribute(ExifInterface.TAG_DATETIME) != null) + } +} diff --git a/app/src/test/resources/rich-exif-sample.jpg b/app/src/test/resources/rich-exif-sample.jpg new file mode 100644 index 00000000000..cbc95948cb7 Binary files /dev/null and b/app/src/test/resources/rich-exif-sample.jpg differ diff --git a/kalium b/kalium index 77bf60957cd..f417fd46f63 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 77bf60957cdc50e9b81d9327883adc2b93285472 +Subproject commit f417fd46f63b0e2c82faf4f86ed115718829f1c6