From 04e06904f2e85911bc0cbd5f939808494fd76d55 Mon Sep 17 00:00:00 2001 From: David Perez Date: Thu, 22 May 2025 14:01:54 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=8D=92PM-17660:=20Sync=20learn=20more=20c?= =?UTF-8?q?herry=20pick=20(#5249)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/itemlisting/ItemListingScreen.kt | 191 +++++++++++++----- .../itemlisting/ItemListingViewModel.kt | 20 +- .../components/card/BitwardenActionCard.kt | 4 +- .../components/row/BitwardenTextRow.kt | 3 +- .../feature/settings/SettingsScreen.kt | 181 +++++++++++++++++ .../feature/settings/SettingsViewModel.kt | 17 +- authenticator/src/main/res/values/strings.xml | 4 +- .../itemlisting/ItemListingScreenTest.kt | 175 ++++++++++++++-- .../itemlisting/ItemListingViewModelTest.kt | 9 + .../feature/settings/SettingsScreenTest.kt | 9 + .../feature/settings/SettingsViewModelTest.kt | 9 + 11 files changed, 544 insertions(+), 78 deletions(-) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt index 44ee72c7a9..d8c3b8d917 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreen.kt @@ -8,16 +8,21 @@ import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FabPosition import androidx.compose.material3.HorizontalDivider @@ -56,7 +61,9 @@ import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.Ve import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenMediumTopAppBar import com.bitwarden.authenticator.ui.platform.components.appbar.BitwardenTopAppBar import com.bitwarden.authenticator.ui.platform.components.appbar.action.BitwardenSearchActionItem +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledButton import com.bitwarden.authenticator.ui.platform.components.button.BitwardenFilledTonalButton +import com.bitwarden.authenticator.ui.platform.components.button.BitwardenTextButton import com.bitwarden.authenticator.ui.platform.components.card.BitwardenActionCard import com.bitwarden.authenticator.ui.platform.components.dialog.BasicDialogState import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenBasicDialog @@ -139,6 +146,10 @@ fun ItemListingScreen( ) } + ItemListingEvent.NavigateToSyncInformation -> { + intentManager.launchUri("https://bitwarden.com/help/totp-sync".toUri()) + } + ItemListingEvent.NavigateToBitwardenSettings -> { intentManager.startMainBitwardenAppAccountSettings() } @@ -230,6 +241,9 @@ fun ItemListingScreen( viewModel.trySendAction(ItemListingAction.SyncWithBitwardenDismiss) } }, + onSyncLearnMoreClick = remember(viewModel) { + { viewModel.trySendAction(ItemListingAction.SyncLearnMoreClick) } + }, ) } @@ -270,6 +284,9 @@ fun ItemListingScreen( viewModel.trySendAction(ItemListingAction.SyncWithBitwardenClick) } }, + onSyncLearnMoreClick = remember(viewModel) { + { viewModel.trySendAction(ItemListingAction.SyncLearnMoreClick) } + }, onDismissSyncWithBitwardenClick = remember(viewModel) { { viewModel.trySendAction(ItemListingAction.SyncWithBitwardenDismiss) @@ -339,6 +356,7 @@ private fun ItemListingContent( onDismissDownloadBitwardenClick: () -> Unit, onSyncWithBitwardenClick: () -> Unit, onDismissSyncWithBitwardenClick: () -> Unit, + onSyncLearnMoreClick: () -> Unit, ) { BitwardenScaffold( modifier = Modifier @@ -402,23 +420,15 @@ private fun ItemListingContent( ) { LazyColumn { item { - when (state.actionCard) { - ItemListingState.ActionCardState.DownloadBitwardenApp -> - DownloadBitwardenActionCard( - modifier = Modifier.padding(horizontal = 16.dp), - onDownloadBitwardenClick = onDownloadBitwardenClick, - onDismissClick = onDismissDownloadBitwardenClick, - ) - - ItemListingState.ActionCardState.SyncWithBitwarden -> - SyncWithBitwardenActionCard( - modifier = Modifier.padding(16.dp), - onSyncWithBitwardenClick = onSyncWithBitwardenClick, - onDismissClick = onDismissSyncWithBitwardenClick, - ) - - ItemListingState.ActionCardState.None -> Unit - } + ActionCard( + actionCardState = state.actionCard, + onDownloadBitwardenClick = onDownloadBitwardenClick, + onDownloadBitwardenDismissClick = onDismissDownloadBitwardenClick, + onSyncWithBitwardenClick = onSyncWithBitwardenClick, + onSyncWithBitwardenDismissClick = onDismissSyncWithBitwardenClick, + onSyncLearnMoreClick = onSyncLearnMoreClick, + modifier = Modifier.padding(all = 16.dp), + ) } if (state.favoriteItems.isNotEmpty()) { item { @@ -572,6 +582,7 @@ fun EmptyItemListingContent( onDownloadBitwardenClick: () -> Unit, onDismissDownloadBitwardenClick: () -> Unit, onSyncWithBitwardenClick: () -> Unit, + onSyncLearnMoreClick: () -> Unit, onDismissSyncWithBitwardenClick: () -> Unit, ) { BitwardenScaffold( @@ -635,23 +646,14 @@ fun EmptyItemListingContent( ItemListingState.ActionCardState.SyncWithBitwarden -> Arrangement.Top }, ) { - when (actionCardState) { - ItemListingState.ActionCardState.DownloadBitwardenApp -> - DownloadBitwardenActionCard( - modifier = Modifier.padding(16.dp), - onDismissClick = onDismissDownloadBitwardenClick, - onDownloadBitwardenClick = onDownloadBitwardenClick, - ) - - ItemListingState.ActionCardState.SyncWithBitwarden -> - SyncWithBitwardenActionCard( - modifier = Modifier.padding(16.dp), - onDismissClick = onDismissSyncWithBitwardenClick, - onSyncWithBitwardenClick = onSyncWithBitwardenClick, - ) - - ItemListingState.ActionCardState.None -> Unit - } + ActionCard( + actionCardState = actionCardState, + onDownloadBitwardenClick = onDownloadBitwardenClick, + onDownloadBitwardenDismissClick = onDismissDownloadBitwardenClick, + onSyncWithBitwardenClick = onSyncWithBitwardenClick, + onSyncWithBitwardenDismissClick = onDismissSyncWithBitwardenClick, + onSyncLearnMoreClick = onSyncLearnMoreClick, + ) // Add a spacer if an action card is showing: when (actionCardState) { @@ -735,32 +737,114 @@ private fun DownloadBitwardenActionCard( }, ) +@Suppress("LongMethod") @Composable private fun SyncWithBitwardenActionCard( modifier: Modifier = Modifier, onDismissClick: () -> Unit, + onAppSettingsClick: () -> Unit, + onLearnMoreClick: () -> Unit, +) { + Card( + modifier = modifier, + shape = RoundedCornerShape(size = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + elevation = CardDefaults.elevatedCardElevation(), + ) { + Spacer(Modifier.height(height = 4.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + Spacer(Modifier.width(width = 16.dp)) + Row( + modifier = Modifier.padding(top = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = rememberVectorPainter(id = R.drawable.ic_bitwarden), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(size = 20.dp), + ) + Spacer(Modifier.width(width = 16.dp)) + Text( + text = stringResource(id = R.string.sync_with_the_bitwarden_app), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + Spacer(Modifier.weight(weight = 1f)) + Spacer(Modifier.width(width = 16.dp)) + IconButton(onClick = onDismissClick) { + Icon( + painter = painterResource(id = R.drawable.ic_close), + contentDescription = stringResource(id = R.string.close), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(size = 24.dp), + ) + } + Spacer(Modifier.width(width = 4.dp)) + } + Text( + text = stringResource(id = R.string.sync_with_bitwarden_action_card_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(start = 36.dp, end = 48.dp) + .fillMaxWidth(), + ) + Spacer(Modifier.height(height = 16.dp)) + BitwardenFilledButton( + label = stringResource(id = R.string.take_me_to_app_settings), + onClick = onAppSettingsClick, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + BitwardenTextButton( + label = stringResource(id = R.string.learn_more), + onClick = onLearnMoreClick, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + Spacer(Modifier.height(height = 4.dp)) + } +} + +@Composable +private fun ActionCard( + actionCardState: ItemListingState.ActionCardState, + onDownloadBitwardenClick: () -> Unit, + onDownloadBitwardenDismissClick: () -> Unit, onSyncWithBitwardenClick: () -> Unit, -) = BitwardenActionCard( - modifier = modifier, - actionIcon = rememberVectorPainter(R.drawable.ic_refresh), - actionText = stringResource(R.string.sync_with_bitwarden_action_card_message), - callToActionText = stringResource(R.string.go_to_settings), - titleText = stringResource(R.string.sync_with_the_bitwarden_app), - onCardClicked = onSyncWithBitwardenClick, - trailingContent = { - IconButton( - onClick = onDismissClick, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_close), - contentDescription = stringResource(id = R.string.close), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier - .size(24.dp), + onSyncWithBitwardenDismissClick: () -> Unit, + onSyncLearnMoreClick: () -> Unit, + modifier: Modifier = Modifier, +) { + when (actionCardState) { + ItemListingState.ActionCardState.DownloadBitwardenApp -> { + DownloadBitwardenActionCard( + modifier = modifier, + onDownloadBitwardenClick = onDownloadBitwardenClick, + onDismissClick = onDownloadBitwardenDismissClick, ) } - }, -) + + ItemListingState.ActionCardState.SyncWithBitwarden -> { + SyncWithBitwardenActionCard( + modifier = modifier, + onAppSettingsClick = onSyncWithBitwardenClick, + onDismissClick = onSyncWithBitwardenDismissClick, + onLearnMoreClick = onSyncLearnMoreClick, + ) + } + + ItemListingState.ActionCardState.None -> Unit + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -776,6 +860,7 @@ private fun EmptyListingContentPreview() { onDownloadBitwardenClick = { }, onDismissDownloadBitwardenClick = { }, onSyncWithBitwardenClick = { }, + onSyncLearnMoreClick = { }, onDismissSyncWithBitwardenClick = { }, ) } diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt index 92d81865d4..484c3a5a08 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModel.kt @@ -153,6 +153,10 @@ class ItemListingViewModel @Inject constructor( ItemListingAction.SyncWithBitwardenDismiss -> { handleSyncWithBitwardenDismiss() } + + ItemListingAction.SyncLearnMoreClick -> { + handleSyncLearnMoreClick() + } } } @@ -564,6 +568,10 @@ class ItemListingViewModel @Inject constructor( } } + private fun handleSyncLearnMoreClick() { + sendEvent(ItemListingEvent.NavigateToSyncInformation) + } + /** * Converts a [SharedVerificationCodesState] into an action card for display. */ @@ -794,6 +802,11 @@ sealed class ItemListingEvent { */ data object NavigateToAppSettings : ItemListingEvent() + /** + * Navigate to the sync information web page. + */ + data object NavigateToSyncInformation : ItemListingEvent() + /** * Navigate to Bitwarden play store listing. */ @@ -872,6 +885,11 @@ sealed class ItemListingAction { */ data object SyncWithBitwardenClick : ItemListingAction() + /** + * The user tapped the learn more button on the sync action card. + */ + data object SyncLearnMoreClick : ItemListingAction() + /** * The user dismissed sync Bitwarden action card. */ @@ -886,7 +904,7 @@ sealed class ItemListingAction { * Represents an action triggered when the user clicks an item in the dropdown menu. * * @param menuAction The action selected from the dropdown menu. - * @param id The identifier of the item on which the action is being performed. + * @param item The item on which the action is being performed. */ data class DropdownMenuClick( val menuAction: VaultDropdownMenuAction, diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/card/BitwardenActionCard.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/card/BitwardenActionCard.kt index e273f3cac4..27c59486e2 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/card/BitwardenActionCard.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/card/BitwardenActionCard.kt @@ -44,8 +44,8 @@ fun BitwardenActionCard( onClick = onCardClicked, shape = RoundedCornerShape(size = 16.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, - disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + containerColor = MaterialTheme.colorScheme.surfaceContainer, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainer, ), modifier = modifier, elevation = CardDefaults.elevatedCardElevation(), diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/row/BitwardenTextRow.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/row/BitwardenTextRow.kt index 637458df8d..0a33a63f9f 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/row/BitwardenTextRow.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/row/BitwardenTextRow.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp /** @@ -37,7 +38,7 @@ fun BitwardenTextRow( text: String, onClick: () -> Unit, modifier: Modifier = Modifier, - description: String? = null, + description: AnnotatedString? = null, withDivider: Boolean = false, content: (@Composable () -> Unit)? = null, ) { diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt index 4bd44bdc50..5975785c89 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt @@ -3,6 +3,10 @@ package com.bitwarden.authenticator.ui.platform.feature.settings import android.content.Intent +import android.text.Annotation +import android.text.SpannableStringBuilder +import android.text.SpannedString +import androidx.annotation.StringRes import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -34,6 +38,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag @@ -42,9 +47,15 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.net.toUri +import androidx.core.text.getSpans import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitwarden.authenticator.R @@ -66,6 +77,7 @@ import com.bitwarden.authenticator.ui.platform.manager.intent.IntentManager import com.bitwarden.authenticator.ui.platform.theme.AuthenticatorTheme import com.bitwarden.authenticator.ui.platform.util.displayLabel import com.bitwarden.ui.platform.base.util.EventsEffect +import com.bitwarden.ui.platform.base.util.toAnnotatedString import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText @@ -107,6 +119,10 @@ fun SettingsScreen( intentManager.launchUri("https://bitwarden.com/privacy".toUri()) } + SettingsEvent.NavigateToSyncInformation -> { + intentManager.launchUri("https://bitwarden.com/help/totp-sync".toUri()) + } + SettingsEvent.NavigateToBitwardenApp -> { intentManager.startActivity( @@ -177,6 +193,9 @@ fun SettingsScreen( viewModel.trySendAction(SettingsAction.DataClick.SyncWithBitwardenClick) } }, + onSyncLearnMoreClick = remember(viewModel) { + { viewModel.trySendAction(SettingsAction.DataClick.SyncLearnMoreClick) } + }, onDefaultSaveOptionUpdated = remember(viewModel) { { viewModel.trySendAction( @@ -280,6 +299,7 @@ private fun VaultSettings( onImportClick: () -> Unit, onBackupClick: () -> Unit, onSyncWithBitwardenClick: () -> Unit, + onSyncLearnMoreClick: () -> Unit, onDefaultSaveOptionUpdated: (DefaultSaveOption) -> Unit, shouldShowSyncWithBitwardenApp: Boolean, shouldShowDefaultSaveOptions: Boolean, @@ -339,6 +359,22 @@ private fun VaultSettings( Spacer(modifier = Modifier.height(8.dp)) BitwardenTextRow( text = stringResource(id = R.string.sync_with_bitwarden_app), + description = R.string + .this_feature_is_not_not_yet_available_for_self_hosted_users + .toAnnotatedString( + style = spanStyleOf( + color = MaterialTheme.colorScheme.onSurfaceVariant, + textStyle = MaterialTheme.typography.bodyMedium, + ), + linkHighlightStyle = spanStyleOf( + color = MaterialTheme.colorScheme.primary, + textStyle = MaterialTheme.typography.labelLarge, + ), + ) { + when (it) { + "learnMore" -> onSyncLearnMoreClick() + } + }, onClick = onSyncWithBitwardenClick, modifier = modifier, withDivider = true, @@ -634,6 +670,151 @@ private fun CopyRow( } } +/** + * Creates a new [SpanStyle] from the specified [color] and [textStyle]. + */ +private fun spanStyleOf( + color: Color, + textStyle: TextStyle, +): SpanStyle = + SpanStyle( + color = color, + fontSize = textStyle.fontSize, + fontWeight = textStyle.fontWeight, + fontStyle = textStyle.fontStyle, + fontSynthesis = textStyle.fontSynthesis, + fontFamily = textStyle.fontFamily, + fontFeatureSettings = textStyle.fontFeatureSettings, + letterSpacing = textStyle.letterSpacing, + baselineShift = textStyle.baselineShift, + textGeometricTransform = textStyle.textGeometricTransform, + localeList = textStyle.localeList, + background = textStyle.background, + textDecoration = textStyle.textDecoration, + shadow = textStyle.shadow, + platformStyle = textStyle.platformStyle?.spanStyle, + drawStyle = textStyle.drawStyle, + ) + +/** + * Creates an [AnnotatedString] from a string resource allowing for optional arguments + * to be applied. + * @param args Optional arguments to be applied to the string resource, must already be a + * [String] + * @param style Style to apply to the entire string + * @param linkHighlightStyle Style to apply to part of the resource that has been annotated with + * the "link" annotation. + * @param onAnnotationClick Callback to invoke when a link annotation is clicked. Will pass back + * the value of the annotation as a string to allow for delineation if there are multiple callbacks + * to be applied. + * + * In order for the styles to be applied the resource must contain custom annotations for example: + * + * If a word or phrase is to have the [linkHighlightStyle] applied then it must be annotated + * with the custom XML tag: where the value will be passed back + * in [onAnnotationClick] and used to delineate which annotation was clicked. + * Foo bar baz + * + * If the contains a format argument (%1$s) then that argument should be wrapped in the + * following custom XML tag: where the value is the index of the argument, + * starting at 0. + */ +@Suppress("LongMethod") +@Composable +private fun @receiver:StringRes Int.toAnnotatedString( + vararg args: String, + style: SpanStyle, + linkHighlightStyle: SpanStyle, + onAnnotationClick: ((annotationKey: String) -> Unit)? = null, +): AnnotatedString { + val resources = LocalContext.current.resources + // The spannableBuilder is used to help parse through the annotations in the string resource. + val spannableBuilder = try { + SpannableStringBuilder(resources.getText(this) as SpannedString) + } catch (_: ClassCastException) { + // the resource did not contain and valid spans so we just return the raw string. + return stringResource(id = this, *args).toAnnotatedString() + } + // Replace any format arguments with the provided arguments. + spannableBuilder.applyArgAnnotations(args = args) + + // The annotatedStringBuilder is used to apply the styles to the string resource. + val annotatedStringBuilder = AnnotatedString.Builder() + + // Add the entire string to the annotated string builder and apply the style. + annotatedStringBuilder.append(spannableBuilder) + annotatedStringBuilder.addStyle( + style = style, + start = 0, + end = spannableBuilder.length, + ) + val annotations = spannableBuilder.getSpans() + // Iterate through the annotations and apply the appropriate style. If the [Annotation.key] + // does not match a [ValidAnnotationType] an exception will be thrown. + for (annotation in annotations) { + // Skip the annotation if it does not have a valid start in the spanned string. + val start = spannableBuilder.getSpanStart(annotation).takeIf { it >= 0 } ?: continue + val end = spannableBuilder.getSpanEnd(annotation) + when (ValidAnnotationType.valueOf(annotation.key.uppercase())) { + ValidAnnotationType.EMPHASIS -> Unit + ValidAnnotationType.LINK -> { + val link = LinkAnnotation.Clickable( + tag = annotation.value.orEmpty(), + styles = TextLinkStyles( + style = linkHighlightStyle, + ), + ) { + onAnnotationClick?.invoke(annotation.value.orEmpty()) + } + annotatedStringBuilder.addLink( + link, + start = start, + end = end, + ) + } + // Handled prior to this point, not styling to be applied. + ValidAnnotationType.ARG -> Unit + } + } + return remember { annotatedStringBuilder.toAnnotatedString() } +} + +/** + * The span between the and tags in the string resource is + * replaced with the index value in the provided [args]. + */ +private fun SpannableStringBuilder.applyArgAnnotations( + vararg args: String, +) { + val argAnnotations = getSpans() + .filter { it.isArgAnnotation() } + for (annotation in argAnnotations) { + // Skip the annotation if it does not have a valid start in the spanned string. + val spanStart = getSpanStart(annotation).takeIf { it >= 0 } ?: continue + val argIndex = Integer.parseInt(annotation.value) + // if no string is available just replace it with an empty string. + val replacementString = args.getOrNull(argIndex).orEmpty() + this.replace( + spanStart, + this.getSpanEnd(annotation), + replacementString, + ) + } +} + +private fun Annotation.isArgAnnotation(): Boolean = + this.key.uppercase() == ValidAnnotationType.ARG.name + +/** + * Enumerated values representing the valid keys that can be processed + * by [Int.toAnnotatedString] + */ +private enum class ValidAnnotationType { + ARG, + LINK, + EMPHASIS, +} + //endregion About settings @Preview diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt index 62b9afaf0d..e42e7c9d0f 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt @@ -184,6 +184,7 @@ class SettingsViewModel @Inject constructor( SettingsAction.DataClick.ImportClick -> handleImportClick() SettingsAction.DataClick.BackupClick -> handleBackupClick() SettingsAction.DataClick.SyncWithBitwardenClick -> handleSyncWithBitwardenClick() + SettingsAction.DataClick.SyncLearnMoreClick -> handleSyncLearnMoreClick() is SettingsAction.DataClick.DefaultSaveOptionUpdated -> handleDefaultSaveOptionChosen(action) } @@ -215,6 +216,10 @@ class SettingsViewModel @Inject constructor( } } + private fun handleSyncLearnMoreClick() { + sendEvent(SettingsEvent.NavigateToSyncInformation) + } + private fun handleExportClick() { sendEvent(SettingsEvent.NavigateToExport) } @@ -424,6 +429,11 @@ sealed class SettingsEvent { */ data object NavigateToPrivacyPolicy : SettingsEvent() + /** + * Navigate to the sync learn more web page. + */ + data object NavigateToSyncInformation : SettingsEvent() + /** * Navigate to the Bitwarden account settings. */ @@ -491,7 +501,12 @@ sealed class SettingsAction( data object SyncWithBitwardenClick : DataClick() /** - * User confirmed a new [DeafultSaveOption]. + * Indicates the user clicked sync learn more button. + */ + data object SyncLearnMoreClick : DataClick() + + /** + * User confirmed a new [DefaultSaveOption]. */ data class DefaultSaveOptionUpdated(val option: DefaultSaveOption) : DataClick() } diff --git a/authenticator/src/main/res/values/strings.xml b/authenticator/src/main/res/values/strings.xml index 148f630599..6bf18030ea 100644 --- a/authenticator/src/main/res/values/strings.xml +++ b/authenticator/src/main/res/values/strings.xml @@ -125,11 +125,13 @@ Store all of your logins and sync verification codes directly with the Authenticator app. Download now Sync with Bitwarden app + This feature is not yet available for self-hosted users. Learn more Unable to sync codes from the Bitwarden app. Make sure both apps are up-to-date. You can still access your existing codes in the Bitwarden app. %1$s | %2$s Sync with the Bitwarden app Go to settings - Allow Authenticator app syncing in settings to view all of your verification codes here. + In order to view all of your verification codes, you’ll need to allow for syncing on all of your accounts. + Take me to the app settings Something went wrong Please try again Move to Bitwarden diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt index 020c588329..a68d0bc1e8 100644 --- a/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingScreenTest.kt @@ -1,13 +1,20 @@ package com.bitwarden.authenticator.ui.authenticator.feature.itemlisting +import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasScrollToNodeAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.longClick import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode import androidx.compose.ui.test.performTouchInput +import androidx.core.net.toUri import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.SharedCodesDisplayState import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VaultDropdownMenuAction import com.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.VerificationCodeDisplayItem @@ -44,7 +51,9 @@ class ItemListingScreenTest : BaseComposeTest() { every { trySendAction(any()) } just runs } - private val intentManager: IntentManager = mockk() + private val intentManager: IntentManager = mockk { + every { launchUri(uri = any()) } just runs + } private val permissionsManager = FakePermissionManager() @Before @@ -63,6 +72,14 @@ class ItemListingScreenTest : BaseComposeTest() { } } + @Test + fun `on NavigateToSyncInformation should launch sync uri`() { + mutableEventFlow.tryEmit(ItemListingEvent.NavigateToSyncInformation) + verify(exactly = 1) { + intentManager.launchUri(uri = "https://bitwarden.com/help/totp-sync".toUri()) + } + } + @Test @Suppress("MaxLineLength") fun `when denying camera permissions and attempting to add a code we should be shown the manual entry screen`() { @@ -174,35 +191,74 @@ class ItemListingScreenTest : BaseComposeTest() { @Test @Suppress("MaxLineLength") - fun `on sync with bitwarden action card click in empty state should send SyncWithBitwardenClick`() { - mutableStateFlow.value = DEFAULT_STATE.copy( - viewState = ItemListingState.ViewState.NoItems( - actionCard = ItemListingState.ActionCardState.SyncWithBitwarden, - ), - ) + fun `on sync with bitwarden app settings click in empty state should send SyncWithBitwardenClick`() { + mutableStateFlow.update { + it.copy( + viewState = ItemListingState.ViewState.NoItems( + actionCard = ItemListingState.ActionCardState.SyncWithBitwarden, + ), + ) + } + composeTestRule - .onNodeWithText("Sync with the Bitwarden app") + .onNodeWithText(text = "Take me to the app settings") .performClick() verify { viewModel.trySendAction(ItemListingAction.SyncWithBitwardenClick) } } @Test - @Suppress("MaxLineLength") - fun `on sync with bitwarden action card click in full state should send SyncWithBitwardenClick`() { - mutableStateFlow.value = DEFAULT_STATE.copy( - viewState = ItemListingState.ViewState.Content( - favoriteItems = emptyList(), - itemList = emptyList(), - sharedItems = SharedCodesDisplayState.Codes(emptyList()), - actionCard = ItemListingState.ActionCardState.SyncWithBitwarden, - ), - ) + fun `on sync with bitwarden learn more click in empty state should send SyncLearnMoreClick`() { + mutableStateFlow.update { + it.copy( + viewState = ItemListingState.ViewState.NoItems( + actionCard = ItemListingState.ActionCardState.SyncWithBitwarden, + ), + ) + } + composeTestRule - .onNodeWithText("Sync with the Bitwarden app") + .onNodeWithText(text = "Learn more") + .performClick() + verify { viewModel.trySendAction(ItemListingAction.SyncLearnMoreClick) } + } + + @Test + @Suppress("MaxLineLength") + fun `on sync with bitwarden app settings click in full state should send SyncWithBitwardenClick`() { + mutableStateFlow.update { + it.copy( + viewState = ItemListingState.ViewState.Content( + favoriteItems = emptyList(), + itemList = emptyList(), + sharedItems = SharedCodesDisplayState.Codes(emptyList()), + actionCard = ItemListingState.ActionCardState.SyncWithBitwarden, + ), + ) + } + composeTestRule + .onNodeWithTextAfterScroll(text = "Take me to the app settings") .performClick() verify { viewModel.trySendAction(ItemListingAction.SyncWithBitwardenClick) } } + @Test + fun `on sync with bitwarden learn more click in full state should send SyncLearnMoreClick`() { + mutableStateFlow.update { + it.copy( + viewState = ItemListingState.ViewState.Content( + favoriteItems = emptyList(), + itemList = emptyList(), + sharedItems = SharedCodesDisplayState.Codes(emptyList()), + actionCard = ItemListingState.ActionCardState.SyncWithBitwarden, + ), + ) + } + composeTestRule + .onNodeWithTextAfterScroll(text = "Learn more") + .performClick() + verify { viewModel.trySendAction(ItemListingAction.SyncLearnMoreClick) } + } + @Test @Suppress("MaxLineLength") fun `on sync with bitwarden action card dismiss in empty state should send SyncWithBitwardenDismiss`() { @@ -234,6 +290,64 @@ class ItemListingScreenTest : BaseComposeTest() { verify { viewModel.trySendAction(ItemListingAction.SyncWithBitwardenDismiss) } } + @Test + fun `on download bitwarden click in empty state should send DownloadBitwardenClick`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.NoItems( + actionCard = ItemListingState.ActionCardState.DownloadBitwardenApp, + ), + ) + composeTestRule + .onNodeWithText(text = "Download now") + .performClick() + verify { viewModel.trySendAction(ItemListingAction.DownloadBitwardenClick) } + } + + @Test + fun `on download bitwarden click in full state should send DownloadBitwardenClick`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + favoriteItems = emptyList(), + itemList = emptyList(), + sharedItems = SharedCodesDisplayState.Codes(emptyList()), + actionCard = ItemListingState.ActionCardState.DownloadBitwardenApp, + ), + ) + composeTestRule + .onNodeWithTextAfterScroll(text = "Download now") + .performClick() + verify { viewModel.trySendAction(ItemListingAction.DownloadBitwardenClick) } + } + + @Test + fun `on download bitwarden dismiss in empty state should send DownloadBitwardenDismiss`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.NoItems( + actionCard = ItemListingState.ActionCardState.DownloadBitwardenApp, + ), + ) + composeTestRule + .onNodeWithContentDescription(label = "Close") + .performClick() + verify { viewModel.trySendAction(ItemListingAction.DownloadBitwardenDismiss) } + } + + @Test + fun `on download bitwarden dismiss in full state should send DownloadBitwardenDismiss`() { + mutableStateFlow.value = DEFAULT_STATE.copy( + viewState = ItemListingState.ViewState.Content( + favoriteItems = emptyList(), + itemList = emptyList(), + sharedItems = SharedCodesDisplayState.Codes(emptyList()), + actionCard = ItemListingState.ActionCardState.DownloadBitwardenApp, + ), + ) + composeTestRule + .onNodeWithContentDescriptionAfterScroll(label = "Close") + .performClick() + verify { viewModel.trySendAction(ItemListingAction.DownloadBitwardenDismiss) } + } + @Test fun `clicking Move to Bitwarden should send MoveToBitwardenClick`() { mutableStateFlow.value = DEFAULT_STATE.copy( @@ -350,3 +464,26 @@ private val DEFAULT_STATE = ItemListingState( ), dialog = null, ) + +/** + * A helper used to scroll to and get the matching node in a scrollable list. This is intended to + * be used with lazy lists that would otherwise fail when calling [performScrollToNode]. + */ +fun ComposeContentTestRule.onNodeWithContentDescriptionAfterScroll( + label: String, +): SemanticsNodeInteraction { + onNode(hasScrollToNodeAction()).performScrollToNode(hasContentDescription(label)) + return onNodeWithContentDescription(label) +} + +/** + * A helper used to scroll to and get the matching node in a scrollable list. This is intended to + * be used with lazy lists that would otherwise fail when calling [performScrollToNode]. + */ +fun ComposeContentTestRule.onNodeWithTextAfterScroll( + text: String, + substring: Boolean = false, +): SemanticsNodeInteraction { + onNode(hasScrollToNodeAction()).performScrollToNode(hasText(text, substring)) + return onNodeWithText(text, substring) +} diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModelTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModelTest.kt index 07b9f08b6c..12f7207950 100644 --- a/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModelTest.kt +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingViewModelTest.kt @@ -368,6 +368,15 @@ class ItemListingViewModelTest : BaseViewModelTest() { assertEquals(expectedState, viewModel.stateFlow.value) } + @Test + fun `on SyncLearnMoreClick should send NavigateToSyncInformation`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(ItemListingAction.SyncLearnMoreClick) + assertEquals(ItemListingEvent.NavigateToSyncInformation, awaitItem()) + } + } + @Test fun `on MoveToBitwardenClick receive should call startAddTotpLoginItemFlow`() { val expectedUriString = "expectedUriString" diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt index 1502a2291a..6fe90e5a35 100644 --- a/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreenTest.kt @@ -118,6 +118,15 @@ class SettingsScreenTest : BaseComposeTest() { } } + @Test + fun `on NavigateToSyncInformation receive launch sync totp uri`() { + every { intentManager.launchUri(uri = any()) } just runs + mutableEventFlow.tryEmit(SettingsEvent.NavigateToSyncInformation) + verify(exactly = 1) { + intentManager.launchUri("https://bitwarden.com/help/totp-sync".toUri()) + } + } + @Test fun `Default Save Option row should be hidden when showDefaultSaveOptionRow is false`() { mutableStateFlow.value = DEFAULT_STATE diff --git a/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt index 8b05482254..0fa129d8be 100644 --- a/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt +++ b/authenticator/src/test/java/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModelTest.kt @@ -157,6 +157,15 @@ class SettingsViewModelTest : BaseViewModelTest() { } } + @Test + fun `on SyncLearnMoreClick should emit NavigateToSyncInformation`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(SettingsAction.DataClick.SyncLearnMoreClick) + assertEquals(SettingsEvent.NavigateToSyncInformation, awaitItem()) + } + } + @Test @Suppress("MaxLineLength") fun `Default save option row should only show when shared codes state shows syncing as enabled`() =