diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 9d0c5b75f5df..462f9cdb03bf 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 9145e8411ba1..261cc9a48ef2 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 f1a81d4d91d9..43bc44880545 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 4f527a94c548..9ff9ec5a0051 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 d31325f140ae..867f61469936 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 b77decc9f02d..30e4ebcc8715 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 42d23a1d0399..bd11a5f6543a 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 { 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 b2150d0037cc..d3c704d0a2d5 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 { override val values = sequenceOf(ConnectUiState.INITIAL) + generateOtherStates() @@ -29,7 +30,7 @@ private fun generateOtherStates(): Sequence = ), TunnelStatePreviewData.generateErrorState(isBlocking = true), ) - .map { state -> + .mapIndexed { index, state -> ConnectUiState( location = GeoIpLocation( @@ -45,7 +46,8 @@ private fun generateOtherStates(): Sequence = 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 39b9e174f29e..ceddf9dc9980 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 7c4bdbd3b3d3..eb6df6c8208f 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, + if (isTv()) { + Scaffold( + snackbarHost = { + SnackbarHost( + snackbarHostState, + snackbar = { snackbarData -> MullvadSnackbar(snackbarData = snackbarData) }, + ) + } + ) { + 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) + } + } +} + +@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() ) { - 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 + MullvadMap(state, indicatorPercentOffset) + + 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.padding( - top = it.calculateTopPadding(), - start = it.calculateStartPadding(LocalLayoutDirection.current), - end = it.calculateEndPadding(LocalLayoutDirection.current), - ) - .fillMaxSize() + modifier = + Modifier.fillMaxSize().padding(bottom = paddingValues.calculateBottomPadding()) ) { - MullvadMap(state, indicatorPercentOffset) - - 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), + 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, ) - - 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, - ) - } } } } 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 4e17b6918bf5..63b596d51333 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 f38e349a7f5b..4b6a339f61c9 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 000000000000..a1fed50acb3f --- /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 0e3e004f0b25..752e185d144c 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 7a74c0f0d2a2..d78521b9be85 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 057494f7629a..a39afe9c390f 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 157de67013c6..5936d7b3a6e5 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 2faca012b75d..4374ca60370b 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 888f9f67bf09..85ea7cf11a7d 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> = 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 d46089a9d3a2..6575871f2147 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 16ec17be5d0b..ae197fa7ef4f 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 9db14ad914e0..b7be4e574de8 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 68b29790acc1..df7d561f8410 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 2c97ea36a1c1..414c7c1e0895 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 20a6a1bef08f..8d2ece124bcd 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 e9452884cfc3..78f2fb72df99 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 5950f6475ff9..ec4e9c0bbbd2 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 b71d21740877..f0a60c50c21f 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 33180d1cf3f3..864af8f07aa1 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 10e0fef4c430..6ddf413c7294 100644 --- a/android/gradle/verification-metadata.xml +++ b/android/gradle/verification-metadata.xml @@ -200,6 +200,7 @@ + @@ -654,6 +655,11 @@ + + + + + @@ -678,6 +684,11 @@ + + + + + @@ -706,6 +717,11 @@ + + + + + @@ -735,6 +751,11 @@ + + + + + @@ -756,6 +777,11 @@ + + + + + @@ -764,6 +790,11 @@ + + + + + @@ -780,6 +811,11 @@ + + + + + @@ -796,6 +832,11 @@ + + + + + @@ -820,6 +861,11 @@ + + + + + @@ -838,6 +884,11 @@ + + + + + @@ -859,6 +910,11 @@ + + + + + @@ -883,6 +939,14 @@ + + + + + + + + @@ -899,6 +963,11 @@ + + + + + @@ -907,6 +976,11 @@ + + + + + @@ -995,6 +1069,11 @@ + + + + + @@ -1118,6 +1197,11 @@ + + + + + @@ -1126,6 +1210,11 @@ + + + + + @@ -1195,6 +1284,11 @@ + + + + + @@ -1203,6 +1297,11 @@ + + + + + @@ -1325,6 +1424,11 @@ + + + + + @@ -1333,6 +1437,11 @@ + + + + + @@ -2437,6 +2546,14 @@ + + + + + + + + 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 000000000000..fdaa5f3c9d83 --- /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 similarity index 65% rename from android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/VersionInfo.kt rename to android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/VersionInfo.kt index 7e2550974dea..1a225d482fa2 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 similarity index 100% rename from android/app/src/main/res/drawable/daita_illustration_1.xml rename to 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 similarity index 100% rename from android/app/src/main/res/drawable/daita_illustration_2.xml rename to 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 similarity index 100% rename from android/app/src/main/res/drawable/logo_text.xml rename to 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 000000000000..d038209d8449 --- /dev/null +++ b/android/lib/resource/src/main/res/values-television/booleans.xml @@ -0,0 +1,4 @@ + + + true + 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 000000000000..45c1b1fac6d5 --- /dev/null +++ b/android/lib/resource/src/main/res/values/booleans.xml @@ -0,0 +1,4 @@ + + + false + 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 dde3d9c7cbc1..74f3577f693e 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 000000000000..ef6b922da642 --- /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 000000000000..cc947c567995 --- /dev/null +++ b/android/lib/tv/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + 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 000000000000..d8a373c2b204 --- /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 { + override val values: Sequence + 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 000000000000..97d986c36ab5 --- /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 000000000000..7804ac0abc24 --- /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 000000000000..cc947c567995 --- /dev/null +++ b/android/lib/ui/component/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + 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 000000000000..5d1d7f0e74e7 --- /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 similarity index 96% rename from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt rename to android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/NotificationData.kt index 58798978bc7c..c7fdad479308 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 similarity index 95% rename from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/RememberPrevious.kt rename to android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/RememberPrevious.kt index 6782e0ab5519..4ea7b530a7ea 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 similarity index 90% rename from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/ResourcesExtensions.kt rename to android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/ResourcesExtensions.kt index 28459c9a5df0..ac61990c892a 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 similarity index 96% rename from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SpannedExtensions.kt rename to android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/SpannedExtensions.kt index 6c294e620795..cf56f8c7023d 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 000000000000..24189d1469cf --- /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 7e49d4cb9294..8f5c5e837a1c 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",