summaryrefslogtreecommitdiffhomepage
path: root/android/lib/ui
diff options
context:
space:
mode:
authorKalle Lindström <karl.lindstrom@mullvad.net>2025-03-11 11:00:15 +0100
committerKalle Lindström <karl.lindstrom@mullvad.net>2025-03-19 09:33:07 +0100
commite2d5d4f1f444f1dfbee5077889a62731aec080c6 (patch)
tree94e8bfca4a946f931ae3ce6b22a8a50eec795b48 /android/lib/ui
parent793c39338a2fcd2bf188952061edaeaa925d614a (diff)
downloadmullvadvpn-e2d5d4f1f444f1dfbee5077889a62731aec080c6.tar.xz
mullvadvpn-e2d5d4f1f444f1dfbee5077889a62731aec080c6.zip
Improve TV connect screen UI
- Implements the navigation rail design for Android TV - Implements the TV notification banner design - Adds two new Gradle modules: * tv: contains the Android TV specific Compose components (e.g. the NavigationDrawerTV component) * ui/compose: contains Compose-specific code that is needed by both the app module and the tv module.
Diffstat (limited to 'android/lib/ui')
-rw-r--r--android/lib/ui/component/build.gradle.kts44
-rw-r--r--android/lib/ui/component/src/main/AndroidManifest.xml1
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/AnimatedNotificationBanner.kt208
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/NotificationData.kt249
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/RememberPrevious.kt43
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/ResourcesExtensions.kt25
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/SpannedExtensions.kt43
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/test/ComposeTestTagConstants.kt6
8 files changed, 619 insertions, 0 deletions
diff --git a/android/lib/ui/component/build.gradle.kts b/android/lib/ui/component/build.gradle.kts
new file mode 100644
index 0000000000..7804ac0abc
--- /dev/null
+++ b/android/lib/ui/component/build.gradle.kts
@@ -0,0 +1,44 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.compose)
+}
+
+android {
+ namespace = "net.mullvad.mullvadvpn.lib.ui.component"
+ compileSdk = Versions.compileSdkVersion
+ buildToolsVersion = Versions.buildToolsVersion
+
+ defaultConfig { minSdk = Versions.minSdkVersion }
+
+ buildFeatures { compose = true }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = Versions.jvmTarget
+ allWarningsAsErrors = true
+ }
+
+ lint {
+ lintConfig = file("${rootProject.projectDir}/config/lint.xml")
+ abortOnError = true
+ warningsAsErrors = true
+ }
+}
+
+dependencies {
+ implementation(libs.compose.material3)
+ implementation(libs.compose.ui)
+ implementation(libs.compose.constrainlayout)
+ implementation(libs.kotlin.stdlib)
+ implementation(libs.compose.icons.extended)
+ implementation(libs.androidx.ktx)
+ implementation(projects.lib.resource)
+ implementation(projects.lib.shared)
+ implementation(projects.lib.theme)
+ implementation(projects.lib.model)
+}
diff --git a/android/lib/ui/component/src/main/AndroidManifest.xml b/android/lib/ui/component/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..cc947c5679
--- /dev/null
+++ b/android/lib/ui/component/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+<manifest />
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/AnimatedNotificationBanner.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/AnimatedNotificationBanner.kt
new file mode 100644
index 0000000000..5d1d7f0e74
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/AnimatedNotificationBanner.kt
@@ -0,0 +1,208 @@
+package net.mullvad.mullvadvpn.lib.ui.component
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.text.toUpperCase
+import androidx.constraintlayout.compose.ConstraintLayout
+import androidx.constraintlayout.compose.Dimension
+import net.mullvad.mullvadvpn.lib.model.InAppNotification
+import net.mullvad.mullvadvpn.lib.model.StatusLevel
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import net.mullvad.mullvadvpn.lib.theme.color.warning
+import net.mullvad.mullvadvpn.lib.ui.component.test.NOTIFICATION_BANNER
+import net.mullvad.mullvadvpn.lib.ui.component.test.NOTIFICATION_BANNER_ACTION
+import net.mullvad.mullvadvpn.lib.ui.component.test.NOTIFICATION_BANNER_TEXT_ACTION
+
+@Composable
+fun AnimatedNotificationBanner(
+ modifier: Modifier = Modifier,
+ notificationModifier: Modifier = Modifier,
+ notification: InAppNotification?,
+ isPlayBuild: Boolean,
+ openAppListing: () -> Unit,
+ onClickShowAccount: () -> Unit,
+ onClickShowChangelog: () -> Unit,
+ onClickDismissChangelog: () -> Unit,
+ onClickDismissNewDevice: () -> Unit,
+) {
+ // Fix for animating to invisible state
+ val previous = rememberPrevious(current = notification, shouldUpdate = { _, _ -> true })
+ AnimatedVisibility(
+ modifier = modifier,
+ visible = notification != null,
+ enter = slideInVertically(initialOffsetY = { -it }),
+ exit = slideOutVertically(targetOffsetY = { -it }),
+ ) {
+ val visibleNotification = notification ?: previous
+ if (visibleNotification != null)
+ Notification(
+ modifier = notificationModifier,
+ visibleNotification.toNotificationData(
+ isPlayBuild = isPlayBuild,
+ openAppListing,
+ onClickShowAccount,
+ onClickShowChangelog,
+ onClickDismissChangelog,
+ onClickDismissNewDevice,
+ ),
+ )
+ }
+}
+
+@Composable
+@Suppress("LongMethod")
+private fun Notification(modifier: Modifier = Modifier, notificationBannerData: NotificationData) {
+ val (title, message, statusLevel, action) = notificationBannerData
+ ConstraintLayout(
+ modifier =
+ modifier
+ .background(color = MaterialTheme.colorScheme.surfaceContainer)
+ .padding(
+ start = Dimens.notificationBannerStartPadding,
+ end = Dimens.notificationBannerEndPadding,
+ top = Dimens.smallPadding,
+ bottom = Dimens.smallPadding,
+ )
+ .animateContentSize()
+ .testTag(NOTIFICATION_BANNER)
+ ) {
+ val (status, textTitle, textMessage, actionIcon) = createRefs()
+ NotificationDot(
+ statusLevel,
+ Modifier.constrainAs(status) {
+ top.linkTo(textTitle.top)
+ start.linkTo(parent.start)
+ bottom.linkTo(textTitle.bottom)
+ },
+ )
+ Text(
+ text = title.toUpperCase(),
+ modifier =
+ Modifier.constrainAs(textTitle) {
+ top.linkTo(parent.top)
+ start.linkTo(status.end)
+ if (message != null) {
+ bottom.linkTo(textMessage.top)
+ } else {
+ bottom.linkTo(parent.bottom)
+ }
+ if (action != null) {
+ end.linkTo(actionIcon.start)
+ } else {
+ end.linkTo(parent.end)
+ }
+ width = Dimension.fillToConstraints
+ }
+ .padding(start = Dimens.smallPadding),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ message?.let { message ->
+ Text(
+ text = message.text,
+ modifier =
+ Modifier.constrainAs(textMessage) {
+ top.linkTo(textTitle.bottom)
+ start.linkTo(textTitle.start)
+ if (action != null) {
+ end.linkTo(actionIcon.start)
+ bottom.linkTo(parent.bottom)
+ } else {
+ end.linkTo(parent.end)
+ bottom.linkTo(parent.bottom)
+ }
+ width = Dimension.fillToConstraints
+ height = Dimension.wrapContent
+ }
+ .padding(start = Dimens.smallPadding, top = Dimens.tinyPadding)
+ .wrapContentWidth(Alignment.Start)
+ .let {
+ if (message is NotificationMessage.ClickableText) {
+ it.clickable(
+ onClickLabel = message.contentDescription,
+ role = Role.Button,
+ ) {
+ message.onClick()
+ }
+ .testTag(NOTIFICATION_BANNER_TEXT_ACTION)
+ } else {
+ it
+ }
+ },
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.labelMedium,
+ )
+ }
+ action?.let {
+ NotificationAction(
+ it.icon,
+ onClick = it.onClick,
+ contentDescription = it.contentDescription,
+ modifier =
+ Modifier.constrainAs(actionIcon) {
+ top.linkTo(parent.top)
+ end.linkTo(parent.end)
+ bottom.linkTo(parent.bottom)
+ },
+ )
+ }
+ }
+}
+
+@Composable
+private fun NotificationDot(statusLevel: StatusLevel, modifier: Modifier) {
+ Box(
+ modifier =
+ modifier
+ .background(
+ color =
+ when (statusLevel) {
+ StatusLevel.Error -> MaterialTheme.colorScheme.error
+ StatusLevel.Warning -> MaterialTheme.colorScheme.warning
+ StatusLevel.Info -> MaterialTheme.colorScheme.tertiary
+ },
+ shape = CircleShape,
+ )
+ .size(Dimens.notificationStatusIconSize)
+ )
+}
+
+@Composable
+private fun NotificationAction(
+ imageVector: ImageVector,
+ contentDescription: String?,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+
+ IconButton(modifier = modifier.testTag(NOTIFICATION_BANNER_ACTION), onClick = onClick) {
+ Icon(
+ modifier = Modifier.padding(Dimens.notificationIconPadding),
+ imageVector = imageVector,
+ contentDescription = contentDescription,
+ tint = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/NotificationData.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/NotificationData.kt
new file mode 100644
index 0000000000..c7fdad4793
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/NotificationData.kt
@@ -0,0 +1,249 @@
+package net.mullvad.mullvadvpn.lib.ui.component
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.OpenInNew
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.withStyle
+import androidx.core.text.HtmlCompat
+import java.net.InetAddress
+import net.mullvad.mullvadvpn.lib.model.AuthFailedError
+import net.mullvad.mullvadvpn.lib.model.ErrorState
+import net.mullvad.mullvadvpn.lib.model.ErrorStateCause
+import net.mullvad.mullvadvpn.lib.model.InAppNotification
+import net.mullvad.mullvadvpn.lib.model.ParameterGenerationError
+import net.mullvad.mullvadvpn.lib.model.StatusLevel
+
+data class NotificationData(
+ val title: AnnotatedString,
+ val message: NotificationMessage? = null,
+ val statusLevel: StatusLevel,
+ val action: NotificationAction? = null,
+) {
+ constructor(
+ title: String,
+ message: String? = null,
+ statusLevel: StatusLevel,
+ action: NotificationAction? = null,
+ ) : this(
+ AnnotatedString(title),
+ message?.let { NotificationMessage.Text(AnnotatedString(it)) },
+ statusLevel,
+ action,
+ )
+
+ constructor(
+ title: String,
+ message: NotificationMessage,
+ statusLevel: StatusLevel,
+ action: NotificationAction? = null,
+ ) : this(AnnotatedString(title), message, statusLevel, action)
+}
+
+sealed interface NotificationMessage {
+ val text: AnnotatedString
+
+ data class Text(override val text: AnnotatedString) : NotificationMessage
+
+ data class ClickableText(
+ override val text: AnnotatedString,
+ val onClick: () -> Unit,
+ val contentDescription: String,
+ ) : NotificationMessage
+}
+
+data class NotificationAction(
+ val icon: ImageVector,
+ val onClick: (() -> Unit),
+ val contentDescription: String,
+)
+
+@Composable
+fun InAppNotification.toNotificationData(
+ isPlayBuild: Boolean,
+ openAppListing: () -> Unit,
+ onClickShowAccount: () -> Unit,
+ onClickShowChangelog: () -> Unit,
+ onClickDismissChangelog: () -> Unit,
+ onClickDismissNewDevice: () -> Unit,
+) =
+ when (this) {
+ is InAppNotification.NewDevice ->
+ NotificationData(
+ title =
+ AnnotatedString(stringResource(id = R.string.new_device_notification_title)),
+ message =
+ NotificationMessage.Text(
+ stringResource(id = R.string.new_device_notification_message, deviceName)
+ .formatWithHtml()
+ ),
+ statusLevel = StatusLevel.Info,
+ action =
+ NotificationAction(
+ Icons.Default.Clear,
+ onClickDismissNewDevice,
+ stringResource(id = R.string.dismiss),
+ ),
+ )
+ is InAppNotification.AccountExpiry ->
+ NotificationData(
+ title = stringResource(id = R.string.account_credit_expires_soon),
+ message = LocalContext.current.resources.getExpiryQuantityString(expiry),
+ statusLevel = StatusLevel.Error,
+ action =
+ if (isPlayBuild) null
+ else
+ NotificationAction(
+ Icons.AutoMirrored.Default.OpenInNew,
+ onClickShowAccount,
+ stringResource(id = R.string.open_url),
+ ),
+ )
+ InAppNotification.TunnelStateBlocked ->
+ NotificationData(
+ title = stringResource(id = R.string.blocking_internet),
+ statusLevel = StatusLevel.Error,
+ )
+ is InAppNotification.TunnelStateError -> errorMessageBannerData(error)
+ is InAppNotification.UnsupportedVersion ->
+ NotificationData(
+ title = stringResource(id = R.string.unsupported_version),
+ message = stringResource(id = R.string.unsupported_version_description),
+ statusLevel = StatusLevel.Error,
+ action =
+ NotificationAction(
+ Icons.AutoMirrored.Default.OpenInNew,
+ openAppListing,
+ stringResource(id = R.string.open_url),
+ ),
+ )
+ is InAppNotification.NewVersionChangelog ->
+ NotificationData(
+ title = stringResource(id = R.string.new_changelog_notification_title),
+ message =
+ NotificationMessage.ClickableText(
+ text =
+ buildAnnotatedString {
+ withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) {
+ append(
+ stringResource(
+ id = R.string.new_changelog_notification_message
+ )
+ )
+ }
+ },
+ onClick = onClickShowChangelog,
+ contentDescription =
+ stringResource(id = R.string.new_changelog_notification_message),
+ ),
+ statusLevel = StatusLevel.Info,
+ action =
+ NotificationAction(
+ Icons.Default.Clear,
+ onClickDismissChangelog,
+ stringResource(id = R.string.dismiss),
+ ),
+ )
+ }
+
+@Composable
+private fun errorMessageBannerData(error: ErrorState) =
+ NotificationData(
+ title = error.title().formatWithHtml(),
+ message = NotificationMessage.Text(error.message().formatWithHtml()),
+ statusLevel = StatusLevel.Error,
+ )
+
+@Composable
+private fun String.formatWithHtml(): AnnotatedString =
+ HtmlCompat.fromHtml(this, HtmlCompat.FROM_HTML_MODE_COMPACT)
+ .toAnnotatedString(
+ boldSpanStyle =
+ SpanStyle(
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.ExtraBold,
+ )
+ )
+
+@Composable
+private fun ErrorState.title(): String {
+ val cause = this.cause
+ return when {
+ cause is ErrorStateCause.InvalidDnsServers -> stringResource(R.string.blocking_internet)
+ cause is ErrorStateCause.NotPrepared ->
+ stringResource(R.string.vpn_permission_error_notification_title)
+ cause is ErrorStateCause.OtherAlwaysOnApp ->
+ stringResource(R.string.always_on_vpn_error_notification_title, cause.appName)
+ cause is ErrorStateCause.OtherLegacyAlwaysOnApp ->
+ stringResource(R.string.legacy_always_on_vpn_error_notification_title)
+ isBlocking -> stringResource(R.string.blocking_internet)
+ else -> stringResource(R.string.critical_error)
+ }
+}
+
+@Composable
+private fun ErrorState.message(): String {
+ val cause = this.cause
+ return when {
+ isBlocking -> cause.errorMessageId()
+ else -> stringResource(R.string.failed_to_block_internet)
+ }
+}
+
+@Composable
+private fun ErrorStateCause.errorMessageId(): String =
+ when (this) {
+ is ErrorStateCause.AuthFailed -> stringResource(error.errorMessageId())
+ is ErrorStateCause.Ipv6Unavailable -> stringResource(R.string.ipv6_unavailable)
+ is ErrorStateCause.FirewallPolicyError -> stringResource(R.string.set_firewall_policy_error)
+ is ErrorStateCause.DnsError -> stringResource(R.string.set_dns_error)
+ is ErrorStateCause.StartTunnelError -> stringResource(R.string.start_tunnel_error)
+ is ErrorStateCause.IsOffline -> stringResource(R.string.is_offline)
+ is ErrorStateCause.TunnelParameterError -> stringResource(error.errorMessageId())
+ is ErrorStateCause.NotPrepared ->
+ stringResource(R.string.vpn_permission_error_notification_message)
+ is ErrorStateCause.OtherAlwaysOnApp ->
+ stringResource(R.string.always_on_vpn_error_notification_content, appName)
+ is ErrorStateCause.OtherLegacyAlwaysOnApp ->
+ stringResource(R.string.legacy_always_on_vpn_error_notification_content)
+ is ErrorStateCause.InvalidDnsServers ->
+ stringResource(
+ R.string.invalid_dns_servers,
+ addresses.joinToString { address -> address.addressString() },
+ )
+ }
+
+private fun AuthFailedError.errorMessageId(): Int =
+ when (this) {
+ AuthFailedError.ExpiredAccount -> R.string.account_credit_has_expired
+ AuthFailedError.InvalidAccount,
+ AuthFailedError.TooManyConnections,
+ AuthFailedError.Unknown -> R.string.auth_failed
+ }
+
+private fun ParameterGenerationError.errorMessageId(): Int =
+ when (this) {
+ ParameterGenerationError.NoMatchingRelay,
+ ParameterGenerationError.NoMatchingBridgeRelay -> {
+ R.string.no_matching_relay
+ }
+ ParameterGenerationError.NoWireguardKey -> R.string.no_wireguard_key
+ ParameterGenerationError.CustomTunnelHostResultionError ->
+ R.string.custom_tunnel_host_resolution_error
+ }
+
+private fun InetAddress.addressString(): String {
+ val hostNameAndAddress = this.toString().split('/', limit = 2)
+ val address = hostNameAndAddress[1]
+
+ return address
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/RememberPrevious.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/RememberPrevious.kt
new file mode 100644
index 0000000000..4ea7b530a7
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/RememberPrevious.kt
@@ -0,0 +1,43 @@
+package net.mullvad.mullvadvpn.lib.ui.component
+
+/*
+ * Code snippet taken from:
+ * https://stackoverflow.com/questions/67801939/get-previous-value-of-state-in-composable-jetpack-compose
+ */
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.remember
+
+@Composable
+fun <T> rememberPrevious(
+ current: T,
+ shouldUpdate: (prev: T?, curr: T) -> Boolean = { a: T?, b: T -> a != b },
+): T? {
+ val ref = rememberRef<T>()
+
+ // launched after render, so the current render will have the old value anyway
+ SideEffect {
+ if (shouldUpdate(ref.value, current)) {
+ ref.value = current
+ }
+ }
+
+ return ref.value
+}
+
+@Composable
+private fun <T> rememberRef(): MutableState<T?> {
+ // for some reason it always recreated the value with vararg keys,
+ // leaving out the keys as a parameter for remember for now
+ return remember {
+ object : MutableState<T?> {
+ override var value: T? = null
+
+ override fun component1(): T? = value
+
+ override fun component2(): (T?) -> Unit = { value = it }
+ }
+ }
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/ResourcesExtensions.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/ResourcesExtensions.kt
new file mode 100644
index 0000000000..ac61990c89
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/ResourcesExtensions.kt
@@ -0,0 +1,25 @@
+package net.mullvad.mullvadvpn.lib.ui.component
+
+import android.content.res.Resources
+import java.time.Duration
+
+private const val DAYS_IN_STANDARD_YEAR = 365
+
+fun Resources.getExpiryQuantityString(accountExpiry: Duration): String {
+ val days = accountExpiry.toDays().toInt()
+ val years = (accountExpiry.toDays() / DAYS_IN_STANDARD_YEAR).toInt()
+
+ return if (accountExpiry.toMillis() <= 0) {
+ getString(R.string.out_of_time)
+ } else if (years > 1) {
+ getRemainingText(this, R.plurals.years_left, years)
+ } else if (days >= 1) {
+ getRemainingText(this, R.plurals.days_left, days)
+ } else {
+ getString(R.string.less_than_a_day_left)
+ }
+}
+
+private fun getRemainingText(resources: Resources, pluralId: Int, quantity: Int): String {
+ return resources.getQuantityString(pluralId, quantity, quantity)
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/SpannedExtensions.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/SpannedExtensions.kt
new file mode 100644
index 0000000000..cf56f8c702
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/SpannedExtensions.kt
@@ -0,0 +1,43 @@
+package net.mullvad.mullvadvpn.lib.ui.component
+
+import android.graphics.Typeface
+import android.text.Spanned
+import android.text.style.StyleSpan
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+
+fun Spanned.toAnnotatedString(boldFontWeight: FontWeight = FontWeight.Bold): AnnotatedString =
+ buildAnnotatedString {
+ val spanned = this@toAnnotatedString
+ append(spanned.toString())
+ getSpans(0, spanned.length, Any::class.java).forEach { span ->
+ val start = getSpanStart(span)
+ val end = getSpanEnd(span)
+ when (span) {
+ is StyleSpan ->
+ when (span.style) {
+ Typeface.BOLD ->
+ addStyle(SpanStyle(fontWeight = boldFontWeight), start, end)
+ }
+ }
+ }
+ }
+
+fun Spanned.toAnnotatedString(
+ boldSpanStyle: SpanStyle = SpanStyle(fontWeight = FontWeight.ExtraBold)
+): AnnotatedString = buildAnnotatedString {
+ val spanned = this@toAnnotatedString
+ append(spanned.toString())
+ getSpans(0, spanned.length, Any::class.java).forEach { span ->
+ val start = getSpanStart(span)
+ val end = getSpanEnd(span)
+ when (span) {
+ is StyleSpan ->
+ when (span.style) {
+ Typeface.BOLD -> addStyle(boldSpanStyle, start, end)
+ }
+ }
+ }
+}
diff --git a/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/test/ComposeTestTagConstants.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/test/ComposeTestTagConstants.kt
new file mode 100644
index 0000000000..24189d1469
--- /dev/null
+++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/test/ComposeTestTagConstants.kt
@@ -0,0 +1,6 @@
+package net.mullvad.mullvadvpn.lib.ui.component.test
+
+// ConnectScreen - Notification banner
+const val NOTIFICATION_BANNER = "notification_banner"
+const val NOTIFICATION_BANNER_ACTION = "notification_banner_action"
+const val NOTIFICATION_BANNER_TEXT_ACTION = "notification_banner_text_action"