diff options
| author | David Göransson <david.goransson@mullvad.net> | 2025-03-19 09:38:52 +0100 |
|---|---|---|
| committer | David Göransson <david.goransson@mullvad.net> | 2025-03-19 09:38:52 +0100 |
| commit | 22fc75dc527d1510874f7253b728a068426f6beb (patch) | |
| tree | 14c4519dccdfe99d89a512ebcc5527aa5761918a | |
| parent | 793c39338a2fcd2bf188952061edaeaa925d614a (diff) | |
| parent | faabed7502583eb9f58774717c48fcec7c1aca70 (diff) | |
| download | mullvadvpn-22fc75dc527d1510874f7253b728a068426f6beb.tar.xz mullvadvpn-22fc75dc527d1510874f7253b728a068426f6beb.zip | |
Merge branch 'improve-tv-ui-experience'
51 files changed, 1027 insertions, 345 deletions
diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 9d0c5b75f5..462f9cdb03 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -29,6 +29,8 @@ Line wrap the file at 100 chars. Th ### Changed - Disable Wireguard port setting when a obfuscation is selected since it is not used when an obfuscation is applied. +- Adapt UI on Connect Screen for Android TV, including a navigation rail and redesigned in-app + notification bar. ### Removed - Remove Google's resolvers from encrypted DNS proxy. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9145e8411b..261cc9a48e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -372,6 +372,8 @@ dependencies { implementation(projects.lib.resource) implementation(projects.lib.shared) implementation(projects.lib.talpid) + implementation(projects.lib.tv) + implementation(projects.lib.ui.component) implementation(projects.tile) implementation(projects.lib.theme) implementation(projects.service) @@ -388,6 +390,7 @@ dependencies { implementation(libs.androidx.lifecycle.runtime) implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.tv) implementation(libs.arrow) implementation(libs.arrow.optics) implementation(libs.arrow.resilience) diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt index f1a81d4d91..43bc448805 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt @@ -19,8 +19,6 @@ import net.mullvad.mullvadvpn.compose.state.ConnectUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.CONNECT_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.test.CONNECT_CARD_HEADER_TEST_TAG -import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_ACTION -import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_TEXT_ACTION import net.mullvad.mullvadvpn.compose.test.RECONNECT_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.test.TOP_BAR_ACCOUNT_BUTTON @@ -28,11 +26,13 @@ import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect import net.mullvad.mullvadvpn.lib.model.ErrorState import net.mullvad.mullvadvpn.lib.model.ErrorStateCause import net.mullvad.mullvadvpn.lib.model.GeoIpLocation +import net.mullvad.mullvadvpn.lib.model.InAppNotification import net.mullvad.mullvadvpn.lib.model.TransportProtocol import net.mullvad.mullvadvpn.lib.model.TunnelEndpoint import net.mullvad.mullvadvpn.lib.model.TunnelState -import net.mullvad.mullvadvpn.repository.InAppNotification -import net.mullvad.mullvadvpn.ui.VersionInfo +import net.mullvad.mullvadvpn.lib.model.VersionInfo +import net.mullvad.mullvadvpn.lib.ui.component.test.NOTIFICATION_BANNER_ACTION +import net.mullvad.mullvadvpn.lib.ui.component.test.NOTIFICATION_BANNER_TEXT_ACTION import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt index 4f527a94c5..9ff9ec5a00 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt @@ -1,49 +1,25 @@ package net.mullvad.mullvadvpn.compose.component.notificationbanner -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.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -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.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.constraintlayout.compose.ConstraintLayout -import androidx.constraintlayout.compose.Dimension import java.time.Duration import net.mullvad.mullvadvpn.compose.component.MullvadTopBar -import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER -import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_ACTION -import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_TEXT_ACTION -import net.mullvad.mullvadvpn.compose.util.rememberPrevious +import net.mullvad.mullvadvpn.compose.util.isTv 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.VersionInfo import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.lib.theme.Dimens -import net.mullvad.mullvadvpn.lib.theme.color.warning -import net.mullvad.mullvadvpn.repository.InAppNotification -import net.mullvad.mullvadvpn.ui.VersionInfo -import net.mullvad.mullvadvpn.ui.notification.StatusLevel +import net.mullvad.mullvadvpn.lib.tv.NotificationBannerTv +import net.mullvad.mullvadvpn.lib.ui.component.AnimatedNotificationBanner @Preview @Composable @@ -52,18 +28,17 @@ private fun PreviewNotificationBanner() { Column(Modifier.background(color = MaterialTheme.colorScheme.surface)) { val bannerDataList = listOf( - InAppNotification.UnsupportedVersion( - versionInfo = VersionInfo(currentVersion = "1.0", isSupported = false) - ), - InAppNotification.AccountExpiry(expiry = Duration.ZERO), - InAppNotification.TunnelStateBlocked, - InAppNotification.NewDevice("Courageous Turtle"), - InAppNotification.TunnelStateError( - error = ErrorState(ErrorStateCause.FirewallPolicyError.Generic, true) - ), - InAppNotification.NewVersionChangelog, - ) - .map { it.toNotificationData(false, {}, {}, {}, {}, {}) } + InAppNotification.UnsupportedVersion( + versionInfo = VersionInfo(currentVersion = "1.0", isSupported = false) + ), + InAppNotification.AccountExpiry(expiry = Duration.ZERO), + InAppNotification.TunnelStateBlocked, + InAppNotification.NewDevice("Courageous Turtle"), + InAppNotification.TunnelStateError( + error = ErrorState(ErrorStateCause.FirewallPolicyError.Generic, true) + ), + InAppNotification.NewVersionChangelog, + ) bannerDataList.forEach { MullvadTopBar( @@ -72,7 +47,15 @@ private fun PreviewNotificationBanner() { onAccountClicked = {}, iconTintColor = MaterialTheme.colorScheme.primary, ) - Notification(it) + NotificationBanner( + notification = it, + isPlayBuild = false, + openAppListing = {}, + onClickShowAccount = {}, + onClickShowChangelog = {}, + onClickDismissChangelog = {}, + onClickDismissNewDevice = {}, + ) Spacer(modifier = Modifier.size(16.dp)) } } @@ -90,163 +73,28 @@ fun NotificationBanner( onClickDismissChangelog: () -> Unit, onClickDismissNewDevice: () -> Unit, ) { - // Fix for animating to invisible state - val previous = rememberPrevious(current = notification, shouldUpdate = { _, _ -> true }) - AnimatedVisibility( - visible = notification != null, - enter = slideInVertically(initialOffsetY = { -it }), - exit = slideOutVertically(targetOffsetY = { -it }), - modifier = modifier, - ) { - val visibleNotification = notification ?: previous - if (visibleNotification != null) - Notification( - visibleNotification.toNotificationData( - isPlayBuild = isPlayBuild, - openAppListing, - onClickShowAccount, - onClickShowChangelog, - onClickDismissChangelog, - onClickDismissNewDevice, - ) - ) - } -} - -@Composable -@Suppress("LongMethod") -private fun Notification(notificationBannerData: NotificationData) { - val (title, message, statusLevel, action) = notificationBannerData - ConstraintLayout( - modifier = - Modifier.fillMaxWidth() - .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, + if (isTv()) { + NotificationBannerTv( + modifier = modifier, + notification = notification, + isPlayBuild = isPlayBuild, + openAppListing = openAppListing, + onClickShowAccount = onClickShowAccount, + onClickShowChangelog = onClickShowChangelog, + onClickDismissChangelog = onClickDismissChangelog, + onClickDismissNewDevice = onClickDismissNewDevice, ) - 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, + } else { + AnimatedNotificationBanner( + modifier = modifier, + notificationModifier = Modifier.fillMaxWidth(), + notification = notification, + isPlayBuild = isPlayBuild, + openAppListing = openAppListing, + onClickShowAccount = onClickShowAccount, + onClickShowChangelog = onClickShowChangelog, + onClickDismissChangelog = onClickDismissChangelog, + onClickDismissNewDevice = onClickDismissNewDevice, ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt index d31325f140..867f614699 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt @@ -15,11 +15,11 @@ import com.ramcosta.composedestinations.result.ResultBackNavigator import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.textResource -import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString import net.mullvad.mullvadvpn.compose.preview.DevicePreviewParameterProvider import net.mullvad.mullvadvpn.lib.model.Device import net.mullvad.mullvadvpn.lib.model.DeviceId import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.ui.component.toAnnotatedString @Preview @Composable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoDialog.kt index b77decc9f0..30e4ebcc87 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoDialog.kt @@ -24,10 +24,10 @@ import androidx.core.text.HtmlCompat import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar -import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar +import net.mullvad.mullvadvpn.lib.ui.component.toAnnotatedString @Preview @Composable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AppInfoUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AppInfoUiStatePreviewParameterProvider.kt index 42d23a1d03..bd11a5f654 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AppInfoUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AppInfoUiStatePreviewParameterProvider.kt @@ -1,7 +1,7 @@ package net.mullvad.mullvadvpn.compose.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import net.mullvad.mullvadvpn.ui.VersionInfo +import net.mullvad.mullvadvpn.lib.model.VersionInfo import net.mullvad.mullvadvpn.viewmodel.AppInfoUiState class AppInfoUiStatePreviewParameterProvider : PreviewParameterProvider<AppInfoUiState> { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ConnectUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ConnectUiStatePreviewParameterProvider.kt index b2150d0037..d3c704d0a2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ConnectUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ConnectUiStatePreviewParameterProvider.kt @@ -5,6 +5,7 @@ import java.net.InetAddress import net.mullvad.mullvadvpn.compose.state.ConnectUiState import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect import net.mullvad.mullvadvpn.lib.model.GeoIpLocation +import net.mullvad.mullvadvpn.lib.model.InAppNotification class ConnectUiStatePreviewParameterProvider : PreviewParameterProvider<ConnectUiState> { override val values = sequenceOf(ConnectUiState.INITIAL) + generateOtherStates() @@ -29,7 +30,7 @@ private fun generateOtherStates(): Sequence<ConnectUiState> = ), TunnelStatePreviewData.generateErrorState(isBlocking = true), ) - .map { state -> + .mapIndexed { index, state -> ConnectUiState( location = GeoIpLocation( @@ -45,7 +46,8 @@ private fun generateOtherStates(): Sequence<ConnectUiState> = selectedRelayItemTitle = "Relay Title", tunnelState = state, showLocation = true, - inAppNotification = null, + inAppNotification = + if (index == 0) InAppNotification.NewDevice("Test Device") else null, deviceName = "Cool Beans", daysLeftUntilExpiry = 42, isPlayBuild = true, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AutoConnectAndLockdownModeScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AutoConnectAndLockdownModeScreen.kt index 39b9e174f2..ceddf9dc99 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AutoConnectAndLockdownModeScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AutoConnectAndLockdownModeScreen.kt @@ -54,13 +54,13 @@ import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithLargeTopBarAndButton -import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.lib.common.util.openVpnSettings import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible +import net.mullvad.mullvadvpn.lib.ui.component.toAnnotatedString import net.mullvad.mullvadvpn.service.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt index 7c4bdbd3b3..eb6df6c820 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.calculateEndPadding @@ -25,6 +26,8 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -71,6 +74,7 @@ import net.mullvad.mullvadvpn.compose.button.SwitchLocationButton import net.mullvad.mullvadvpn.compose.component.ConnectionStatusText import net.mullvad.mullvadvpn.compose.component.ExpandChevron import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge +import net.mullvad.mullvadvpn.compose.component.MullvadSnackbar import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBarAndDeviceName import net.mullvad.mullvadvpn.compose.component.connectioninfo.ConnectionDetailPanel import net.mullvad.mullvadvpn.compose.component.connectioninfo.FeatureIndicatorsPanel @@ -89,6 +93,7 @@ import net.mullvad.mullvadvpn.compose.transitions.HomeTransition import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle import net.mullvad.mullvadvpn.compose.util.CreateVpnProfile import net.mullvad.mullvadvpn.compose.util.OnNavResultValue +import net.mullvad.mullvadvpn.compose.util.isTv import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately import net.mullvad.mullvadvpn.constant.SECURE_ZOOM import net.mullvad.mullvadvpn.constant.SECURE_ZOOM_ANIMATION_MILLIS @@ -115,6 +120,7 @@ import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible import net.mullvad.mullvadvpn.lib.theme.typeface.connectionStatus import net.mullvad.mullvadvpn.lib.theme.typeface.hostname +import net.mullvad.mullvadvpn.lib.tv.NavigationDrawerTv import net.mullvad.mullvadvpn.util.removeHtmlTags import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel import org.koin.androidx.compose.koinViewModel @@ -267,71 +273,130 @@ fun ConnectScreen( onAccountClick: () -> Unit, onDismissNewDeviceClick: () -> Unit, ) { + val content = + @Composable { padding: PaddingValues -> + Content( + padding, + state, + onDisconnectClick, + onReconnectClick, + onConnectClick, + onCancelClick, + onSwitchLocationClick, + onOpenAppListing, + onManageAccountClick, + onChangelogClick, + onDismissChangelogClick, + onDismissNewDeviceClick, + ) + } - ScaffoldWithTopBarAndDeviceName( - topBarColor = state.tunnelState.topBarColor(), - iconTintColor = state.tunnelState.iconTintColor(), - onSettingsClicked = onSettingsClick, - onAccountClicked = onAccountClick, - deviceName = state.deviceName, - timeLeft = state.daysLeftUntilExpiry, - snackbarHostState = snackbarHostState, - ) { - val configuration = LocalConfiguration.current - val screenHeight = configuration.screenHeightDp.dp - val indicatorPercentOffset = - if (screenHeight < SCREEN_HEIGHT_THRESHOLD) SHORT_SCREEN_INDICATOR_BIAS - else TALL_SCREEN_INDICATOR_BIAS - - Box( - Modifier.padding( - top = it.calculateTopPadding(), - start = it.calculateStartPadding(LocalLayoutDirection.current), - end = it.calculateEndPadding(LocalLayoutDirection.current), + if (isTv()) { + Scaffold( + snackbarHost = { + SnackbarHost( + snackbarHostState, + snackbar = { snackbarData -> MullvadSnackbar(snackbarData = snackbarData) }, ) - .fillMaxSize() + } ) { - MullvadMap(state, indicatorPercentOffset) + NavigationDrawerTv( + daysLeftUntilExpiry = state.daysLeftUntilExpiry, + deviceName = state.deviceName, + onSettingsClick = onSettingsClick, + onAccountClick = onAccountClick, + ) { + content(it) + } + } + } else { + ScaffoldWithTopBarAndDeviceName( + topBarColor = state.tunnelState.topBarColor(), + iconTintColor = state.tunnelState.iconTintColor(), + onSettingsClicked = onSettingsClick, + onAccountClicked = onAccountClick, + deviceName = state.deviceName, + timeLeft = state.daysLeftUntilExpiry, + snackbarHostState = snackbarHostState, + ) { + content(it) + } + } +} - MullvadCircularProgressIndicatorLarge( - color = MaterialTheme.colorScheme.onSurface, - modifier = - Modifier.layout { measurable, constraints -> - val placeable = measurable.measure(constraints) - layout(placeable.width, placeable.height) { - placeable.placeRelative( - x = (constraints.maxWidth * 0.5f - placeable.width / 2).toInt(), - y = - (constraints.maxHeight * indicatorPercentOffset - - placeable.height / 2) - .toInt(), - ) - } - } - .alpha(if (state.showLoading) AlphaVisible else AlphaInvisible) - .testTag(CIRCULAR_PROGRESS_INDICATOR), +@Composable +private fun Content( + paddingValues: PaddingValues, + state: ConnectUiState, + onDisconnectClick: () -> Unit, + onReconnectClick: () -> Unit, + onConnectClick: () -> Unit, + onCancelClick: () -> Unit, + onSwitchLocationClick: () -> Unit, + onOpenAppListing: () -> Unit, + onManageAccountClick: () -> Unit, + onChangelogClick: () -> Unit, + onDismissChangelogClick: () -> Unit, + onDismissNewDeviceClick: () -> Unit, +) { + val configuration = LocalConfiguration.current + val screenHeight = configuration.screenHeightDp.dp + val indicatorPercentOffset = + if (screenHeight < SCREEN_HEIGHT_THRESHOLD) SHORT_SCREEN_INDICATOR_BIAS + else TALL_SCREEN_INDICATOR_BIAS + + Box( + Modifier.padding( + top = paddingValues.calculateTopPadding(), + start = paddingValues.calculateStartPadding(LocalLayoutDirection.current), + end = paddingValues.calculateEndPadding(LocalLayoutDirection.current), ) + .fillMaxSize() + ) { + MullvadMap(state, indicatorPercentOffset) - Box(modifier = Modifier.fillMaxSize().padding(bottom = it.calculateBottomPadding())) { - NotificationBanner( - notification = state.inAppNotification, - isPlayBuild = state.isPlayBuild, - openAppListing = onOpenAppListing, - onClickShowAccount = onManageAccountClick, - onClickShowChangelog = onChangelogClick, - onClickDismissChangelog = onDismissChangelogClick, - onClickDismissNewDevice = onDismissNewDeviceClick, - ) - ConnectionCard( - state = state, - modifier = Modifier.align(Alignment.BottomCenter), - onSwitchLocationClick = onSwitchLocationClick, - onDisconnectClick = onDisconnectClick, - onReconnectClick = onReconnectClick, - onCancelClick = onCancelClick, - onConnectClick = onConnectClick, - ) - } + MullvadCircularProgressIndicatorLarge( + color = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.placeRelative( + x = (constraints.maxWidth * 0.5f - placeable.width / 2).toInt(), + y = + (constraints.maxHeight * indicatorPercentOffset - + placeable.height / 2) + .toInt(), + ) + } + } + .alpha(if (state.showLoading) AlphaVisible else AlphaInvisible) + .testTag(CIRCULAR_PROGRESS_INDICATOR), + ) + + Box( + modifier = + Modifier.fillMaxSize().padding(bottom = paddingValues.calculateBottomPadding()) + ) { + NotificationBanner( + modifier = Modifier.align(Alignment.TopCenter), + notification = state.inAppNotification, + isPlayBuild = state.isPlayBuild, + openAppListing = onOpenAppListing, + onClickShowAccount = onManageAccountClick, + onClickShowChangelog = onChangelogClick, + onClickDismissChangelog = onDismissChangelogClick, + onClickDismissNewDevice = onDismissNewDeviceClick, + ) + ConnectionCard( + state = state, + modifier = Modifier.align(Alignment.BottomCenter), + onSwitchLocationClick = onSwitchLocationClick, + onDisconnectClick = onDisconnectClick, + onReconnectClick = onReconnectClick, + onCancelClick = onCancelClick, + onConnectClick = onConnectClick, + ) } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt index 4e17b6918b..63b596d513 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt @@ -1,8 +1,8 @@ package net.mullvad.mullvadvpn.compose.state import net.mullvad.mullvadvpn.lib.model.GeoIpLocation +import net.mullvad.mullvadvpn.lib.model.InAppNotification import net.mullvad.mullvadvpn.lib.model.TunnelState -import net.mullvad.mullvadvpn.repository.InAppNotification data class ConnectUiState( val location: GeoIpLocation?, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt index f38e349a7f..4b6a339f61 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt @@ -49,11 +49,6 @@ const val LOCATION_INFO_TEST_TAG = "location_info_test_tag" const val LOCATION_INFO_CONNECTION_IN_TEST_TAG = "location_info_connection_in_test_tag" const val LOCATION_INFO_CONNECTION_OUT_TEST_TAG = "location_info_connection_out_test_tag" -// 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" - // PlayPayment const val PLAY_PAYMENT_INFO_ICON_TEST_TAG = "play_payment_info_icon_test_tag" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Device.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Device.kt new file mode 100644 index 0000000000..a1fed50acb --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Device.kt @@ -0,0 +1,13 @@ +package net.mullvad.mullvadvpn.compose.util + +import android.content.pm.PackageManager +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.booleanResource +import net.mullvad.mullvadvpn.R + +@Composable +fun isTv(): Boolean { + return booleanResource(R.bool.isTv) || + LocalContext.current.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt index 0e3e004f0b..752e185d14 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt @@ -1,60 +1,16 @@ package net.mullvad.mullvadvpn.repository -import java.time.Duration import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import net.mullvad.mullvadvpn.lib.model.ErrorState -import net.mullvad.mullvadvpn.ui.VersionInfo import net.mullvad.mullvadvpn.usecase.AccountExpiryInAppNotificationUseCase import net.mullvad.mullvadvpn.usecase.NewChangelogNotificationUseCase import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase -enum class StatusLevel { - Error, - Warning, - Info, -} - -sealed class InAppNotification { - abstract val statusLevel: StatusLevel - abstract val priority: Long - - data class TunnelStateError(val error: ErrorState) : InAppNotification() { - override val statusLevel = StatusLevel.Error - override val priority: Long = 1001 - } - - data object TunnelStateBlocked : InAppNotification() { - override val statusLevel = StatusLevel.Error - override val priority: Long = 1000 - } - - data class UnsupportedVersion(val versionInfo: VersionInfo) : InAppNotification() { - override val statusLevel = StatusLevel.Error - override val priority: Long = 999 - } - - data class AccountExpiry(val expiry: Duration) : InAppNotification() { - override val statusLevel = StatusLevel.Warning - override val priority: Long = 1001 - } - - data class NewDevice(val deviceName: String) : InAppNotification() { - override val statusLevel = StatusLevel.Info - override val priority: Long = 1001 - } - - data object NewVersionChangelog : InAppNotification() { - override val statusLevel = StatusLevel.Info - override val priority: Long = 1001 - } -} - class InAppNotificationController( accountExpiryInAppNotificationUseCase: AccountExpiryInAppNotificationUseCase, newDeviceNotificationUseCase: NewDeviceNotificationUseCase, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AppVersionInfoRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AppVersionInfoRepository.kt index 7a74c0f0d2..d78521b9be 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AppVersionInfoRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AppVersionInfoRepository.kt @@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService import net.mullvad.mullvadvpn.lib.model.BuildVersion -import net.mullvad.mullvadvpn.ui.VersionInfo +import net.mullvad.mullvadvpn.lib.model.VersionInfo class AppVersionInfoRepository( private val buildVersion: BuildVersion, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt index 057494f762..a39afe9c39 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt @@ -5,8 +5,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import net.mullvad.mullvadvpn.lib.model.InAppNotification import net.mullvad.mullvadvpn.lib.shared.AccountRepository -import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL import net.mullvad.mullvadvpn.service.notifications.accountexpiry.AccountExpiryTicker diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewChangelogNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewChangelogNotificationUseCase.kt index 157de67013..5936d7b3a6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewChangelogNotificationUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewChangelogNotificationUseCase.kt @@ -2,8 +2,8 @@ package net.mullvad.mullvadvpn.usecase import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import net.mullvad.mullvadvpn.lib.model.InAppNotification import net.mullvad.mullvadvpn.repository.ChangelogRepository -import net.mullvad.mullvadvpn.repository.InAppNotification class NewChangelogNotificationUseCase(private val changelogRepository: ChangelogRepository) { operator fun invoke() = diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCase.kt index 2faca012b7..4374ca6037 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCase.kt @@ -3,8 +3,8 @@ package net.mullvad.mullvadvpn.usecase import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import net.mullvad.mullvadvpn.lib.model.InAppNotification import net.mullvad.mullvadvpn.lib.shared.DeviceRepository -import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.mullvadvpn.repository.NewDeviceRepository class NewDeviceNotificationUseCase( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCase.kt index 888f9f67bf..85ea7cf11a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCase.kt @@ -4,9 +4,9 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect +import net.mullvad.mullvadvpn.lib.model.InAppNotification import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy -import net.mullvad.mullvadvpn.repository.InAppNotification class TunnelStateNotificationUseCase(private val connectionProxy: ConnectionProxy) { operator fun invoke(): Flow<List<InAppNotification>> = diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt index d46089a9d3..6575871f21 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt @@ -2,8 +2,8 @@ package net.mullvad.mullvadvpn.usecase import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -import net.mullvad.mullvadvpn.repository.InAppNotification -import net.mullvad.mullvadvpn.ui.VersionInfo +import net.mullvad.mullvadvpn.lib.model.InAppNotification +import net.mullvad.mullvadvpn.lib.model.VersionInfo import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository class VersionNotificationUseCase( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AppInfoViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AppInfoViewModel.kt index 16ec17be5d..ae197fa7ef 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AppInfoViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AppInfoViewModel.kt @@ -14,8 +14,8 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.lib.model.VersionInfo import net.mullvad.mullvadvpn.repository.ChangelogRepository -import net.mullvad.mullvadvpn.ui.VersionInfo import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository class AppInfoViewModel( diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt index 9db14ad914..b7be4e574d 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.lib.model.ErrorState -import net.mullvad.mullvadvpn.repository.InAppNotification +import net.mullvad.mullvadvpn.lib.model.InAppNotification import net.mullvad.mullvadvpn.repository.InAppNotificationController import net.mullvad.mullvadvpn.usecase.AccountExpiryInAppNotificationUseCase import net.mullvad.mullvadvpn.usecase.NewChangelogNotificationUseCase diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCaseTest.kt index 68b29790ac..df7d561f84 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCaseTest.kt @@ -17,8 +17,8 @@ import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.lib.model.AccountData +import net.mullvad.mullvadvpn.lib.model.InAppNotification import net.mullvad.mullvadvpn.lib.shared.AccountRepository -import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL import org.junit.jupiter.api.AfterEach diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCaseTest.kt index 2c97ea36a1..414c7c1e08 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCaseTest.kt @@ -16,8 +16,8 @@ import net.mullvad.mullvadvpn.lib.model.AccountNumber import net.mullvad.mullvadvpn.lib.model.Device import net.mullvad.mullvadvpn.lib.model.DeviceId import net.mullvad.mullvadvpn.lib.model.DeviceState +import net.mullvad.mullvadvpn.lib.model.InAppNotification import net.mullvad.mullvadvpn.lib.shared.DeviceRepository -import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.mullvadvpn.repository.NewDeviceRepository import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt index 20a6a1bef0..8d2ece124b 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt @@ -12,9 +12,9 @@ import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect import net.mullvad.mullvadvpn.lib.model.ErrorState +import net.mullvad.mullvadvpn.lib.model.InAppNotification import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy -import net.mullvad.mullvadvpn.repository.InAppNotification import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt index e9452884cf..78f2fb72df 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt @@ -10,8 +10,8 @@ import kotlin.test.assertTrue import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.repository.InAppNotification -import net.mullvad.mullvadvpn.ui.VersionInfo +import net.mullvad.mullvadvpn.lib.model.InAppNotification +import net.mullvad.mullvadvpn.lib.model.VersionInfo import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt index 5950f6475f..ec4e9c0bbb 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt @@ -24,6 +24,7 @@ import net.mullvad.mullvadvpn.lib.model.AccountData import net.mullvad.mullvadvpn.lib.model.DeviceState import net.mullvad.mullvadvpn.lib.model.ErrorState import net.mullvad.mullvadvpn.lib.model.GeoIpLocation +import net.mullvad.mullvadvpn.lib.model.InAppNotification import net.mullvad.mullvadvpn.lib.model.TunnelEndpoint import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken @@ -31,7 +32,6 @@ import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.repository.ChangelogRepository -import net.mullvad.mullvadvpn.repository.InAppNotification import net.mullvad.mullvadvpn.repository.InAppNotificationController import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt index b71d217408..f0a60c50c2 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt @@ -13,11 +13,11 @@ import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.lib.model.Constraint import net.mullvad.mullvadvpn.lib.model.DeviceState import net.mullvad.mullvadvpn.lib.model.Settings +import net.mullvad.mullvadvpn.lib.model.VersionInfo import net.mullvad.mullvadvpn.lib.model.WireguardConstraints import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.repository.SettingsRepository import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository -import net.mullvad.mullvadvpn.ui.VersionInfo import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 33180d1cf3..864af8f07a 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -22,6 +22,7 @@ androidx-testmonitor = "1.7.2" androidx-testorchestrator = "1.5.1" androidx-testrunner = "1.6.2" androidx-uiautomator = "2.4.0-alpha01" +androidx-tv = "1.0.0" # Arrow arrow = "2.0.1" @@ -31,6 +32,7 @@ compose = "1.7.8" compose-destinations = "2.1.0" compose-constraintlayout = "1.1.1" compose-material3 = "1.3.1" +compose-material-tv = "1.1.0-alpha01" # Update suppression for 'InvalidPackage' in config/lint.xml grpc = "1.71.0" @@ -99,6 +101,7 @@ androidx-test-runner = { module = "androidx.test:runner", version.ref = "android androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" } androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" } androidx-test-orchestrator = { module = "androidx.test:orchestrator", version.ref = "androidx-testorchestrator" } +androidx-tv = { module = "androidx.tv:tv-material", version.ref = "androidx-tv" } # Arrow arrow = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" } diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml index 10e0fef4c4..6ddf413c72 100644 --- a/android/gradle/verification-metadata.xml +++ b/android/gradle/verification-metadata.xml @@ -200,6 +200,7 @@ <trusting group="androidx.lifecycle"/> <trusting group="androidx.navigation"/> <trusting group="androidx.profileinstaller" name="profileinstaller"/> + <trusting group="androidx.tv" name="tv-material"/> <trusting group="^androidx[.]compose($|([.].*))" regex="true"/> <trusting group="^androidx[.]test($|([.].*))" regex="true"/> <trusting group="^com[.]android($|([.].*))" regex="true"/> @@ -654,6 +655,11 @@ <sha256 value="e98defdf92ca1fcbeaf16e78a60c18052b01340da3849b93e25e944f06a4e527" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.animation" name="animation" version="1.6.8"> + <artifact name="animation-1.6.8.module"> + <sha256 value="31e6783f9a1de6e021942c5be1f1d777e330bfe017f5429032a24f4c3a940726" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.animation" name="animation" version="1.7.0"> <artifact name="animation-1.7.0.module"> <sha256 value="de5277c940808643cc4928d33ffd3a20c0d1da49c1f985bf57f4edbe9f0ed625" origin="Generated by Gradle"/> @@ -678,6 +684,11 @@ <sha256 value="a2773807796a9ea149e4d567d3e4746bc3b2514147a180b00be2ca307f732c4d" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.animation" name="animation-android" version="1.6.8"> + <artifact name="animation-android-1.6.8.module"> + <sha256 value="4f2718c77ef295fbbba8a92b0deaf6d97fc21d0bcd1753d9cba3c20b25c4a076" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.animation" name="animation-android" version="1.7.2"> <artifact name="animation-android-1.7.2.module"> <sha256 value="7f4773c5800c09adb41f37a266effaac1618737eeadae80310a771a37cd8b547" origin="Generated by Gradle"/> @@ -706,6 +717,11 @@ <sha256 value="61bfa244fb1979bcca6dcb80a5c040115aecbad84d7d2e29cf237b483cab2248" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.animation" name="animation-core" version="1.6.8"> + <artifact name="animation-core-1.6.8.module"> + <sha256 value="73e54651dbbfec4641840b3f3b7ad477833132cd336de2a22cc93ab233d3bd5c" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.animation" name="animation-core" version="1.7.0"> <artifact name="animation-core-1.7.0.module"> <sha256 value="f47f2eeea6677efe6e54516ece48c9835810dc4a8581fe530761463a602cd239" origin="Generated by Gradle"/> @@ -735,6 +751,11 @@ <sha256 value="c571f43b5780fb80f42e85644e0ba5696af088d7228f3251c3e1d64c3bd64376" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.animation" name="animation-core-android" version="1.6.8"> + <artifact name="animation-core-android-1.6.8.module"> + <sha256 value="2814fcf1645cb1d5782b216236b99a4e2dde5bdcbb8e815f4514c044c28b2bef" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.animation" name="animation-core-android" version="1.7.8"> <artifact name="animation-core-android-1.7.8.module"> <sha256 value="3ce792d09bcf03a1cc1d55f1424822f0961ec9bb019450d2f036c7af1205a10c" origin="Generated by Gradle"/> @@ -756,6 +777,11 @@ <sha256 value="7d45e05892d2b9a14341e56e40c090bf9970128ab25c0d2140ba51a0389ce639" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.animation" name="animation-desktop" version="1.6.8"> + <artifact name="animation-desktop-1.6.8.module"> + <sha256 value="1ab7498b6321262d6c42611a8d9034d950bc11ede7eac09b7e2fc6bf97dbf8c2" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.animation" name="animation-jvmstubs" version="1.7.8"> <artifact name="animation-jvmstubs-1.7.8.jar"> <sha256 value="085168cfa19c0bf96ce0e1ef5bd2586997555d706ae2572d522aef141cb4d335" origin="Generated by Gradle"/> @@ -764,6 +790,11 @@ <sha256 value="9a3c2e37dd9715444b0c55358dbe1bdca4d042e6d5071e512a029d3c65bda225" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.foundation" name="foundation" version="1.6.8"> + <artifact name="foundation-1.6.8.module"> + <sha256 value="045615477691111fefca60926d3657707d4af3dc5d0221a9cfbbe9cf92399699" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.foundation" name="foundation" version="1.7.0"> <artifact name="foundation-1.7.0.module"> <sha256 value="8462ad30b9671fb6afd6c757c9b0830f66bb13ff0ca4d28e554e7b151a1c7291" origin="Generated by Gradle"/> @@ -780,6 +811,11 @@ <sha256 value="40c973f6464c280219e3e96443c5c6d536e84a077a8d493d55a545b3869744bd" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.foundation" name="foundation-android" version="1.6.8"> + <artifact name="foundation-android-1.6.8.module"> + <sha256 value="b92502e46bc91f147a8569758db9430bba34e6af477068c3cfea1207f872ab27" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.foundation" name="foundation-android" version="1.7.8"> <artifact name="foundation-android-1.7.8.module"> <sha256 value="dc733df156257961210fca6e715d1b5406afd2ed92989e44ed44366fc395bb1e" origin="Generated by Gradle"/> @@ -796,6 +832,11 @@ <sha256 value="a693be7c9683ab7a2932de9624047f92457a7543546c8f624f1e6528df892505" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.foundation" name="foundation-layout" version="1.6.8"> + <artifact name="foundation-layout-1.6.8.module"> + <sha256 value="7563a54ddec275c1428c3aed8ff4627a5e2ab405051e81b126e3c1c99502aa11" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.foundation" name="foundation-layout" version="1.7.0"> <artifact name="foundation-layout-1.7.0.module"> <sha256 value="7846029e463db74321494031fbfd0d1cd95d269c8bf586213bca191052e63876" origin="Generated by Gradle"/> @@ -820,6 +861,11 @@ <sha256 value="4094e5a3626380b22b684086e548be51afe18e7979f46d261d4f1e0b6898fb39" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.foundation" name="foundation-layout-android" version="1.6.8"> + <artifact name="foundation-layout-android-1.6.8.module"> + <sha256 value="f7769c1f05d0361bc0258291baea6865bba438bebd090ce21ff5ec1771b58c71" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.foundation" name="foundation-layout-android" version="1.7.0"> <artifact name="foundation-layout-android-1.7.0.module"> <sha256 value="4f6877bd205bd23a54f6f8209b07333fb878fcbd5187ba9a9881b298cd12b8a8" origin="Generated by Gradle"/> @@ -838,6 +884,11 @@ <sha256 value="a83233e768e30aab870e6667277ec91dd40adb5663d32f36dfa5dbad367db561" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.foundation" name="foundation-layout-desktop" version="1.6.8"> + <artifact name="foundation-layout-desktop-1.6.8.module"> + <sha256 value="47a7bcfbd1473204da930c9a3611e967ef1953cbfdae64e785863012573e7487" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.foundation" name="foundation-layout-jvmstubs" version="1.7.8"> <artifact name="foundation-layout-jvmstubs-1.7.8.jar"> <sha256 value="c03f5dca426beedbdfdf53abcf90ca5445dc6eb354688db763078c47f74bb5e3" origin="Generated by Gradle"/> @@ -859,6 +910,11 @@ <sha256 value="bf08f6b522f64eef4467d56e566951039eae55b54c37023712a81d4dfb214bdd" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.material" name="material" version="1.6.8"> + <artifact name="material-1.6.8.module"> + <sha256 value="85760ca4ac3b28d2e869669f042ac41976da2ad16ea17e2f280c2ae1b6f7eebd" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.material" name="material-android" version="1.6.0"> <artifact name="material-android-1.6.0.module"> <sha256 value="923f5d7b44d423605193b651023f1b4fd7e1d8cf763bd64942b80122ff7e2885" origin="Generated by Gradle"/> @@ -883,6 +939,14 @@ <sha256 value="951f2a3a6c0913819dfaae7c69cb8cdf977f7c79bd53fef03e4faf459ee30a0f" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.material" name="material-icons-core" version="1.6.8"> + <artifact name="material-icons-core-1.6.8.module"> + <sha256 value="ea1acaa1dfd488d6cab2d9d645010892e784b10b592adbe8290c5a8aaaf1944e" origin="Generated by Gradle"/> + </artifact> + <artifact name="material-icons-core-metadata-1.6.8.jar"> + <sha256 value="951f2a3a6c0913819dfaae7c69cb8cdf977f7c79bd53fef03e4faf459ee30a0f" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.material" name="material-icons-core" version="1.7.8"> <artifact name="material-icons-core-1.7.8.module"> <sha256 value="f9d63655bac19ff7f27abf68a9c0f38f5e42c85e365655b990e6e1a317f92e2f" origin="Generated by Gradle"/> @@ -899,6 +963,11 @@ <sha256 value="0400755a3aa7270893445a18cd845e35064c9de02c1c41cf2083ad4724bcac6f" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.material" name="material-icons-core-android" version="1.6.8"> + <artifact name="material-icons-core-android-1.6.8.module"> + <sha256 value="8e0540fb09dd3a483168488cceec4806e4cc0a7946354a64c72c61e63fd415ff" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.material" name="material-icons-core-android" version="1.7.8"> <artifact name="material-icons-core-android-1.7.8.module"> <sha256 value="99a1ca83e54261a65eb96d44ea02fae43588be45ade5e97963d73e8489ea4a54" origin="Generated by Gradle"/> @@ -907,6 +976,11 @@ <sha256 value="332c06b25e662cc417fb087e76b8faa5cb249f4992ffa3360084a3d4ab882284" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.material" name="material-icons-core-desktop" version="1.6.8"> + <artifact name="material-icons-core-desktop-1.6.8.module"> + <sha256 value="898008d26735f253b40fef3fc1b66d34d6d593706e679b4f21d0ce6e1ad1c75a" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.material" name="material-icons-core-desktop" version="1.7.8"> <artifact name="material-icons-core-desktop-1.7.8.jar"> <sha256 value="b5729220e242132b22b0c0317a304ff167a05cc685c3e9e6483d5dfca3495f56" origin="Generated by Gradle"/> @@ -995,6 +1069,11 @@ <sha256 value="89a673451d542de4819c0da6c0c680f5cc15d93e234eb67a3feed3a1a774d348" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.runtime" name="runtime" version="1.6.8"> + <artifact name="runtime-1.6.8.module"> + <sha256 value="15d27ca9a22e02345d2193c1d1ab509f77c714c6b3533df1a5a2c268e667b097" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.runtime" name="runtime" version="1.7.0"> <artifact name="runtime-1.7.0.module"> <sha256 value="7b9351b0ff6df9276d02d40f12765ae55bf5c6dfb8ff8df4c77dfca138fb9fc1" origin="Generated by Gradle"/> @@ -1118,6 +1197,11 @@ <sha256 value="56366721d8a87924773839c2725e72ef252da662b306b9ed662afd64b6508e6e" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.ui" name="ui-graphics" version="1.6.8"> + <artifact name="ui-graphics-1.6.8.module"> + <sha256 value="eb4d03821a8d2c7919743a8b80ba9f96186c6022bd7ecec02940bc1555abee20" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.ui" name="ui-graphics" version="1.7.8"> <artifact name="ui-graphics-1.7.8.module"> <sha256 value="809d8da1b129997269435728c351a7939260d9acd28e2ccb9f7400eec2ec2d56" origin="Generated by Gradle"/> @@ -1126,6 +1210,11 @@ <sha256 value="5ba9ece097ba31fea936095e1c34b1f517645a1af55e4a641e707c583e61d94b" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.ui" name="ui-graphics-android" version="1.6.8"> + <artifact name="ui-graphics-android-1.6.8.module"> + <sha256 value="39bece706d28b44b3fb2a3ae5b1508f3247ba35d5d405d96033378c98e615965" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.ui" name="ui-graphics-android" version="1.7.8"> <artifact name="ui-graphics-android-1.7.8.module"> <sha256 value="fafcba9f63c7bbd89304e7dae09327b2f2ae9ae1a3676b0fe60469403b973dcd" origin="Generated by Gradle"/> @@ -1195,6 +1284,11 @@ <sha256 value="c830a2fdf5cf174da80bbcaf977b69a4f55dd935dce78bab676e191780aa449a" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.ui" name="ui-text" version="1.6.8"> + <artifact name="ui-text-1.6.8.module"> + <sha256 value="0fae7dc013e91f4792b34b13b36416684cd1750cbb360e498d09e79bd7ce4af7" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.ui" name="ui-text" version="1.7.8"> <artifact name="ui-text-1.7.8.module"> <sha256 value="1ec485dbc361a0b132a0c01b441d42932ee207542ca4f8d82fb3e2a1560e3143" origin="Generated by Gradle"/> @@ -1203,6 +1297,11 @@ <sha256 value="5bd379bebabeb47ea92b3777bb1ceb31dc93c294b46f264dcf8e4c010f4699fc" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.ui" name="ui-text-android" version="1.6.8"> + <artifact name="ui-text-android-1.6.8.module"> + <sha256 value="31c5457679534b6bdeaffa0071266614f6fb9af0b7119928427eb2fb00ff6748" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.ui" name="ui-text-android" version="1.7.8"> <artifact name="ui-text-android-1.7.8.module"> <sha256 value="1fa981ca4d73faea7079a722564237f05465ebb257e81509895aebf207392331" origin="Generated by Gradle"/> @@ -1325,6 +1424,11 @@ <sha256 value="85b335a307cb203c0ee477bfeada5023aadc282bd8ab9d114d69d61937490e17" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.ui" name="ui-util" version="1.6.8"> + <artifact name="ui-util-1.6.8.module"> + <sha256 value="c91b6fe99cd05baae7c6858dc0d52dd643c9ce3edf15308b51b6c6e6bb22873c" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.ui" name="ui-util" version="1.7.8"> <artifact name="ui-util-1.7.8.module"> <sha256 value="c6489709dad543bdfee77f42784a7b26284ce913e2e8cf33554ad2ed79c7dea7" origin="Generated by Gradle"/> @@ -1333,6 +1437,11 @@ <sha256 value="25afa139ccbda2c33c6d9e7be3579e2ca9295f986e3b4f5b96297d6ce0fba86a" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.ui" name="ui-util-android" version="1.6.8"> + <artifact name="ui-util-android-1.6.8.module"> + <sha256 value="c6fccb2b21c7187a0905c5b667939680c8996b7cbe0c33a8c8a477a2f6b5e53c" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.ui" name="ui-util-android" version="1.7.8"> <artifact name="ui-util-android-1.7.8.module"> <sha256 value="a18c49781d7324410966e1faa76dd165a874474a13f8bb0c6f2214b50bbb4d6c" origin="Generated by Gradle"/> @@ -2437,6 +2546,14 @@ <sha256 value="d0d8d486b6bd33206dbf3f1a6d167e9b43c268ea63c3321c886b1543ad05ece3" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.tv" name="tv-material" version="1.0.0"> + <artifact name="tv-material-1.0.0.aar"> + <sha256 value="a66890ad58ffc31036d8a9ea99fa3ab478c5daa7189b296c6b92e05e8c7db604" origin="Generated by Gradle"/> + </artifact> + <artifact name="tv-material-1.0.0.module"> + <sha256 value="fa6689598785362efcb66a328907ecead533c7fd79f7d52c649aefa52841400d" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.vectordrawable" name="vectordrawable" version="1.1.0"> <artifact name="vectordrawable-1.1.0.aar"> <sha256 value="46fd633ac01b49b7fcabc263bf098c5a8b9e9a69774d234edcca04fb02df8e26" origin="Generated by Gradle"/> diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/InAppNotification.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/InAppNotification.kt new file mode 100644 index 0000000000..fdaa5f3c9d --- /dev/null +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/InAppNotification.kt @@ -0,0 +1,44 @@ +package net.mullvad.mullvadvpn.lib.model + +import java.time.Duration + +enum class StatusLevel { + Error, + Warning, + Info, +} + +sealed class InAppNotification { + abstract val statusLevel: StatusLevel + abstract val priority: Long + + data class TunnelStateError(val error: ErrorState) : InAppNotification() { + override val statusLevel = StatusLevel.Error + override val priority: Long = 1001 + } + + data object TunnelStateBlocked : InAppNotification() { + override val statusLevel = StatusLevel.Error + override val priority: Long = 1000 + } + + data class UnsupportedVersion(val versionInfo: VersionInfo) : InAppNotification() { + override val statusLevel = StatusLevel.Error + override val priority: Long = 999 + } + + data class AccountExpiry(val expiry: Duration) : InAppNotification() { + override val statusLevel = StatusLevel.Warning + override val priority: Long = 1001 + } + + data class NewDevice(val deviceName: String) : InAppNotification() { + override val statusLevel = StatusLevel.Info + override val priority: Long = 1001 + } + + data object NewVersionChangelog : InAppNotification() { + override val statusLevel = StatusLevel.Info + override val priority: Long = 1001 + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/VersionInfo.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/VersionInfo.kt index 7e2550974d..1a225d482f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/VersionInfo.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/VersionInfo.kt @@ -1,3 +1,3 @@ -package net.mullvad.mullvadvpn.ui +package net.mullvad.mullvadvpn.lib.model data class VersionInfo(val currentVersion: String, val isSupported: Boolean) diff --git a/android/app/src/main/res/drawable/daita_illustration_1.xml b/android/lib/resource/src/main/res/drawable/daita_illustration_1.xml index 918f0c9e6e..918f0c9e6e 100644 --- a/android/app/src/main/res/drawable/daita_illustration_1.xml +++ b/android/lib/resource/src/main/res/drawable/daita_illustration_1.xml diff --git a/android/app/src/main/res/drawable/daita_illustration_2.xml b/android/lib/resource/src/main/res/drawable/daita_illustration_2.xml index b8de37fadf..b8de37fadf 100644 --- a/android/app/src/main/res/drawable/daita_illustration_2.xml +++ b/android/lib/resource/src/main/res/drawable/daita_illustration_2.xml diff --git a/android/app/src/main/res/drawable/logo_text.xml b/android/lib/resource/src/main/res/drawable/logo_text.xml index aacefa3579..aacefa3579 100644 --- a/android/app/src/main/res/drawable/logo_text.xml +++ b/android/lib/resource/src/main/res/drawable/logo_text.xml diff --git a/android/lib/resource/src/main/res/values-television/booleans.xml b/android/lib/resource/src/main/res/values-television/booleans.xml new file mode 100644 index 0000000000..d038209d84 --- /dev/null +++ b/android/lib/resource/src/main/res/values-television/booleans.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <bool name="isTv">true</bool> +</resources> diff --git a/android/lib/resource/src/main/res/values/booleans.xml b/android/lib/resource/src/main/res/values/booleans.xml new file mode 100644 index 0000000000..45c1b1fac6 --- /dev/null +++ b/android/lib/resource/src/main/res/values/booleans.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <bool name="isTv">false</bool> +</resources> diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt index dde3d9c7cb..74f3577f69 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt @@ -40,9 +40,12 @@ data class Dimensions( val largePadding: Dp = 32.dp, val listIconSize: Dp = 24.dp, val listItemDivider: Dp = 1.dp, + val mediumIconSize: Dp = 32.dp, val mediumPadding: Dp = 16.dp, val mediumSpacer: Dp = 16.dp, val miniPadding: Dp = 4.dp, + val mullvadLogoTextStartPadding: Dp = 6.dp, + val mullvadLogoTextHeight: Dp = 13.dp, val notificationBannerEndPadding: Dp = 8.dp, val notificationBannerStartPadding: Dp = 16.dp, val notificationEndIconPadding: Dp = 4.dp, @@ -75,6 +78,9 @@ data class Dimensions( val tinyPadding: Dp = 4.dp, val titleIconSize: Dp = 48.dp, val topPadding: Dp = 20.dp, + val tvDrawerHorizontalPadding: Dp = 12.dp, + val tvDrawerHeaderStartPadding: Dp = 12.dp, + val tvDrawerHeaderWithFocusStartPadding: Dp = 16.dp, val verticalDividerPadding: Dp = 12.dp, val verticalSpace: Dp = 20.dp, val verticalSpacer: Dp = 1.dp, diff --git a/android/lib/tv/build.gradle.kts b/android/lib/tv/build.gradle.kts new file mode 100644 index 0000000000..ef6b922da6 --- /dev/null +++ b/android/lib/tv/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.compose) +} + +android { + namespace = "net.mullvad.mullvadvpn.lib.tv" + 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.kotlin.stdlib) + implementation(libs.androidx.tv) + implementation(libs.androidx.activity.compose) + implementation(libs.compose.material3) + implementation(libs.compose.ui) + implementation(projects.lib.model) + implementation(projects.lib.resource) + implementation(projects.lib.shared) + implementation(projects.lib.theme) + implementation(projects.lib.ui.component) + + // UI tooling + implementation(libs.compose.ui.tooling.preview) + debugImplementation(libs.compose.ui.tooling) +} diff --git a/android/lib/tv/src/main/AndroidManifest.xml b/android/lib/tv/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..cc947c5679 --- /dev/null +++ b/android/lib/tv/src/main/AndroidManifest.xml @@ -0,0 +1 @@ +<manifest /> diff --git a/android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NavigationDrawerTv.kt b/android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NavigationDrawerTv.kt new file mode 100644 index 0000000000..d8a373c2b2 --- /dev/null +++ b/android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NavigationDrawerTv.kt @@ -0,0 +1,251 @@ +package net.mullvad.mullvadvpn.lib.tv + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.FocusRequester.Companion.Cancel +import androidx.compose.ui.focus.FocusRequester.Companion.Default +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.tv.material3.DrawerValue +import androidx.tv.material3.ModalNavigationDrawer +import androidx.tv.material3.NavigationDrawerItem +import androidx.tv.material3.NavigationDrawerItemDefaults +import androidx.tv.material3.NavigationDrawerScope +import androidx.tv.material3.rememberDrawerState +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens + +private class DrawerValueProvider : PreviewParameterProvider<DrawerValue> { + override val values: Sequence<DrawerValue> + get() = sequenceOf(DrawerValue.Closed, DrawerValue.Open) +} + +@Preview("Closed|Open") +@Composable +fun PreviewNavigationDrawerTvClosed( + @PreviewParameter(DrawerValueProvider::class) drawerValue: DrawerValue +) { + AppTheme { + NavigationDrawerTv( + daysLeftUntilExpiry = 30, + deviceName = "Cool Cat", + initialDrawerValue = drawerValue, + onSettingsClick = {}, + onAccountClick = {}, + ) {} + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +@Suppress("LongMethod") +fun NavigationDrawerTv( + daysLeftUntilExpiry: Long?, + deviceName: String?, + initialDrawerValue: DrawerValue = DrawerValue.Closed, + onSettingsClick: (() -> Unit), + onAccountClick: (() -> Unit), + content: @Composable () -> Unit, +) { + val drawerState = rememberDrawerState(initialDrawerValue) + val focusRequester = remember { FocusRequester() } + val brush = remember { Brush.horizontalGradient(listOf(Color.Black, Color.Transparent)) } + + val focusManager = LocalFocusManager.current + + if (drawerState.currentValue == DrawerValue.Open) { + BackHandler( + onBack = { + drawerState.setValue(DrawerValue.Closed) + focusManager.moveFocus(FocusDirection.Right) + } + ) + } + + ModalNavigationDrawer( + modifier = + Modifier.focusRequester(focusRequester).focusProperties { + enter = { if (focusRequester.restoreFocusedChild()) Cancel else Default } + }, + drawerState = drawerState, + scrimBrush = brush, + drawerContent = { + Box( + Modifier.fillMaxHeight() + .background(brush) + .padding( + top = Dimens.screenVerticalMargin, + bottom = Dimens.screenVerticalMargin, + start = Dimens.tvDrawerHorizontalPadding, + end = Dimens.tvDrawerHorizontalPadding, + ) + .selectableGroup() + ) { + val animatedPadding = + animateDpAsState( + if (hasFocus) Dimens.tvDrawerHeaderWithFocusStartPadding + else Dimens.tvDrawerHeaderStartPadding + ) + + NavigationDrawerTvHeader( + modifier = + Modifier.align(Alignment.TopStart).padding(start = animatedPadding.value), + isExpanded = hasFocus, + daysLeftUntilExpiry = daysLeftUntilExpiry, + deviceName = deviceName, + ) + DrawerItemTv( + modifier = + Modifier.align(Alignment.CenterStart).onFocusChanged { + focusRequester.saveFocusedChild() + }, + icon = Icons.Default.AccountCircle, + text = stringResource(R.string.settings_account), + onClick = onAccountClick, + ) + DrawerItemTv( + modifier = + Modifier.align(Alignment.BottomStart).onFocusChanged { + focusRequester.saveFocusedChild() + }, + icon = Icons.Default.Settings, + text = stringResource(R.string.settings), + onClick = onSettingsClick, + ) + } + }, + content = content, + ) +} + +@Composable +private fun NavigationDrawerScope.DrawerItemTv( + modifier: Modifier = Modifier, + icon: ImageVector, + text: String, + onClick: () -> Unit, +) { + NavigationDrawerItem( + modifier = modifier, + onClick = onClick, + selected = false, + leadingContent = { + Icon( + tint = MaterialTheme.colorScheme.onPrimary, + imageVector = icon, + contentDescription = null, + ) + }, + ) { + Text( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.onPrimary, + text = text, + maxLines = 1, + overflow = TextOverflow.Clip, + ) + } +} + +@Composable +private fun NavigationDrawerTvHeader( + modifier: Modifier = Modifier, + isExpanded: Boolean, + daysLeftUntilExpiry: Long?, + deviceName: String?, +) { + Column( + modifier = + modifier.width( + if (isExpanded) NavigationDrawerItemDefaults.ExpandedDrawerItemWidth + else NavigationDrawerItemDefaults.CollapsedDrawerItemWidth + ) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Dimens.mullvadLogoTextStartPadding), + ) { + Icon( + modifier = Modifier.size(Dimens.mediumIconSize), + painter = painterResource(id = R.drawable.logo_icon), + contentDescription = null, // No meaningful user info or action. + tint = Color.Unspecified, // Logo should not be tinted + ) + if (isExpanded) { + Icon( + modifier = Modifier.height(Dimens.mullvadLogoTextHeight), + painter = painterResource(id = R.drawable.logo_text), + contentDescription = null, // No meaningful user info or action. + tint = Color.Unspecified, // Logo should not be tinted + ) + } + } + Spacer(Modifier.height(8.dp)) + + if (isExpanded) { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.top_bar_device_name, deviceName ?: ""), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimary, + maxLines = 1, + overflow = TextOverflow.Clip, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = + stringResource( + id = R.string.top_bar_time_left, + pluralStringResource( + id = R.plurals.days, + daysLeftUntilExpiry?.toInt() ?: 0, + daysLeftUntilExpiry ?: 0, + ), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimary, + maxLines = 1, + overflow = TextOverflow.Clip, + ) + } + } +} diff --git a/android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NotificationBannerTv.kt b/android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NotificationBannerTv.kt new file mode 100644 index 0000000000..97d986c36a --- /dev/null +++ b/android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NotificationBannerTv.kt @@ -0,0 +1,63 @@ +package net.mullvad.mullvadvpn.lib.tv + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.mullvad.mullvadvpn.lib.model.InAppNotification +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.ui.component.AnimatedNotificationBanner + +@Preview +@Composable +fun PreviewNotificationBannerTv() { + AppTheme { + NotificationBannerTv( + notification = InAppNotification.NewDevice("Sad Panda"), + isPlayBuild = true, + openAppListing = {}, + onClickShowAccount = {}, + onClickShowChangelog = {}, + onClickDismissChangelog = {}, + ) {} + } +} + +@Composable +fun NotificationBannerTv( + modifier: Modifier = Modifier, + notification: InAppNotification?, + isPlayBuild: Boolean, + openAppListing: () -> Unit, + onClickShowAccount: () -> Unit, + onClickShowChangelog: () -> Unit, + onClickDismissChangelog: () -> Unit, + onClickDismissNewDevice: () -> Unit, +) { + AnimatedNotificationBanner( + modifier = modifier, + notificationModifier = + Modifier.width(Dimens.connectionCardMaxWidth) + .padding(start = Dimens.mediumPadding, end = Dimens.mediumPadding) + .clip( + RoundedCornerShape( + bottomEnd = Dimens.mediumPadding, + bottomStart = Dimens.mediumPadding, + topStart = 0.dp, + topEnd = 0.dp, + ) + ), + notification = notification, + isPlayBuild = isPlayBuild, + openAppListing = openAppListing, + onClickShowAccount = onClickShowAccount, + onClickShowChangelog = onClickShowChangelog, + onClickDismissChangelog = onClickDismissChangelog, + onClickDismissNewDevice = onClickDismissNewDevice, + ) +} 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/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/NotificationData.kt index 58798978bc..c7fdad4793 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt +++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/NotificationData.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.compose.component.notificationbanner +package net.mullvad.mullvadvpn.lib.ui.component import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew @@ -16,15 +16,12 @@ 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.R -import net.mullvad.mullvadvpn.compose.extensions.getExpiryQuantityString -import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString 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.repository.InAppNotification -import net.mullvad.mullvadvpn.ui.notification.StatusLevel +import net.mullvad.mullvadvpn.lib.model.StatusLevel data class NotificationData( val title: AnnotatedString, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/RememberPrevious.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/RememberPrevious.kt index 6782e0ab55..4ea7b530a7 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/RememberPrevious.kt +++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/RememberPrevious.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.compose.util +package net.mullvad.mullvadvpn.lib.ui.component /* * Code snippet taken from: diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/ResourcesExtensions.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/ResourcesExtensions.kt index 28459c9a5d..ac61990c89 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/ResourcesExtensions.kt +++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/ResourcesExtensions.kt @@ -1,8 +1,7 @@ -package net.mullvad.mullvadvpn.compose.extensions +package net.mullvad.mullvadvpn.lib.ui.component import android.content.res.Resources import java.time.Duration -import net.mullvad.mullvadvpn.R private const val DAYS_IN_STANDARD_YEAR = 365 diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SpannedExtensions.kt b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/SpannedExtensions.kt index 6c294e6207..cf56f8c702 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SpannedExtensions.kt +++ b/android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/SpannedExtensions.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.compose.extensions +package net.mullvad.mullvadvpn.lib.ui.component import android.graphics.Typeface import android.text.Spanned 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" diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 7e49d4cb92..8f5c5e837a 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -27,7 +27,9 @@ include( ":lib:resource", ":lib:shared", ":lib:talpid", - ":lib:theme" + ":lib:theme", + ":lib:tv", + ":lib:ui:component", ) include( ":test", |
