diff options
| author | Kalle Lindström <karl.lindstrom@mullvad.net> | 2025-03-11 11:00:15 +0100 |
|---|---|---|
| committer | Kalle Lindström <karl.lindstrom@mullvad.net> | 2025-03-19 09:33:07 +0100 |
| commit | e2d5d4f1f444f1dfbee5077889a62731aec080c6 (patch) | |
| tree | 94e8bfca4a946f931ae3ce6b22a8a50eec795b48 /android/lib/ui | |
| parent | 793c39338a2fcd2bf188952061edaeaa925d614a (diff) | |
| download | mullvadvpn-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')
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" |
