🍒PM-17660: Sync learn more cherry pick (#5249)

This commit is contained in:
David Perez 2025-05-22 14:01:54 -05:00 committed by GitHub
parent e738b458ef
commit 04e06904f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 544 additions and 78 deletions

View File

@ -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 = { },
)
}

View File

@ -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,

View File

@ -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(),

View File

@ -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,
) {

View File

@ -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: <annotation link="callBackKey"> where the value will be passed back
* in [onAnnotationClick] and used to delineate which annotation was clicked.
* <string foo>Foo <annotation link="onBarClick">bar</annotation> baz</string>
*
* If the <string> contains a format argument (%1$s) then that argument should be wrapped in the
* following custom XML tag: <annotation arg="0"> 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<Annotation>()
// 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 <annotation arg="0"> and </annotation> 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<Annotation>()
.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 <annotation> keys that can be processed
* by [Int.toAnnotatedString]
*/
private enum class ValidAnnotationType {
ARG,
LINK,
EMPHASIS,
}
//endregion About settings
@Preview

View File

@ -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()
}

View File

@ -125,11 +125,13 @@
<string name="download_bitwarden_card_message">Store all of your logins and sync verification codes directly with the Authenticator app.</string>
<string name="download_now">Download now</string>
<string name="sync_with_bitwarden_app">Sync with Bitwarden app</string>
<string name="this_feature_is_not_not_yet_available_for_self_hosted_users">This feature is not yet available for self-hosted users. <annotation link="learnMore">Learn more</annotation></string>
<string name="shared_codes_error">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.</string>
<string name="shared_accounts_header">%1$s | %2$s</string>
<string name="sync_with_the_bitwarden_app">Sync with the Bitwarden app</string>
<string name="go_to_settings">Go to settings</string>
<string name="sync_with_bitwarden_action_card_message">Allow Authenticator app syncing in settings to view all of your verification codes here.</string>
<string name="sync_with_bitwarden_action_card_message">In order to view all of your verification codes, youll need to allow for syncing on all of your accounts.</string>
<string name="take_me_to_app_settings">Take me to the app settings</string>
<string name="something_went_wrong">Something went wrong</string>
<string name="please_try_again">Please try again</string>
<string name="move_to_bitwarden">Move to Bitwarden</string>

View File

@ -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)
}

View File

@ -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"

View File

@ -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

View File

@ -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`() =