mirror of
https://github.com/bitwarden/android.git
synced 2025-12-10 00:06:22 -06:00
🍒PM-17660: Sync learn more cherry pick (#5249)
This commit is contained in:
parent
e738b458ef
commit
04e06904f2
@ -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 = { },
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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, you’ll 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>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`() =
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user