mirror of
https://github.com/bitwarden/android.git
synced 2025-12-10 19:17:16 -06:00
PM-14333 Complete fix for crash caused by spannable text creation (#4479)
This commit is contained in:
parent
f32eecc0d7
commit
1148e4821c
@ -367,6 +367,7 @@ tasks {
|
||||
maxHeapSize = "2g"
|
||||
maxParallelForks = Runtime.getRuntime().availableProcessors()
|
||||
jvmArgs = jvmArgs.orEmpty() + "-XX:+UseParallelGC"
|
||||
android.sourceSets["main"].res.srcDirs("src/test/res")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -35,11 +35,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.auth.feature.checkemail.handlers.rememberCheckEmailHandler
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.ClickableTextHighlight
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.createAnnotatedString
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.createClickableAnnotatedString
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
|
||||
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
|
||||
@ -152,18 +150,13 @@ private fun CheckEmailContent(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
val descriptionAnnotatedString = createAnnotatedString(
|
||||
mainString = stringResource(
|
||||
id = R.string.we_sent_an_email_to,
|
||||
email,
|
||||
),
|
||||
highlights = listOf(email),
|
||||
highlightStyle = SpanStyle(
|
||||
val descriptionAnnotatedString = R.string.we_sent_an_email_to.toAnnotatedString(
|
||||
args = arrayOf(email),
|
||||
emphasisHighlightStyle = SpanStyle(
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
tag = "EMAIL",
|
||||
)
|
||||
Text(
|
||||
text = descriptionAnnotatedString,
|
||||
@ -241,18 +234,14 @@ private fun CheckEmailLegacyContent(
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
val descriptionAnnotatedString = createAnnotatedString(
|
||||
mainString = stringResource(
|
||||
id = R.string.follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account,
|
||||
val descriptionAnnotatedString =
|
||||
R.string.follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account.toAnnotatedString(
|
||||
email,
|
||||
),
|
||||
highlights = listOf(email),
|
||||
highlightStyle = SpanStyle(
|
||||
emphasisHighlightStyle = SpanStyle(
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
tag = "EMAIL",
|
||||
)
|
||||
Text(
|
||||
text = descriptionAnnotatedString,
|
||||
@ -276,34 +265,17 @@ private fun CheckEmailLegacyContent(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
val goBackAnnotatedString = createClickableAnnotatedString(
|
||||
mainString = stringResource(
|
||||
id = R.string.no_email_go_back_to_edit_your_email_address,
|
||||
),
|
||||
highlights = listOf(
|
||||
ClickableTextHighlight(
|
||||
textToHighlight = stringResource(id = R.string.go_back),
|
||||
onTextClick = onChangeEmailClick,
|
||||
),
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = goBackAnnotatedString,
|
||||
text = R.string.no_email_go_back_to_edit_your_email_address.toAnnotatedString {
|
||||
onChangeEmailClick()
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
val logInAnnotatedString = createClickableAnnotatedString(
|
||||
mainString = stringResource(
|
||||
id = R.string.or_log_in_you_may_already_have_an_account,
|
||||
),
|
||||
highlights = listOf(
|
||||
ClickableTextHighlight(
|
||||
textToHighlight = stringResource(id = R.string.log_in_verb),
|
||||
onTextClick = onLoginClick,
|
||||
),
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = logInAnnotatedString,
|
||||
text = R.string.or_log_in_you_may_already_have_an_account
|
||||
.toAnnotatedString {
|
||||
onLoginClick()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,8 +24,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.bitwardenBoldSpanStyle
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.createAnnotatedString
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
|
||||
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
@ -130,6 +128,7 @@ private fun MasterPasswordGuidanceContent(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Composable
|
||||
private fun MasterPasswordGuidanceContentBlocks(modifier: Modifier = Modifier) {
|
||||
Column(modifier = modifier) {
|
||||
@ -138,46 +137,20 @@ private fun MasterPasswordGuidanceContentBlocks(modifier: Modifier = Modifier) {
|
||||
ContentBlockData(
|
||||
headerText = stringResource(R.string.choose_three_or_four_random_words)
|
||||
.toAnnotatedString(),
|
||||
subtitleText = createAnnotatedString(
|
||||
mainString = stringResource(
|
||||
R.string.pick_three_or_four_random_unrelated_words,
|
||||
),
|
||||
highlights = listOf(
|
||||
stringResource(
|
||||
R.string.pick_three_or_four_random_unrelated_words_highlight,
|
||||
),
|
||||
),
|
||||
highlightStyle = bitwardenBoldSpanStyle,
|
||||
),
|
||||
subtitleText = R.string.pick_three_or_four_random_unrelated_words.toAnnotatedString(),
|
||||
iconVectorResource = R.drawable.ic_number1,
|
||||
),
|
||||
ContentBlockData(
|
||||
headerText = stringResource(R.string.combine_those_words_together)
|
||||
.toAnnotatedString(),
|
||||
subtitleText = createAnnotatedString(
|
||||
mainString = stringResource(
|
||||
R.string.put_the_words_together_in_any_order_to_form_your_passphrase,
|
||||
),
|
||||
highlights = listOf(
|
||||
stringResource(
|
||||
R.string.use_hyphens_spaces_or_leave_them_as_long_word_highlight,
|
||||
),
|
||||
),
|
||||
highlightStyle = bitwardenBoldSpanStyle,
|
||||
),
|
||||
subtitleText = R.string.put_the_words_together_in_any_order_to_form_your_passphrase
|
||||
.toAnnotatedString(),
|
||||
iconVectorResource = R.drawable.ic_number2,
|
||||
),
|
||||
ContentBlockData(
|
||||
headerText = stringResource(R.string.make_it_yours).toAnnotatedString(),
|
||||
subtitleText = createAnnotatedString(
|
||||
mainString = stringResource(
|
||||
R.string.add_a_number_or_symbol_to_make_it_even_stronger,
|
||||
),
|
||||
highlights = listOf(
|
||||
stringResource(R.string.add_a_number_or_symbol_highlight),
|
||||
),
|
||||
highlightStyle = bitwardenBoldSpanStyle,
|
||||
),
|
||||
subtitleText = R.string.add_a_number_or_symbol_to_make_it_even_stronger
|
||||
.toAnnotatedString(),
|
||||
iconVectorResource = R.drawable.ic_number3,
|
||||
),
|
||||
),
|
||||
|
||||
@ -31,8 +31,8 @@ import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeEmailA
|
||||
import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeEmailAccessAction.EmailAccessToggle
|
||||
import com.x8bit.bitwarden.ui.auth.feature.newdevicenotice.NewDeviceNoticeEmailAccessEvent.NavigateToTwoFactorOptions
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.createAnnotatedString
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
|
||||
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenSwitch
|
||||
@ -146,18 +146,14 @@ private fun MainContent(
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = createAnnotatedString(
|
||||
mainString = stringResource(
|
||||
R.string.do_you_have_reliable_access_to_your_email,
|
||||
email,
|
||||
),
|
||||
mainStringStyle = SpanStyle(
|
||||
text = R.string.do_you_have_reliable_access_to_your_email.toAnnotatedString(
|
||||
args = arrayOf(email),
|
||||
style = SpanStyle(
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
fontSize = BitwardenTheme.typography.bodyLarge.fontSize,
|
||||
fontWeight = FontWeight.Normal,
|
||||
),
|
||||
highlights = listOf(email),
|
||||
highlightStyle = SpanStyle(
|
||||
emphasisHighlightStyle = SpanStyle(
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
fontSize = BitwardenTheme.typography.bodyLarge.fontSize,
|
||||
fontWeight = FontWeight.Bold,
|
||||
|
||||
@ -49,10 +49,9 @@ import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationEv
|
||||
import com.x8bit.bitwarden.ui.auth.feature.startregistration.StartRegistrationEvent.NavigateToTerms
|
||||
import com.x8bit.bitwarden.ui.auth.feature.startregistration.handlers.StartRegistrationHandler
|
||||
import com.x8bit.bitwarden.ui.auth.feature.startregistration.handlers.rememberStartRegistrationHandler
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.ClickableTextHighlight
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.createClickableAnnotatedString
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
|
||||
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
|
||||
@ -288,7 +287,7 @@ private fun StartRegistrationContent(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Suppress("LongMethod", "MaxLineLength")
|
||||
@Composable
|
||||
private fun TermsAndPrivacyText(
|
||||
onTermsClick: () -> Unit,
|
||||
@ -297,22 +296,13 @@ private fun TermsAndPrivacyText(
|
||||
) {
|
||||
val strTerms = stringResource(id = R.string.terms_of_service)
|
||||
val strPrivacy = stringResource(id = R.string.privacy_policy)
|
||||
val strTermsAndPrivacy = stringResource(
|
||||
id = R.string.by_continuing_you_agree_to_the_terms_of_service_and_privacy_policy,
|
||||
)
|
||||
val annotatedLinkString: AnnotatedString = createClickableAnnotatedString(
|
||||
mainString = strTermsAndPrivacy,
|
||||
highlights = listOf(
|
||||
ClickableTextHighlight(
|
||||
textToHighlight = strTerms,
|
||||
onTextClick = onTermsClick,
|
||||
),
|
||||
ClickableTextHighlight(
|
||||
textToHighlight = strPrivacy,
|
||||
onTextClick = onPrivacyPolicyClick,
|
||||
),
|
||||
),
|
||||
)
|
||||
val annotatedLinkString: AnnotatedString =
|
||||
R.string.by_continuing_you_agree_to_the_terms_of_service_and_privacy_policy.toAnnotatedString {
|
||||
when (it) {
|
||||
"termsOfService" -> onTermsClick()
|
||||
"privacyPolicy" -> onPrivacyPolicyClick()
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@ -356,19 +346,7 @@ private fun ReceiveMarketingEmailsSwitch(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val unsubscribeString = stringResource(id = R.string.unsubscribe)
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
val annotatedLinkString = createClickableAnnotatedString(
|
||||
mainString = stringResource(
|
||||
id = R.string.get_emails_from_bitwarden_for_announcements_advices_and_research_opportunities_unsubscribe_any_time,
|
||||
),
|
||||
highlights = listOf(
|
||||
ClickableTextHighlight(
|
||||
textToHighlight = unsubscribeString,
|
||||
onTextClick = onUnsubscribeClick,
|
||||
),
|
||||
),
|
||||
)
|
||||
BitwardenSwitch(
|
||||
modifier = modifier
|
||||
.semantics(mergeDescendants = true) {
|
||||
@ -382,7 +360,10 @@ private fun ReceiveMarketingEmailsSwitch(
|
||||
),
|
||||
)
|
||||
},
|
||||
label = annotatedLinkString,
|
||||
label = R.string.get_emails_from_bitwarden_for_announcements_advices_and_research_opportunities_unsubscribe_any_time
|
||||
.toAnnotatedString {
|
||||
onUnsubscribeClick()
|
||||
},
|
||||
isChecked = isChecked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
contentDescription = "ReceiveMarketingEmailsToggle",
|
||||
|
||||
@ -0,0 +1,174 @@
|
||||
package com.x8bit.bitwarden.ui.platform.base.util
|
||||
|
||||
import android.text.Annotation
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.SpannedString
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.font.FontWeight
|
||||
import androidx.core.text.getSpans
|
||||
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
|
||||
/**
|
||||
* 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 emphasisHighlightStyle Style to apply to part of the resource that has been annotated
|
||||
* with the "emphasis" annotation.
|
||||
* @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 [emphasisHighlightStyle] applied then it must be annotated
|
||||
* with the custom XML tag: <annotation emphasis="anything"> where the value does not matter.
|
||||
* <string foo>Foo <annotation emphasis="anything">bar</annotation> baz</string>
|
||||
*
|
||||
* 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
|
||||
fun @receiver:StringRes Int.toAnnotatedString(
|
||||
vararg args: String,
|
||||
style: SpanStyle = bitwardenDefaultSpanStyle,
|
||||
emphasisHighlightStyle: SpanStyle = bitwardenBoldSpanStyle,
|
||||
linkHighlightStyle: SpanStyle = bitwardenClickableTextSpanStyle,
|
||||
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 (e: ClassCastException) {
|
||||
// the resource did not contain and valid spans so we just return the raw string.
|
||||
return stringResource(id = this).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 -> {
|
||||
annotatedStringBuilder.addStyle(
|
||||
style = emphasisHighlightStyle,
|
||||
start = start,
|
||||
end = end,
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enumerated values representing the valid <annotation> keys that can be processed
|
||||
* by [Int.toAnnotatedString]
|
||||
*/
|
||||
private enum class ValidAnnotationType {
|
||||
ARG,
|
||||
LINK,
|
||||
EMPHASIS,
|
||||
}
|
||||
|
||||
private fun Annotation.isArgAnnotation(): Boolean =
|
||||
this.key.uppercase() == ValidAnnotationType.ARG.name
|
||||
|
||||
val bitwardenDefaultSpanStyle: SpanStyle
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = SpanStyle(
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
|
||||
fontFamily = BitwardenTheme.typography.bodyMedium.fontFamily,
|
||||
)
|
||||
|
||||
val bitwardenBoldSpanStyle: SpanStyle
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = bitwardenDefaultSpanStyle.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
val bitwardenClickableTextSpanStyle: SpanStyle
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = bitwardenBoldSpanStyle.copy(
|
||||
color = BitwardenTheme.colorScheme.text.interaction,
|
||||
)
|
||||
@ -6,15 +6,7 @@ import androidx.annotation.PluralsRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.parcelize.RawValue
|
||||
|
||||
@ -125,149 +117,3 @@ fun @receiver:StringRes Int.asText(): Text = ResText(this)
|
||||
* Convert a resource Id to [Text] with format args.
|
||||
*/
|
||||
fun @receiver:StringRes Int.asText(vararg args: Any): Text = ResArgsText(this, args.asList())
|
||||
|
||||
/**
|
||||
* Create an [AnnotatedString] with highlighted parts.
|
||||
* @param mainString the full string
|
||||
* @param highlights parts of the mainString that will be highlighted
|
||||
* @param highlightStyle the style to apply to the highlights
|
||||
* @param mainStringStyle the style to apply to the mainString
|
||||
* @param tag the tag that will be used for the annotation
|
||||
*/
|
||||
@Composable
|
||||
fun createAnnotatedString(
|
||||
mainString: String,
|
||||
highlights: List<String>,
|
||||
highlightStyle: SpanStyle = bitwardenClickableTextSpanStyle,
|
||||
mainStringStyle: SpanStyle = bitwardenDefaultSpanStyle,
|
||||
tag: String? = null,
|
||||
): AnnotatedString {
|
||||
return buildAnnotatedString {
|
||||
append(mainString)
|
||||
addStyle(
|
||||
style = mainStringStyle,
|
||||
start = 0,
|
||||
end = mainString.length,
|
||||
)
|
||||
for (highlightString in highlights) {
|
||||
val startIndex = mainString.indexOf(highlightString, ignoreCase = true)
|
||||
val endIndex = startIndex + highlightString.length
|
||||
addStyle(
|
||||
style = highlightStyle,
|
||||
start = startIndex,
|
||||
end = endIndex,
|
||||
)
|
||||
tag?.let {
|
||||
addStringAnnotation(
|
||||
tag = it,
|
||||
annotation = highlightString,
|
||||
start = startIndex,
|
||||
end = endIndex,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an [AnnotatedString] with highlighted parts that can be clicked.
|
||||
* @param mainString the full string to be processed.
|
||||
* @param highlights list of [ClickableTextHighlight]s to be annotated within the [mainString].
|
||||
* If a highlighted text is repeated in the [mainString], you must choose which instance to use
|
||||
* by setting the [ClickableTextHighlight.instance] property. Only one instance of the text will
|
||||
* be annotated.
|
||||
*/
|
||||
@Composable
|
||||
fun createClickableAnnotatedString(
|
||||
mainString: String,
|
||||
highlights: List<ClickableTextHighlight>,
|
||||
style: SpanStyle = bitwardenDefaultSpanStyle,
|
||||
highlightStyle: SpanStyle = bitwardenClickableTextSpanStyle,
|
||||
): AnnotatedString {
|
||||
return buildAnnotatedString {
|
||||
append(mainString)
|
||||
addStyle(
|
||||
style = style,
|
||||
start = 0,
|
||||
end = mainString.length,
|
||||
)
|
||||
for (highlight in highlights) {
|
||||
val text = highlight.textToHighlight
|
||||
val startIndex = when (highlight.instance) {
|
||||
ClickableTextHighlight.Instance.FIRST -> {
|
||||
mainString.indexOf(text, ignoreCase = true)
|
||||
}
|
||||
|
||||
ClickableTextHighlight.Instance.LAST -> {
|
||||
mainString.lastIndexOf(text, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
// Skip adding the link if the text to highlight is not found in the main string.
|
||||
// This can happen if the highlighted text is correctly translated, but the main string
|
||||
// is not yet translated, causing the startIndex to be -1.
|
||||
if (startIndex < 0) continue
|
||||
|
||||
val endIndex = startIndex + highlight.textToHighlight.length
|
||||
val link = LinkAnnotation.Clickable(
|
||||
tag = highlight.textToHighlight,
|
||||
styles = TextLinkStyles(
|
||||
style = highlightStyle,
|
||||
),
|
||||
) {
|
||||
highlight.onTextClick.invoke()
|
||||
}
|
||||
addLink(
|
||||
link,
|
||||
start = startIndex,
|
||||
end = endIndex,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models text that should be highlighted with and associated with a click action.
|
||||
* @property textToHighlight the text to highlight and associate with click action.
|
||||
* @property onTextClick the click action to perform when the text is clicked.
|
||||
* @property instance to denote if there are multiple instances of the [textToHighlight] in the
|
||||
* [AnnotatedString] which should be highlighted.
|
||||
*/
|
||||
data class ClickableTextHighlight(
|
||||
val textToHighlight: String,
|
||||
val onTextClick: () -> Unit,
|
||||
val instance: Instance = Instance.FIRST,
|
||||
) {
|
||||
/**
|
||||
* To denote if a [ClickableTextHighlight.textToHighlight] should highlight the
|
||||
* first instance of the text or the last instance.
|
||||
* "If you ain't first, you're last" == true
|
||||
*/
|
||||
enum class Instance {
|
||||
FIRST,
|
||||
LAST,
|
||||
}
|
||||
}
|
||||
|
||||
val bitwardenDefaultSpanStyle: SpanStyle
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = SpanStyle(
|
||||
color = BitwardenTheme.colorScheme.text.primary,
|
||||
fontSize = BitwardenTheme.typography.bodyMedium.fontSize,
|
||||
fontFamily = BitwardenTheme.typography.bodyMedium.fontFamily,
|
||||
)
|
||||
|
||||
val bitwardenBoldSpanStyle: SpanStyle
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = bitwardenDefaultSpanStyle.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
val bitwardenClickableTextSpanStyle: SpanStyle
|
||||
@Composable
|
||||
@ReadOnlyComposable
|
||||
get() = bitwardenBoldSpanStyle.copy(
|
||||
color = BitwardenTheme.colorScheme.text.interaction,
|
||||
)
|
||||
|
||||
@ -41,7 +41,6 @@ import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.bitwardenBoldSpanStyle
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.createAnnotatedString
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
|
||||
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
|
||||
@ -280,6 +279,7 @@ private fun InitialImportLoginsContent(
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Composable
|
||||
private fun ImportLoginsStepOneContent(
|
||||
onContinueClick: () -> Unit,
|
||||
@ -287,29 +287,13 @@ private fun ImportLoginsStepOneContent(
|
||||
onHelpClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val instruction1 = createAnnotatedString(
|
||||
mainString = stringResource(
|
||||
R.string.on_your_computer_log_in_to_your_current_browser_or_password_manager,
|
||||
),
|
||||
highlights = listOf(
|
||||
stringResource(R.string.log_in_to_your_current_browser_or_password_manager_highlight),
|
||||
),
|
||||
highlightStyle = bitwardenBoldSpanStyle,
|
||||
)
|
||||
val instruction2 = createAnnotatedString(
|
||||
mainString = stringResource(
|
||||
R.string.export_your_passwords_this_option_is_usually_found_in_your_settings,
|
||||
),
|
||||
listOf(stringResource(R.string.export_your_passwords_highlight)),
|
||||
highlightStyle = bitwardenBoldSpanStyle,
|
||||
)
|
||||
val instruction3 = createAnnotatedString(
|
||||
mainString = stringResource(
|
||||
R.string.save_the_exported_file_somewhere_on_your_computer_you_can_find_easily,
|
||||
),
|
||||
highlights = listOf(stringResource(R.string.save_the_exported_file_highlight)),
|
||||
highlightStyle = bitwardenBoldSpanStyle,
|
||||
)
|
||||
val instruction1 =
|
||||
R.string.on_your_computer_log_in_to_your_current_browser_or_password_manager.toAnnotatedString()
|
||||
val instruction2 =
|
||||
R.string.export_your_passwords_this_option_is_usually_found_in_your_settings.toAnnotatedString()
|
||||
val instruction3 =
|
||||
R.string.save_the_exported_file_somewhere_on_your_computer_you_can_find_easily
|
||||
.toAnnotatedString()
|
||||
ImportLoginsInstructionStep(
|
||||
stepText = stringResource(R.string.step_1_of_3),
|
||||
stepTitle = stringResource(R.string.export_your_saved_logins),
|
||||
@ -346,19 +330,11 @@ private fun ImportLoginsStepTwoContent(
|
||||
onHelpClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val instruction1 = createAnnotatedString(
|
||||
mainString = stringResource(
|
||||
R.string.on_your_computer_open_a_new_browser_tab_and_go_to_vault_bitwarden_com,
|
||||
vaultUrl,
|
||||
),
|
||||
highlights = listOf(
|
||||
stringResource(
|
||||
R.string.go_to_vault_bitwarden_com_highlight,
|
||||
val instruction1 =
|
||||
R.string.on_your_computer_open_a_new_browser_tab_and_go_to_vault_bitwarden_com
|
||||
.toAnnotatedString(
|
||||
vaultUrl,
|
||||
),
|
||||
),
|
||||
highlightStyle = bitwardenBoldSpanStyle,
|
||||
)
|
||||
)
|
||||
val instruction2Text = stringResource(R.string.log_in_to_the_bitwarden_web_app)
|
||||
val instruction2 = buildAnnotatedString {
|
||||
withStyle(bitwardenBoldSpanStyle) {
|
||||
@ -387,7 +363,7 @@ private fun ImportLoginsStepTwoContent(
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Suppress("LongMethod", "MaxLineLength")
|
||||
@Composable
|
||||
private fun ImportLoginsStepThreeContent(
|
||||
onContinueClick: () -> Unit,
|
||||
@ -395,42 +371,15 @@ private fun ImportLoginsStepThreeContent(
|
||||
onHelpClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val instruction1 = createAnnotatedString(
|
||||
mainString = stringResource(
|
||||
R.string.in_the_bitwarden_navigation_find_the_tools_option_and_select_import_data,
|
||||
),
|
||||
highlights = listOf(
|
||||
stringResource(R.string.find_the_tools_highlight),
|
||||
stringResource(R.string.select_import_data_step_3_highlight),
|
||||
),
|
||||
highlightStyle = bitwardenBoldSpanStyle,
|
||||
)
|
||||
val instruction2 = createAnnotatedString(
|
||||
mainString = stringResource(R.string.fill_out_the_form_and_import_your_saved_password_file),
|
||||
highlights = listOf(
|
||||
stringResource(R.string.import_your_saved_password_file_highlight),
|
||||
),
|
||||
highlightStyle = bitwardenBoldSpanStyle,
|
||||
)
|
||||
val instruction3 = createAnnotatedString(
|
||||
mainString = stringResource(
|
||||
R.string.select_import_data_in_the_web_app_then_done_to_finish_syncing,
|
||||
),
|
||||
highlights = listOf(
|
||||
stringResource(R.string.select_import_data_highlight),
|
||||
stringResource(R.string.then_done_highlight),
|
||||
),
|
||||
highlightStyle = bitwardenBoldSpanStyle,
|
||||
)
|
||||
val instruction4 = createAnnotatedString(
|
||||
mainString = stringResource(
|
||||
R.string.for_your_security_be_sure_to_delete_your_saved_password_file,
|
||||
),
|
||||
highlights = listOf(
|
||||
stringResource(R.string.delete_your_saved_password_file),
|
||||
),
|
||||
highlightStyle = bitwardenBoldSpanStyle,
|
||||
)
|
||||
val instruction1 =
|
||||
R.string.in_the_bitwarden_navigation_find_the_tools_option_and_select_import_data
|
||||
.toAnnotatedString()
|
||||
val instruction2 = R.string.fill_out_the_form_and_import_your_saved_password_file
|
||||
.toAnnotatedString()
|
||||
val instruction3 = R.string.select_import_data_in_the_web_app_then_done_to_finish_syncing
|
||||
.toAnnotatedString()
|
||||
val instruction4 = R.string.for_your_security_be_sure_to_delete_your_saved_password_file
|
||||
.toAnnotatedString()
|
||||
ImportLoginsInstructionStep(
|
||||
stepText = stringResource(R.string.step_3_of_3),
|
||||
stepTitle = stringResource(R.string.import_logins_to_bitwarden),
|
||||
|
||||
@ -20,8 +20,6 @@ import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.ClickableTextHighlight
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.createClickableAnnotatedString
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.toAnnotatedString
|
||||
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
|
||||
@ -68,15 +66,10 @@ fun ImportLoginsInstructionStep(
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = createClickableAnnotatedString(
|
||||
mainString = stringResource(R.string.need_help_check_out_import_help),
|
||||
highlights = listOf(
|
||||
ClickableTextHighlight(
|
||||
textToHighlight = stringResource(R.string.import_help_highlight),
|
||||
onTextClick = onHelpClick,
|
||||
),
|
||||
),
|
||||
),
|
||||
text = R.string.need_help_check_out_import_help
|
||||
.toAnnotatedString {
|
||||
onHelpClick()
|
||||
},
|
||||
style = BitwardenTheme.typography.bodySmall,
|
||||
color = BitwardenTheme.colorScheme.text.secondary,
|
||||
modifier = Modifier.standardHorizontalMargin(),
|
||||
|
||||
@ -939,17 +939,17 @@ Do you want to switch to this account?</string>
|
||||
<string name="user_verification_direction">User verification</string>
|
||||
<string name="creating_on">Creating on:</string>
|
||||
<string name="follow_the_instructions_in_the_email_sent_to_x_to_continue_creating_your_account">Follow the instructions in the email sent to %1$s to continue creating your account.</string>
|
||||
<string name="we_sent_an_email_to">We sent an email to %1$s.</string>
|
||||
<string name="by_continuing_you_agree_to_the_terms_of_service_and_privacy_policy">By continuing, you agree to the Terms of Service and Privacy Policy</string>
|
||||
<string name="we_sent_an_email_to">We sent an email to <annotation emphasis="bold"><annotation arg="0">%1$s</annotation></annotation>.</string>
|
||||
<string name="by_continuing_you_agree_to_the_terms_of_service_and_privacy_policy">By continuing, you agree to the <annotation link="termsOfService">Terms of Service</annotation> and <annotation link="privacyPolicy">Privacy Policy</annotation></string>
|
||||
<string name="set_password">Set password</string>
|
||||
<string name="unsubscribe">Unsubscribe</string>
|
||||
<string name="check_your_email">Check your email</string>
|
||||
<string name="open_email_app">Open email app</string>
|
||||
<string name="go_back">Go back</string>
|
||||
<string name="email_verified">Email verified</string>
|
||||
<string name="no_email_go_back_to_edit_your_email_address">No email? Go back to edit your email address.</string>
|
||||
<string name="or_log_in_you_may_already_have_an_account">Or log in, you may already have an account.</string>
|
||||
<string name="get_emails_from_bitwarden_for_announcements_advices_and_research_opportunities_unsubscribe_any_time">Get emails from Bitwarden for announcements, advice, and research opportunities. Unsubscribe at any time.</string>
|
||||
<string name="no_email_go_back_to_edit_your_email_address">No email? <annotation link="goBack">Go back</annotation> to edit your email address.</string>
|
||||
<string name="or_log_in_you_may_already_have_an_account">Or <annotation link="logIn">log in</annotation>, you may already have an account.</string>
|
||||
<string name="get_emails_from_bitwarden_for_announcements_advices_and_research_opportunities_unsubscribe_any_time">Get emails from Bitwarden for announcements, advice, and research opportunities. <annotation link="unsubscribe">Unsubscribe</annotation> at any time.</string>
|
||||
<string name="get_advice_announcements_and_research_opportunities_from_bitwarden_in_your_inbox_unsubscribe_any_time">Get advice, announcements, and research opportunities from Bitwarden in your inbox. Unsubscribe at any time.</string>
|
||||
<string name="security_prioritized">Security, prioritized</string>
|
||||
<string name="welcome_message_1">Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you.</string>
|
||||
@ -1032,34 +1032,23 @@ Do you want to switch to this account?</string>
|
||||
<string name="give_your_vault_a_head_start">Give your vault a head start</string>
|
||||
<string name="class_3_biometrics_description">Unlock with biometrics requires strong biometric authentication and may not be compatible with all biometric options on this device.</string>
|
||||
<string name="class_2_biometrics_description">Unlock with biometrics requires strong biometric authentication and is not compatible with the biometrics options available on this device.</string>
|
||||
<string name="on_your_computer_log_in_to_your_current_browser_or_password_manager">On your computer, log in to your current browser or password manager.</string>
|
||||
<string name="log_in_to_your_current_browser_or_password_manager_highlight">log in to your current browser or password manager.</string>
|
||||
<string name="export_your_passwords_this_option_is_usually_found_in_your_settings">Export your passwords. This option is usually found in your settings.</string>
|
||||
<string name="export_your_passwords_highlight">Export your passwords.</string>
|
||||
<string name="select_import_data_in_the_web_app_then_done_to_finish_syncing">Select Import data in the web app, then Done below to finish syncing.</string>
|
||||
<string name="select_import_data_highlight">Select Import data</string>
|
||||
<string name="on_your_computer_log_in_to_your_current_browser_or_password_manager">On your computer, <annotation emphasis="bold">log in to your current browser or password manager.</annotation></string>
|
||||
<string name="export_your_passwords_this_option_is_usually_found_in_your_settings"><annotation emphasis="bold">Export your passwords.</annotation> This option is usually found in your settings.</string>
|
||||
<string name="select_import_data_in_the_web_app_then_done_to_finish_syncing"><annotation emphasis="bold">Select Import data</annotation> in the web app, then Done below to finish syncing.</string>
|
||||
<string name="step_1_of_3">Step 1 of 3</string>
|
||||
<string name="export_your_saved_logins">Export your saved logins</string>
|
||||
<string name="delete_this_file_after_import_is_complete">You’ll delete this file after import is complete.</string>
|
||||
<string name="on_your_computer_open_a_new_browser_tab_and_go_to_vault_bitwarden_com">On your computer, open a new browser tab and go to %1$s</string>
|
||||
<string name="go_to_vault_bitwarden_com_highlight">go to %1$s</string>
|
||||
<string name="on_your_computer_open_a_new_browser_tab_and_go_to_vault_bitwarden_com">On your computer, open a new browser tab and <annotation emphasis="bold">go to <annotation arg="0">%1$s</annotation></annotation></string>
|
||||
<string name="log_in_to_the_bitwarden_web_app">Log in to the Bitwarden web app.</string>
|
||||
<string name="step_2_of_3">Step 2 of 3</string>
|
||||
<string name="log_in_to_bitwarden">Log in to Bitwarden</string>
|
||||
<string name="step_3_of_3">Step 3 of 3</string>
|
||||
<string name="import_logins_to_bitwarden">Import logins to Bitwarden</string>
|
||||
<string name="in_the_bitwarden_navigation_find_the_tools_option_and_select_import_data">In the Bitwarden navigation, find the Tools option and select Import data.</string>
|
||||
<string name="find_the_tools_highlight">find the Tools</string>
|
||||
<string name="select_import_data_step_3_highlight">select Import data.</string>
|
||||
<string name="fill_out_the_form_and_import_your_saved_password_file">Fill out the form and import your saved password file.</string>
|
||||
<string name="import_your_saved_password_file_highlight">import your saved password file.</string>
|
||||
<string name="then_done_highlight">then Done</string>
|
||||
<string name="for_your_security_be_sure_to_delete_your_saved_password_file">For your security, be sure to delete your saved password file.</string>
|
||||
<string name="delete_your_saved_password_file">delete your saved password file.</string>
|
||||
<string name="need_help_check_out_import_help">Need help? Check out import help.</string>
|
||||
<string name="import_help_highlight">import help</string>
|
||||
<string name="save_the_exported_file_somewhere_on_your_computer_you_can_find_easily">Save the exported file somewhere on your computer you can find easily.</string>
|
||||
<string name="save_the_exported_file_highlight">Save the exported file</string>
|
||||
<string name="in_the_bitwarden_navigation_find_the_tools_option_and_select_import_data">In the Bitwarden navigation, <annotation emphasis="bold">find the Tools</annotation> option and <annotation emphasis="bold">select Import data.</annotation></string>
|
||||
<string name="fill_out_the_form_and_import_your_saved_password_file">Fill out the form and <annotation emphasis="bold">import your saved password file.</annotation></string>
|
||||
<string name="for_your_security_be_sure_to_delete_your_saved_password_file">For your security, be sure to <annotation emphasis="bold">delete your saved password file.</annotation></string>
|
||||
<string name="need_help_check_out_import_help">Need help? Check out <annotation link="importHelp">import help</annotation>.</string>
|
||||
<string name="save_the_exported_file_somewhere_on_your_computer_you_can_find_easily"><annotation emphasis="bold">Save the exported file</annotation> somewhere on your computer you can find easily.</string>
|
||||
<string name="this_is_not_a_recognized_bitwarden_server_you_may_need_to_check_with_your_provider_or_update_your_server">This is not a recognized Bitwarden server. You may need to check with your provider or update your server.</string>
|
||||
<string name="syncing_logins_loading_message">Syncing logins...</string>
|
||||
<string name="ssh_key_cipher_item_types">SSH Key Cipher Item Types</string>
|
||||
@ -1100,7 +1089,7 @@ Do you want to switch to this account?</string>
|
||||
<string name="copy_address">Copy address</string>
|
||||
<string name="important_notice">Important notice</string>
|
||||
<string name="bitwarden_will_soon_send_a_code_to_your_account_email_to_verify_logins_from_new_devices_in_february">Bitwarden will send a code to your account email to verify logins from new devices starting in February 2025.</string>
|
||||
<string name="do_you_have_reliable_access_to_your_email">Do you have reliable access to your email, %1$s? </string>
|
||||
<string name="do_you_have_reliable_access_to_your_email">Do you have reliable access to your email, <annotation emphasis="bold"><annotation arg="0">%1$s?</annotation></annotation></string>
|
||||
<string name="yes_i_can_reliably_access_my_email">Yes, I can reliably access my email</string>
|
||||
<string name="biometrics_no_longer_supported_title">Biometrics are no longer supported on this device</string>
|
||||
<string name="biometrics_no_longer_supported">You’ve been logged out because your device’s biometrics don’t meet the latest security requirements. To update settings, log in once again or contact your administrator for access.</string>
|
||||
@ -1111,14 +1100,11 @@ Do you want to switch to this account?</string>
|
||||
<string name="turn_on_two_step_login">Turn on two-step login</string>
|
||||
<string name="change_account_email">Change account email</string>
|
||||
<string name="choose_three_or_four_random_words">Choose three or four random words</string>
|
||||
<string name="pick_three_or_four_random_unrelated_words">Pick three or four random, unrelated words that you can easily remember. Think of objects, places, or things you like.</string>
|
||||
<string name="pick_three_or_four_random_unrelated_words_highlight">objects, places, or things</string>
|
||||
<string name="pick_three_or_four_random_unrelated_words">Pick three or four random, unrelated words that you can easily remember. Think of <annotation emphasis="bold">objects, places, or things</annotation> you like.</string>
|
||||
<string name="combine_those_words_together">Combine those words together</string>
|
||||
<string name="put_the_words_together_in_any_order_to_form_your_passphrase">Put the words together in any order to form your passphrase. Use hyphens, spaces, or leave them as one long word—your choice!</string>
|
||||
<string name="use_hyphens_spaces_or_leave_them_as_long_word_highlight">Use hyphens, spaces, or leave them as one long word</string>
|
||||
<string name="put_the_words_together_in_any_order_to_form_your_passphrase">Put the words together in any order to form your passphrase. <annotation emphasis="bold">Use hyphens, spaces, or leave them as one long word</annotation>—your choice!</string>
|
||||
<string name="make_it_yours">Make it yours</string>
|
||||
<string name="add_a_number_or_symbol_to_make_it_even_stronger">Add a number or symbol to make it even stronger. Now you have a unique, secure, and memorable passphrase!</string>
|
||||
<string name="add_a_number_or_symbol_highlight">Add a number or symbol</string>
|
||||
<string name="add_a_number_or_symbol_to_make_it_even_stronger"><annotation emphasis="bold">Add a number or symbol</annotation> to make it even stronger. Now you have a unique, secure, and memorable passphrase!</string>
|
||||
<string name="need_some_inspiration">"Need some inspiration?"</string>
|
||||
<string name="check_out_the_passphrase_generator">"Check out the passphrase generator"</string>
|
||||
<string name="copied_to_clipboard">Copied to clipboard.</string>
|
||||
|
||||
@ -2,10 +2,8 @@ package com.x8bit.bitwarden.ui.auth.feature.checkemail
|
||||
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.printToLog
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
|
||||
@ -91,16 +89,9 @@ class CheckEmailScreenTest : BaseComposeTest() {
|
||||
@Test
|
||||
fun `go back and update email text click should send ChangeEmailClick action`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(showNewOnboardingUi = false)
|
||||
composeTestRule.onRoot().printToLog("oh shit")
|
||||
val mainString = "No email? Go back to edit your email address."
|
||||
val linkText = "Go back"
|
||||
val expectedStart = mainString.indexOf(linkText)
|
||||
val expectedEnd = expectedStart + linkText.length
|
||||
composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
|
||||
mainString = mainString,
|
||||
highLightText = linkText,
|
||||
expectedStart = expectedStart,
|
||||
expectedEnd = expectedEnd,
|
||||
)
|
||||
|
||||
verify { viewModel.trySendAction(CheckEmailAction.ChangeEmailClick) }
|
||||
@ -110,14 +101,8 @@ class CheckEmailScreenTest : BaseComposeTest() {
|
||||
fun `already have account text click should send ChangeEmailClick action`() {
|
||||
mutableStateFlow.value = DEFAULT_STATE.copy(showNewOnboardingUi = false)
|
||||
val mainString = "Or log in, you may already have an account."
|
||||
val linkText = "log in"
|
||||
val expectedStart = mainString.indexOf(linkText)
|
||||
val expectedEnd = expectedStart + linkText.length
|
||||
composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
|
||||
mainString = mainString,
|
||||
highLightText = linkText,
|
||||
expectedStart = expectedStart,
|
||||
expectedEnd = expectedEnd,
|
||||
)
|
||||
|
||||
verify { viewModel.trySendAction(CheckEmailAction.LoginClick) }
|
||||
|
||||
@ -1,136 +0,0 @@
|
||||
package com.x8bit.bitwarden.ui.platform.base.util
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.util.assertLinkAnnotationIsAppliedAndInvokeClickAction
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ClickableAnnotatedStringTest : BaseComposeTest() {
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `clickable annotated string should add Clickable LinkAnnotation to highlighted string`() {
|
||||
var textClickCalled = false
|
||||
val mainString = "This is me testing the thing."
|
||||
val highLightText = "testing"
|
||||
composeTestRule.setContent {
|
||||
val annotatedString = createClickableAnnotatedString(
|
||||
mainString,
|
||||
listOf(
|
||||
ClickableTextHighlight(
|
||||
textToHighlight = highLightText,
|
||||
onTextClick = { textClickCalled = true },
|
||||
),
|
||||
),
|
||||
)
|
||||
Text(text = annotatedString)
|
||||
}
|
||||
val expectedStart = mainString.indexOf(highLightText)
|
||||
val expectedEnd = expectedStart + highLightText.length
|
||||
composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
|
||||
mainString,
|
||||
highLightText,
|
||||
expectedStart,
|
||||
expectedEnd,
|
||||
)
|
||||
assertTrue(textClickCalled)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `clickable annotated string should add multiple Clickable LinkAnnotations to highlighted string`() {
|
||||
val mainString = "This is me testing the thing."
|
||||
val highLightText1 = "testing"
|
||||
val highlightText2 = "thing"
|
||||
composeTestRule.setContent {
|
||||
val annotatedString = createClickableAnnotatedString(
|
||||
mainString,
|
||||
listOf(
|
||||
ClickableTextHighlight(
|
||||
textToHighlight = highLightText1,
|
||||
onTextClick = {},
|
||||
),
|
||||
ClickableTextHighlight(
|
||||
textToHighlight = highlightText2,
|
||||
onTextClick = {},
|
||||
),
|
||||
),
|
||||
)
|
||||
Text(text = annotatedString)
|
||||
}
|
||||
val expectedStart1 = mainString.indexOf(highLightText1)
|
||||
val expectedEnd1 = expectedStart1 + highLightText1.length
|
||||
val expectedStart2 = mainString.indexOf(highlightText2)
|
||||
val expectedEnd2 = expectedStart2 + highlightText2.length
|
||||
composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
|
||||
mainString,
|
||||
highLightText1,
|
||||
expectedStart1,
|
||||
expectedEnd1,
|
||||
)
|
||||
composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
|
||||
mainString,
|
||||
highlightText2,
|
||||
expectedStart2,
|
||||
expectedEnd2,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `clickable annotated string should add annotation to first instance of highlighted string`() {
|
||||
val mainString = "Testing 1,2,3 testing"
|
||||
val highLightText = "testing"
|
||||
composeTestRule.setContent {
|
||||
val annotatedString = createClickableAnnotatedString(
|
||||
mainString,
|
||||
listOf(
|
||||
ClickableTextHighlight(
|
||||
textToHighlight = highLightText,
|
||||
onTextClick = {},
|
||||
instance = ClickableTextHighlight.Instance.FIRST,
|
||||
),
|
||||
),
|
||||
)
|
||||
Text(text = annotatedString)
|
||||
}
|
||||
// indexOf returns the index of the first instance.
|
||||
val expectedStart = mainString.indexOf(highLightText)
|
||||
val expectedEnd = expectedStart + highLightText.length
|
||||
composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
|
||||
mainString,
|
||||
highLightText,
|
||||
expectedStart,
|
||||
expectedEnd,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `clickable annotated string should add annotation to last instance of highlighted string`() {
|
||||
val mainString = "Testing 1,2,3 testing"
|
||||
val highLightText = "testing"
|
||||
composeTestRule.setContent {
|
||||
val annotatedString = createClickableAnnotatedString(
|
||||
mainString,
|
||||
listOf(
|
||||
ClickableTextHighlight(
|
||||
textToHighlight = highLightText,
|
||||
onTextClick = {},
|
||||
instance = ClickableTextHighlight.Instance.FIRST,
|
||||
),
|
||||
),
|
||||
)
|
||||
Text(text = annotatedString)
|
||||
}
|
||||
// indexOf returns the index of the first instance.
|
||||
val expectedStart = mainString.lastIndexOf(highLightText)
|
||||
val expectedEnd = expectedStart + highLightText.length
|
||||
composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
|
||||
mainString,
|
||||
highLightText,
|
||||
expectedStart,
|
||||
expectedEnd,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,103 @@
|
||||
package com.x8bit.bitwarden.ui.platform.base.util
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.getOrNull
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.util.assertLinkAnnotationIsAppliedAndInvokeClickAction
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class StringRestExtensionsTest : BaseComposeTest() {
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `toAnnotatedString should add Clickable LinkAnnotation to highlighted string`() {
|
||||
var textClickCalled = false
|
||||
composeTestRule.setContent {
|
||||
val annotatedString =
|
||||
R.string.test_for_single_link_annotation.toAnnotatedString {
|
||||
textClickCalled = true
|
||||
}
|
||||
Text(text = annotatedString)
|
||||
}
|
||||
composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
|
||||
mainString = "Get emails from Bitwarden",
|
||||
)
|
||||
assertTrue(textClickCalled)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `toAnnotatedString should add multiple Clickable LinkAnnotations to highlighted string`() {
|
||||
composeTestRule.setContent {
|
||||
val annotatedString =
|
||||
R.string.test_for_multi_link_annotation.toAnnotatedString()
|
||||
Text(text = annotatedString)
|
||||
}
|
||||
composeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
|
||||
mainString = "By continuing, you agree to the",
|
||||
expectedLinkCount = 2,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no link annotations should be applied to non annotated string resource`() {
|
||||
composeTestRule.setContent {
|
||||
Text(text = R.string.test_for_string_with_no_annotations.toAnnotatedString())
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("Nothing special here.")
|
||||
.fetchSemanticsNode()
|
||||
.config
|
||||
.getOrNull(SemanticsProperties.Text)
|
||||
?.let { text ->
|
||||
text.forEach {
|
||||
// get any link annotations present
|
||||
val linkAnnotations = it.getLinkAnnotations(0, it.length)
|
||||
assertTrue(linkAnnotations.isEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `string with args should only use the arguments available in the string`() {
|
||||
composeTestRule.setContent {
|
||||
Text(
|
||||
text =
|
||||
R.string.test_for_string_with_annotation_and_arg_annotation
|
||||
.toAnnotatedString(
|
||||
args = arrayOf("vault.bitwarden.com", "i should not exist"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(
|
||||
"On your computer, open a new browser " +
|
||||
"tab and go to vault.bitwarden.com",
|
||||
)
|
||||
.assertIsDisplayed()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("i should not exist")
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `string with arg annotations but no passed in args should just append empty string`() {
|
||||
composeTestRule.setContent {
|
||||
Text(
|
||||
text = R.string.test_for_string_with_annotation_and_arg_annotation
|
||||
.toAnnotatedString(),
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText("On your computer, open a new browser tab and go to ")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@ -23,8 +23,8 @@ import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.compose.ui.test.printToString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
|
||||
/**
|
||||
@ -182,27 +182,34 @@ fun SemanticsNodeInteraction.performCustomAccessibilityAction(label: String) {
|
||||
* Helper function to assert link annotation is applied to the given text in
|
||||
* the [mainString] and invoke click action if it is found.
|
||||
*/
|
||||
@Suppress("NestedBlockDepth")
|
||||
fun ComposeTestRule.assertLinkAnnotationIsAppliedAndInvokeClickAction(
|
||||
mainString: String,
|
||||
highLightText: String,
|
||||
expectedStart: Int,
|
||||
expectedEnd: Int,
|
||||
expectedLinkCount: Int? = null,
|
||||
) {
|
||||
this
|
||||
.onNodeWithText(mainString)
|
||||
.onNodeWithText(mainString, substring = true, ignoreCase = true)
|
||||
.fetchSemanticsNode()
|
||||
.config
|
||||
.getOrNull(SemanticsProperties.Text)
|
||||
?.let { text ->
|
||||
text.forEach {
|
||||
it.getLinkAnnotations(expectedStart, expectedEnd)
|
||||
.forEach { annotationRange ->
|
||||
val linkAnnotations = it.getLinkAnnotations(0, it.length)
|
||||
if (linkAnnotations.isEmpty()) {
|
||||
throw AssertionError(
|
||||
"No link annotation found",
|
||||
)
|
||||
} else {
|
||||
linkAnnotations.forEach { annotationRange ->
|
||||
val annotation = annotationRange.item as? LinkAnnotation.Clickable
|
||||
val tag = annotation?.tag
|
||||
assertNotNull(tag)
|
||||
assertTrue(highLightText.equals(tag, ignoreCase = true))
|
||||
annotation?.linkInteractionListener?.onClick(annotation)
|
||||
}
|
||||
expectedLinkCount?.let {
|
||||
assertEquals(expectedLinkCount, linkAnnotations.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9
app/src/test/res/values/strings_for_tests_only.xml
Normal file
9
app/src/test/res/values/strings_for_tests_only.xml
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- StringResExtensions Test value -->
|
||||
<string name="test_for_single_link_annotation">Get emails from Bitwarden for announcements, advice, and research opportunities. <annotation link="unsubscribe">Unsubscribe</annotation> at any time.</string>
|
||||
<string name="test_for_multi_link_annotation">By continuing, you agree to the <annotation link="termsOfService">Terms of Service</annotation> and <annotation link="privacyPolicy">Privacy Policy</annotation></string>
|
||||
<string name="test_for_string_with_no_annotations">Nothing special here.</string>
|
||||
<string name="test_for_string_with_annotation_and_arg_annotation">On your computer, open a new browser tab and <annotation emphasis="bold">go to <annotation arg="0">%1$s</annotation></annotation></string>
|
||||
<!-- /StringResExtensions Test value -->
|
||||
</resources>
|
||||
Loading…
x
Reference in New Issue
Block a user