summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
Diffstat (limited to 'android')
-rw-r--r--android/app/build.gradle.kts3
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt244
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoDialog.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AppInfoUiStatePreviewParameterProvider.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ConnectUiStatePreviewParameterProvider.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AutoConnectAndLockdownModeScreen.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt183
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/test/ComposeTestTagConstants.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Device.kt13
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/InAppNotificationController.kt44
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AppVersionInfoRepository.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCase.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewChangelogNotificationUseCase.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCase.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCase.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCase.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AppInfoViewModel.kt2
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/InAppNotificationControllerTest.kt2
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/AccountExpiryInAppNotificationUseCaseTest.kt2
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/NewDeviceNotificationUseCaseTest.kt2
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/TunnelStateNotificationUseCaseTest.kt2
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/VersionNotificationUseCaseTest.kt4
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModelTest.kt2
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/SettingsViewModelTest.kt2
-rw-r--r--android/gradle/libs.versions.toml3
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/InAppNotification.kt44
-rw-r--r--android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/VersionInfo.kt (renamed from android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/VersionInfo.kt)2
-rw-r--r--android/lib/resource/src/main/res/drawable/daita_illustration_1.xml (renamed from android/app/src/main/res/drawable/daita_illustration_1.xml)0
-rw-r--r--android/lib/resource/src/main/res/drawable/daita_illustration_2.xml (renamed from android/app/src/main/res/drawable/daita_illustration_2.xml)0
-rw-r--r--android/lib/resource/src/main/res/drawable/logo_text.xml (renamed from android/app/src/main/res/drawable/logo_text.xml)0
-rw-r--r--android/lib/resource/src/main/res/values-television/booleans.xml4
-rw-r--r--android/lib/resource/src/main/res/values/booleans.xml4
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt6
-rw-r--r--android/lib/tv/build.gradle.kts48
-rw-r--r--android/lib/tv/src/main/AndroidManifest.xml1
-rw-r--r--android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NavigationDrawerTv.kt251
-rw-r--r--android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NotificationBannerTv.kt63
-rw-r--r--android/lib/ui/component/build.gradle.kts44
-rw-r--r--android/lib/ui/component/src/main/AndroidManifest.xml1
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/AnimatedNotificationBanner.kt208
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/NotificationData.kt (renamed from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationData.kt)9
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/RememberPrevious.kt (renamed from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/RememberPrevious.kt)2
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/ResourcesExtensions.kt (renamed from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/ResourcesExtensions.kt)3
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/SpannedExtensions.kt (renamed from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SpannedExtensions.kt)2
-rw-r--r--android/lib/ui/component/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/component/test/ComposeTestTagConstants.kt6
-rw-r--r--android/settings.gradle.kts4
49 files changed, 908 insertions, 345 deletions
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/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",