diff --git a/.github/workflows/publish-test-results.yml b/.github/workflows/publish-test-results.yml index 6d2b3e3de0d..baaadb526b9 100644 --- a/.github/workflows/publish-test-results.yml +++ b/.github/workflows/publish-test-results.yml @@ -65,7 +65,7 @@ jobs: PR_NUMBER=$(jq --raw-output .pull_request.number "$EVENT_FILE_PATH") gh pr comment "$PR_NUMBER" --body "APKs built during tests are available [here]($CHECKS_LINK). Scroll down to **Artifacts**!" - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 18 - name: Install datadog-ci diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 897d7058766..ade3fe8381a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -49,6 +49,8 @@ + + @@ -58,6 +60,10 @@ android:glEsVersion="0x00020000" android:required="false" /> + + UILastMessageContent.None + MessagePreviewContent.VerificationChanged.VerifiedMls -> + UILastMessageContent.VerificationChanged(R.string.last_message_verified_conversation_mls) + MessagePreviewContent.VerificationChanged.VerifiedProteus -> + UILastMessageContent.VerificationChanged(R.string.last_message_verified_conversation_proteus) + MessagePreviewContent.VerificationChanged.DegradedMls -> + UILastMessageContent.VerificationChanged(R.string.last_message_conversations_verification_degraded_mls) + MessagePreviewContent.VerificationChanged.DegradedProteus -> + UILastMessageContent.VerificationChanged(R.string.last_message_conversations_verification_degraded_proteus) Unknown -> UILastMessageContent.None } } diff --git a/app/src/main/kotlin/com/wire/android/mapper/SystemMessageContentMapper.kt b/app/src/main/kotlin/com/wire/android/mapper/SystemMessageContentMapper.kt index a85a0425084..1ce361e681b 100644 --- a/app/src/main/kotlin/com/wire/android/mapper/SystemMessageContentMapper.kt +++ b/app/src/main/kotlin/com/wire/android/mapper/SystemMessageContentMapper.kt @@ -70,6 +70,7 @@ class SystemMessageContentMapper @Inject constructor( is MessageContent.ConversationVerifiedProteus -> mapConversationVerified(Conversation.Protocol.PROTEUS) is MessageContent.FederationStopped -> mapFederationMessage(content) is MessageContent.ConversationProtocolChanged -> mapConversationProtocolChanged(content) + is MessageContent.ConversationStartedUnverifiedWarning -> mapConversationCreatedUnverifiedWarning() } private fun mapConversationCreated(senderUserId: UserId, date: String, userList: List): UIMessageContent.SystemMessage { @@ -85,6 +86,10 @@ class SystemMessageContentMapper @Inject constructor( ) } + private fun mapConversationCreatedUnverifiedWarning(): UIMessageContent.SystemMessage { + return UIMessageContent.SystemMessage.ConversationMessageCreatedUnverifiedWarning + } + private fun mapConversationTimerChanged( senderUserId: UserId, content: MessageContent.ConversationMessageTimerChanged, @@ -246,12 +251,16 @@ class SystemMessageContentMapper @Inject constructor( private fun mapConversationHistoryLost(): UIMessageContent.SystemMessage = UIMessageContent.SystemMessage.HistoryLost + private fun mapMLSWrongEpochWarning(): UIMessageContent.SystemMessage = UIMessageContent.SystemMessage.MLSWrongEpochWarning() + private fun mapConversationHistoryListProtocolChanged(): UIMessageContent.SystemMessage = UIMessageContent.SystemMessage.HistoryLostProtocolChanged + private fun mapConversationDegraded(protocol: Conversation.Protocol): UIMessageContent.SystemMessage = UIMessageContent.SystemMessage.ConversationDegraded(protocol) + private fun mapConversationVerified(protocol: Conversation.Protocol): UIMessageContent.SystemMessage = UIMessageContent.SystemMessage.ConversationVerified(protocol) diff --git a/app/src/main/kotlin/com/wire/android/migration/MigrationMapper.kt b/app/src/main/kotlin/com/wire/android/migration/MigrationMapper.kt index 7803ed9edc1..72dbf33eb64 100644 --- a/app/src/main/kotlin/com/wire/android/migration/MigrationMapper.kt +++ b/app/src/main/kotlin/com/wire/android/migration/MigrationMapper.kt @@ -106,7 +106,8 @@ class MigrationMapper @Inject constructor() { userMessageTimer = null, archived = false, archivedDateTime = null, - verificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED ) } } 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 bea848e7b88..7e152e6b43f 100644 --- a/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt +++ b/app/src/main/kotlin/com/wire/android/navigation/HomeDestination.kt @@ -112,7 +112,7 @@ sealed class HomeDestination( private const val ITEM_NAME_PREFIX = "HomeNavigationItem." fun fromRoute(fullRoute: String): HomeDestination? = - values().find { it.direction.route.getPrimaryRoute() == fullRoute.getPrimaryRoute() } + values().find { it.direction.route.getBaseRoute() == fullRoute.getBaseRoute() } fun values(): Array = arrayOf(Conversations, Calls, Mentions, Settings, Vault, Archive, Support, WhatsNew) } 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 e4c28cce428..183aa352462 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 lastDestination() = currentBackStack.value.lastOrNull { it.route() is DestinationSpec<*> } fun lastNestedGraph() = lastDestination()?.takeIf { it.navGraph() != navGraph }?.navGraph() fun firstDestinationWithRoute(route: String) = - currentBackStack.value.firstOrNull { it.destination.route?.getPrimaryRoute() == route.getPrimaryRoute() } + currentBackStack.value.firstOrNull { it.destination.route?.getBaseRoute() == route.getBaseRoute() } fun lastDestinationFromOtherGraph(graph: NavGraphSpec) = currentBackStack.value.lastOrNull { it.navGraph() != graph } appLogger.d("[$TAG] -> command: ${command.destination.route.obfuscateId()}") @@ -104,17 +104,11 @@ private fun NavOptionsBuilder.popUpTo( internal fun NavDestination.toDestination(): Destination? = this.route?.let { currentRoute -> NavGraphs.root.destinationsByRoute[currentRoute] } -fun String.getPrimaryRoute(): String { - val splitByQuestion = this.split("?") - val splitBySlash = this.split("/") - - val primaryRoute = when { - splitByQuestion.size > 1 -> splitByQuestion[0] - splitBySlash.size > 1 -> splitBySlash[0] - else -> this +fun String.getBaseRoute(): String = + this.indexOfAny(listOf("?", "/")).let { + if (it != -1) this.substring(0, it) + else this } - return primaryRoute -} fun Direction.handleNavigation(context: Context, handleOtherDirection: (Direction) -> Unit) = when (this) { is ExternalUriDirection -> CustomTabsHelper.launchUri(context, this.uri) diff --git a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/DeviceItem.kt b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/DeviceItem.kt index 6f4a29d8361..adb0d6108da 100644 --- a/app/src/main/kotlin/com/wire/android/ui/authentication/devices/DeviceItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/authentication/devices/DeviceItem.kt @@ -183,7 +183,7 @@ private fun DeviceItemTexts( ) if (shouldShowVerifyLabel) { Spacer(modifier = Modifier.width(MaterialTheme.wireDimensions.spacing8x)) - if (device.isVerifiedProteus) ProteusVerifiedIcon(Modifier.wrapContentWidth()) + if (device.isVerifiedProteus) ProteusVerifiedIcon(Modifier.wrapContentWidth().align(Alignment.CenterVertically)) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/SearchBar.kt b/app/src/main/kotlin/com/wire/android/ui/common/SearchBar.kt index 4e3db7fa6ad..8ab97ef06a6 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/SearchBar.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/SearchBar.kt @@ -31,47 +31,20 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.wire.android.R import com.wire.android.ui.common.textfield.WireTextField +import com.wire.android.ui.theme.WireTheme import com.wire.android.ui.theme.wireColorScheme - -@Composable -fun SearchBar( - placeholderText: String, - onTextTyped: (TextFieldValue) -> Unit = {}, - modifier: Modifier = Modifier -) { - SearchBarInput( - placeholderText = placeholderText, - leadingIcon = - { - IconButton(onClick = { }) { - Icon( - painter = painterResource(id = R.drawable.ic_search), - contentDescription = stringResource(R.string.content_description_conversation_search_icon), - tint = MaterialTheme.wireColorScheme.onBackground - ) - } - }, - placeholderTextStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), - textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Start), - onTextTyped = onTextTyped, - modifier = modifier - ) -} +import com.wire.android.util.ui.PreviewMultipleThemes @Composable fun SearchBarInput( @@ -80,10 +53,12 @@ fun SearchBarInput( text: TextFieldValue = TextFieldValue(""), onTextTyped: (TextFieldValue) -> Unit = {}, placeholderTextStyle: TextStyle = LocalTextStyle.current, + placeholderAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, textStyle: TextStyle = LocalTextStyle.current, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { + WireTextField( modifier = modifier, value = text, @@ -112,14 +87,28 @@ fun SearchBarInput( interactionSource = interactionSource, textStyle = textStyle.copy(fontSize = 14.sp), placeholderTextStyle = placeholderTextStyle.copy(fontSize = 14.sp), + placeholderAlignment = placeholderAlignment, placeholderText = placeholderText, maxLines = 1, singleLine = true, ) } -@Preview(showBackground = true) +@PreviewMultipleThemes @Composable -fun PreviewSearchBarCollapsed() { - SearchBar("Search text") +fun PreviewSearchBarInput() { + WireTheme { + SearchBarInput( + placeholderText = "placeholder", + leadingIcon = { + IconButton(onClick = { }) { + Icon( + painter = painterResource(id = R.drawable.ic_search), + contentDescription = stringResource(R.string.content_description_conversation_search_icon), + tint = MaterialTheme.wireColorScheme.onBackground + ) + } + }, + ) + } } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/VerifiedIcons.kt b/app/src/main/kotlin/com/wire/android/ui/common/VerifiedIcons.kt index a79f3740bee..0834be5d5e8 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/VerifiedIcons.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/VerifiedIcons.kt @@ -17,6 +17,7 @@ */ package com.wire.android.ui.common +import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable @@ -26,10 +27,25 @@ import androidx.compose.ui.res.stringResource import com.wire.android.R @Composable -fun ProteusVerifiedIcon(modifier: Modifier = Modifier) { +fun ProteusVerifiedIcon( + modifier: Modifier = Modifier, + @StringRes contentDescriptionId: Int = R.string.label_client_verified +) { Image( modifier = modifier.padding(start = dimensions().spacing4x), painter = painterResource(id = R.drawable.ic_certificate_valid_proteus), - contentDescription = stringResource(R.string.label_client_verified) + contentDescription = stringResource(contentDescriptionId) + ) +} + +@Composable +fun MLSVerifiedIcon( + modifier: Modifier = Modifier, + @StringRes contentDescriptionId: Int = R.string.label_client_verified +) { + Image( + modifier = modifier.padding(start = dimensions().spacing4x), + painter = painterResource(id = R.drawable.ic_certificate_valid_mls), + contentDescription = stringResource(contentDescriptionId) ) } 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 306a0e7508e..227973328eb 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 @@ -125,7 +125,10 @@ data class ConversationSheetContent( val conversationTypeDetail: ConversationTypeDetail, val selfRole: Conversation.Member.Role?, val isTeamConversation: Boolean, - val isArchived: Boolean + val isArchived: Boolean, + val protocol: Conversation.ProtocolInfo, + val mlsVerificationStatus: Conversation.VerificationStatus, + val proteusVerificationStatus: Conversation.VerificationStatus ) { private val isSelfUserMember: Boolean get() = selfRole != null 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 648c1f4c62a..d3833edaf8c 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 @@ -76,10 +76,14 @@ fun rememberConversationSheetState( ), isTeamConversation = teamId != null, selfRole = selfMemberRole, - isArchived = conversationItem.isArchived + isArchived = conversationItem.isArchived, + protocol = Conversation.ProtocolInfo.Proteus, + mlsVerificationStatus = Conversation.VerificationStatus.VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.VERIFIED ) } } + is ConversationItem.PrivateConversation -> { with(conversationItem) { ConversationSheetContent( @@ -95,10 +99,14 @@ fun rememberConversationSheetState( ), isTeamConversation = isTeamConversation, selfRole = Conversation.Member.Role.Member, - isArchived = conversationItem.isArchived + isArchived = conversationItem.isArchived, + protocol = Conversation.ProtocolInfo.Proteus, + mlsVerificationStatus = Conversation.VerificationStatus.VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.VERIFIED ) } } + is ConversationItem.ConnectionConversation -> { with(conversationItem) { ConversationSheetContent( @@ -110,7 +118,10 @@ fun rememberConversationSheetState( ), isTeamConversation = isTeamConversation, selfRole = Conversation.Member.Role.Member, - isArchived = conversationItem.isArchived + isArchived = conversationItem.isArchived, + protocol = Conversation.ProtocolInfo.Proteus, + mlsVerificationStatus = Conversation.VerificationStatus.VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.VERIFIED ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/HomeSheetContent.kt b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/HomeSheetContent.kt index 72fe53f7c48..fc7934570c2 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/HomeSheetContent.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/bottomsheet/conversation/HomeSheetContent.kt @@ -153,10 +153,11 @@ internal fun ConversationMainSheetContent( with(conversationSheetContent) { updateConversationArchiveStatus( DialogState( - conversationId, - title, - conversationTypeDetail, - isArchived + conversationId = conversationId, + conversationName = title, + conversationTypeDetail = conversationTypeDetail, + isArchived = isArchived, + isMember = conversationSheetContent.selfRole != null ) ) } @@ -174,10 +175,11 @@ internal fun ConversationMainSheetContent( onItemClick = { clearConversationContent( DialogState( - conversationSheetContent.conversationId, - conversationSheetContent.title, - conversationSheetContent.conversationTypeDetail, - conversationSheetContent.isArchived + conversationId = conversationSheetContent.conversationId, + conversationName = conversationSheetContent.title, + conversationTypeDetail = conversationSheetContent.conversationTypeDetail, + isArchived = conversationSheetContent.isArchived, + isMember = conversationSheetContent.selfRole != null ) ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt index c4e8fdfc872..8ec324031e3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/textfield/WireTextField.kt @@ -71,6 +71,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.wire.android.R import com.wire.android.ui.common.Icon @@ -100,6 +101,7 @@ internal fun WireTextField( visualTransformation: VisualTransformation = VisualTransformation.None, textStyle: TextStyle = MaterialTheme.wireTypography.body01, placeholderTextStyle: TextStyle = MaterialTheme.wireTypography.body01, + placeholderAlignment: Alignment.Horizontal = Alignment.Start, inputMinHeight: Dp = MaterialTheme.wireDimensions.textFieldMinHeight, shape: Shape = RoundedCornerShape(MaterialTheme.wireDimensions.textFieldCornerSize), colors: WireTextFieldColors = wireTextFieldColors(), @@ -162,6 +164,7 @@ internal fun WireTextField( placeholderText, state, placeholderTextStyle, + placeholderAlignment, inputMinHeight, colors, shouldDetectTaps, @@ -227,6 +230,7 @@ private fun InnerText( placeholderText: String? = null, state: WireTextFieldState = WireTextFieldState.Default, placeholderTextStyle: TextStyle = MaterialTheme.wireTypography.body01, + placeholderAlignment: Alignment.Horizontal = Alignment.Start, inputMinHeight: Dp = 48.dp, colors: WireTextFieldColors = wireTextFieldColors(), shouldDetectTaps: Boolean = false, @@ -254,26 +258,27 @@ private fun InnerText( Tint(contentColor = colors.iconColor(state).value, content = leadingIcon) } } - Box(Modifier.weight(1f)) { - val padding = Modifier.padding( - start = if (leadingIcon == null) 16.dp else 0.dp, - end = if (trailingOrStateIcon == null) 16.dp else 0.dp, - top = 2.dp, bottom = 2.dp - ) + + Box( + Modifier + .weight(1f) + .padding( + start = if (leadingIcon == null) 16.dp else 0.dp, + end = if (trailingOrStateIcon == null) 16.dp else 0.dp, + top = 2.dp, bottom = 2.dp + ) + ) { if (value.text.isEmpty() && placeholderText != null) { Text( text = placeholderText, style = placeholderTextStyle, color = colors.placeholderColor(state).value, modifier = Modifier - .fillMaxWidth() - .then(padding) + .align(placeholderAlignment.toAlignment()) ) } Box( - modifier = Modifier - .fillMaxWidth() - .then(padding), + modifier = Modifier.fillMaxWidth(), propagateMinConstraints = true ) { innerTextField() @@ -287,6 +292,10 @@ private fun InnerText( } } +private fun Alignment.Horizontal.toAlignment(): Alignment = Alignment { size, space, layoutDirection -> + IntOffset(this@toAlignment.align(size.width, space.width, layoutDirection), 0) +} + @Preview(name = "Default WireTextField") @Composable fun PreviewWireTextField() { diff --git a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/WireCenterAlignedTopAppBar.kt b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/WireCenterAlignedTopAppBar.kt index 93847b255d7..27ca4b07465 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/WireCenterAlignedTopAppBar.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/WireCenterAlignedTopAppBar.kt @@ -39,7 +39,6 @@ import com.wire.android.ui.common.dimensions import com.wire.android.ui.theme.wireDimensions import com.wire.android.ui.theme.wireTypography -@OptIn(ExperimentalMaterial3Api::class) @Composable fun WireCenterAlignedTopAppBar( title: String, diff --git a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/search/SearchBarState.kt b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/search/SearchBarState.kt index c28497c9025..f8484800564 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/search/SearchBarState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/search/SearchBarState.kt @@ -48,6 +48,7 @@ class SearchBarState( private set var searchQuery by mutableStateOf(searchQuery) + private set fun closeSearch() { isSearchActive = false @@ -57,6 +58,10 @@ class SearchBarState( isSearchActive = true } + fun searchActiveChanged(isSearchActive: Boolean) { + this.isSearchActive = isSearchActive + } + fun searchQueryChanged(searchQuery: TextFieldValue) { this.searchQuery = searchQuery } diff --git a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/search/SearchTopBar.kt b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/search/SearchTopBar.kt index 6c72e05c290..19e4d2d75d0 100644 --- a/app/src/main/kotlin/com/wire/android/ui/common/topappbar/search/SearchTopBar.kt +++ b/app/src/main/kotlin/com/wire/android/ui/common/topappbar/search/SearchTopBar.kt @@ -21,123 +21,171 @@ package com.wire.android.ui.common.topappbar.search +import android.annotation.SuppressLint import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import com.wire.android.R import com.wire.android.ui.common.SearchBarInput import com.wire.android.ui.common.dimensions +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.util.ui.PreviewMultipleThemes -@OptIn(ExperimentalAnimationApi::class) +@OptIn(ExperimentalComposeUiApi::class) @Composable fun SearchTopBar( + modifier: Modifier = Modifier, isSearchActive: Boolean, searchBarHint: String, searchQuery: TextFieldValue = TextFieldValue(""), onSearchQueryChanged: (TextFieldValue) -> Unit, - onInputClicked: () -> Unit, - onCloseSearchClicked: () -> Unit, + onCloseSearchClicked: (() -> Unit)? = null, + onActiveChanged: (isActive: Boolean) -> Unit = {}, bottomContent: @Composable ColumnScope.() -> Unit = {} ) { Column( - modifier = Modifier + modifier = modifier .wrapContentHeight() .fillMaxWidth() .background(MaterialTheme.wireColorScheme.background) ) { - val interactionSource = remember { - MutableInteractionSource() - } - - val focusManager = LocalFocusManager.current + val interactionSource = remember { MutableInteractionSource() } val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current - LaunchedEffect(isSearchActive) { - if (!isSearchActive) { + fun setActive(isActive: Boolean) { + if (isActive) { + focusRequester.requestFocus() + keyboardController?.show() + } else { focusManager.clearFocus() + keyboardController?.hide() onSearchQueryChanged(TextFieldValue("")) - } else { - focusRequester.requestFocus() } } - Box { - SearchBarInput( - placeholderText = searchBarHint, - text = searchQuery, - onTextTyped = onSearchQueryChanged, - leadingIcon = { - AnimatedContent(!isSearchActive, label = "") { isVisible -> - IconButton(onClick = { - if (!isVisible) { - onCloseSearchClicked() - } - }) { + LaunchedEffect(isSearchActive) { + setActive(isSearchActive) + } + + val placeholderAlignment by animateHorizontalAlignmentAsState( + targetAlignment = if (isSearchActive) Alignment.CenterStart else Alignment.Center + ) + + SearchBarInput( + placeholderText = searchBarHint, + text = searchQuery, + onTextTyped = onSearchQueryChanged, + leadingIcon = { + AnimatedContent(!isSearchActive, label = "") { isVisible -> + if (isVisible) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(dimensions().buttonCircleMinSize) + ) { Icon( - painter = painterResource( - id = if (isVisible) R.drawable.ic_search - else R.drawable.ic_arrow_left - ), + painter = painterResource(R.drawable.ic_search), contentDescription = stringResource(R.string.content_description_conversation_search_icon), - tint = MaterialTheme.wireColorScheme.onBackground + tint = MaterialTheme.wireColorScheme.onBackground, + ) + } + } else { + IconButton( + onClick = { onCloseSearchClicked?.invoke() ?: setActive(false) }, + modifier = Modifier.size(dimensions().buttonCircleMinSize) + ) { + Icon( + painter = rememberVectorPainter(image = Icons.Filled.ArrowBack), + contentDescription = stringResource(R.string.content_description_back_button), + tint = MaterialTheme.wireColorScheme.onBackground, ) } } - }, - placeholderTextStyle = textStyleAlignment(isTopBarVisible = !isSearchActive), - textStyle = textStyleAlignment(isTopBarVisible = !isSearchActive), - interactionSource = interactionSource, - modifier = Modifier - .padding(dimensions().spacing8x) - .focusRequester(focusRequester) - ) - // We added an invisible clickable box only present when the search is not active. - // That way we can still make the whole top bar clickable and intercept and discard the long press gestures. - if (!isSearchActive) { - Box( - modifier = Modifier - .matchParentSize() - .padding(dimensions().spacing8x) - .clip(RoundedCornerShape(MaterialTheme.wireDimensions.textFieldCornerSize)) - .clickable(onClick = onInputClicked) - ) - } - } + } + }, + placeholderTextStyle = LocalTextStyle.current.copy(textAlign = if (!isSearchActive) TextAlign.Center else TextAlign.Start), + placeholderAlignment = placeholderAlignment, + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Start), + interactionSource = interactionSource, + modifier = Modifier + .padding(dimensions().spacing8x) + .focusRequester(focusRequester) + .onFocusEvent { onActiveChanged(it.isFocused) } + ) bottomContent() } } +@SuppressLint("UnrememberedMutableState") @Composable -private fun textStyleAlignment(isTopBarVisible: Boolean): TextStyle { - return if (isTopBarVisible) LocalTextStyle.current.copy(textAlign = TextAlign.Center) else LocalTextStyle.current.copy( - textAlign = TextAlign.Start - ) +private fun animateHorizontalAlignmentAsState( + targetAlignment: Alignment, +): State { + val biased = targetAlignment as BiasAlignment + val bias by animateFloatAsState(biased.horizontalBias, label = "AnimateHorizontalAlignment") + return derivedStateOf { BiasAlignment.Horizontal(bias) } +} + +@PreviewMultipleThemes +@Composable +fun PreviewSearchTopBarActive() { + WireTheme { + SearchTopBar( + isSearchActive = true, + searchBarHint = "Search", + searchQuery = TextFieldValue(""), + onSearchQueryChanged = {}, + onActiveChanged = {}, + ) + } +} + +@PreviewMultipleThemes +@Composable +fun PreviewSearchTopBarInactive() { + WireTheme { + SearchTopBar( + isSearchActive = false, + searchBarHint = "Search", + searchQuery = TextFieldValue(""), + onSearchQueryChanged = {}, + onActiveChanged = {}, + ) + } } 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 411e95d7f94..5d8159ab260 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 @@ -260,8 +260,7 @@ fun HomeContent( searchBarHint = stringResource(R.string.search_bar_conversations_hint), searchQuery = searchBarState.searchQuery, onSearchQueryChanged = searchBarState::searchQueryChanged, - onInputClicked = searchBarState::openSearch, - onCloseSearchClicked = searchBarState::closeSearch, + onActiveChanged = searchBarState::searchActiveChanged, ) } }, 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 18caf570ff2..f674e3c55f3 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 @@ -39,6 +39,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -912,9 +913,17 @@ fun JumpToLastMessageButton( ) { SmallFloatingActionButton( onClick = { coroutineScope.launch { lazyListState.animateScrollToItem(0) } }, - containerColor = MaterialTheme.wireColorScheme.onSecondaryButtonDisabled, - contentColor = MaterialTheme.wireColorScheme.secondaryButtonDisabled, + containerColor = MaterialTheme.wireColorScheme.scrollToBottomButtonColor, + contentColor = MaterialTheme.wireColorScheme.onScrollToBottomButtonColor, shape = CircleShape, + elevation = FloatingActionButtonDefaults.elevation(dimensions().spacing0x), + modifier = Modifier + .padding( + PaddingValues( + bottom = dimensions().typingIndicatorHeight + dimensions().spacing8x, + end = dimensions().spacing16x + ) + ) ) { Icon( imageVector = Icons.Default.KeyboardArrowDown, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationTopAppBar.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationTopAppBar.kt index 3b048f551e4..1459ad286a9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationTopAppBar.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/ConversationTopAppBar.kt @@ -20,11 +20,11 @@ package com.wire.android.ui.home.conversations -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -48,6 +48,8 @@ import com.wire.android.R import com.wire.android.model.UserAvatarData import com.wire.android.ui.calling.controlbuttons.JoinButton import com.wire.android.ui.calling.controlbuttons.StartCallButton +import com.wire.android.ui.common.MLSVerifiedIcon +import com.wire.android.ui.common.ProteusVerifiedIcon import com.wire.android.ui.common.UserProfileAvatar import com.wire.android.ui.common.button.WireSecondaryIconButton import com.wire.android.ui.common.colorsScheme @@ -133,7 +135,11 @@ private fun ConversationScreenTopAppBarContent( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(weight = 1f, fill = false) ) - VerificationIcon(conversationInfoViewState.protocolInfo, conversationInfoViewState.verificationStatus) + VerificationIcons( + conversationInfoViewState.protocolInfo, + conversationInfoViewState.mlsVerificationStatus, + conversationInfoViewState.proteusVerificationStatus + ) if (isDropDownEnabled && isInteractionEnabled) { Icon( painter = painterResource(id = R.drawable.ic_dropdown_icon), @@ -181,21 +187,29 @@ private fun ConversationScreenTopAppBarContent( } @Composable -private fun VerificationIcon(protocolInfo: Conversation.ProtocolInfo?, verificationStatus: Conversation.VerificationStatus?) { - if (verificationStatus != Conversation.VerificationStatus.VERIFIED || protocolInfo == null) return - - val (iconId, contentDescriptionId) = when (protocolInfo) { - is Conversation.ProtocolInfo.MLS -> - R.drawable.ic_certificate_valid_mls to R.string.content_description_mls_certificate_valid +private fun RowScope.VerificationIcons( + protocolInfo: Conversation.ProtocolInfo?, + mlsVerificationStatus: Conversation.VerificationStatus?, + proteusVerificationStatus: Conversation.VerificationStatus? +) { + val mlsIcon: @Composable () -> Unit = { + if (mlsVerificationStatus == Conversation.VerificationStatus.VERIFIED) { + MLSVerifiedIcon(contentDescriptionId = R.string.content_description_mls_certificate_valid) + } + } + val proteusIcon: @Composable () -> Unit = { + if (proteusVerificationStatus == Conversation.VerificationStatus.VERIFIED) { + ProteusVerifiedIcon(contentDescriptionId = R.string.content_description_proteus_certificate_valid) + } + } - is Conversation.ProtocolInfo.Proteus, is Conversation.ProtocolInfo.Mixed -> - R.drawable.ic_certificate_valid_proteus to R.string.content_description_proteus_certificate_valid + if (protocolInfo is Conversation.ProtocolInfo.Proteus) { + proteusIcon() + mlsIcon() + } else { + mlsIcon() + proteusIcon() } - Image( - modifier = Modifier.padding(start = dimensions().spacing4x), - painter = painterResource(id = iconId), - contentDescription = stringResource(contentDescriptionId) - ) } @Composable @@ -371,3 +385,30 @@ fun PreviewConversationScreenTopAppBarShortTitleWithOngoingCall() { isSearchEnabled = false ) } + +@Preview("Topbar with a short conversation title and verified") +@Composable +fun PreviewConversationScreenTopAppBarShortTitleWithVerified() { + val conversationId = QualifiedID("", "") + ConversationScreenTopAppBarContent( + ConversationInfoViewState( + conversationId = ConversationId("value", "domain"), + conversationName = UIText.DynamicString("Short title"), + conversationDetailsData = ConversationDetailsData.Group(conversationId), + conversationAvatar = ConversationAvatar.Group(conversationId), + protocolInfo = Conversation.ProtocolInfo.Proteus, + proteusVerificationStatus = Conversation.VerificationStatus.VERIFIED, + mlsVerificationStatus = Conversation.VerificationStatus.VERIFIED + ), + onBackButtonClick = {}, + onDropDownClick = {}, + isDropDownEnabled = true, + onSearchButtonClick = {}, + onPhoneButtonClick = {}, + hasOngoingCall = false, + onJoinCallButtonClick = {}, + onPermanentPermissionDecline = {}, + isInteractionEnabled = true, + isSearchEnabled = false + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageItem.kt index ca7dd4d3c21..bac6cd25114 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/MessageItem.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.foundation.layout.FlowRow +import androidx.compose.ui.graphics.Color import com.wire.android.R import com.wire.android.media.audiomessage.AudioState import com.wire.android.model.Clickable @@ -106,7 +107,10 @@ fun MessageItem( onSelfDeletingMessageRead: (UIMessage) -> Unit, onFailedMessageRetryClicked: (String) -> Unit = {}, onFailedMessageCancelClicked: (String) -> Unit = {}, - onLinkClick: (String) -> Unit = {} + onLinkClick: (String) -> Unit = {}, + defaultBackgroundColor: Color = Color.Transparent, + shouldDisplayMessageStatus: Boolean = true, + shouldDisplayFooter: Boolean = true ) { with(message) { val selfDeletionTimerState = rememberSelfDeletionTimer(header.messageStatus.expirationStatus) @@ -133,7 +137,7 @@ fun MessageItem( Modifier.background(color) } else { - Modifier + Modifier.background(defaultBackgroundColor) } Box(backgroundColorModifier) { @@ -236,7 +240,7 @@ fun MessageItem( onLinkClick = onLinkClick ) } - if (isMyMessage) { + if (isMyMessage && shouldDisplayMessageStatus) { MessageStatusIndicator( status = message.header.messageStatus.flowStatus, isGroupConversation = conversationDetailsData is ConversationDetailsData.Group, @@ -249,10 +253,12 @@ fun MessageItem( HorizontalSpace.x24() } } - MessageFooter( - messageFooter, - onReactionClicked - ) + if (shouldDisplayFooter) { + MessageFooter( + messageFooter = messageFooter, + onReactionClicked = onReactionClicked + ) + } } else { MessageDecryptionFailure( messageHeader = header, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/SystemMessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/SystemMessageItem.kt index 9266e57dc6e..72f670f15dd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/SystemMessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/SystemMessageItem.kt @@ -265,6 +265,7 @@ private fun getColorFilter(message: SystemMessage): ColorFilter? { is SystemMessage.ConversationMessageTimerDeactivated, is SystemMessage.FederationMemberRemoved, is SystemMessage.FederationStopped, + is SystemMessage.ConversationMessageCreatedUnverifiedWarning, is SystemMessage.MLSWrongEpochWarning -> ColorFilter.tint(colorsScheme().onBackground) } } @@ -495,6 +496,7 @@ private val SystemMessage.expandable is SystemMessage.ConversationDegraded -> false is SystemMessage.ConversationVerified -> false is SystemMessage.FederationStopped -> false + is SystemMessage.ConversationMessageCreatedUnverifiedWarning -> false } private fun List.toUserNamesListString(res: Resources): String = when { @@ -581,6 +583,7 @@ fun SystemMessage.annotatedString( ) is SystemMessage.FederationStopped -> domainList.toTypedArray() + is SystemMessage.ConversationMessageCreatedUnverifiedWarning -> arrayOf() } return res.annotatedText(stringResId, normalStyle, boldStyle, normalColor, boldColor, errorColor, isErrorString, *args) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt index c210cef85a7..73e2350db24 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsScreen.kt @@ -21,9 +21,14 @@ package com.wire.android.ui.home.conversations.details import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalOverscrollConfiguration -import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState @@ -32,6 +37,8 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -44,10 +51,12 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import com.ramcosta.composedestinations.annotation.Destination @@ -60,7 +69,10 @@ import com.wire.android.appLogger import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.navigation.style.PopUpNavigationAnimation +import com.wire.android.ui.common.CollapsingTopBarScaffold +import com.wire.android.ui.common.MLSVerifiedIcon import com.wire.android.ui.common.MoreOptionIcon +import com.wire.android.ui.common.ProteusVerifiedIcon import com.wire.android.ui.common.TabItem import com.wire.android.ui.common.WireTabRow import com.wire.android.ui.common.bottomsheet.WireModalSheetLayout @@ -69,11 +81,12 @@ import com.wire.android.ui.common.bottomsheet.conversation.rememberConversationS import com.wire.android.ui.common.bottomsheet.rememberWireModalSheetState import com.wire.android.ui.common.calculateCurrentTab import com.wire.android.ui.common.dialogs.ArchiveConversationDialog -import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.snackbar.LocalSnackbarHostState import com.wire.android.ui.common.topBarElevation import com.wire.android.ui.common.topappbar.NavigationIconType import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar +import com.wire.android.ui.common.topappbar.WireTopAppBarTitle import com.wire.android.ui.common.visbility.rememberVisibilityState import com.wire.android.ui.destinations.AddMembersSearchScreenDestination import com.wire.android.ui.destinations.EditConversationNameScreenDestination @@ -81,6 +94,7 @@ import com.wire.android.ui.destinations.EditGuestAccessScreenDestination import com.wire.android.ui.destinations.EditSelfDeletingMessagesScreenDestination import com.wire.android.ui.destinations.GroupConversationAllParticipantsScreenDestination import com.wire.android.ui.destinations.OtherUserProfileScreenDestination +import com.wire.android.ui.destinations.SearchConversationMessagesScreenDestination import com.wire.android.ui.destinations.SelfUserProfileScreenDestination import com.wire.android.ui.destinations.ServiceDetailsScreenDestination import com.wire.android.ui.home.conversations.details.dialog.ClearConversationContentDialog @@ -95,8 +109,11 @@ import com.wire.android.ui.home.conversations.details.participants.model.UIParti import com.wire.android.ui.home.conversationslist.model.DialogState import com.wire.android.ui.home.conversationslist.model.GroupDialogState 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.UIText +import com.wire.kalium.logic.data.conversation.Conversation import kotlinx.coroutines.launch @RootNavGraph @@ -116,6 +133,16 @@ fun GroupConversationDetailsScreen( val snackbarHostState = LocalSnackbarHostState.current val showSnackbarMessage: (UIText) -> Unit = remember { { scope.launch { snackbarHostState.showSnackbar(it.asString(resources)) } } } + val onSearchConversationMessagesClick: () -> Unit = { + navigator.navigate( + NavigationCommand( + SearchConversationMessagesScreenDestination( + conversationId = viewModel.conversationId + ) + ) + ) + } + GroupConversationDetailsContent( conversationSheetContent = viewModel.conversationSheetContent, bottomSheetEventsHandler = viewModel, @@ -195,7 +222,8 @@ fun GroupConversationDetailsScreen( onEditGroupName = { navigator.navigate(NavigationCommand(EditConversationNameScreenDestination(viewModel.conversationId))) }, - isLoading = viewModel.requestInProgress + isLoading = viewModel.requestInProgress, + onSearchConversationMessagesClick = onSearchConversationMessagesClick ) val tryAgainSnackBarMessage = stringResource(id = R.string.error_unknown_message) @@ -235,7 +263,8 @@ private fun GroupConversationDetailsContent( onLeaveGroup: (GroupDialogState) -> Unit, onDeleteGroup: (GroupDialogState) -> Unit, groupParticipantsState: GroupConversationParticipantsState, - isLoading: Boolean + isLoading: Boolean, + onSearchConversationMessagesClick: () -> Unit ) { val scope = rememberCoroutineScope() val resources = LocalContext.current.resources @@ -281,64 +310,94 @@ private fun GroupConversationDetailsContent( clearConversationDialogState.dismiss() archiveConversationDialogState.dismiss() } - WireScaffold( - topBar = { + + CollapsingTopBarScaffold( + topBarHeader = { WireCenterAlignedTopAppBar( elevation = elevationState, - title = stringResource(R.string.conversation_details_title), + titleContent = { + WireTopAppBarTitle( + title = stringResource(R.string.conversation_details_title), + style = MaterialTheme.wireTypography.title01, + maxLines = 2 + ) + VerificationInfo(conversationSheetContent) + }, navigationIconType = NavigationIconType.Close, onNavigationPressed = onBackPressed, actions = { MoreOptionIcon(onButtonClicked = openBottomSheet) } - ) { - WireTabRow( - tabs = GroupConversationDetailsTabItem.entries, - selectedTabIndex = currentTabState, - onTabChange = { scope.launch { pagerState.animateScrollToPage(it) } }, - modifier = Modifier.padding(top = MaterialTheme.wireDimensions.spacing16x), - divider = {} // no divider + ) + }, + topBarCollapsing = { + conversationSheetState.conversationSheetContent?.let { + GroupConversationDetailsTopBarCollapsing( + title = it.title, + conversationId = it.conversationId, + totalParticipants = groupParticipantsState.data.allCount, + isLoading = isLoading, + onSearchConversationMessagesClick = onSearchConversationMessagesClick ) } }, - modifier = Modifier.fillMaxHeight(), - ) { internalPadding -> - var focusedTabIndex: Int by remember { mutableStateOf(initialPageIndex) } - val keyboardController = LocalSoftwareKeyboardController.current - val focusManager = LocalFocusManager.current - - CompositionLocalProvider(LocalOverscrollConfiguration provides null) { - HorizontalPager( - state = pagerState, - modifier = Modifier - .fillMaxWidth() - .padding(internalPadding) - ) { pageIndex -> - when (GroupConversationDetailsTabItem.entries[pageIndex]) { - GroupConversationDetailsTabItem.OPTIONS -> GroupConversationOptions( - lazyListState = lazyListStates[pageIndex], - onEditGuestAccess = onEditGuestAccess, - onEditSelfDeletingMessages = onEditSelfDeletingMessages, - onEditGroupName = onEditGroupName - ) - - GroupConversationDetailsTabItem.PARTICIPANTS -> GroupConversationParticipants( - groupParticipantsState = groupParticipantsState, - openFullListPressed = openFullListPressed, - onAddParticipantsPressed = onAddParticipantsPressed, - onProfilePressed = onProfilePressed, - lazyListState = lazyListStates[pageIndex] + topBarFooter = { + AnimatedVisibility( + visible = conversationSheetState.conversationSheetContent != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + Surface( + shadowElevation = elevationState, + color = MaterialTheme.wireColorScheme.background + ) { + WireTabRow( + tabs = GroupConversationDetailsTabItem.entries, + selectedTabIndex = currentTabState, + onTabChange = { scope.launch { pagerState.animateScrollToPage(it) } }, + modifier = Modifier.padding(top = MaterialTheme.wireDimensions.spacing16x), + divider = {} // no divider ) } } + }, + content = { + var focusedTabIndex: Int by remember { mutableStateOf(initialPageIndex) } + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + + CompositionLocalProvider(LocalOverscrollConfiguration provides null) { + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + ) { pageIndex -> + when (GroupConversationDetailsTabItem.entries[pageIndex]) { + GroupConversationDetailsTabItem.OPTIONS -> GroupConversationOptions( + lazyListState = lazyListStates[pageIndex], + onEditGuestAccess = onEditGuestAccess, + onEditSelfDeletingMessages = onEditSelfDeletingMessages, + onEditGroupName = onEditGroupName + ) - LaunchedEffect(pagerState.isScrollInProgress, focusedTabIndex, pagerState.currentPage) { - if (!pagerState.isScrollInProgress && focusedTabIndex != pagerState.currentPage) { - keyboardController?.hide() - focusManager.clearFocus() - focusedTabIndex = pagerState.currentPage + GroupConversationDetailsTabItem.PARTICIPANTS -> GroupConversationParticipants( + groupParticipantsState = groupParticipantsState, + openFullListPressed = openFullListPressed, + onAddParticipantsPressed = onAddParticipantsPressed, + onProfilePressed = onProfilePressed, + lazyListState = lazyListStates[pageIndex] + ) + } + } + + LaunchedEffect(pagerState.isScrollInProgress, focusedTabIndex, pagerState.currentPage) { + if (!pagerState.isScrollInProgress && focusedTabIndex != pagerState.currentPage) { + keyboardController?.hide() + focusManager.clearFocus() + focusedTabIndex = pagerState.currentPage + } } } } - } + ) WireModalSheetLayout( sheetState = sheetState, @@ -406,6 +465,59 @@ private fun GroupConversationDetailsContent( ) } +@Composable +private fun VerificationInfo(conversationSheetContent: ConversationSheetContent?) { + if (conversationSheetContent == null) return + + val isProteusVerified = conversationSheetContent.proteusVerificationStatus == Conversation.VerificationStatus.VERIFIED + val isMlsVerified = conversationSheetContent.mlsVerificationStatus == Conversation.VerificationStatus.VERIFIED + val isProteusProtocol = conversationSheetContent.protocol == Conversation.ProtocolInfo.Proteus + + if (isProteusVerified && (isProteusProtocol || !isMlsVerified)) { + ProteusVerifiedLabel() + } else if (isMlsVerified) { + MLSVerifiedLabel() + } +} + +@Composable +private fun MLSVerifiedLabel() { + VerifiedLabel( + stringResource(id = R.string.label_conversations_details_verified_mls).uppercase(), + MaterialTheme.wireColorScheme.mlsVerificationTextColor + ) { MLSVerifiedIcon() } +} + +@Composable +private fun ProteusVerifiedLabel() { + VerifiedLabel( + stringResource(id = R.string.label_conversations_details_verified_proteus).uppercase(), + MaterialTheme.wireColorScheme.primary + ) { ProteusVerifiedIcon() } +} + +@Composable +private fun VerifiedLabel(text: String, color: Color, icon: @Composable RowScope.() -> Unit = {}) { + Row( + modifier = Modifier + .padding(top = dimensions().spacing4x) + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Text( + modifier = Modifier.padding( + start = dimensions().spacing6x, + end = dimensions().spacing6x + ), + text = text, + style = MaterialTheme.wireTypography.label01, + color = color, + overflow = TextOverflow.Ellipsis + ) + icon() + } +} + enum class GroupConversationDetailsTabItem(@StringRes override val titleResId: Int) : TabItem { OPTIONS(R.string.conversation_details_options_tab), PARTICIPANTS(R.string.conversation_details_participants_tab); @@ -428,7 +540,8 @@ fun PreviewGroupConversationDetails() { isLoading = false, onEditGroupName = {}, onEditSelfDeletingMessages = {}, - onEditGuestAccess = {} + onEditGuestAccess = {}, + onSearchConversationMessagesClick = {} ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsTopBarCollapsing.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsTopBarCollapsing.kt new file mode 100644 index 00000000000..d2cf6f777da --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsTopBarCollapsing.kt @@ -0,0 +1,130 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.details + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.constraintlayout.compose.ConstraintLayout +import com.wire.android.R +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.conversationColor +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesButton +import com.wire.android.ui.home.conversationslist.common.GroupConversationAvatar +import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireTypography +import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.data.id.ConversationId + +@Composable +fun GroupConversationDetailsTopBarCollapsing( + title: String, + conversationId: ConversationId, + totalParticipants: Int, + isLoading: Boolean, + onSearchConversationMessagesClick: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + ) { + Box(contentAlignment = Alignment.Center) { + GroupConversationAvatar( + color = colorsScheme().conversationColor(id = conversationId), + size = dimensions().groupAvatarConversationDetailsTopBarSize, + cornerRadius = dimensions().groupAvatarConversationDetailsCornerRadius, + padding = dimensions().avatarConversationTopBarClickablePadding, + ) + } + ConstraintLayout( + Modifier + .fillMaxWidth() + .wrapContentHeight() + .animateContentSize() + ) { + val (userDescription) = createRefs() + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .wrapContentSize() + .constrainAs(userDescription) { + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + start.linkTo(parent.start) + end.linkTo(parent.end) + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding( + horizontal = dimensions().spacing16x, + vertical = dimensions().spacing4x + ) + ) { + Text( + text = title.ifBlank { + if (isLoading) "" + else UIText.StringResource(R.string.group_unavailable_label).asString() + }, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.wireTypography.body02, + color = MaterialTheme.colorScheme.onBackground + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = dimensions().spacing64x) + ) { + Text( + text = stringResource( + id = R.string.conversation_details_participants_count, + totalParticipants + ), + style = MaterialTheme.wireTypography.subline01, + color = MaterialTheme.wireColorScheme.secondaryText + ) + } + } + } + + SearchConversationMessagesButton( + onSearchConversationMessagesClick = onSearchConversationMessagesClick + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt index b257ace5767..c86b4087e48 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModel.kt @@ -150,7 +150,10 @@ class GroupConversationDetailsViewModel @Inject constructor( conversationTypeDetail = ConversationTypeDetail.Group(conversationId, groupDetails.isSelfUserCreator), isTeamConversation = groupDetails.conversation.teamId?.value != null, selfRole = groupDetails.selfRole, - isArchived = groupDetails.conversation.archived + isArchived = groupDetails.conversation.archived, + protocol = groupDetails.conversation.protocol, + mlsVerificationStatus = groupDetails.conversation.mlsVerificationStatus, + proteusVerificationStatus = groupDetails.conversation.proteusVerificationStatus ) val isGuestAllowed = groupDetails.conversation.isGuestAllowed() || groupDetails.conversation.isNonTeamMemberAllowed() val isUpdatingReadReceiptAllowed = if (selfTeam == null) { @@ -387,7 +390,14 @@ class GroupConversationDetailsViewModel @Inject constructor( viewModelScope.launch { val shouldArchive = dialogState.isArchived.not() requestInProgress = true - val result = withContext(dispatcher.io()) { updateConversationArchivedStatus(conversationId, shouldArchive, timestamp) } + val result = withContext(dispatcher.io()) { + updateConversationArchivedStatus( + conversationId = conversationId, + shouldArchiveConversation = shouldArchive, + onlyLocally = !dialogState.isMember, + archivedStatusTimestamp = timestamp + ) + } requestInProgress = false when (result) { ArchiveStatusUpdateResult.Failure -> onMessage( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModel.kt index dc6cdd4f606..3ef82c796bd 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModel.kt @@ -118,7 +118,8 @@ class ConversationInfoViewModel @Inject constructor( hasUserPermissionToEdit = detailsData !is ConversationDetailsData.None, conversationType = conversationDetails.conversation.type, protocolInfo = conversationDetails.conversation.protocol, - verificationStatus = conversationDetails.conversation.verificationStatus + mlsVerificationStatus = conversationDetails.conversation.mlsVerificationStatus, + proteusVerificationStatus = conversationDetails.conversation.proteusVerificationStatus ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewState.kt index b8b11cc394f..de4822c984d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewState.kt @@ -36,7 +36,8 @@ data class ConversationInfoViewState( val hasUserPermissionToEdit: Boolean = false, val conversationType: Conversation.Type = Conversation.Type.ONE_ON_ONE, val protocolInfo: Conversation.ProtocolInfo? = null, - val verificationStatus: Conversation.VerificationStatus? = null, + val mlsVerificationStatus: Conversation.VerificationStatus? = null, + val proteusVerificationStatus: Conversation.VerificationStatus? = null, ) sealed class ConversationDetailsData { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt index 721a6780304..eea235af9fc 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/model/UIMessage.kt @@ -193,6 +193,8 @@ sealed class UILastMessageContent { data class MultipleMessage(val messages: List, val separator: String = " ") : UILastMessageContent() data class Connection(val connectionState: ConnectionState, val userId: UserId) : UILastMessageContent() + + data class VerificationChanged(@StringRes val textResId: Int) : UILastMessageContent() } sealed class UIMessageContent { @@ -423,7 +425,8 @@ sealed class UIMessageContent { object HistoryLost : SystemMessage( R.drawable.ic_info, R.string.label_system_message_conversation_history_lost, - true) + true + ) object HistoryLostProtocolChanged : SystemMessage( R.drawable.ic_info, @@ -467,7 +470,8 @@ sealed class UIMessageContent { data class ConversationDegraded(val protocol: Conversation.Protocol) : SystemMessage( if (protocol == Conversation.Protocol.MLS) R.drawable.ic_conversation_degraded_mls else R.drawable.ic_shield_holo, - R.string.label_system_message_conversation_degraded + if (protocol == Conversation.Protocol.MLS) R.string.label_system_message_conversation_degraded_mls + else R.string.label_system_message_conversation_degraded_proteus ) data class ConversationVerified(val protocol: Conversation.Protocol) : SystemMessage( @@ -476,6 +480,11 @@ sealed class UIMessageContent { if (protocol == Conversation.Protocol.MLS) R.string.label_system_message_conversation_verified_mls else R.string.label_system_message_conversation_verified_proteus ) + + data object ConversationMessageCreatedUnverifiedWarning : SystemMessage( + R.drawable.ic_info, + R.string.label_system_message_conversation_started_sensitive_information + ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchPeopleRouter.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchPeopleRouter.kt index a941bee7d42..296b2304612 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchPeopleRouter.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/SearchPeopleRouter.kt @@ -111,15 +111,12 @@ fun SearchPeopleScreen( } }, topBarCollapsing = { - val onInputClicked: () -> Unit = remember { { searchBarState.openSearch() } } - val onCloseSearchClicked: () -> Unit = remember { { searchBarState.closeSearch() } } SearchTopBar( isSearchActive = searchBarState.isSearchActive, searchBarHint = stringResource(R.string.label_search_people), searchQuery = searchQuery, onSearchQueryChanged = onSearchQueryChanged, - onInputClicked = onInputClicked, - onCloseSearchClicked = onCloseSearchClicked + onActiveChanged = searchBarState::searchActiveChanged, ) { if (screenType == SearchPeopleScreenType.CONVERSATION_DETAILS && searchPeopleState.isServicesAllowed diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesButton.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesButton.kt new file mode 100644 index 00000000000..bf09fee6a11 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesButton.kt @@ -0,0 +1,73 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.search.messages + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpSize +import com.wire.android.R +import com.wire.android.ui.common.button.WireSecondaryButton +import com.wire.android.ui.common.dimensions + +@Composable +fun SearchConversationMessagesButton( + onSearchConversationMessagesClick: () -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .wrapContentSize() + .fillMaxWidth() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding( + top = dimensions().spacing24x, + start = dimensions().spacing16x, + end = dimensions().spacing16x, + ) + .fillMaxWidth() + ) { + WireSecondaryButton( + text = stringResource(R.string.label_search_button), + onClick = onSearchConversationMessagesClick, + minSize = DpSize(dimensions().spacing0x, dimensions().spacing48x), + fillMaxWidth = true, + leadingIcon = { + Icon( + painter = painterResource(id = R.drawable.ic_search), + contentDescription = stringResource(R.string.label_search_messages), + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(end = dimensions().spacing8x) + ) + } + ) + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesEmptyScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesEmptyScreen.kt new file mode 100644 index 00000000000..3397a2cfc8b --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesEmptyScreen.kt @@ -0,0 +1,89 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.search.messages + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import com.wire.android.BuildConfig +import com.wire.android.R +import com.wire.android.ui.common.dimensions +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.CustomTabsHelper +import com.wire.android.util.ui.PreviewMultipleThemes + +@Composable +fun SearchConversationMessagesEmptyScreen() { + val context = LocalContext.current + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(dimensions().spacing16x)) + Text( + text = stringResource(R.string.label_search_messages_empty_title), + style = MaterialTheme.wireTypography.body01.copy(color = MaterialTheme.wireColorScheme.secondaryText), + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(dimensions().spacing8x)) + Text( + text = stringResource(R.string.label_learn_more), + style = MaterialTheme.wireTypography.body02.copy( + textDecoration = TextDecoration.Underline, + color = MaterialTheme.colorScheme.primary + ), + modifier = Modifier.clickable { + CustomTabsHelper.launchUrl( + context, + LEARN_ABOUT_SEARCH_URL + ) + } + ) + } + } +} + +private const val LEARN_ABOUT_SEARCH_URL = + "${BuildConfig.URL_SUPPORT}/hc/en-us/articles/115001426529-Search-in-a-conversation" + +@PreviewMultipleThemes +@Composable +fun previewSearchConversationMessagesEmptyScreen() { + WireTheme { + SearchConversationMessagesEmptyScreen() + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesNavArgs.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesNavArgs.kt new file mode 100644 index 00000000000..cc96a1bbe68 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesNavArgs.kt @@ -0,0 +1,24 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.search.messages + +import com.wire.kalium.logic.data.id.ConversationId + +data class SearchConversationMessagesNavArgs( + val conversationId: ConversationId +) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesNoResultsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesNoResultsScreen.kt new file mode 100644 index 00000000000..a33bc202ab4 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesNoResultsScreen.kt @@ -0,0 +1,66 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.search.messages + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import com.wire.android.R +import com.wire.android.ui.common.dimensions +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.ui.PreviewMultipleThemes + +@Composable +fun SearchConversationMessagesNoResultsScreen() { + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(dimensions().spacing16x)) + Text( + text = stringResource(R.string.label_search_messages_no_results), + style = MaterialTheme.wireTypography.body01.copy(color = MaterialTheme.wireColorScheme.secondaryText), + textAlign = TextAlign.Center + ) + } + } +} + +@PreviewMultipleThemes +@Composable +fun previewSearchConversationMessagesNoResultsScreen() { + WireTheme { + SearchConversationMessagesNoResultsScreen() + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt new file mode 100644 index 00000000000..0f3c65a04f6 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesResultsScreen.kt @@ -0,0 +1,74 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.search.messages + +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.home.conversations.MessageItem +import com.wire.android.ui.home.conversations.info.ConversationDetailsData +import com.wire.android.ui.home.conversations.mock.mockMessageWithText +import com.wire.android.ui.home.conversations.model.UIMessage +import com.wire.android.ui.theme.WireTheme +import com.wire.android.util.ui.PreviewMultipleThemes + +@Composable +fun SearchConversationMessagesResultsScreen( + searchResult: List +) { + LazyColumn { + items(searchResult) { message -> + when (message) { + is UIMessage.Regular -> { + MessageItem( + message = message, + conversationDetailsData = ConversationDetailsData.None, + audioMessagesState = mapOf(), + onLongClicked = { }, + onAssetMessageClicked = { }, + onAudioClick = { }, + onChangeAudioPosition = { _, _ -> }, + onImageMessageClicked = { _, _ -> }, + onOpenProfile = { }, + onReactionClicked = { _, _ -> }, + onResetSessionClicked = { _, _ -> }, + onSelfDeletingMessageRead = { }, + defaultBackgroundColor = colorsScheme().backgroundVariant, + shouldDisplayMessageStatus = false, + shouldDisplayFooter = false + ) + } + is UIMessage.System -> { } + } + } + } +} + +@PreviewMultipleThemes +@Composable +fun previewSearchConversationMessagesResultsScreen() { + WireTheme { + SearchConversationMessagesResultsScreen( + searchResult = listOf( + mockMessageWithText, + mockMessageWithText, + ) + ) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesScreen.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesScreen.kt new file mode 100644 index 00000000000..a4dbff8b8ca --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesScreen.kt @@ -0,0 +1,89 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.search.messages + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootNavGraph +import com.wire.android.R +import com.wire.android.navigation.Navigator +import com.wire.android.navigation.style.PopUpNavigationAnimation +import com.wire.android.ui.common.CollapsingTopBarScaffold +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.topappbar.search.SearchTopBar +import com.wire.android.ui.home.conversations.model.UIMessage + +@RootNavGraph +@Destination( + navArgsDelegate = SearchConversationMessagesNavArgs::class, + style = PopUpNavigationAnimation::class +) +@Composable +fun SearchConversationMessagesScreen( + navigator: Navigator, + searchConversationMessagesViewModel: SearchConversationMessagesViewModel = hiltViewModel() +) { + with(searchConversationMessagesViewModel.searchConversationMessagesState) { + CollapsingTopBarScaffold( + topBarHeader = { }, + topBarCollapsing = { + SearchTopBar( + isSearchActive = true, // we want the search to be always active and back arrow visible on this particular screen + searchBarHint = stringResource(id = R.string.label_search_messages), + searchQuery = searchQuery, + onSearchQueryChanged = searchConversationMessagesViewModel::searchQueryChanged, + modifier = Modifier.padding(top = dimensions().spacing24x), + onCloseSearchClicked = navigator::navigateBack, + ) + }, + content = { + SearchConversationMessagesResultContent( + searchQuery = searchQuery.text, + noneSearchSucceed = isEmptyResult, + searchResult = searchResult + ) + }, + bottomBar = { }, + snapOnFling = false, + keepElevationWhenCollapsed = true + ) + } +} + +@Composable +fun SearchConversationMessagesResultContent( + searchQuery: String, + noneSearchSucceed: Boolean, + searchResult: List +) { + if (searchQuery.isEmpty()) { + SearchConversationMessagesEmptyScreen() + } else { + if (noneSearchSucceed) { + SearchConversationMessagesNoResultsScreen() + } else { + SearchConversationMessagesResultsScreen( + searchResult = searchResult + ) + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesState.kt new file mode 100644 index 00000000000..43fa32f70b5 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesState.kt @@ -0,0 +1,29 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.search.messages + +import androidx.compose.ui.text.input.TextFieldValue +import com.wire.android.ui.home.conversations.model.UIMessage +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +data class SearchConversationMessagesState( + val searchQuery: TextFieldValue = TextFieldValue(""), + val searchResult: ImmutableList = persistentListOf(), + val isEmptyResult: Boolean = false +) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesViewModel.kt new file mode 100644 index 00000000000..5ea8bebdba9 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/search/messages/SearchConversationMessagesViewModel.kt @@ -0,0 +1,88 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.search.messages + +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi +import androidx.lifecycle.viewmodel.compose.saveable +import com.wire.android.ui.home.conversations.search.SearchPeopleViewModel +import com.wire.android.ui.home.conversations.usecase.GetConversationMessagesFromSearchUseCase +import com.wire.android.ui.navArgs +import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.functional.onSuccess +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SearchConversationMessagesViewModel @Inject constructor( + private val getSearchMessagesForConversation: GetConversationMessagesFromSearchUseCase, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val searchConversationMessagesNavArgs: SearchConversationMessagesNavArgs = savedStateHandle.navArgs() + private val conversationId: QualifiedID = searchConversationMessagesNavArgs.conversationId + + @OptIn(SavedStateHandleSaveableApi::class) + var searchConversationMessagesState by savedStateHandle.saveable( + stateSaver = Saver( + save = { it.searchQuery.text }, + restore = { SearchConversationMessagesState(searchQuery = TextFieldValue(it)) } + ) + ) { mutableStateOf(SearchConversationMessagesState()) } + + private val mutableSearchQueryFlow = MutableStateFlow(searchConversationMessagesState.searchQuery.text) + + init { + viewModelScope.launch { + mutableSearchQueryFlow + .debounce(SearchPeopleViewModel.DEFAULT_SEARCH_QUERY_DEBOUNCE) + .collectLatest { searchTerm -> + getSearchMessagesForConversation( + searchTerm = searchTerm, + conversationId = conversationId + ).onSuccess { uiMessages -> + searchConversationMessagesState = searchConversationMessagesState.copy( + searchResult = uiMessages.toPersistentList(), + isEmptyResult = uiMessages.isEmpty() + ) + } + } + } + } + + fun searchQueryChanged(searchQuery: TextFieldValue) { + val textQueryChanged = searchConversationMessagesState.searchQuery.text != searchQuery.text + // we set the state with a searchQuery, immediately to update the UI first + searchConversationMessagesState = searchConversationMessagesState.copy(searchQuery = searchQuery) + if (textQueryChanged) { + viewModelScope.launch { + mutableSearchQueryFlow.emit(searchQuery.text) + } + } + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCase.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCase.kt new file mode 100644 index 00000000000..716a98e1da4 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCase.kt @@ -0,0 +1,73 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.usecase + +import com.wire.android.mapper.MessageMapper +import com.wire.android.ui.home.conversations.model.UIMessage +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.feature.conversation.ObserveUserListByIdUseCase +import com.wire.kalium.logic.feature.message.GetConversationMessagesFromSearchQueryUseCase +import com.wire.kalium.logic.functional.Either +import com.wire.kalium.logic.functional.map +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class GetConversationMessagesFromSearchUseCase @Inject constructor( + private val getConversationMessagesFromSearch: GetConversationMessagesFromSearchQueryUseCase, + private val observeMemberDetailsByIds: ObserveUserListByIdUseCase, + private val messageMapper: MessageMapper +) { + + /** + * This operation combines messages searched from a conversation and its respective user to UI + * @param searchQuery The search term used to define which messages will be returned. + * @param conversationId The conversation ID that it will look for messages in. + * @return A [Either>] indicating the success of the operation. + */ + suspend operator fun invoke( + searchTerm: String, + conversationId: ConversationId + ): Either> = + if (searchTerm.length >= MINIMUM_CHARACTERS_TO_SEARCH) { + getConversationMessagesFromSearch( + searchQuery = searchTerm, + conversationId = conversationId + ).map { foundMessages -> + foundMessages.flatMap { messageItem -> + observeMemberDetailsByIds( + userIdList = messageMapper.memberIdList( + messages = foundMessages + ) + ).map { usersList -> + messageMapper.toUIMessage( + userList = usersList, + message = messageItem + )?.let { listOf(it) } ?: emptyList() + }.first() + } + } + } else { + Either.Right(value = listOf()) + } + + private companion object { + const val MINIMUM_CHARACTERS_TO_SEARCH = 2 + } +} 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 4b511ff7522..a10b1ca9c2c 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 @@ -414,8 +414,14 @@ class ConversationListViewModel @Inject constructor( with(dialogState) { viewModelScope.launch { val isArchiving = !isArchived + requestInProgress = true - val result = updateConversationArchivedStatus(conversationId, isArchiving, timestamp) + val result = updateConversationArchivedStatus( + conversationId = conversationId, + shouldArchiveConversation = isArchiving, + onlyLocally = !dialogState.isMember, + archivedStatusTimestamp = timestamp + ) requestInProgress = false when (result) { is ArchiveStatusUpdateResult.Failure -> { @@ -486,7 +492,9 @@ private fun ConversationDetails.toConversationItem( isSelfUserMember = isSelfUserMember, teamId = conversation.teamId, selfMemberRole = selfRole, - isArchived = conversation.archived + isArchived = conversation.archived, + mlsVerificationStatus = conversation.mlsVerificationStatus, + proteusVerificationStatus = conversation.proteusVerificationStatus ) } @@ -517,7 +525,9 @@ private fun ConversationDetails.toConversationItem( userId = otherUser.id, blockingState = otherUser.BlockState, teamId = otherUser.teamId, - isArchived = conversation.archived + isArchived = conversation.archived, + mlsVerificationStatus = conversation.mlsVerificationStatus, + proteusVerificationStatus = conversation.proteusVerificationStatus ) } 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 19f18bd8d87..aef200a5964 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 @@ -32,6 +32,8 @@ import com.wire.android.R import com.wire.android.model.Clickable import com.wire.android.model.UserAvatarData import com.wire.android.ui.calling.controlbuttons.JoinButton +import com.wire.android.ui.common.MLSVerifiedIcon +import com.wire.android.ui.common.ProteusVerifiedIcon import com.wire.android.ui.common.RowItemTemplate import com.wire.android.ui.common.WireRadioButton import com.wire.android.ui.common.colorsScheme @@ -45,6 +47,7 @@ import com.wire.android.ui.home.conversationslist.model.ConversationInfo import com.wire.android.ui.home.conversationslist.model.ConversationItem import com.wire.android.ui.home.conversationslist.model.toUserInfoLabel import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.MutedConversationStatus import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.id.QualifiedID @@ -98,6 +101,8 @@ fun ConversationItemFactory( ) is UILastMessageContent.Connection -> ConnectionLabel(connectionInfo = messageContent) + is UILastMessageContent.VerificationChanged -> LastMessageSubtitle(UIText.StringResource(messageContent.textResId)) + else -> {} } } @@ -110,6 +115,7 @@ fun ConversationItemFactory( ) } +@Suppress("ComplexMethod") @Composable private fun GeneralConversationItem( searchQuery: String, @@ -140,7 +146,15 @@ private fun GeneralConversationItem( ConversationTitle( name = groupName.ifEmpty { stringResource(id = R.string.member_name_deleted_label) }, isLegalHold = conversation.isLegalHold, - searchQuery = searchQuery + searchQuery = searchQuery, + badges = { + if (proteusVerificationStatus == Conversation.VerificationStatus.VERIFIED) { + ProteusVerifiedIcon(contentDescriptionId = R.string.content_description_proteus_certificate_valid) + } + if (mlsVerificationStatus == Conversation.VerificationStatus.VERIFIED) { + MLSVerifiedIcon(contentDescriptionId = R.string.content_description_mls_certificate_valid) + } + } ) }, subTitle = subTitle, @@ -245,7 +259,9 @@ fun PreviewGroupConversationItemWithUnreadCount() { badgeEventType = BadgeEventType.UnreadMessage(100), selfMemberRole = null, teamId = null, - isArchived = false + isArchived = false, + mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED ), searchQuery = "", isSelectableItem = false, @@ -268,7 +284,9 @@ fun PreviewGroupConversationItemWithNoBadges() { badgeEventType = BadgeEventType.None, selfMemberRole = null, teamId = null, - isArchived = false + isArchived = false, + mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED ), searchQuery = "", isSelectableItem = false, @@ -291,7 +309,9 @@ fun PreviewGroupConversationItemWithMutedBadgeAndUnreadMentionBadge() { badgeEventType = BadgeEventType.UnreadMention, selfMemberRole = null, teamId = null, - isArchived = false + isArchived = false, + mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED ), searchQuery = "", isSelectableItem = false, @@ -315,7 +335,9 @@ fun PreviewGroupConversationItemWithOngoingCall() { selfMemberRole = null, teamId = null, hasOnGoingCall = true, - isArchived = false + isArchived = false, + mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED ), searchQuery = "", isSelectableItem = false, @@ -376,7 +398,9 @@ fun PreviewPrivateConversationItemWithBlockedBadge() { blockingState = BlockingState.BLOCKED, teamId = null, userId = UserId("value", "domain"), - isArchived = false + isArchived = false, + mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED ), searchQuery = "", isSelectableItem = false, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/UserLabel.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/UserLabel.kt index 42034edf2ce..dd134bf1125 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/UserLabel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/common/UserLabel.kt @@ -27,9 +27,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.wire.android.R +import com.wire.android.ui.common.MLSVerifiedIcon import com.wire.android.ui.common.MembershipQualifierLabel +import com.wire.android.ui.common.ProteusVerifiedIcon import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.home.conversationslist.model.hasLabel +import com.wire.kalium.logic.data.conversation.Conversation @Composable fun UserLabel( @@ -47,6 +50,12 @@ fun UserLabel( Spacer(modifier = Modifier.width(6.dp)) MembershipQualifierLabel(membership) } + if (proteusVerificationStatus == Conversation.VerificationStatus.VERIFIED) { + ProteusVerifiedIcon(contentDescriptionId = R.string.content_description_proteus_certificate_valid) + } + if (mlsVerificationStatus == Conversation.VerificationStatus.VERIFIED) { + MLSVerifiedIcon(contentDescriptionId = R.string.content_description_mls_certificate_valid) + } }, searchQuery = searchQuery ) @@ -57,5 +66,7 @@ data class UserInfoLabel( val labelName: String, val isLegalHold: Boolean, val membership: Membership, - val unavailable: Boolean = false + val unavailable: Boolean = false, + val proteusVerificationStatus: Conversation.VerificationStatus? = null, + val mlsVerificationStatus: Conversation.VerificationStatus? = null, ) 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 be72a99c490..eef09b9ad42 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 @@ -40,6 +40,8 @@ sealed class ConversationItem { abstract val badgeEventType: BadgeEventType abstract val teamId: TeamId? abstract val isArchived: Boolean + abstract val mlsVerificationStatus: Conversation.VerificationStatus + abstract val proteusVerificationStatus: Conversation.VerificationStatus val isTeamConversation get() = teamId != null @@ -56,6 +58,8 @@ sealed class ConversationItem { override val badgeEventType: BadgeEventType, override val teamId: TeamId?, override val isArchived: Boolean, + override val mlsVerificationStatus: Conversation.VerificationStatus, + override val proteusVerificationStatus: Conversation.VerificationStatus ) : ConversationItem() data class PrivateConversation( @@ -69,7 +73,9 @@ sealed class ConversationItem { override val lastMessageContent: UILastMessageContent?, override val badgeEventType: BadgeEventType, override val teamId: TeamId?, - override val isArchived: Boolean + override val isArchived: Boolean, + override val mlsVerificationStatus: Conversation.VerificationStatus, + override val proteusVerificationStatus: Conversation.VerificationStatus ) : ConversationItem() data class ConnectionConversation( @@ -83,6 +89,8 @@ sealed class ConversationItem { override val isArchived: Boolean = false, ) : ConversationItem() { override val teamId: TeamId? = null + override val mlsVerificationStatus: Conversation.VerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + override val proteusVerificationStatus: Conversation.VerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED } } @@ -111,7 +119,9 @@ fun ConversationItem.PrivateConversation.toUserInfoLabel() = labelName = conversationInfo.name, isLegalHold = isLegalHold, membership = conversationInfo.membership, - unavailable = conversationInfo.isSenderUnavailable + unavailable = conversationInfo.isSenderUnavailable, + mlsVerificationStatus = mlsVerificationStatus, + proteusVerificationStatus = proteusVerificationStatus ) fun ConversationItem.ConnectionConversation.toUserInfoLabel() = diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/GroupDialogState.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/GroupDialogState.kt index cffea2a5b4c..4c172647caa 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/GroupDialogState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversationslist/model/GroupDialogState.kt @@ -32,5 +32,6 @@ data class DialogState( val conversationId: ConversationId, val conversationName: String, val conversationTypeDetail: ConversationTypeDetail, - val isArchived: Boolean + val isArchived: Boolean, + val isMember: Boolean ) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/AttachmentOptions.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/AttachmentOptions.kt index a0cc294d185..e32aba168c9 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/AttachmentOptions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/AttachmentOptions.kt @@ -101,7 +101,7 @@ fun AttachmentOptionsComponent( BoxWithConstraints(Modifier.fillMaxSize()) { val fullWidth: Dp = with(density) { constraints.maxWidth.toDp() } val minPadding: Dp = dimensions().spacing2x - val minColumnWidth: Dp = with(density) { maxTextWidth.toDp() + dimensions().spacing24x } + val minColumnWidth: Dp = with(density) { maxTextWidth.toDp() + dimensions().spacing28x } val visibleAttachmentOptions = attachmentOptions.filter { it.shouldShow } val params by remember(fullWidth, visibleAttachmentOptions.size) { derivedStateOf { 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 a84a792ddd0..900e5a2f26b 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 @@ -21,6 +21,7 @@ import android.net.Uri import androidx.activity.compose.BackHandler import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -131,7 +132,7 @@ fun EnabledMessageComposer( if (!inputStateHolder.isTextExpanded) { UsersTypingIndicatorForConversation(conversationId = conversationId) } - if (messageComposerViewState.value.mentionSearchResult.isNotEmpty()) { + if (!inputStateHolder.isTextExpanded && messageComposerViewState.value.mentionSearchResult.isNotEmpty()) { MembersMentionList( membersToMention = messageComposerViewState.value.mentionSearchResult, searchQuery = messageComposition.value.messageText, @@ -144,13 +145,14 @@ fun EnabledMessageComposer( } } val fillRemainingSpaceOrWrapContent = - if (!inputStateHolder.isTextExpanded) { - Modifier.wrapContentHeight() - } else { + if (inputStateHolder.isTextExpanded) { Modifier.weight(1f) + } else { + Modifier.wrapContentHeight() } Column( horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Bottom, modifier = fillRemainingSpaceOrWrapContent .fillMaxWidth() .background(color = colorsScheme().backgroundVariant) @@ -162,7 +164,7 @@ fun EnabledMessageComposer( } if (additionalOptionStateHolder.additionalOptionsSubMenuState != AdditionalOptionSubMenuState.RecordAudio) { - Box(fillRemainingSpaceOrWrapContent) { + Box(fillRemainingSpaceOrWrapContent, contentAlignment = Alignment.BottomCenter) { var currentSelectedLineIndex by remember { mutableStateOf(0) } var cursorCoordinateY by remember { mutableStateOf(0F) } @@ -202,8 +204,7 @@ fun EnabledMessageComposer( ) val mentionSearchResult = messageComposerViewState.value.mentionSearchResult - if (mentionSearchResult.isNotEmpty() && - inputStateHolder.isTextExpanded + if (mentionSearchResult.isNotEmpty() && inputStateHolder.isTextExpanded ) { DropDownMentionsSuggestions( currentSelectedLineIndex = currentSelectedLineIndex, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageActions.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageActions.kt index 70c22843f20..03c000b712d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageActions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageActions.kt @@ -25,9 +25,9 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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.wrapContentSize import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme @@ -105,9 +105,8 @@ fun MessageEditActions( Row( modifier = modifier .fillMaxWidth() - .wrapContentSize() + .height(dimensions().spacing64x) ) { - Box( // we need to wrap it because button is smaller than minimum touch size so compose will add paddings to it to be 48dp anyway modifier = Modifier.size(width = dimensions().spacing64x, height = dimensions().spacing56x), contentAlignment = Alignment.CenterEnd diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt index 7a58135ea31..7981a6849ff 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/MessageComposerInput.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding @@ -93,8 +92,6 @@ fun ActiveMessageComposerInput( ) { Column( modifier = modifier - .wrapContentHeight() - .fillMaxWidth() .background(inputType.backgroundColor()) ) { Divider(color = MaterialTheme.wireColorScheme.outline) @@ -115,17 +112,19 @@ fun ActiveMessageComposerInput( } val stretchToMaxParentConstraintHeightOrWithInBoundary = if (isTextExpanded) { - Modifier.fillMaxHeight() + Modifier.weight(1F) } else { - Modifier.heightIn(max = dimensions().messageComposerActiveInputMaxHeight) - }.weight(1F) + Modifier + .heightIn(max = dimensions().messageComposerActiveInputMaxHeight) + .weight(1F) + } if (isTextExpanded) { Column( horizontalAlignment = Alignment.End, modifier = Modifier .fillMaxWidth() - .wrapContentHeight() + .weight(1F) ) { InputContent( conversationId = conversationId, diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreState.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreState.kt index ea255e155e8..0259c085a00 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreState.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreState.kt @@ -20,6 +20,7 @@ package com.wire.android.ui.home.settings.backup +import com.wire.kalium.logic.feature.auth.ValidatePasswordResult import okio.Path data class BackupAndRestoreState( @@ -27,7 +28,7 @@ data class BackupAndRestoreState( val restoreFileValidation: RestoreFileValidation, val restorePasswordValidation: PasswordValidation, val backupCreationProgress: BackupCreationProgress, - val backupCreationPasswordValidation: PasswordValidation + val passwordValidation: ValidatePasswordResult = ValidatePasswordResult.Valid ) { data class CreatedBackup(val path: Path, val assetName: String, val assetSize: Long, val isEncrypted: Boolean) @@ -37,7 +38,7 @@ data class BackupAndRestoreState( restoreFileValidation = RestoreFileValidation.Initial, backupCreationProgress = BackupCreationProgress.InProgress(), restorePasswordValidation = PasswordValidation.NotVerified, - backupCreationPasswordValidation = PasswordValidation.Valid, + passwordValidation = ValidatePasswordResult.Valid, ) } } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreViewModel.kt index dae2745abf3..22ee2757c2d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/BackupAndRestoreViewModel.kt @@ -33,6 +33,8 @@ import com.wire.android.appLogger import com.wire.android.util.FileManager import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.asset.KaliumFileSystem +import com.wire.kalium.logic.feature.auth.ValidatePasswordResult +import com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase import com.wire.kalium.logic.feature.backup.CreateBackupResult import com.wire.kalium.logic.feature.backup.CreateBackupUseCase import com.wire.kalium.logic.feature.backup.RestoreBackupResult @@ -58,6 +60,7 @@ class BackupAndRestoreViewModel private val importBackup: RestoreBackupUseCase, private val createBackupFile: CreateBackupUseCase, private val verifyBackup: VerifyBackupUseCase, + private val validatePassword: ValidatePasswordUseCase, private val kaliumFileSystem: KaliumFileSystem, private val fileManager: FileManager, private val dispatcher: DispatcherProvider, @@ -228,7 +231,13 @@ class BackupAndRestoreViewModel } fun validateBackupCreationPassword(backupPassword: TextFieldValue) { - // TODO: modify in case the password requirements change + state = state.copy( + passwordValidation = if (backupPassword.text.isEmpty()) { + ValidatePasswordResult.Valid + } else { + validatePassword(backupPassword.text) + } + ) } fun cancelBackupCreation() = viewModelScope.launch(dispatcher.main()) { diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/dialog/create/CreateBackupDialogFlow.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/dialog/create/CreateBackupDialogFlow.kt index 57eda0f256a..8ed5ee1135d 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/dialog/create/CreateBackupDialogFlow.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/dialog/create/CreateBackupDialogFlow.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.text.input.TextFieldValue import com.wire.android.R import com.wire.android.ui.home.settings.backup.BackupAndRestoreState import com.wire.android.ui.home.settings.backup.BackupCreationProgress -import com.wire.android.ui.home.settings.backup.PasswordValidation import com.wire.android.ui.home.settings.backup.dialog.common.FailureDialog @Composable @@ -47,7 +46,7 @@ fun CreateBackupDialogFlow( when (currentBackupDialogStep) { BackUpDialogStep.SetPassword -> { SetBackupPasswordDialog( - isBackupPasswordValid = backUpAndRestoreState.backupCreationPasswordValidation is PasswordValidation.Valid, + passwordValidation = backUpAndRestoreState.passwordValidation, onBackupPasswordChanged = onValidateBackupPassword, onCreateBackup = { password -> toCreatingBackup() diff --git a/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/dialog/create/CreateBackupDialogs.kt b/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/dialog/create/CreateBackupDialogs.kt index 655666c7558..4bd5c91629e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/dialog/create/CreateBackupDialogs.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/settings/backup/dialog/create/CreateBackupDialogs.kt @@ -25,6 +25,7 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -42,18 +43,20 @@ 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.dimensions import com.wire.android.ui.common.spacers.VerticalSpace import com.wire.android.ui.common.textfield.WirePasswordTextField import com.wire.android.ui.common.textfield.WireTextFieldState import com.wire.android.ui.common.wireDialogPropertiesBuilder import com.wire.android.ui.theme.wireTypography import com.wire.android.util.permission.rememberCreateFileFlow +import com.wire.kalium.logic.feature.auth.ValidatePasswordResult import java.util.Locale import kotlin.math.roundToInt @Composable fun SetBackupPasswordDialog( - isBackupPasswordValid: Boolean, + passwordValidation: ValidatePasswordResult, onBackupPasswordChanged: (TextFieldValue) -> Unit, onCreateBackup: (String) -> Unit, onDismissDialog: () -> Unit @@ -73,12 +76,14 @@ fun SetBackupPasswordDialog( onClick = { onCreateBackup(backupPassword.text) }, text = stringResource(id = R.string.backup_dialog_create_backup_now), type = WireDialogButtonType.Primary, - state = if (!isBackupPasswordValid) WireButtonState.Disabled else WireButtonState.Default + state = if (passwordValidation.isValid) WireButtonState.Default else WireButtonState.Disabled ) ) { WirePasswordTextField( + modifier = Modifier.padding(bottom = dimensions().spacing16x), labelText = stringResource(R.string.label_textfield_optional_password).uppercase(Locale.getDefault()), - state = if (!isBackupPasswordValid) WireTextFieldState.Error("some error") else WireTextFieldState.Default, + descriptionText = stringResource(R.string.create_account_details_password_description), + state = if (passwordValidation.isValid) WireTextFieldState.Default else WireTextFieldState.Error(), value = backupPassword, onValueChange = { backupPassword = it 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 5f962dd030a..7c9e635c788 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 @@ -34,6 +34,7 @@ import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootNavGraph import com.wire.android.BuildConfig import com.wire.android.R +import com.wire.android.navigation.NavigationCommand import com.wire.android.navigation.Navigator import com.wire.android.ui.authentication.devices.model.Device import com.wire.android.ui.authentication.devices.model.lastActiveDescription @@ -54,6 +55,7 @@ import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.scaffold.WireScaffold import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar import com.wire.android.ui.common.topappbar.WireTopAppBarTitle +import com.wire.android.ui.destinations.E2eiCertificateDetailsScreenDestination import com.wire.android.ui.home.conversationslist.common.FolderHeader import com.wire.android.ui.settings.devices.model.DeviceDetailsState import com.wire.android.ui.theme.wireColorScheme @@ -86,7 +88,13 @@ fun DeviceDetailsScreen( onDialogDismiss = viewModel::onDialogDismissed, onErrorDialogDismiss = viewModel::clearDeleteClientError, onNavigateBack = navigator::navigateBack, - onUpdateClientVerification = viewModel::onUpdateVerificationStatus + onUpdateClientVerification = viewModel::onUpdateVerificationStatus, + enrollE2eiCertificate = viewModel::enrollE2eiCertificate, + onNavigateToE2eiCertificateDetailsScreen = { + navigator.navigate( + NavigationCommand(E2eiCertificateDetailsScreenDestination(it)) + ) + } ) } } @@ -96,10 +104,12 @@ fun DeviceDetailsContent( state: DeviceDetailsState, onDeleteDevice: () -> Unit = {}, onNavigateBack: () -> Unit = {}, + onNavigateToE2eiCertificateDetailsScreen: (String) -> Unit = {}, onPasswordChange: (TextFieldValue) -> Unit = {}, onRemoveConfirm: () -> Unit = {}, onDialogDismiss: () -> Unit = {}, onErrorDialogDismiss: () -> Unit = {}, + enrollE2eiCertificate: () -> Unit = {}, onUpdateClientVerification: (Boolean) -> Unit = {} ) { val screenState = rememberConversationScreenState() @@ -151,7 +161,17 @@ fun DeviceDetailsContent( Divider(color = MaterialTheme.wireColorScheme.background) } } - + item { + EndToEndIdentityCertificateItem( + isE2eiCertificateActivated = state.isE2eiCertificateActivated, + certificate = state.e2eiCertificate, + isSelfClient = state.isSelfClient, + enrollE2eiCertificate = enrollE2eiCertificate, + updateE2eiCertificate = {}, + showCertificate = onNavigateToE2eiCertificateDetailsScreen + ) + Divider(color = colorsScheme().background) + } item { FolderHeader( name = stringResource(id = R.string.label_proteus_details).uppercase(), @@ -244,7 +264,7 @@ private fun DeviceDetailsTopBar( maxLines = 2 ) if (!isCurrentDevice && device.isVerifiedProteus) { - ProteusVerifiedIcon() + ProteusVerifiedIcon(Modifier.align(Alignment.CenterVertically)) } } } @@ -366,7 +386,8 @@ private fun VerificationDescription( text = stringResource( id = R.string.label_client_verification_description, userName ?: stringResource(id = R.string.unknown_user_name) - ), leanMoreLink = stringResource(id = R.string.url_self_client_verification_learn_more) + ), + leanMoreLink = stringResource(id = R.string.url_self_client_verification_learn_more) ) } @@ -382,7 +403,10 @@ private fun VerificationDescription( leanMoreLink = stringResource(id = R.string.url_self_client_fingerprint_learn_more) ) } else { - DescriptionText(text = stringResource(id = R.string.label_fingerprint_description), leanMoreLink = null) + DescriptionText( + text = stringResource(id = R.string.label_fingerprint_description), + leanMoreLink = null + ) } } } @@ -426,7 +450,11 @@ private fun DescriptionText( ClickableText(text = annotatedString, onClick = { offset -> leanMoreLink?.let { - annotatedString.getStringAnnotations(tag = "learn_more", start = offset, end = offset).firstOrNull()?.let { + annotatedString.getStringAnnotations( + tag = "learn_more", + start = offset, + end = offset + ).firstOrNull()?.let { CustomTabsHelper.launchUrl(context, it.item) } } @@ -492,6 +520,7 @@ fun PreviewDeviceDetailsScreen() { isCurrentDevice = false ), onPasswordChange = { }, + enrollE2eiCertificate = { }, onRemoveConfirm = { }, onDialogDismiss = { }, onErrorDialogDismiss = { } 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 949c0c4fcaf..2886561fe91 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 @@ -23,6 +23,8 @@ import com.wire.kalium.logic.feature.client.DeleteClientUseCase import com.wire.kalium.logic.feature.client.GetClientDetailsResult import com.wire.kalium.logic.feature.client.ObserveClientDetailsUseCase import com.wire.kalium.logic.feature.client.UpdateClientVerificationStatusUseCase +import com.wire.kalium.logic.feature.e2ei.usecase.GetE2EICertificateUseCaseResult +import com.wire.kalium.logic.feature.e2ei.usecase.GetE2eiCertificateUseCase import com.wire.kalium.logic.feature.user.GetUserInfoResult import com.wire.kalium.logic.feature.user.IsPasswordRequiredUseCase import com.wire.kalium.logic.feature.user.ObserveUserInfoUseCase @@ -30,6 +32,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject +@Suppress("TooManyFunctions", "LongParameterList") @HiltViewModel class DeviceDetailsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, @@ -40,7 +43,8 @@ class DeviceDetailsViewModel @Inject constructor( private val isPasswordRequired: IsPasswordRequiredUseCase, private val fingerprintUseCase: ClientFingerprintUseCase, private val updateClientVerificationStatus: UpdateClientVerificationStatusUseCase, - private val observeUserInfo: ObserveUserInfoUseCase + private val observeUserInfo: ObserveUserInfoUseCase, + private val e2eiCertificate: GetE2eiCertificateUseCase ) : SavedStateViewModel(savedStateHandle) { private val deviceDetailsNavArgs: DeviceDetailsNavArgs = savedStateHandle.navArgs() @@ -54,6 +58,7 @@ class DeviceDetailsViewModel @Inject constructor( observeDeviceDetails() getClientFingerPrint() observeUserName() + getE2eiCertificate() } private val isSelfClient: Boolean @@ -74,6 +79,22 @@ class DeviceDetailsViewModel @Inject constructor( } } + private fun getE2eiCertificate() { + val certificate = e2eiCertificate(deviceId) + state = if (certificate is GetE2EICertificateUseCaseResult.Success) { + state.copy( + isE2eiCertificateActivated = true, + e2eiCertificate = certificate.certificate + ) + } else { + state.copy(isE2eiCertificateActivated = false) + } + } + + fun enrollE2eiCertificate() { + // TODO invoke correspondent use case + } + private fun getClientFingerPrint() { viewModelScope.launch { state = when (val result = fingerprintUseCase(userId, deviceId)) { diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/EndToEndIdentityCertificateItem.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/EndToEndIdentityCertificateItem.kt new file mode 100644 index 00000000000..cf9870c8df1 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/EndToEndIdentityCertificateItem.kt @@ -0,0 +1,207 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.settings.devices + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.wire.android.R +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.settings.devices.button.GetE2eiCertificateButton +import com.wire.android.ui.settings.devices.button.ShowE2eiCertificateButton +import com.wire.android.ui.settings.devices.button.UpdateE2eiCertificateButton +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.feature.e2ei.CertificateStatus +import com.wire.kalium.logic.feature.e2ei.E2eiCertificate + +@Composable +fun EndToEndIdentityCertificateItem( + isE2eiCertificateActivated: Boolean, + certificate: E2eiCertificate, + isSelfClient: Boolean, + enrollE2eiCertificate: () -> Unit, + updateE2eiCertificate: () -> Unit, + showCertificate: (String) -> Unit +) { + Column( + modifier = Modifier + .padding( + top = MaterialTheme.wireDimensions.spacing12x, + bottom = MaterialTheme.wireDimensions.spacing12x, + start = MaterialTheme.wireDimensions.spacing16x, + end = MaterialTheme.wireDimensions.spacing12x + ) + ) { + Text( + text = stringResource(id = R.string.item_title_e2ei_certificate), + style = MaterialTheme.wireTypography.title02, + color = MaterialTheme.wireColorScheme.onBackground + ) + Text( + modifier = Modifier.padding( + top = dimensions().spacing8x, + bottom = dimensions().spacing4x + ), + text = stringResource(id = R.string.item_subtitle_status_e2ei_certificate).uppercase(), + style = MaterialTheme.wireTypography.label01, + color = MaterialTheme.wireColorScheme.secondaryText, + ) + Column { + if (isE2eiCertificateActivated) { + when (certificate.status) { + CertificateStatus.REVOKED -> { + E2EIStatusRow( + label = stringResource(id = R.string.e2ei_certificat_status_revoked), + labelColor = colorsScheme().error, + icon = R.drawable.ic_certificate_revoked_mls + ) + SerialNumberBlock(certificate.serialNumber) + ShowE2eiCertificateButton( + enabled = true, + isLoading = false, + onShowCertificateClicked = { + showCertificate(certificate.certificateDetail) + } + ) + } + + CertificateStatus.EXPIRED -> { + E2EIStatusRow( + label = stringResource(id = R.string.e2ei_certificat_status_expired), + labelColor = colorsScheme().error, + icon = R.drawable.ic_certificate_not_activated_mls + ) + SerialNumberBlock(certificate.serialNumber) + UpdateE2eiCertificateButton( + enabled = true, + isLoading = false, + updateE2eiCertificate + ) + ShowE2eiCertificateButton( + enabled = true, + isLoading = false, + onShowCertificateClicked = { + showCertificate(certificate.certificateDetail) + } + ) + } + + CertificateStatus.VALID -> { + E2EIStatusRow( + label = stringResource(id = R.string.e2ei_certificat_status_valid), + labelColor = colorsScheme().validE2eiStatusColor, + icon = R.drawable.ic_certificate_valid_mls + ) + SerialNumberBlock(certificate.serialNumber) + ShowE2eiCertificateButton( + enabled = true, + isLoading = false, + onShowCertificateClicked = { + showCertificate(certificate.certificateDetail) + } + ) + } + } + } else { + E2EIStatusRow( + label = stringResource(id = R.string.e2ei_certificat_status_not_activated), + labelColor = colorsScheme().error, + icon = R.drawable.ic_certificate_not_activated_mls + ) + if (isSelfClient) { + GetE2eiCertificateButton(enabled = true, isLoading = false) { } + } + } + } + } +} + +@Composable +private fun SerialNumberBlock(serialNumber: String) { + Column { + Text( + modifier = Modifier.padding( + top = dimensions().spacing8x, + bottom = dimensions().spacing4x + ), + text = stringResource(id = R.string.item_subtitle_serial_number_e2ei_certificate).uppercase(), + style = MaterialTheme.wireTypography.label01, + color = MaterialTheme.wireColorScheme.secondaryText, + ) + val updatedSerialNumber = serialNumber + .replaceRange(24, 24, "\n") + .replace(":", " : ") + + Text( + text = updatedSerialNumber.uppercase(), + style = MaterialTheme.wireTypography.body01, + color = MaterialTheme.wireColorScheme.onBackground, + ) + } +} + +@Composable +private fun E2EIStatusRow( + label: String, + labelColor: Color, + icon: Int, + iconContentDescription: String = "" +) { + Row { + Text( + modifier = Modifier.padding(end = dimensions().spacing4x), + text = label, + style = MaterialTheme.wireTypography.body02, + color = labelColor, + ) + Image( + painter = painterResource(id = icon), + contentDescription = iconContentDescription, + ) + } +} + +@PreviewMultipleThemes +@Composable +fun PreviewEndToEndIdentityCertificateItem() { + EndToEndIdentityCertificateItem( + isE2eiCertificateActivated = true, + isSelfClient = false, + certificate = E2eiCertificate( + issuer = "Wire", + status = CertificateStatus.VALID, + serialNumber = "e5:d5:e6:75:7e:04:86:07:14:3c:a0:ed:9a:8d:e4:fd", + certificateDetail = "" + ), + enrollE2eiCertificate = {}, + updateE2eiCertificate = {}, + showCertificate = {} + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/button/GetE2eiCertificateButton.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/button/GetE2eiCertificateButton.kt new file mode 100644 index 00000000000..94cc4a62871 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/button/GetE2eiCertificateButton.kt @@ -0,0 +1,61 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.settings.devices.button + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.wire.android.R +import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.button.WirePrimaryButton +import com.wire.android.ui.common.dimensions +import com.wire.android.util.ui.PreviewMultipleThemes + +@Composable +fun GetE2eiCertificateButton( + enabled: Boolean, + isLoading: Boolean, + onGetCertificateClicked: () -> Unit +) { + WirePrimaryButton( + text = stringResource(id = R.string.get_e2ei_certificate_button), + fillMaxWidth = true, + onClick = onGetCertificateClicked, + loading = isLoading, + state = if (!enabled) WireButtonState.Disabled + else WireButtonState.Default, + modifier = Modifier + .fillMaxWidth() + .padding( + top = dimensions().spacing8x, + bottom = dimensions().spacing8x, + ) + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewGetE2eiCertificateButton() { + GetE2eiCertificateButton( + enabled = true, + isLoading = false, + onGetCertificateClicked = {} + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/button/ShowE2eiCertificateButton.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/button/ShowE2eiCertificateButton.kt new file mode 100644 index 00000000000..ae5af6954e5 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/button/ShowE2eiCertificateButton.kt @@ -0,0 +1,64 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.settings.devices.button + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.wire.android.R +import com.wire.android.ui.common.Icon +import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.button.WireSecondaryButton +import com.wire.android.ui.common.dimensions +import com.wire.android.util.ui.PreviewMultipleThemes + +@Composable +fun ShowE2eiCertificateButton( + enabled: Boolean, + isLoading: Boolean, + onShowCertificateClicked: () -> Unit +) { + WireSecondaryButton( + modifier = Modifier + .fillMaxWidth() + .padding( + top = dimensions().spacing8x, + bottom = dimensions().spacing8x + ), + text = stringResource(id = R.string.show_e2ei_certificate_details_button), + fillMaxWidth = true, + onClick = onShowCertificateClicked, + loading = isLoading, + state = if (!enabled) WireButtonState.Disabled else WireButtonState.Default, + trailingIcon = Icons.Filled.ChevronRight.Icon() + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewShowE2eiCertificateButton() { + ShowE2eiCertificateButton( + enabled = true, + isLoading = false, + onShowCertificateClicked = {} + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/button/UpdateE2eiCertificateButton.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/button/UpdateE2eiCertificateButton.kt new file mode 100644 index 00000000000..7e438ba31b1 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/button/UpdateE2eiCertificateButton.kt @@ -0,0 +1,58 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.settings.devices.button + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.wire.android.R +import com.wire.android.ui.common.button.WireButtonState +import com.wire.android.ui.common.button.WirePrimaryButton +import com.wire.android.ui.common.dimensions +import com.wire.android.util.ui.PreviewMultipleThemes + +@Composable +fun UpdateE2eiCertificateButton( + enabled: Boolean, + isLoading: Boolean, + onUpdateCertificateClicked: () -> Unit +) { + WirePrimaryButton( + text = stringResource(id = R.string.update_e2ei_certificat_button), + fillMaxWidth = true, + onClick = onUpdateCertificateClicked, + loading = isLoading, + state = if (!enabled) WireButtonState.Disabled + else WireButtonState.Default, + modifier = Modifier + .fillMaxWidth() + .padding(top = dimensions().spacing8x) + ) +} + +@PreviewMultipleThemes +@Composable +fun PreviewUpdateE2eiCertificateButton() { + UpdateE2eiCertificateButton( + enabled = true, + isLoading = false, + onUpdateCertificateClicked = {} + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsBottomSheet.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsBottomSheet.kt new file mode 100644 index 00000000000..1ae656397d0 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsBottomSheet.kt @@ -0,0 +1,93 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.settings.devices.e2ei + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.wire.android.R +import com.wire.android.ui.common.bottomsheet.MenuBottomSheetItem +import com.wire.android.ui.common.bottomsheet.MenuItemIcon +import com.wire.android.ui.common.bottomsheet.MenuModalSheetContent +import com.wire.android.ui.common.bottomsheet.MenuModalSheetHeader +import com.wire.android.ui.common.bottomsheet.WireModalSheetLayout +import com.wire.android.ui.common.bottomsheet.WireModalSheetState + +@Composable +fun E2eiCertificateDetailsBottomSheet( + sheetState: WireModalSheetState, + onCopyToClipboard: () -> Unit, + onDownload: () -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + + WireModalSheetLayout(sheetState = sheetState, coroutineScope = coroutineScope) { + MenuModalSheetContent( + header = MenuModalSheetHeader.Gone, + menuItems = buildList { + add { + CreateCertificateSheetItem( + title = stringResource(R.string.e2ei_certificate_details_copy_to_clipboard), + icon = R.drawable.ic_copy, + onClicked = onCopyToClipboard, + enabled = true + ) + } + add { + CreateCertificateSheetItem( + title = stringResource(R.string.e2ei_certificate_details_download), + icon = R.drawable.ic_download, + onClicked = onDownload, + enabled = true + ) + } + } + ) + } +} + +@Composable +private fun CreateCertificateSheetItem( + title: String, + icon: Int, + onClicked: () -> Unit, + enabled: Boolean = true, +) { + MenuBottomSheetItem( + title = title, + onItemClick = onClicked, + icon = { + MenuItemIcon( + id = icon, + contentDescription = "", + ) + }, + enabled = enabled + ) +} + +@Preview +@Composable +fun PreviewE2eiCertificateDetailsBottomSheet() { + E2eiCertificateDetailsBottomSheet( + sheetState = WireModalSheetState(), + onCopyToClipboard = { }, + onDownload = { } + ) +} diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsScreen.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsScreen.kt new file mode 100644 index 00000000000..a413017156d --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsScreen.kt @@ -0,0 +1,157 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.settings.devices.e2ei + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootNavGraph +import com.wire.android.R +import com.wire.android.navigation.Navigator +import com.wire.android.navigation.style.PopUpNavigationAnimation +import com.wire.android.ui.common.button.WireSecondaryIconButton +import com.wire.android.ui.common.colorsScheme +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.scaffold.WireScaffold +import com.wire.android.ui.common.snackbar.LocalSnackbarHostState +import com.wire.android.ui.common.topappbar.NavigationIconType +import com.wire.android.ui.common.topappbar.WireCenterAlignedTopAppBar +import com.wire.android.util.copyLinkToClipboard +import com.wire.android.util.createPemFile +import com.wire.android.util.saveFileToDownloadsFolder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okio.Path.Companion.toOkioPath + +@RootNavGraph +@Destination( + navArgsDelegate = E2eiCertificateDetailsScreenNavArgs::class, + style = PopUpNavigationAnimation::class, +) +@Composable +fun E2eiCertificateDetailsScreen( + e2eiCertificateDetailsViewModel: E2eiCertificateDetailsViewModel = hiltViewModel(), + navigator: Navigator +) { + val snackbarHostState = LocalSnackbarHostState.current + val scope = rememberCoroutineScope() + val context = LocalContext.current + + WireScaffold( + topBar = { + WireCenterAlignedTopAppBar( + onNavigationPressed = navigator::navigateBack, + title = stringResource(R.string.e2ei_certificate_details_screen_title), + navigationIconType = NavigationIconType.Back, + actions = { + WireSecondaryIconButton( + onButtonClicked = { + e2eiCertificateDetailsViewModel.state.wireModalSheetState.show() + }, + iconResource = R.drawable.ic_more, + contentDescription = R.string.content_description_more_options + ) + } + ) + } + ) { + val clipboardManager = LocalClipboardManager.current + + with(e2eiCertificateDetailsViewModel) { + val copiedToClipboardString = + stringResource(id = R.string.e2ei_certificate_details_certificate_copied_to_clipboard) + val downloadedString = stringResource(id = R.string.media_gallery_on_image_downloaded) + + E2eiCertificateDetailsContent( + padding = it, + certificateString = getCertificate() + ) + E2eiCertificateDetailsBottomSheet( + sheetState = state.wireModalSheetState, + onCopyToClipboard = { + clipboardManager.copyLinkToClipboard(getCertificate()) + scope.launch { + state.wireModalSheetState.hide() + snackbarHostState.showSnackbar(copiedToClipboardString) + } + }, + onDownload = { + scope.launch { + withContext(Dispatchers.IO) { + createPemFile(CERTIFICATE_FILE_NAME, getCertificate()).also { + saveFileToDownloadsFolder( + context = context, + assetName = CERTIFICATE_FILE_NAME, + assetDataPath = it.toPath().toOkioPath(), + assetDataSize = it.length() + ) + } + } + state.wireModalSheetState.hide() + snackbarHostState.showSnackbar(downloadedString) + } + } + ) + } + } +} + +@Composable +fun E2eiCertificateDetailsContent( + padding: PaddingValues, + certificateString: String +) { + val textStyle = TextStyle( + textAlign = TextAlign.Justify, + fontSize = 12.sp, + lineHeight = 18.sp, + color = colorsScheme().onBackground + ) + val scroll = rememberScrollState(0) + Text( + modifier = Modifier + .verticalScroll(scroll) + .padding( + top = padding.calculateTopPadding() + dimensions().spacing16x, + start = padding.calculateStartPadding(LayoutDirection.Ltr) + dimensions().spacing16x, + end = padding.calculateEndPadding(LayoutDirection.Ltr) + dimensions().spacing16x, + bottom = padding.calculateBottomPadding() + ), + text = certificateString, + style = textStyle + ) +} + +const val CERTIFICATE_FILE_NAME = "certificate.pem" diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsScreenNavArgs.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsScreenNavArgs.kt new file mode 100644 index 00000000000..9ebe51da34f --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsScreenNavArgs.kt @@ -0,0 +1,22 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.settings.devices.e2ei + +data class E2eiCertificateDetailsScreenNavArgs( + val certificateString: String +) diff --git a/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsViewModel.kt new file mode 100644 index 00000000000..4e77cdbbb17 --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/settings/devices/e2ei/E2eiCertificateDetailsViewModel.kt @@ -0,0 +1,46 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.settings.devices.e2ei + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.wire.android.ui.common.bottomsheet.WireModalSheetState +import com.wire.android.ui.navArgs +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class E2eiCertificateDetailsViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + var state: E2eiCertificateDetailsState by mutableStateOf(E2eiCertificateDetailsState()) + private set + + private val e2eiCertificateDetailsScreenNavArgs: E2eiCertificateDetailsScreenNavArgs = + savedStateHandle.navArgs() + + fun getCertificate() = e2eiCertificateDetailsScreenNavArgs.certificateString +} + +data class E2eiCertificateDetailsState( + val wireModalSheetState: WireModalSheetState = WireModalSheetState() +) 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 ab60d1b755c..24b01807567 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 @@ -3,6 +3,7 @@ package com.wire.android.ui.settings.devices.model import com.wire.android.ui.authentication.devices.model.Device import com.wire.android.ui.authentication.devices.remove.RemoveDeviceDialogState import com.wire.android.ui.authentication.devices.remove.RemoveDeviceError +import com.wire.kalium.logic.feature.e2ei.E2eiCertificate data class DeviceDetailsState( val device: Device = Device(), @@ -11,5 +12,7 @@ data class DeviceDetailsState( val error: RemoveDeviceError = RemoveDeviceError.None, val fingerPrint: String? = null, val isSelfClient: Boolean = false, - val userName: String? = null + val userName: String? = null, + val isE2eiCertificateActivated: Boolean = false, + val e2eiCertificate: E2eiCertificate = E2eiCertificate() ) diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt index e76814f5cfb..249d644cede 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaAuthenticatedViewModel.kt @@ -192,7 +192,9 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( isSelfUserMember = isSelfUserMember, teamId = conversation.teamId, selfMemberRole = selfRole, - isArchived = conversation.archived + isArchived = conversation.archived, + mlsVerificationStatus = conversation.mlsVerificationStatus, + proteusVerificationStatus = conversation.proteusVerificationStatus ) } @@ -228,7 +230,9 @@ class ImportMediaAuthenticatedViewModel @Inject constructor( userId = otherUser.id, blockingState = otherUser.BlockState, teamId = otherUser.teamId, - isArchived = conversation.archived + isArchived = conversation.archived, + mlsVerificationStatus = conversation.mlsVerificationStatus, + proteusVerificationStatus = conversation.proteusVerificationStatus ) } diff --git a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt index f045a281f52..a609edb4986 100644 --- a/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt +++ b/app/src/main/kotlin/com/wire/android/ui/sharing/ImportMediaScreen.kt @@ -403,8 +403,7 @@ private fun ImportMediaContent( onSearchQueryChanged(it) searchBarState.searchQueryChanged(it) }, - onInputClicked = searchBarState::openSearch, - onCloseSearchClicked = searchBarState::closeSearch + onActiveChanged = searchBarState::searchActiveChanged, ) } ConversationList( diff --git a/app/src/main/kotlin/com/wire/android/ui/theme/WireColorPalette.kt b/app/src/main/kotlin/com/wire/android/ui/theme/WireColorPalette.kt index 9c7d52548c8..e2cab12dff4 100644 --- a/app/src/main/kotlin/com/wire/android/ui/theme/WireColorPalette.kt +++ b/app/src/main/kotlin/com/wire/android/ui/theme/WireColorPalette.kt @@ -62,6 +62,8 @@ object WireColorPalette { @Stable val LightGreen500 = Color(0xFF207C37) @Stable + val LightGreen550 = Color(0xFF1D7833) + @Stable val LightGreen600 = Color(0xFF1A632C) @Stable val LightGreen700 = Color(0xFF134A21) @@ -187,6 +189,7 @@ object WireColorPalette { val DarkGreen400 = Color(0xFF59E27C) @Stable val DarkGreen500 = Color(0xFF30DB5B) + val DarkGreen550 = Color(0xFF1D7833) @Stable val DarkGreen600 = Color(0xFF26AF49) @Stable diff --git a/app/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt b/app/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt index 5008f4dee84..6811c2e7f7e 100644 --- a/app/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt +++ b/app/src/main/kotlin/com/wire/android/ui/theme/WireColorScheme.kt @@ -100,7 +100,11 @@ data class WireColorScheme( val unclassifiedBannerBackgroundColor: Color, val unclassifiedBannerForegroundColor: Color, val recordAudioStartColor: Color, - val recordAudioStopColor: Color + val recordAudioStopColor: Color, + val scrollToBottomButtonColor: Color, + val onScrollToBottomButtonColor: Color, + val validE2eiStatusColor: Color, + val mlsVerificationTextColor: Color, ) { fun toColorScheme(): ColorScheme = ColorScheme( primary = primary, @@ -231,7 +235,11 @@ private val LightWireColorScheme = WireColorScheme( unclassifiedBannerBackgroundColor = WireColorPalette.LightRed600, unclassifiedBannerForegroundColor = Color.White, recordAudioStartColor = WireColorPalette.LightBlue500, - recordAudioStopColor = WireColorPalette.LightRed500 + recordAudioStopColor = WireColorPalette.LightRed500, + scrollToBottomButtonColor = WireColorPalette.Gray70, + onScrollToBottomButtonColor = Color.White, + validE2eiStatusColor = WireColorPalette.LightGreen550, + mlsVerificationTextColor = WireColorPalette.DarkGreen700 ) // Dark WireColorScheme @@ -336,7 +344,11 @@ private val DarkWireColorScheme = WireColorScheme( unclassifiedBannerBackgroundColor = WireColorPalette.DarkRed500, unclassifiedBannerForegroundColor = Color.Black, recordAudioStartColor = WireColorPalette.LightBlue500, - recordAudioStopColor = WireColorPalette.LightRed500 + recordAudioStopColor = WireColorPalette.LightRed500, + scrollToBottomButtonColor = WireColorPalette.Gray60, + onScrollToBottomButtonColor = Color.Black, + validE2eiStatusColor = WireColorPalette.DarkGreen550, + mlsVerificationTextColor = WireColorPalette.DarkGreen700 ) @PackagePrivate diff --git a/app/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt b/app/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt index c8bc7b15aa8..6c00ead58df 100644 --- a/app/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt +++ b/app/src/main/kotlin/com/wire/android/ui/theme/WireDimensions.kt @@ -46,6 +46,8 @@ data class WireDimensions( val groupAvatarCornerRadius: Dp, val avatarConversationTopBarSize: Dp, val groupAvatarConversationTopBarCornerRadius: Dp, + val groupAvatarConversationDetailsTopBarSize: Dp, + val groupAvatarConversationDetailsCornerRadius: Dp, val avatarConversationTopBarClickablePadding: Dp, // Drawer Navigation val homeDrawerHorizontalPadding: Dp, @@ -210,6 +212,8 @@ private val DefaultPhonePortraitWireDimensions: WireDimensions = WireDimensions( groupAvatarCornerRadius = 10.dp, avatarConversationTopBarSize = 24.dp, groupAvatarConversationTopBarCornerRadius = 8.dp, + groupAvatarConversationDetailsTopBarSize = 64.dp, + groupAvatarConversationDetailsCornerRadius = 20.dp, avatarConversationTopBarClickablePadding = 2.dp, homeDrawerHorizontalPadding = 8.dp, homeDrawerBottomPadding = 16.dp, @@ -237,7 +241,7 @@ private val DefaultPhonePortraitWireDimensions: WireDimensions = WireDimensions( messageQuoteBorderRadius = 1.dp, messageQuoteIconSize = 10.dp, messageAssetBorderRadius = 10.dp, - messageComposerActiveInputMaxHeight = 168.dp, + messageComposerActiveInputMaxHeight = 128.dp, attachmentButtonSize = 40.dp, textFieldMinHeight = 48.dp, textFieldCornerSize = 16.dp, diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/common/UserProfileInfo.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/common/UserProfileInfo.kt index ecfa398f3e6..c45204efa01 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/common/UserProfileInfo.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/common/UserProfileInfo.kt @@ -62,6 +62,7 @@ import com.wire.android.ui.common.UserProfileAvatar import com.wire.android.ui.common.banner.SecurityClassificationBannerForUser import com.wire.android.ui.common.dimensions import com.wire.android.ui.common.progress.WireCircularProgressIndicator +import com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesButton import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.ui.theme.wireColorScheme import com.wire.android.ui.theme.wireTypography @@ -88,7 +89,9 @@ fun UserProfileInfo( modifier: Modifier = Modifier, connection: ConnectionState = ConnectionState.ACCEPTED, delayToShowPlaceholderIfNoAsset: Duration = 200.milliseconds, - isProteusVerified: Boolean = false + isProteusVerified: Boolean = false, + onSearchConversationMessagesClick: () -> Unit = {}, + shouldShowSearchButton: Boolean = false ) { Column( horizontalAlignment = CenterHorizontally, @@ -227,6 +230,12 @@ fun UserProfileInfo( modifier = Modifier.padding(top = dimensions().spacing8x) ) } + + if (shouldShowSearchButton) { + SearchConversationMessagesButton( + onSearchConversationMessagesClick = onSearchConversationMessagesClick + ) + } } } diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserConnectionStatusInfo.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserConnectionStatusInfo.kt index 682b26cdbac..047687aec96 100644 --- a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserConnectionStatusInfo.kt +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserConnectionStatusInfo.kt @@ -58,16 +58,8 @@ fun OtherUserConnectionStatusInfo(connectionStatus: ConnectionState, membership: style = MaterialTheme.wireTypography.title02 ) } - val descriptionResource = when (connectionStatus) { - ConnectionState.PENDING, ConnectionState.IGNORED -> R.string.connection_label_accepting_request_description - ConnectionState.ACCEPTED, ConnectionState.BLOCKED -> null - else -> if (membership == Membership.None) { - R.string.connection_label_member_not_conneted - } else { - R.string.connection_label_member_not_belongs_to_team - } - } - descriptionResource?.let { + + descriptionResourceForConnectionAndMembership(connectionStatus, membership)?.let { Text( text = stringResource(it), textAlign = TextAlign.Center, @@ -80,6 +72,20 @@ fun OtherUserConnectionStatusInfo(connectionStatus: ConnectionState, membership: } } +@Composable +private fun descriptionResourceForConnectionAndMembership( + connectionStatus: ConnectionState, + membership: Membership +) = when (connectionStatus) { + ConnectionState.PENDING, ConnectionState.IGNORED -> R.string.connection_label_accepting_request_description + ConnectionState.ACCEPTED, ConnectionState.BLOCKED -> null + else -> if (membership == Membership.None) { + R.string.connection_label_member_not_conneted + } else { + R.string.connection_label_member_not_belongs_to_team + } +} + @Composable @Preview fun PreviewOtherUserConnectionStatusInfo() { diff --git a/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserConnectionUnverifiedWarning.kt b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserConnectionUnverifiedWarning.kt new file mode 100644 index 00000000000..53884ac03bc --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/userprofile/other/OtherUserConnectionUnverifiedWarning.kt @@ -0,0 +1,79 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + * + */ + +package com.wire.android.ui.userprofile.other + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.wire.android.R +import com.wire.android.ui.common.dimensions +import com.wire.android.ui.common.spacers.VerticalSpace +import com.wire.android.ui.theme.wireColorScheme +import com.wire.android.ui.theme.wireTypography +import com.wire.kalium.logic.data.user.ConnectionState + +@Composable +fun OtherUserConnectionUnverifiedWarning( + userName: String, + connectionStatus: ConnectionState +) { + Box( + Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(start = dimensions().spacing32x, end = dimensions().spacing32x) + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + unverifiedDescriptionResource(connectionStatus)?.let { + Text( + text = stringResource(it, userName), + textAlign = TextAlign.Center, + color = MaterialTheme.wireColorScheme.error, + style = MaterialTheme.wireTypography.body01 + ) + VerticalSpace.x24() + } + } + } +} + +@Composable +private fun unverifiedDescriptionResource(connectionStatus: ConnectionState) = when (connectionStatus) { + ConnectionState.PENDING, ConnectionState.IGNORED -> R.string.connection_label_received_unverified_warning + ConnectionState.ACCEPTED, ConnectionState.BLOCKED -> null + else -> R.string.connection_label_send_unverified_warning +} + +@Composable +@Preview +fun PreviewOtherUserConnectionUnverifiedWarning() { + OtherUserConnectionUnverifiedWarning("Bob", ConnectionState.NOT_CONNECTED) +} 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 c7d3e2c0480..7800e6660eb 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 @@ -89,6 +89,7 @@ import com.wire.android.ui.common.visbility.rememberVisibilityState import com.wire.android.ui.connection.ConnectionActionButton import com.wire.android.ui.destinations.ConversationScreenDestination import com.wire.android.ui.destinations.DeviceDetailsScreenDestination +import com.wire.android.ui.destinations.SearchConversationMessagesScreenDestination import com.wire.android.ui.home.conversations.details.dialog.ClearConversationContentDialog import com.wire.android.ui.home.conversationslist.model.DialogState import com.wire.android.ui.home.conversationslist.model.Membership @@ -128,6 +129,21 @@ fun OtherUserProfileScreen( val openBottomSheet: () -> Unit = remember { { scope.launch { sheetState.show() } } } val closeBottomSheet: () -> Unit = remember { { scope.launch { sheetState.hide() } } } + val conversationId = viewModel.state.conversationId + ?: viewModel.state.conversationSheetContent?.conversationId + val shouldShowSearchButton = viewModel.shouldShowSearchButton(conversationId = conversationId) + val onSearchConversationMessagesClick: () -> Unit = { + conversationId?.let { + navigator.navigate( + NavigationCommand( + SearchConversationMessagesScreenDestination( + conversationId = it + ) + ) + ) + } + } + OtherProfileScreenContent( scope = scope, state = viewModel.state, @@ -143,6 +159,8 @@ fun OtherUserProfileScreen( }, onOpenConversation = { navigator.navigate(NavigationCommand(ConversationScreenDestination(it), BackStackMode.UPDATE_EXISTED)) }, onOpenDeviceDetails = { navigator.navigate(NavigationCommand(DeviceDetailsScreenDestination(navArgs.userId, it.clientId))) }, + onSearchConversationMessagesClick = onSearchConversationMessagesClick, + shouldShowSearchButton = shouldShowSearchButton, navigateBack = navigator::navigateBack, navigationIconType = NavigationIconType.Close, ) @@ -175,6 +193,8 @@ fun OtherProfileScreenContent( onIgnoreConnectionRequest: (String) -> Unit = { }, onOpenConversation: (ConversationId) -> Unit = {}, onOpenDeviceDetails: (Device) -> Unit = {}, + onSearchConversationMessagesClick: () -> Unit, + shouldShowSearchButton: Boolean, navigateBack: () -> Unit = {} ) { val otherUserProfileScreenState = rememberOtherUserProfileScreenState() @@ -249,7 +269,13 @@ fun OtherProfileScreenContent( openConversationBottomSheet = openConversationBottomSheet ) }, - topBarCollapsing = { TopBarCollapsing(state) }, + topBarCollapsing = { + TopBarCollapsing( + state = state, + onSearchConversationMessagesClick = onSearchConversationMessagesClick, + shouldShowSearchButton = shouldShowSearchButton + ) + }, topBarFooter = { TopBarFooter(state, pagerState, tabBarElevationState, tabItems, currentTabState, scope) }, content = { Content( @@ -345,8 +371,15 @@ private fun TopBarHeader( } @Composable -private fun TopBarCollapsing(state: OtherUserProfileState) { - Crossfade(targetState = state, label = "OtherUserProfileScreenTopBarCollapsing") { targetState -> +private fun TopBarCollapsing( + state: OtherUserProfileState, + onSearchConversationMessagesClick: () -> Unit, + shouldShowSearchButton: Boolean +) { + Crossfade( + targetState = state, + label = "OtherUserProfileScreenTopBarCollapsing" + ) { targetState -> UserProfileInfo( userId = targetState.userId, isLoading = targetState.isAvatarLoading, @@ -358,7 +391,9 @@ private fun TopBarCollapsing(state: OtherUserProfileState) { editableState = EditableState.NotEditable, modifier = Modifier.padding(bottom = dimensions().spacing16x), connection = targetState.connectionState, - isProteusVerified = targetState.isProteusVerified + isProteusVerified = targetState.isProteusVerified, + onSearchConversationMessagesClick = onSearchConversationMessagesClick, + shouldShowSearchButton = shouldShowSearchButton ) } } @@ -412,6 +447,7 @@ private fun Content( Column { if (!state.isDataLoading) { OtherUserConnectionStatusInfo(state.connectionState, state.membership) + OtherUserConnectionUnverifiedWarning(state.fullName, state.connectionState) } when { state.isDataLoading || state.botService != null -> Box {} // no content visible while loading @@ -505,13 +541,17 @@ enum class OtherUserProfileTabItem(@StringRes override val titleResId: Int) : Ta fun PreviewOtherProfileScreenContent() { WireTheme(isPreview = true) { OtherProfileScreenContent( - rememberCoroutineScope(), - OtherUserProfileState.PREVIEW.copy(connectionState = ConnectionState.ACCEPTED), - NavigationIconType.Back, - false, - rememberWireModalSheetState(), - {}, {}, OtherUserProfileEventsHandler.PREVIEW, - OtherUserProfileBottomSheetEventsHandler.PREVIEW + scope = rememberCoroutineScope(), + state = OtherUserProfileState.PREVIEW.copy(connectionState = ConnectionState.ACCEPTED), + navigationIconType = NavigationIconType.Back, + requestInProgress = false, + sheetState = rememberWireModalSheetState(), + openBottomSheet = {}, + closeBottomSheet = {}, + eventsHandler = OtherUserProfileEventsHandler.PREVIEW, + bottomSheetEventsHandler = OtherUserProfileBottomSheetEventsHandler.PREVIEW, + onSearchConversationMessagesClick = {}, + shouldShowSearchButton = false ) } } @@ -522,13 +562,17 @@ fun PreviewOtherProfileScreenContent() { fun PreviewOtherProfileScreenContentNotConnected() { WireTheme(isPreview = true) { OtherProfileScreenContent( - rememberCoroutineScope(), - OtherUserProfileState.PREVIEW.copy(connectionState = ConnectionState.CANCELLED), - NavigationIconType.Back, - false, - rememberWireModalSheetState(), - {}, {}, OtherUserProfileEventsHandler.PREVIEW, - OtherUserProfileBottomSheetEventsHandler.PREVIEW, + scope = rememberCoroutineScope(), + state = OtherUserProfileState.PREVIEW.copy(connectionState = ConnectionState.CANCELLED), + navigationIconType = NavigationIconType.Back, + requestInProgress = false, + sheetState = rememberWireModalSheetState(), + openBottomSheet = {}, + closeBottomSheet = {}, + eventsHandler = OtherUserProfileEventsHandler.PREVIEW, + bottomSheetEventsHandler = OtherUserProfileBottomSheetEventsHandler.PREVIEW, + onSearchConversationMessagesClick = {}, + shouldShowSearchButton = false ) } } 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 c6cc450170b..22bdbdc652c 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 @@ -54,6 +54,7 @@ import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.MutedConversationStatus import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.data.user.ConnectionState import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.feature.client.ObserveClientsByUserIdUseCase import com.wire.kalium.logic.feature.client.PersistOtherUserClientsUseCase @@ -299,7 +300,13 @@ class OtherUserProfileScreenViewModel @Inject constructor( viewModelScope.launch { val shouldArchive = !dialogState.isArchived requestInProgress = true - val result = withContext(dispatchers.io()) { updateConversationArchivedStatus(dialogState.conversationId, shouldArchive) } + val result = withContext(dispatchers.io()) { + updateConversationArchivedStatus( + conversationId = dialogState.conversationId, + shouldArchiveConversation = shouldArchive, + onlyLocally = !dialogState.isMember + ) + } requestInProgress = false when (result) { ArchiveStatusUpdateResult.Failure -> { @@ -379,9 +386,19 @@ class OtherUserProfileScreenViewModel @Inject constructor( ), isTeamConversation = conversation.isTeamGroup(), selfRole = Conversation.Member.Role.Member, - isArchived = conversation.archived + isArchived = conversation.archived, + protocol = conversation.protocol, + mlsVerificationStatus = conversation.mlsVerificationStatus, + proteusVerificationStatus = conversation.proteusVerificationStatus ) } ) } + + fun shouldShowSearchButton(conversationId: ConversationId?): Boolean = + conversationId != null && state.connectionState in listOf( + ConnectionState.ACCEPTED, + ConnectionState.BLOCKED, + ConnectionState.MISSING_LEGALHOLD_CONSENT + ) } 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 5ade6c96e85..5df06246774 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 @@ -27,12 +27,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wire.android.BuildConfig import com.wire.android.appLogger +import com.wire.android.datastore.GlobalDataStore import com.wire.android.datastore.UserDataStore import com.wire.android.di.AuthServerConfigProvider import com.wire.android.di.CurrentAccount import com.wire.android.feature.AccountSwitchUseCase import com.wire.android.feature.SwitchAccountActions import com.wire.android.feature.SwitchAccountParam +import com.wire.android.feature.SwitchAccountResult import com.wire.android.mapper.OtherAccountMapper import com.wire.android.model.ImageAsset.UserAvatarAsset import com.wire.android.notification.NotificationChannelsManager @@ -70,6 +72,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import javax.inject.Inject +// TODO cover this class with unit test // Suppress for now after removing mockMethodForAvatar it should not complain @Suppress("TooManyFunctions", "LongParameterList") @HiltViewModel @@ -91,7 +94,8 @@ class SelfUserProfileViewModel @Inject constructor( private val endCall: EndCallUseCase, private val isReadOnlyAccount: IsReadOnlyAccountUseCase, private val notificationChannelsManager: NotificationChannelsManager, - private val notificationManager: WireNotificationManager + private val notificationManager: WireNotificationManager, + private val globalDataStore: GlobalDataStore ) : ViewModel() { var userProfileState by mutableStateOf(SelfUserProfileState(userId = selfUserId, isAvatarLoading = true)) @@ -213,8 +217,11 @@ class SelfUserProfileViewModel @Inject constructor( notificationManager.stopObservingOnLogout(selfUserId) notificationChannelsManager.deleteChannelGroup(selfUserId) - accountSwitch(SwitchAccountParam.TryToSwitchToNextAccount) - .callAction(switchAccountActions) + accountSwitch(SwitchAccountParam.TryToSwitchToNextAccount).also { + if (it == SwitchAccountResult.NoOtherAccountToSwitch) { + globalDataStore.clearAppLockPasscode() + } + }.callAction(switchAccountActions) } } diff --git a/app/src/main/kotlin/com/wire/android/util/ClipboardCopier.kt b/app/src/main/kotlin/com/wire/android/util/ClipboardCopier.kt index 2d0135f6e44..d75dc4a8d38 100644 --- a/app/src/main/kotlin/com/wire/android/util/ClipboardCopier.kt +++ b/app/src/main/kotlin/com/wire/android/util/ClipboardCopier.kt @@ -23,6 +23,6 @@ package com.wire.android.util import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.text.AnnotatedString -fun ClipboardManager.copyLinkToClipboard(link: String) { - setText(AnnotatedString(link)) +fun ClipboardManager.copyLinkToClipboard(text: String) { + setText(AnnotatedString(text)) } diff --git a/app/src/main/kotlin/com/wire/android/util/FileUtil.kt b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt index 63edb30291a..b8938b5cc9f 100644 --- a/app/src/main/kotlin/com/wire/android/util/FileUtil.kt +++ b/app/src/main/kotlin/com/wire/android/util/FileUtil.kt @@ -58,6 +58,7 @@ import com.wire.android.util.dispatchers.DispatcherProvider import com.wire.kalium.logic.data.asset.isAudioMimeType import com.wire.kalium.logic.util.buildFileName import com.wire.kalium.logic.util.splitFileExtensionAndCopyCounter +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okio.Path import java.io.File @@ -112,6 +113,22 @@ fun getTempWritableAttachmentUri(context: Context, attachmentPath: Path): Uri { return FileProvider.getUriForFile(context, context.getProviderAuthority(), file) } +suspend fun createPemFile( + pathname: String, + content: String +): File { + return withContext(Dispatchers.IO) { + return@withContext File( + Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS + ), + pathname + ).apply { + writeText(content) + } + } +} + private fun Context.saveFileDataToDownloadsFolder(assetName: String, downloadedDataPath: Path, fileSize: Long): Uri? { val resolver = contentResolver val mimeType = Uri.parse(downloadedDataPath.toString()).getMimeType(this@saveFileDataToDownloadsFolder) diff --git a/app/src/main/res/drawable-night/ic_certificate_not_activated_mls.xml b/app/src/main/res/drawable-night/ic_certificate_not_activated_mls.xml new file mode 100644 index 00000000000..a229be4887f --- /dev/null +++ b/app/src/main/res/drawable-night/ic_certificate_not_activated_mls.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable-night/ic_certificate_revoked_mls.xml b/app/src/main/res/drawable-night/ic_certificate_revoked_mls.xml new file mode 100644 index 00000000000..b452e46beb3 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_certificate_revoked_mls.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_certificate_expired_mls.xml b/app/src/main/res/drawable/ic_certificate_expired_mls.xml new file mode 100644 index 00000000000..9d575b4d809 --- /dev/null +++ b/app/src/main/res/drawable/ic_certificate_expired_mls.xml @@ -0,0 +1,27 @@ + + + + diff --git a/app/src/main/res/drawable/ic_certificate_not_activated_mls.xml b/app/src/main/res/drawable/ic_certificate_not_activated_mls.xml new file mode 100644 index 00000000000..ba2cd1efcea --- /dev/null +++ b/app/src/main/res/drawable/ic_certificate_not_activated_mls.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_certificate_revoked_mls.xml b/app/src/main/res/drawable/ic_certificate_revoked_mls.xml new file mode 100644 index 00000000000..02362ae6361 --- /dev/null +++ b/app/src/main/res/drawable/ic_certificate_revoked_mls.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values-af/strings.xml b/app/src/main/res/values-af/strings.xml index 39999a6376e..0c0cae718bd 100644 --- a/app/src/main/res/values-af/strings.xml +++ b/app/src/main/res/values-af/strings.xml @@ -589,7 +589,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index e8d45d64096..53d34e3b335 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -597,7 +597,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 109ee050226..5d24aa54920 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -589,7 +589,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 39999a6376e..0c0cae718bd 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -589,7 +589,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 7f78a072c11..d2e56b12c56 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -593,7 +593,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 39999a6376e..0c0cae718bd 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -589,7 +589,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 7aa3218c54e..74c74aad2ce 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -590,7 +590,7 @@ %1$s **Teilnehmer** konnten der Gruppe nicht hinzugefügt werden. %1$s konnten der Gruppe nicht hinzugefügt werden. %1$s konnte der Gruppe nicht hinzugefügt werden. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 39999a6376e..0c0cae718bd 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -589,7 +589,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 60c2f2eb786..a18a2337682 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -587,7 +587,7 @@ Hasta 500 personas pueden unirse a una conversación en grupo. %1$s **participants** could not be added to the group. %1$s no pudieron ser añadidos a la conversación. %1$s no pudo ser añadido a la conversación. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index ddfa543a5b3..94fd65f80a4 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -589,7 +589,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 109ee050226..5d24aa54920 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -589,7 +589,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 39999a6376e..0c0cae718bd 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -589,7 +589,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 2d3a72e70ba..7cdd6b8822f 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -578,7 +578,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index 240af6cb17c..c3e583b9d16 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -593,7 +593,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 109ee050226..5d24aa54920 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -589,7 +589,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 826b3377474..3d4c79ffb99 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -585,7 +585,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 188d7310541..7eb3b8d911e 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -589,7 +589,7 @@ %1$s **résztvevőket** nem sikerült hozzáadni a csoporthoz. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index b6375f34ee7..fcd9ef822a9 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -587,7 +587,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 56abf9440ed..984cc48d773 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -587,7 +587,7 @@ Fino a 500 persone possono unirsi a una conversazione di gruppo. %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 1cdf54afb67..129c6204400 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -587,7 +587,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 1cdf54afb67..129c6204400 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -587,7 +587,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index 0634c2d468c..d8e03f4c744 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -593,7 +593,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-mk/strings.xml b/app/src/main/res/values-mk/strings.xml index 109ee050226..5d24aa54920 100644 --- a/app/src/main/res/values-mk/strings.xml +++ b/app/src/main/res/values-mk/strings.xml @@ -589,7 +589,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 39999a6376e..0c0cae718bd 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -589,7 +589,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-no/strings.xml b/app/src/main/res/values-no/strings.xml index 39999a6376e..0c0cae718bd 100644 --- a/app/src/main/res/values-no/strings.xml +++ b/app/src/main/res/values-no/strings.xml @@ -589,7 +589,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 109ee050226..5d24aa54920 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -589,7 +589,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 1c2772760ea..ab8be0a63ad 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -593,7 +593,7 @@ Do grupy może dołączyć maksymalnie 500 osób. %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index e3fcc4f3398..375bddc88c5 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -588,7 +588,7 @@ Até 500 pessoas podem participar de uma conversa em grupo. %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index ecb7160d0d5..424fe7ef390 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -591,7 +591,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ae990370859..9b24bbe051d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -593,7 +593,7 @@ Не удалось добавить в группу %1$s **участников**. Не удалось добавить %1$s в группу. Не удалось добавить %1$s в группу. - Эта беседа больше не верифицируется, так как кто-то из пользователей использует по крайней мере одно устройство без действительного сертификата сквозной идентификации. + Эта беседа больше не верифицируется, так как кто-то из пользователей использует по крайней мере одно устройство без действительного сертификата сквозной идентификации. Все устройства верифицированы (сквозная идентификация) Все отпечатки верифицированы (Proteus) diff --git a/app/src/main/res/values-si/strings.xml b/app/src/main/res/values-si/strings.xml index f2cb92e9a70..5be28b47260 100644 --- a/app/src/main/res/values-si/strings.xml +++ b/app/src/main/res/values-si/strings.xml @@ -579,7 +579,7 @@ **සහභාගීන්** %1$s ක් සමූහයට එක් කිරීමට නොහැකි විය. %1$s දෙනෙක් සමූහයට එක් කිරීමට නොහැකි විය. %1$s දෙනෙක් සමූහයට එක් කිරීමට නොහැකි විය. - ඇතැම් පරිශ්‍රීලකයින් වලංගු අන්ත අනන්‍යතා සහතිකයක් නැතිව අවම වශයෙන් එක් උපාංගයක් භාවිතා කරන බැවින් මෙම සංවාදය තවදුරටත් සත්‍යාපිත නොවේ. + ඇතැම් පරිශ්‍රීලකයින් වලංගු අන්ත අනන්‍යතා සහතිකයක් නැතිව අවම වශයෙන් එක් උපාංගයක් භාවිතා කරන බැවින් මෙම සංවාදය තවදුරටත් සත්‍යාපිත නොවේ. සියලු උපාංග සත්‍යාපිතයි (අන්ත අනන්‍යතාව) සියලු ඇඟිලි සටහන් සත්‍යාපිතයි (ප්‍රෝතියස්) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 872f9ce6950..c97cab9970c 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -593,7 +593,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 909f267e803..da9dc9ab421 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -593,7 +593,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index ecb7160d0d5..424fe7ef390 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -591,7 +591,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 650275c6a28..44dc6b8281e 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -589,7 +589,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 1c1afc9d28f..99126c42070 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -589,7 +589,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index c82967628b6..bf372c93951 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -593,7 +593,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 1cdf54afb67..129c6204400 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -587,7 +587,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index b6375f34ee7..fcd9ef822a9 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -587,7 +587,7 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9c35b0067d6..c6cdcbbba73 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -104,7 +104,8 @@ Delete the group Start audio call Wire - Conversation search icon + Search icon + Search input Enable rich text mode button Rich text formatting Header Rich text formatting Bold @@ -304,6 +305,18 @@ Invalid password Remove this device if you have stopped using it. You will be logged out of this device immediately. Remove your device if you have stopped using it. According to your team\'s security settings, your conversation history will also be deleted. + + End-to-end Identity Certificate + Status + Serial Number + Show Certificate Details + Get Certificate + Update Certificate + Valid + Expired + Revoked + Not activated + Add this Device Enter your password to use Wire on this device. @@ -417,6 +430,7 @@ you Add Participants Name not available + Group name not available Conversation Details OPTIONS PARTICIPANTS @@ -424,6 +438,7 @@ GROUP ADMINS (%d) GROUP MEMBERS (%d) This group has %s participants.\nUp to 500 people can join a group conversation. + %s participants Show all participants (%d) Group Participants Add participants @@ -602,9 +617,13 @@ %1$s **participants** could not be added to the group. %1$s could not be added to the group. %1$s could not be added to the group. - This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. + This conversation is no longer verified, as some user uses at least one device without a valid end-to-end identity certificate. All devices are verified (end-to-end identity) All fingerprints are verified (Proteus) + Communication in Wire is always end-to-end encrypted. Everything you send and receive in this conversation is only accessible to you and other group participants.\n**Please still be careful with who you share sensitive information.** + Verified (Proteus) + Verified (End-to-end Identity) You added 1 person to the conversation @@ -652,6 +671,10 @@ left the conversation. joined the conversation. left the conversation. + All device fingerprints are verified (Proteus) + All devices are verified (End-to-end Identity) + Conversation is no longer verified + Conversation is no longer verified %1$d ping @@ -680,7 +703,12 @@ %s is typing %1$s and %2$d more are typing - + + Search messages + Search all messages in this conversation. + No results could be found.\nPlease refine your search and try again. + Search + CONTACTS New Group New Conversation @@ -816,6 +844,8 @@ If you accept their request, they will be added as a contact and you two can communicate directly. Accept Ignore + Get certainty about the identity of %s\'s before connecting. + Please verify the person\'s identity before accepting the connection request. Media Gallery Saved to Downloads folder @@ -906,7 +936,7 @@ Lock with passcode Set app lock passcode - The app will lock itself after %1$s of inactivity. To unlock the app you need to enter this passcode.\n\nMake sure to remember this passcode as there is no way to recover it. + The app will lock itself after %1$s of inactivity. To unlock the app you need to enter this passcode or use biometric authentication.\n\nMake sure to remember this passcode as there is no way to recover it. Passcode Set a passcode Enter passcode to unlock Wire @@ -974,7 +1004,7 @@ MLS Thumbprint Set password - The backup will be compressed, and you can encrypt it with a password. + The backup will be compressed and encrypted with a password. Make sure to store it in a secure place. Back Up Now Creating Backup Saving conversations... @@ -1229,4 +1259,9 @@ Authenticate with biometrics To unlock Wire Use passcode + + Certificate Details + Copy to Clipboard + Download + Certificate copied to clipboard diff --git a/app/src/test/kotlin/com/wire/android/framework/TestConversation.kt b/app/src/test/kotlin/com/wire/android/framework/TestConversation.kt index 3d44a0c7878..01af3d57d03 100644 --- a/app/src/test/kotlin/com/wire/android/framework/TestConversation.kt +++ b/app/src/test/kotlin/com/wire/android/framework/TestConversation.kt @@ -52,7 +52,8 @@ object TestConversation { userMessageTimer = null, archived = false, archivedDateTime = null, - verificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED ) val SELF = Conversation( ID.copy(value = "SELF ID"), @@ -73,7 +74,8 @@ object TestConversation { userMessageTimer = null, archived = false, archivedDateTime = null, - verificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED ) fun GROUP(protocolInfo: ProtocolInfo = ProtocolInfo.Proteus) = Conversation( @@ -95,7 +97,8 @@ object TestConversation { userMessageTimer = null, archived = false, archivedDateTime = null, - verificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED ) fun one_on_one(convId: ConversationId) = Conversation( @@ -117,7 +120,8 @@ object TestConversation { userMessageTimer = null, archived = false, archivedDateTime = null, - verificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED ) val USER_1 = UserId("member1", "domainMember") @@ -146,6 +150,7 @@ object TestConversation { userMessageTimer = null, archived = false, archivedDateTime = null, - verificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED ) } diff --git a/app/src/test/kotlin/com/wire/android/navigation/NavigationUtilsTest.kt b/app/src/test/kotlin/com/wire/android/navigation/NavigationUtilsTest.kt index 88397803c4f..fbc47c9d6f2 100644 --- a/app/src/test/kotlin/com/wire/android/navigation/NavigationUtilsTest.kt +++ b/app/src/test/kotlin/com/wire/android/navigation/NavigationUtilsTest.kt @@ -103,4 +103,47 @@ internal class NavigationUtilsTest { // Then assertEquals(mappedImagePrivateAsset, expectedPrivateAssetImage) } + + @Test + fun `given route without segments and parameters, when getting primary route, then return only host part which is the same route`() { + // Given + val route = "route" + // When + val result = route.getBaseRoute() + // Then + assertEquals(route, result) + } + + @Test + fun `given route with segments but without parameters, when getting primary route, then return only host part`() { + // Given + val host = "route" + val route = "$host/segment1/segment2" + // When + val result = route.getBaseRoute() + // Then + assertEquals(host, result) + } + + @Test + fun `given route without segments but with parameters, when getting primary route, then return only host part`() { + // Given + val host = "route" + val route = "$host?param1=value1¶m2=value2" + // When + val result = route.getBaseRoute() + // Then + assertEquals(host, result) + } + + @Test + fun `given route with segments and parameters, when getting primary route, then return only host part`() { + // Given + val host = "route" + val route = "$host/segment1/segment2?param1=value1¶m2=value2" + // When + val result = route.getBaseRoute() + // Then + assertEquals(host, result) + } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModelTest.kt index cdfb6dcc203..fe0d6b98338 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/GroupConversationDetailsViewModelTest.kt @@ -147,7 +147,8 @@ class GroupConversationDetailsViewModelTest { conversationId = conversationDetails.conversation.id, isCreator = conversationDetails.isSelfUserCreator ), - isArchived = conversationDetails.conversation.archived + isArchived = conversationDetails.conversation.archived, + isMember = true ) val (arrangement, viewModel) = GroupConversationDetailsViewModelArrangement() @@ -167,7 +168,8 @@ class GroupConversationDetailsViewModelTest { arrangement.updateConversationArchivedStatus( conversationId = viewModel.conversationId, shouldArchiveConversation = !conversationDetails.conversation.archived, - archivedStatusTimestamp = archivingEventTimestamp + archivedStatusTimestamp = archivingEventTimestamp, + onlyLocally = false ) } } @@ -194,7 +196,8 @@ class GroupConversationDetailsViewModelTest { conversationId = conversationDetails.conversation.id, isCreator = conversationDetails.isSelfUserCreator ), - isArchived = conversationDetails.conversation.archived + isArchived = conversationDetails.conversation.archived, + isMember = true ) val (arrangement, viewModel) = GroupConversationDetailsViewModelArrangement() @@ -214,7 +217,8 @@ class GroupConversationDetailsViewModelTest { arrangement.updateConversationArchivedStatus( conversationId = viewModel.conversationId, shouldArchiveConversation = false, - archivedStatusTimestamp = archivingEventTimestamp + archivedStatusTimestamp = archivingEventTimestamp, + onlyLocally = false ) } } @@ -415,7 +419,10 @@ class GroupConversationDetailsViewModelTest { conversationTypeDetail = ConversationTypeDetail.Group(details.conversation.id, details.isSelfUserCreator), selfRole = Conversation.Member.Role.Member, isTeamConversation = details.conversation.isTeamGroup(), - isArchived = false + isArchived = false, + protocol = Conversation.ProtocolInfo.Proteus, + mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED ) // When - Then assertEquals(expected, viewModel.conversationSheetContent) @@ -585,7 +592,8 @@ class GroupConversationDetailsViewModelTest { userMessageTimer = null, archived = false, archivedDateTime = null, - verificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED ), legalHoldStatus = LegalHoldStatus.DISABLED, hasOngoingCall = false, @@ -692,7 +700,7 @@ internal class GroupConversationDetailsViewModelArrangement { coEvery { isMLSEnabledUseCase() } returns true coEvery { updateConversationMutedStatus(any(), any(), any()) } returns ConversationUpdateStatusResult.Success coEvery { observeSelfDeletionTimerSettingsForConversation(any(), any()) } returns flowOf(SelfDeletionTimer.Disabled) - coEvery { updateConversationArchivedStatus(any(), any()) } returns ArchiveStatusUpdateResult.Success + coEvery { updateConversationArchivedStatus(any(), any(), any()) } returns ArchiveStatusUpdateResult.Success } suspend fun withConversationDetailUpdate(conversationDetails: ConversationDetails) = apply { @@ -719,8 +727,8 @@ internal class GroupConversationDetailsViewModelArrangement { } suspend fun withUpdateArchivedStatus(result: ArchiveStatusUpdateResult) = apply { - coEvery { updateConversationArchivedStatus(any(), any()) } returns result coEvery { updateConversationArchivedStatus(any(), any(), any()) } returns result + coEvery { updateConversationArchivedStatus(any(), any(), any(), any()) } returns result } fun arrange() = this to viewModel diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelTest.kt index cc73a66a62c..0d10563c47b 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/info/ConversationInfoViewModelTest.kt @@ -233,8 +233,8 @@ class ConversationInfoViewModelTest { viewModel.conversationInfoViewState.protocolInfo ) assertEquals( - groupConversationDetails.conversation.verificationStatus, - viewModel.conversationInfoViewState.verificationStatus + groupConversationDetails.conversation.mlsVerificationStatus, + viewModel.conversationInfoViewState.mlsVerificationStatus ) cancel() } @@ -257,8 +257,8 @@ class ConversationInfoViewModelTest { viewModel.conversationInfoViewState.protocolInfo ) assertEquals( - groupConversationDetails.conversation.verificationStatus, - viewModel.conversationInfoViewState.verificationStatus + groupConversationDetails.conversation.mlsVerificationStatus, + viewModel.conversationInfoViewState.mlsVerificationStatus ) cancel() } @@ -281,8 +281,8 @@ class ConversationInfoViewModelTest { viewModel.conversationInfoViewState.protocolInfo ) assertEquals( - groupConversationDetails.conversation.verificationStatus, - viewModel.conversationInfoViewState.verificationStatus + groupConversationDetails.conversation.mlsVerificationStatus, + viewModel.conversationInfoViewState.mlsVerificationStatus ) cancel() } @@ -302,7 +302,7 @@ class ConversationInfoViewModelTest { ) assertEquals( null, - viewModel.conversationInfoViewState.verificationStatus + viewModel.conversationInfoViewState.mlsVerificationStatus ) } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/SearchConversationMessagesViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/SearchConversationMessagesViewModelTest.kt new file mode 100644 index 00000000000..0db253effeb --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/messages/SearchConversationMessagesViewModelTest.kt @@ -0,0 +1,138 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.messages + +import android.os.Bundle +import androidx.compose.ui.text.input.TextFieldValue +import androidx.core.os.bundleOf +import androidx.lifecycle.SavedStateHandle +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.config.NavigationTestExtension +import com.wire.android.config.mockUri +import com.wire.android.ui.home.conversations.mock.mockMessageWithText +import com.wire.android.ui.home.conversations.model.MessageBody +import com.wire.android.ui.home.conversations.model.UIMessage +import com.wire.android.ui.home.conversations.model.UIMessageContent +import com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesNavArgs +import com.wire.android.ui.home.conversations.search.messages.SearchConversationMessagesViewModel +import com.wire.android.ui.home.conversations.usecase.GetConversationMessagesFromSearchUseCase +import com.wire.android.ui.navArgs +import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.data.id.ConversationId +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 kotlinx.coroutines.ExperimentalCoroutinesApi +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 + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(CoroutineTestExtension::class) +@ExtendWith(NavigationTestExtension::class) +class SearchConversationMessagesViewModelTest { + + @Test + fun `given search term, when searching for messages, then specific messages are returned`() = + runTest() { + // given + val searchTerm = "message" + val messages = listOf( + mockMessageWithText.copy( + messageContent = UIMessageContent.TextMessage( + messageBody = MessageBody( + UIText.DynamicString("message1") + ) + ) + ), + mockMessageWithText.copy( + messageContent = UIMessageContent.TextMessage( + messageBody = MessageBody( + UIText.DynamicString("message2") + ) + ) + ) + ) + + val (arrangement, viewModel) = SearchConversationMessagesViewModelArrangement() + .withSuccessSearch(searchTerm, messages) + .arrange() + + // when + viewModel.searchQueryChanged(TextFieldValue(searchTerm)) + advanceUntilIdle() + + // then + assertEquals( + TextFieldValue(searchTerm), + viewModel.searchConversationMessagesState.searchQuery + ) + coVerify(exactly = 1) { + arrangement.getSearchMessagesForConversation( + searchTerm, + arrangement.conversationId + ) + } + } + + class SearchConversationMessagesViewModelArrangement { + val conversationId: ConversationId = ConversationId( + value = "some-dummy-value", + domain = "some-dummy-domain" + ) + + @MockK + private lateinit var savedStateHandle: SavedStateHandle + + @MockK + lateinit var getSearchMessagesForConversation: GetConversationMessagesFromSearchUseCase + + private val viewModel: SearchConversationMessagesViewModel by lazy { + SearchConversationMessagesViewModel( + getSearchMessagesForConversation = getSearchMessagesForConversation, + savedStateHandle = savedStateHandle + ) + } + + init { + // Tests setup + MockKAnnotations.init(this, relaxUnitFun = true) + mockUri() + every { savedStateHandle.navArgs() } returns SearchConversationMessagesNavArgs( + conversationId = conversationId + ) + every { savedStateHandle.get("searchConversationMessagesState") } returns bundleOf("value" to "") + } + + suspend fun withSuccessSearch( + searchTerm: String, + messages: List + ) = apply { + coEvery { + getSearchMessagesForConversation(eq(searchTerm), eq(conversationId)) + } returns Either.Right(messages) + } + + fun arrange() = this to viewModel + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCaseTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCaseTest.kt new file mode 100644 index 00000000000..f52b90449dc --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/usecase/GetConversationMessagesFromSearchUseCaseTest.kt @@ -0,0 +1,194 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.conversations.usecase + +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.framework.TestMessage +import com.wire.android.framework.TestUser +import com.wire.android.mapper.MessageMapper +import com.wire.android.model.UserAvatarData +import com.wire.android.ui.home.conversations.model.MessageBody +import com.wire.android.ui.home.conversations.model.MessageSource +import com.wire.android.ui.home.conversations.model.UIMessage +import com.wire.android.ui.home.conversations.model.UIMessageContent +import com.wire.android.util.ui.UIText +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.message.Message +import com.wire.kalium.logic.data.message.MessageContent +import com.wire.kalium.logic.data.user.User +import com.wire.kalium.logic.data.user.UserAvailabilityStatus +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.feature.conversation.ObserveUserListByIdUseCase +import com.wire.kalium.logic.feature.message.GetConversationMessagesFromSearchQueryUseCase +import com.wire.kalium.logic.functional.Either +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.internal.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import java.util.UUID + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(CoroutineTestExtension::class) +class GetConversationMessagesFromSearchUseCaseTest { + + @Test + fun `given below minimum characters to search, when searching, then return an empty list`() = + runTest { + // given + val (arrangement, useCase) = Arrangement() + .arrange() + + // when + val result = useCase("a", arrangement.conversationId) + + // then + assert(result is Either.Right>) + assertEquals( + Either.Right(listOf()), + result + ) + } + + @Test + fun `given search term, when searching messages, then return messages list`() = runTest { + // given + val (arrangement, useCase) = Arrangement() + .withSearchSuccess() + .withMemberIdList() + .withMemberDetails() + .withMappedMessage( + user = Arrangement.user1, + message = Arrangement.message1 + ) + .withMappedMessage( + user = Arrangement.user2, + message = Arrangement.message2 + ) + .arrange() + + // when + val result = useCase(arrangement.searchTerm, arrangement.conversationId) + + // then + assert(result is Either.Right>) + assertEquals( + Arrangement.messages.size, + (result as Either.Right).value.size + ) + } + + class Arrangement { + val searchTerm = "message" + val conversationId = ConversationId( + value = "some-dummy-value", + domain = "some-dummy-domain" + ) + + @MockK + lateinit var getConversationMessagesFromSearch: GetConversationMessagesFromSearchQueryUseCase + + @MockK + lateinit var observeMemberDetailsByIds: ObserveUserListByIdUseCase + + @MockK + lateinit var messageMapper: MessageMapper + + private val useCase: GetConversationMessagesFromSearchUseCase by lazy { + GetConversationMessagesFromSearchUseCase( + getConversationMessagesFromSearch, + observeMemberDetailsByIds, + messageMapper + ) + } + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + } + + suspend fun withSearchSuccess() = apply { + coEvery { + getConversationMessagesFromSearch(searchTerm, conversationId) + } returns Either.Right(messages) + } + + fun withMemberIdList() = apply { + every { messageMapper.memberIdList(messages) } returns listOf( + message1.senderUserId, + message2.senderUserId + ) + } + + suspend fun withMemberDetails() = apply { + coEvery { observeMemberDetailsByIds(any()) } returns flowOf( + listOf(user1, user2) + ) + } + + fun withMappedMessage(user: User, message: Message.Standalone) = apply { + every { messageMapper.toUIMessage(users, message) } returns UIMessage.Regular( + userAvatarData = UserAvatarData( + asset = null, + availabilityStatus = UserAvailabilityStatus.NONE + ), + source = MessageSource.OtherUser, + header = TestMessage.UI_MESSAGE_HEADER.copy( + messageId = UUID.randomUUID().toString(), + userId = user.id + ), + messageContent = UIMessageContent.TextMessage( + MessageBody( + UIText.DynamicString( + (message.content as MessageContent.Text).value + ) + ) + ), + messageFooter = com.wire.android.ui.home.conversations.model.MessageFooter( + TestMessage.UI_MESSAGE_HEADER.messageId + ) + ) + } + + fun arrange() = this to useCase + + companion object { + val user1 = TestUser.OTHER_USER.copy( + id = UserId("user-id1", "domain") + ) + val user2 = TestUser.OTHER_USER.copy( + id = UserId("user-id2", "domain") + ) + val users = listOf(user1, user2) + + val message1 = TestMessage.TEXT_MESSAGE.copy( + content = MessageContent.Text("message1"), + senderUserId = user1.id + ) + val message2 = TestMessage.TEXT_MESSAGE.copy( + content = MessageContent.Text("message2"), + senderUserId = user2.id + ) + val messages = listOf(message1, message2) + } + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt index 336156ca679..1de57abc136 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversationslist/ConversationListViewModelTest.kt @@ -42,6 +42,7 @@ import com.wire.android.ui.home.conversationslist.model.DialogState import com.wire.android.ui.home.conversationslist.model.Membership import com.wire.android.util.orDefault import com.wire.android.util.ui.WireSessionImageLoader +import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.conversation.MutedConversationStatus import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.user.UserId @@ -337,11 +338,12 @@ class ConversationListViewModelTest { conversationItem.conversationId, conversationItem.conversationInfo.name, ConversationTypeDetail.Private(null, conversationItem.userId, BlockingState.NOT_BLOCKED), - !isArchiving + !isArchiving, + true ) val archivingTimestamp = 123456789L - coEvery { updateConversationArchivedStatus(any(), any(), any()) } returns ArchiveStatusUpdateResult.Success + coEvery { updateConversationArchivedStatus(any(), any(), any(), any()) } returns ArchiveStatusUpdateResult.Success conversationListViewModel.homeSnackBarState.test { conversationListViewModel.moveConversationToArchive(dialogState, archivingTimestamp) @@ -351,6 +353,7 @@ class ConversationListViewModelTest { updateConversationArchivedStatus.invoke( dialogState.conversationId, !dialogState.isArchived, + onlyLocally = false, archivingTimestamp ) } @@ -363,11 +366,12 @@ class ConversationListViewModelTest { conversationItem.conversationId, conversationItem.conversationInfo.name, ConversationTypeDetail.Private(null, conversationItem.userId, BlockingState.NOT_BLOCKED), - !isArchiving + !isArchiving, + isMember = true ) val archivingTimestamp = 123456789L - coEvery { updateConversationArchivedStatus(any(), any(), any()) } returns ArchiveStatusUpdateResult.Failure + coEvery { updateConversationArchivedStatus(any(), any(), any(), any()) } returns ArchiveStatusUpdateResult.Failure conversationListViewModel.homeSnackBarState.test { conversationListViewModel.moveConversationToArchive(dialogState, archivingTimestamp) @@ -377,7 +381,8 @@ class ConversationListViewModelTest { updateConversationArchivedStatus.invoke( dialogState.conversationId, !dialogState.isArchived, - archivingTimestamp + false, + archivingTimestamp, ) } } @@ -402,7 +407,9 @@ class ConversationListViewModelTest { userId = userId, blockingState = BlockingState.CAN_NOT_BE_BLOCKED, teamId = null, - isArchived = false + isArchived = false, + mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED ) } } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModelTest.kt index 50eb8b96de6..e59862ddd9e 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/gallery/MediaGalleryViewModelTest.kt @@ -305,7 +305,8 @@ class MediaGalleryViewModelTest { userMessageTimer = null, archived = false, archivedDateTime = null, - verificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED ), otherUser = OtherUser( QualifiedID("other-user-id", "domain-id"), diff --git a/app/src/test/kotlin/com/wire/android/ui/home/newconversation/NewConversationViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/home/newconversation/NewConversationViewModelArrangement.kt index 3976b38b2a6..f3359c3a3df 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/newconversation/NewConversationViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/newconversation/NewConversationViewModelArrangement.kt @@ -159,7 +159,8 @@ internal class NewConversationViewModelArrangement { userMessageTimer = null, archived = false, archivedDateTime = null, - verificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED ) val PUBLIC_USER = OtherUser( diff --git a/app/src/test/kotlin/com/wire/android/ui/home/settings/home/BackupAndRestoreViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/settings/home/BackupAndRestoreViewModelTest.kt index 105215cddad..95291a62a38 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/settings/home/BackupAndRestoreViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/settings/home/BackupAndRestoreViewModelTest.kt @@ -21,6 +21,7 @@ package com.wire.android.ui.home.settings.home import android.net.Uri +import androidx.compose.ui.text.input.TextFieldValue import androidx.core.net.toUri import com.wire.android.config.CoroutineTestExtension import com.wire.android.config.TestDispatcherProvider @@ -33,6 +34,8 @@ import com.wire.android.ui.home.settings.backup.PasswordValidation import com.wire.android.ui.home.settings.backup.RestoreFileValidation import com.wire.android.util.FileManager import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.feature.auth.ValidatePasswordResult +import com.wire.kalium.logic.feature.auth.ValidatePasswordUseCase import com.wire.kalium.logic.feature.backup.CreateBackupResult import com.wire.kalium.logic.feature.backup.CreateBackupUseCase import com.wire.kalium.logic.feature.backup.RestoreBackupResult @@ -103,6 +106,55 @@ class BackupAndRestoreViewModelTest { coVerify(exactly = 1) { arrangement.createBackupFile(password = password) } } + @Test + fun givenAnEmptyPassword_whenValidating_thenItUpdatePasswordStateToValid() = runTest(dispatcher.default()) { + // Given + val password = "" + val (arrangement, backupAndRestoreViewModel) = Arrangement() + .withInvalidPassword() + .arrange() + + // When + backupAndRestoreViewModel.validateBackupCreationPassword(TextFieldValue(password)) + advanceUntilIdle() + + // Then + assert(backupAndRestoreViewModel.state.passwordValidation.isValid) + coVerify(exactly = 0) { arrangement.validatePassword(any()) } + } + + @Test + fun givenANonEmptyPassword_whenItIsInvalid_thenItUpdatePasswordValidationState() = runTest(dispatcher.default()) { + // Given + val password = "mayTh3ForceBeWIthYou" + val (arrangement, backupAndRestoreViewModel) = Arrangement() + .withInvalidPassword() + .arrange() + + // When + backupAndRestoreViewModel.validateBackupCreationPassword(TextFieldValue(password)) + advanceUntilIdle() + + // Then + assert(!backupAndRestoreViewModel.state.passwordValidation.isValid) + } + + @Test + fun givenANonEmptyPassword_whenItIsValid_thenItUpdatePasswordValidationState() = runTest(dispatcher.default()) { + // Given + val password = "mayTh3ForceBeWIthYou_" + val (arrangement, backupAndRestoreViewModel) = Arrangement() + .withValidPassword() + .arrange() + + // When + backupAndRestoreViewModel.validateBackupCreationPassword(TextFieldValue(password)) + advanceUntilIdle() + + // Then + assert(backupAndRestoreViewModel.state.passwordValidation.isValid) + } + @Test fun givenANonEmptyPassword_whenCreatingABackupWithAGivenError_thenItReturnsAFailure() = runTest(dispatcher.default()) { // Given @@ -412,6 +464,9 @@ class BackupAndRestoreViewModelTest { @MockK private lateinit var verifyBackup: VerifyBackupUseCase + @MockK + lateinit var validatePassword: ValidatePasswordUseCase + @MockK lateinit var fileManager: FileManager @@ -423,7 +478,8 @@ class BackupAndRestoreViewModelTest { verifyBackup = verifyBackup, kaliumFileSystem = fakeKaliumFileSystem, dispatcher = dispatcher, - fileManager = fileManager + fileManager = fileManager, + validatePassword = validatePassword ) fun withSuccessfulCreation(password: String) = apply { @@ -491,6 +547,14 @@ class BackupAndRestoreViewModelTest { coEvery { importBackup(any(), any()) } returns error } + fun withValidPassword() = apply { + every { validatePassword(any()) } returns ValidatePasswordResult.Valid + } + + fun withInvalidPassword() = apply { + every { validatePassword(any()) } returns ValidatePasswordResult.Invalid() + } + fun arrange() = this to viewModel } } 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 657c453b3d5..cb4ef9d9c74 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 @@ -18,6 +18,8 @@ import com.wire.kalium.logic.feature.client.DeleteClientUseCase import com.wire.kalium.logic.feature.client.GetClientDetailsResult import com.wire.kalium.logic.feature.client.ObserveClientDetailsUseCase import com.wire.kalium.logic.feature.client.UpdateClientVerificationStatusUseCase +import com.wire.kalium.logic.feature.e2ei.usecase.GetE2EICertificateUseCaseResult +import com.wire.kalium.logic.feature.e2ei.usecase.GetE2eiCertificateUseCase import com.wire.kalium.logic.feature.user.GetUserInfoResult import com.wire.kalium.logic.feature.user.IsPasswordRequiredUseCase import com.wire.kalium.logic.feature.user.ObserveUserInfoUseCase @@ -212,6 +214,9 @@ class DeviceDetailsViewModelTest { @MockK lateinit var observeUserInfo: ObserveUserInfoUseCase + @MockK + lateinit var getE2eiCertificate: GetE2eiCertificateUseCase + @MockK(relaxed = true) lateinit var onSuccess: () -> Unit @@ -226,7 +231,8 @@ class DeviceDetailsViewModelTest { fingerprintUseCase = deviceFingerprint, updateClientVerificationStatus = updateClientVerificationStatus, currentUserId = currentUserId, - observeUserInfo = observeUserInfo + observeUserInfo = observeUserInfo, + e2eiCertificate = getE2eiCertificate ) } @@ -234,6 +240,7 @@ class DeviceDetailsViewModelTest { MockKAnnotations.init(this, relaxUnitFun = true) withFingerprintSuccess() coEvery { observeUserInfo(any()) } returns flowOf(GetUserInfoResult.Success(TestUser.OTHER_USER, null)) + coEvery { getE2eiCertificate(any()) } returns GetE2EICertificateUseCaseResult.Failure.NotActivated } fun withUserRequiresPasswordResult(result: IsPasswordRequiredUseCase.Result = IsPasswordRequiredUseCase.Result.Success(true)) = diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModelTest.kt index a7c48a3c747..61aa56606fa 100644 --- a/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileScreenViewModelTest.kt @@ -194,7 +194,7 @@ class OtherUserProfileScreenViewModelTest { @Test fun `given not connected user, then direct conversation is not found`() = runTest { // given - val (arrangement, viewModel) = OtherUserProfileViewModelArrangement() + val (_, viewModel) = OtherUserProfileViewModelArrangement() .withUserInfo( GetUserInfoResult.Success(OTHER_USER.copy(connectionStatus = ConnectionState.NOT_CONNECTED), TEAM) ) @@ -247,7 +247,8 @@ class OtherUserProfileScreenViewModelTest { userMessageTimer = null, archived = false, archivedDateTime = null, - verificationStatus = Conversation.VerificationStatus.NOT_VERIFIED + mlsVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED, + proteusVerificationStatus = Conversation.VerificationStatus.NOT_VERIFIED ) val CONVERSATION_ROLE_DATA = ConversationRoleData( "some_name", diff --git a/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileViewModelArrangement.kt b/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileViewModelArrangement.kt index 39f2fb89b0d..6bf5877728c 100644 --- a/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileViewModelArrangement.kt +++ b/app/src/test/kotlin/com/wire/android/ui/userprofile/other/OtherUserProfileViewModelArrangement.kt @@ -144,7 +144,7 @@ internal class OtherUserProfileViewModelArrangement { ) ) coEvery { observeSelfUser() } returns flowOf(TestUser.SELF_USER) - coEvery { updateConversationArchivedStatus(any(), any()) } returns ArchiveStatusUpdateResult.Success + coEvery { updateConversationArchivedStatus(any(), any(), any()) } returns ArchiveStatusUpdateResult.Success every { userTypeMapper.toMembership(any()) } returns Membership.None coEvery { getOneToOneConversation(USER_ID) } returns flowOf( GetOneToOneConversationUseCase.Result.Success(OtherUserProfileScreenViewModelTest.CONVERSATION) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0996e8719c3..a601f24280b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,6 +38,7 @@ androidx-biometric = "1.1.0" # Compose composeBom = "2023.10.00" # TODO check if in new version [anchoredDraggable] is available +compose-activity = "1.8.0" compose-compiler = "1.5.2" compose-constraint = "1.0.1" compose-navigation = "2.7.3" # adjusted to work with compose-destinations "1.9.54" @@ -159,7 +160,6 @@ hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hilt-work" } # Compose BOM compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } -compose-activity = { module = "androidx.activity:activity-compose" } compose-foundation = { module = "androidx.compose.foundation:foundation" } compose-material-core = { module = "androidx.compose.material:material" } compose-material-icons = { module = "androidx.compose.material:material-icons-extended" } @@ -173,6 +173,7 @@ compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-preview = { module = "androidx.compose.ui:ui-tooling-preview" } # Compose other +compose-activity = { module = "androidx.activity:activity-compose", version.ref = "compose-activity" } compose-constraintLayout = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "compose-constraint" } compose-navigation = { module = "androidx.navigation:navigation-compose", version.ref = "compose-navigation" } compose-destinations-core = { module = "io.github.raamcosta.compose-destinations:animations-core", version.ref = "compose-destinations" } diff --git a/kalium b/kalium index c82d8aa7757..07301bd8a12 160000 --- a/kalium +++ b/kalium @@ -1 +1 @@ -Subproject commit c82d8aa7757ddb2b22c6ded6120daed6e2c35a2a +Subproject commit 07301bd8a120b930e057a94c1d80fb99e9edc16f