From 9e754462355b88fee7930bd2e3a43a3df8463ffc Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 13 Dec 2024 11:20:25 +0200 Subject: [PATCH 01/23] feat: Add Mentions to draft messages #WPB-12062 (#3687) Co-authored-by: Yamil Medina --- .../state/MessageComposerStateHolder.kt | 26 ++++++++++++------- .../state/MessageCompositionHolder.kt | 9 ++++++- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt index 0ea6534dc7d..02b0152216f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageComposerStateHolder.kt @@ -55,15 +55,6 @@ fun rememberMessageComposerStateHolder( val messageTextFieldValue = remember { mutableStateOf(TextFieldValue()) } - LaunchedEffect(draftMessageComposition.draftText) { - if (draftMessageComposition.draftText.isNotBlank()) { - messageTextFieldValue.value = messageTextFieldValue.value.copy( - text = draftMessageComposition.draftText, - selection = TextRange(draftMessageComposition.draftText.length) // Place cursor at the end of the new text - ) - } - } - val messageCompositionHolder = remember { mutableStateOf( MessageCompositionHolder( @@ -77,6 +68,23 @@ fun rememberMessageComposerStateHolder( ) ) } + + LaunchedEffect(draftMessageComposition.draftText) { + if (draftMessageComposition.draftText.isNotBlank()) { + messageTextFieldValue.value = messageTextFieldValue.value.copy( + text = draftMessageComposition.draftText, + selection = TextRange(draftMessageComposition.draftText.length) // Place cursor at the end of the new text + ) + } + + if (draftMessageComposition.selectedMentions.isNotEmpty()) { + messageCompositionHolder.value.setMentions( + draftMessageComposition.draftText, + draftMessageComposition.selectedMentions.map { it.intoMessageMention() } + ) + } + } + LaunchedEffect(Unit) { messageCompositionHolder.value.handleMessageTextUpdates() } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt index 9c0b29b89fb..9b5590244c3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/state/MessageCompositionHolder.kt @@ -241,13 +241,20 @@ class MessageCompositionHolder( ) messageComposition.update { it.copy( - selectedMentions = mentions.mapNotNull { it.toUiMention(editMessageText) }, + selectedMentions = mentions.mapNotNull { mention -> mention.toUiMention(editMessageText) }, editMessageId = messageId ) } onSaveDraft(messageComposition.value.toDraft(editMessageText)) } + fun setMentions(editMessageText: String, mentions: List) { + messageComposition.update { + it.copy(selectedMentions = mentions.mapNotNull { mention -> mention.toUiMention(editMessageText) }) + } + onSaveDraft(messageComposition.value.toDraft(editMessageText)) + } + fun addOrRemoveMessageMarkdown( markdown: RichTextMarkdown, ) { From a346709854e51337cde00257dcbb4bba6828ec8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Zag=C3=B3rski?= Date: Fri, 13 Dec 2024 12:21:04 +0100 Subject: [PATCH 02/23] feat: Deleted users adjustments [WPB-1655] (#3704) --- .../wire/android/mapper/ConversationMapper.kt | 1 + .../conversation/ConversationSheetContent.kt | 13 +- .../conversation/ConversationSheetState.kt | 7 +- .../common/ConversationItemFactory.kt | 3 +- .../common/ConversationList.kt | 3 +- .../model/ConversationItem.kt | 1 + .../other/OtherUserProfileScreen.kt | 3 +- .../other/OtherUserProfileScreenViewModel.kt | 8 +- .../other/OtherUserProfileState.kt | 3 +- .../android/framework/TestConversationItem.kt | 3 +- .../ConversationSheetContentTest.kt | 168 ++++++++++++++---- 11 files changed, 168 insertions(+), 45 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt index 4098405e5c8..abda5b931af 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/ConversationMapper.kt @@ -96,6 +96,7 @@ fun ConversationDetailsWithEvents.toConversationItem( ), userId = conversationDetails.otherUser.id, blockingState = conversationDetails.otherUser.BlockState, + isUserDeleted = conversationDetails.otherUser.deleted, teamId = conversationDetails.otherUser.teamId, isArchived = conversationDetails.conversation.archived, mlsVerificationStatus = conversationDetails.conversation.mlsVerificationStatus, diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContent.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContent.kt index 4d43febe51e..36ca87da01e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContent.kt @@ -103,7 +103,8 @@ sealed class ConversationTypeDetail { data class Private( val avatarAsset: UserAvatarAsset?, val userId: UserId, - val blockingState: BlockingState + val blockingState: BlockingState, + val isUserDeleted: Boolean ) : ConversationTypeDetail() data class Connection(val avatarAsset: UserAvatarAsset?) : ConversationTypeDetail() @@ -131,7 +132,8 @@ data class ConversationSheetContent( fun canEditNotifications(): Boolean = isSelfUserMember && ((conversationTypeDetail is ConversationTypeDetail.Private - && (conversationTypeDetail.blockingState != BlockingState.BLOCKED)) + && (conversationTypeDetail.blockingState != BlockingState.BLOCKED) + && !conversationTypeDetail.isUserDeleted) || conversationTypeDetail is ConversationTypeDetail.Group) fun canDeleteGroup(): Boolean { @@ -142,8 +144,11 @@ data class ConversationSheetContent( fun canLeaveTheGroup(): Boolean = conversationTypeDetail is ConversationTypeDetail.Group && isSelfUserMember - fun canBlockUser(): Boolean = - conversationTypeDetail is ConversationTypeDetail.Private && conversationTypeDetail.blockingState == BlockingState.NOT_BLOCKED + fun canBlockUser(): Boolean { + return conversationTypeDetail is ConversationTypeDetail.Private + && conversationTypeDetail.blockingState == BlockingState.NOT_BLOCKED + && !conversationTypeDetail.isUserDeleted + } fun canUnblockUser(): Boolean = conversationTypeDetail is ConversationTypeDetail.Private && conversationTypeDetail.blockingState == BlockingState.BLOCKED diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetState.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetState.kt index f5bd110bc79..250e5c7439b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetState.kt @@ -93,9 +93,10 @@ fun rememberConversationSheetState( } else conversationInfo.name, mutingConversationState = mutedStatus, conversationTypeDetail = ConversationTypeDetail.Private( - userAvatarData.asset, - userId, - blockingState + avatarAsset = userAvatarData.asset, + userId = userId, + blockingState = blockingState, + isUserDeleted = isUserDeleted ), isTeamConversation = isTeamConversation, selfRole = Conversation.Member.Role.Member, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt index 45db07413b1..d462bc90234 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationItemFactory.kt @@ -516,7 +516,8 @@ fun PreviewPrivateConversationItemWithBlockedBadge() = WireTheme { isArchived = false, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - isFavorite = false + isFavorite = false, + isUserDeleted = false ), modifier = Modifier, isSelectableItem = false, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt index 730ef25eaf7..ed04d4d72bc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/ConversationList.kt @@ -224,7 +224,8 @@ fun previewConversationList(count: Int, startIndex: Int = 0, unread: Boolean = f mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, searchQuery = searchQuery, - isFavorite = false + isFavorite = false, + isUserDeleted = false ) ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt index d5ae38f7625..66bced8c774 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationItem.kt @@ -75,6 +75,7 @@ sealed class ConversationItem : ConversationFolderItem { val conversationInfo: ConversationInfo, val userId: UserId, val blockingState: BlockingState, + val isUserDeleted: Boolean, override val conversationId: ConversationId, override val mutedStatus: MutedConversationStatus, override val showLegalHoldIndicator: Boolean = false, diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt index 98d5a77978d..6a869b01690 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreen.kt @@ -598,7 +598,8 @@ fun ContentFooter( exit = fadeOut(), ) { // TODO show open conversation button for service bots after AR-2135 - if (!state.isMetadataEmpty() && state.membership != Membership.Service && !state.isTemporaryUser()) { + val isNotTemporaryAndNotDeleted = !state.isTemporaryUser() && !state.isDeletedUser + if (!state.isMetadataEmpty() && state.membership != Membership.Service && isNotTemporaryAndNotDeleted) { Surface( shadowElevation = dimensions().bottomNavigationShadowElevation, color = MaterialTheme.wireColorScheme.background diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt index 9f809c80afc..69effb5e0db 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModel.kt @@ -398,15 +398,17 @@ class OtherUserProfileScreenViewModel @Inject constructor( isUnderLegalHold = otherUser.isUnderLegalHold, expiresAt = otherUser.expiresAt, accentId = otherUser.accentId, + isDeletedUser = otherUser.deleted, conversationSheetContent = conversation?.let { ConversationSheetContent( title = otherUser.name.orEmpty(), conversationId = conversation.id, mutingConversationState = conversation.mutedStatus, conversationTypeDetail = ConversationTypeDetail.Private( - userAvatarAsset, - userId, - otherUser.BlockState + avatarAsset = userAvatarAsset, + userId = userId, + blockingState = otherUser.BlockState, + isUserDeleted = otherUser.deleted ), isTeamConversation = conversation.isTeamGroup(), selfRole = Conversation.Member.Role.Member, diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileState.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileState.kt index 491108acc7f..672093c0b55 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileState.kt @@ -55,7 +55,8 @@ data class OtherUserProfileState( val isConversationStarted: Boolean = false, val expiresAt: Instant? = null, val accentId: Int = -1, - val errorLoadingUser: ErrorLoadingUser? = null + val errorLoadingUser: ErrorLoadingUser? = null, + val isDeletedUser: Boolean = false ) { fun updateMuteStatus(status: MutedConversationStatus): OtherUserProfileState { return conversationSheetContent?.let { diff --git a/app/src/test/kotlin/com/wire/android/framework/TestConversationItem.kt b/app/src/test/kotlin/com/wire/android/framework/TestConversationItem.kt index 0d95340a56a..848fb683703 100644 --- a/app/src/test/kotlin/com/wire/android/framework/TestConversationItem.kt +++ b/app/src/test/kotlin/com/wire/android/framework/TestConversationItem.kt @@ -45,7 +45,8 @@ object TestConversationItem { isArchived = false, mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - isFavorite = false + isFavorite = false, + isUserDeleted = false ) val GROUP = ConversationItem.GroupConversation( diff --git a/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContentTest.kt b/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContentTest.kt index a95c20a4985..35cd7180406 100644 --- a/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContentTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/common/bottomsheet/conversation/ConversationSheetContentTest.kt @@ -17,33 +17,22 @@ */ package com.wire.android.ui.common.bottomsheet.conversation +import com.wire.android.framework.TestUser import com.wire.android.ui.home.conversations.details.GroupConversationDetailsViewModelTest.Companion.testGroup +import com.wire.android.ui.home.conversationslist.model.BlockingState import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.TeamId import kotlinx.coroutines.test.runTest import org.amshove.kluent.internal.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class ConversationSheetContentTest { @Test fun givenTitleIsEmptyAndTheGroupSizeIsOne_whenCallingIsTheGroupAbandoned_returnsTrue() = runTest { - val details = testGroup.copy(conversation = testGroup.conversation.copy(teamId = TeamId("team_id"))) - - val givenConversationSheetContent = ConversationSheetContent( - title = "", - conversationId = details.conversation.id, - mutingConversationState = details.conversation.mutedStatus, - conversationTypeDetail = ConversationTypeDetail.Group(details.conversation.id, false), - selfRole = Conversation.Member.Role.Member, - isTeamConversation = details.conversation.isTeamGroup(), - isArchived = false, - protocol = Conversation.ProtocolInfo.Proteus, - mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, - isUnderLegalHold = false, - isFavorite = false - ) + val givenConversationSheetContent = createGroupSheetContent("") val givenParticipantsCount = 1 assertEquals(true, givenConversationSheetContent.isAbandonedOneOnOneConversation(givenParticipantsCount)) @@ -51,13 +40,137 @@ class ConversationSheetContentTest { @Test fun givenTitleIsEmptyAndTheGroupSizeIsGtOne_whenCallingIsTheGroupAbandoned_returnsFalse() = runTest { - val details = testGroup.copy(conversation = testGroup.conversation.copy(teamId = TeamId("team_id"))) + val givenConversationSheetContent = createGroupSheetContent("") + val givenParticipantsCount = 3 + + assertEquals(false, givenConversationSheetContent.isAbandonedOneOnOneConversation(givenParticipantsCount)) + } + + @Test + fun givenTitleIsNotEmptyAndTheGroupSizeIsOne_whenCallingIsTheGroupAbandoned_returnsFalse() = runTest { + val givenConversationSheetContent = createGroupSheetContent("notEmpty") + val givenParticipantsCount = 3 + + assertEquals(false, givenConversationSheetContent.isAbandonedOneOnOneConversation(givenParticipantsCount)) + } + + @Test + fun givenPrivateConversationWithoutBlockedAndNotDeletedUser_whenCanDeleteUserIsInvoked_thenReturnsTrue() = runTest { + // given + val conversationSheetContent = + createPrivateSheetContent(blockingState = BlockingState.NOT_BLOCKED, isUserDeleted = false) + + // when + val canBlockUser = conversationSheetContent.canBlockUser() + + // then + assertTrue(canBlockUser) + } + + @Test + fun givenGroupConversation_whenCanDeleteUserIsInvoked_thenReturnsFalse() = runTest { + // given + val conversationSheetContent = createGroupSheetContent("") + + // when + val canBlockUser = conversationSheetContent.canBlockUser() + + // then + assertFalse(canBlockUser) + } + + @Test + fun givenPrivateConversationWithBlockedAndNotDeletedUser_whenCanDeleteUserIsInvoked_thenReturnsFalse() = runTest { + // given + val conversationSheetContent = + createPrivateSheetContent(blockingState = BlockingState.BLOCKED, isUserDeleted = false) + + // when + val canBlockUser = conversationSheetContent.canBlockUser() - val givenConversationSheetContent = ConversationSheetContent( - title = "", + // then + assertFalse(canBlockUser) + } + + @Test + fun givenPrivateConversationWithoutBlockedAndDeletedUser_whenCanDeleteUserIsInvoked_thenReturnsFalse() = runTest { + // given + val conversationSheetContent = + createPrivateSheetContent(blockingState = BlockingState.NOT_BLOCKED, isUserDeleted = true) + + // when + val canBlockUser = conversationSheetContent.canBlockUser() + + // then + assertFalse(canBlockUser) + } + + @Test + fun givenPrivateConversationWithoutBlockedAndNotDeletedUser_whenCanEditNotificationsIsInvoked_thenReturnsTrue() = runTest { + // given + val conversationSheetContent = + createPrivateSheetContent(blockingState = BlockingState.NOT_BLOCKED, isUserDeleted = false) + + // when + val canEditNotifications = conversationSheetContent.canEditNotifications() + + // then + assertTrue(canEditNotifications) + } + + @Test + fun givenGroupConversation_whenCanEditNotificationsIsInvoked_thenReturnsTrue() = runTest { + // given + val conversationSheetContent = createGroupSheetContent("") + + // when + val canEditNotifications = conversationSheetContent.canEditNotifications() + + // then + assertTrue(canEditNotifications) + } + + @Test + fun givenPrivateConversationWithBlockedAndNotDeletedUser_whenCanEditNotificationsIsInvoked_thenReturnsFalse() = runTest { + // given + val conversationSheetContent = + createPrivateSheetContent(blockingState = BlockingState.BLOCKED, isUserDeleted = false) + + // when + val canEditNotifications = conversationSheetContent.canEditNotifications() + + // then + assertFalse(canEditNotifications) + } + + @Test + fun givenPrivateConversationWithoutBlockedAndDeletedUser_whenCanEditNotificationsIsInvoked_thenReturnsFalse() = runTest { + // given + val conversationSheetContent = + createPrivateSheetContent(blockingState = BlockingState.BLOCKED, isUserDeleted = false) + + // when + val canEditNotifications = conversationSheetContent.canEditNotifications() + + // then + assertFalse(canEditNotifications) + } + + private fun createPrivateSheetContent( + blockingState: BlockingState, + isUserDeleted: Boolean + ): ConversationSheetContent { + val details = testGroup.copy(conversation = testGroup.conversation.copy(teamId = TeamId("team_id"))) + return ConversationSheetContent( + title = "notEmpty", conversationId = details.conversation.id, mutingConversationState = details.conversation.mutedStatus, - conversationTypeDetail = ConversationTypeDetail.Group(details.conversation.id, false), + conversationTypeDetail = ConversationTypeDetail.Private( + avatarAsset = null, + userId = TestUser.USER_ID, + blockingState = blockingState, + isUserDeleted = isUserDeleted + ), selfRole = Conversation.Member.Role.Member, isTeamConversation = details.conversation.isTeamGroup(), isArchived = false, @@ -67,17 +180,15 @@ class ConversationSheetContentTest { isUnderLegalHold = false, isFavorite = false ) - val givenParticipantsCount = 3 - - assertEquals(false, givenConversationSheetContent.isAbandonedOneOnOneConversation(givenParticipantsCount)) } - @Test - fun givenTitleIsNotEmptyAndTheGroupSizeIsOne_whenCallingIsTheGroupAbandoned_returnsFalse() = runTest { + private fun createGroupSheetContent( + title: String + ): ConversationSheetContent { val details = testGroup.copy(conversation = testGroup.conversation.copy(teamId = TeamId("team_id"))) - val givenConversationSheetContent = ConversationSheetContent( - title = "notEmpty", + return ConversationSheetContent( + title = title, conversationId = details.conversation.id, mutingConversationState = details.conversation.mutedStatus, conversationTypeDetail = ConversationTypeDetail.Group(details.conversation.id, false), @@ -90,8 +201,5 @@ class ConversationSheetContentTest { isUnderLegalHold = false, isFavorite = false ) - val givenParticipantsCount = 3 - - assertEquals(false, givenConversationSheetContent.isAbandonedOneOnOneConversation(givenParticipantsCount)) } } From 375b8ca81eb50fbb054cda6eaa58dba24a4e1fe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= <30429749+saleniuk@users.noreply.github.com> Date: Fri, 13 Dec 2024 16:04:41 +0100 Subject: [PATCH 03/23] feat: add query profiling and cached paginated list [WPB-14826] (#3726) --- .../com/wire/android/WireApplication.kt | 8 + .../android/debug/DatabaseProfilingManager.kt | 56 ++++++ .../ConversationListViewModel.kt | 6 +- .../debug/DatabaseProfilingManagerTest.kt | 187 ++++++++++++++++++ .../android/ui/CallActivityViewModelTest.kt | 2 + kalium | 2 +- 6 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/debug/DatabaseProfilingManager.kt create mode 100644 app/src/test/kotlin/com/wire/android/debug/DatabaseProfilingManagerTest.kt diff --git a/app/src/main/kotlin/com/wire/android/WireApplication.kt b/app/src/main/kotlin/com/wire/android/WireApplication.kt index 9b2d2823d17..d67de8e4c97 100644 --- a/app/src/main/kotlin/com/wire/android/WireApplication.kt +++ b/app/src/main/kotlin/com/wire/android/WireApplication.kt @@ -29,6 +29,7 @@ import co.touchlab.kermit.platformLogWriter import com.wire.android.analytics.ObserveCurrentSessionAnalyticsUseCase import com.wire.android.datastore.GlobalDataStore import com.wire.android.datastore.UserDataStoreProvider +import com.wire.android.debug.DatabaseProfilingManager import com.wire.android.di.ApplicationScope import com.wire.android.di.KaliumCoreLogic import com.wire.android.feature.analytics.AnonymousAnalyticsManagerImpl @@ -89,6 +90,9 @@ class WireApplication : BaseApp() { @Inject lateinit var currentScreenManager: CurrentScreenManager + @Inject + lateinit var databaseProfilingManager: DatabaseProfilingManager + override val workManagerConfiguration: Configuration get() = Configuration.Builder() .setWorkerFactory(wireWorkerFactory.get()) @@ -183,6 +187,10 @@ class WireApplication : BaseApp() { logDeviceInformation() // 5. Verify if we can initialize Anonymous Analytics initializeAnonymousAnalytics() + // 6. Observe and update profiling when needed + globalAppScope.launch { + databaseProfilingManager.observeAndUpdateProfiling() + } } private fun initializeAnonymousAnalytics() { diff --git a/app/src/main/kotlin/com/wire/android/debug/DatabaseProfilingManager.kt b/app/src/main/kotlin/com/wire/android/debug/DatabaseProfilingManager.kt new file mode 100644 index 00000000000..e6fcc1aa28c --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/debug/DatabaseProfilingManager.kt @@ -0,0 +1,56 @@ +/* + * 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.debug + +import com.wire.android.datastore.GlobalDataStore +import com.wire.android.di.KaliumCoreLogic +import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.functional.mapToRightOr +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.scan +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DatabaseProfilingManager @Inject constructor( + @KaliumCoreLogic private val coreLogic: CoreLogic, + private val globalDataStore: GlobalDataStore, +) { + + suspend fun observeAndUpdateProfiling() { + globalDataStore.isLoggingEnabled() + .flatMapLatest { isLoggingEnabled -> + coreLogic.getGlobalScope().sessionRepository.allValidSessionsFlow() + .mapToRightOr(emptyList()) + .map { it.map { it.userId } } + .scan(emptyList()) { previousList, currentList -> currentList - previousList.toSet() } + .map { userIds -> isLoggingEnabled to userIds } + } + .filter { (_, userIds) -> userIds.isNotEmpty() } + .distinctUntilChanged() + .collect { (isLoggingEnabled, userIds) -> + userIds.forEach { userId -> + coreLogic.getSessionScope(userId).debug.changeProfiling(isLoggingEnabled) + } + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt index 466664122da..0814e5f74e6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData +import androidx.paging.cachedIn import androidx.paging.insertSeparators import androidx.paging.map import com.wire.android.BuildConfig @@ -211,10 +212,11 @@ class ConversationListViewModelImpl @AssistedInject constructor( } } .flowOn(dispatcher.io()) + .cachedIn(viewModelScope) private var notPaginatedConversationListState by mutableStateOf(ConversationListState.NotPaginated()) - override val conversationListState: ConversationListState - get() = if (usePagination) { + override val conversationListState: ConversationListState = + if (usePagination) { ConversationListState.Paginated( conversations = conversationsPaginatedFlow, domain = currentAccount.domain diff --git a/app/src/test/kotlin/com/wire/android/debug/DatabaseProfilingManagerTest.kt b/app/src/test/kotlin/com/wire/android/debug/DatabaseProfilingManagerTest.kt new file mode 100644 index 00000000000..b0dc851cc0d --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/debug/DatabaseProfilingManagerTest.kt @@ -0,0 +1,187 @@ +/* + * 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.debug + +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.datastore.GlobalDataStore +import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.StorageFailure +import com.wire.kalium.logic.data.auth.AccountInfo +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.functional.Either +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.internal.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(CoroutineTestExtension::class) +class DatabaseProfilingManagerTest { + + @Test + fun `given valid session and logging enabled, when observing, then profiling should be enabled`() = + runTest { + // given + val account = AccountInfo.Valid(UserId("user", "domain")) + val (arrangement, databaseProfilingManager) = Arrangement() + .withAllValidSessions(flowOf(Either.Right(listOf(account)))) + .withIsLoggingEnabled(flowOf(true)) + .arrange() + + // when + val job = launch { + databaseProfilingManager.observeAndUpdateProfiling() + } + advanceUntilIdle() + // then + assertEquals(true, arrangement.profilingValues[account.userId]) + job.cancel() + } + + @Test + fun `given valid session and logging disabled, when observing, then profiling is disabled`() = + runTest { + // given + val account = AccountInfo.Valid(UserId("user", "domain")) + val (arrangement, databaseProfilingManager) = Arrangement() + .withAllValidSessions(flowOf(Either.Right(listOf(account)))) + .withIsLoggingEnabled(flowOf(false)) + .arrange() + // when + val job = launch { + databaseProfilingManager.observeAndUpdateProfiling() + } + advanceUntilIdle() + // then + assertEquals(false, arrangement.profilingValues[account.userId]) + job.cancel() + } + + @Test + fun `given valid session, when observing and logging changes from disabled to enabled, then profiling is enabled`() = + runTest { + // given + val account = AccountInfo.Valid(UserId("user", "domain")) + val (arrangement, databaseProfilingManager) = Arrangement() + .withAllValidSessions(flowOf(Either.Right(listOf(account)))) + .withIsLoggingEnabled(flowOf(false)) + .arrange() + // when + val job = launch { + databaseProfilingManager.observeAndUpdateProfiling() + } + arrangement.withIsLoggingEnabled(flowOf(true)) + advanceUntilIdle() + // then + assertEquals(true, arrangement.profilingValues[account.userId]) + job.cancel() + } + + @Test + fun `given two valid sessions, when observing and logging changes from disabled to enabled, then profiling is enabled for both`() = + runTest { + // given + val account1 = AccountInfo.Valid(UserId("user1", "domain")) + val account2 = AccountInfo.Valid(UserId("user2", "domain")) + val (arrangement, databaseProfilingManager) = Arrangement() + .withAllValidSessions(flowOf(Either.Right(listOf(account1, account2)))) + .withIsLoggingEnabled(flowOf(false)) + .arrange() + // when + val job = launch { + databaseProfilingManager.observeAndUpdateProfiling() + } + arrangement.withIsLoggingEnabled(flowOf(true)) + advanceUntilIdle() + // then + assertEquals(true, arrangement.profilingValues[account1.userId]) + assertEquals(true, arrangement.profilingValues[account2.userId]) + job.cancel() + } + + @Test + fun `given valid session and logging enabled, when observing and new session appears, then profiling is enabled for both`() = + runTest { + // given + val account1 = AccountInfo.Valid(UserId("user1", "domain")) + val account2 = AccountInfo.Valid(UserId("user2", "domain")) + val validSessionsFlow = MutableStateFlow(Either.Right(listOf(account1))) + val (arrangement, databaseProfilingManager) = Arrangement() + .withAllValidSessions(validSessionsFlow) + .withIsLoggingEnabled(flowOf(true)) + .arrange() + // when + val job = launch { + databaseProfilingManager.observeAndUpdateProfiling() + } + validSessionsFlow.value = Either.Right(listOf(account1, account2)) + advanceUntilIdle() + // then + assertEquals(true, arrangement.profilingValues[account1.userId]) + assertEquals(true, arrangement.profilingValues[account2.userId]) + job.cancel() + } + + private class Arrangement { + + @MockK + lateinit var coreLogic: CoreLogic + + @MockK + private lateinit var globalDataStore: GlobalDataStore + + var profilingValues: PersistentMap = persistentMapOf() + private set + + init { + MockKAnnotations.init(this, relaxed = true, relaxUnitFun = true) + coEvery { coreLogic.getSessionScope(any()).debug.changeProfiling(any()) } answers { + profilingValues = profilingValues.put(firstArg(), secondArg()) + } + coEvery { coreLogic.getSessionScope(any()) } answers { + val userId = firstArg() + mockk { + coEvery { debug.changeProfiling(any()) } answers { + val profilingValue = firstArg() + profilingValues = profilingValues.put(userId, profilingValue) + } + } + } + } + + fun withIsLoggingEnabled(isLoggingEnabledFlow: Flow) = apply { + coEvery { globalDataStore.isLoggingEnabled() } returns isLoggingEnabledFlow + } + + fun withAllValidSessions(allValidSessionsFlow: Flow>>) = apply { + coEvery { coreLogic.getGlobalScope().sessionRepository.allValidSessionsFlow() } returns allValidSessionsFlow + } + + fun arrange() = this to DatabaseProfilingManager(coreLogic, globalDataStore) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/CallActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/CallActivityViewModelTest.kt index 1c7345672a2..448d1f987fb 100644 --- a/app/src/test/kotlin/com/wire/android/ui/CallActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/CallActivityViewModelTest.kt @@ -117,6 +117,7 @@ class CallActivityViewModelTest { .arrange() viewModel.switchAccountIfNeeded(userId, arrangement.switchAccountActions) + advanceUntilIdle() coVerify(inverse = true) { arrangement.accountSwitch(any()) } } @@ -132,6 +133,7 @@ class CallActivityViewModelTest { .arrange() viewModel.switchAccountIfNeeded(UserId("anotherUser", "domain"), arrangement.switchAccountActions) + advanceUntilIdle() coVerify(exactly = if (switchedToAnotherAccountCalled) 1 else 0) { arrangement.switchAccountActions.switchedToAnotherAccount() diff --git a/kalium b/kalium index 26b7d4b4dcf..0667f9b780a 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 26b7d4b4dcf2b50b64a3979e6211094a7a5d63d4 +Subproject commit 0667f9b780a8262768b0c37af3d49d4f83c55701 From 924475a574387789c638d7e571d6ad73f755c796 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 15 Dec 2024 13:17:12 +0100 Subject: [PATCH 04/23] =?UTF-8?q?feat:=20add=20more=20debug=20information?= =?UTF-8?q?=20(WPB-14930)=20=F0=9F=8D=92=20(#3729)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: sergeibakhtiarov Co-authored-by: sergei.bakhtiarov Co-authored-by: Yamil Medina --- .../wire/android/ui/debug/DebugDataOptions.kt | 15 ++ .../android/ui/debug/DebugDataOptionsState.kt | 5 +- .../ui/debug/DebugDataOptionsViewModel.kt | 34 ++++ app/src/main/res/values/strings.xml | 5 + .../debug/DebugDataOptionsViewModelTest.kt | 186 ++++++++++++++++++ 5 files changed, 244 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt index 756669e1442..165da415254 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptions.kt @@ -161,6 +161,21 @@ fun DebugDataOptionsContent( ) ) + Column { + SettingsItem( + title = stringResource(R.string.debug_federation_enabled), + text = state.isFederationEnabled.toString(), + ) + SettingsItem( + title = stringResource(R.string.debug_default_backend_protocol), + text = state.defaultProtocol, + ) + SettingsItem( + title = stringResource(R.string.debug_current_api_version), + text = state.currentApiVersion, + ) + } + if (BuildConfig.DEBUG) { GetE2EICertificateSwitch( enrollE2EI = enrollE2EICertificate diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsState.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsState.kt index acc93712f82..3f7387e53d3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsState.kt @@ -29,5 +29,8 @@ data class DebugDataOptionsState( val certificate: String = "null", val showCertificate: Boolean = false, val startGettingE2EICertificate: Boolean = false, - val analyticsTrackingId: String = "null" + val analyticsTrackingId: String = "null", + val isFederationEnabled: Boolean = false, + val currentApiVersion: String = "null", + val defaultProtocol: String = "null", ) diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt index b23e0d65238..e98c20eb903 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt @@ -34,6 +34,8 @@ import com.wire.android.util.getGitBuildId import com.wire.android.util.ui.UIText import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.E2EIFailure +import com.wire.kalium.logic.configuration.server.CommonApiVersionType +import com.wire.kalium.logic.data.user.SupportedProtocol import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.analytics.GetCurrentAnalyticsTrackingIdentifierUseCase import com.wire.kalium.logic.feature.e2ei.CheckCrlRevocationListUseCase @@ -42,6 +44,8 @@ import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountResult import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountUseCase import com.wire.kalium.logic.feature.notificationToken.SendFCMTokenError import com.wire.kalium.logic.feature.notificationToken.SendFCMTokenUseCase +import com.wire.kalium.logic.feature.user.GetDefaultProtocolUseCase +import com.wire.kalium.logic.feature.user.SelfServerConfigUseCase import com.wire.kalium.logic.functional.Either import com.wire.kalium.logic.functional.fold import com.wire.kalium.logic.sync.periodic.UpdateApiVersionsScheduler @@ -87,6 +91,8 @@ class DebugDataOptionsViewModelImpl private val getCurrentAnalyticsTrackingIdentifier: GetCurrentAnalyticsTrackingIdentifierUseCase, private val sendFCMToken: SendFCMTokenUseCase, private val dispatcherProvider: DispatcherProvider, + private val selfServerConfigUseCase: SelfServerConfigUseCase, + private val getDefaultProtocolUseCase: GetDefaultProtocolUseCase, ) : ViewModel(), DebugDataOptionsViewModel { var state by mutableStateOf( @@ -102,6 +108,34 @@ class DebugDataOptionsViewModelImpl checkIfCanTriggerManualMigration() setGitHashAndDeviceId() setAnalyticsTrackingId() + setServerConfigData() + setDefaultProtocol() + } + + private fun setDefaultProtocol() { + viewModelScope.launch { + state = state.copy( + defaultProtocol = when (getDefaultProtocolUseCase()) { + SupportedProtocol.PROTEUS -> "Proteus" + SupportedProtocol.MLS -> "MLS" + } + ) + } + } + + private fun setServerConfigData() { + viewModelScope.launch { + val result = selfServerConfigUseCase() + if (result is SelfServerConfigUseCase.Result.Success) { + state = state.copy( + isFederationEnabled = result.serverLinks.metaData.federation, + currentApiVersion = when (result.serverLinks.metaData.commonApiVersion) { + CommonApiVersionType.Unknown -> "Unknown" + else -> result.serverLinks.metaData.commonApiVersion.version.toString() + }, + ) + } + } } private fun setAnalyticsTrackingId() { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 060d2d7a6c2..3583986c1a3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,6 +23,11 @@ Analytics Tracking Identifier + Federation Enabled + + Default Backend Protocol + + Current API Version New Login OK diff --git a/app/src/test/kotlin/com/wire/android/ui/settings/debug/DebugDataOptionsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/settings/debug/DebugDataOptionsViewModelTest.kt index f4476ca976c..b14eb1d1cdf 100644 --- a/app/src/test/kotlin/com/wire/android/ui/settings/debug/DebugDataOptionsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/settings/debug/DebugDataOptionsViewModelTest.kt @@ -31,7 +31,11 @@ import com.wire.android.ui.debug.DebugDataOptionsViewModelImpl import com.wire.android.util.getDeviceIdString import com.wire.android.util.getGitBuildId import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.configuration.server.CommonApiVersionType +import com.wire.kalium.logic.configuration.server.ServerConfig import com.wire.kalium.logic.data.conversation.ClientId +import com.wire.kalium.logic.data.user.SupportedProtocol import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.analytics.GetCurrentAnalyticsTrackingIdentifierUseCase import com.wire.kalium.logic.feature.e2ei.CheckCrlRevocationListUseCase @@ -39,6 +43,8 @@ import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountResult import com.wire.kalium.logic.feature.keypackage.MLSKeyPackageCountUseCase import com.wire.kalium.logic.feature.notificationToken.SendFCMTokenError import com.wire.kalium.logic.feature.notificationToken.SendFCMTokenUseCase +import com.wire.kalium.logic.feature.user.GetDefaultProtocolUseCase +import com.wire.kalium.logic.feature.user.SelfServerConfigUseCase import com.wire.kalium.logic.functional.Either import com.wire.kalium.logic.sync.periodic.UpdateApiVersionsScheduler import com.wire.kalium.logic.sync.slow.RestartSlowSyncProcessForRecoveryUseCase @@ -46,6 +52,7 @@ import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.mockk import io.mockk.mockkStatic import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -128,6 +135,77 @@ class DebugDataOptionsViewModelTest { assertEquals(UIText.DynamicString("Can't register token, error: error message"), result) } } + + @Test + fun `given that Proteus protocol is used, view state should have Proteus protocol name`() = runTest { + // given + val (_, viewModel) = DebugDataOptionsHiltArrangement() + .withProteusProtocolSetup() + .arrange() + + assertEquals("Proteus", viewModel.state.defaultProtocol) + } + + @Test + fun `given that Mls protocol is used, view state should have proteus Mls name`() = runTest { + // given + val (_, viewModel) = DebugDataOptionsHiltArrangement() + .withMlsProtocolSetup() + .arrange() + + assertEquals("MLS", viewModel.state.defaultProtocol) + } + + @Test + fun `given that federation is disabled, view state should have federation value of false`() = runTest { + // given + val (_, viewModel) = DebugDataOptionsHiltArrangement() + .withFederationDisabled() + .arrange() + + assertEquals(false, viewModel.state.isFederationEnabled) + } + + @Test + fun `given that federation is enabled, view state should have federation value of true`() = runTest { + // given + val (_, viewModel) = DebugDataOptionsHiltArrangement() + .withFederationEnabled() + .arrange() + + assertEquals(true, viewModel.state.isFederationEnabled) + } + + @Test + fun `given that api version is unknown, view state should have api version unknown`() = runTest { + // given + val (_, viewModel) = DebugDataOptionsHiltArrangement() + .withApiVersionUnknown() + .arrange() + + assertEquals("Unknown", viewModel.state.currentApiVersion) + } + + @Test + fun `given that api version is set, view state should have api version set`() = runTest { + // given + val (_, viewModel) = DebugDataOptionsHiltArrangement() + .withApiVersionSet(7) + .arrange() + + assertEquals("7", viewModel.state.currentApiVersion) + } + + @Test + fun `given server config failure, view state should have default values`() = runTest { + // given + val (_, viewModel) = DebugDataOptionsHiltArrangement() + .withServerConfigError() + .arrange() + + assertEquals("null", viewModel.state.currentApiVersion) + assertEquals(false, viewModel.state.isFederationEnabled) + } } internal class DebugDataOptionsHiltArrangement { @@ -155,6 +233,12 @@ internal class DebugDataOptionsHiltArrangement { @MockK lateinit var getCurrentAnalyticsTrackingIdentifier: GetCurrentAnalyticsTrackingIdentifierUseCase + @MockK + lateinit var selfServerConfigUseCase: SelfServerConfigUseCase + + @MockK + lateinit var getDefaultProtocolUseCase: GetDefaultProtocolUseCase + @MockK lateinit var sendFCMToken: SendFCMTokenUseCase @@ -170,6 +254,8 @@ internal class DebugDataOptionsHiltArrangement { getCurrentAnalyticsTrackingIdentifier = getCurrentAnalyticsTrackingIdentifier, sendFCMToken = sendFCMToken, dispatcherProvider = TestDispatcherProvider(), + selfServerConfigUseCase = selfServerConfigUseCase, + getDefaultProtocolUseCase = getDefaultProtocolUseCase, ) } @@ -196,6 +282,22 @@ internal class DebugDataOptionsHiltArrangement { coEvery { globalDataStore.getUserMigrationStatus(TestUser.SELF_USER_ID.value) } returns flowOf(UserMigrationStatus.NoNeed) + coEvery { + selfServerConfigUseCase() + } returns SelfServerConfigUseCase.Result.Success( + ServerConfig( + id = "id", + links = mockk(), + metaData = ServerConfig.MetaData( + federation = true, + commonApiVersion = CommonApiVersionType.Unknown, + domain = null, + ) + ) + ) + every { + getDefaultProtocolUseCase() + } returns SupportedProtocol.PROTEUS } fun arrange() = this to viewModel @@ -223,4 +325,88 @@ internal class DebugDataOptionsHiltArrangement { sendFCMToken() } returns Either.Left(SendFCMTokenError(SendFCMTokenError.Reason.CANT_REGISTER_TOKEN, "error message")) } + + fun withProteusProtocolSetup() = apply { + every { + getDefaultProtocolUseCase() + } returns SupportedProtocol.PROTEUS + } + + fun withMlsProtocolSetup() = apply { + every { + getDefaultProtocolUseCase() + } returns SupportedProtocol.MLS + } + + fun withFederationEnabled() = apply { + coEvery { + selfServerConfigUseCase() + } returns SelfServerConfigUseCase.Result.Success( + ServerConfig( + id = "id", + links = mockk(), + metaData = ServerConfig.MetaData( + federation = true, + commonApiVersion = CommonApiVersionType.Unknown, + domain = null, + ) + ) + ) + } + + fun withFederationDisabled() = apply { + coEvery { + selfServerConfigUseCase() + } returns SelfServerConfigUseCase.Result.Success( + ServerConfig( + id = "id", + links = mockk(), + metaData = ServerConfig.MetaData( + federation = false, + commonApiVersion = CommonApiVersionType.Unknown, + domain = null, + ) + ) + ) + } + + fun withApiVersionUnknown() = apply { + coEvery { + selfServerConfigUseCase() + } returns SelfServerConfigUseCase.Result.Success( + ServerConfig( + id = "id", + links = mockk(), + metaData = ServerConfig.MetaData( + federation = true, + commonApiVersion = CommonApiVersionType.Unknown, + domain = null, + ) + ) + ) + } + + fun withApiVersionSet(version: Int) = apply { + coEvery { + selfServerConfigUseCase() + } returns SelfServerConfigUseCase.Result.Success( + ServerConfig( + id = "id", + links = mockk(), + metaData = ServerConfig.MetaData( + federation = true, + commonApiVersion = CommonApiVersionType.Valid(version), + domain = null, + ) + ) + ) + } + + fun withServerConfigError() = apply { + coEvery { + selfServerConfigUseCase() + } returns SelfServerConfigUseCase.Result.Failure( + CoreFailure.Unknown(IllegalStateException()) + ) + } } From a59ff2e0421c294ba9b41d0c178a1b07d711c060 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 15 Dec 2024 13:17:43 +0100 Subject: [PATCH 05/23] =?UTF-8?q?fix:=20countly=20general=20implementation?= =?UTF-8?q?=20fixes=20(WPB-14941)=20=F0=9F=8D=92=20(#3733)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Yamil Medina --- .../AnonymousAnalyticsManagerImpl.kt | 11 +++++++ .../AnonymousAnalyticsRecorderImpl.kt | 2 +- .../AnonymousAnalyticsManagerTest.kt | 33 ++++++++++++++++++- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerImpl.kt b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerImpl.kt index 7e78362974f..0eba82a61ae 100644 --- a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerImpl.kt +++ b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerImpl.kt @@ -42,6 +42,9 @@ object AnonymousAnalyticsManagerImpl : AnonymousAnalyticsManager { private val mutex = Mutex() private lateinit var coroutineScope: CoroutineScope + // TODO: Sync with product, when we want to enable view tracking, var for testing purposes + internal var VIEW_TRACKING_ENABLED: Boolean = false + override fun init( context: Context, analyticsSettings: AnalyticsSettings, @@ -172,6 +175,10 @@ object AnonymousAnalyticsManagerImpl : AnonymousAnalyticsManager { } override fun recordView(screen: String) { + if (!VIEW_TRACKING_ENABLED) { + Log.d(TAG, "View tracking is disabled for this build.") + return + } coroutineScope.launch { mutex.withLock { if (!isAnonymousUsageDataEnabled) return@withLock @@ -181,6 +188,10 @@ object AnonymousAnalyticsManagerImpl : AnonymousAnalyticsManager { } override fun stopView(screen: String) { + if (!VIEW_TRACKING_ENABLED) { + Log.d(TAG, "View tracking is disabled for this build.") + return + } coroutineScope.launch { mutex.withLock { if (!isAnonymousUsageDataEnabled) return@withLock diff --git a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt index bee5ab38855..0bb72c5545d 100644 --- a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt +++ b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt @@ -85,7 +85,7 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { override fun halt() { isConfigured = false - Countly.sharedInstance().halt() + Countly.sharedInstance().consent().removeConsentAll() } override suspend fun setTrackingIdentifierWithMerge( diff --git a/core/analytics-enabled/src/test/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerTest.kt b/core/analytics-enabled/src/test/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerTest.kt index ec45685727f..691810ab5a3 100644 --- a/core/analytics-enabled/src/test/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerTest.kt +++ b/core/analytics-enabled/src/test/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsManagerTest.kt @@ -294,6 +294,37 @@ class AnonymousAnalyticsManagerTest { } } + @Test + fun givenManagerInitialized_whenRecordingViewAndFlagDisabled_thenScreenIsNOTRecorded() = runTest(dispatcher) { + // given + val (arrangement, manager) = Arrangement() + .withAnonymousAnalyticsRecorderConfigure() + .arrange(shouldTrackViews = false) + + val screen = "screen" + arrangement.withAnalyticsResult(Arrangement.existingIdentifierResult) + + // when + manager.init( + context = arrangement.context, + analyticsSettings = Arrangement.analyticsSettings, + analyticsResultFlow = arrangement.analyticsResultChannel.consumeAsFlow(), + anonymousAnalyticsRecorder = arrangement.anonymousAnalyticsRecorder, + migrationHandler = arrangement.migrationHandler, + propagationHandler = arrangement.propagationHandler, + dispatcher = dispatcher + ) + advanceUntilIdle() + + manager.recordView(screen) + advanceUntilIdle() + + // then + verify(exactly = 0) { + arrangement.anonymousAnalyticsRecorder.recordView(eq(screen)) + } + } + @Test fun givenManagerInitialized_whenStoppingView_thenScreenIsStoppedToRecord() = runTest(dispatcher) { // given @@ -387,7 +418,7 @@ class AnonymousAnalyticsManagerTest { AnonymousAnalyticsManagerImpl } - fun arrange() = this to manager + fun arrange(shouldTrackViews: Boolean = true) = this to manager.apply { VIEW_TRACKING_ENABLED = shouldTrackViews } fun withAnonymousAnalyticsRecorderConfigure() = apply { every { anonymousAnalyticsRecorder.configure(any(), any()) } returns Unit From 05c53e851e2c0e9c0a2318d8e9d4862988b3179b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Zag=C3=B3rski?= Date: Mon, 16 Dec 2024 10:30:27 +0100 Subject: [PATCH 06/23] feat: alerts for non paying users [WPB-1826] (#3715) --- .../android/di/accountScoped/CallsModule.kt | 5 + .../calling/CallingFeatureActivatedDialog.kt | 44 +++ .../CallingFeatureUnavailableDialog.kt | 45 ++++ .../home/conversations/ConversationScreen.kt | 46 +++- .../ConversationScreenDialogType.kt | 3 + .../call/ConversationListCallViewModel.kt | 27 +- app/src/main/res/values/strings.xml | 10 + .../call/ConversationListCallViewModelTest.kt | 250 ++++++++++++------ 8 files changed, 341 insertions(+), 89 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/common/dialogs/calling/CallingFeatureActivatedDialog.kt diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt index 516dc982bfd..803e627db87 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt @@ -197,4 +197,9 @@ class CallsModule { @Provides fun provideIsEligibleToStartCall(callsScope: CallsScope) = callsScope.isEligibleToStartCall + + @ViewModelScoped + @Provides + fun provideObserveConferenceCallingEnabledUseCase(callsScope: CallsScope) = + callsScope.observeConferenceCallingEnabled } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/dialogs/calling/CallingFeatureActivatedDialog.kt b/app/src/main/kotlin/com/wire/android/ui/common/dialogs/calling/CallingFeatureActivatedDialog.kt new file mode 100644 index 00000000000..370f24eb6ca --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/dialogs/calling/CallingFeatureActivatedDialog.kt @@ -0,0 +1,44 @@ +/* + * 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.ui.common.dialogs.calling + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.wire.android.R +import com.wire.android.ui.common.DialogTextSuffixLink +import com.wire.android.ui.common.WireDialog +import com.wire.android.ui.common.WireDialogButtonProperties +import com.wire.android.ui.common.WireDialogButtonType + +@Composable +fun CallingFeatureActivatedDialog(onDialogDismiss: () -> Unit) { + WireDialog( + title = stringResource(id = R.string.calling_feature_enabled_title_alert), + text = stringResource(id = R.string.calling_feature_enabled_message_alert), + onDismiss = onDialogDismiss, + textSuffixLink = DialogTextSuffixLink( + linkText = stringResource(R.string.calling_feature_enabled_message_link_alert), + linkUrl = stringResource(R.string.url_wire_enterprise) + ), + optionButton1Properties = WireDialogButtonProperties( + onClick = onDialogDismiss, + text = stringResource(id = R.string.label_ok), + type = WireDialogButtonType.Primary + ) + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/common/dialogs/calling/CallingFeatureUnavailableDialog.kt b/app/src/main/kotlin/com/wire/android/ui/common/dialogs/calling/CallingFeatureUnavailableDialog.kt index 21864ebd840..55926a5503b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/dialogs/calling/CallingFeatureUnavailableDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/dialogs/calling/CallingFeatureUnavailableDialog.kt @@ -21,6 +21,7 @@ package com.wire.android.ui.common.dialogs.calling import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.wire.android.R +import com.wire.android.ui.common.DialogTextSuffixLink import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties import com.wire.android.ui.common.WireDialogButtonType @@ -38,3 +39,47 @@ fun CallingFeatureUnavailableDialog(onDialogDismiss: () -> Unit) { ) ) } + +@Composable +fun CallingFeatureUnavailableTeamMemberDialog(onDialogDismiss: () -> Unit) { + WireDialog( + title = stringResource(id = R.string.calling_feature_unavailable_title_alert), + text = stringResource(id = R.string.calling_feature_unavailable_team_member_message_alert), + onDismiss = onDialogDismiss, + optionButton1Properties = WireDialogButtonProperties( + onClick = onDialogDismiss, + text = stringResource(id = R.string.label_ok), + type = WireDialogButtonType.Primary + ) + ) +} + +@Composable +fun CallingFeatureUnavailableTeamAdminDialog( + onUpgradeAction: (String) -> Unit, + onDialogDismiss: () -> Unit +) { + val upgradeLink = stringResource(R.string.url_team_management_login) + WireDialog( + title = stringResource(id = R.string.calling_feature_unavailable_team_admin_title_alert), + text = stringResource(id = R.string.calling_feature_unavailable_team_admin_message_alert), + onDismiss = onDialogDismiss, + textSuffixLink = DialogTextSuffixLink( + linkText = stringResource(R.string.calling_feature_unavailable_team_admin_message_link_alert), + linkUrl = stringResource(R.string.url_team_management_login) + ), + optionButton2Properties = WireDialogButtonProperties( + onClick = { + onUpgradeAction(upgradeLink) + onDialogDismiss() + }, + text = stringResource(id = R.string.calling_feature_unavailable_team_admin_upgrade_action_alert), + type = WireDialogButtonType.Primary + ), + optionButton1Properties = WireDialogButtonProperties( + onClick = onDialogDismiss, + text = stringResource(id = R.string.label_cancel), + type = WireDialogButtonType.Secondary + ) + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index 164aeb80620..061b6c1a1b9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -101,7 +101,10 @@ import com.wire.android.ui.common.dialogs.InvalidLinkDialog import com.wire.android.ui.common.dialogs.PermissionPermanentlyDeniedDialog import com.wire.android.ui.common.dialogs.SureAboutMessagingInDegradedConversationDialog import com.wire.android.ui.common.dialogs.VisitLinkDialog +import com.wire.android.ui.common.dialogs.calling.CallingFeatureActivatedDialog import com.wire.android.ui.common.dialogs.calling.CallingFeatureUnavailableDialog +import com.wire.android.ui.common.dialogs.calling.CallingFeatureUnavailableTeamAdminDialog +import com.wire.android.ui.common.dialogs.calling.CallingFeatureUnavailableTeamMemberDialog import com.wire.android.ui.common.dialogs.calling.ConfirmStartCallDialog import com.wire.android.ui.common.dialogs.calling.JoinAnywayDialog import com.wire.android.ui.common.dialogs.calling.OngoingActiveCallDialog @@ -173,6 +176,7 @@ import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.message.MessageAssetStatus import com.wire.kalium.logic.data.message.SelfDeletionTimer import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.data.user.type.UserType import com.wire.kalium.logic.feature.call.usecase.ConferenceCallingResult import kotlinx.collections.immutable.PersistentMap import kotlinx.coroutines.CoroutineScope @@ -299,6 +303,12 @@ fun ConversationScreen( } } + LaunchedEffect(Unit) { + conversationListCallViewModel.callingEnabled.collect { + showDialog.value = ConversationScreenDialogType.CALLING_FEATURE_ACTIVATED + } + } + conversationMigrationViewModel.migratedConversationId?.let { migratedConversationId -> navigator.navigate( NavigationCommand( @@ -387,9 +397,30 @@ fun ConversationScreen( } ConversationScreenDialogType.CALLING_FEATURE_UNAVAILABLE -> { - CallingFeatureUnavailableDialog(onDialogDismiss = { + CallingFeatureUnavailableDialog { showDialog.value = ConversationScreenDialogType.NONE - }) + } + } + + ConversationScreenDialogType.CALLING_FEATURE_UNAVAILABLE_TEAM_MEMBER -> { + CallingFeatureUnavailableTeamMemberDialog { + showDialog.value = ConversationScreenDialogType.NONE + } + } + + ConversationScreenDialogType.CALLING_FEATURE_UNAVAILABLE_TEAM_ADMIN -> { + CallingFeatureUnavailableTeamAdminDialog( + onUpgradeAction = uriHandler::openUri, + onDialogDismiss = { + showDialog.value = ConversationScreenDialogType.NONE + } + ) + } + + ConversationScreenDialogType.CALLING_FEATURE_ACTIVATED -> { + CallingFeatureActivatedDialog { + showDialog.value = ConversationScreenDialogType.NONE + } } ConversationScreenDialogType.VERIFICATION_DEGRADED -> { @@ -771,7 +802,16 @@ private fun startCallIfPossible( } ConferenceCallingResult.Disabled.OngoingCall -> ConversationScreenDialogType.ONGOING_ACTIVE_CALL - ConferenceCallingResult.Disabled.Unavailable -> ConversationScreenDialogType.CALLING_FEATURE_UNAVAILABLE + ConferenceCallingResult.Disabled.Unavailable -> { + when (conversationListCallViewModel.selfTeamRole.value) { + UserType.INTERNAL -> ConversationScreenDialogType.CALLING_FEATURE_UNAVAILABLE_TEAM_MEMBER + UserType.OWNER, + UserType.ADMIN -> ConversationScreenDialogType.CALLING_FEATURE_UNAVAILABLE_TEAM_ADMIN + + else -> ConversationScreenDialogType.CALLING_FEATURE_UNAVAILABLE + } + } + else -> ConversationScreenDialogType.NONE } showDialog.value = dialogValue diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreenDialogType.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreenDialogType.kt index 8cada4a4eeb..28b81774094 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreenDialogType.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreenDialogType.kt @@ -25,5 +25,8 @@ enum class ConversationScreenDialogType { CALL_CONFIRMATION, PING_CONFIRMATION, CALLING_FEATURE_UNAVAILABLE, + CALLING_FEATURE_UNAVAILABLE_TEAM_MEMBER, + CALLING_FEATURE_UNAVAILABLE_TEAM_ADMIN, + CALLING_FEATURE_ACTIVATED, NONE } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModel.kt index 2f533ac6044..26c7fb86589 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModel.kt @@ -32,18 +32,22 @@ import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.sync.SyncState +import com.wire.kalium.logic.data.user.type.UserType import com.wire.kalium.logic.feature.call.usecase.AnswerCallUseCase import com.wire.kalium.logic.feature.call.usecase.ConferenceCallingResult import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase import com.wire.kalium.logic.feature.call.usecase.IsEligibleToStartCallUseCase +import com.wire.kalium.logic.feature.call.usecase.ObserveConferenceCallingEnabledUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveOngoingCallsUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase import com.wire.kalium.logic.feature.conversation.ObserveDegradedConversationNotifiedUseCase import com.wire.kalium.logic.feature.conversation.SetUserInformedAboutVerificationUseCase +import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import com.wire.kalium.logic.sync.ObserveSyncStateUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -64,7 +68,9 @@ class ConversationListCallViewModel @Inject constructor( private val isConferenceCallingEnabled: IsEligibleToStartCallUseCase, private val observeConversationDetails: ObserveConversationDetailsUseCase, private val setUserInformedAboutVerification: SetUserInformedAboutVerificationUseCase, - private val observeDegradedConversationNotified: ObserveDegradedConversationNotifiedUseCase + private val observeDegradedConversationNotified: ObserveDegradedConversationNotifiedUseCase, + private val observeConferenceCallingEnabled: ObserveConferenceCallingEnabledUseCase, + private val getSelf: GetSelfUserUseCase ) : SavedStateViewModel(savedStateHandle) { private val conversationNavArgs: ConversationNavArgs = savedStateHandle.navArgs() @@ -72,6 +78,8 @@ class ConversationListCallViewModel @Inject constructor( var conversationCallViewState by mutableStateOf(ConversationCallViewState()) val shouldInformAboutVerification = mutableStateOf(false) + val selfTeamRole = mutableStateOf(UserType.GUEST) + val callingEnabled = MutableSharedFlow(replay = 1) var establishedCallConversationId: QualifiedID? = null @@ -80,6 +88,15 @@ class ConversationListCallViewModel @Inject constructor( observeEstablishedCall() observeParticipantsForConversation() observeInformedAboutDegradedVerification() + observeSelfTeamRole() + observeCallingActivatedEvent() + } + + private fun observeCallingActivatedEvent() { + viewModelScope.launch { + observeConferenceCallingEnabled() + .collectLatest { callingEnabled.emit(Unit) } + } } private fun observeParticipantsForConversation() { @@ -91,6 +108,14 @@ class ConversationListCallViewModel @Inject constructor( } } + private fun observeSelfTeamRole() { + viewModelScope.launch { + getSelf().collectLatest { self -> + selfTeamRole.value = self.userType + } + } + } + private fun observeInformedAboutDegradedVerification() = viewModelScope.launch { observeDegradedConversationNotified(conversationId).collect { shouldInformAboutVerification.value = !it } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3583986c1a3..718e9d41961 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -280,6 +280,8 @@ https://support.wire.com/hc/articles/360002855817 https://wire.com/pricing https://teams.wire.com/ + https://teams.wire.com/login + https://wire.com/en/enterprise Vault Archive @@ -981,6 +983,14 @@ Double tap to go back Feature unavailable The option to initiate a conference call is only available in the paid version of Wire. + To start a conference call, your team needs to upgrade to the Enterprise plan. + Upgrade to Enterprise + Your team is currently on the free Basic plan. Upgrade to Enterprise for access to features such as starting conferences and more. + Learn more about Wire\'s pricing + Upgrade now + Wire Enterprise + Your team was upgraded to Wire Enterprise, which gives you access to features such as conference calls and more. + Learn more about Wire Enterprise Connecting… Start a call Are you sure you want to call %1$s people? diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModelTest.kt index 183101b7bd6..272326163e7 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/call/ConversationListCallViewModelTest.kt @@ -18,20 +18,25 @@ package com.wire.android.ui.home.conversations.call import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension +import com.wire.android.framework.TestUser import com.wire.android.ui.home.conversations.ConversationNavArgs import com.wire.android.ui.home.conversations.details.participants.usecase.ObserveParticipantsForConversationUseCase import com.wire.android.ui.navArgs import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.user.type.UserType import com.wire.kalium.logic.feature.call.usecase.AnswerCallUseCase import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase import com.wire.kalium.logic.feature.call.usecase.IsEligibleToStartCallUseCase +import com.wire.kalium.logic.feature.call.usecase.ObserveConferenceCallingEnabledUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveOngoingCallsUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase import com.wire.kalium.logic.feature.conversation.ObserveDegradedConversationNotifiedUseCase import com.wire.kalium.logic.feature.conversation.SetUserInformedAboutVerificationUseCase +import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import com.wire.kalium.logic.sync.ObserveSyncStateUseCase import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -40,125 +45,200 @@ import io.mockk.every import io.mockk.impl.annotations.MockK import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest import org.amshove.kluent.internal.assertEquals -import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith -@ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) +@ExtendWith(CoroutineTestExtension::class, NavigationTestExtension::class) class ConversationListCallViewModelTest { - @MockK - private lateinit var savedStateHandle: SavedStateHandle - - @MockK - private lateinit var observeOngoingCalls: ObserveOngoingCallsUseCase + @Test + fun `given join dialog displayed, when user dismiss it, then hide it`() = runTest { + val (_, viewModel) = Arrangement() + .withDefaultAnswers() + .arrange() + viewModel.conversationCallViewState = viewModel.conversationCallViewState.copy( + shouldShowJoinAnywayDialog = true + ) - @MockK - private lateinit var observeEstablishedCalls: ObserveEstablishedCallsUseCase + viewModel.dismissJoinCallAnywayDialog() - @MockK - private lateinit var joinCall: AnswerCallUseCase + assertEquals(false, viewModel.conversationCallViewState.shouldShowJoinAnywayDialog) + } - @MockK - private lateinit var endCall: EndCallUseCase + @Test + fun `given no ongoing call, when user tries to join a call, then invoke answerCall call use case`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withDefaultAnswers() + .withJoinCallResponse() + .arrange() + viewModel.conversationCallViewState = viewModel.conversationCallViewState.copy(hasEstablishedCall = false) + + viewModel.joinOngoingCall(arrangement.onAnswered) + + coVerify(exactly = 1) { arrangement.joinCall(conversationId = any()) } + coVerify(exactly = 1) { arrangement.onAnswered(any()) } + assertEquals(false, viewModel.conversationCallViewState.shouldShowJoinAnywayDialog) + } - @MockK - private lateinit var observeSyncState: ObserveSyncStateUseCase + @Test + fun `given an ongoing call, when user tries to join a call, then show JoinCallAnywayDialog`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withDefaultAnswers() + .arrange() + viewModel.conversationCallViewState = viewModel.conversationCallViewState.copy(hasEstablishedCall = true) - @MockK - private lateinit var isConferenceCallingEnabled: IsEligibleToStartCallUseCase + viewModel.joinOngoingCall(arrangement.onAnswered) - @MockK(relaxed = true) - private lateinit var onAnswered: (conversationId: ConversationId) -> Unit + assertEquals(true, viewModel.conversationCallViewState.shouldShowJoinAnywayDialog) + coVerify(inverse = true) { arrangement.joinCall(conversationId = any()) } + } - @MockK - private lateinit var observeConversationDetails: ObserveConversationDetailsUseCase + @Test + fun `given an ongoing call, when user confirms dialog to join a call, then end current call and join the newer one`() = runTest { + val (arrangement, viewModel) = Arrangement() + .withDefaultAnswers() + .withEndCallResponse() + .arrange() + viewModel.conversationCallViewState = + viewModel.conversationCallViewState.copy(hasEstablishedCall = true) + viewModel.establishedCallConversationId = ConversationId("value", "Domain") + + viewModel.joinAnyway(arrangement.onAnswered) + + coVerify(exactly = 1) { arrangement.endCall(any()) } + } - @MockK - private lateinit var observeParticipantsForConversation: ObserveParticipantsForConversationUseCase + @Test + fun `given self team role as admin in conversation, when we observe own role, then its properly propagated`() = runTest { + val (_, viewModel) = Arrangement() + .withDefaultAnswers() + .withSelfAsAdmin() + .arrange() - @MockK - lateinit var setUserInformedAboutVerificationUseCase: SetUserInformedAboutVerificationUseCase + val role = viewModel.selfTeamRole - @MockK - lateinit var observeDegradedConversationNotifiedUseCase: ObserveDegradedConversationNotifiedUseCase + assertEquals(UserType.ADMIN, role.value) + } - private lateinit var conversationListCallViewModel: ConversationListCallViewModel + @Test + fun `given calling enabled event, when we observe it, then its properly propagated`() = runTest { + val (_, viewModel) = Arrangement() + .withDefaultAnswers() + .withConferenceCallingEnabledResponse() + .arrange() - @BeforeEach - fun setUp() { - MockKAnnotations.init(this) - val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") - every { savedStateHandle.navArgs() } returns ConversationNavArgs(conversationId = conversationId) - coEvery { observeEstablishedCalls.invoke() } returns emptyFlow() - coEvery { observeOngoingCalls.invoke() } returns emptyFlow() - coEvery { observeConversationDetails(any()) } returns flowOf() - coEvery { observeParticipantsForConversation(any()) } returns flowOf() - coEvery { setUserInformedAboutVerificationUseCase(any()) } returns Unit - coEvery { observeDegradedConversationNotifiedUseCase(any()) } returns flowOf(false) + val callingEnabled = viewModel.callingEnabled - conversationListCallViewModel = ConversationListCallViewModel( - savedStateHandle = savedStateHandle, - observeOngoingCalls = observeOngoingCalls, - observeEstablishedCalls = observeEstablishedCalls, - answerCall = joinCall, - endCall = endCall, - observeSyncState = observeSyncState, - isConferenceCallingEnabled = isConferenceCallingEnabled, - observeConversationDetails = observeConversationDetails, - observeParticipantsForConversation = observeParticipantsForConversation, - setUserInformedAboutVerification = setUserInformedAboutVerificationUseCase, - observeDegradedConversationNotified = observeDegradedConversationNotifiedUseCase - ) + callingEnabled.test { + assertEquals(Unit, awaitItem()) + } } @Test - fun `given join dialog displayed, when user dismiss it, then hide it`() { - conversationListCallViewModel.conversationCallViewState = conversationListCallViewModel.conversationCallViewState.copy( - shouldShowJoinAnywayDialog = true - ) + fun `given no calling enabled event, when we observe it, then there are no events propagated`() = runTest { + val (_, viewModel) = Arrangement() + .withDefaultAnswers() + .arrange() - conversationListCallViewModel.dismissJoinCallAnywayDialog() + val callingEnabled = viewModel.callingEnabled - assertEquals(false, conversationListCallViewModel.conversationCallViewState.shouldShowJoinAnywayDialog) + callingEnabled.test { + expectNoEvents() + } } - @Test - fun `given no ongoing call, when user tries to join a call, then invoke answerCall call use case`() { - conversationListCallViewModel.conversationCallViewState = - conversationListCallViewModel.conversationCallViewState.copy(hasEstablishedCall = false) + private class Arrangement { + @MockK + private lateinit var savedStateHandle: SavedStateHandle - coEvery { joinCall(conversationId = any()) } returns Unit + @MockK + private lateinit var observeOngoingCalls: ObserveOngoingCallsUseCase - conversationListCallViewModel.joinOngoingCall(onAnswered) + @MockK + private lateinit var observeEstablishedCalls: ObserveEstablishedCallsUseCase - coVerify(exactly = 1) { joinCall(conversationId = any()) } - coVerify(exactly = 1) { onAnswered(any()) } - assertEquals(false, conversationListCallViewModel.conversationCallViewState.shouldShowJoinAnywayDialog) - } + @MockK + lateinit var joinCall: AnswerCallUseCase - @Test - fun `given an ongoing call, when user tries to join a call, then show JoinCallAnywayDialog`() { - conversationListCallViewModel.conversationCallViewState = - conversationListCallViewModel.conversationCallViewState.copy(hasEstablishedCall = true) + @MockK + lateinit var endCall: EndCallUseCase - conversationListCallViewModel.joinOngoingCall(onAnswered) + @MockK + private lateinit var observeSyncState: ObserveSyncStateUseCase - assertEquals(true, conversationListCallViewModel.conversationCallViewState.shouldShowJoinAnywayDialog) - coVerify(inverse = true) { joinCall(conversationId = any()) } - } + @MockK + private lateinit var isConferenceCallingEnabled: IsEligibleToStartCallUseCase - @Test - fun `given an ongoing call, when user confirms dialog to join a call, then end current call and join the newer one`() { - conversationListCallViewModel.conversationCallViewState = - conversationListCallViewModel.conversationCallViewState.copy(hasEstablishedCall = true) - conversationListCallViewModel.establishedCallConversationId = ConversationId("value", "Domain") - coEvery { endCall(any()) } returns Unit + @MockK(relaxed = true) + lateinit var onAnswered: (conversationId: ConversationId) -> Unit + + @MockK + private lateinit var observeConversationDetails: ObserveConversationDetailsUseCase + + @MockK + private lateinit var observeParticipantsForConversation: ObserveParticipantsForConversationUseCase + + @MockK + lateinit var setUserInformedAboutVerificationUseCase: SetUserInformedAboutVerificationUseCase + + @MockK + lateinit var observeDegradedConversationNotifiedUseCase: ObserveDegradedConversationNotifiedUseCase + + @MockK + lateinit var getSelfUserUseCase: GetSelfUserUseCase + + @MockK + lateinit var observeConferenceCallingEnabled: ObserveConferenceCallingEnabledUseCase + + init { + MockKAnnotations.init(this) + } + + suspend fun withDefaultAnswers() = apply { + val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") + every { savedStateHandle.navArgs() } returns ConversationNavArgs(conversationId = conversationId) + coEvery { observeEstablishedCalls.invoke() } returns emptyFlow() + coEvery { observeOngoingCalls.invoke() } returns emptyFlow() + coEvery { observeConversationDetails(any()) } returns flowOf() + coEvery { observeParticipantsForConversation(any()) } returns flowOf() + coEvery { setUserInformedAboutVerificationUseCase(any()) } returns Unit + coEvery { observeDegradedConversationNotifiedUseCase(any()) } returns flowOf(false) + coEvery { getSelfUserUseCase() } returns flowOf() + coEvery { observeConferenceCallingEnabled() } returns flowOf() + } + + suspend fun withSelfAsAdmin() = apply { + coEvery { getSelfUserUseCase.invoke() } returns flowOf(TestUser.SELF_USER.copy(userType = UserType.ADMIN)) + } - conversationListCallViewModel.joinAnyway(onAnswered) + suspend fun withConferenceCallingEnabledResponse() = apply { + coEvery { observeConferenceCallingEnabled() } returns flowOf(Unit) + } - coVerify(exactly = 1) { endCall(any()) } + suspend fun withJoinCallResponse() = apply { + coEvery { joinCall(conversationId = any()) } returns Unit + } + + suspend fun withEndCallResponse() = apply { + coEvery { endCall(any()) } returns Unit + } + + fun arrange(): Pair = this to ConversationListCallViewModel( + savedStateHandle = savedStateHandle, + observeOngoingCalls = observeOngoingCalls, + observeEstablishedCalls = observeEstablishedCalls, + answerCall = joinCall, + endCall = endCall, + observeSyncState = observeSyncState, + isConferenceCallingEnabled = isConferenceCallingEnabled, + observeConversationDetails = observeConversationDetails, + observeParticipantsForConversation = observeParticipantsForConversation, + setUserInformedAboutVerification = setUserInformedAboutVerificationUseCase, + observeDegradedConversationNotified = observeDegradedConversationNotifiedUseCase, + observeConferenceCallingEnabled = observeConferenceCallingEnabled, + getSelf = getSelfUserUseCase + ) } } From d37cf33a94732b6e31227393d1eea36043471b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= <30429749+saleniuk@users.noreply.github.com> Date: Mon, 16 Dec 2024 10:34:26 +0100 Subject: [PATCH 07/23] fix: retain filters and simplify navigation [WPB-14518] (#3737) --- .../android/navigation/HomeDestination.kt | 78 +---------------- .../ui/calling/CallActivityViewModel.kt | 3 +- .../topappbar/ConversationFilterState.kt | 53 ++++++++++++ .../com/wire/android/ui/home/HomeScreen.kt | 21 ++--- .../wire/android/ui/home/HomeStateHolder.kt | 53 ++++++++---- .../com/wire/android/ui/home/HomeTopBar.kt | 17 +++- .../all/AllConversationsScreen.kt | 86 ++++--------------- .../filter/ConversationFilterSheetContent.kt | 5 +- .../model/ConversationsSource.kt | 10 +++ 9 files changed, 143 insertions(+), 183 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/common/topappbar/ConversationFilterState.kt diff --git a/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt b/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt index 42aed03d34a..24c30a17b74 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt @@ -19,22 +19,14 @@ package com.wire.android.navigation import androidx.annotation.DrawableRes -import androidx.navigation.NavBackStackEntry import com.ramcosta.composedestinations.spec.Direction import com.wire.android.R import com.wire.android.ui.destinations.AllConversationsScreenDestination import com.wire.android.ui.destinations.ArchiveScreenDestination -import com.wire.android.ui.destinations.FavoritesConversationsScreenDestination -import com.wire.android.ui.destinations.FolderConversationsScreenDestination -import com.wire.android.ui.destinations.GroupConversationsScreenDestination -import com.wire.android.ui.destinations.OneOnOneConversationsScreenDestination import com.wire.android.ui.destinations.SettingsScreenDestination import com.wire.android.ui.destinations.VaultScreenDestination import com.wire.android.ui.destinations.WhatsNewScreenDestination import com.wire.android.util.ui.UIText -import com.wire.kalium.logic.data.conversation.ConversationFilter -import kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.persistentListOf @Suppress("LongParameterList") sealed class HomeDestination( @@ -45,10 +37,6 @@ sealed class HomeDestination( val withUserAvatar: Boolean = true, val direction: Direction ) { - - internal fun NavBackStackEntry.baseRouteMatches(): Boolean = direction.route.getBaseRoute() == destination.route?.getBaseRoute() - open fun entryMatches(entry: NavBackStackEntry): Boolean = entry.baseRouteMatches() - data object Conversations : HomeDestination( title = UIText.StringResource(R.string.conversations_screen_title), icon = R.drawable.ic_conversation, @@ -57,43 +45,6 @@ sealed class HomeDestination( direction = AllConversationsScreenDestination ) - data object Favorites : HomeDestination( - title = UIText.StringResource(R.string.label_filter_favorites), - icon = R.drawable.ic_conversation, - isSearchable = true, - withNewConversationFab = true, - direction = FavoritesConversationsScreenDestination - ) - - data class Folder( - val folderNavArgs: FolderNavArgs - ) : HomeDestination( - title = UIText.DynamicString(folderNavArgs.folderName), - icon = R.drawable.ic_conversation, - isSearchable = true, - withNewConversationFab = true, - direction = FolderConversationsScreenDestination(folderNavArgs) - ) { - override fun entryMatches(entry: NavBackStackEntry): Boolean = - entry.baseRouteMatches() && FolderConversationsScreenDestination.argsFrom(entry).folderId == folderNavArgs.folderId - } - - data object Group : HomeDestination( - title = UIText.StringResource(R.string.label_filter_group), - icon = R.drawable.ic_conversation, - isSearchable = true, - withNewConversationFab = true, - direction = GroupConversationsScreenDestination - ) - - data object OneOnOne : HomeDestination( - title = UIText.StringResource(R.string.label_filter_one_on_one), - icon = R.drawable.ic_conversation, - isSearchable = true, - withNewConversationFab = true, - direction = OneOnOneConversationsScreenDestination - ) - data object Settings : HomeDestination( title = UIText.StringResource(R.string.settings_screen_title), icon = R.drawable.ic_settings, @@ -130,32 +81,11 @@ sealed class HomeDestination( companion object { private const val ITEM_NAME_PREFIX = "HomeNavigationItem." - fun values(): PersistentList = - persistentListOf(Conversations, Favorites, Group, OneOnOne, Settings, Vault, Archive, Support, WhatsNew) - } -} -fun HomeDestination.currentFilter(): ConversationFilter { - return when (this) { - HomeDestination.Conversations -> ConversationFilter.All - HomeDestination.Favorites -> ConversationFilter.Favorites - HomeDestination.Group -> ConversationFilter.Groups - HomeDestination.OneOnOne -> ConversationFilter.OneOnOne - is HomeDestination.Folder -> ConversationFilter.Folder(folderName = folderNavArgs.folderName, folderId = folderNavArgs.folderId) - HomeDestination.Archive, - HomeDestination.Settings, - HomeDestination.Support, - HomeDestination.Vault, - HomeDestination.WhatsNew -> ConversationFilter.All - } -} + fun fromRoute(fullRoute: String): HomeDestination? = + values().find { it.direction.route.getBaseRoute() == fullRoute.getBaseRoute() } -fun ConversationFilter.toDestination(): HomeDestination { - return when (this) { - ConversationFilter.All -> HomeDestination.Conversations - ConversationFilter.Favorites -> HomeDestination.Favorites - ConversationFilter.Groups -> HomeDestination.Group - ConversationFilter.OneOnOne -> HomeDestination.OneOnOne - is ConversationFilter.Folder -> HomeDestination.Folder(FolderNavArgs(folderId, folderName)) + fun values(): Array = + arrayOf(Conversations, Settings, Vault, Archive, Support, WhatsNew) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/CallActivityViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/CallActivityViewModel.kt index 0dd6344b588..004667bb057 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/CallActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/CallActivityViewModel.kt @@ -30,7 +30,6 @@ import com.wire.kalium.logic.feature.session.CurrentSessionUseCase import com.wire.kalium.logic.feature.user.screenshotCensoring.ObserveScreenshotCensoringConfigResult import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -61,7 +60,7 @@ class CallActivityViewModel @Inject constructor( } fun switchAccountIfNeeded(userId: UserId, actions: SwitchAccountActions) { - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launch(dispatchers.io()) { val shouldSwitchAccount = when (val result = currentSession()) { is CurrentSessionResult.Failure.Generic -> true CurrentSessionResult.Failure.SessionNotFound -> true diff --git a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/ConversationFilterState.kt b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/ConversationFilterState.kt new file mode 100644 index 00000000000..f9bb13e28fc --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/ConversationFilterState.kt @@ -0,0 +1,53 @@ +/* + * 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.ui.common.topappbar + +import android.os.Bundle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.wire.kalium.logic.data.conversation.ConversationFilter +import dev.ahmedmourad.bundlizer.Bundlizer + +@Composable +fun rememberConversationFilterState(): ConversationFilterState = rememberSaveable(saver = ConversationFilterState.saver()) { + ConversationFilterState() +} + +class ConversationFilterState(initialValue: ConversationFilter = ConversationFilter.All) { + var filter: ConversationFilter by mutableStateOf(initialValue) + private set + + fun changeFilter(newFilter: ConversationFilter) { + filter = newFilter + } + + companion object { + fun saver(): Saver = Saver( + save = { + Bundlizer.bundle(ConversationFilter.serializer(), it.filter) + }, + restore = { + ConversationFilterState(Bundlizer.unbundle(ConversationFilter.serializer(), it)) + } + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt index 32907047c6e..3b48dd13629 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeScreen.kt @@ -38,7 +38,6 @@ import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter @@ -64,13 +63,11 @@ import com.ramcosta.composedestinations.result.ResultRecipient import com.wire.android.R import com.wire.android.appLogger import com.wire.android.di.hiltViewModelScoped -import com.wire.android.navigation.FolderNavArgs import com.wire.android.navigation.HomeDestination import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.WireDestination import com.wire.android.navigation.handleNavigation -import com.wire.android.navigation.toDestination import com.wire.android.ui.NavGraphs import com.wire.android.ui.analytics.AnalyticsUsageViewModel import com.wire.android.ui.common.CollapsingTopBarScaffold @@ -121,14 +118,8 @@ fun HomeScreen( ) ) { homeViewModel.checkRequirements { it.navigate(navigator::navigate) } - val homeDestinations = remember(foldersViewModel.state().folders) { - HomeDestination.values() - .plus( - foldersViewModel.state().folders.map { HomeDestination.Folder(FolderNavArgs(it.id, it.name)) } - ) - } - val homeScreenState = rememberHomeScreenState(navigator, homeDestinations = homeDestinations) + val homeScreenState = rememberHomeScreenState(navigator) val notificationsPermissionDeniedDialogState = rememberVisibilityState() val showNotificationsPermissionDeniedDialog = { notificationsPermissionDeniedDialogState.show( @@ -318,6 +309,8 @@ fun HomeContent( exit = shrinkVertically() + fadeOut(), ) { HomeTopBar( + title = currentTitle.asString(), + currentFilter = currentConversationFilter, navigationItem = currentNavigationItem, userAvatarData = homeState.userAvatarData, elevation = dimensions().spacing0x, // CollapsingTopBarScaffold manages applied elevation @@ -347,7 +340,7 @@ fun HomeContent( } }, collapsingEnabled = !searchBarState.isSearchActive, - contentLazyListState = homeStateHolder.nullAbleLazyListStateFor(currentNavigationItem), + contentLazyListState = homeStateHolder.lazyListStateFor(currentNavigationItem), content = { /** * This "if" is a workaround, otherwise it can crash because of the SubcomposeLayout's nature. @@ -407,11 +400,7 @@ fun HomeContent( ConversationFilterSheetContent( onChangeFilter = { filter -> filterSheetState.hide() - openHomeDestination(filter.toDestination()) - }, - onChangeFolder = { - filterSheetState.hide() - openHomeDestination(it.toDestination()) + homeStateHolder.changeFilter(filter) }, filterSheetState = sheetContentState ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt index f6e802ce309..ed39cb797a8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeStateHolder.kt @@ -19,7 +19,6 @@ package com.wire.android.ui.home import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.material3.rememberDrawerState @@ -34,10 +33,13 @@ import androidx.navigation.compose.currentBackStackEntryAsState import com.wire.android.navigation.HomeDestination import com.wire.android.navigation.HomeDestination.Conversations import com.wire.android.navigation.Navigator -import com.wire.android.navigation.getBaseRoute import com.wire.android.navigation.rememberTrackingAnimatedNavController +import com.wire.android.ui.common.topappbar.ConversationFilterState +import com.wire.android.ui.common.topappbar.rememberConversationFilterState import com.wire.android.ui.common.topappbar.search.SearchBarState import com.wire.android.ui.common.topappbar.search.rememberSearchbarState +import com.wire.android.ui.home.conversationslist.filter.uiText +import com.wire.kalium.logic.data.conversation.ConversationFilter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -49,18 +51,34 @@ class HomeStateHolder( val searchBarState: SearchBarState, val navigator: Navigator, private val currentNavigationItemState: State, - private val lazyListStates: Map, + private val conversationFilterState: ConversationFilterState, ) { val currentNavigationItem get() = currentNavigationItemState.value - fun lazyListStateFor(destination: HomeDestination): LazyListState { - return lazyListStates[destination] ?: error("No LazyListState found for $destination") - } + val currentConversationFilter + get() = conversationFilterState.filter - fun nullAbleLazyListStateFor(destination: HomeDestination): LazyListState? { - return lazyListStates[destination] - } + val currentTitle + get() = when (currentNavigationItemState.value) { + Conversations -> conversationFilterState.filter.uiText() + else -> currentNavigationItemState.value.title + } + + private val lazyListStatesMap = mutableMapOf() + + fun lazyListStateFor( + destination: HomeDestination, + conversationFilter: ConversationFilter = ConversationFilter.All, + ): LazyListState = + lazyListStatesMap.getOrPut( + key = destination.itemName + when (destination) { + Conversations -> ":$conversationFilter" // each filter has its own scroll state + else -> "" // other destinations shouldn't care about the conversation filter + } + ) { + LazyListState() + } fun closeDrawer() { coroutineScope.launch { @@ -73,15 +91,16 @@ class HomeStateHolder( drawerState.open() } } + + fun changeFilter(filter: ConversationFilter) = conversationFilterState.changeFilter(filter) } @Composable fun rememberHomeScreenState( navigator: Navigator, - homeDestinations: List, coroutineScope: CoroutineScope = rememberCoroutineScope(), - navController: NavHostController = rememberTrackingAnimatedNavController { route -> - homeDestinations.find { it.direction.route.getBaseRoute() == route }?.itemName + navController: NavHostController = rememberTrackingAnimatedNavController { + HomeDestination.fromRoute(it)?.itemName }, drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed) ): HomeStateHolder { @@ -89,14 +108,14 @@ fun rememberHomeScreenState( val searchBarState = rememberSearchbarState() val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentNavigationItemState = remember(homeDestinations) { + val currentNavigationItemState = remember { derivedStateOf { - navBackStackEntry?.let { entry -> homeDestinations.find { it.entryMatches(entry) } } ?: Conversations + navBackStackEntry?.destination?.route?.let { HomeDestination.fromRoute(it) } ?: Conversations } } - val lazyListStates = homeDestinations.associateWith { rememberLazyListState() } + val conversationFilterState = rememberConversationFilterState() - return remember(homeDestinations) { + return remember { HomeStateHolder( coroutineScope = coroutineScope, navController = navController, @@ -104,7 +123,7 @@ fun rememberHomeScreenState( searchBarState = searchBarState, navigator = navigator, currentNavigationItemState = currentNavigationItemState, - lazyListStates = lazyListStates + conversationFilterState = conversationFilterState, ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt b/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt index 28039c3ea12..cc0ea36b60d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/HomeTopBar.kt @@ -28,7 +28,6 @@ import com.wire.android.model.Clickable import com.wire.android.model.NameBasedAvatar import com.wire.android.model.UserAvatarData import com.wire.android.navigation.HomeDestination -import com.wire.android.navigation.currentFilter import com.wire.android.ui.common.avatar.UserProfileAvatar import com.wire.android.ui.common.avatar.UserProfileAvatarType import com.wire.android.ui.common.button.WireButtonState @@ -42,6 +41,8 @@ import com.wire.kalium.logic.data.user.UserAvailabilityStatus @Composable fun HomeTopBar( + title: String, + currentFilter: ConversationFilter, navigationItem: HomeDestination, userAvatarData: UserAvatarData, elevation: Dp, @@ -52,7 +53,7 @@ fun HomeTopBar( onOpenConversationFilter: (filter: ConversationFilter) -> Unit ) { WireCenterAlignedTopAppBar( - title = navigationItem.title.asString(), + title = title, onNavigationPressed = onHamburgerMenuClick, navigationIconType = NavigationIconType.Menu, actions = { @@ -60,12 +61,12 @@ fun HomeTopBar( WireTertiaryIconButton( iconResource = R.drawable.ic_filter, contentDescription = R.string.label_filter_conversations, - state = if (navigationItem.currentFilter() == ConversationFilter.All) { + state = if (currentFilter == ConversationFilter.All) { WireButtonState.Default } else { WireButtonState.Selected }, - onButtonClicked = { onOpenConversationFilter(navigationItem.currentFilter()) } + onButtonClicked = { onOpenConversationFilter(currentFilter) } ) } if (navigationItem.withUserAvatar) { @@ -100,7 +101,9 @@ fun HomeTopBar( fun PreviewTopBar() { WireTheme { HomeTopBar( + title = "Conversations", navigationItem = HomeDestination.Conversations, + currentFilter = ConversationFilter.All, userAvatarData = UserAvatarData(null, UserAvailabilityStatus.AVAILABLE), elevation = 0.dp, withLegalHoldIndicator = false, @@ -117,7 +120,9 @@ fun PreviewTopBar() { fun PreviewSettingsTopBarWithoutAvatar() { WireTheme { HomeTopBar( + title = "Settings", navigationItem = HomeDestination.Settings, + currentFilter = ConversationFilter.All, userAvatarData = UserAvatarData(null, UserAvailabilityStatus.AVAILABLE), elevation = 0.dp, withLegalHoldIndicator = false, @@ -134,7 +139,9 @@ fun PreviewSettingsTopBarWithoutAvatar() { fun PreviewTopBarWithNameBasedAvatar() { WireTheme { HomeTopBar( + title = "Conversations", navigationItem = HomeDestination.Conversations, + currentFilter = ConversationFilter.All, userAvatarData = UserAvatarData( asset = null, availabilityStatus = UserAvailabilityStatus.AVAILABLE, @@ -155,7 +162,9 @@ fun PreviewTopBarWithNameBasedAvatar() { fun PreviewTopBarWithLegalHold() { WireTheme { HomeTopBar( + title = "Archive", navigationItem = HomeDestination.Archive, + currentFilter = ConversationFilter.All, userAvatarData = UserAvatarData(null, UserAvailabilityStatus.AVAILABLE), elevation = 0.dp, withLegalHoldIndicator = true, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt index 03318926dc3..3f9ff8fe23a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/all/AllConversationsScreen.kt @@ -18,9 +18,9 @@ package com.wire.android.ui.home.conversationslist.all +import androidx.compose.animation.Crossfade import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Composable -import com.wire.android.navigation.FolderNavArgs import com.wire.android.navigation.HomeDestination import com.wire.android.navigation.HomeNavGraph import com.wire.android.navigation.WireDestination @@ -41,72 +41,24 @@ import kotlinx.coroutines.flow.flowOf @Composable fun AllConversationsScreen(homeStateHolder: HomeStateHolder) { with(homeStateHolder) { - ConversationsScreenContent( - navigator = navigator, - searchBarState = searchBarState, - conversationsSource = ConversationsSource.MAIN, - lazyListState = lazyListStateFor(HomeDestination.Conversations), - emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.All) } - ) - } -} - -@HomeNavGraph -@WireDestination -@Composable -fun FavoritesConversationsScreen(homeStateHolder: HomeStateHolder) { - with(homeStateHolder) { - ConversationsScreenContent( - navigator = navigator, - searchBarState = searchBarState, - conversationsSource = ConversationsSource.FAVORITES, - lazyListState = lazyListStateFor(HomeDestination.Favorites), - emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.Favorites) } - ) - } -} - -@HomeNavGraph -@WireDestination(navArgsDelegate = FolderNavArgs::class) -@Composable -fun FolderConversationsScreen(homeStateHolder: HomeStateHolder, args: FolderNavArgs) { - with(homeStateHolder) { - ConversationsScreenContent( - navigator = navigator, - searchBarState = searchBarState, - conversationsSource = ConversationsSource.FOLDER(args.folderId, args.folderName), - emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.Folder(args.folderId, args.folderName)) } - ) - } -} - -@HomeNavGraph -@WireDestination -@Composable -fun GroupConversationsScreen(homeStateHolder: HomeStateHolder) { - with(homeStateHolder) { - ConversationsScreenContent( - navigator = navigator, - searchBarState = searchBarState, - conversationsSource = ConversationsSource.GROUPS, - lazyListState = lazyListStateFor(HomeDestination.Group), - emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.Groups) } - ) - } -} - -@HomeNavGraph -@WireDestination -@Composable -fun OneOnOneConversationsScreen(homeStateHolder: HomeStateHolder) { - with(homeStateHolder) { - ConversationsScreenContent( - navigator = navigator, - searchBarState = searchBarState, - conversationsSource = ConversationsSource.ONE_ON_ONE, - lazyListState = lazyListStateFor(HomeDestination.OneOnOne), - emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.OneOnOne, domain = it) } - ) + Crossfade( + targetState = homeStateHolder.currentConversationFilter, + label = "Conversation filter change animation", + ) { filter -> + ConversationsScreenContent( + navigator = navigator, + searchBarState = searchBarState, + conversationsSource = when (filter) { + is ConversationFilter.All -> ConversationsSource.MAIN + is ConversationFilter.Favorites -> ConversationsSource.FAVORITES + is ConversationFilter.Groups -> ConversationsSource.GROUPS + is ConversationFilter.OneOnOne -> ConversationsSource.ONE_ON_ONE + is ConversationFilter.Folder -> ConversationsSource.FOLDER(filter.folderId, filter.folderName) + }, + lazyListState = lazyListStateFor(HomeDestination.Conversations, filter), + emptyListContent = { ConversationsEmptyContent(filter = ConversationFilter.All) } + ) + } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetContent.kt index 0dcb4da607f..4ae6fca2f91 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/filter/ConversationFilterSheetContent.kt @@ -28,7 +28,6 @@ import com.wire.kalium.logic.data.conversation.ConversationFilter fun ConversationFilterSheetContent( filterSheetState: ConversationFilterSheetState, onChangeFilter: (ConversationFilter) -> Unit, - onChangeFolder: (ConversationFilter.Folder) -> Unit, isBottomSheetVisible: () -> Boolean = { true } ) { when (filterSheetState.currentData.tab) { @@ -45,7 +44,7 @@ fun ConversationFilterSheetContent( FilterTab.FOLDERS -> { ConversationFoldersSheetContent( sheetData = filterSheetState.currentData, - onChangeFolder = onChangeFolder, + onChangeFolder = onChangeFilter, onBackClick = { filterSheetState.toFilters() } @@ -77,5 +76,5 @@ fun ConversationFilter.uiText(): UIText = when (this) { ConversationFilter.Favorites -> UIText.StringResource(R.string.label_filter_favorites) ConversationFilter.Groups -> UIText.StringResource(R.string.label_filter_group) ConversationFilter.OneOnOne -> UIText.StringResource(R.string.label_filter_one_on_one) - is ConversationFilter.Folder -> UIText.StringResource(R.string.label_filter_folders, this.folderName) + is ConversationFilter.Folder -> UIText.DynamicString(this.folderName) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationsSource.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationsSource.kt index 45a7aad4391..4efe537856a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationsSource.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/ConversationsSource.kt @@ -22,11 +22,21 @@ import kotlinx.serialization.Serializable @Serializable sealed class ConversationsSource { + @Serializable data object MAIN : ConversationsSource() + + @Serializable data object ARCHIVE : ConversationsSource() + + @Serializable data object FAVORITES : ConversationsSource() + + @Serializable data object GROUPS : ConversationsSource() + + @Serializable data object ONE_ON_ONE : ConversationsSource() + @Serializable data class FOLDER(val folderId: String, val folderName: String) : ConversationsSource() } From a9a85bebf72170aff14618940eb1a70173bdef2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Zag=C3=B3rski?= Date: Mon, 16 Dec 2024 14:16:34 +0100 Subject: [PATCH 08/23] feat: Custom server no network dialog #WPB-11627 (#3543) --- .../com/wire/android/ui/WireActivity.kt | 18 +++++----- .../wire/android/ui/WireActivityDialogs.kt | 13 +++++--- .../wire/android/ui/WireActivityViewModel.kt | 21 +++++++----- ...alog.kt => CustomServerNoNetworkDialog.kt} | 33 ++++++++++++------- app/src/main/res/values-hu/strings.xml | 3 +- app/src/main/res/values-ru/strings.xml | 3 +- app/src/main/res/values/strings.xml | 5 +-- .../android/ui/WireActivityViewModelTest.kt | 16 ++++----- 8 files changed, 63 insertions(+), 49 deletions(-) rename app/src/main/kotlin/com/wire/android/ui/common/dialogs/{CustomServerInvalidJsonDialog.kt => CustomServerNoNetworkDialog.kt} (59%) diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt index a95911f1af0..54d9e7ff6d6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivity.kt @@ -517,16 +517,14 @@ class WireActivity : AppCompatActivity() { ) CustomBackendDialog( viewModel.globalAppState, - viewModel::dismissCustomBackendDialog - ) { - viewModel.customBackendDialogProceedButtonClicked { - navigate( - NavigationCommand( - WelcomeScreenDestination - ) - ) - } - } + viewModel::dismissCustomBackendDialog, + onConfirm = { + viewModel.customBackendDialogProceedButtonClicked { + navigate(NavigationCommand(WelcomeScreenDestination)) + } + }, + onTryAgain = viewModel::onCustomServerConfig + ) MaxAccountDialog( shouldShow = viewModel.globalAppState.maxAccountDialog, onConfirm = { diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityDialogs.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityDialogs.kt index 8b8ee6e66b3..564950de627 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityDialogs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityDialogs.kt @@ -57,8 +57,8 @@ import com.wire.android.ui.common.button.WireButtonState import com.wire.android.ui.common.button.WireSecondaryButton import com.wire.android.ui.common.dialogs.CustomServerDetailsDialog import com.wire.android.ui.common.dialogs.CustomServerDetailsDialogState -import com.wire.android.ui.common.dialogs.CustomServerInvalidJsonDialog -import com.wire.android.ui.common.dialogs.CustomServerInvalidJsonDialogState +import com.wire.android.ui.common.dialogs.CustomServerNoNetworkDialog +import com.wire.android.ui.common.dialogs.CustomServerNoNetworkDialogState import com.wire.android.ui.common.dialogs.MaxAccountAllowedDialogContent import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.wireDialogPropertiesBuilder @@ -244,7 +244,8 @@ fun JoinConversationDialog( fun CustomBackendDialog( globalAppState: GlobalAppState, onDismiss: () -> Unit, - onConfirm: () -> Unit + onConfirm: () -> Unit, + onTryAgain: (String) -> Unit ) { when (globalAppState.customBackendDialog) { is CustomServerDetailsDialogState -> { @@ -255,8 +256,9 @@ fun CustomBackendDialog( ) } - is CustomServerInvalidJsonDialogState -> { - CustomServerInvalidJsonDialog( + is CustomServerNoNetworkDialogState -> { + CustomServerNoNetworkDialog( + onTryAgain = { onTryAgain(globalAppState.customBackendDialog.customServerUrl) }, onDismiss = onDismiss ) } @@ -581,6 +583,7 @@ fun PreviewCustomBackendDialog() { ) ), {}, + {}, {} ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt index d4ee4acfd2b..fc173d079cb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt @@ -42,7 +42,7 @@ import com.wire.android.services.ServicesManager import com.wire.android.ui.authentication.devices.model.displayName import com.wire.android.ui.common.dialogs.CustomServerDetailsDialogState import com.wire.android.ui.common.dialogs.CustomServerDialogState -import com.wire.android.ui.common.dialogs.CustomServerInvalidJsonDialogState +import com.wire.android.ui.common.dialogs.CustomServerNoNetworkDialogState import com.wire.android.ui.joinConversation.JoinConversationViaCodeState import com.wire.android.ui.theme.ThemeOption import com.wire.android.util.CurrentScreen @@ -320,7 +320,7 @@ class WireActivityViewModel @Inject constructor( when (val result = deepLinkProcessor.get().invoke(intent?.data, isSharingIntent)) { DeepLinkResult.AuthorizationNeeded -> onAuthorizationNeeded() is DeepLinkResult.SSOLogin -> onSSOLogin(result) - is DeepLinkResult.CustomServerConfig -> onCustomServerConfig(result) + is DeepLinkResult.CustomServerConfig -> onCustomServerConfig(result.url) is DeepLinkResult.Failure.OngoingCall -> onCannotLoginDuringACall() is DeepLinkResult.Failure.Unknown -> appLogger.e("unknown deeplink failure") is DeepLinkResult.JoinConversation -> onConversationInviteDeepLink( @@ -429,13 +429,16 @@ class WireActivityViewModel @Inject constructor( } } - private suspend fun onCustomServerConfig(result: DeepLinkResult.CustomServerConfig) { - val customBackendDialogData = loadServerConfig(result.url)?.let { serverLinks -> - CustomServerDetailsDialogState(serverLinks = serverLinks) - } ?: CustomServerInvalidJsonDialogState - globalAppState = globalAppState.copy( - customBackendDialog = customBackendDialogData - ) + fun onCustomServerConfig(customServerUrl: String) { + viewModelScope.launch(dispatchers.io()) { + val customBackendDialogData = loadServerConfig(customServerUrl) + ?.let { serverLinks -> CustomServerDetailsDialogState(serverLinks = serverLinks) } + ?: CustomServerNoNetworkDialogState(customServerUrl) + + globalAppState = globalAppState.copy( + customBackendDialog = customBackendDialogData + ) + } } private suspend fun onConversationInviteDeepLink( diff --git a/app/src/main/kotlin/com/wire/android/ui/common/dialogs/CustomServerInvalidJsonDialog.kt b/app/src/main/kotlin/com/wire/android/ui/common/dialogs/CustomServerNoNetworkDialog.kt similarity index 59% rename from app/src/main/kotlin/com/wire/android/ui/common/dialogs/CustomServerInvalidJsonDialog.kt rename to app/src/main/kotlin/com/wire/android/ui/common/dialogs/CustomServerNoNetworkDialog.kt index 356d9b12f17..1ad8d752f0b 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/dialogs/CustomServerInvalidJsonDialog.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/dialogs/CustomServerNoNetworkDialog.kt @@ -28,29 +28,40 @@ import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes @Composable -internal fun CustomServerInvalidJsonDialog( +internal fun CustomServerNoNetworkDialog( + onTryAgain: () -> Unit, onDismiss: () -> Unit ) { WireDialog( - title = stringResource(R.string.custom_backend_invalid_deeplink_data_title), - text = stringResource(R.string.custom_backend_invalid_deeplink_data_body), + title = stringResource(R.string.custom_backend_error_title), + text = stringResource(R.string.custom_backend_error_no_internet_connection_body), onDismiss = onDismiss, + buttonsHorizontalAlignment = false, optionButton1Properties = WireDialogButtonProperties( - onClick = onDismiss, - text = stringResource(id = R.string.label_ok), + onClick = { + onTryAgain() + onDismiss() + }, + text = stringResource(id = R.string.custom_backend_error_no_internet_connection_try_again), type = WireDialogButtonType.Primary, - state = - WireButtonState.Default + state = WireButtonState.Default ), + optionButton2Properties = WireDialogButtonProperties( + onClick = onDismiss, + text = stringResource(id = R.string.label_cancel), + type = WireDialogButtonType.Secondary, + state = WireButtonState.Default + ) ) } -data object CustomServerInvalidJsonDialogState : CustomServerDialogState() +data class CustomServerNoNetworkDialogState(val customServerUrl: String) : CustomServerDialogState() @PreviewMultipleThemes @Composable -fun PreviewCustomServerInvalidJsonDialog() = WireTheme { - CustomServerInvalidJsonDialog( - onDismiss = { } +fun PreviewCustomServerNoNetworkDialog() = WireTheme { + CustomServerNoNetworkDialog( + onTryAgain = {}, + onDismiss = {} ) } diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index ff5e1b7e98a..57ed2e7001d 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1133,8 +1133,7 @@ Ez a beállítás az összes beszélgetésre érvényes ezen az eszközön.Fiókok URL: Honlap URL: Kiszolgáló WSURL: - Hiba történt - A saját kiszolgálóra történő átirányítás nem volt lehetséges, mivel a JSON fájl érvénytelen beállítást tartalmazott.\n\nLépjen kapcsolatba a rendszergazdával, vagy ellenőrizze a mélylinket, ami ide vezette. + Hiba történt Új üzenetek lekérdezése Szöveg a vágólapra másolva Naplók diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 8dfcce63cb6..99ecf9a8a1b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1171,8 +1171,7 @@ URL аккаунта: URL веб-сайта: WSURL бэкэнда: - Произошла ошибка - Перенаправление на локальный бэкэнд было неудачным, поскольку в JSON-файле была неверная конфигурация.\n\nСвяжитесь с администратором или проверьте ссылку, которая привела вас сюда. + Произошла ошибка Получение новых сообщений Текст скопирован в буфер обмена Журналы diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 718e9d41961..4811da7ef86 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1183,8 +1183,9 @@ In group conversations, the group admin can overwrite this setting. Accounts URL: Website URL: Backend WSURL: - An error occurred - Redirecting to an on-premises backend was not possible, as there was an invalid configuration in the JSON file.\n\nContact your admin or check the deeplink that brought you here. + An error occurred + Redirecting to an on-premises backend was not possible, you don’t seem to be connected to the internet.\n\nEstablish an internet connection and try again. + Try again Receiving new messages Text copied to clipboard Logs diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt index 6169a00d52f..f7070a642bf 100644 --- a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt @@ -38,7 +38,7 @@ import com.wire.android.framework.TestUser import com.wire.android.migration.MigrationManager import com.wire.android.services.ServicesManager import com.wire.android.ui.common.dialogs.CustomServerDetailsDialogState -import com.wire.android.ui.common.dialogs.CustomServerInvalidJsonDialogState +import com.wire.android.ui.common.dialogs.CustomServerNoNetworkDialogState import com.wire.android.ui.common.topappbar.CommonTopAppBarViewModelTest import com.wire.android.ui.joinConversation.JoinConversationViaCodeState import com.wire.android.ui.theme.ThemeOption @@ -141,13 +141,13 @@ class WireActivityViewModelTest { } @Test - fun `given Intent with malformed ServerConfig json, when currentSessions is present, then initialAppState is LOGGED_IN and customBackEndInvalidJson dialog is shown`() = + fun `given intent with correct ServerConfig json, when no network is present, then initialAppState is LOGGED_IN and no network dialog is shown`() = runTest { val result = DeepLinkResult.CustomServerConfig("url") val (arrangement, viewModel) = Arrangement() .withSomeCurrentSession() .withDeepLinkResult(result) - .withMalformedServerJson() + .withNoNetworkConnectionWhenGettingServerConfig() .withNoOngoingCall() .arrange() @@ -155,17 +155,17 @@ class WireActivityViewModelTest { assertEquals(InitialAppState.LOGGED_IN, viewModel.initialAppState()) verify(exactly = 0) { arrangement.onDeepLinkResult(any()) } - assertInstanceOf(CustomServerInvalidJsonDialogState::class.java, viewModel.globalAppState.customBackendDialog) + assertInstanceOf(CustomServerNoNetworkDialogState::class.java, viewModel.globalAppState.customBackendDialog) } @Test - fun `given Intent with malformed ServerConfig json, when currentSessions is present, then initialAppState is NOT_LOGGED_IN and customBackEndInvalidJson dialog is shown`() = + fun `given Intent with malformed ServerConfig json, when currentSessions is absent, then initialAppState is NOT_LOGGED_IN and no network dialog is shown`() = runTest { val result = DeepLinkResult.CustomServerConfig("url") val (arrangement, viewModel) = Arrangement() .withNoCurrentSession() .withDeepLinkResult(result) - .withMalformedServerJson() + .withNoNetworkConnectionWhenGettingServerConfig() .withNoOngoingCall() .arrange() @@ -173,7 +173,7 @@ class WireActivityViewModelTest { assertEquals(InitialAppState.NOT_LOGGED_IN, viewModel.initialAppState()) verify(exactly = 0) { arrangement.onDeepLinkResult(any()) } - assertInstanceOf(CustomServerInvalidJsonDialogState::class.java, viewModel.globalAppState.customBackendDialog) + assertInstanceOf(CustomServerNoNetworkDialogState::class.java, viewModel.globalAppState.customBackendDialog) } @Test @@ -919,7 +919,7 @@ class WireActivityViewModelTest { coEvery { coreLogic.getSessionScope(TEST_ACCOUNT_INFO.userId).observeIfE2EIRequiredDuringLogin() } returns flowOf(false) } - fun withMalformedServerJson() = apply { + fun withNoNetworkConnectionWhenGettingServerConfig() = apply { coEvery { getServerConfigUseCase(any()) } returns GetServerConfigResult.Failure.Generic(NetworkFailure.NoNetworkConnection(null)) } From 7a53935227c43625454eddaf1439cc96b80eeab9 Mon Sep 17 00:00:00 2001 From: Yamil Medina Date: Mon, 16 Dec 2024 10:59:00 -0300 Subject: [PATCH 09/23] fix: harden countly sdk integration (WPB-15007) (#3746) --- .../AnonymousAnalyticsRecorderImpl.kt | 66 ++++++++++++------- 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt index 0bb72c5545d..71fe3723667 100644 --- a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt +++ b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt @@ -20,6 +20,7 @@ package com.wire.android.feature.analytics import android.app.Activity import android.app.Application import android.content.Context +import android.util.Log import com.wire.android.feature.analytics.model.AnalyticsEvent import com.wire.android.feature.analytics.model.AnalyticsEventConstants import com.wire.android.feature.analytics.model.AnalyticsSettings @@ -34,8 +35,8 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { override fun configure( context: Context, analyticsSettings: AnalyticsSettings - ) { - if (isConfigured) return + ) = wrapCountlyRequest { + if (isConfigured) return@wrapCountlyRequest val countlyConfig = CountlyConfig( context, @@ -54,24 +55,24 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { } } - Countly.sharedInstance().init(countlyConfig) - Countly.sharedInstance().consent().giveConsent(arrayOf("apm")) + Countly.sharedInstance()?.init(countlyConfig) + Countly.sharedInstance()?.consent()?.giveConsent(arrayOf("apm")) val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) val globalSegmentations = mapOf( AnalyticsEventConstants.APP_NAME to AnalyticsEventConstants.APP_NAME_ANDROID, AnalyticsEventConstants.APP_VERSION to packageInfo.versionName ) - Countly.sharedInstance().views().setGlobalViewSegmentation(globalSegmentations) + Countly.sharedInstance()?.views()?.setGlobalViewSegmentation(globalSegmentations) isConfigured = true } - override fun onStart(activity: Activity) { - Countly.sharedInstance().onStart(activity) + override fun onStart(activity: Activity) = wrapCountlyRequest { + Countly.sharedInstance()?.onStart(activity) } - override fun onStop() { - Countly.sharedInstance().onStop() + override fun onStop() = wrapCountlyRequest { + Countly.sharedInstance()?.onStop() } /** @@ -79,11 +80,11 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { * Countly is doing additional operations on it. * See [UtilsInternalLimits.removeUnsupportedDataTypes] */ - override fun sendEvent(event: AnalyticsEvent) { - Countly.sharedInstance().events().recordEvent(event.key, event.toSegmentation().toMutableMap()) + override fun sendEvent(event: AnalyticsEvent) = wrapCountlyRequest { + Countly.sharedInstance()?.events()?.recordEvent(event.key, event.toSegmentation().toMutableMap()) } - override fun halt() { + override fun halt() = wrapCountlyRequest { isConfigured = false Countly.sharedInstance().consent().removeConsentAll() } @@ -93,7 +94,9 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { isTeamMember: Boolean, migrationComplete: suspend () -> Unit ) { - Countly.sharedInstance().deviceId().changeWithMerge(identifier).also { + wrapCountlyRequest { + Countly.sharedInstance()?.deviceId()?.changeWithMerge(identifier) + }.also { migrationComplete() } @@ -106,7 +109,9 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { isTeamMember: Boolean, propagateIdentifier: suspend () -> Unit ) { - Countly.sharedInstance().deviceId().changeWithoutMerge(identifier) + wrapCountlyRequest { + Countly.sharedInstance()?.deviceId()?.changeWithoutMerge(identifier) + } setUserProfileProperties(isTeamMember = isTeamMember) @@ -115,27 +120,42 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { } } - private fun setUserProfileProperties(isTeamMember: Boolean) { - Countly.sharedInstance().userProfile().setProperty( + private fun setUserProfileProperties(isTeamMember: Boolean) = wrapCountlyRequest { + Countly.sharedInstance()?.userProfile()?.setProperty( AnalyticsEventConstants.TEAM_IS_TEAM, isTeamMember ) - Countly.sharedInstance().userProfile().save() + Countly.sharedInstance()?.userProfile()?.save() } override fun isAnalyticsInitialized(): Boolean = Countly.sharedInstance().isInitialized - override fun applicationOnCreate() { - if (isConfigured) return + override fun applicationOnCreate() = wrapCountlyRequest { + if (isConfigured) return@wrapCountlyRequest Countly.applicationOnCreate() } - override fun recordView(screen: String) { - Countly.sharedInstance().views().startAutoStoppedView(screen) + override fun recordView(screen: String) = wrapCountlyRequest { + Countly.sharedInstance()?.views()?.startAutoStoppedView(screen) + } + + override fun stopView(screen: String) = wrapCountlyRequest { + Countly.sharedInstance()?.views()?.stopViewWithName(screen) + } + + @Suppress("TooGenericExceptionCaught") + private fun wrapCountlyRequest(block: () -> Unit) { + try { + block() + } catch (e: Exception) { + // Countly SDK throws exceptions on some cases, just log it + // We don't want to crash the app because of that. + Log.wtf(TAG, "Countly SDK request failed", e) + } } - override fun stopView(screen: String) { - Countly.sharedInstance().views().stopViewWithName(screen) + companion object { + private const val TAG = "AnonymousAnalyticsRecorderImpl" } } From 842aa6a7c5aa7c8aa5d71212d9017a4a53c6917b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Saleniuk?= <30429749+saleniuk@users.noreply.github.com> Date: Mon, 16 Dec 2024 16:03:51 +0100 Subject: [PATCH 10/23] fix: non-paginated conversation list is not loading [WPB-15066] (#3747) --- .../wire/android/navigation/NavigationUtils.kt | 2 +- .../ConversationListViewModel.kt | 17 +++++++---------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt b/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt index 24944b89d3e..9ba8c69233c 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/NavigationUtils.kt @@ -45,7 +45,7 @@ internal fun NavController.navigateToItem(command: NavigationCommand) { fun lastDestinationFromOtherGraph(graph: NavGraphSpec) = currentBackStack.value.lastOrNull { it.navGraph() != graph } - appLogger.d("[$TAG] -> command: ${command.destination.route.obfuscateId()}") + appLogger.d("[$TAG] -> command: ${command.destination.route.obfuscateId()} backStackMode:${command.backStackMode}") navigate(command.destination) { when (command.backStackMode) { BackStackMode.CLEAR_WHOLE, BackStackMode.CLEAR_TILL_START -> { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt index 0814e5f74e6..070abed799e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModel.kt @@ -214,16 +214,13 @@ class ConversationListViewModelImpl @AssistedInject constructor( .flowOn(dispatcher.io()) .cachedIn(viewModelScope) - private var notPaginatedConversationListState by mutableStateOf(ConversationListState.NotPaginated()) - override val conversationListState: ConversationListState = - if (usePagination) { - ConversationListState.Paginated( - conversations = conversationsPaginatedFlow, - domain = currentAccount.domain - ) - } else { - notPaginatedConversationListState + override var conversationListState by mutableStateOf( + when (usePagination) { + true -> ConversationListState.Paginated(conversations = conversationsPaginatedFlow, domain = currentAccount.domain) + false -> ConversationListState.NotPaginated() } + ) + private set init { if (!usePagination) { @@ -258,7 +255,7 @@ class ConversationListViewModelImpl @AssistedInject constructor( } .flowOn(dispatcher.io()) .collect { - notPaginatedConversationListState = notPaginatedConversationListState.copy( + conversationListState = ConversationListState.NotPaginated( isLoading = false, conversations = it, domain = currentAccount.domain From 8128377caa0278ef1642c58cff2d95b1cc0fd3ac Mon Sep 17 00:00:00 2001 From: Yamil Medina Date: Mon, 16 Dec 2024 13:39:45 -0300 Subject: [PATCH 11/23] fix: add signature used by mls client (WPB-15040) (#3743) --- .../wire/android/ui/settings/devices/DeviceDetailsScreen.kt | 6 ++++++ .../android/ui/settings/devices/DeviceDetailsViewModel.kt | 4 ++++ .../android/ui/settings/devices/model/DeviceDetailsState.kt | 3 ++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt index 422d9647472..7a8894fe512 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsScreen.kt @@ -204,6 +204,12 @@ fun DeviceDetailsContent( ) { state.device.mlsClientIdentity?.let { identity -> item { + FolderHeader( + name = stringResource(id = R.string.label_mls_signature, state.mlsCipherSuiteSignature.orEmpty()).uppercase(), + modifier = Modifier + .background(MaterialTheme.wireColorScheme.background) + .fillMaxWidth() + ) DeviceMLSSignatureItem(identity.thumbprint, screenState::copyMessage) HorizontalDivider(color = MaterialTheme.wireColorScheme.background) } diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt index f6d06b76b68..826154cc6b6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt @@ -37,6 +37,7 @@ import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.data.client.ClientType import com.wire.kalium.logic.data.client.DeleteClientParam import com.wire.kalium.logic.data.conversation.ClientId +import com.wire.kalium.logic.data.mlspublickeys.MLSPublicKeyType import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.client.ClientFingerprintUseCase import com.wire.kalium.logic.feature.client.DeleteClientResult @@ -198,6 +199,9 @@ class DeviceDetailsViewModel @Inject constructor( isCurrentDevice = result.isCurrentClient, removeDeviceDialogState = RemoveDeviceDialogState.Hidden, canBeRemoved = !result.isCurrentClient && isSelfClient && result.client.type != ClientType.LegalHold, + mlsCipherSuiteSignature = MLSPublicKeyType.from( + result.client.mlsPublicKeys?.keys?.firstOrNull().orEmpty() + ).value.toString() ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/model/DeviceDetailsState.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/model/DeviceDetailsState.kt index b68745619e9..96216d33068 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/model/DeviceDetailsState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/model/DeviceDetailsState.kt @@ -37,5 +37,6 @@ data class DeviceDetailsState( val isE2EICertificateEnrollSuccess: Boolean = false, val isE2EICertificateEnrollError: Boolean = false, val isE2EIEnabled: Boolean = false, - val startGettingE2EICertificate: Boolean = false + val startGettingE2EICertificate: Boolean = false, + val mlsCipherSuiteSignature: String? = null, ) From 14fa902de3a0f786c98bfbf7b202f81d0d15b9f1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 17 Dec 2024 10:22:05 +0100 Subject: [PATCH 12/23] =?UTF-8?q?fix:=20countly=20sdk=20integration=20repl?= =?UTF-8?q?ace=20halt=20and=20bump=20to=20latest=20(WPB-15007)=20?= =?UTF-8?q?=F0=9F=8D=92=20(#3749)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Yamil Medina --- .../android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt | 2 +- gradle/libs.versions.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt index 71fe3723667..6baadc5b295 100644 --- a/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt +++ b/core/analytics-enabled/src/main/kotlin/com/wire/android/feature/analytics/AnonymousAnalyticsRecorderImpl.kt @@ -86,7 +86,7 @@ class AnonymousAnalyticsRecorderImpl : AnonymousAnalyticsRecorder { override fun halt() = wrapCountlyRequest { isConfigured = false - Countly.sharedInstance().consent().removeConsentAll() + Countly.sharedInstance()?.consent()?.removeConsentAll() } override suspend fun setTrackingIdentifierWithMerge( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 557c6558a97..6dbdf6c2991 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,7 +67,7 @@ coil = "2.7.0" commonmark = "0.24.0" # Countly -countly = "24.4.0" +countly = "24.7.7" # RSS rss-parser = "6.0.7" From 41b7ca74e7e9b4af08748e27c99825a54b146722 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Dec 2024 10:27:49 +0100 Subject: [PATCH 13/23] chore(deps): [WPB-9777] bump softprops/action-gh-release from 2.1.0 to 2.2.0 (#3750) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-prod-app.yml | 2 +- .github/workflows/generate-changelog.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-prod-app.yml b/.github/workflows/build-prod-app.yml index b42866dfe69..f682e36fc1f 100644 --- a/.github/workflows/build-prod-app.yml +++ b/.github/workflows/build-prod-app.yml @@ -114,7 +114,7 @@ jobs: build-flavour: prod build-variant: compatrelease - name: Attach APK and version file to release - uses: softprops/action-gh-release@v2.1.0 + uses: softprops/action-gh-release@v2.2.0 with: files: | app/build/outputs/apk/prodCompatrelease/*.apk diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml index a239d508807..05955c37a68 100644 --- a/.github/workflows/generate-changelog.yml +++ b/.github/workflows/generate-changelog.yml @@ -48,7 +48,7 @@ jobs: npx generate-changelog@1.8.0 -t "$PREVIOUS_TAG...$CURRENT_TAG" - name: 'Attach changelog to tag' - uses: softprops/action-gh-release@v2.1.0 + uses: softprops/action-gh-release@v2.2.0 env: GITHUB_TOKEN: ${{ secrets.ANDROID_BOB_GH_TOKEN }} with: From 21ee4fcfdbeaeb5692fdfb718eaf123b3a1310c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BBerko?= Date: Tue, 17 Dec 2024 11:05:08 +0100 Subject: [PATCH 14/23] fix: handle mls disabled error [WPB-15022] (#3752) --- .../com/wire/android/ui/debug/DebugDataOptionsViewModel.kt | 3 +++ .../android/ui/settings/devices/DeviceDetailsViewModel.kt | 4 ++-- .../android/ui/settings/devices/DeviceDetailsViewModelTest.kt | 2 +- kalium | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt index e98c20eb903..27fdf191eb9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/debug/DebugDataOptionsViewModel.kt @@ -304,6 +304,9 @@ class DebugDataOptionsViewModelImpl } is MLSKeyPackageCountResult.Failure.Generic -> {} + MLSKeyPackageCountResult.Failure.NotEnabled -> { + state = state.copy(mlsErrorMessage = "Not Enabled!") + } } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt index 826154cc6b6..1ca4096174f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModel.kt @@ -73,7 +73,7 @@ class DeviceDetailsViewModel @Inject constructor( private val fingerprintUseCase: ClientFingerprintUseCase, private val updateClientVerificationStatus: UpdateClientVerificationStatusUseCase, private val observeUserInfo: ObserveUserInfoUseCase, - private val e2eiCertificate: GetMLSClientIdentityUseCase, + private val mlsClientIdentity: GetMLSClientIdentityUseCase, private val breakSession: BreakSessionUseCase, isE2EIEnabledUseCase: IsE2EIEnabledUseCase ) : SavedStateViewModel(savedStateHandle) { @@ -134,7 +134,7 @@ class DeviceDetailsViewModel @Inject constructor( private fun getE2eiCertificate() { viewModelScope.launch { - state = e2eiCertificate(deviceId).fold({ + state = mlsClientIdentity(deviceId).fold({ state.copy(isE2eiCertificateActivated = false, isLoadingCertificate = false) }, { mlsClientIdentity -> state.copy( diff --git a/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModelTest.kt index 6b69dabbd6e..5e0ea113b3a 100644 --- a/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/settings/devices/DeviceDetailsViewModelTest.kt @@ -377,7 +377,7 @@ class DeviceDetailsViewModelTest { updateClientVerificationStatus = updateClientVerificationStatus, currentUserId = currentUserId, observeUserInfo = observeUserInfo, - e2eiCertificate = getE2eiCertificate, + mlsClientIdentity = getE2eiCertificate, isE2EIEnabledUseCase = isE2EIEnabledUseCase, breakSession = breakSession ) diff --git a/kalium b/kalium index 0667f9b780a..9926d3dc47c 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 0667f9b780a8262768b0c37af3d49d4f83c55701 +Subproject commit 9926d3dc47c1a238dfd9c292095fbb4ededf81ba From e0e5c8b3bd203c1e261da3cffb4b48311e8b45aa Mon Sep 17 00:00:00 2001 From: Yamil Medina Date: Tue, 17 Dec 2024 07:56:27 -0300 Subject: [PATCH 15/23] feat: conference simulcast support (WPB-11480) (#3744) --- .../ui/calling/ongoing/OngoingCallScreen.kt | 12 +++- .../calling/ongoing/OngoingCallViewModel.kt | 26 ++++++- .../ongoing/fullscreen/FullScreenTile.kt | 6 ++ .../ongoing/fullscreen/SelectedParticipant.kt | 8 ++- .../ui/calling/OngoingCallViewModelTest.kt | 69 +++++++++++++++++++ kalium | 2 +- 6 files changed, 117 insertions(+), 6 deletions(-) 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 f9877a1fa6d..ec577f2c221 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 @@ -177,6 +177,8 @@ fun OngoingCallScreen( clearVideoPreview = sharedCallingViewModel::clearVideoPreview, onCollapse = onCollapse, requestVideoStreams = ongoingCallViewModel::requestVideoStreams, + onSelectedParticipant = ongoingCallViewModel::onSelectedParticipant, + selectedParticipantForFullScreen = ongoingCallViewModel.selectedParticipant, hideDoubleTapToast = ongoingCallViewModel::hideDoubleTapToast, onCameraPermissionPermanentlyDenied = onCameraPermissionPermanentlyDenied, participants = sharedCallingViewModel.participantsState, @@ -289,6 +291,8 @@ private fun OngoingCallContent( hideDoubleTapToast: () -> Unit, onCameraPermissionPermanentlyDenied: () -> Unit, requestVideoStreams: (participants: List) -> Unit, + onSelectedParticipant: (selectedParticipant: SelectedParticipant) -> Unit, + selectedParticipantForFullScreen: SelectedParticipant, participants: PersistentList, inPictureInPictureMode: Boolean, currentUserId: UserId, @@ -303,7 +307,6 @@ private fun OngoingCallContent( ) var shouldOpenFullScreen by remember { mutableStateOf(false) } - var selectedParticipantForFullScreen by remember { mutableStateOf(SelectedParticipant()) } WireBottomSheetScaffold( sheetDragHandle = null, @@ -391,11 +394,14 @@ private fun OngoingCallContent( selectedParticipant = selectedParticipantForFullScreen, height = this@BoxWithConstraints.maxHeight - dimensions().spacing4x, closeFullScreen = { + onSelectedParticipant(SelectedParticipant()) shouldOpenFullScreen = !shouldOpenFullScreen }, onBackButtonClicked = { + onSelectedParticipant(SelectedParticipant()) shouldOpenFullScreen = !shouldOpenFullScreen }, + requestVideoStreams = requestVideoStreams, setVideoPreview = setVideoPreview, clearVideoPreview = clearVideoPreview, participants = participants @@ -412,7 +418,7 @@ private fun OngoingCallContent( requestVideoStreams = requestVideoStreams, currentUserId = currentUserId, onDoubleTap = { selectedParticipant -> - selectedParticipantForFullScreen = selectedParticipant + onSelectedParticipant(selectedParticipant) shouldOpenFullScreen = !shouldOpenFullScreen }, ) @@ -580,6 +586,8 @@ fun PreviewOngoingCallContent(participants: PersistentList) { participants = participants, inPictureInPictureMode = false, currentUserId = UserId("userId", "domain"), + onSelectedParticipant = {}, + selectedParticipantForFullScreen = SelectedParticipant(), ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt index 8e07e40bc1b..84c6e1a57d1 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt @@ -28,8 +28,10 @@ import com.wire.android.appLogger import com.wire.android.datastore.GlobalDataStore import com.wire.android.di.CurrentAccount import com.wire.android.ui.calling.model.UICallParticipant +import com.wire.android.ui.calling.ongoing.fullscreen.SelectedParticipant import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.CallClient +import com.wire.kalium.logic.data.call.CallQuality import com.wire.kalium.logic.data.call.VideoState import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId @@ -63,6 +65,8 @@ class OngoingCallViewModel @AssistedInject constructor( var state by mutableStateOf(OngoingCallState()) private set + var selectedParticipant by mutableStateOf(SelectedParticipant()) + private set init { viewModelScope.launch { @@ -124,7 +128,11 @@ class OngoingCallViewModel @AssistedInject constructor( .also { if (it.isNotEmpty()) { val clients: List = it.map { uiParticipant -> - CallClient(uiParticipant.id.toString(), uiParticipant.clientId) + CallClient( + userId = uiParticipant.id.toString(), + clientId = uiParticipant.clientId, + quality = mapQualityStream(uiParticipant) + ) } requestVideoStreams(conversationId, clients) } @@ -132,12 +140,20 @@ class OngoingCallViewModel @AssistedInject constructor( } } + private fun mapQualityStream(uiParticipant: UICallParticipant): CallQuality { + return if (uiParticipant.clientId == selectedParticipant.clientId) { + CallQuality.HIGH + } else { + CallQuality.LOW + } + } + private fun startDoubleTapToastDisplayCountDown() { doubleTapIndicatorCountDownTimer?.cancel() doubleTapIndicatorCountDownTimer = object : CountDownTimer(DOUBLE_TAP_TOAST_DISPLAY_TIME, COUNT_DOWN_INTERVAL) { override fun onTick(p0: Long) { - appLogger.i("startDoubleTapToastDisplayCountDown: $p0") + appLogger.d("$TAG - startDoubleTapToastDisplayCountDown: $p0") } override fun onFinish() { @@ -171,10 +187,16 @@ class OngoingCallViewModel @AssistedInject constructor( } } + fun onSelectedParticipant(selectedParticipant: SelectedParticipant) { + appLogger.d("$TAG - Selected participant: ${selectedParticipant.toLogString()}") + this.selectedParticipant = selectedParticipant + } + companion object { const val DOUBLE_TAP_TOAST_DISPLAY_TIME = 7000L const val COUNT_DOWN_INTERVAL = 1000L const val DELAY_TO_SHOW_DOUBLE_TAP_TOAST = 500L + const val TAG = "OngoingCallViewModel" } @AssistedFactory diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt index 7edbd0d7ba3..cee64f83032 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt @@ -60,6 +60,7 @@ fun FullScreenTile( closeFullScreen: (offset: Offset) -> Unit, onBackButtonClicked: () -> Unit, setVideoPreview: (View) -> Unit, + requestVideoStreams: (participants: List) -> Unit, clearVideoPreview: () -> Unit, modifier: Modifier = Modifier, contentPadding: Dp = dimensions().spacing4x, @@ -119,6 +120,10 @@ fun FullScreenTile( } ) } + + LaunchedEffect(selectedParticipant.userId) { + requestVideoStreams(listOf(it)) + } } } @@ -139,6 +144,7 @@ fun PreviewFullScreenTile() = WireTheme { closeFullScreen = {}, onBackButtonClicked = {}, setVideoPreview = {}, + requestVideoStreams = {}, clearVideoPreview = {}, participants = participants, ) diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/SelectedParticipant.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/SelectedParticipant.kt index 95238a33c27..af1a1d87f45 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/SelectedParticipant.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/SelectedParticipant.kt @@ -17,10 +17,16 @@ */ package com.wire.android.ui.calling.ongoing.fullscreen +import com.wire.kalium.logger.obfuscateId import com.wire.kalium.logic.data.user.UserId data class SelectedParticipant( val userId: UserId = UserId("", ""), val clientId: String = "", val isSelfUser: Boolean = false -) +) { + + fun toLogString(): String { + return "SelectedParticipant(userId=${userId.toLogString()}, clientId=${clientId.obfuscateId()}, isSelfUser=$isSelfUser)" + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt index 79e3abce3cb..92937700fdb 100644 --- a/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt @@ -23,9 +23,11 @@ import com.wire.android.config.NavigationTestExtension import com.wire.android.datastore.GlobalDataStore import com.wire.android.ui.calling.model.UICallParticipant import com.wire.android.ui.calling.ongoing.OngoingCallViewModel +import com.wire.android.ui.calling.ongoing.fullscreen.SelectedParticipant import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.CallClient +import com.wire.kalium.logic.data.call.CallQuality import com.wire.kalium.logic.data.call.CallStatus import com.wire.kalium.logic.data.call.VideoState import com.wire.kalium.logic.data.conversation.Conversation @@ -170,6 +172,72 @@ class OngoingCallViewModelTest { } } + @Test + fun givenAUserIsSelected_whenRequestedFullScreen_thenSetTheUserAsSelected() = + runTest { + val (_, ongoingCallViewModel) = Arrangement() + .withCall(provideCall().copy(isCameraOn = true)) + .withShouldShowDoubleTapToastReturning(false) + .withSetVideoSendState() + .arrange() + + ongoingCallViewModel.onSelectedParticipant(selectedParticipant3) + + assertEquals(selectedParticipant3, ongoingCallViewModel.selectedParticipant) + } + + @Test + fun givenParticipantsList_WhenRequestingVideoStreamForFullScreenParticipant_ThenRequestItInHighQuality() = + runTest { + val expectedClients = listOf( + CallClient(participant1.id.toString(), participant1.clientId, false, CallQuality.LOW), + CallClient(participant3.id.toString(), participant3.clientId, false, CallQuality.HIGH) + ) + + val (arrangement, ongoingCallViewModel) = Arrangement() + .withCall(provideCall()) + .withShouldShowDoubleTapToastReturning(false) + .withSetVideoSendState() + .withRequestVideoStreams(conversationId, expectedClients) + .arrange() + + ongoingCallViewModel.onSelectedParticipant(selectedParticipant3) + ongoingCallViewModel.requestVideoStreams(participants) + + coVerify(exactly = 1) { + arrangement.requestVideoStreams( + conversationId, + expectedClients + ) + } + } + + @Test + fun givenParticipantsList_WhenRequestingVideoStreamForAllParticipant_ThenRequestItInLowQuality() = + runTest { + val expectedClients = listOf( + CallClient(participant1.id.toString(), participant1.clientId, false, CallQuality.LOW), + CallClient(participant3.id.toString(), participant3.clientId, false, CallQuality.LOW) + ) + + val (arrangement, ongoingCallViewModel) = Arrangement() + .withCall(provideCall()) + .withShouldShowDoubleTapToastReturning(false) + .withSetVideoSendState() + .withRequestVideoStreams(conversationId, expectedClients) + .arrange() + + ongoingCallViewModel.onSelectedParticipant(SelectedParticipant()) + ongoingCallViewModel.requestVideoStreams(participants) + + coVerify(exactly = 1) { + arrangement.requestVideoStreams( + conversationId, + expectedClients + ) + } + } + private class Arrangement { @MockK @@ -268,6 +336,7 @@ class OngoingCallViewModelTest { accentId = -1 ) val participants = listOf(participant1, participant2, participant3) + val selectedParticipant3 = SelectedParticipant(participant3.id, participant3.clientId, false) } private fun provideCall( diff --git a/kalium b/kalium index 9926d3dc47c..1ca6dfc988e 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 9926d3dc47c1a238dfd9c292095fbb4ededf81ba +Subproject commit 1ca6dfc988eeccf550858620ae1dadf8e49555da From 22a0b22c4e4dc4c1086df242ccc013389750679c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Zag=C3=B3rski?= Date: Wed, 18 Dec 2024 19:17:50 +0100 Subject: [PATCH 16/23] feat: Add option to manage team [#WPB-14873] (#3753) --- .../android/di/accountScoped/UserModule.kt | 6 +++++ .../userprofile/self/SelfUserProfileScreen.kt | 22 ++++++++++++++++++- .../userprofile/self/SelfUserProfileState.kt | 1 + .../self/SelfUserProfileViewModel.kt | 6 ++++- app/src/main/res/values/strings.xml | 2 ++ .../SelfUserProfileViewModelArrangement.kt | 8 ++++++- kalium | 2 +- 7 files changed, 43 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/UserModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/UserModule.kt index e4ed7d01bfa..2c8c7ac115c 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/UserModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/UserModule.kt @@ -37,6 +37,7 @@ import com.wire.kalium.logic.feature.personaltoteamaccount.CanMigrateFromPersona import com.wire.kalium.logic.feature.publicuser.GetAllContactsUseCase import com.wire.kalium.logic.feature.publicuser.GetKnownUserUseCase import com.wire.kalium.logic.feature.publicuser.RefreshUsersWithoutMetadataUseCase +import com.wire.kalium.logic.feature.server.GetTeamUrlUseCase import com.wire.kalium.logic.feature.user.DeleteAccountUseCase import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import com.wire.kalium.logic.feature.user.GetUserInfoUseCase @@ -191,6 +192,11 @@ class UserModule { fun provideGetSelfUseCase(userScope: UserScope): GetSelfUserUseCase = userScope.getSelfUser + @ViewModelScoped + @Provides + fun provideGetTeamUrlUseCase(userScope: UserScope): GetTeamUrlUseCase = + userScope.getTeamUrl + @ViewModelScoped @Provides fun provideGetAvatarAssetUseCase(userScope: UserScope): GetAvatarAssetUseCase = diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt index e09da4b1532..60d081bf260 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.defaultMinSize @@ -43,6 +44,7 @@ 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.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -208,6 +210,7 @@ private fun SelfUserProfileContent( isUserInCall: () -> Boolean ) { val snackbarHostState = LocalSnackbarHostState.current + val uriHandler = LocalUriHandler.current state.errorMessageCode?.let { errorCode -> val errorMessage = mapErrorCodeToString(errorCode) @@ -345,7 +348,13 @@ private fun SelfUserProfileContent( Divider(color = MaterialTheme.wireColorScheme.outline) - Box(modifier = Modifier.padding(dimensions().spacing16x)) { + Column( + modifier = Modifier.padding(dimensions().spacing16x), + verticalArrangement = Arrangement.spacedBy(dimensions().spacing8x) + ) { + if (teamUrl != null) { + ManageTeamButton { uriHandler.openUri(teamUrl) } + } NewTeamButton(onAddAccountClick, isUserInCall, context) } } @@ -440,6 +449,17 @@ private fun CurrentSelfUserStatus( } } +@Composable +private fun ManageTeamButton( + onManageTeamClick: () -> Unit +) { + WireSecondaryButton( + text = stringResource(R.string.user_profile_account_management), + onClickDescription = stringResource(R.string.content_description_self_profile_manage_team_btn), + onClick = onManageTeamClick + ) +} + @Composable private fun NewTeamButton( onAddAccountClick: () -> Unit, diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileState.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileState.kt index c5ffb5c647a..5c2a742f02d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/self/SelfUserProfileState.kt @@ -34,6 +34,7 @@ data class SelfUserProfileState( val fullName: String = "", val userName: String = "", val teamName: String? = "", // maybe teamId is better here + val teamUrl: String? = null, val otherAccounts: List = emptyList(), val statusDialogData: StatusDialogData? = null, // null means no dialog to display val isAvatarLoading: Boolean = false, 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 7ea1e925b6b..2820cc43ae5 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 @@ -48,12 +48,14 @@ import com.wire.kalium.logic.data.user.SelfUser import com.wire.kalium.logic.data.user.UserAssetId import com.wire.kalium.logic.data.user.UserAvailabilityStatus import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.data.user.type.UserType import com.wire.kalium.logic.feature.auth.LogoutUseCase import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase import com.wire.kalium.logic.feature.legalhold.LegalHoldStateForSelfUser import com.wire.kalium.logic.feature.legalhold.ObserveLegalHoldStateForSelfUserUseCase import com.wire.kalium.logic.feature.personaltoteamaccount.CanMigrateFromPersonalToTeamUseCase +import com.wire.kalium.logic.feature.server.GetTeamUrlUseCase import com.wire.kalium.logic.feature.team.GetUpdatedSelfTeamUseCase import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import com.wire.kalium.logic.feature.user.IsReadOnlyAccountUseCase @@ -98,7 +100,8 @@ class SelfUserProfileViewModel @Inject constructor( private val notificationManager: WireNotificationManager, private val globalDataStore: GlobalDataStore, private val qualifiedIdMapper: QualifiedIdMapper, - private val anonymousAnalyticsManager: AnonymousAnalyticsManager + private val anonymousAnalyticsManager: AnonymousAnalyticsManager, + private val getTeamUrl: GetTeamUrlUseCase ) : ViewModel() { var userProfileState by mutableStateOf(SelfUserProfileState(userId = selfUserId, isAvatarLoading = true)) @@ -176,6 +179,7 @@ class SelfUserProfileViewModel @Inject constructor( fullName = name.orEmpty(), userName = handle.orEmpty(), teamName = selfTeam?.name, + teamUrl = getTeamUrl().takeIf { userType == UserType.OWNER || userType == UserType.ADMIN }, otherAccounts = otherAccounts, avatarAsset = userProfileState.avatarAsset, isAvatarLoading = false, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4811da7ef86..f12f3cbc10a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -242,6 +242,7 @@ Team name, %s change availability status Create a new team or personal account or log in + Manage team Go back to your profile overview Close new team creation and login view Go back to new team creation and login view @@ -612,6 +613,7 @@ Away None New Team or Add Account + Manage Team Details Devices Group 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 47b10e19c10..b278b335428 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 @@ -35,6 +35,7 @@ import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase import com.wire.kalium.logic.feature.legalhold.LegalHoldStateForSelfUser import com.wire.kalium.logic.feature.legalhold.ObserveLegalHoldStateForSelfUserUseCase import com.wire.kalium.logic.feature.personaltoteamaccount.CanMigrateFromPersonalToTeamUseCase +import com.wire.kalium.logic.feature.server.GetTeamUrlUseCase import com.wire.kalium.logic.feature.team.GetUpdatedSelfTeamUseCase import com.wire.kalium.logic.feature.user.GetSelfUserUseCase import com.wire.kalium.logic.feature.user.IsReadOnlyAccountUseCase @@ -101,6 +102,9 @@ class SelfUserProfileViewModelArrangement { @MockK lateinit var canMigrateFromPersonalToTeam: CanMigrateFromPersonalToTeamUseCase + @MockK + lateinit var getTeamUrl: GetTeamUrlUseCase + private val viewModel by lazy { SelfUserProfileViewModel( selfUserId = TestUser.SELF_USER.id, @@ -121,7 +125,8 @@ class SelfUserProfileViewModelArrangement { globalDataStore = globalDataStore, qualifiedIdMapper = qualifiedIdMapper, anonymousAnalyticsManager = anonymousAnalyticsManager, - canMigrateFromPersonalToTeam = canMigrateFromPersonalToTeam + canMigrateFromPersonalToTeam = canMigrateFromPersonalToTeam, + getTeamUrl = getTeamUrl ) } @@ -136,6 +141,7 @@ class SelfUserProfileViewModelArrangement { coEvery { observeEstablishedCalls.invoke() } returns flowOf(emptyList()) coEvery { observeEstablishedCalls.invoke() } returns flowOf(emptyList()) coEvery { canMigrateFromPersonalToTeam.invoke() } returns true + coEvery { getTeamUrl.invoke() } returns "" } fun withLegalHoldStatus(result: LegalHoldStateForSelfUser) = apply { diff --git a/kalium b/kalium index 1ca6dfc988e..91b8319e99d 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 1ca6dfc988eeccf550858620ae1dadf8e49555da +Subproject commit 91b8319e99d83e486751967c8adf4f027d57a82e From 8d9c35d9260a9a750b06a6f74d1efe33f777bed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Zag=C3=B3rski?= Date: Mon, 23 Dec 2024 09:58:43 +0100 Subject: [PATCH 17/23] feat: Track ended call segmentation [#WPB-14256] (#3756) --- .../com/wire/android/WireApplication.kt | 20 +++++ .../android/di/accountScoped/CallsModule.kt | 4 +- .../wire/android/ui/WireActivityViewModel.kt | 2 +- .../android/ui/WireActivityViewModelTest.kt | 23 ++++++ .../feature/analytics/model/AnalyticsEvent.kt | 79 ++++++++++++++++++- 5 files changed, 123 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/WireApplication.kt b/app/src/main/kotlin/com/wire/android/WireApplication.kt index d67de8e4c97..58ed10c4a55 100644 --- a/app/src/main/kotlin/com/wire/android/WireApplication.kt +++ b/app/src/main/kotlin/com/wire/android/WireApplication.kt @@ -32,6 +32,7 @@ import com.wire.android.datastore.UserDataStoreProvider import com.wire.android.debug.DatabaseProfilingManager import com.wire.android.di.ApplicationScope import com.wire.android.di.KaliumCoreLogic +import com.wire.android.feature.analytics.AnonymousAnalyticsManager import com.wire.android.feature.analytics.AnonymousAnalyticsManagerImpl import com.wire.android.feature.analytics.AnonymousAnalyticsRecorderImpl import com.wire.android.feature.analytics.globalAnalyticsManager @@ -48,12 +49,15 @@ import com.wire.kalium.logger.KaliumLogLevel import com.wire.kalium.logger.KaliumLogger import com.wire.kalium.logic.CoreLogger import com.wire.kalium.logic.CoreLogic +import com.wire.kalium.logic.feature.session.CurrentSessionResult import dagger.Lazy import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject @@ -93,6 +97,9 @@ class WireApplication : BaseApp() { @Inject lateinit var databaseProfilingManager: DatabaseProfilingManager + @Inject + lateinit var analyticsManager: Lazy + override val workManagerConfiguration: Configuration get() = Configuration.Builder() .setWorkerFactory(wireWorkerFactory.get()) @@ -121,9 +128,22 @@ class WireApplication : BaseApp() { appLogger.i("$TAG global observers") globalObserversManager.get().observe() + + observeRecentlyEndedCall() } } + private suspend fun observeRecentlyEndedCall() { + coreLogic.get().getGlobalScope().session.currentSessionFlow().filterIsInstance(CurrentSessionResult.Success::class) + .filter { session -> session.accountInfo.isValid() } + .flatMapLatest { session -> + coreLogic.get().getSessionScope(session.accountInfo.userId).calls.observeRecentlyEndedCallMetadata() + } + .collect { metadata -> + analyticsManager.get().sendEvent(AnalyticsEvent.RecentlyEndedCallEvent(metadata)) + } + } + private fun enableStrictMode() { if (BuildConfig.DEBUG) { StrictMode.setThreadPolicy( diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt index 803e627db87..99d5a287ad4 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt @@ -19,8 +19,6 @@ package com.wire.android.di.accountScoped import com.wire.android.di.CurrentAccount import com.wire.android.di.KaliumCoreLogic -import dagger.Module -import dagger.Provides import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.call.CallsScope @@ -40,6 +38,8 @@ import com.wire.kalium.logic.feature.call.usecase.TurnLoudSpeakerOnUseCase import com.wire.kalium.logic.feature.call.usecase.UnMuteCallUseCase import com.wire.kalium.logic.feature.call.usecase.video.SetVideoSendStateUseCase import com.wire.kalium.logic.feature.call.usecase.video.UpdateVideoStateUseCase +import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.components.ViewModelComponent import dagger.hilt.android.scopes.ViewModelScoped diff --git a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt index fc173d079cb..ddcf9d3f0b4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/WireActivityViewModel.kt @@ -121,7 +121,7 @@ class WireActivityViewModel @Inject constructor( private val observeScreenshotCensoringConfigUseCaseProviderFactory: ObserveScreenshotCensoringConfigUseCaseProvider.Factory, private val globalDataStore: Lazy, private val observeIfE2EIRequiredDuringLoginUseCaseProviderFactory: ObserveIfE2EIRequiredDuringLoginUseCaseProvider.Factory, - private val workManager: Lazy, + private val workManager: Lazy ) : ViewModel() { var globalAppState: GlobalAppState by mutableStateOf(GlobalAppState()) diff --git a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt index f7070a642bf..c2744f0635f 100644 --- a/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/WireActivityViewModelTest.kt @@ -53,6 +53,7 @@ import com.wire.kalium.logic.data.auth.AccountInfo import com.wire.kalium.logic.data.auth.PersistentWebSocketStatus import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.CallStatus +import com.wire.kalium.logic.data.call.RecentlyEndedCallMetadata import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.id.QualifiedID @@ -974,6 +975,28 @@ class WireActivityViewModelTest { callerTeamName = "team1" ) + val recentlyEndedCallMetadata = RecentlyEndedCallMetadata( + callEndReason = 1, + callDetails = RecentlyEndedCallMetadata.CallDetails( + isCallScreenShare = false, + screenShareDurationInSeconds = 20L, + callScreenShareUniques = 5, + isOutgoingCall = true, + callDurationInSeconds = 100L, + callParticipantsCount = 5, + conversationServices = 1, + callAVSwitchToggle = false, + callVideoEnabled = false + ), + conversationDetails = RecentlyEndedCallMetadata.ConversationDetails( + conversationType = Conversation.Type.ONE_ON_ONE, + conversationSize = 5, + conversationGuests = 2, + conversationGuestsPro = 1 + ), + isTeamMember = true + ) + fun invalidAccountInfo(logoutReason: LogoutReason): AccountInfo.Invalid = AccountInfo.Invalid(USER_ID, logoutReason) } } diff --git a/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/model/AnalyticsEvent.kt b/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/model/AnalyticsEvent.kt index b08eb4585b5..b133c02d172 100644 --- a/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/model/AnalyticsEvent.kt +++ b/core/analytics/src/main/kotlin/com/wire/android/feature/analytics/model/AnalyticsEvent.kt @@ -17,6 +17,21 @@ */ package com.wire.android.feature.analytics.model +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CALLING_ENDED +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CALLING_ENDED_AV_SWITCH_TOGGLE +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CALLING_ENDED_CALL_DIRECTION +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CALLING_ENDED_CALL_DURATION +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CALLING_ENDED_CALL_PARTICIPANTS +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CALLING_ENDED_CALL_SCREEN_SHARE +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CALLING_ENDED_CALL_VIDEO +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CALLING_ENDED_CONVERSATION_GUESTS +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CALLING_ENDED_CONVERSATION_GUESTS_PRO +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CALLING_ENDED_CONVERSATION_SERVICES +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CALLING_ENDED_CONVERSATION_SIZE +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CALLING_ENDED_CONVERSATION_TYPE +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CALLING_ENDED_END_REASON +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CALLING_ENDED_IS_TEAM_MEMBER +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CALLING_ENDED_UNIQUE_SCREEN_SHARE import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CALLING_QUALITY_REVIEW_IGNORE_REASON import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CALLING_QUALITY_REVIEW_IGNORE_REASON_KEY import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CALLING_QUALITY_REVIEW_LABEL_ANSWERED @@ -29,8 +44,6 @@ import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CLICKED_ import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CLICKED_PERSONAL_MIGRATION_CTA_EVENT import com.wire.android.feature.analytics.model.AnalyticsEventConstants.CONTRIBUTED_LOCATION import com.wire.android.feature.analytics.model.AnalyticsEventConstants.MESSAGE_ACTION_KEY -import com.wire.android.feature.analytics.model.AnalyticsEventConstants.QR_CODE_SEGMENTATION_USER_TYPE_PERSONAL -import com.wire.android.feature.analytics.model.AnalyticsEventConstants.QR_CODE_SEGMENTATION_USER_TYPE_TEAM import com.wire.android.feature.analytics.model.AnalyticsEventConstants.MIGRATION_DOT_ACTIVE import com.wire.android.feature.analytics.model.AnalyticsEventConstants.MODAL_BACK_TO_WIRE_CLICKED import com.wire.android.feature.analytics.model.AnalyticsEventConstants.MODAL_CONTINUE_CLICKED @@ -40,8 +53,12 @@ import com.wire.android.feature.analytics.model.AnalyticsEventConstants.MODAL_TE import com.wire.android.feature.analytics.model.AnalyticsEventConstants.PERSONAL_TEAM_CREATION_FLOW_CANCELLED import com.wire.android.feature.analytics.model.AnalyticsEventConstants.PERSONAL_TEAM_CREATION_FLOW_COMPLETED import com.wire.android.feature.analytics.model.AnalyticsEventConstants.PERSONAL_TEAM_CREATION_FLOW_STARTED_EVENT +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.QR_CODE_SEGMENTATION_USER_TYPE_PERSONAL +import com.wire.android.feature.analytics.model.AnalyticsEventConstants.QR_CODE_SEGMENTATION_USER_TYPE_TEAM import com.wire.android.feature.analytics.model.AnalyticsEventConstants.STEP_MODAL_CREATE_TEAM import com.wire.android.feature.analytics.model.AnalyticsEventConstants.USER_PROFILE_OPENED +import com.wire.kalium.logic.data.call.RecentlyEndedCallMetadata +import com.wire.kalium.logic.data.conversation.Conversation interface AnalyticsEvent { /** @@ -129,6 +146,45 @@ interface AnalyticsEvent { } } + data class RecentlyEndedCallEvent(val metadata: RecentlyEndedCallMetadata) : AnalyticsEvent { + override val key: String = CALLING_ENDED + + override fun toSegmentation(): Map { + return mapOf( + CALLING_ENDED_IS_TEAM_MEMBER to metadata.isTeamMember, + CALLING_ENDED_CALL_SCREEN_SHARE to metadata.callDetails.screenShareDurationInSeconds, + CALLING_ENDED_UNIQUE_SCREEN_SHARE to metadata.callDetails.callScreenShareUniques, + CALLING_ENDED_CALL_DIRECTION to metadata.toCallDirection(), + CALLING_ENDED_CALL_DURATION to metadata.callDetails.callDurationInSeconds, + CALLING_ENDED_CONVERSATION_TYPE to metadata.toConversationType(), + CALLING_ENDED_CONVERSATION_SIZE to metadata.conversationDetails.conversationSize, + CALLING_ENDED_CONVERSATION_GUESTS to metadata.conversationDetails.conversationGuests, + CALLING_ENDED_CONVERSATION_GUESTS_PRO to metadata.conversationDetails.conversationGuestsPro, + CALLING_ENDED_CALL_PARTICIPANTS to metadata.callDetails.callParticipantsCount, + CALLING_ENDED_END_REASON to metadata.callEndReason, + CALLING_ENDED_CONVERSATION_SERVICES to metadata.callDetails.conversationServices, + CALLING_ENDED_AV_SWITCH_TOGGLE to metadata.callDetails.callAVSwitchToggle, + CALLING_ENDED_CALL_VIDEO to metadata.callDetails.callVideoEnabled, + ) + } + + private fun RecentlyEndedCallMetadata.toCallDirection(): String { + return if (callDetails.isOutgoingCall) { + "outgoing" + } else { + "incoming" + } + } + + private fun RecentlyEndedCallMetadata.toConversationType(): String { + return when (conversationDetails.conversationType) { + Conversation.Type.ONE_ON_ONE -> "one_to_one" + Conversation.Type.GROUP -> "group" + else -> throw IllegalStateException("Call should not happen for ${conversationDetails.conversationType}") + } + } + } + /** * Backup */ @@ -336,6 +392,7 @@ object AnalyticsEventConstants { */ const val CALLING_INITIATED = "calling.initiated_call" const val CALLING_JOINED = "calling.joined_call" + const val CALLING_ENDED = "calling.ended_call" const val CALLING_QUALITY_REVIEW = "calling.call_quality_review" const val CALLING_QUALITY_REVIEW_LABEL_KEY = "label" @@ -346,6 +403,24 @@ object AnalyticsEventConstants { const val CALLING_QUALITY_REVIEW_IGNORE_REASON_KEY = "ignore-reason" const val CALLING_QUALITY_REVIEW_IGNORE_REASON = "muted" + /** + * Call ended + */ + const val CALLING_ENDED_IS_TEAM_MEMBER = "is_team_member" + const val CALLING_ENDED_CALL_SCREEN_SHARE = "call_screen_share_duration" + const val CALLING_ENDED_UNIQUE_SCREEN_SHARE = "call_screen_share_unique" + const val CALLING_ENDED_CALL_DIRECTION = "call_direction" + const val CALLING_ENDED_CALL_DURATION = "call_duration" + const val CALLING_ENDED_CONVERSATION_TYPE = "conversation_type" + const val CALLING_ENDED_CONVERSATION_SIZE = "conversation_size" + const val CALLING_ENDED_CONVERSATION_GUESTS = "conversation_guests" + const val CALLING_ENDED_CONVERSATION_GUESTS_PRO = "conversation_guest_pro" + const val CALLING_ENDED_CALL_PARTICIPANTS = "call_participants" + const val CALLING_ENDED_END_REASON = "call_end_reason" + const val CALLING_ENDED_CONVERSATION_SERVICES = "conversation_services" + const val CALLING_ENDED_AV_SWITCH_TOGGLE = "call_av_switch_toggle" + const val CALLING_ENDED_CALL_VIDEO = "call_video" + /** * Backup */ From a8898c2df82b25f6f4366d20c253d0b332718267 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 23 Dec 2024 10:12:28 +0100 Subject: [PATCH 18/23] =?UTF-8?q?fix:=20attachments=20menu=20blends=20with?= =?UTF-8?q?=20background=20[WPB-15102]=20=F0=9F=8D=92=20(#3765)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Michał Saleniuk <30429749+saleniuk@users.noreply.github.com> Co-authored-by: Michał Saleniuk --- .../participantsview/ParticipantTile.kt | 2 +- .../messagecomposer/EnabledMessageComposer.kt | 77 +++++++++++++------ .../wire/android/ui/theme/WireColorScheme.kt | 2 +- 3 files changed, 57 insertions(+), 24 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/ParticipantTile.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/ParticipantTile.kt index 34dfecc6af2..8fe6dadd09c 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/ParticipantTile.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/ParticipantTile.kt @@ -390,7 +390,7 @@ private fun UsernameTile( modifier: Modifier = Modifier, ) { val color = - if (isSpeaking) colorsScheme().primary else darkColorsScheme().surfaceContainerLowest + if (isSpeaking) colorsScheme().primary else darkColorsScheme().inverseOnSurface val nameLabelColor = when { isSpeaking -> colorsScheme().onPrimary diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt index fc14ab01833..05886b0b4a9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/EnabledMessageComposer.kt @@ -43,7 +43,7 @@ import androidx.compose.foundation.layout.isImeVisible import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.GenericShape import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -55,12 +55,20 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.toRect import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Popup @@ -340,11 +348,12 @@ fun EnabledMessageComposer( showAttachments(false) } ) { - val rippleColor = colorsScheme().surface - val shape = if (isImeVisible) { - RectangleShape - } else { - RoundedCornerShape(dimensions().corner14x) + val rippleColor = colorsScheme().surfaceContainerLowest + val borderColor = colorsScheme().divider + val borderWidthPx = if (isImeVisible) 0f else dimensions().spacing1x.toPx(density) + val cornerRadiusPx = if (isImeVisible) 0f else dimensions().corner14x.toPx(density) + val shape = GenericShape { size, _ -> + addPath(calculateOptionsPath(cornerRadiusPx, rippleProgress.value, isImeVisible, size)) } Box( @@ -365,21 +374,22 @@ fun EnabledMessageComposer( .clip(shape) .drawBehind { if (!hideRipple || rippleProgress.value > 0f) { - val maxRadius = size.getDistanceToCorner(Offset(0f, 0f)) - val currentRadius = maxRadius * rippleProgress.value - - drawCircle( - color = rippleColor, - radius = currentRadius, - center = Offset( - 0f, - if (isImeVisible) { - 0f - } else { - size.height - } + calculateOptionsPath(cornerRadiusPx, rippleProgress.value, isImeVisible, size).let { + drawPath( + path = it, + color = rippleColor, + style = Fill ) - ) + if (borderWidthPx > 0f) { + drawPath( + path = it, + color = borderColor, + style = Stroke( + width = borderWidthPx * 2f // double to make inner stroke, outer half is clipped anyway + ) + ) + } + } } } @@ -416,7 +426,30 @@ fun EnabledMessageComposer( } } -fun Size.getDistanceToCorner(corner: Offset): Float { +private fun Size.getDistanceToCorner(corner: Offset): Float { val cornerOffset = Offset(width - corner.x, height - corner.y) return cornerOffset.getDistance() } + +private fun calculateOptionsPath(cornerRadiusPx: Float, rippleProgress: Float, isImeVisible: Boolean, size: Size): Path { + val ripplePath = Path() + ripplePath.addOval( + oval = Rect( + center = Offset( + x = 0f, + y = if (isImeVisible) 0f else size.height + ), + radius = rippleProgress * size.getDistanceToCorner(Offset(0f, 0f)) + ) + ) + val shapePath = Path() + shapePath.addRoundRect( + roundRect = RoundRect( + rect = size.toRect(), + cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx) + ) + ) + return ripplePath.and(shapePath) +} + +private fun Dp.toPx(density: Density) = with(density) { toPx() } diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt index e6599228ef0..ea4f6f8b5e6 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt @@ -207,7 +207,7 @@ private val DarkWireColorScheme = WireColorScheme( surfaceVariant = WireColorPalette.Gray90, onSurfaceVariant = Color.White, inverseSurface = Color.White, inverseOnSurface = Color.Black, surfaceBright = WireColorPalette.Gray70, surfaceDim = WireColorPalette.Gray95, - surfaceContainerLowest = Color.Black, + surfaceContainerLowest = WireColorPalette.Gray100, surfaceContainerLow = WireColorPalette.Gray95, surfaceContainer = WireColorPalette.Gray90, surfaceContainerHigh = WireColorPalette.Gray80, From 8847c15b7f6eb1c77dfeccc12ee7633046e6cc84 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 23 Dec 2024 10:46:49 +0100 Subject: [PATCH 19/23] =?UTF-8?q?fix:=20allowing=20empty=20team=20name=20w?= =?UTF-8?q?hen=20migrating=20[WPB-15092]=20=F0=9F=8D=92=20(#3764)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Michał Saleniuk <30429749+saleniuk@users.noreply.github.com> Co-authored-by: Michał Saleniuk --- .../teammigration/TeamMigrationViewModel.kt | 2 +- .../step2/TeamMigrationTeamNameStepScreen.kt | 2 +- .../teammigration/TeamMigrationViewModelTest.kt | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModel.kt index b4080251aa6..818a70b9b27 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModel.kt @@ -95,7 +95,7 @@ class TeamMigrationViewModel @Inject constructor( fun migrateFromPersonalToTeamAccount(onSuccess: () -> Unit) { viewModelScope.launch { migrateFromPersonalToTeam.invoke( - teamMigrationState.teamNameTextState.text.toString(), + teamMigrationState.teamNameTextState.text.trim().toString(), ).let { result -> when (result) { is MigrateFromPersonalToTeamResult.Success -> { diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step2/TeamMigrationTeamNameStepScreen.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step2/TeamMigrationTeamNameStepScreen.kt index aa1e8a9ecf6..da3d8ab4504 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step2/TeamMigrationTeamNameStepScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/teammigration/step2/TeamMigrationTeamNameStepScreen.kt @@ -129,7 +129,7 @@ private fun TeamMigrationTeamNameStepScreenContent( textFieldState = teamNameTextFieldState, ) } - val isContinueButtonEnabled = teamNameTextFieldState.text.isNotEmpty() + val isContinueButtonEnabled = teamNameTextFieldState.text.isNotEmpty() && teamNameTextFieldState.text.isNotBlank() BottomLineButtons( isContinueButtonEnabled = isContinueButtonEnabled, onContinue = onContinueButtonClicked, diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModelTest.kt index 42a7a926e05..ef00ce0c20c 100644 --- a/app/src/test/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/teammigration/TeamMigrationViewModelTest.kt @@ -243,6 +243,22 @@ class TeamMigrationViewModelTest { Assertions.assertNull(viewModel.teamMigrationState.migrationFailure) } + @Test + fun `given team name with spaces at start or end, when invoking migration, then trim the name`() = runTest { + // given + val (arrangement, viewModel) = Arrangement() + .withMigrateFromPersonalToTeamSuccess() + .arrange() + val onSuccess = {} + viewModel.teamMigrationState.teamNameTextState.setTextAndPlaceCursorAtEnd(" ${Arrangement.TEAM_NAME} ") + // when + viewModel.migrateFromPersonalToTeamAccount(onSuccess) + // then + coVerify(exactly = 1) { + arrangement.migrateFromPersonalToTeam(Arrangement.TEAM_NAME) + } + } + private class Arrangement { @MockK From 188bc6c63371f9331d8197a518cff5863de0c2a1 Mon Sep 17 00:00:00 2001 From: sergeibakhtiarov Date: Mon, 23 Dec 2024 15:44:05 +0100 Subject: [PATCH 20/23] feat: send and receive in-call reactions [#WPB-14254] (#3759) Co-authored-by: sergei.bakhtiarov --- .../android/di/accountScoped/CallsModule.kt | 5 + .../android/di/accountScoped/MessageModule.kt | 6 + .../android/mapper/UICallParticipantMapper.kt | 4 +- .../ui/calling/SharedCallingViewModel.kt | 94 +++++- .../ui/calling/controlbuttons/CameraButton.kt | 4 - .../ui/calling/controlbuttons/HangUpButton.kt | 2 +- .../controlbuttons/HangUpOngoingButton.kt | 67 ++++ ...FlipButton.kt => InCallReactionsButton.kt} | 43 +-- .../controlbuttons/MicrophoneButton.kt | 4 - .../calling/controlbuttons/SpeakerButton.kt | 4 - .../controlbuttons/WireCallControlButton.kt | 14 +- .../ui/calling/model/InCallReaction.kt | 29 ++ .../ui/calling/model/UICallParticipant.kt | 1 + .../ui/calling/ongoing/OngoingCallScreen.kt | 285 +++++++++++------- .../calling/ongoing/OngoingCallViewModel.kt | 2 +- .../ongoing/fullscreen/FullScreenTile.kt | 9 +- .../incallreactions/InCallReactions.kt | 52 ++++ .../InCallReactionsModifier.kt | 137 +++++++++ .../incallreactions/InCallReactionsPanel.kt | 170 +++++++++++ .../incallreactions/InCallReactionsState.kt | 114 +++++++ .../participantsview/FlipCameraButton.kt | 66 ++++ .../participantsview/FloatingSelfUserTile.kt | 7 +- .../participantsview/ParticipantTile.kt | 90 +++++- .../participantsview/VerticalCallingPager.kt | 16 +- .../gridview/CallingGridView.kt | 21 +- .../horizentalview/CallingHorizontalView.kt | 16 +- .../com/wire/android/util/ExpiringMap.kt | 74 +++++ .../com/wire/android/util/extension/Flow.kt | 9 + app/src/main/res/drawable/ic_flip_camera.xml | 30 ++ .../main/res/drawable/ic_incall_reactions.xml | 32 ++ app/src/main/res/values/strings.xml | 3 + .../mapper/UICallParticipantMapperTest.kt | 30 +- .../ui/calling/OngoingCallViewModelTest.kt | 4 + .../ui/calling/SharedCallingViewModelTest.kt | 125 +++++++- .../com/wire/android/util/ExpiringMapTest.kt | 116 +++++++ .../wire/android/ui/theme/WireColorScheme.kt | 4 + .../wire/android/ui/theme/WireDimensions.kt | 10 +- .../wire/android/ui/theme/WireTypography.kt | 8 +- .../android/ui/theme/WireTypographyBase.kt | 6 + kalium | 2 +- 40 files changed, 1516 insertions(+), 199 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/HangUpOngoingButton.kt rename app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/{CameraFlipButton.kt => InCallReactionsButton.kt} (54%) create mode 100644 app/src/main/kotlin/com/wire/android/ui/calling/model/InCallReaction.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactions.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsModifier.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsPanel.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsState.kt create mode 100644 app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/FlipCameraButton.kt create mode 100644 app/src/main/kotlin/com/wire/android/util/ExpiringMap.kt create mode 100644 app/src/main/res/drawable/ic_flip_camera.xml create mode 100644 app/src/main/res/drawable/ic_incall_reactions.xml create mode 100644 app/src/test/kotlin/com/wire/android/util/ExpiringMapTest.kt diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt index 99d5a287ad4..8e5e764d608 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/CallsModule.kt @@ -202,4 +202,9 @@ class CallsModule { @Provides fun provideObserveConferenceCallingEnabledUseCase(callsScope: CallsScope) = callsScope.observeConferenceCallingEnabled + + @ViewModelScoped + @Provides + fun provideObserveInCallReactionsUseCase(callsScope: CallsScope) = + callsScope.observeInCallReactions } diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt index ad72e7b2fea..2c1f3dd1692 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt @@ -28,6 +28,7 @@ import com.wire.kalium.logic.feature.asset.ObserveAssetStatusesUseCase import com.wire.kalium.logic.feature.asset.ObservePaginatedAssetImageMessages import com.wire.kalium.logic.feature.asset.ScheduleNewAssetMessageUseCase import com.wire.kalium.logic.feature.asset.UpdateAssetMessageTransferStatusUseCase +import com.wire.kalium.logic.feature.incallreaction.SendInCallReactionUseCase import com.wire.kalium.logic.feature.message.DeleteMessageUseCase import com.wire.kalium.logic.feature.message.GetMessageByIdUseCase import com.wire.kalium.logic.feature.message.GetNotificationsUseCase @@ -216,4 +217,9 @@ class MessageModule { @Provides fun provideRemoveMessageDraftUseCase(messageScope: MessageScope): RemoveMessageDraftUseCase = messageScope.removeMessageDraftUseCase + + @ViewModelScoped + @Provides + fun provideSendInCallReactionUseCase(messageScope: MessageScope): SendInCallReactionUseCase = + messageScope.sendInCallReactionUseCase } diff --git a/app/src/main/kotlin/com/wire/android/mapper/UICallParticipantMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/UICallParticipantMapper.kt index 3ce437b99df..78f0a45cc0a 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/UICallParticipantMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/UICallParticipantMapper.kt @@ -21,14 +21,16 @@ package com.wire.android.mapper import com.wire.android.model.ImageAsset import com.wire.android.ui.calling.model.UICallParticipant import com.wire.kalium.logic.data.call.Participant +import com.wire.kalium.logic.data.conversation.ClientId import javax.inject.Inject class UICallParticipantMapper @Inject constructor( private val userTypeMapper: UserTypeMapper, ) { - fun toUICallParticipant(participant: Participant) = UICallParticipant( + fun toUICallParticipant(participant: Participant, currentClientId: ClientId) = UICallParticipant( id = participant.id, clientId = participant.clientId, + isSelfUser = participant.clientId == currentClientId.value, name = participant.name, isMuted = participant.isMuted, isSpeaking = participant.isSpeaking, diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/SharedCallingViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/SharedCallingViewModel.kt index 807f0600b43..536d175b451 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/SharedCallingViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/SharedCallingViewModel.kt @@ -20,6 +20,7 @@ package com.wire.android.ui.calling import android.view.View import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel @@ -29,26 +30,37 @@ import com.wire.android.mapper.UICallParticipantMapper import com.wire.android.mapper.UserTypeMapper import com.wire.android.media.CallRinger import com.wire.android.model.ImageAsset +import com.wire.android.ui.calling.model.InCallReaction +import com.wire.android.ui.calling.model.ReactionSender import com.wire.android.ui.calling.model.UICallParticipant +import com.wire.android.ui.calling.ongoing.incallreactions.InCallReactions +import com.wire.android.util.ExpiringMap import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.android.util.extension.withDelayAfterFirst import com.wire.kalium.logic.data.call.Call import com.wire.kalium.logic.data.call.ConversationTypeForCall import com.wire.kalium.logic.data.call.VideoState import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase import com.wire.kalium.logic.feature.call.usecase.FlipToBackCameraUseCase import com.wire.kalium.logic.feature.call.usecase.FlipToFrontCameraUseCase import com.wire.kalium.logic.feature.call.usecase.MuteCallUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallWithSortedParticipantsUseCase +import com.wire.kalium.logic.feature.call.usecase.ObserveInCallReactionsUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveSpeakerUseCase import com.wire.kalium.logic.feature.call.usecase.SetVideoPreviewUseCase import com.wire.kalium.logic.feature.call.usecase.TurnLoudSpeakerOffUseCase import com.wire.kalium.logic.feature.call.usecase.TurnLoudSpeakerOnUseCase import com.wire.kalium.logic.feature.call.usecase.UnMuteCallUseCase import com.wire.kalium.logic.feature.call.usecase.video.UpdateVideoStateUseCase +import com.wire.kalium.logic.feature.client.ObserveCurrentClientIdUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase +import com.wire.kalium.logic.feature.incallreaction.SendInCallReactionUseCase +import com.wire.kalium.logic.functional.onSuccess import com.wire.kalium.logic.util.PlatformView import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -57,13 +69,19 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch @@ -83,6 +101,9 @@ class SharedCallingViewModel @AssistedInject constructor( private val flipToFrontCamera: FlipToFrontCameraUseCase, private val flipToBackCamera: FlipToBackCameraUseCase, private val observeSpeaker: ObserveSpeakerUseCase, + private val observeInCallReactionsUseCase: ObserveInCallReactionsUseCase, + private val sendInCallReactionUseCase: SendInCallReactionUseCase, + private val getCurrentClientId: ObserveCurrentClientIdUseCase, private val callRinger: CallRinger, private val uiCallParticipantMapper: UICallParticipantMapper, private val userTypeMapper: UserTypeMapper, @@ -93,6 +114,15 @@ class SharedCallingViewModel @AssistedInject constructor( var participantsState by mutableStateOf(persistentListOf()) + private val _inCallReactions = Channel( + capacity = 300, // Max reactions to keep in queue + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + val inCallReactions = _inCallReactions.receiveAsFlow().withDelayAfterFirst(InCallReactions.reactionsThrottleDelayMs) + + val recentReactions = recentInCallReactionMap() + init { viewModelScope.launch { val allCallsSharedFlow = observeEstablishedCallWithSortedParticipants(conversationId) @@ -110,6 +140,9 @@ class SharedCallingViewModel @AssistedInject constructor( launch { observeOnSpeaker(this) } + launch { + observeInCallReactions() + } } } @@ -172,18 +205,21 @@ class SharedCallingViewModel @AssistedInject constructor( } private suspend fun observeParticipants(sharedFlow: SharedFlow) { - sharedFlow.collectLatest { call -> - call?.let { - callState = callState.copy( - isMuted = it.isMuted, - callStatus = it.status, - isCameraOn = it.isCameraOn, - isCbrEnabled = it.isCbrEnabled && call.conversationType == Conversation.Type.ONE_ON_ONE, - callerName = it.callerName, - ) - participantsState = call.participants.map { uiCallParticipantMapper.toUICallParticipant(it) }.toPersistentList() - } - } + combine( + getCurrentClientId().filterNotNull(), + sharedFlow.filterNotNull(), + ) { clientId, call -> + callState = callState.copy( + isMuted = call.isMuted, + callStatus = call.status, + isCameraOn = call.isCameraOn, + isCbrEnabled = call.isCbrEnabled && call.conversationType == Conversation.Type.ONE_ON_ONE, + callerName = call.callerName, + ) + participantsState = call.participants.map { + uiCallParticipantMapper.toUICallParticipant(it, clientId) + }.toPersistentList() + }.collect() } fun hangUpCall(onCompleted: () -> Unit) { @@ -279,8 +315,42 @@ class SharedCallingViewModel @AssistedInject constructor( } } + private suspend fun observeInCallReactions() { + observeInCallReactionsUseCase(conversationId).collect { message -> + + val sender = participantsState.senderName(message.senderUserId)?.let { name -> + ReactionSender.Other(name) + } ?: ReactionSender.Unknown + + message.emojis.forEach { emoji -> + _inCallReactions.send(InCallReaction(emoji, sender)) + } + + if (message.emojis.isNotEmpty()) { + recentReactions.put(message.senderUserId, message.emojis.last()) + } + } + } + + fun onReactionClick(emoji: String) { + viewModelScope.launch { + sendInCallReactionUseCase(conversationId, emoji).onSuccess { + _inCallReactions.send(InCallReaction(emoji, ReactionSender.You)) + } + } + } + + private fun recentInCallReactionMap(): MutableMap = + ExpiringMap( + scope = viewModelScope, + expiration = InCallReactions.recentReactionShowDurationMs, + delegate = mutableStateMapOf() + ) + @AssistedFactory interface Factory { fun create(conversationId: ConversationId): SharedCallingViewModel } } + +private fun List.senderName(userId: QualifiedID) = firstOrNull { it.id.value == userId.value }?.name diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraButton.kt index cc920e44ffe..3acf75cd9a8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraButton.kt @@ -20,10 +20,8 @@ package com.wire.android.ui.calling.controlbuttons import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp import com.wire.android.R import com.wire.android.appLogger -import com.wire.android.ui.common.dimensions import com.wire.android.ui.theme.WireTheme import com.wire.android.util.permission.rememberCameraPermissionFlow import com.wire.android.util.ui.PreviewMultipleThemes @@ -34,7 +32,6 @@ fun CameraButton( onPermissionPermanentlyDenied: () -> Unit, modifier: Modifier = Modifier, isCameraOn: Boolean = false, - size: Dp = dimensions().defaultCallingControlsSize, ) { val cameraPermissionCheck = rememberCameraPermissionFlow( onPermissionGranted = { @@ -56,7 +53,6 @@ fun CameraButton( false -> R.string.content_description_calling_turn_camera_on }, onClick = cameraPermissionCheck::launch, - size = size, modifier = modifier, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/HangUpButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/HangUpButton.kt index c64e3cb7379..41812569a70 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/HangUpButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/HangUpButton.kt @@ -38,7 +38,7 @@ import com.wire.android.util.ui.PreviewMultipleThemes fun HangUpButton( onHangUpButtonClicked: () -> Unit, modifier: Modifier = Modifier, - size: Dp = dimensions().bigCallingControlsSize, + size: Dp = dimensions().defaultCallingControlsHeight, iconSize: Dp = dimensions().bigCallingHangUpButtonIconSize, ) { WirePrimaryIconButton( diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/HangUpOngoingButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/HangUpOngoingButton.kt new file mode 100644 index 00000000000..1a484e26b04 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/HangUpOngoingButton.kt @@ -0,0 +1,67 @@ +/* + * 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.ui.calling.controlbuttons + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import com.wire.android.R +import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.button.WirePrimaryIconButton +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.WireTheme +import com.wire.android.ui.theme.wireDimensions +import com.wire.android.util.ui.PreviewMultipleThemes + +@Composable +fun HangUpOngoingButton( + onHangUpButtonClicked: () -> Unit, + modifier: Modifier = Modifier, + width: Dp = dimensions().defaultCallingControlsWidth, + height: Dp = dimensions().defaultCallingControlsHeight, + iconSize: Dp = dimensions().bigCallingHangUpButtonIconSize, +) { + WirePrimaryIconButton( + iconResource = R.drawable.ic_call_reject, + contentDescription = R.string.content_description_calling_hang_up_call, + state = WireButtonState.Error, + shape = CircleShape, + minSize = DpSize(width, height), + minClickableSize = DpSize(width, height), + iconSize = iconSize, + onButtonClicked = onHangUpButtonClicked, + modifier = modifier, + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewComposableHangUpOngoingButton() = WireTheme { + HangUpOngoingButton( + modifier = Modifier + .width(MaterialTheme.wireDimensions.bigCallingControlsSize) + .height(MaterialTheme.wireDimensions.bigCallingControlsSize), + onHangUpButtonClicked = { } + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraFlipButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/InCallReactionsButton.kt similarity index 54% rename from app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraFlipButton.kt rename to app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/InCallReactionsButton.kt index bc58e225943..0a8d3d3bb3e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/CameraFlipButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/InCallReactionsButton.kt @@ -15,48 +15,49 @@ * 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.ui.calling.controlbuttons import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp import com.wire.android.R -import com.wire.android.ui.common.dimensions import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes @Composable -fun CameraFlipButton( - onCameraFlipButtonClicked: () -> Unit, +fun InCallReactionsButton( + isSelected: Boolean, + onInCallReactionsClick: () -> Unit, modifier: Modifier = Modifier, - isOnFrontCamera: Boolean = false, - size: Dp = dimensions().defaultCallingControlsSize ) { WireCallControlButton( - isSelected = !isOnFrontCamera, - iconResId = when (isOnFrontCamera) { - true -> R.drawable.ic_camera_flipped - false -> R.drawable.ic_camera_flip + isSelected = isSelected, + iconResId = when (isSelected) { + true -> R.drawable.ic_incall_reactions + false -> R.drawable.ic_incall_reactions }, - contentDescription = when (isOnFrontCamera) { - true -> R.string.content_description_calling_flip_camera_on - false -> R.string.content_description_calling_flip_camera_off + contentDescription = when (isSelected) { + true -> R.string.content_description_calling_unmute_call + false -> R.string.content_description_calling_mute_call }, - onClick = onCameraFlipButtonClicked, - size = size, - modifier = modifier, + onClick = onInCallReactionsClick, + modifier = modifier ) } @PreviewMultipleThemes @Composable -fun PreviewCameraFlipButtonOn() = WireTheme { - CameraFlipButton(isOnFrontCamera = true, onCameraFlipButtonClicked = { }) +fun PreviewInCallReactionsButton() = WireTheme { + InCallReactionsButton( + isSelected = false, + onInCallReactionsClick = { } + ) } @PreviewMultipleThemes @Composable -fun PreviewCameraFlipButtonOff() = WireTheme { - CameraFlipButton(isOnFrontCamera = false, onCameraFlipButtonClicked = { }) +fun PreviewInCallReactionsButtonSelected() = WireTheme { + InCallReactionsButton( + isSelected = true, + onInCallReactionsClick = { } + ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/MicrophoneButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/MicrophoneButton.kt index 272f6da8b92..ea76130a913 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/MicrophoneButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/MicrophoneButton.kt @@ -20,9 +20,7 @@ package com.wire.android.ui.calling.controlbuttons import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp import com.wire.android.R -import com.wire.android.ui.common.dimensions import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes @@ -31,7 +29,6 @@ fun MicrophoneButton( isMuted: Boolean, onMicrophoneButtonClicked: () -> Unit, modifier: Modifier = Modifier, - size: Dp = dimensions().defaultCallingControlsSize ) { WireCallControlButton( isSelected = !isMuted, @@ -44,7 +41,6 @@ fun MicrophoneButton( false -> R.string.content_description_calling_mute_call }, onClick = onMicrophoneButtonClicked, - size = size, modifier = modifier ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/SpeakerButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/SpeakerButton.kt index 46bf66d8f50..d399f3e43c8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/SpeakerButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/SpeakerButton.kt @@ -20,9 +20,7 @@ package com.wire.android.ui.calling.controlbuttons import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp import com.wire.android.R -import com.wire.android.ui.common.dimensions import com.wire.android.ui.theme.WireTheme import com.wire.android.util.ui.PreviewMultipleThemes @@ -31,7 +29,6 @@ fun SpeakerButton( isSpeakerOn: Boolean, onSpeakerButtonClicked: () -> Unit, modifier: Modifier = Modifier, - size: Dp = dimensions().defaultCallingControlsSize ) { WireCallControlButton( isSelected = isSpeakerOn, @@ -44,7 +41,6 @@ fun SpeakerButton( false -> R.string.content_description_calling_turn_speaker_on }, onClick = onSpeakerButtonClicked, - size = size, modifier = modifier, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/WireCallControlButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/WireCallControlButton.kt index 3f438a43087..4208a19e0a5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/WireCallControlButton.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/controlbuttons/WireCallControlButton.kt @@ -20,14 +20,13 @@ package com.wire.android.ui.calling.controlbuttons import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import com.wire.android.ui.common.button.WireButtonState -import com.wire.android.ui.common.button.WireSecondaryIconButton +import com.wire.android.ui.common.button.WirePrimaryIconButton import com.wire.android.ui.common.button.wireSecondaryButtonColors import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions @@ -39,10 +38,11 @@ fun WireCallControlButton( @StringRes contentDescription: Int, onClick: () -> Unit, modifier: Modifier = Modifier, - size: Dp = dimensions().defaultCallingControlsSize, + width: Dp = dimensions().defaultCallingControlsWidth, + height: Dp = dimensions().defaultCallingControlsHeight, iconSize: Dp = dimensions().defaultCallingControlsIconSize ) { - WireSecondaryIconButton( + WirePrimaryIconButton( onButtonClicked = onClick, iconResource = iconResId, shape = CircleShape, @@ -60,9 +60,9 @@ fun WireCallControlButton( }, contentDescription = contentDescription, state = if (isSelected) WireButtonState.Selected else WireButtonState.Default, - minSize = DpSize(size, size), - minClickableSize = DpSize(size, size), + minSize = DpSize(width, height), + minClickableSize = DpSize(width, height), iconSize = iconSize, - modifier = modifier.size(size), + modifier = modifier, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/model/InCallReaction.kt b/app/src/main/kotlin/com/wire/android/ui/calling/model/InCallReaction.kt new file mode 100644 index 00000000000..0470beae5dc --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/calling/model/InCallReaction.kt @@ -0,0 +1,29 @@ +/* + * 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.ui.calling.model + +data class InCallReaction( + val emoji: String, + val sender: ReactionSender, +) + +sealed interface ReactionSender { + data object You : ReactionSender + data class Other(val name: String) : ReactionSender + data object Unknown : ReactionSender +} diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/model/UICallParticipant.kt b/app/src/main/kotlin/com/wire/android/ui/calling/model/UICallParticipant.kt index 105046d26b7..b7b3fcf2407 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/model/UICallParticipant.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/model/UICallParticipant.kt @@ -25,6 +25,7 @@ import com.wire.kalium.logic.data.id.QualifiedID data class UICallParticipant( val id: QualifiedID, val clientId: String, + val isSelfUser: Boolean, val name: String? = null, val isMuted: Boolean, val isSpeaking: Boolean = false, 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 ec577f2c221..9a5b93df9f6 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 @@ -21,6 +21,10 @@ package com.wire.android.ui.calling.ongoing import android.content.pm.PackageManager import android.view.View import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -32,7 +36,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue @@ -65,14 +68,20 @@ import com.wire.android.ui.calling.CallState import com.wire.android.ui.calling.ConversationName import com.wire.android.ui.calling.SharedCallingViewModel import com.wire.android.ui.calling.controlbuttons.CameraButton -import com.wire.android.ui.calling.controlbuttons.CameraFlipButton -import com.wire.android.ui.calling.controlbuttons.HangUpButton +import com.wire.android.ui.calling.controlbuttons.HangUpOngoingButton +import com.wire.android.ui.calling.controlbuttons.InCallReactionsButton import com.wire.android.ui.calling.controlbuttons.MicrophoneButton import com.wire.android.ui.calling.controlbuttons.SpeakerButton +import com.wire.android.ui.calling.model.InCallReaction import com.wire.android.ui.calling.model.UICallParticipant import com.wire.android.ui.calling.ongoing.fullscreen.DoubleTapToast import com.wire.android.ui.calling.ongoing.fullscreen.FullScreenTile import com.wire.android.ui.calling.ongoing.fullscreen.SelectedParticipant +import com.wire.android.ui.calling.ongoing.incallreactions.AnimatableReaction +import com.wire.android.ui.calling.ongoing.incallreactions.InCallReactionsPanel +import com.wire.android.ui.calling.ongoing.incallreactions.InCallReactionsState +import com.wire.android.ui.calling.ongoing.incallreactions.drawInCallReactions +import com.wire.android.ui.calling.ongoing.incallreactions.rememberInCallReactionsState import com.wire.android.ui.calling.ongoing.participantsview.FloatingSelfUserTile import com.wire.android.ui.calling.ongoing.participantsview.VerticalCallingPager import com.wire.android.ui.common.ConversationVerificationIcons @@ -85,11 +94,11 @@ import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.visbility.rememberVisibilityState +import com.wire.android.ui.emoji.EmojiPickerBottomSheet import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme -import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography import com.wire.android.util.ui.PreviewMultipleThemes import com.wire.kalium.logic.data.call.CallStatus @@ -100,6 +109,7 @@ import com.wire.kalium.logic.data.user.UserId import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.collectLatest import java.util.Locale @Suppress("ParameterWrapping") @@ -118,6 +128,8 @@ fun OngoingCallScreen( val permissionPermanentlyDeniedDialogState = rememberVisibilityState() + val inCallReactionsState = rememberInCallReactionsState() + val activity = LocalActivity.current val isPiPAvailableOnThisDevice = activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) @@ -133,6 +145,13 @@ fun OngoingCallScreen( } } } + + LaunchedEffect(Unit) { + sharedCallingViewModel.inCallReactions.collectLatest { reaction -> + inCallReactionsState.runAnimation(reaction) + } + } + val hangUpCall = remember { { sharedCallingViewModel.hangUpCall { activity.finishAndRemoveTask() } @@ -167,6 +186,7 @@ fun OngoingCallScreen( val inPictureInPictureMode = activity.isInPictureInPictureMode OngoingCallContent( callState = sharedCallingViewModel.callState, + inCallReactionsState = inCallReactionsState, shouldShowDoubleTapToast = ongoingCallViewModel.shouldShowDoubleTapToast, toggleSpeaker = sharedCallingViewModel::toggleSpeaker, toggleMute = sharedCallingViewModel::toggleMute, @@ -181,9 +201,10 @@ fun OngoingCallScreen( selectedParticipantForFullScreen = ongoingCallViewModel.selectedParticipant, hideDoubleTapToast = ongoingCallViewModel::hideDoubleTapToast, onCameraPermissionPermanentlyDenied = onCameraPermissionPermanentlyDenied, + onReactionClick = sharedCallingViewModel::onReactionClick, participants = sharedCallingViewModel.participantsState, inPictureInPictureMode = inPictureInPictureMode, - currentUserId = ongoingCallViewModel.currentUserId, + recentReactions = sharedCallingViewModel.recentReactions, ) BackHandler { @@ -279,6 +300,7 @@ private fun HandleSendingVideoFeed( @Composable private fun OngoingCallContent( callState: CallState, + inCallReactionsState: InCallReactionsState, shouldShowDoubleTapToast: Boolean, toggleSpeaker: () -> Unit, toggleMute: () -> Unit, @@ -290,12 +312,13 @@ private fun OngoingCallContent( onCollapse: () -> Unit, hideDoubleTapToast: () -> Unit, onCameraPermissionPermanentlyDenied: () -> Unit, + onReactionClick: (String) -> Unit, requestVideoStreams: (participants: List) -> Unit, onSelectedParticipant: (selectedParticipant: SelectedParticipant) -> Unit, selectedParticipantForFullScreen: SelectedParticipant, participants: PersistentList, + recentReactions: Map, inPictureInPictureMode: Boolean, - currentUserId: UserId, ) { val sheetInitialValue = SheetValue.PartiallyExpanded val sheetState = rememberStandardBottomSheetState( @@ -308,6 +331,9 @@ private fun OngoingCallContent( var shouldOpenFullScreen by remember { mutableStateOf(false) } + var showInCallReactionsPanel by remember { mutableStateOf(false) } + var showEmojiPicker by remember { mutableStateOf(false) } + WireBottomSheetScaffold( sheetDragHandle = null, topBar = if (inPictureInPictureMode) { @@ -336,19 +362,22 @@ private fun OngoingCallContent( conversationId = callState.conversationId, isMuted = callState.isMuted ?: true, isCameraOn = callState.isCameraOn, - isOnFrontCamera = callState.isOnFrontCamera, isSpeakerOn = callState.isSpeakerOn, + isShowingCallReactions = showInCallReactionsPanel, toggleSpeaker = toggleSpeaker, toggleMute = toggleMute, onHangUpCall = hangUpCall, onToggleVideo = toggleVideo, - flipCamera = flipCamera, + onCallReactionsClick = { + showInCallReactionsPanel = !showInCallReactionsPanel + }, onCameraPermissionPermanentlyDenied = onCameraPermissionPermanentlyDenied ) } }, + sheetContainerColor = colorsScheme().background, ) { - BoxWithConstraints( + Column( modifier = Modifier .padding( top = it.calculateTopPadding(), @@ -356,96 +385,135 @@ private fun OngoingCallContent( ) ) { - if (participants.isEmpty()) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - WireCircularProgressIndicator( - progressColor = MaterialTheme.wireColorScheme.onSurface, - modifier = Modifier.align(Alignment.CenterHorizontally), - size = dimensions().spacing32x - ) - Text( - text = stringResource(id = R.string.calling_screen_connecting_until_call_established), - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - } - } else { - Box( - modifier = Modifier.fillMaxSize() - ) { - - // if there is only one in the call, do not allow full screen - if (participants.size == 1) { - shouldOpenFullScreen = false - } - - // if we are on full screen, and that user left the call, then we leave the full screen - if (participants.find { user -> user.id == selectedParticipantForFullScreen.userId } == null) { - shouldOpenFullScreen = false - } - - if (shouldOpenFullScreen) { - hideDoubleTapToast() - FullScreenTile( - callState = callState, - selectedParticipant = selectedParticipantForFullScreen, - height = this@BoxWithConstraints.maxHeight - dimensions().spacing4x, - closeFullScreen = { - onSelectedParticipant(SelectedParticipant()) - shouldOpenFullScreen = !shouldOpenFullScreen - }, - onBackButtonClicked = { - onSelectedParticipant(SelectedParticipant()) - shouldOpenFullScreen = !shouldOpenFullScreen - }, - requestVideoStreams = requestVideoStreams, - setVideoPreview = setVideoPreview, - clearVideoPreview = clearVideoPreview, - participants = participants - ) - } else { - VerticalCallingPager( - participants = participants, - isSelfUserCameraOn = callState.isCameraOn, - isSelfUserMuted = callState.isMuted ?: true, - isInPictureInPictureMode = inPictureInPictureMode, - contentHeight = this@BoxWithConstraints.maxHeight, - onSelfVideoPreviewCreated = setVideoPreview, - onSelfClearVideoPreview = clearVideoPreview, - requestVideoStreams = requestVideoStreams, - currentUserId = currentUserId, - onDoubleTap = { selectedParticipant -> - onSelectedParticipant(selectedParticipant) - shouldOpenFullScreen = !shouldOpenFullScreen - }, + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .weight(1f) + ) { + + if (participants.isEmpty()) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + WireCircularProgressIndicator( + progressColor = MaterialTheme.wireColorScheme.onSurface, + modifier = Modifier.align(Alignment.CenterHorizontally), + size = dimensions().spacing32x ) - DoubleTapToast( - modifier = Modifier.align(Alignment.TopCenter), - enabled = shouldShowDoubleTapToast, - text = stringResource(id = R.string.calling_ongoing_double_tap_for_full_screen), - onTap = hideDoubleTapToast + Text( + text = stringResource(id = R.string.calling_screen_connecting_until_call_established), + modifier = Modifier.align(Alignment.CenterHorizontally), ) } - if (BuildConfig.PICTURE_IN_PICTURE_ENABLED && participants.size > 1) { - val selfUser = - participants.first { participant -> - // API returns only id.value, without domain, till this get changed compare only id.value - participant.id.equalsIgnoringBlankDomain(currentUserId) - } - FloatingSelfUserTile( - modifier = Modifier.align(Alignment.TopEnd), - contentHeight = this@BoxWithConstraints.maxHeight, - contentWidth = this@BoxWithConstraints.maxWidth, - participant = selfUser, - onSelfUserVideoPreviewCreated = setVideoPreview, - onClearSelfUserVideoPreview = clearVideoPreview - ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .drawInCallReactions(state = inCallReactionsState) + ) { + + // if there is only one in the call, do not allow full screen + if (participants.size == 1) { + shouldOpenFullScreen = false + } + + // if we are on full screen, and that user left the call, then we leave the full screen + if (participants.find { user -> user.id == selectedParticipantForFullScreen.userId } == null) { + shouldOpenFullScreen = false + } + + if (shouldOpenFullScreen) { + hideDoubleTapToast() + FullScreenTile( + callState = callState, + selectedParticipant = selectedParticipantForFullScreen, + height = this@BoxWithConstraints.maxHeight - dimensions().spacing4x, + closeFullScreen = { + onSelectedParticipant(SelectedParticipant()) + shouldOpenFullScreen = !shouldOpenFullScreen + }, + onBackButtonClicked = { + onSelectedParticipant(SelectedParticipant()) + shouldOpenFullScreen = !shouldOpenFullScreen + }, + requestVideoStreams = requestVideoStreams, + setVideoPreview = setVideoPreview, + clearVideoPreview = clearVideoPreview, + participants = participants, + isOnFrontCamera = callState.isOnFrontCamera, + flipCamera = flipCamera, + ) + } else { + VerticalCallingPager( + participants = participants, + isSelfUserCameraOn = callState.isCameraOn, + isSelfUserMuted = callState.isMuted ?: true, + isInPictureInPictureMode = inPictureInPictureMode, + isOnFrontCamera = callState.isOnFrontCamera, + contentHeight = this@BoxWithConstraints.maxHeight, + onSelfVideoPreviewCreated = setVideoPreview, + onSelfClearVideoPreview = clearVideoPreview, + requestVideoStreams = requestVideoStreams, + recentReactions = recentReactions, + onDoubleTap = { selectedParticipant -> + onSelectedParticipant(selectedParticipant) + shouldOpenFullScreen = !shouldOpenFullScreen + }, + flipCamera = flipCamera, + ) + DoubleTapToast( + modifier = Modifier.align(Alignment.TopCenter), + enabled = shouldShowDoubleTapToast, + text = stringResource(id = R.string.calling_ongoing_double_tap_for_full_screen), + onTap = hideDoubleTapToast + ) + } + if (BuildConfig.PICTURE_IN_PICTURE_ENABLED && participants.size > 1) { + val selfUser = participants.first { it.isSelfUser } + FloatingSelfUserTile( + modifier = Modifier.align(Alignment.TopEnd), + contentHeight = this@BoxWithConstraints.maxHeight, + contentWidth = this@BoxWithConstraints.maxWidth, + participant = selfUser, + isOnFrontCamera = callState.isOnFrontCamera, + onSelfUserVideoPreviewCreated = setVideoPreview, + onClearSelfUserVideoPreview = clearVideoPreview, + flipCamera = flipCamera, + ) + } } } } + + AnimatedContent( + targetState = showInCallReactionsPanel, + transitionSpec = { + val enter = slideInVertically(initialOffsetY = { it }) + val exit = slideOutVertically(targetOffsetY = { it }) + enter.togetherWith(exit) + }, + label = "InCallReactions" + ) { show -> + if (show) { + InCallReactionsPanel( + onReactionClick = onReactionClick, + onMoreClick = { showEmojiPicker = true } + ) + } + } + + EmojiPickerBottomSheet( + isVisible = showEmojiPicker, + onEmojiSelected = { + showEmojiPicker = false + onReactionClick(it) + }, + onDismiss = { + showEmojiPicker = false + }, + ) } } } @@ -508,12 +576,12 @@ private fun CallingControls( isMuted: Boolean, isCameraOn: Boolean, isSpeakerOn: Boolean, - isOnFrontCamera: Boolean, + isShowingCallReactions: Boolean, toggleSpeaker: () -> Unit, toggleMute: () -> Unit, onHangUpCall: () -> Unit, onToggleVideo: () -> Unit, - flipCamera: () -> Unit, + onCallReactionsClick: () -> Unit, onCameraPermissionPermanentlyDenied: () -> Unit ) { Column( @@ -527,7 +595,10 @@ private fun CallingControls( .fillMaxWidth() .height(dimensions().spacing56x) ) { - MicrophoneButton(isMuted = isMuted, onMicrophoneButtonClicked = toggleMute) + MicrophoneButton( + isMuted = isMuted, + onMicrophoneButtonClicked = toggleMute + ) CameraButton( isCameraOn = isCameraOn, onPermissionPermanentlyDenied = onCameraPermissionPermanentlyDenied, @@ -539,15 +610,12 @@ private fun CallingControls( onSpeakerButtonClicked = toggleSpeaker ) - if (isCameraOn) { - CameraFlipButton( - isOnFrontCamera = isOnFrontCamera, - onCameraFlipButtonClicked = flipCamera - ) - } + InCallReactionsButton( + isSelected = isShowingCallReactions, + onInCallReactionsClick = onCallReactionsClick + ) - HangUpButton( - modifier = Modifier.size(MaterialTheme.wireDimensions.defaultCallingControlsSize), + HangUpOngoingButton( onHangUpButtonClicked = onHangUpCall ) } @@ -556,6 +624,7 @@ private fun CallingControls( } } +@Suppress("EmptyFunctionBlock") @Composable fun PreviewOngoingCallContent(participants: PersistentList) { OngoingCallContent( @@ -571,6 +640,10 @@ fun PreviewOngoingCallContent(participants: PersistentList) { mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, ), + inCallReactionsState = object : InCallReactionsState { + override fun runAnimation(inCallReaction: InCallReaction) {} + override fun getReactions(): List = emptyList() + }, shouldShowDoubleTapToast = false, toggleSpeaker = {}, toggleMute = {}, @@ -582,12 +655,13 @@ fun PreviewOngoingCallContent(participants: PersistentList) { onCollapse = {}, hideDoubleTapToast = {}, onCameraPermissionPermanentlyDenied = {}, + onReactionClick = {}, requestVideoStreams = {}, participants = participants, inPictureInPictureMode = false, - currentUserId = UserId("userId", "domain"), onSelectedParticipant = {}, selectedParticipantForFullScreen = SelectedParticipant(), + recentReactions = emptyMap(), ) } @@ -621,6 +695,7 @@ fun buildPreviewParticipantsList(count: Int = 10) = buildList { UICallParticipant( id = QualifiedID("id_$index", ""), clientId = "client_id_$index", + isSelfUser = false, name = "Participant $index", isSpeaking = index % 3 == 1, isMuted = index % 3 == 2, diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt index 84c6e1a57d1..a70325a0cd7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/OngoingCallViewModel.kt @@ -47,7 +47,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") @HiltViewModel(assistedFactory = OngoingCallViewModel.Factory::class) class OngoingCallViewModel @AssistedInject constructor( @Assisted diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt index cee64f83032..add7376f6dc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/fullscreen/FullScreenTile.kt @@ -62,6 +62,8 @@ fun FullScreenTile( setVideoPreview: (View) -> Unit, requestVideoStreams: (participants: List) -> Unit, clearVideoPreview: () -> Unit, + isOnFrontCamera: Boolean, + flipCamera: () -> Unit, modifier: Modifier = Modifier, contentPadding: Dp = dimensions().spacing4x, ) { @@ -87,7 +89,6 @@ fun FullScreenTile( .height(height) .padding(contentPadding), participantTitleState = it, - isSelfUser = selectedParticipant.isSelfUser, isSelfUserCameraOn = if (selectedParticipant.isSelfUser) { callState.isCameraOn } else { @@ -101,7 +102,9 @@ fun FullScreenTile( shouldFillOthersVideoPreview = false, isZoomingEnabled = true, onSelfUserVideoPreviewCreated = setVideoPreview, - onClearSelfUserVideoPreview = clearVideoPreview + onClearSelfUserVideoPreview = clearVideoPreview, + isOnFrontCamera = isOnFrontCamera, + flipCamera = flipCamera, ) LaunchedEffect(Unit) { delay(200) @@ -147,5 +150,7 @@ fun PreviewFullScreenTile() = WireTheme { requestVideoStreams = {}, clearVideoPreview = {}, participants = participants, + isOnFrontCamera = false, + flipCamera = {}, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactions.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactions.kt new file mode 100644 index 00000000000..336a5d39397 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactions.kt @@ -0,0 +1,52 @@ +/* + * 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.ui.calling.ongoing.incallreactions + +@Suppress("MagicNumber") +object InCallReactions { + + /** + * Default in call reaction emojis + */ + val defaultReactions = listOf("👍", "🎉", "❤️", "😂", "😮", "👏", "🤔", "😢", "👎") + + /** + * Next reaction click is disabled until delay expires + */ + const val reactionDelayMs = 3000L + + /** + * Total duration for reaction animation + */ + const val animationDurationMs = 3000 + + /** + * Duration for reaction fade out animation + */ + const val fadeOutAnimationDuarationMs = 500 + + /** + * Delay between displaying reactions on screen + */ + const val reactionsThrottleDelayMs: Long = 200 + + /** + * Duration for showing recent reaction next to user image + */ + const val recentReactionShowDurationMs: Long = 6000 +} diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsModifier.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsModifier.kt new file mode 100644 index 00000000000..eaafe6c35fd --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsModifier.kt @@ -0,0 +1,137 @@ +/* + * 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.ui.calling.ongoing.incallreactions + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.center +import androidx.compose.ui.unit.toSize +import com.wire.android.R +import com.wire.android.ui.calling.model.ReactionSender +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.typography +import kotlin.math.max + +@Composable +fun Modifier.drawInCallReactions( + state: InCallReactionsState, + labelTextColor: Color = Color.White, + labelColor: Color = Color.Black, + emojiBackgroundColor: Color = colorsScheme().emojiBackgroundColor, + emojiBackgroundSize: Dp = dimensions().inCallReactionButtonSize, + emojiTextStyle: TextStyle = typography().inCallReactionEmoji, + labelTextStyle: TextStyle = typography().label01, +): Modifier { + + val textMeasurer = rememberTextMeasurer() + + val emojiBackgroundSizePx = with(LocalDensity.current) { emojiBackgroundSize.toPx() } + val labelTopMarginPx = with(LocalDensity.current) { dimensions().spacing12x.toPx() } + val labelTextPaddingPx = with(LocalDensity.current) { dimensions().spacing4x.toPx() } + val reactionSenderSelf = stringResource(R.string.reaction_sender_self) + + return this then Modifier.drawWithContent { + + drawContent() + + clipRect(left = 0f, top = 0f, right = size.width, bottom = size.height) { + + state.getReactions().forEach { reaction -> + + val senderText = when (reaction.inCallReaction.sender) { + is ReactionSender.You -> reactionSenderSelf + is ReactionSender.Other -> reaction.inCallReaction.sender.name + is ReactionSender.Unknown -> "" + } + + val emojiLayoutResult = textMeasurer.measure(reaction.inCallReaction.emoji, emojiTextStyle) + val labelLayoutResult = textMeasurer.measure(senderText, labelTextStyle) + + val emojiSize = emojiLayoutResult.size.toSize() + val labelSize = Size( + width = labelLayoutResult.size.width + labelTextPaddingPx * 2, + height = labelLayoutResult.size.height + labelTextPaddingPx * 2 + ) + + val offsetVertical = size.height - size.height * reaction.verticalOffset.value + val offsetHorizontal = (size.width - max(emojiSize.width, labelSize.width)) * reaction.horizontalOffset + + translate( + top = offsetVertical + emojiSize.height, + left = offsetHorizontal + (labelLayoutResult.size.width - emojiSize.width).coerceAtLeast(0f) / 2f + ) { + + // Draw emoji background + drawRoundRect( + color = emojiBackgroundColor.copy(alpha = reaction.alpha.value), + topLeft = Offset( + x = emojiSize.center.x - emojiBackgroundSizePx / 2, + y = emojiSize.center.y - emojiBackgroundSizePx / 2, + ), + size = Size(emojiBackgroundSizePx, emojiBackgroundSizePx), + cornerRadius = CornerRadius(20f), + ) + + // Draw emoji + drawText( + textLayoutResult = emojiLayoutResult, + color = Color.Black.copy(alpha = reaction.alpha.value), + ) + + if (reaction.inCallReaction.sender != ReactionSender.Unknown) { + // Draw label background + drawRoundRect( + color = labelColor.copy(alpha = reaction.alpha.value), + topLeft = Offset( + x = emojiSize.center.x - labelSize.center.x, + y = emojiSize.height + labelTopMarginPx, + ), + size = labelSize, + cornerRadius = CornerRadius(10f), + ) + + // Draw label text + drawText( + textLayoutResult = labelLayoutResult, + color = labelTextColor.copy(alpha = reaction.alpha.value), + topLeft = Offset( + x = emojiSize.center.x - labelLayoutResult.size.center.x, + y = emojiSize.height + labelTopMarginPx + labelTextPaddingPx + ) + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsPanel.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsPanel.kt new file mode 100644 index 00000000000..5472e84cc7d --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsPanel.kt @@ -0,0 +1,170 @@ +/* + * 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.ui.calling.ongoing.incallreactions + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.wire.android.R +import com.wire.android.ui.calling.ongoing.incallreactions.InCallReactions.reactionDelayMs +import com.wire.android.ui.calling.ongoing.incallreactions.InCallReactions.defaultReactions +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.typography +import com.wire.android.ui.theme.WireTheme +import com.wire.android.util.ui.PreviewMultipleThemes +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun InCallReactionsPanel( + onReactionClick: (String) -> Unit, + onMoreClick: () -> Unit, + modifier: Modifier = Modifier, +) { + + val scope = rememberCoroutineScope() + + val disabledReactions = remember { mutableStateListOf() } + + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = dimensions().spacing8x) + .horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimensions().spacing16x) + ) { + + Spacer(modifier = Modifier.width(dimensions().spacing8x)) + + defaultReactions.forEach { reaction -> + EmojiButton( + isEnabled = disabledReactions.contains(reaction).not(), + emoji = reaction, + onClick = { + onReactionClick(reaction) + disabledReactions.add(reaction) + scope.launch { + delay(reactionDelayMs) + disabledReactions.remove(reaction) + } + } + ) + } + + Icon( + modifier = Modifier.clickable { onMoreClick() }, + painter = painterResource(id = R.drawable.ic_more_emojis), + contentDescription = stringResource(R.string.content_description_more_emojis) + ) + + Spacer(modifier = Modifier.width(dimensions().spacing8x)) + } +} + +@Composable +private fun EmojiButton( + emoji: String, + isEnabled: Boolean, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .size(dimensions().inCallReactionButtonSize) + .clip(RoundedCornerShape(dimensions().corner10x)) + .clickable(isEnabled) { onClick() }, + contentAlignment = Alignment.Center, + ) { + AnimatedVisibility( + visible = isEnabled, + enter = fadeIn(), + exit = fadeOut() + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background( + color = colorsScheme().secondaryButtonEnabled, + shape = RoundedCornerShape(dimensions().corner10x) + ), + ) + } + Text( + modifier = Modifier + .alpha(if (isEnabled) 1f else 0.5f), + text = emoji, + fontSize = typography().inCallReactionEmoji.fontSize, + ) + } +} + +@PreviewMultipleThemes +@Composable +private fun PreviewEmojiButton() = WireTheme { + EmojiButton( + emoji = defaultReactions[0], + isEnabled = true, + onClick = {} + ) +} + +@PreviewMultipleThemes +@Composable +private fun PreviewEmojiButtonDisabled() = WireTheme { + EmojiButton( + emoji = defaultReactions[0], + isEnabled = false, + onClick = {} + ) +} + +@PreviewMultipleThemes +@Composable +private fun PreviewInCallReactionsPanel() = WireTheme { + InCallReactionsPanel( + onReactionClick = {}, + onMoreClick = {}, + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsState.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsState.kt new file mode 100644 index 00000000000..c2e77c5624e --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/incallreactions/InCallReactionsState.kt @@ -0,0 +1,114 @@ +/* + * 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.ui.calling.ongoing.incallreactions + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import com.wire.android.ui.calling.model.InCallReaction +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import okhttp3.internal.toImmutableList +import kotlin.random.Random + +/** + * Keeps state for currently animated reactions + */ +interface InCallReactionsState { + fun runAnimation(inCallReaction: InCallReaction) + fun getReactions(): List +} + +@Composable +internal fun rememberInCallReactionsState(): InCallReactionsState { + + val scope = rememberCoroutineScope() + + return remember { + InCallReactionsStateImpl( + scope = scope, + mutableReactions = mutableStateListOf(), + ) + } +} + +private class InCallReactionsStateImpl( + private val scope: CoroutineScope, + private val mutableReactions: MutableList, +) : InCallReactionsState { + + /** + * Used by modifier to draw each animated emoji with current animation state + */ + override fun getReactions(): List = mutableReactions.toImmutableList() + + /** + * Adds new emoji to the list of animated emojis. + * Runs animations + * Removes emoji from the list once animations are complete + */ + override fun runAnimation(inCallReaction: InCallReaction) { + scope.launch(Dispatchers.Main) { + val animatable = AnimatableReaction( + inCallReaction = inCallReaction, + horizontalOffset = Random.nextFloat(), + ) + + mutableReactions.add(animatable) + runAnimations(animatable) + mutableReactions.remove(animatable) + } + } + + /** + * Start transition and fade-out animations, wait for complete and return + */ + private suspend fun CoroutineScope.runAnimations(reaction: AnimatableReaction) { + listOf( + launch { + reaction.verticalOffset.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = InCallReactions.animationDurationMs, easing = LinearEasing) + ) + }, + launch { + reaction.alpha.animateTo( + targetValue = 0.0f, + animationSpec = tween( + durationMillis = InCallReactions.fadeOutAnimationDuarationMs, + delayMillis = InCallReactions.animationDurationMs - InCallReactions.fadeOutAnimationDuarationMs + ) + ) + } + ).joinAll() + } +} + +data class AnimatableReaction( + val inCallReaction: InCallReaction, + val verticalOffset: Animatable = Animatable(0f), + val alpha: Animatable = Animatable(1f), + val horizontalOffset: Float, +) diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/FlipCameraButton.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/FlipCameraButton.kt new file mode 100644 index 00000000000..02a4d15cc30 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/FlipCameraButton.kt @@ -0,0 +1,66 @@ +/* + * 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.ui.calling.ongoing.participantsview + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.wire.android.R +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.theme.WireTheme +import com.wire.android.util.ui.PreviewMultipleThemes + +@Composable +internal fun FlipCameraButton( + isOnFrontCamera: Boolean, + modifier: Modifier = Modifier, + flipCamera: () -> Unit, +) { + Icon( + modifier = modifier + .padding(dimensions().spacing12x) + .size(32.dp) + .background(color = colorsScheme().surface, shape = CircleShape) + .clip(CircleShape) + .clickable { flipCamera() } + .padding(dimensions().spacing6x), + painter = painterResource(R.drawable.ic_flip_camera), + tint = colorsScheme().onSurface, + contentDescription = if (isOnFrontCamera) { + stringResource(R.string.content_description_calling_flip_camera_on) + } else { + stringResource(R.string.content_description_calling_flip_camera_on) + } + ) +} + +@PreviewMultipleThemes +@Composable +private fun PreviewFlipCameraButton() { + WireTheme { FlipCameraButton(isOnFrontCamera = true) { } } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/FloatingSelfUserTile.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/FloatingSelfUserTile.kt index 32dea7ebfb5..7be5a2bb3e0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/FloatingSelfUserTile.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/FloatingSelfUserTile.kt @@ -60,9 +60,11 @@ fun FloatingSelfUserTile( contentHeight: Dp, contentWidth: Dp, participant: UICallParticipant, + isOnFrontCamera: Boolean, onSelfUserVideoPreviewCreated: (view: View) -> Unit, + onClearSelfUserVideoPreview: () -> Unit, + flipCamera: () -> Unit, modifier: Modifier = Modifier, - onClearSelfUserVideoPreview: () -> Unit ) { var selfVideoTileHeight by remember { mutableStateOf(contentHeight / 4) @@ -163,13 +165,14 @@ fun FloatingSelfUserTile( ) { ParticipantTile( participantTitleState = participant, - isSelfUser = true, isOnPiPMode = isOnPiPMode, shouldFillSelfUserCameraPreview = true, isSelfUserMuted = participant.isMuted, isSelfUserCameraOn = participant.isCameraOn, onSelfUserVideoPreviewCreated = onSelfUserVideoPreviewCreated, onClearSelfUserVideoPreview = onClearSelfUserVideoPreview, + isOnFrontCamera = isOnFrontCamera, + flipCamera = flipCamera, ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/ParticipantTile.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/ParticipantTile.kt index 8fe6dadd09c..8709e580b46 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/ParticipantTile.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/ParticipantTile.kt @@ -22,9 +22,14 @@ package com.wire.android.ui.calling.ongoing.participantsview import android.view.View import android.view.ViewGroup.LayoutParams.MATCH_PARENT import android.widget.FrameLayout +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -56,6 +61,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize @@ -74,11 +80,13 @@ import com.wire.android.R import com.wire.android.model.NameBasedAvatar import com.wire.android.model.UserAvatarData import com.wire.android.ui.calling.model.UICallParticipant +import com.wire.android.ui.calling.ongoing.incallreactions.InCallReactions import com.wire.android.ui.common.avatar.UserProfileAvatar import com.wire.android.ui.common.avatar.UserProfileAvatarType import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.darkColorsScheme import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.typography import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireTypography @@ -88,16 +96,18 @@ import com.wire.kalium.logic.data.id.QualifiedID @Composable fun ParticipantTile( participantTitleState: UICallParticipant, - isSelfUser: Boolean, isSelfUserMuted: Boolean, isSelfUserCameraOn: Boolean, onSelfUserVideoPreviewCreated: (view: View) -> Unit, + isOnFrontCamera: Boolean, + flipCamera: () -> Unit, modifier: Modifier = Modifier, isOnPiPMode: Boolean = false, shouldFillSelfUserCameraPreview: Boolean = false, shouldFillOthersVideoPreview: Boolean = true, isZoomingEnabled: Boolean = false, - onClearSelfUserVideoPreview: () -> Unit + recentReaction: String? = null, + onClearSelfUserVideoPreview: () -> Unit, ) { val alpha = if (participantTitleState.hasEstablishedAudio) ContentAlpha.high else ContentAlpha.medium @@ -107,8 +117,9 @@ fun ParticipantTile( color = darkColorsScheme().surfaceContainer, shape = RoundedCornerShape(if (participantTitleState.isSpeaking) dimensions().corner8x else dimensions().corner3x), ) { + ConstraintLayout { - val (avatar, bottomRow) = createRefs() + val (avatar, bottomRow, cameraButton) = createRefs() val maxAvatarSize = dimensions().onGoingCallUserAvatarSize val activeSpeakerBorderPadding = dimensions().spacing6x @@ -136,7 +147,7 @@ fun ParticipantTile( isOnPiPMode = isOnPiPMode ) - if (isSelfUser) { + if (participantTitleState.isSelfUser) { CameraPreview( isCameraOn = isSelfUserCameraOn, shouldFill = shouldFillSelfUserCameraPreview, @@ -157,7 +168,6 @@ fun ParticipantTile( if (!isOnPiPMode) { BottomRow( participantTitleState = participantTitleState, - isSelfUser = isSelfUser, isSelfUserMuted = isSelfUserMuted, modifier = Modifier .padding( @@ -173,6 +183,42 @@ fun ParticipantTile( } ) } + + AnimatedVisibility( + modifier = Modifier + .padding(dimensions().spacing12x), + visible = recentReaction != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = Modifier + .size(dimensions().inCallReactionRecentReactionSize) + .background( + color = colorsScheme().emojiBackgroundColor, + shape = RoundedCornerShape(dimensions().corner6x) + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = recentReaction ?: "", + textAlign = TextAlign.Center, + style = typography().inCallReactionRecentEmoji, + ) + } + } + + if (participantTitleState.isSelfUser && isSelfUserCameraOn) { + FlipCameraButton( + modifier = Modifier + .constrainAs(cameraButton) { + top.linkTo(parent.top) + end.linkTo(parent.end) + }, + isOnFrontCamera = isOnFrontCamera, + flipCamera = flipCamera, + ) + } } } } @@ -196,7 +242,6 @@ private val activeSpeakerBorderModifier @Composable private fun BottomRow( participantTitleState: UICallParticipant, - isSelfUser: Boolean, isSelfUserMuted: Boolean, modifier: Modifier = Modifier, ) { @@ -208,7 +253,7 @@ private fun BottomRow( modifier = Modifier .padding(end = dimensions().spacing8x) .layoutId("muteIcon"), - isMuted = if (isSelfUser) isSelfUserMuted else participantTitleState.isMuted, + isMuted = if (participantTitleState.isSelfUser) isSelfUserMuted else participantTitleState.isMuted, hasEstablishedAudio = participantTitleState.hasEstablishedAudio ) UsernameTile( @@ -464,12 +509,16 @@ private fun PreviewParticipantTile( isSpeaking: Boolean = false, hasEstablishedAudio: Boolean = true, shape: PreviewTileShape = PreviewTileShape.Wide, + recentReaction: String? = null, + isSelfUser: Boolean = false, + isSelfCameraOn: Boolean = false, ) { ParticipantTile( modifier = Modifier.size(width = shape.width, height = shape.height), participantTitleState = UICallParticipant( id = QualifiedID("", ""), clientId = "client-id", + isSelfUser = isSelfUser, name = if (longName) "long user name to be displayed in participant tile during a call" else "user name", isMuted = isMuted, isSpeaking = isSpeaking, @@ -482,9 +531,11 @@ private fun PreviewParticipantTile( ), onClearSelfUserVideoPreview = {}, onSelfUserVideoPreviewCreated = {}, - isSelfUser = false, isSelfUserMuted = false, - isSelfUserCameraOn = false + isSelfUserCameraOn = isSelfCameraOn, + recentReaction = recentReaction, + isOnFrontCamera = false, + flipCamera = { }, ) } @@ -569,3 +620,24 @@ fun PreviewParticipantTallLongNameTalking() = WireTheme { fun PreviewParticipantWideLongNameTalking() = WireTheme { PreviewParticipantTile(shape = PreviewTileShape.Wide, isSpeaking = true) } + +@PreviewMultipleThemes +@Composable +fun PreviewParticipantTalkingReaction() = WireTheme { + PreviewParticipantTile( + shape = PreviewTileShape.Regular, + isSpeaking = true, + recentReaction = InCallReactions.defaultReactions[2], + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewParticipantCameraButton() = WireTheme { + PreviewParticipantTile( + shape = PreviewTileShape.Regular, + recentReaction = InCallReactions.defaultReactions[2], + isSelfUser = true, + isSelfCameraOn = true, + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/VerticalCallingPager.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/VerticalCallingPager.kt index 9aa4550af2c..95e7d793b2e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/VerticalCallingPager.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/VerticalCallingPager.kt @@ -61,12 +61,14 @@ fun VerticalCallingPager( isSelfUserMuted: Boolean, isSelfUserCameraOn: Boolean, isInPictureInPictureMode: Boolean, + isOnFrontCamera: Boolean, contentHeight: Dp, - currentUserId: UserId, + recentReactions: Map, onSelfVideoPreviewCreated: (view: View) -> Unit, onSelfClearVideoPreview: () -> Unit, requestVideoStreams: (participants: List) -> Unit, onDoubleTap: (selectedParticipant: SelectedParticipant) -> Unit, + flipCamera: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -106,7 +108,9 @@ fun VerticalCallingPager( onSelfVideoPreviewCreated = onSelfVideoPreviewCreated, onSelfClearVideoPreview = onSelfClearVideoPreview, onDoubleTap = onDoubleTap, - currentUserId = currentUserId, + recentReactions = recentReactions, + isOnFrontCamera = isOnFrontCamera, + flipCamera = flipCamera, ) } else { GroupCallGrid( @@ -118,8 +122,10 @@ fun VerticalCallingPager( onSelfVideoPreviewCreated = onSelfVideoPreviewCreated, onSelfClearVideoPreview = onSelfClearVideoPreview, onDoubleTap = onDoubleTap, - currentUserId = currentUserId, isInPictureInPictureMode = isInPictureInPictureMode, + recentReactions = recentReactions, + isOnFrontCamera = isOnFrontCamera, + flipCamera = flipCamera, ) } @@ -177,8 +183,10 @@ private fun PreviewVerticalCallingPager(participants: List) { onSelfClearVideoPreview = {}, requestVideoStreams = {}, onDoubleTap = { }, + flipCamera = { }, isInPictureInPictureMode = false, - currentUserId = participants[0].id, + recentReactions = emptyMap(), + isOnFrontCamera = false, ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/gridview/CallingGridView.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/gridview/CallingGridView.kt index c1208dcf50e..5ad0e33a495 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/gridview/CallingGridView.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/gridview/CallingGridView.kt @@ -50,14 +50,16 @@ fun GroupCallGrid( isSelfUserMuted: Boolean, isSelfUserCameraOn: Boolean, contentHeight: Dp, - currentUserId: UserId, + isOnFrontCamera: Boolean, onSelfVideoPreviewCreated: (view: View) -> Unit, onSelfClearVideoPreview: () -> Unit, onDoubleTap: (selectedParticipant: SelectedParticipant) -> Unit, + flipCamera: () -> Unit, + isInPictureInPictureMode: Boolean, + recentReactions: Map, modifier: Modifier = Modifier, contentPadding: Dp = dimensions().spacing4x, spacedBy: Dp = dimensions().spacing2x, - isInPictureInPictureMode: Boolean, ) { // We need the number of tiles rows needed to calculate their height val numberOfTilesRows = remember(participants.size) { @@ -81,9 +83,6 @@ fun GroupCallGrid( key = { it.id.toString() + it.clientId + pageIndex }, contentType = { getContentType(it.isCameraOn, it.isSharingScreen) } ) { participant -> - // API returns only id.value, without domain, till this get changed compare only id.value - val isSelfUser = participant.id.equalsIgnoringBlankDomain(currentUserId) - ParticipantTile( modifier = Modifier .pointerInput(Unit) { @@ -93,7 +92,7 @@ fun GroupCallGrid( SelectedParticipant( userId = participant.id, clientId = participant.clientId, - isSelfUser = isSelfUser + isSelfUser = participant.isSelfUser, ) ) } @@ -103,11 +102,13 @@ fun GroupCallGrid( .animateItem(), participantTitleState = participant, isOnPiPMode = isInPictureInPictureMode, - isSelfUser = isSelfUser, isSelfUserMuted = isSelfUserMuted, isSelfUserCameraOn = isSelfUserCameraOn, onSelfUserVideoPreviewCreated = onSelfVideoPreviewCreated, - onClearSelfUserVideoPreview = onSelfClearVideoPreview + onClearSelfUserVideoPreview = onSelfClearVideoPreview, + recentReaction = recentReactions[participant.id], + isOnFrontCamera = isOnFrontCamera, + flipCamera = flipCamera, ) } } @@ -139,8 +140,10 @@ private fun PreviewGroupCallGrid(participants: List, modifier onSelfVideoPreviewCreated = {}, onSelfClearVideoPreview = {}, onDoubleTap = { }, - currentUserId = UserId("id", "domain"), isInPictureInPictureMode = false, + recentReactions = emptyMap(), + isOnFrontCamera = false, + flipCamera = {}, ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/horizentalview/CallingHorizontalView.kt b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/horizentalview/CallingHorizontalView.kt index 42dce497b4d..5ff9d31eb57 100644 --- a/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/horizentalview/CallingHorizontalView.kt +++ b/app/src/main/kotlin/com/wire/android/ui/calling/ongoing/participantsview/horizentalview/CallingHorizontalView.kt @@ -49,10 +49,12 @@ fun CallingHorizontalView( isSelfUserMuted: Boolean, isSelfUserCameraOn: Boolean, contentHeight: Dp, - currentUserId: UserId, onSelfVideoPreviewCreated: (view: View) -> Unit, onSelfClearVideoPreview: () -> Unit, + recentReactions: Map, onDoubleTap: (selectedParticipant: SelectedParticipant) -> Unit, + isOnFrontCamera: Boolean, + flipCamera: () -> Unit, modifier: Modifier = Modifier, contentPadding: Dp = dimensions().spacing4x, spacedBy: Dp = dimensions().spacing2x, @@ -69,8 +71,6 @@ fun CallingHorizontalView( verticalArrangement = Arrangement.spacedBy(spacedBy) ) { items(items = participants, key = { it.id.toString() + it.clientId }) { participant -> - // API returns only id.value, without domain, till this get changed compare only id.value - val isSelfUser = participant.id.equalsIgnoringBlankDomain(currentUserId) ParticipantTile( modifier = Modifier .pointerInput(Unit) { @@ -80,7 +80,7 @@ fun CallingHorizontalView( SelectedParticipant( userId = participant.id, clientId = participant.clientId, - isSelfUser = isSelfUser + isSelfUser = participant.isSelfUser, ) ) } @@ -90,11 +90,13 @@ fun CallingHorizontalView( .height(tileHeight) .animateItem(), participantTitleState = participant, - isSelfUser = isSelfUser, isSelfUserMuted = isSelfUserMuted, isSelfUserCameraOn = isSelfUserCameraOn, onSelfUserVideoPreviewCreated = onSelfVideoPreviewCreated, onClearSelfUserVideoPreview = onSelfClearVideoPreview, + recentReaction = recentReactions[participant.id], + isOnFrontCamera = isOnFrontCamera, + flipCamera = flipCamera, ) } } @@ -111,10 +113,12 @@ fun PreviewCallingHorizontalView( isSelfUserMuted = true, isSelfUserCameraOn = false, contentHeight = 800.dp, - currentUserId = UserId("id", "domain"), + recentReactions = emptyMap(), onSelfVideoPreviewCreated = {}, onSelfClearVideoPreview = {}, onDoubleTap = { }, + isOnFrontCamera = false, + flipCamera = { }, ) } } diff --git a/app/src/main/kotlin/com/wire/android/util/ExpiringMap.kt b/app/src/main/kotlin/com/wire/android/util/ExpiringMap.kt new file mode 100644 index 00000000000..4b60e3a4cff --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/util/ExpiringMap.kt @@ -0,0 +1,74 @@ +/* + * 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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap + +/** + * Map implementation that removes entries after a delay. Not thread-safe. + */ +class ExpiringMap( + private val scope: CoroutineScope, + private val expiration: Long, + private val delegate: MutableMap, + private val currentTime: () -> Long = { System.currentTimeMillis() }, +) : MutableMap by delegate { + + private val timestamps: MutableMap = ConcurrentHashMap() + private var cleanupJob: Job? = null + + override fun put(key: K, value: V): V? { + return delegate.put(key, value).also { + timestamps.put(key, currentTime() + expiration) + scheduleCleanup() + } + } + + override fun remove(key: K): V? { + return delegate.remove(key).also { + timestamps.remove(key) + scheduleCleanup() + } + } + + private fun scheduleCleanup() { + cleanupJob?.cancel() + timestamps.values.sorted().firstOrNull()?.let { nextExpiration -> + val delayToNext = nextExpiration - currentTime() + cleanupJob = scope.launch { + delay(delayToNext) + removeAllExpired() + } + } + } + + private fun removeAllExpired() { + val now = currentTime() + timestamps.entries.onEach { (key, expiration) -> + if (expiration <= now) { + delegate.remove(key) + } + } + timestamps.entries.removeAll { it.value <= now } + scheduleCleanup() + } +} diff --git a/app/src/main/kotlin/com/wire/android/util/extension/Flow.kt b/app/src/main/kotlin/com/wire/android/util/extension/Flow.kt index 7640755fae3..cabcc055463 100644 --- a/app/src/main/kotlin/com/wire/android/util/extension/Flow.kt +++ b/app/src/main/kotlin/com/wire/android/util/extension/Flow.kt @@ -19,7 +19,10 @@ package com.wire.android.util.extension import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.withIndex fun intervalFlow(periodMs: Long, initialDelayMs: Long = 0L, stopWhen: () -> Boolean = { false }) = flow { @@ -29,3 +32,9 @@ fun intervalFlow(periodMs: Long, initialDelayMs: Long = 0L, stopWhen: () -> Bool delay(periodMs) } } + +fun Flow.withDelayAfterFirst(timeMillis: Long): Flow = withIndex() + .map { (index, value) -> + if (index > 0) delay(timeMillis) + value + } diff --git a/app/src/main/res/drawable/ic_flip_camera.xml b/app/src/main/res/drawable/ic_flip_camera.xml new file mode 100644 index 00000000000..1f0c24e6eb4 --- /dev/null +++ b/app/src/main/res/drawable/ic_flip_camera.xml @@ -0,0 +1,30 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_incall_reactions.xml b/app/src/main/res/drawable/ic_incall_reactions.xml new file mode 100644 index 00000000000..d5b6fefbf03 --- /dev/null +++ b/app/src/main/res/drawable/ic_incall_reactions.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f12f3cbc10a..0239968ec0e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -162,6 +162,8 @@ Turn camera off Turn speaker on Turn speaker off + Show in call reactions panel + Hide in call reactions panel Show more options Reply to the message Cancel message reply @@ -1662,4 +1664,5 @@ In group conversations, the group admin can overwrite this setting. You\'ve created or joined a team with this email address on another device. Wire could not complete your team creation due to a slow internet connection. Wire could not complete your team creation due to an unknown error. + You diff --git a/app/src/test/kotlin/com/wire/android/mapper/UICallParticipantMapperTest.kt b/app/src/test/kotlin/com/wire/android/mapper/UICallParticipantMapperTest.kt index d3c23da2a63..a3781781a9f 100644 --- a/app/src/test/kotlin/com/wire/android/mapper/UICallParticipantMapperTest.kt +++ b/app/src/test/kotlin/com/wire/android/mapper/UICallParticipantMapperTest.kt @@ -19,6 +19,7 @@ package com.wire.android.mapper import com.wire.kalium.logic.data.call.Participant +import com.wire.kalium.logic.data.conversation.ClientId import com.wire.kalium.logic.data.id.QualifiedID import io.mockk.MockKAnnotations import kotlinx.coroutines.test.runTest @@ -42,12 +43,37 @@ class UICallParticipantMapperTest { accentId = -1 ) // When - val result = mapper.toUICallParticipant(item) + val result = mapper.toUICallParticipant(item, ClientId("clientId")) // Then assert( result.id == item.id && result.clientId == item.clientId && result.name == item.name && result.isMuted == item.isMuted && result.isSpeaking == item.isSpeaking && result.avatar?.userAssetId == item.avatarAssetId - && result.isCameraOn == item.isCameraOn + && result.isCameraOn == item.isCameraOn && result.isSelfUser == true + ) + } + + @Test + fun givenParticipant_whenMappingToUICallParticipant_thenCorrectValuesShouldBeReturnedForNonSelfUser() = runTest { + val (_, mapper) = Arrangement().arrange() + // Given + val item = Participant( + id = QualifiedID("value", "domain"), + clientId = "clientId", + name = "name", + isMuted = false, + isCameraOn = false, + isSpeaking = false, + isSharingScreen = false, + hasEstablishedAudio = true, + accentId = -1 + ) + // When + val result = mapper.toUICallParticipant(item, ClientId("otherClientId")) + // Then + assert( + result.id == item.id && result.clientId == item.clientId && result.name == item.name && result.isMuted == item.isMuted + && result.isSpeaking == item.isSpeaking && result.avatar?.userAssetId == item.avatarAssetId + && result.isCameraOn == item.isCameraOn && result.isSelfUser == false ) } diff --git a/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt index 92937700fdb..587abd03426 100644 --- a/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/calling/OngoingCallViewModelTest.kt @@ -21,6 +21,7 @@ package com.wire.android.ui.calling import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension import com.wire.android.datastore.GlobalDataStore +import com.wire.android.ui.calling.OngoingCallViewModelTest.Arrangement import com.wire.android.ui.calling.model.UICallParticipant import com.wire.android.ui.calling.ongoing.OngoingCallViewModel import com.wire.android.ui.calling.ongoing.fullscreen.SelectedParticipant @@ -302,6 +303,7 @@ class OngoingCallViewModelTest { private val participant1 = UICallParticipant( id = QualifiedID("value1", "domain"), clientId = "client-id1", + isSelfUser = false, name = "name1", isMuted = false, isSpeaking = false, @@ -314,6 +316,7 @@ class OngoingCallViewModelTest { private val participant2 = UICallParticipant( id = QualifiedID("value2", "domain"), clientId = "client-id2", + isSelfUser = false, name = "name2", isMuted = false, isSpeaking = false, @@ -326,6 +329,7 @@ class OngoingCallViewModelTest { private val participant3 = UICallParticipant( id = QualifiedID("value3", "domain"), clientId = "client-id3", + isSelfUser = false, name = "name3", isMuted = false, isSpeaking = false, diff --git a/app/src/test/kotlin/com/wire/android/ui/calling/SharedCallingViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/calling/SharedCallingViewModelTest.kt index 18e62a81877..59f69ff85b7 100644 --- a/app/src/test/kotlin/com/wire/android/ui/calling/SharedCallingViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/calling/SharedCallingViewModelTest.kt @@ -19,35 +19,50 @@ package com.wire.android.ui.calling import android.view.View +import app.cash.turbine.test +import com.wire.android.assertIs import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension import com.wire.android.config.TestDispatcherProvider +import com.wire.android.framework.TestUser import com.wire.android.mapper.UICallParticipantMapper import com.wire.android.mapper.UserTypeMapper import com.wire.android.media.CallRinger +import com.wire.android.ui.calling.model.ReactionSender +import com.wire.kalium.logic.NetworkFailure +import com.wire.kalium.logic.data.call.Call +import com.wire.kalium.logic.data.call.InCallReactionMessage import com.wire.kalium.logic.data.call.VideoState +import com.wire.kalium.logic.data.conversation.ClientId import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.feature.call.usecase.EndCallUseCase import com.wire.kalium.logic.feature.call.usecase.FlipToBackCameraUseCase import com.wire.kalium.logic.feature.call.usecase.FlipToFrontCameraUseCase import com.wire.kalium.logic.feature.call.usecase.MuteCallUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallWithSortedParticipantsUseCase +import com.wire.kalium.logic.feature.call.usecase.ObserveInCallReactionsUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveSpeakerUseCase import com.wire.kalium.logic.feature.call.usecase.SetVideoPreviewUseCase import com.wire.kalium.logic.feature.call.usecase.TurnLoudSpeakerOffUseCase import com.wire.kalium.logic.feature.call.usecase.TurnLoudSpeakerOnUseCase import com.wire.kalium.logic.feature.call.usecase.UnMuteCallUseCase import com.wire.kalium.logic.feature.call.usecase.video.UpdateVideoStateUseCase +import com.wire.kalium.logic.feature.client.ObserveCurrentClientIdUseCase import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseCase +import com.wire.kalium.logic.feature.incallreaction.SendInCallReactionUseCase +import com.wire.kalium.logic.functional.Either import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.verify +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.amshove.kluent.internal.assertEquals import org.amshove.kluent.shouldBeEqualTo import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -93,6 +108,15 @@ class SharedCallingViewModelTest { @MockK private lateinit var observeSpeaker: ObserveSpeakerUseCase + @MockK + lateinit var observeInCallReactionsUseCase: ObserveInCallReactionsUseCase + + @MockK + lateinit var sendInCallReactionUseCase: SendInCallReactionUseCase + + @MockK + lateinit var getCurrentClientId: ObserveCurrentClientIdUseCase + @MockK private lateinit var callRinger: CallRinger @@ -111,12 +135,17 @@ class SharedCallingViewModelTest { private lateinit var sharedCallingViewModel: SharedCallingViewModel + val reactionsFlow = MutableSharedFlow() + val callFlow = MutableSharedFlow() + @BeforeEach fun setup() { MockKAnnotations.init(this) - coEvery { establishedCall.invoke(any()) } returns emptyFlow() + coEvery { establishedCall.invoke(any()) } returns callFlow coEvery { observeConversationDetails.invoke(any()) } returns emptyFlow() coEvery { observeSpeaker.invoke() } returns emptyFlow() + coEvery { observeInCallReactionsUseCase(any()) } returns reactionsFlow + coEvery { getCurrentClientId() } returns flowOf(ClientId("clientId")) sharedCallingViewModel = SharedCallingViewModel( conversationId = conversationId, @@ -135,6 +164,9 @@ class SharedCallingViewModelTest { callRinger = callRinger, uiCallParticipantMapper = uiCallParticipantMapper, userTypeMapper = userTypeMapper, + observeInCallReactionsUseCase = observeInCallReactionsUseCase, + sendInCallReactionUseCase = sendInCallReactionUseCase, + getCurrentClientId = getCurrentClientId, dispatchers = TestDispatcherProvider() ) } @@ -312,6 +344,97 @@ class SharedCallingViewModelTest { coVerify(exactly = 1) { setVideoPreview(any(), any()) } } + @Test + fun givenAnOngoingCall_WhenInCallReactionIsReceived_ThenNewEmojiIsEmitted() = runTest { + + sharedCallingViewModel.inCallReactions.test { + + // when + reactionsFlow.emit(InCallReactionMessage(conversationId, TestUser.USER_ID, setOf("👍", "🎉"))) + + val reaction1 = awaitItem() + val reaction2 = awaitItem() + + // then + assertEquals("👍", reaction1.emoji) + assertIs(reaction1.sender) + assertEquals("🎉", reaction2.emoji) + assertIs(reaction2.sender) + } + } + + @Test + fun givenAnOngoingCall_WhenInCallReactionIsReceived_ThenNewRecentReactionEmitted() = runTest { + + // when + reactionsFlow.emit(InCallReactionMessage(conversationId, TestUser.USER_ID, setOf("👍"))) + + val recentReaction = sharedCallingViewModel.recentReactions.getValue(TestUser.USER_ID) + + // then + assertEquals("👍", recentReaction) + } + + @Test + fun givenAnOngoingCall_WhenNewInCallReactionIsReceived_ThenRecentReactionUpdated() = runTest { + + // when + reactionsFlow.emit(InCallReactionMessage(conversationId, TestUser.USER_ID, setOf("👍", "🎉"))) + + val recentReaction = sharedCallingViewModel.recentReactions.getValue(TestUser.USER_ID) + + // then + assertEquals("🎉", recentReaction) + } + + @Test + fun givenAnOngoingCall_WhenInCallReactionIsSent_ThenReactionMessageIsSent() = runTest { + + // given + coEvery { sendInCallReactionUseCase(conversationId, any()) } returns Either.Right(Unit) + + // when + sharedCallingViewModel.onReactionClick("👍") + + // then + coVerify(exactly = 1) { + sendInCallReactionUseCase(OngoingCallViewModelTest.Companion.conversationId, "👍") + } + } + + @Test + fun givenAnOngoingCall_WhenInCallReactionIsSent_ThenNewEmojiIsEmitted() = runTest { + + // given + coEvery { sendInCallReactionUseCase(conversationId, any()) } returns Either.Right(Unit) + + sharedCallingViewModel.inCallReactions.test { + // when + sharedCallingViewModel.onReactionClick("👍") + + val reaction = awaitItem() + + // then + assertEquals("👍", reaction.emoji) + assertIs(reaction.sender) + } + } + + @Test + fun givenAnOngoingCall_WhenInCallReactionSentFails_ThenNoEmojiIsEmitted() = runTest { + // given + coEvery { sendInCallReactionUseCase(conversationId, any()) } returns + Either.Left(NetworkFailure.NoNetworkConnection(IllegalStateException())) + + sharedCallingViewModel.inCallReactions.test { + // when + sharedCallingViewModel.onReactionClick("👍") + + // then + expectNoEvents() + } + } + companion object { private val conversationId = ConversationId("some-dummy-value", "some.dummy.domain") } diff --git a/app/src/test/kotlin/com/wire/android/util/ExpiringMapTest.kt b/app/src/test/kotlin/com/wire/android/util/ExpiringMapTest.kt new file mode 100644 index 00000000000..0fa3f809b98 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/util/ExpiringMapTest.kt @@ -0,0 +1,116 @@ +/* + * 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 kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.internal.assertEquals +import org.junit.jupiter.api.Test + +class ExpiringMapTest { + + @Test + fun `check new item can be added map `() = runTest { + // given + val map = withTestExpiringMap() + + // when + map.put("testKey", "testValue") + + // then + assertEquals("testValue", map["testKey"]) + } + + @Test + fun `check item can be removed from map before expiration`() = runTest { + // given + val map = withTestExpiringMap() + + // when + map.put("testKey", "testValue") + map.remove("testKey") + + // then + assertEquals(null, map["testKey"]) + } + + @Test + fun `check item can not be obtained before expiration`() = runTest { + // given + val map = withTestExpiringMap() + + // when + map.put("testKey", "testValue") + advanceTimeBy(300) + + // then + assertEquals("testValue", map["testKey"]) + } + + @Test + fun `check item can not be obtained after expiration`() = runTest { + // given + val map = withTestExpiringMap() + + // when + map.put("testKey", "testValue") + advanceTimeBy(301) + + // then + assertEquals(null, map["testKey"]) + } + + @Test + fun `check adding item with existing key resets expiration`() = runTest { + // given + val map = withTestExpiringMap() + + // when + map.put("testKey", "testValue") + advanceTimeBy(300) + map.put("testKey", "testValue2") + advanceTimeBy(300) + + // then + assertEquals("testValue2", map["testKey"]) + } + + @Test + fun `check adding item with non-existing key keeps expiration for other keys`() = runTest { + // given + val map = withTestExpiringMap() + + // when + map.put("testKey", "testValue") + advanceTimeBy(200) + map.put("testKey2", "testValue2") + advanceTimeBy(200) + + // then + assertEquals(null, map["testKey"]) + } + + private fun TestScope.withTestExpiringMap(): MutableMap = ExpiringMap( + scope = this.backgroundScope, + expiration = 300, + delegate = mutableMapOf(), + currentTime = { currentTime } + ) +} diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt index ea4f6f8b5e6..64339eea7b1 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt @@ -81,6 +81,8 @@ class WireColorScheme( // accents val groupAvatarColors: List, val wireAccentColors: WireAccentColors, + + val emojiBackgroundColor: Color, ) { fun toColorScheme(): ColorScheme = ColorScheme( primary = primary, onPrimary = onPrimary, @@ -182,6 +184,7 @@ private val LightWireColorScheme = WireColorScheme( Accent.Unknown -> WireColorPalette.LightBlue500 } }, + emojiBackgroundColor = Color.White, ) // Dark WireColorScheme @@ -257,6 +260,7 @@ private val DarkWireColorScheme = WireColorScheme( Accent.Unknown -> WireColorPalette.DarkBlue500 } }, + emojiBackgroundColor = Color.White, ) @PackagePrivate diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt index 324e9477336..d88a5b59776 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt @@ -107,6 +107,8 @@ data class WireDimensions( val badgeSmallMinSize: DpSize, val badgeSmallMinClickableSize: DpSize, val onMoreOptionsButtonCornerRadius: Dp, + val inCallReactionButtonSize: Dp, + val inCallReactionRecentReactionSize: Dp, // Dialog val dialogButtonsSpacing: Dp, val dialogTextsSpacing: Dp, @@ -183,6 +185,8 @@ data class WireDimensions( val groupButtonHeight: Dp, // Calling val defaultCallingControlsSize: Dp, + val defaultCallingControlsHeight: Dp, + val defaultCallingControlsWidth: Dp, val defaultCallingControlsIconSize: Dp, val bigCallingControlsSize: Dp, val bigCallingHangUpButtonIconSize: Dp, @@ -341,11 +345,13 @@ private val DefaultPhonePortraitWireDimensions: WireDimensions = WireDimensions( systemMessageIconLargeSize = 18.dp, groupButtonHeight = 82.dp, defaultCallingControlsSize = 56.dp, + defaultCallingControlsHeight = 40.dp, + defaultCallingControlsWidth = 56.dp, defaultCallingControlsIconSize = 20.dp, bigCallingControlsSize = 72.dp, bigCallingHangUpButtonIconSize = 32.dp, bigCallingAcceptButtonIconSize = 24.dp, - defaultSheetPeekHeight = 100.dp, + defaultSheetPeekHeight = 72.dp, defaultOutgoingCallSheetPeekHeight = 281.dp, onGoingCallUserAvatarSize = 72.dp, onGoingCallTileUsernameMaxWidth = 120.dp, @@ -360,6 +366,8 @@ private val DefaultPhonePortraitWireDimensions: WireDimensions = WireDimensions( importedMediaAssetSize = 120.dp, typingIndicatorHeight = 24.dp, legalHoldBannerMinHeight = 26.dp, + inCallReactionButtonSize = 48.dp, + inCallReactionRecentReactionSize = 32.dp, ) private val DefaultPhoneLandscapeWireDimensions: WireDimensions = DefaultPhonePortraitWireDimensions diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypography.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypography.kt index 79da10ada0b..e1f290c34d5 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypography.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypography.kt @@ -37,7 +37,9 @@ data class WireTypography( val label01: TextStyle, val label02: TextStyle, val label03: TextStyle, val label04: TextStyle, val label05: TextStyle, val badge01: TextStyle, val subline01: TextStyle, - val code01: TextStyle + val code01: TextStyle, + val inCallReactionEmoji: TextStyle, + val inCallReactionRecentEmoji: TextStyle, ) { fun toTypography() = Typography( titleLarge = title01, titleMedium = title02, titleSmall = title03, @@ -69,7 +71,9 @@ private val DefaultWireTypography = WireTypography( label05 = WireTypographyBase.Label05, badge01 = WireTypographyBase.Badge01, subline01 = WireTypographyBase.SubLine01, - code01 = WireTypographyBase.Code01 + code01 = WireTypographyBase.Code01, + inCallReactionEmoji = WireTypographyBase.InCallEmoji, + inCallReactionRecentEmoji = WireTypographyBase.InCallEmojiRecent, ) @PackagePrivate diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypographyBase.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypographyBase.kt index 0a12f33559e..e8ef201ffd2 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypographyBase.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireTypographyBase.kt @@ -148,4 +148,10 @@ object WireTypographyBase { lineHeight = 28.13.sp, textAlign = TextAlign.Center ) + val InCallEmoji = TextStyle( + fontSize = 32.sp, + ) + val InCallEmojiRecent = TextStyle( + fontSize = 20.sp, + ) } diff --git a/kalium b/kalium index 91b8319e99d..d168aa2c17c 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit 91b8319e99d83e486751967c8adf4f027d57a82e +Subproject commit d168aa2c17c460263910294b80f9ca402baaf4cb From f1c158beb3e1e534eed404ee1596a068d9518009 Mon Sep 17 00:00:00 2001 From: sergeibakhtiarov Date: Fri, 27 Dec 2024 11:39:21 +0100 Subject: [PATCH 21/23] fix: call controls visibility after closing PiP mode [#WPB-15142] (#3770) Co-authored-by: sergei.bakhtiarov --- .../ui/calling/ongoing/OngoingCallScreen.kt | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) 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 9a5b93df9f6..864219f20c6 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 @@ -21,6 +21,7 @@ package com.wire.android.ui.calling.ongoing import android.content.pm.PackageManager import android.view.View import androidx.activity.compose.BackHandler +import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.AnimatedContent import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically @@ -56,6 +57,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.app.PictureInPictureModeChangedInfo +import androidx.core.util.Consumer import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -130,10 +133,14 @@ fun OngoingCallScreen( val inCallReactionsState = rememberInCallReactionsState() - val activity = LocalActivity.current - val isPiPAvailableOnThisDevice = - activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) + val activity = LocalActivity.current as AppCompatActivity + val isPiPAvailableOnThisDevice = activity.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) val shouldUsePiPMode = BuildConfig.PICTURE_IN_PICTURE_ENABLED && isPiPAvailableOnThisDevice + var inPictureInPictureMode by remember { mutableStateOf(shouldUsePiPMode && activity.isInPictureInPictureMode) } + + if (shouldUsePiPMode) { + activity.observePictureInPictureMode { inPictureInPictureMode = it } + } LaunchedEffect(ongoingCallViewModel.state.flowState) { when (ongoingCallViewModel.state.flowState) { @@ -183,7 +190,6 @@ fun OngoingCallScreen( } } - val inPictureInPictureMode = activity.isInPictureInPictureMode OngoingCallContent( callState = sharedCallingViewModel.callState, inCallReactionsState = inCallReactionsState, @@ -624,6 +630,23 @@ private fun CallingControls( } } +@Composable +private fun AppCompatActivity.observePictureInPictureMode(onChanged: (Boolean) -> Unit) { + DisposableEffect(Unit) { + val consumer = object : Consumer { + override fun accept(info: PictureInPictureModeChangedInfo) { + onChanged(info.isInPictureInPictureMode) + } + } + + addOnPictureInPictureModeChangedListener(consumer) + + onDispose { + removeOnPictureInPictureModeChangedListener(consumer) + } + } +} + @Suppress("EmptyFunctionBlock") @Composable fun PreviewOngoingCallContent(participants: PersistentList) { From 7741fa7b3bc38bf3b19d4f193fb4e94e8a119d3b Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 27 Dec 2024 15:44:02 +0200 Subject: [PATCH 22/23] feat: Audio messages new design #WPB-11723 (#3718) --- app/build.gradle.kts | 1 + .../kotlin/com/wire/android/di/AppModule.kt | 4 + .../android/di/accountScoped/MessageModule.kt | 6 + .../android/media/audiomessage/AudioState.kt | 40 ++- .../audiomessage/AudioWavesMaskHelper.kt | 76 ++++++ .../ConversationAudioMessagePlayer.kt | 107 ++++++-- .../audiomessage/RecordAudioMessagePlayer.kt | 17 +- .../home/conversations/ConversationScreen.kt | 89 ++++++- .../media/ConversationMediaScreen.kt | 6 +- .../conversations/media/FileAssetsContent.kt | 19 +- .../messages/ConversationMessagesViewModel.kt | 70 +++++- .../messages/ConversationMessagesViewState.kt | 17 +- .../messages/item/MessageClickActions.kt | 3 + .../messages/item/MessageContainerItem.kt | 3 + .../messages/item/MessageContentAndStatus.kt | 10 + .../messages/item/RegularMessageItem.kt | 7 +- .../messagetypes/audio/AudioMessageType.kt | 236 ++++++++++++------ .../recordaudio/RecordAudioButtons.kt | 6 +- .../recordaudio/RecordAudioViewModel.kt | 12 +- .../wire/android/util/DateAndTimeParsers.kt | 4 + app/src/main/res/values/strings.xml | 5 + .../ConversationAudioMessagePlayerTest.kt | 76 ++++++ .../MessageComposerViewModelArrangement.kt | 25 ++ ...onversationMessagesViewModelArrangement.kt | 16 +- .../ConversationMessagesViewModelTest.kt | 145 ++++++++++- .../recordaudio/RecordAudioViewModelTest.kt | 8 + .../wire/android/ui/theme/WireDimensions.kt | 2 +- gradle/libs.versions.toml | 3 + kalium | 2 +- 29 files changed, 861 insertions(+), 154 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 52065cb0462..77fc097d467 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -259,6 +259,7 @@ dependencies { implementation(libs.aboutLibraries.core) implementation(libs.aboutLibraries.ui) implementation(libs.compose.qr.code) + implementation(libs.audio.amplituda) // screenshot testing screenshotTestImplementation(libs.compose.ui.tooling) diff --git a/app/src/main/kotlin/com/wire/android/di/AppModule.kt b/app/src/main/kotlin/com/wire/android/di/AppModule.kt index 82aed26f4ee..bbf8efa2c40 100644 --- a/app/src/main/kotlin/com/wire/android/di/AppModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/AppModule.kt @@ -38,6 +38,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import linc.com.amplituda.Amplituda import javax.inject.Qualifier import javax.inject.Singleton @@ -84,6 +85,9 @@ object AppModule { } } + @Provides + fun provideAmplituda(appContext: Context): Amplituda = Amplituda(appContext) + @Singleton @Provides fun provideCurrentTimestampProvider(): CurrentTimestampProvider = { System.currentTimeMillis() } diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt index 2c1f3dd1692..a28486b38b7 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/MessageModule.kt @@ -35,6 +35,7 @@ import com.wire.kalium.logic.feature.message.GetNotificationsUseCase import com.wire.kalium.logic.feature.message.GetPaginatedFlowOfMessagesByConversationUseCase import com.wire.kalium.logic.feature.message.GetPaginatedFlowOfMessagesBySearchQueryAndConversationIdUseCase import com.wire.kalium.logic.feature.message.GetSearchedConversationMessagePositionUseCase +import com.wire.kalium.logic.feature.message.GetSenderNameByMessageIdUseCase import com.wire.kalium.logic.feature.message.MarkMessagesAsNotifiedUseCase import com.wire.kalium.logic.feature.message.MessageScope import com.wire.kalium.logic.feature.message.ObserveMessageReactionsUseCase @@ -222,4 +223,9 @@ class MessageModule { @Provides fun provideSendInCallReactionUseCase(messageScope: MessageScope): SendInCallReactionUseCase = messageScope.sendInCallReactionUseCase + + @ViewModelScoped + @Provides + fun provideGetSenderNameByMessageIdUseCase(messageScope: MessageScope): GetSenderNameByMessageIdUseCase = + messageScope.getSenderNameByMessageId } diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt index 7fa9d4a98ea..63346ae97fe 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioState.kt @@ -17,20 +17,24 @@ */ package com.wire.android.media.audiomessage +import androidx.annotation.StringRes +import com.wire.android.R + data class AudioState( val audioMediaPlayingState: AudioMediaPlayingState, val currentPositionInMs: Int, - val totalTimeInMs: TotalTimeInMs + val totalTimeInMs: TotalTimeInMs, + val wavesMask: List ) { companion object { - val DEFAULT = AudioState(AudioMediaPlayingState.Stopped, 0, TotalTimeInMs.NotKnown) + val DEFAULT = AudioState(AudioMediaPlayingState.Stopped, 0, TotalTimeInMs.NotKnown, listOf()) } // if the back-end returned the total time, we use that, in case it didn't we use what we get from // the [ConversationAudioMessagePlayer.kt] which will emit the time once the users play the audio. fun sanitizeTotalTime(otherClientTotalTime: Int): TotalTimeInMs { if (otherClientTotalTime != 0) { - return TotalTimeInMs.Known(otherClientTotalTime) + return TotalTimeInMs.Known(otherClientTotalTime) } return totalTimeInMs @@ -43,6 +47,27 @@ data class AudioState( } } +@Suppress("MagicNumber") +enum class AudioSpeed(val value: Float, @StringRes val titleRes: Int) { + NORMAL(1f, R.string.audio_speed_1), + FAST(1.5f, R.string.audio_speed_1_5), + MAX(2f, R.string.audio_speed_2); + + fun toggle(): AudioSpeed = when (this) { + NORMAL -> FAST + FAST -> MAX + MAX -> NORMAL + } + + companion object { + fun fromFloat(speed: Float): AudioSpeed = when { + (speed < FAST.value) -> NORMAL + (speed < MAX.value) -> FAST + else -> MAX + } + } +} + sealed class AudioMediaPlayingState { object Playing : AudioMediaPlayingState() object Stopped : AudioMediaPlayingState() @@ -75,6 +100,11 @@ sealed class AudioMediaPlayerStateUpdate( override val messageId: String, val totalTimeInMs: Int ) : AudioMediaPlayerStateUpdate(messageId) + + data class WaveMaskUpdate( + override val messageId: String, + val waveMask: List + ) : AudioMediaPlayerStateUpdate(messageId) } sealed class RecordAudioMediaPlayerStateUpdate { @@ -89,4 +119,8 @@ sealed class RecordAudioMediaPlayerStateUpdate { data class TotalTimeUpdate( val totalTimeInMs: Int ) : RecordAudioMediaPlayerStateUpdate() + + data class WaveMaskUpdate( + val waveMask: List + ) : RecordAudioMediaPlayerStateUpdate() } diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt new file mode 100644 index 00000000000..c4bc1df831c --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/AudioWavesMaskHelper.kt @@ -0,0 +1,76 @@ +/* + * 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.media.audiomessage + +import linc.com.amplituda.Amplituda +import linc.com.amplituda.Cache +import okio.Path +import java.io.File +import javax.inject.Inject +import kotlin.math.roundToInt + +class AudioWavesMaskHelper @Inject constructor( + private val amplituda: Amplituda +) { + + companion object { + private const val WAVES_AMOUNT = 75 + private const val WAVE_MAX = 32 + } + + fun getWaveMask(decodedAssetPath: Path): List = getWaveMask(File(decodedAssetPath.toString())) + + fun getWaveMask(file: File): List = amplituda + .processAudio(file, Cache.withParams(Cache.REUSE)) + .get() + .amplitudesAsList() + .averageWavesMask() + .equalizeWavesMask() + + private fun List.equalizeWavesMask(): List { + if (this.isEmpty()) return listOf() + + val divider = max() / (WAVE_MAX - 1) + return map { (it / divider).roundToInt() + 1 } + } + + private fun List.averageWavesMask(): List { + val wavesSize = size + val sectionSize = (wavesSize.toFloat() / WAVES_AMOUNT).roundToInt() + + if (wavesSize < WAVES_AMOUNT || sectionSize == 1) return map { it.toDouble() } + + val averagedWaves = mutableListOf() + for (i in 0..<(wavesSize / sectionSize)) { + val startIndex = (i * sectionSize) + if (startIndex >= wavesSize) continue + val endIndex = (startIndex + sectionSize).coerceAtMost(wavesSize - 1) + averagedWaves.add(subList(startIndex, endIndex).averageInt()) + } + return averagedWaves + } + + private fun List.averageInt(): Double { + if (isEmpty()) return 0.0 + return sum().toDouble() / size + } + + fun clear() { + amplituda.clearCache() + } +} diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt index 3f6c08ed1be..53c04bf616b 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/ConversationAudioMessagePlayer.kt @@ -46,6 +46,7 @@ class ConversationAudioMessagePlayerProvider @Inject constructor( private val context: Context, private val audioMediaPlayer: MediaPlayer, + private val wavesMaskHelper: AudioWavesMaskHelper, @KaliumCoreLogic private val coreLogic: CoreLogic, ) { private var player: ConversationAudioMessagePlayer? = null @@ -53,7 +54,9 @@ class ConversationAudioMessagePlayerProvider @Synchronized fun provide(): ConversationAudioMessagePlayer { - val player = player ?: ConversationAudioMessagePlayer(context, audioMediaPlayer, coreLogic).also { player = it } + val player = player ?: ConversationAudioMessagePlayer(context, audioMediaPlayer, wavesMaskHelper, coreLogic).also { + player = it + } usageCount++ return player @@ -69,14 +72,16 @@ class ConversationAudioMessagePlayerProvider } } +@Suppress("TooManyFunctions") class ConversationAudioMessagePlayer internal constructor( private val context: Context, private val audioMediaPlayer: MediaPlayer, + private val wavesMaskHelper: AudioWavesMaskHelper, @KaliumCoreLogic private val coreLogic: CoreLogic, ) { private companion object { - const val UPDATE_POSITION_INTERVAL_IN_MS = 1000L + const val UPDATE_POSITION_INTERVAL_IN_MS = 100L } init { @@ -99,6 +104,12 @@ internal constructor( extraBufferCapacity = 1 ) + private val _audioSpeed = MutableSharedFlow( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + extraBufferCapacity = 1, + replay = 1 + ) + // MediaPlayer API does not have any mechanism that would inform as about the currentPosition, // in a callback manner, therefore we need to create a timer manually that ticks every 1 second // and emits the current position @@ -166,11 +177,22 @@ internal constructor( ) } } + + is AudioMediaPlayerStateUpdate.WaveMaskUpdate -> { + audioMessageStateHistory = audioMessageStateHistory.toMutableMap().apply { + put( + audioMessageStateUpdate.messageId, + currentState.copy(wavesMask = audioMessageStateUpdate.waveMask) + ) + } + } } audioMessageStateHistory }.onStart { emit(audioMessageStateHistory) } + val audioSpeed: Flow = _audioSpeed.onStart { emit(AudioSpeed.NORMAL) } + private var currentAudioMessageId: String? = null suspend fun playAudio( @@ -190,6 +212,12 @@ internal constructor( } } + suspend fun setSpeed(speed: AudioSpeed) { + val currentParams = audioMediaPlayer.playbackParams + audioMediaPlayer.playbackParams = currentParams.setSpeed(speed.value) + updateSpeedFlow() + } + private fun previouslyResumedPosition(requestedAudioMessageId: String): Int? { return audioMessageStateHistory[requestedAudioMessageId]?.run { if (audioMediaPlayingState == AudioMediaPlayingState.Completed) { @@ -230,16 +258,10 @@ internal constructor( if (currentAccountResult is CurrentSessionResult.Failure) return@launch audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( - messageId, - AudioMediaPlayingState.Fetching - ) + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Fetching) ) - val assetMessage = coreLogic - .getSessionScope((currentAccountResult as CurrentSessionResult.Success).accountInfo.userId) - .messages - .getAssetMessage(conversationId, messageId) + val assetMessage = getAssetMessage(currentAccountResult, conversationId, messageId) when (val result = assetMessage.await()) { is MessageAssetResult.Success -> { @@ -253,38 +275,35 @@ internal constructor( val isFetchedAudioCurrentlyQueuedToPlay = messageId == currentAudioMessageId if (isFetchedAudioCurrentlyQueuedToPlay) { - audioMediaPlayer.setDataSource( - context, - Uri.parse(result.decodedAssetPath.toString()) - ) + audioMediaPlayer.setDataSource(context, Uri.parse(result.decodedAssetPath.toString())) audioMediaPlayer.prepare() + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.WaveMaskUpdate( + messageId, + wavesMaskHelper.getWaveMask(result.decodedAssetPath) + ) + ) + if (position != null) audioMediaPlayer.seekTo(position) audioMediaPlayer.start() + updateSpeedFlow() + audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( - messageId, - AudioMediaPlayingState.Playing - ) + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Playing) ) audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.TotalTimeUpdate( - messageId, - audioMediaPlayer.duration - ) + AudioMediaPlayerStateUpdate.TotalTimeUpdate(messageId, audioMediaPlayer.duration) ) } } is MessageAssetResult.Failure -> { audioMessageStateUpdate.emit( - AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate( - messageId, - AudioMediaPlayingState.Failed - ) + AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Failed) ) } } @@ -306,8 +325,38 @@ internal constructor( seekToAudioPosition.emit(messageId to position) } + suspend fun fetchWavesMask(conversationId: ConversationId, messageId: String) { + val currentAccountResult = coreLogic.getGlobalScope().session.currentSession() + if (currentAccountResult is CurrentSessionResult.Failure) return + + val result = coreLogic + .getSessionScope((currentAccountResult as CurrentSessionResult.Success).accountInfo.userId) + .messages + .getAssetMessage(conversationId, messageId) + .await() + + if (result is MessageAssetResult.Success) { + audioMessageStateUpdate.emit( + AudioMediaPlayerStateUpdate.WaveMaskUpdate( + messageId, + wavesMaskHelper.getWaveMask(result.decodedAssetPath) + ) + ) + } + } + + private suspend fun getAssetMessage( + currentAccountResult: CurrentSessionResult, + conversationId: ConversationId, + messageId: String + ) = coreLogic + .getSessionScope((currentAccountResult as CurrentSessionResult.Success).accountInfo.userId) + .messages + .getAssetMessage(conversationId, messageId) + private suspend fun resumeAudio(messageId: String) { audioMediaPlayer.start() + updateSpeedFlow() audioMessageStateUpdate.emit( AudioMediaPlayerStateUpdate.AudioMediaPlayingStateUpdate(messageId, AudioMediaPlayingState.Playing) @@ -330,7 +379,13 @@ internal constructor( ) } + private suspend fun updateSpeedFlow() { + val currentSpeed = AudioSpeed.fromFloat(audioMediaPlayer.playbackParams.speed) + _audioSpeed.emit(currentSpeed) + } + internal fun close() { audioMediaPlayer.reset() + wavesMaskHelper.clear() } } diff --git a/app/src/main/kotlin/com/wire/android/media/audiomessage/RecordAudioMessagePlayer.kt b/app/src/main/kotlin/com/wire/android/media/audiomessage/RecordAudioMessagePlayer.kt index 678f7f8aa80..bfc24b46c4e 100644 --- a/app/src/main/kotlin/com/wire/android/media/audiomessage/RecordAudioMessagePlayer.kt +++ b/app/src/main/kotlin/com/wire/android/media/audiomessage/RecordAudioMessagePlayer.kt @@ -36,7 +36,8 @@ import javax.inject.Inject @ViewModelScoped class RecordAudioMessagePlayer @Inject constructor( private val context: Context, - private val audioMediaPlayer: MediaPlayer + private val audioMediaPlayer: MediaPlayer, + private val wavesMaskHelper: AudioWavesMaskHelper ) { private var currentAudioFile: File? = null private var audioState: AudioState = AudioState.DEFAULT @@ -110,6 +111,12 @@ class RecordAudioMessagePlayer @Inject constructor( ) ) } + + is RecordAudioMediaPlayerStateUpdate.WaveMaskUpdate -> { + audioState = audioState.copy( + wavesMask = audioStateUpdate.waveMask + ) + } } audioState @@ -164,6 +171,12 @@ class RecordAudioMessagePlayer @Inject constructor( audioMediaPlayer.seekTo(position) audioMediaPlayer.start() + audioMessageStateUpdate.emit( + RecordAudioMediaPlayerStateUpdate.WaveMaskUpdate( + wavesMaskHelper.getWaveMask(audioFile) + ) + ) + audioMessageStateUpdate.emit( RecordAudioMediaPlayerStateUpdate.RecordAudioMediaPlayingStateUpdate( audioMediaPlayingState = AudioMediaPlayingState.Playing @@ -217,6 +230,6 @@ class RecordAudioMessagePlayer @Inject constructor( } private companion object { - const val UPDATE_POSITION_INTERVAL_IN_MS = 1000L + const val UPDATE_POSITION_INTERVAL_IN_MS = 100L } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt index 061b6c1a1b9..d624f997f1f 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationScreen.kt @@ -27,7 +27,9 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandIn import androidx.compose.animation.shrinkOut import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -37,9 +39,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.FloatingActionButtonDefaults @@ -65,7 +69,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.hilt.navigation.compose.hiltViewModel import androidx.paging.PagingData import androidx.paging.compose.LazyPagingItems @@ -86,7 +92,7 @@ import com.wire.android.feature.sketch.destinations.DrawingCanvasScreenDestinati import com.wire.android.feature.sketch.model.DrawingCanvasNavArgs import com.wire.android.feature.sketch.model.DrawingCanvasNavBackArgs import com.wire.android.mapper.MessageDateTimeGroup -import com.wire.android.media.audiomessage.AudioState +import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.model.SnackBarMessage import com.wire.android.navigation.BackStackMode import com.wire.android.navigation.NavigationCommand @@ -137,8 +143,10 @@ import com.wire.android.ui.home.conversations.info.ConversationDetailsData import com.wire.android.ui.home.conversations.info.ConversationInfoViewModel import com.wire.android.ui.home.conversations.info.ConversationInfoViewState import com.wire.android.ui.home.conversations.media.preview.ImagesPreviewNavBackArgs +import com.wire.android.ui.home.conversations.messages.AudioMessagesState import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewState +import com.wire.android.ui.home.conversations.messages.PlayingAudiMessage import com.wire.android.ui.home.conversations.messages.draft.MessageDraftViewModel import com.wire.android.ui.home.conversations.messages.item.MessageClickActions import com.wire.android.ui.home.conversations.messages.item.MessageContainerItem @@ -163,6 +171,7 @@ import com.wire.android.ui.legalhold.dialog.subject.LegalHoldSubjectMessageDialo import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.DateAndTimeParsers import com.wire.android.util.normalizeLink import com.wire.android.util.serverDate import com.wire.android.util.ui.PreviewMultipleThemes @@ -542,6 +551,7 @@ fun ConversationScreen( }, onAudioClick = conversationMessagesViewModel::audioClick, onChangeAudioPosition = conversationMessagesViewModel::changeAudioPosition, + onChangeAudioSpeed = conversationMessagesViewModel::changeAudioSpeed, onResetSessionClick = conversationMessagesViewModel::onResetSession, onUpdateConversationReadDate = messageComposerViewModel::updateConversationReadDate, onDropDownClick = { @@ -836,6 +846,7 @@ private fun ConversationScreen( onDeleteMessage: (String, Boolean) -> Unit, onAudioClick: (String) -> Unit, onChangeAudioPosition: (String, Int) -> Unit, + onChangeAudioSpeed: (AudioSpeed) -> Unit, onAssetItemClicked: (String) -> Unit, onImageFullScreenMode: (UIMessage.Regular, Boolean) -> Unit, onStartCall: () -> Unit, @@ -920,7 +931,7 @@ private fun ConversationScreen( audioMessagesState = conversationMessagesViewState.audioMessagesState, assetStatuses = conversationMessagesViewState.assetStatuses, lastUnreadMessageInstant = conversationMessagesViewState.firstUnreadInstant, - unreadEventCount = conversationMessagesViewState.firstuUnreadEventIndex, + unreadEventCount = conversationMessagesViewState.firstUnreadEventIndex, conversationDetailsData = conversationInfoViewState.conversationDetailsData, selectedMessageId = conversationMessagesViewState.searchedMessageId, messageComposerStateHolder = messageComposerStateHolder, @@ -931,6 +942,7 @@ private fun ConversationScreen( onAssetItemClicked = onAssetItemClicked, onAudioItemClicked = onAudioClick, onChangeAudioPosition = onChangeAudioPosition, + onChangeAudioSpeed = onChangeAudioSpeed, onImageFullScreenMode = onImageFullScreenMode, onReactionClicked = onReactionClick, onResetSessionClicked = onResetSessionClick, @@ -997,7 +1009,7 @@ private fun ConversationScreenContent( bottomSheetVisible: Boolean, lastUnreadMessageInstant: Instant?, unreadEventCount: Int, - audioMessagesState: PersistentMap, + audioMessagesState: AudioMessagesState, assetStatuses: PersistentMap, selectedMessageId: String?, messageComposerStateHolder: MessageComposerStateHolder, @@ -1008,6 +1020,7 @@ private fun ConversationScreenContent( onAssetItemClicked: (String) -> Unit, onAudioItemClicked: (String) -> Unit, onChangeAudioPosition: (String, Int) -> Unit, + onChangeAudioSpeed: (AudioSpeed) -> Unit, onImageFullScreenMode: (UIMessage.Regular, Boolean) -> Unit, onReactionClicked: (String, String) -> Unit, onResetSessionClicked: (senderUserId: UserId, clientId: String?) -> Unit, @@ -1055,6 +1068,7 @@ private fun ConversationScreenContent( onAssetClicked = onAssetItemClicked, onPlayAudioClicked = onAudioItemClicked, onAudioPositionChanged = onChangeAudioPosition, + onAudioSpeedChange = onChangeAudioSpeed, onImageClicked = onImageFullScreenMode, onLinkClicked = onLinkClick, onReplyClicked = onNavigateToReplyOriginalMessage, @@ -1123,7 +1137,7 @@ fun MessageList( lazyPagingMessages: LazyPagingItems, lazyListState: LazyListState, lastUnreadMessageInstant: Instant?, - audioMessagesState: PersistentMap, + audioMessagesState: AudioMessagesState, assetStatuses: PersistentMap, onUpdateConversationReadDate: (String) -> Unit, onSwipedToReply: (UIMessage.Regular) -> Unit, @@ -1237,7 +1251,8 @@ fun MessageList( conversationDetailsData = conversationDetailsData, showAuthor = showAuthor, useSmallBottomPadding = useSmallBottomPadding, - audioState = audioMessagesState[message.header.messageId], + audioState = audioMessagesState.audioStates[message.header.messageId], + audioSpeed = audioMessagesState.audioSpeed, assetStatus = assetStatuses[message.header.messageId]?.transferStatus, clickActions = clickActions, swipableMessageConfiguration = swipableConfiguration, @@ -1260,6 +1275,11 @@ fun MessageList( } } } + JumpToPlayingAudioButton( + lazyListState = lazyListState, + lazyPagingMessages = lazyPagingMessages, + playingAudiMessage = audioMessagesState.playingAudiMessage + ) JumpToLastMessageButton(lazyListState = lazyListState) } ) @@ -1394,6 +1414,64 @@ fun JumpToLastMessageButton( } } +@Composable +fun BoxScope.JumpToPlayingAudioButton( + lazyListState: LazyListState, + playingAudiMessage: PlayingAudiMessage?, + lazyPagingMessages: LazyPagingItems, + modifier: Modifier = Modifier, + coroutineScope: CoroutineScope = rememberCoroutineScope() +) { + val indexOfPlayedMessage = playingAudiMessage?.let { + lazyPagingMessages.itemSnapshotList + .indexOfFirst { playingAudiMessage.messageId == it?.header?.messageId } + } ?: -1 + + if (indexOfPlayedMessage < 0) return + + val firstVisibleIndex = lazyListState.firstVisibleItemIndex + val lastVisibleIndex = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: firstVisibleIndex + + if (indexOfPlayedMessage in firstVisibleIndex..lastVisibleIndex) return + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .wrapContentWidth() + .align(Alignment.TopCenter) + .padding(all = dimensions().spacing8x) + .clickable { coroutineScope.launch { lazyListState.animateScrollToItem(indexOfPlayedMessage) } } + .background( + color = colorsScheme().secondaryText, + shape = RoundedCornerShape(dimensions().corner16x) + ) + .padding(horizontal = dimensions().spacing16x, vertical = dimensions().spacing8x) + ) { + Icon( + modifier = Modifier.size(dimensions().systemMessageIconSize), + painter = painterResource(id = R.drawable.ic_play), + contentDescription = null, + tint = MaterialTheme.wireColorScheme.onPrimaryButtonEnabled + ) + Text( + modifier = Modifier + .padding(horizontal = dimensions().spacing8x) + .weight(1f, fill = false), + text = playingAudiMessage!!.authorName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = colorsScheme().onPrimaryButtonEnabled, + style = MaterialTheme.wireTypography.body04, + ) + Text( + modifier = Modifier, + text = DateAndTimeParsers.audioMessageTime(playingAudiMessage.currentTimeMs.toLong()), + color = colorsScheme().onPrimaryButtonEnabled, + style = MaterialTheme.wireTypography.label03, + ) + } +} + private fun CoroutineScope.withSmoothScreenLoad(block: () -> Unit) = launch { val smoothAnimationDuration = 200.milliseconds delay(smoothAnimationDuration) // we wait a bit until the whole screen is loaded to show the animation properly @@ -1464,5 +1542,6 @@ fun PreviewConversationScreen() = WireTheme { onLinkClick = { _ -> }, openDrawingCanvas = {}, onImagesPicked = {}, + onChangeAudioSpeed = {} ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt index 1d4a09ab831..86a2d4223bc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/ConversationMediaScreen.kt @@ -44,7 +44,6 @@ import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import com.ramcosta.composedestinations.annotation.RootNavGraph import com.wire.android.R -import com.wire.android.media.audiomessage.AudioState import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.WireDestination @@ -70,6 +69,7 @@ import com.wire.android.ui.home.conversations.DownloadedAssetDialog import com.wire.android.ui.home.conversations.PermissionPermanentlyDeniedDialogState import com.wire.android.ui.home.conversations.delete.DeleteMessageDialog import com.wire.android.ui.home.conversations.edit.assetOptionsMenuItems +import com.wire.android.ui.home.conversations.messages.AudioMessagesState import com.wire.android.ui.home.conversations.messages.ConversationMessagesViewModel import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireDimensions @@ -78,8 +78,6 @@ import com.wire.android.util.ui.SnackBarMessageHandler import com.wire.android.util.ui.UIText import com.wire.android.util.ui.openDownloadFolder import com.wire.kalium.logic.data.id.ConversationId -import kotlinx.collections.immutable.PersistentMap -import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.launch @RootNavGraph @@ -169,7 +167,7 @@ fun ConversationMediaScreen( @Composable private fun Content( state: ConversationAssetMessagesViewState, - audioMessagesState: PersistentMap = persistentMapOf(), + audioMessagesState: AudioMessagesState = AudioMessagesState(), initialPage: ConversationMediaScreenTabItem = ConversationMediaScreenTabItem.PICTURES, onImageFullScreenMode: (conversationId: ConversationId, messageId: String, isSelfAsset: Boolean) -> Unit = { _, _, _ -> }, onPlayAudioItemClicked: (String) -> Unit = {}, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt index a7559b94a90..d4e76a358e6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/media/FileAssetsContent.kt @@ -36,10 +36,12 @@ import androidx.paging.compose.itemContentType import androidx.paging.compose.itemKey import com.wire.android.R import com.wire.android.media.audiomessage.AudioMediaPlayingState +import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.home.conversations.info.ConversationDetailsData +import com.wire.android.ui.home.conversations.messages.AudioMessagesState import com.wire.android.ui.home.conversations.messages.item.MessageClickActions import com.wire.android.ui.home.conversations.messages.item.MessageContainerItem import com.wire.android.ui.home.conversations.messages.item.SwipableMessageConfiguration @@ -65,7 +67,7 @@ import kotlinx.datetime.Instant fun FileAssetsContent( groupedAssetMessageList: Flow>, assetStatuses: PersistentMap, - audioMessagesState: PersistentMap = persistentMapOf(), + audioMessagesState: AudioMessagesState = AudioMessagesState(), onPlayAudioItemClicked: (messageId: String) -> Unit = {}, onAudioItemPositionChanged: (String, Int) -> Unit = { _, _ -> }, onAssetItemClicked: (messageId: String) -> Unit = {}, @@ -93,7 +95,7 @@ fun FileAssetsContent( @Composable private fun AssetMessagesListContent( groupedAssetMessageList: LazyPagingItems, - audioMessagesState: PersistentMap, + audioMessagesState: AudioMessagesState, assetStatuses: PersistentMap, onPlayAudioItemClicked: (messageId: String) -> Unit, onAudioItemPositionChanged: (String, Int) -> Unit, @@ -137,7 +139,8 @@ private fun AssetMessagesListContent( MessageContainerItem( message = message, conversationDetailsData = ConversationDetailsData.None(null), - audioState = audioMessagesState[message.header.messageId], + audioState = audioMessagesState.audioStates[message.header.messageId], + audioSpeed = audioMessagesState.audioSpeed, assetStatus = assetStatuses[message.header.messageId]?.transferStatus, clickActions = MessageClickActions.Content( onFullMessageLongClicked = remember { { onItemLongClicked(it.header.messageId, it.isMyMessage) } }, @@ -171,7 +174,7 @@ private fun AssetMessagesListContent( @PreviewMultipleThemes @Composable fun PreviewFileAssetsEmptyContent() = WireTheme { - FileAssetsContent(groupedAssetMessageList = emptyFlow(), assetStatuses = persistentMapOf(), audioMessagesState = persistentMapOf()) + FileAssetsContent(groupedAssetMessageList = emptyFlow(), assetStatuses = persistentMapOf()) } @PreviewMultipleThemes @@ -182,7 +185,7 @@ fun PreviewFileAssetsContent() = WireTheme { } @Suppress("MagicNumber") -fun mockAssets(): Triple>, PersistentMap, PersistentMap> { +fun mockAssets(): Triple>, PersistentMap, AudioMessagesState> { val msg1 = mockAssetMessage(assetId = "assset1", messageId = "msg1") val msg2 = mockAssetMessage(assetId = "assset2", messageId = "msg2") val msg3 = mockAssetMessage(assetId = "assset3", messageId = "msg3") @@ -207,8 +210,8 @@ fun mockAssets(): Triple>, PersistentMap + audioMessageStates.firstNotNullOfOrNull { (messageId, audioState) -> + if (audioState.audioMediaPlayingState == AudioMediaPlayingState.Playing) messageId else null + } + } + .distinctUntilChanged() + .map { messageId -> + val senderName = messageId?.let { + val result = getSenderNameByMessageId(conversationId, it) + if (result is GetSenderNameByMessageIdUseCase.Result.Success) result.name else null + } + + messageId to senderName + } + viewModelScope.launch { - conversationAudioMessagePlayer.observableAudioMessagesState.collect { - conversationViewState = conversationViewState.copy( - audioMessagesState = it.toPersistentMap() - ) + combine( + observableAudioMessagesState, + conversationAudioMessagePlayer.audioSpeed, + playingMessageData + ) { audioMessageStates, audioSpeed, (playingMessageId, playingMessageSenderName) -> + val playingAudiMessage = playingMessageId?.let { + PlayingAudiMessage( + messageId = playingMessageId, + authorName = playingMessageSenderName.orEmpty(), + currentTimeMs = audioMessageStates[playingMessageId]?.currentPositionInMs ?: 0 + ) + } + AudioMessagesState(audioMessageStates.toPersistentMap(), audioSpeed, playingAudiMessage) } + .collect { conversationViewState = conversationViewState.copy(audioMessagesState = it) } } } @@ -215,11 +253,12 @@ class ConversationMessagesViewModel @Inject constructor( } val paginatedMessagesFlow = getMessageForConversation(conversationId, lastReadIndex) + .fetchAudioWavesMaskIfNeeded() .flowOn(dispatchers.io()) conversationViewState = conversationViewState.copy( messages = paginatedMessagesFlow, - firstuUnreadEventIndex = max(lastReadIndex - 1, 0) + firstUnreadEventIndex = max(lastReadIndex - 1, 0) ) handleSelectedSearchedMessageHighlighting() @@ -397,6 +436,12 @@ class ConversationMessagesViewModel @Inject constructor( } } + fun changeAudioSpeed(audioSpeed: AudioSpeed) { + viewModelScope.launch { + conversationAudioMessagePlayer.setSpeed(audioSpeed) + } + } + fun updateImageOnFullscreenMode(message: UIMessage.Regular?) { lastImageMessageShownOnGallery = message } @@ -435,6 +480,19 @@ class ConversationMessagesViewModel @Inject constructor( } } + // checking all the new messages if it's an AudioMessage and fetch WavesMask for it if so + private fun Flow>.fetchAudioWavesMaskIfNeeded(): Flow> = + map { + it.map { message -> + if (message.messageContent is UIMessageContent.AudioAssetMessage) { + viewModelScope.launch { + conversationAudioMessagePlayer.fetchWavesMask(conversationId, message.header.messageId) + } + } + message + } + } + override fun onCleared() { super.onCleared() conversationAudioMessagePlayerProvider.onCleared() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt index 6ce5489cf51..119139691d5 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewState.kt @@ -19,6 +19,7 @@ package com.wire.android.ui.home.conversations.messages import androidx.paging.PagingData +import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState import com.wire.android.ui.home.conversations.model.AssetBundle import com.wire.android.ui.home.conversations.model.UIMessage @@ -32,13 +33,25 @@ import kotlinx.datetime.Instant data class ConversationMessagesViewState( val messages: Flow> = emptyFlow(), val firstUnreadInstant: Instant? = null, - val firstuUnreadEventIndex: Int = 0, + val firstUnreadEventIndex: Int = 0, val downloadedAssetDialogState: DownloadedAssetDialogVisibilityState = DownloadedAssetDialogVisibilityState.Hidden, - val audioMessagesState: PersistentMap = persistentMapOf(), + val audioMessagesState: AudioMessagesState = AudioMessagesState(), val assetStatuses: PersistentMap = persistentMapOf(), val searchedMessageId: String? = null ) +data class AudioMessagesState( + val audioStates: PersistentMap = persistentMapOf(), + val audioSpeed: AudioSpeed = AudioSpeed.NORMAL, + val playingAudiMessage: PlayingAudiMessage? = null +) + +data class PlayingAudiMessage( + val messageId: String, + val authorName: String, + val currentTimeMs: Int +) + sealed class DownloadedAssetDialogVisibilityState { object Hidden : DownloadedAssetDialogVisibilityState() data class Displayed(val assetData: AssetBundle, val messageId: String) : DownloadedAssetDialogVisibilityState() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt index 159d2b01081..912173b093e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageClickActions.kt @@ -17,6 +17,7 @@ */ package com.wire.android.ui.home.conversations.messages.item +import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.ui.home.conversations.model.UIMessage import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId @@ -29,6 +30,7 @@ sealed class MessageClickActions { open val onAssetClicked: (String) -> Unit = {} open val onPlayAudioClicked: (String) -> Unit = {} open val onAudioPositionChanged: (String, Int) -> Unit = { _, _ -> } + open val onAudioSpeedChange: (AudioSpeed) -> Unit = { _ -> } open val onImageClicked: (UIMessage.Regular, Boolean) -> Unit = { _, _ -> } open val onLinkClicked: (String) -> Unit = {} open val onReplyClicked: (UIMessage.Regular) -> Unit = {} @@ -48,6 +50,7 @@ sealed class MessageClickActions { override val onAssetClicked: (String) -> Unit = {}, override val onPlayAudioClicked: (String) -> Unit = {}, override val onAudioPositionChanged: (String, Int) -> Unit = { _, _ -> }, + override val onAudioSpeedChange: (AudioSpeed) -> Unit = { _ -> }, override val onImageClicked: (UIMessage.Regular, Boolean) -> Unit = { _, _ -> }, override val onLinkClicked: (String) -> Unit = {}, override val onReplyClicked: (UIMessage.Regular) -> Unit = {}, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContainerItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContainerItem.kt index 7960fb27861..4e2e58e7ea7 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContainerItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContainerItem.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState import com.wire.android.ui.common.colorsScheme import com.wire.android.ui.common.dimensions @@ -52,6 +53,7 @@ fun MessageContainerItem( showAuthor: Boolean = true, useSmallBottomPadding: Boolean = false, audioState: AudioState? = null, + audioSpeed: AudioSpeed = AudioSpeed.NORMAL, assetStatus: AssetTransferStatus? = null, shouldDisplayMessageStatus: Boolean = true, shouldDisplayFooter: Boolean = true, @@ -95,6 +97,7 @@ fun MessageContainerItem( clickActions = clickActions, showAuthor = showAuthor, audioState = audioState, + audioSpeed = audioSpeed, assetStatus = assetStatus, swipableMessageConfiguration = swipableMessageConfiguration, failureInteractionAvailable = failureInteractionAvailable, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt index b38cbd0ff91..5040809be01 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContentAndStatus.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.wire.android.R +import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState import com.wire.android.model.Clickable import com.wire.android.ui.common.dimensions @@ -41,10 +42,12 @@ internal fun UIMessage.Regular.MessageContentAndStatus( assetStatus: AssetTransferStatus?, searchQuery: String, audioState: AudioState?, + audioSpeed: AudioSpeed, onAssetClicked: (String) -> Unit, onImageClicked: (UIMessage.Regular, Boolean) -> Unit, onAudioClicked: (String) -> Unit, onAudioPositionChanged: (String, Int) -> Unit, + onAudioSpeedChange: (AudioSpeed) -> Unit, onProfileClicked: (String) -> Unit, onLinkClicked: (String) -> Unit, onReplyClicked: (UIMessage.Regular) -> Unit, @@ -73,11 +76,13 @@ internal fun UIMessage.Regular.MessageContentAndStatus( messageContent = messageContent, searchQuery = searchQuery, audioState = audioState, + audioSpeed = audioSpeed, assetStatus = assetStatus, onAudioClick = onAudioClicked, onChangeAudioPosition = onAudioPositionChanged, onAssetClick = onAssetClickable, onImageClick = onImageClickable, + onAudioSpeedChange = onAudioSpeedChange, onOpenProfile = onProfileClicked, onLinkClick = onLinkClicked, onReplyClick = onReplyClickable, @@ -105,11 +110,13 @@ private fun MessageContent( messageContent: UIMessageContent.Regular?, searchQuery: String, audioState: AudioState?, + audioSpeed: AudioSpeed, assetStatus: AssetTransferStatus?, onAssetClick: Clickable, onImageClick: Clickable, onAudioClick: (String) -> Unit, onChangeAudioPosition: (String, Int) -> Unit, + onAudioSpeedChange: (AudioSpeed) -> Unit, onOpenProfile: (String) -> Unit, onLinkClick: (String) -> Unit, onReplyClick: Clickable, @@ -233,10 +240,13 @@ private fun MessageContent( audioMediaPlayingState = audioMessageState.audioMediaPlayingState, totalTimeInMs = totalTimeInMs, currentPositionInMs = audioMessageState.currentPositionInMs, + audioSpeed = audioSpeed, + waveMask = audioMessageState.wavesMask, onPlayButtonClick = { onAudioClick(message.header.messageId) }, onSliderPositionChange = { position -> onChangeAudioPosition(message.header.messageId, position.toInt()) }, + onAudioSpeedChange = { onAudioSpeedChange(audioSpeed.toggle()) } ) PartialDeliveryInformation(messageContent.deliveryStatus) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt index e42be6c5184..e8e4d4d79bb 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.wire.android.R +import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState import com.wire.android.ui.common.LegalHoldIndicator import com.wire.android.ui.common.StatusBox @@ -73,6 +74,7 @@ fun RegularMessageItem( message: UIMessage.Regular, conversationDetailsData: ConversationDetailsData, audioState: AudioState?, + audioSpeed: AudioSpeed, modifier: Modifier = Modifier, searchQuery: String = "", showAuthor: Boolean = true, @@ -149,14 +151,15 @@ fun RegularMessageItem( onImageClicked = clickActions.onImageClicked, searchQuery = searchQuery, audioState = audioState, + audioSpeed = audioSpeed, onAudioClicked = clickActions.onPlayAudioClicked, onAudioPositionChanged = clickActions.onAudioPositionChanged, onProfileClicked = clickActions.onProfileClicked, onLinkClicked = clickActions.onLinkClicked, shouldDisplayMessageStatus = shouldDisplayMessageStatus, conversationDetailsData = conversationDetailsData, - onReplyClicked = clickActions.onReplyClicked - + onReplyClicked = clickActions.onReplyClicked, + onAudioSpeedChange = clickActions.onAudioSpeedChange ) if (shouldDisplayFooter) { VerticalSpace.x4() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt index a95d1fb5eb9..1e79cd8db6a 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/messagetypes/audio/AudioMessageType.kt @@ -22,8 +22,10 @@ import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -50,12 +52,14 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import com.wire.android.R import com.wire.android.media.audiomessage.AudioMediaPlayingState +import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState import com.wire.android.model.Clickable import com.wire.android.ui.common.WireDialog import com.wire.android.ui.common.WireDialogButtonProperties import com.wire.android.ui.common.WireDialogButtonType import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.button.WirePrimaryButton import com.wire.android.ui.common.button.WireSecondaryIconButton import com.wire.android.ui.common.clickable import com.wire.android.ui.common.colorsScheme @@ -64,6 +68,9 @@ import com.wire.android.ui.common.progress.WireCircularProgressIndicator import com.wire.android.ui.common.spacers.HorizontalSpace import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireDimensions +import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.DateAndTimeParsers import com.wire.android.util.ui.PreviewMultipleThemes @Composable @@ -71,8 +78,11 @@ fun AudioMessage( audioMediaPlayingState: AudioMediaPlayingState, totalTimeInMs: AudioState.TotalTimeInMs, currentPositionInMs: Int, + audioSpeed: AudioSpeed, + waveMask: List, onPlayButtonClick: () -> Unit, onSliderPositionChange: (Float) -> Unit, + onAudioSpeedChange: () -> Unit, modifier: Modifier = Modifier, ) { Box( @@ -96,8 +106,11 @@ fun AudioMessage( audioMediaPlayingState = audioMediaPlayingState, totalTimeInMs = totalTimeInMs, currentPositionInMs = currentPositionInMs, + audioSpeed = audioSpeed, + waveMask = waveMask, onPlayButtonClick = onPlayButtonClick, onSliderPositionChange = onSliderPositionChange, + onAudioSpeedChange = onAudioSpeedChange ) } } @@ -108,6 +121,7 @@ fun RecordedAudioMessage( audioMediaPlayingState: AudioMediaPlayingState, totalTimeInMs: AudioState.TotalTimeInMs, currentPositionInMs: Int, + waveMask: List, onPlayButtonClick: () -> Unit, onSliderPositionChange: (Float) -> Unit, modifier: Modifier = Modifier, @@ -123,8 +137,11 @@ fun RecordedAudioMessage( audioMediaPlayingState = audioMediaPlayingState, totalTimeInMs = totalTimeInMs, currentPositionInMs = currentPositionInMs, + audioSpeed = AudioSpeed.NORMAL, + waveMask = waveMask, onPlayButtonClick = onPlayButtonClick, onSliderPositionChange = onSliderPositionChange, + onAudioSpeedChange = null ) } } @@ -134,8 +151,11 @@ private fun SuccessfulAudioMessage( audioMediaPlayingState: AudioMediaPlayingState, totalTimeInMs: AudioState.TotalTimeInMs, currentPositionInMs: Int, + audioSpeed: AudioSpeed, + waveMask: List, onPlayButtonClick: () -> Unit, onSliderPositionChange: (Float) -> Unit, + onAudioSpeedChange: (() -> Unit)?, modifier: Modifier = Modifier, ) { val audioDuration by remember(currentPositionInMs) { @@ -145,39 +165,83 @@ private fun SuccessfulAudioMessage( } Row( - modifier = modifier - .fillMaxWidth() - .height(dimensions().audioMessageHeight), - verticalAlignment = Alignment.CenterVertically + modifier = modifier.fillMaxWidth(), ) { + val (iconResource, contentDescriptionRes) = getPlayOrPauseIcon(audioMediaPlayingState) WireSecondaryIconButton( - minSize = dimensions().buttonSmallMinSize, + minSize = DpSize(dimensions().spacing32x, dimensions().spacing32x), minClickableSize = dimensions().buttonMinClickableSize, iconSize = dimensions().spacing12x, - iconResource = getPlayOrPauseIcon(audioMediaPlayingState), + iconResource = iconResource, shape = CircleShape, - contentDescription = R.string.content_description_image_message, + contentDescription = contentDescriptionRes, state = if (audioMediaPlayingState is AudioMediaPlayingState.Fetching) WireButtonState.Disabled else WireButtonState.Default, onButtonClicked = onPlayButtonClick ) - AudioMessageSlider( - audioDuration = audioDuration, - totalTimeInMs = totalTimeInMs, - onSliderPositionChange = onSliderPositionChange - ) + Column( + modifier = Modifier.fillMaxWidth(), + ) { - if (audioMediaPlayingState is AudioMediaPlayingState.Fetching) { - WireCircularProgressIndicator( - progressColor = MaterialTheme.wireColorScheme.secondaryButtonEnabled - ) - } else { - Text( - text = audioDuration.formattedTimeLeft(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.wireColorScheme.secondaryText, - maxLines = 1 + AudioMessageSlider( + audioDuration = audioDuration, + totalTimeInMs = totalTimeInMs, + waveMask = waveMask, + onSliderPositionChange = onSliderPositionChange ) + + Row { + Text( + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(vertical = MaterialTheme.wireDimensions.spacing2x), + text = audioDuration.formattedCurrentTime(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.wireColorScheme.primary, + maxLines = 1 + ) + + if (audioMediaPlayingState is AudioMediaPlayingState.Playing && onAudioSpeedChange != null) { + WirePrimaryButton( + onClick = onAudioSpeedChange, + text = stringResource(audioSpeed.titleRes), + textStyle = MaterialTheme.wireTypography.label03, + contentPadding = PaddingValues( + horizontal = MaterialTheme.wireDimensions.spacing4x, + vertical = MaterialTheme.wireDimensions.spacing2x + ), + shape = RoundedCornerShape(MaterialTheme.wireDimensions.corner4x), + minSize = DpSize( + dimensions().spacing32x, + dimensions().spacing16x + ), + minClickableSize = DpSize( + dimensions().spacing40x, + dimensions().spacing16x + ), + fillMaxWidth = false + ) + } + + Spacer(Modifier.weight(1F)) + + if (audioMediaPlayingState is AudioMediaPlayingState.Fetching) { + WireCircularProgressIndicator( + modifier = Modifier.align(Alignment.CenterVertically), + progressColor = MaterialTheme.wireColorScheme.secondaryButtonEnabled + ) + } else { + Text( + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(vertical = MaterialTheme.wireDimensions.spacing2x), + text = audioDuration.formattedTotalTime(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.wireColorScheme.secondaryText, + maxLines = 1 + ) + } + } } } } @@ -193,36 +257,59 @@ private fun SuccessfulAudioMessage( */ @Composable @OptIn(ExperimentalMaterial3Api::class) -private fun RowScope.AudioMessageSlider( +private fun AudioMessageSlider( audioDuration: AudioDuration, totalTimeInMs: AudioState.TotalTimeInMs, + waveMask: List, onSliderPositionChange: (Float) -> Unit, ) { - Slider( - value = audioDuration.currentPositionInMs.toFloat(), - onValueChange = onSliderPositionChange, - valueRange = 0f..if (totalTimeInMs is AudioState.TotalTimeInMs.Known) totalTimeInMs.value.toFloat() else 0f, - thumb = { - SliderDefaults.Thumb( - interactionSource = remember { MutableInteractionSource() }, - thumbSize = DpSize(dimensions().spacing20x, dimensions().spacing20x) - ) - }, - track = { sliderState -> - SliderDefaults.Track( - modifier = Modifier.height(dimensions().spacing4x), - sliderState = sliderState, - thumbTrackGapSize = dimensions().spacing0x, - drawStopIndicator = { - // nop we do not want to draw stop indicator at all. - } - ) - }, - colors = SliderDefaults.colors( - inactiveTrackColor = colorsScheme().secondaryButtonDisabledOutline - ), - modifier = Modifier.weight(1f) - ) + Box(modifier = Modifier.fillMaxWidth()) { + val totalMs = if (totalTimeInMs is AudioState.TotalTimeInMs.Known) totalTimeInMs.value.toFloat() else 0f + val waves = waveMask.ifEmpty { getDefaultWaveMask() } + val wavesAmount = waves.size + + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center), + verticalAlignment = Alignment.CenterVertically + ) { + waves.forEachIndexed { index, wave -> + val isWaveActivated = totalMs > 0 && (index / wavesAmount.toFloat()) < audioDuration.currentPositionInMs / totalMs + Spacer( + Modifier + .background( + color = if (isWaveActivated) colorsScheme().primary else colorsScheme().onTertiaryButtonDisabled, + shape = RoundedCornerShape(dimensions().corner2x) + ) + .weight(2f) + .height(wave.dp) + ) + + Spacer(Modifier.weight(1F)) + } + } + + Slider( + value = audioDuration.currentPositionInMs.toFloat(), + onValueChange = onSliderPositionChange, + valueRange = 0f..totalMs, + thumb = { + SliderDefaults.Thumb( + interactionSource = remember { MutableInteractionSource() }, + thumbSize = DpSize(dimensions().spacing4x, dimensions().spacing32x) + ) + }, + track = { _ -> + // just empty, track is displayed by waves above + Spacer(Modifier.fillMaxWidth()) + }, + colors = SliderDefaults.colors( + inactiveTrackColor = colorsScheme().secondaryButtonDisabledOutline + ), + modifier = Modifier.fillMaxWidth() + ) + } } @Composable @@ -268,44 +355,28 @@ private fun FailedAudioMessage() { } } -private fun getPlayOrPauseIcon(audioMediaPlayingState: AudioMediaPlayingState): Int = +private fun getPlayOrPauseIcon(audioMediaPlayingState: AudioMediaPlayingState): Pair = when (audioMediaPlayingState) { - AudioMediaPlayingState.Playing -> R.drawable.ic_pause - AudioMediaPlayingState.Completed -> R.drawable.ic_play - else -> R.drawable.ic_play + AudioMediaPlayingState.Playing -> R.drawable.ic_pause to R.string.content_description_pause_audio + AudioMediaPlayingState.Completed -> R.drawable.ic_play to R.string.content_description_play_audio + else -> R.drawable.ic_play to R.string.content_description_play_audio } -// helper wrapper class to format the time that is left +@Suppress("MagicNumber") +private fun getDefaultWaveMask(): List = List(75) { 1 } + +// helper wrapper class to format the time private data class AudioDuration(val totalDurationInMs: AudioState.TotalTimeInMs, val currentPositionInMs: Int) { companion object { - const val totalMsInSec = 1000 - const val totalSecInMin = 60 const val UNKNOWN_DURATION_LABEL = "-:--" } - fun formattedTimeLeft(): String { - if (totalDurationInMs is AudioState.TotalTimeInMs.Known) { - val totalTimeInSec = totalDurationInMs.value / totalMsInSec - val currentPositionInSec = currentPositionInMs / totalMsInSec - - val isTotalTimeInSecKnown = totalTimeInSec > 0 - - val timeLeft = if (!isTotalTimeInSecKnown) { - currentPositionInSec - } else { - totalTimeInSec - currentPositionInSec - } - - // sanity check, timeLeft, should not be smaller, however if the back-end makes mistake we - // will display a negative values, which we do not want - val minutes = if (timeLeft < 0) 0 else timeLeft / totalSecInMin - val seconds = if (timeLeft < 0) 0 else timeLeft % totalSecInMin - val formattedSeconds = String.format("%02d", seconds) - - return "$minutes:$formattedSeconds" - } + fun formattedCurrentTime(): String = DateAndTimeParsers.audioMessageTime(currentPositionInMs.toLong()) - return UNKNOWN_DURATION_LABEL + fun formattedTotalTime(): String = if (totalDurationInMs is AudioState.TotalTimeInMs.Known) { + DateAndTimeParsers.audioMessageTime(totalDurationInMs.value.toLong()) + } else { + UNKNOWN_DURATION_LABEL } } @@ -317,8 +388,15 @@ private fun PreviewSuccessfulAudioMessage() { audioMediaPlayingState = AudioMediaPlayingState.Completed, totalTimeInMs = AudioState.TotalTimeInMs.Known(10000), currentPositionInMs = 5000, + audioSpeed = AudioSpeed.NORMAL, + waveMask = listOf( + 32, 1, 24, 23, 13, 16, 9, 0, 4, 30, 23, 12, 14, 1, 7, 8, 0, 12, 32, 23, 34, 4, 16, 9, 0, 4, 30, 23, 12, + 14, 1, 7, 8, 0, 13, 16, 9, 0, 4, 30, 23, 12, 14, 1, 7, 8, 0, 12, 32, 23, 34, 4, 16, 13, 16, 9, 0, 4, 30, 23, 12, 14, 1, + 7, 8, 0, 12, 32, 23, 34, 4, 16, + ), onPlayButtonClick = {}, - onSliderPositionChange = {} + onSliderPositionChange = {}, + onAudioSpeedChange = {} ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioButtons.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioButtons.kt index 1356c670ba0..e3a592d80c3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioButtons.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioButtons.kt @@ -200,6 +200,7 @@ fun RecordAudioButtonSend( audioMediaPlayingState = audioState.audioMediaPlayingState, totalTimeInMs = audioState.totalTimeInMs, currentPositionInMs = audioState.currentPositionInMs, + waveMask = audioState.wavesMask, onPlayButtonClick = onPlayAudio, onSliderPositionChange = { position -> onSliderPositionChange(position.toInt()) @@ -230,7 +231,7 @@ private fun RecordAudioButton( isAudioFilterEnabled: Boolean = true, loading: Boolean = false, trailingIconAlignment: IconAlignment = IconAlignment.Border, - ) { +) { Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally @@ -323,7 +324,8 @@ fun PreviewRecordAudioButtonSend() { audioState = AudioState( audioMediaPlayingState = AudioMediaPlayingState.Paused, totalTimeInMs = AudioState.TotalTimeInMs.Known(1000), - currentPositionInMs = 0 + currentPositionInMs = 0, + wavesMask = listOf(32, 1, 24, 23, 13, 16, 9, 0, 4, 30, 23) ), onClick = {}, modifier = Modifier, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt index bcc95331587..e6fc0b029ff 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModel.kt @@ -28,6 +28,7 @@ import com.wire.android.appLogger import com.wire.android.datastore.GlobalDataStore import com.wire.android.media.audiomessage.AudioMediaPlayingState import com.wire.android.media.audiomessage.AudioState +import com.wire.android.media.audiomessage.AudioWavesMaskHelper import com.wire.android.media.audiomessage.RecordAudioMessagePlayer import com.wire.android.ui.home.conversations.model.UriAsset import com.wire.android.util.CurrentScreen @@ -64,6 +65,7 @@ class RecordAudioViewModel @Inject constructor( private val currentScreenManager: CurrentScreenManager, private val audioMediaRecorder: AudioMediaRecorder, private val globalDataStore: GlobalDataStore, + private val audioWavesMaskHelper: AudioWavesMaskHelper, private val dispatchers: DispatcherProvider, private val kaliumFileSystem: KaliumFileSystem ) : ViewModel() { @@ -201,17 +203,19 @@ class RecordAudioViewModel @Inject constructor( ) } + val playableAudioFile = getPlayableAudioFile() state = state.copy( buttonState = RecordAudioButtonState.READY_TO_SEND, audioState = AudioState.DEFAULT.copy( totalTimeInMs = AudioState.TotalTimeInMs.Known( - getPlayableAudioFile()?.let { + playableAudioFile?.let { getAudioLengthInMs( dataPath = it.path.toPath(), mimeType = SUPPORTED_AUDIO_MIME_TYPE ).toInt() } ?: 0 - ) + ), + wavesMask = playableAudioFile?.let { audioWavesMaskHelper.getWaveMask(it) } ?: listOf() ) ) } @@ -382,7 +386,8 @@ class RecordAudioViewModel @Inject constructor( dataPath = effectsFile.path.toPath(), mimeType = SUPPORTED_AUDIO_MIME_TYPE ).toInt() - ) + ), + wavesMask = listOf() ), shouldApplyEffects = true ) @@ -403,6 +408,7 @@ class RecordAudioViewModel @Inject constructor( override fun onCleared() { super.onCleared() recordAudioMessagePlayer.close() + audioWavesMaskHelper.clear() } companion object { diff --git a/app/src/main/kotlin/com/wire/android/util/DateAndTimeParsers.kt b/app/src/main/kotlin/com/wire/android/util/DateAndTimeParsers.kt index 32fee5383eb..aa7fff52c2b 100644 --- a/app/src/main/kotlin/com/wire/android/util/DateAndTimeParsers.kt +++ b/app/src/main/kotlin/com/wire/android/util/DateAndTimeParsers.kt @@ -90,6 +90,8 @@ class DateAndTimeParsers private constructor() { private val messageTimeFormatter = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT, Locale.getDefault()).apply { this.timeZone = java.util.TimeZone.getDefault() } + private val audioMessageTimeFormat = DateTimeFormatter.ofPattern("mm:ss", Locale.getDefault()) + .withZone(ZoneId.systemDefault()) @Deprecated("Date String parsing is discouraged and will be removed soon for direct Instant/DateTime versions") fun serverDate(stringDate: String): Date? { @@ -137,5 +139,7 @@ class DateAndTimeParsers private constructor() { } catch (e: Exception) { null } + + fun audioMessageTime(timeMs: Long): String = audioMessageTimeFormat.format(java.time.Instant.ofEpochMilli(timeMs)) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0239968ec0e..ee6757b6701 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -148,6 +148,8 @@ More options Add contact Image message + Pause audio + Play audio File message Ping Set timer for self-deleting messages @@ -1334,6 +1336,9 @@ In group conversations, the group admin can overwrite this setting. Revoke Link Audio not available Something went wrong while downloading this audio file. Please ask the sender to upload it again + 1x + 1.5x + 2x Link couldn\'t be created. Please try again Link couldn\'t be revoked. Please try again New guests will not be able to join with this link. Current guests will still have access. diff --git a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt index d761d47ca4c..4f6dc7d8b73 100644 --- a/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt +++ b/app/src/test/kotlin/com/wire/android/media/ConversationAudioMessagePlayerTest.kt @@ -19,11 +19,14 @@ package com.wire.android.media import android.content.Context import android.media.MediaPlayer +import android.media.PlaybackParams import app.cash.turbine.TurbineTestContext import app.cash.turbine.test import com.wire.android.framework.FakeKaliumFileSystem import com.wire.android.media.audiomessage.AudioMediaPlayingState +import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState +import com.wire.android.media.audiomessage.AudioWavesMaskHelper import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer import com.wire.kalium.logic.CoreLogic import com.wire.kalium.logic.data.auth.AccountInfo @@ -38,6 +41,8 @@ import io.mockk.impl.annotations.MockK import io.mockk.verify import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.test.runTest +import okio.Path +import org.amshove.kluent.internal.assertEquals import org.junit.jupiter.api.Test @Suppress("LongMethod") @@ -71,6 +76,11 @@ class ConversationAudioMessagePlayerTest { assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } + awaitAndAssertStateUpdate { state -> + val currentState = state[testAudioMessageId] + assert(currentState != null) + assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + } awaitAndAssertStateUpdate { state -> val currentState = state[testAudioMessageId] assert(currentState != null) @@ -127,6 +137,11 @@ class ConversationAudioMessagePlayerTest { assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } + awaitAndAssertStateUpdate { state -> + val currentState = state[testAudioMessageId] + assert(currentState != null) + assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + } awaitAndAssertStateUpdate { state -> val currentState = state[testAudioMessageId] assert(currentState != null) @@ -196,6 +211,11 @@ class ConversationAudioMessagePlayerTest { assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } + awaitAndAssertStateUpdate { state -> + val currentState = state[firstAudioMessageId] + assert(currentState != null) + assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + } awaitAndAssertStateUpdate { state -> val currentState = state[firstAudioMessageId] assert(currentState != null) @@ -231,6 +251,11 @@ class ConversationAudioMessagePlayerTest { assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } + awaitAndAssertStateUpdate { state -> + val currentState = state[firstAudioMessageId] + assert(currentState != null) + assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + } awaitAndAssertStateUpdate { state -> val currentState = state[secondAudioMessageId] assert(currentState != null) @@ -280,6 +305,11 @@ class ConversationAudioMessagePlayerTest { assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } + awaitAndAssertStateUpdate { state -> + val currentState = state[firstAudioMessageId] + assert(currentState != null) + assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + } awaitAndAssertStateUpdate { state -> val currentState = state[firstAudioMessageId] assert(currentState != null) @@ -317,6 +347,11 @@ class ConversationAudioMessagePlayerTest { assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } + awaitAndAssertStateUpdate { state -> + val currentState = state[firstAudioMessageId] + assert(currentState != null) + assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + } awaitAndAssertStateUpdate { state -> val currentState = state[secondAudioMessageId] assert(currentState != null) @@ -351,6 +386,11 @@ class ConversationAudioMessagePlayerTest { assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } + awaitAndAssertStateUpdate { state -> + val currentState = state[firstAudioMessageId] + assert(currentState != null) + assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + } awaitAndAssertStateUpdate { state -> val currentState = state[firstAudioMessageId] assert(currentState != null) @@ -407,6 +447,11 @@ class ConversationAudioMessagePlayerTest { assert(currentState != null) assert(currentState!!.audioMediaPlayingState is AudioMediaPlayingState.SuccessfulFetching) } + awaitAndAssertStateUpdate { state -> + val currentState = state[testAudioMessageId] + assert(currentState != null) + assertEquals(currentState!!.wavesMask, Arrangement.WAVES_MASK) + } awaitAndAssertStateUpdate { state -> val currentState = state[testAudioMessageId] assert(currentState != null) @@ -458,6 +503,22 @@ class ConversationAudioMessagePlayerTest { } } + @Test + fun givenTheSuccessFullAssetFetch_whenAudioSpeedChanged_thenMediaPlayerParamsWereUpdated() = runTest { + val params = PlaybackParams() + val (arrangement, conversationAudioMessagePlayer) = Arrangement() + .withSuccessFullAssetFetch() + .withCurrentSession() + .withAudioMediaPlayerReturningParams(params) + .arrange() + + // when + conversationAudioMessagePlayer.setSpeed(AudioSpeed.MAX) + + // then + verify(exactly = 1) { arrangement.mediaPlayer.playbackParams = params.setSpeed(2F) } + } + private suspend fun TurbineTestContext.awaitAndAssertStateUpdate(assertion: (T) -> Unit) { val state = awaitItem() assert(state != null) @@ -477,16 +538,23 @@ class Arrangement { @MockK lateinit var mediaPlayer: MediaPlayer + @MockK + lateinit var wavesMaskHelper: AudioWavesMaskHelper + private val conversationAudioMessagePlayer by lazy { ConversationAudioMessagePlayer( context, mediaPlayer, + wavesMaskHelper, coreLogic, ) } init { MockKAnnotations.init(this, relaxed = true) + + every { wavesMaskHelper.getWaveMask(any()) } returns WAVES_MASK + every { wavesMaskHelper.clear() } returns Unit } fun withCurrentSession() = apply { @@ -519,5 +587,13 @@ class Arrangement { every { mediaPlayer.duration } returns total } + fun withAudioMediaPlayerReturningParams(params: PlaybackParams = PlaybackParams()) = apply { + every { mediaPlayer.playbackParams } returns params + } + fun arrange() = this to conversationAudioMessagePlayer + + companion object { + val WAVES_MASK = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 0) + } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt index 231254312a6..c3d49835c84 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/composer/MessageComposerViewModelArrangement.kt @@ -35,6 +35,7 @@ import com.wire.android.ui.home.conversations.model.MessageSource import com.wire.android.ui.home.conversations.model.MessageStatus import com.wire.android.ui.home.conversations.model.MessageTime import com.wire.android.ui.home.conversations.model.UIMessage +import com.wire.android.ui.home.conversations.model.UIMessageContent import com.wire.android.ui.navArgs import com.wire.android.util.FileManager import com.wire.android.util.ui.UIText @@ -45,6 +46,7 @@ import com.wire.kalium.logic.data.conversation.ConversationDetails import com.wire.kalium.logic.data.conversation.InteractionAvailability import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.sync.SyncState +import com.wire.kalium.logic.data.user.AssetId import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.OtherUser import com.wire.kalium.logic.data.user.UserAssetId @@ -234,3 +236,26 @@ internal fun mockUITextMessage(id: String = "someId", userName: String = "mockUs every { it.messageContent } returns null } } + +internal fun mockUIAudioMessage(id: String = "someId", userName: String = "mockUserName"): UIMessage { + return mockk().also { + every { it.userAvatarData } returns UserAvatarData() + every { it.source } returns MessageSource.OtherUser + every { it.header } returns mockk().also { + every { it.messageId } returns id + every { it.username } returns UIText.DynamicString(userName) + every { it.showLegalHoldIndicator } returns false + every { it.messageTime } returns MessageTime(Instant.DISTANT_PAST) + every { it.messageStatus } returns MessageStatus( + flowStatus = MessageFlowStatus.Sent, + expirationStatus = ExpirationStatus.NotExpirable + ) + } + every { it.messageContent } returns UIMessageContent.AudioAssetMessage( + "assert_name", + ".mp4", + AssetId("value", "domain"), + 1000L + ) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt index bc48d1c4fe4..01f5cb4d252 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelArrangement.kt @@ -22,6 +22,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.paging.PagingData import com.wire.android.config.TestDispatcherProvider import com.wire.android.config.mockUri +import com.wire.android.media.audiomessage.AudioSpeed import com.wire.android.media.audiomessage.AudioState import com.wire.android.media.audiomessage.ConversationAudioMessagePlayer import com.wire.android.media.audiomessage.ConversationAudioMessagePlayerProvider @@ -46,6 +47,7 @@ import com.wire.kalium.logic.feature.conversation.ObserveConversationDetailsUseC import com.wire.kalium.logic.feature.message.DeleteMessageUseCase import com.wire.kalium.logic.feature.message.GetMessageByIdUseCase import com.wire.kalium.logic.feature.message.GetSearchedConversationMessagePositionUseCase +import com.wire.kalium.logic.feature.message.GetSenderNameByMessageIdUseCase import com.wire.kalium.logic.feature.message.ToggleReactionUseCase import com.wire.kalium.logic.feature.sessionreset.ResetSessionResult import com.wire.kalium.logic.feature.sessionreset.ResetSessionUseCase @@ -115,6 +117,9 @@ class ConversationMessagesViewModelArrangement { @MockK lateinit var deleteMessage: DeleteMessageUseCase + @MockK + lateinit var getSenderNameByMessageId: GetSenderNameByMessageIdUseCase + private val viewModel: ConversationMessagesViewModel by lazy { ConversationMessagesViewModel( savedStateHandle, @@ -132,7 +137,8 @@ class ConversationMessagesViewModelArrangement { getConversationUnreadEventsCount, clearUsersTypingEvents, getSearchedConversationMessagePosition, - deleteMessage + deleteMessage, + getSenderNameByMessageId ) } @@ -154,6 +160,10 @@ class ConversationMessagesViewModelArrangement { } returns GetSearchedConversationMessagePositionUseCase.Result.Success(position = 0) coEvery { observeAssetStatuses(any()) } returns flowOf(mapOf()) + + coEvery { conversationAudioMessagePlayer.audioSpeed } returns flowOf(AudioSpeed.NORMAL) + coEvery { conversationAudioMessagePlayer.fetchWavesMask(any(), any()) } returns Unit + coEvery { getSenderNameByMessageId(any(), any()) } returns GetSenderNameByMessageIdUseCase.Result.Success("User Name") } fun withSuccessfulViewModelInit() = apply { @@ -227,5 +237,9 @@ class ConversationMessagesViewModelArrangement { return this } + fun withGetSenderNameByMessageId(result: GetSenderNameByMessageIdUseCase.Result) = apply { + coEvery { getSenderNameByMessageId(any(), any()) } returns result + } + fun arrange() = this to viewModel } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt index 157d0d0196e..18f19efd891 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/ConversationMessagesViewModelTest.kt @@ -20,23 +20,33 @@ package com.wire.android.ui.home.conversations.messages import androidx.paging.PagingData import androidx.paging.map +import androidx.paging.testing.asSnapshot import app.cash.turbine.test import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.NavigationTestExtension import com.wire.android.framework.TestMessage import com.wire.android.framework.TestMessage.GENERIC_ASSET_CONTENT +import com.wire.android.media.audiomessage.AudioMediaPlayingState +import com.wire.android.media.audiomessage.AudioSpeed +import com.wire.android.media.audiomessage.AudioState import com.wire.android.ui.home.conversations.ConversationSnackbarMessages +import com.wire.android.ui.home.conversations.composer.mockUIAudioMessage +import com.wire.android.ui.home.conversations.composer.mockUITextMessage import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogActiveState import com.wire.android.ui.home.conversations.delete.DeleteMessageDialogsState -import com.wire.android.ui.home.conversations.composer.mockUITextMessage +import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.StorageFailure import com.wire.kalium.logic.data.message.MessageContent import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.conversation.GetConversationUnreadEventsCountUseCase +import com.wire.kalium.logic.feature.message.GetSenderNameByMessageIdUseCase import io.mockk.coVerify import io.mockk.verify +import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import okio.Path.Companion.toPath import org.amshove.kluent.internal.assertEquals @@ -53,7 +63,7 @@ class ConversationMessagesViewModelTest { fun `given an message ID, when downloading or fetching into internal storage, then should get message details by ID`() = runTest { val message = TestMessage.ASSET_MESSAGE val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() - .withObservableAudioMessagesState(flowOf()) + .withSuccessfulViewModelInit() .withGetMessageAssetUseCaseReturning("path".toPath(), 42L) .withGetMessageByIdReturning(message) .arrange() @@ -77,7 +87,7 @@ class ConversationMessagesViewModelTest { ) val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() .withGetMessageByIdReturning(message) - .withObservableAudioMessagesState(flowOf()) + .withSuccessfulViewModelInit() .withGetMessageAssetUseCaseReturning(assetDataPath, assetSize) .withSuccessfulOpenAssetMessage(assetMimeType, assetName, assetDataPath, assetSize, messageId) .arrange() @@ -105,7 +115,7 @@ class ConversationMessagesViewModelTest { content = MessageContent.Asset(GENERIC_ASSET_CONTENT.copy(name = assetName, mimeType = mimeType, sizeInBytes = assetSize)) ) val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() - .withObservableAudioMessagesState(flowOf()) + .withSuccessfulViewModelInit() .withGetMessageByIdReturning(message) .withGetMessageAssetUseCaseReturning(dataPath, assetSize) .withSuccessfulSaveAssetMessage(mimeType, assetName, dataPath, assetSize, messageId) @@ -129,7 +139,7 @@ class ConversationMessagesViewModelTest { val updatedPagingData = PagingData.from(listOf(secondMessage)) val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() - .withObservableAudioMessagesState(flowOf()) + .withSuccessfulViewModelInit() .arrange() viewModel.conversationViewState.messages.test { @@ -143,7 +153,7 @@ class ConversationMessagesViewModelTest { @Test fun `given a message and a reaction, when toggleReaction is called, then should call ToggleReactionUseCase`() = runTest { val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() - .withObservableAudioMessagesState(flowOf()) + .withSuccessfulViewModelInit() .arrange() val messageId = "mID" @@ -160,7 +170,7 @@ class ConversationMessagesViewModelTest { fun `given getting UnreadEventsCount failed, then messages requested anyway`() = runTest { val (arrangement, _) = ConversationMessagesViewModelArrangement() .withConversationUnreadEventsCount(GetConversationUnreadEventsCountUseCase.Result.Failure(StorageFailure.DataNotFound)) - .withObservableAudioMessagesState(flowOf()) + .withSuccessfulViewModelInit() .arrange() coVerify(exactly = 1) { arrangement.getMessagesForConversationUseCase(any(), 0) } @@ -170,7 +180,7 @@ class ConversationMessagesViewModelTest { fun `given getting UnreadEventsCount succeed, then messages requested with corresponding lastReadIndex`() = runTest { val (arrangement, _) = ConversationMessagesViewModelArrangement() .withConversationUnreadEventsCount(GetConversationUnreadEventsCountUseCase.Result.Success(12)) - .withObservableAudioMessagesState(flowOf()) + .withSuccessfulViewModelInit() .arrange() coVerify(exactly = 1) { arrangement.getMessagesForConversationUseCase(any(), 12) } @@ -181,7 +191,7 @@ class ConversationMessagesViewModelTest { val userId = UserId("someID", "someDomain") val clientId = "someClientId" val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() - .withObservableAudioMessagesState(flowOf()) + .withSuccessfulViewModelInit() .withResetSessionResult() .arrange() @@ -298,4 +308,121 @@ class ConversationMessagesViewModelTest { ) assertEquals(expectedState, viewModel.deleteMessageDialogsState) } + + @Test + fun `given the AudioMessage in list, when getting paging flow, then fetching the waveMask for AudioMessage is called`() = runTest { + // Given + val firstMessage = mockUITextMessage(id = "firstId") + val secondMessage = mockUIAudioMessage(id = "secondId") + val pagingData = PagingData.from(listOf(firstMessage, secondMessage)) + + val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() + .withSuccessfulViewModelInit() + .withPaginatedMessagesReturning(pagingData) + .arrange() + + val job = launch { viewModel.conversationViewState.messages.asSnapshot() } + job.start() + advanceUntilIdle() + + coVerify(exactly = 1) { arrangement.conversationAudioMessagePlayer.fetchWavesMask(any(), any()) } + + job.cancel() + } + + @Test + fun `given an message ID, when some Audio is played, then should get message sender name by message ID`() = runTest { + val message = TestMessage.ASSET_MESSAGE + val audioState = AudioState.DEFAULT.copy( + audioMediaPlayingState = AudioMediaPlayingState.Playing, + totalTimeInMs = AudioState.TotalTimeInMs.Known(10000), + currentPositionInMs = 300 + ) + val userName = "some name" + val expectedAudioMessagesState = AudioMessagesState( + audioStates = persistentMapOf(message.id to audioState), + audioSpeed = AudioSpeed.NORMAL, + playingAudiMessage = PlayingAudiMessage( + messageId = message.id, + authorName = userName, + currentTimeMs = audioState.currentPositionInMs + ) + ) + val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() + .withSuccessfulViewModelInit() + .withGetSenderNameByMessageId(GetSenderNameByMessageIdUseCase.Result.Success(userName)) + .withObservableAudioMessagesState( + flowOf( + mapOf( + message.id to audioState.copy(currentPositionInMs = 100), + message.id to audioState + ) + ) + ) + .arrange() + + advanceUntilIdle() + + coVerify(exactly = 1) { arrangement.getSenderNameByMessageId(arrangement.conversationId, message.id) } + assertEquals(expectedAudioMessagesState, viewModel.conversationViewState.audioMessagesState) + } + + @Test + fun `given an message ID, when getSenderNameByMessageId fails, then senderName in PlayingAudiMessage is empty`() = runTest { + val message = TestMessage.ASSET_MESSAGE + val audioState = AudioState.DEFAULT.copy( + audioMediaPlayingState = AudioMediaPlayingState.Playing, + totalTimeInMs = AudioState.TotalTimeInMs.Known(10000), + currentPositionInMs = 300 + ) + val expectedAudioMessagesState = AudioMessagesState( + audioStates = persistentMapOf(message.id to audioState), + audioSpeed = AudioSpeed.NORMAL, + playingAudiMessage = PlayingAudiMessage( + messageId = message.id, + authorName = "", + currentTimeMs = audioState.currentPositionInMs + ) + ) + val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() + .withSuccessfulViewModelInit() + .withGetSenderNameByMessageId(GetSenderNameByMessageIdUseCase.Result.Failure(CoreFailure.Unknown(null))) + .withObservableAudioMessagesState( + flowOf( + mapOf( + message.id to audioState.copy(currentPositionInMs = 100), + message.id to audioState + ) + ) + ) + .arrange() + + advanceUntilIdle() + + assertEquals(expectedAudioMessagesState, viewModel.conversationViewState.audioMessagesState) + } + + @Test + fun `given an message ID, when no playing Audio message, then PlayingAudiMessage is null`() = runTest { + val message = TestMessage.ASSET_MESSAGE + val audioState = AudioState.DEFAULT.copy( + audioMediaPlayingState = AudioMediaPlayingState.Stopped, + totalTimeInMs = AudioState.TotalTimeInMs.Known(10000), + currentPositionInMs = 300 + ) + val expectedAudioMessagesState = AudioMessagesState( + audioStates = persistentMapOf(message.id to audioState), + audioSpeed = AudioSpeed.NORMAL, + playingAudiMessage = null + ) + val (arrangement, viewModel) = ConversationMessagesViewModelArrangement() + .withSuccessfulViewModelInit() + .withObservableAudioMessagesState(flowOf(mapOf(message.id to audioState))) + .arrange() + + advanceUntilIdle() + + coVerify(exactly = 0) { arrangement.getSenderNameByMessageId(arrangement.conversationId, message.id) } + assertEquals(expectedAudioMessagesState, viewModel.conversationViewState.audioMessagesState) + } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModelTest.kt index a8a9c1ee02c..e4cf7d8190b 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/recordaudio/RecordAudioViewModelTest.kt @@ -24,6 +24,7 @@ import com.wire.android.config.TestDispatcherProvider import com.wire.android.datastore.GlobalDataStore import com.wire.android.framework.FakeKaliumFileSystem import com.wire.android.media.audiomessage.AudioState +import com.wire.android.media.audiomessage.AudioWavesMaskHelper import com.wire.android.media.audiomessage.RecordAudioMessagePlayer import com.wire.android.ui.home.messagecomposer.recordaudio.RecordAudioViewModelTest.Arrangement.Companion.ASSET_SIZE_LIMIT import com.wire.android.util.CurrentScreen @@ -45,9 +46,11 @@ import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest +import okio.Path import org.amshove.kluent.internal.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import java.io.File @ExtendWith(CoroutineTestExtension::class) class RecordAudioViewModelTest { @@ -356,6 +359,7 @@ class RecordAudioViewModelTest { val context = mockk() val dispatchers = TestDispatcherProvider() val fakeKaliumFileSystem = FakeKaliumFileSystem() + val audioWavesMaskHelper = mockk() val viewModel by lazy { RecordAudioViewModel( @@ -368,6 +372,7 @@ class RecordAudioViewModelTest { generateAudioFileWithEffects = generateAudioFileWithEffects, globalDataStore = globalDataStore, dispatchers = dispatchers, + audioWavesMaskHelper = audioWavesMaskHelper, kaliumFileSystem = fakeKaliumFileSystem ) } @@ -401,6 +406,9 @@ class RecordAudioViewModelTest { coEvery { recordAudioMessagePlayer.close() } returns Unit coEvery { observeEstablishedCalls() } returns flowOf(listOf()) + + every { audioWavesMaskHelper.getWaveMask(any()) } returns listOf() + every { audioWavesMaskHelper.getWaveMask(any()) } returns listOf() } fun withEstablishedCall() = apply { diff --git a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt index d88a5b59776..fdf99a843c7 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt @@ -362,7 +362,7 @@ private val DefaultPhonePortraitWireDimensions: WireDimensions = WireDimensions( messageItemHorizontalPadding = 12.dp, conversationOptionsItemMinHeight = 57.dp, ongoingCallLabelHeight = 28.dp, - audioMessageHeight = 48.dp, + audioMessageHeight = 68.dp, importedMediaAssetSize = 120.dp, typingIndicatorHeight = 24.dp, legalHoldBannerMinHeight = 26.dp, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6dbdf6c2991..866ab3393b9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,6 +44,7 @@ androidx-biometric = "1.1.0" androidx-startup = "1.2.0" androidx-compose-runtime = "1.7.2" compose-qr = "1.0.1" +amplituda = "2.2.2" # Compose composeBom = "2024.11.00" @@ -250,6 +251,8 @@ countly-sdk = { module = "ly.count.android:sdk", version.ref = "countly" } # QRs compose-qr-code = { module = "com.lightspark:compose-qr-code", version.ref = "compose-qr" } +audio-amplituda = { module = "com.github.lincollincol:amplituda", version.ref = "amplituda" } + # Dev tools aboutLibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } aboutLibraries-ui = { module = "com.mikepenz:aboutlibraries-compose", version.ref = "aboutLibraries" } diff --git a/kalium b/kalium index d168aa2c17c..6ddef736ac6 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit d168aa2c17c460263910294b80f9ca402baaf4cb +Subproject commit 6ddef736ac6399a4e849daa618bc941591e859ea From 8bf8c7ffe2855d984c7b63667c7c99e352857478 Mon Sep 17 00:00:00 2001 From: AndroidBob Date: Mon, 30 Dec 2024 14:18:28 +0100 Subject: [PATCH 23/23] chore(l10n): update localization strings via Crowdin (WPB-9776) (#3739) Co-authored-by: yamilmedina --- app/src/main/res/values-de/strings.xml | 14 ++++++++++++++ app/src/main/res/values-hu/strings.xml | 18 +++++++++++++++++- app/src/main/res/values-ru/strings.xml | 24 ++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index e9e81df190e..c0c13ad05ed 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -189,6 +189,7 @@ Teamname, %s Verfügbarkeitsstatus ändern Erstellen Sie ein neues Team oder ein privates Konto oder melden Sie sich an + Team verwalten Zurück zu Ihrer Profilübersicht Ansicht zur Team-Erstellung und Anmeldung schließen Zurück zur Ansicht zur Team-Erstellung und Anmeldung @@ -528,6 +529,7 @@ Abwesend Kein Status Neues Team oder Konto + Team verwalten Details Geräte Gruppe @@ -579,8 +581,10 @@ Gruppe verlassen Gruppe löschen + Ordner Gruppen 1:1 Unterhaltungen + Ordner Alles Sie erhalten alle Benachrichtigungen für diese Unterhaltung, einschließlich Audio- und Videoanrufe @@ -879,6 +883,14 @@ Zum Zurückgehen doppeltippen Funktion nicht verfügbar Die Option, eine Telefonkonferenz zu starten, ist nur in der kostenpflichtigen Version verfügbar. + Um eine Telefonkonferenz zu starten, muss Ihr Team auf das Enterprise-Abo upgraden. + Auf Enterprise upgraden + Ihr Team nutzt derzeit das kostenlose Basis-Abo. Upgraden Sie auf Enterprise für den Zugriff auf weitere Funktionen wie das Starten von Telefonkonferenzen. + Erfahren Sie mehr über Wires Preise + Jetzt upgraden + Wire Enterprise + Ihr Team nutzt jetzt Wire Enterprise. Dadurch haben Sie Zugriff auf Funktionen wie beispielsweise Telefonkonferenzen. + Erfahren Sie mehr über das Enterprise-Abo Verbinden… Anruf starten Sind Sie sicher, dass Sie %1$s Personen anrufen möchten? @@ -1092,6 +1104,8 @@ Bei Gruppenunterhaltungen kann der Gruppen-Admin diese Einstellung überschreibe Anzahl der Schlüssel-Pakete MLS Client-ID Verbinden Sie sich mit anderen oder erstellen Sie eine neue Gruppe, um zu kommunizieren! + Fügen Sie Ihre Unterhaltungen Ordnern hinzu, um organisiert zu bleiben. + Hinzufügen einer Unterhaltung zu einem Ordner Willkommen 👋 Keine Unterhaltungen gefunden. Mit neuen Personen verbinden oder eine neue Unterhaltung beginnen diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 57ed2e7001d..e6c365ce3af 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -233,6 +233,7 @@ Csapatnév, %s elérhetőségi állapot megváltoztatása Hozzon létre új csapatot vagy személyes fiókot vagy jelentkezzen be + Csapat kezelése Vissza a saját profil áttekintéséhez Az új csapat létrehozása és bejelentkezés lap bezárása Vissza az új csapat létrehozása és bejelentkezés lapra @@ -574,6 +575,7 @@ Nincs a gépnél Semmi Új csapat, vagy fiók hozzáadása + Csapat kezelése Részletek Eszközök Csoport @@ -628,8 +630,10 @@ Beszélgetések szűrése Az összes beszélgetés Kedvencek + Mappák Csoportok Kétszemélyes beszélgetések + Mappák Minden Értesítsen ezen beszélgetéssel kapcsolatban mindenről (hang- és videóhívásokat is beleértve) @@ -940,6 +944,14 @@ Visszalépéshez koppintson duplán Ez a funkció nem érhető el Konferenciahívás kezdeményezése lehetőség csak a Wire fizetős verziójában elérhető. + Konferenciahívás indításához a csapatának Vállalati szintű előfizetésre kell váltania. + Vállalati szintre váltás + A csapata jelenleg az ingyenes Alap csomagot használja. Lépjen Vállalati szintre, hogy hozzáférjenek olyan lehetőségekhez, mint egyebek közt a konferenciahívás indítása. + Tudjon meg többet a Wire árairól + Váltás magasabb csomagra + Wire Vállalati szint + Csapata Vállalati szintre lépett, ami hozzáférést biztosít olyan lehetőségekhez, mint egyebek közt a konferenciahívások. + Tudjon meg többet a Wire Vállalati szintről Csatlakozás… Hívás indítása Biztosan hívni akarja ő(ke)t: %1$s? @@ -1134,6 +1146,8 @@ Ez a beállítás az összes beszélgetésre érvényes ezen az eszközön.Honlap URL: Kiszolgáló WSURL: Hiba történt + A helyi kiszolgálóra történő átirányítás nem volt lehetséges, úgy tűnik, nem kapcsolódik az internethez.\n\nKapcsolódjon az internetre, és próbálja meg újra. + Próbálja meg újra Új üzenetek lekérdezése Szöveg a vágólapra másolva Naplók @@ -1163,6 +1177,8 @@ Ez a beállítás az összes beszélgetésre érvényes ezen az eszközön.Ön még nem résztvevője csoportos beszélgetésnek.\nKezdjen el egyet! Önnek még nincsenek partnerei.\nKeressen személyeket %1$s tartományában és kapcsolódjon velük. Hogyan jelölhet meg kedvencként beszélgetéseket + A rendezettség érdekében helyezze beszélgetéseit mappákba. + Hogyan helyezzen egy beszélgetést egy mappába Üdvözöljük 👋 Nem található beszélgetés. Lépjen kapcsolatba új felhasználókkal, vagy kezdjen új beszélgetésbe @@ -1559,7 +1575,7 @@ Kérjük, próbálja meg újra. Szervezzen videókonferenciákat akár 150 résztvevővel. Elérhetőségi állapot: Tudassa csapatával, hogy elérhető, elfoglalt vagy távol van. - Vállalati szintre lépés: + Vállalati szintre váltás: Jusson további funkciókhoz és kiemelt támogatáshoz. Tudjon meg többet a Wire csomagjairól Csapat neve diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 99ecf9a8a1b..8ffed8d6c5a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -141,6 +141,8 @@ Больше вариантов Добавить контакт Изображение + Приостановить аудио + Воспроизвести аудио Файл Пинг Установите таймер для самоудаления сообщений @@ -155,6 +157,8 @@ Отключить камеру Включить динамик Выключить динамик + Показать в панели реакций вызова + Скрыть в панели реакций вызова Показать больше вариантов Ответить на сообщение Отменить ответ на сообщение @@ -235,6 +239,7 @@ Название команды, %s изменить статус доступности Создать новую команду или личный аккаунт или авторизоваться + Управлять командой Вернуться к обзору вашего профиля Закрыть просмотр создания новой команды и авторизации Вернуться к просмотру создания новой команды и авторизации @@ -578,6 +583,7 @@ Отошел Отключен Новая команда или аккаунт + Управлять командой Подробности Устройства Группа @@ -632,8 +638,10 @@ Отфильтровать беседы Все беседы Избранное + Папки Группы Беседы 1:1 + Папки Все Получать уведомления в этой беседе обо всем (включая аудио- и видеозвонки) @@ -975,6 +983,14 @@ Двойное нажатие для возврата назад Возможность недоступна Возможность инициировать групповой вызов доступна только в платной версии Wire. + Чтобы выполнить групповой вызов вашей команде необходимо перейти на план Enterprise. + Перейти на Enterprise + В настоящее время ваша команда использует бесплатный тарифный план Basic. Перейдите на тарифный план Enterprise, чтобы получить доступ к таким возможностям, как проведение конференций и многим другим. + Узнайте больше о тарифах Wire + Перейти сейчас + Wire Enterprise + Ваша команда перешла на тариф Wire Enterprise, который дает вам доступ к таким возможностям, как конференц-связь и многое другое. + Узнайте больше о Wire Enterprise Подключение… Начать звонок Вы действительно хотите позвонить %1$s пользователям? @@ -1172,6 +1188,8 @@ URL веб-сайта: WSURL бэкэнда: Произошла ошибка + Перенаправление на локальный бэкэнд не удалось, вероятно, вы не подключены к интернету.\n\nПодключитесь к интернету и повторите попытку. + Повторить Получение новых сообщений Текст скопирован в буфер обмена Журналы @@ -1201,6 +1219,8 @@ Вы еще не участвуете ни в одной групповой беседе.\nНачните новую! У вас пока нет контактов.\nИщите друзей в %1$s и общайтесь. Как помечать беседы как избранные + Добавляйте беседы в папки, чтобы сохранять их упорядоченность. + Как добавить беседу в папку Добро пожаловать 👋 Беседы не найдены. Общайтесь с новыми пользователями или начните новую беседу @@ -1309,6 +1329,9 @@ Отозвать ссылку Аудио недоступно При загрузке этого аудиофайла что-то пошло не так. Пожалуйста, попросите отправителя отправить его снова + 1x + 1.5x + 2x Ссылка не может быть создана. Пожалуйста, попробуйте еще раз Ссылка не может быть отозвана. Пожалуйста, попробуйте еще раз Новые гости не смогут присоединиться по этой ссылке. На доступ текущих гостей это не повлияет. @@ -1644,4 +1667,5 @@ Вы создали или присоединились к команде с этим email на другом устройстве. Wire не удалось завершить создание вашей команды из-за медленного интернет-соединения. Wire не удалось завершить создание вашей команды из-за неизвестной ошибки. + Вы