From d4f8725e7d508af530cd1b3134f20aa73733c96a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20G=C3=B6ransson?= Date: Mon, 9 Oct 2023 08:17:55 +0200 Subject: [PATCH 1/3] Refactor capitalizeFirstCharOfEachWord to model --- .../compose/dialog/DeviceRemovalDialog.kt | 3 +-- .../mullvadvpn/compose/screen/AccountScreen.kt | 3 +-- .../compose/screen/DeviceListScreen.kt | 3 +-- .../mullvadvpn/viewmodel/WelcomeViewModel.kt | 3 +-- .../lib/common/util/CommonStringExtensions.kt | 6 ------ .../mullvad/mullvadvpn/model/AccountExpiry.kt | 17 +++++++++++++++-- .../net/mullvad/mullvadvpn/model/DeviceState.kt | 8 +++++++- 7 files changed, 26 insertions(+), 17 deletions(-) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceRemovalDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceRemovalDialog.kt index e55a549e27d6..4cbbc0d292eb 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceRemovalDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceRemovalDialog.kt @@ -26,7 +26,6 @@ import androidx.compose.ui.unit.sp import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.HtmlText import net.mullvad.mullvadvpn.compose.component.textResource -import net.mullvad.mullvadvpn.lib.common.util.capitalizeFirstCharOfEachWord import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.model.Device @@ -60,7 +59,7 @@ fun ShowDeviceRemovalDialog(onDismiss: () -> Unit, onConfirm: () -> Unit, device val htmlFormattedDialogText = textResource( id = R.string.max_devices_confirm_removal_description, - device.name.capitalizeFirstCharOfEachWord() + device.name ) HtmlText(htmlFormattedString = htmlFormattedDialogText, textSize = 16.sp.value) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt index 46ee51640bfc..71f79e55b5e4 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt @@ -37,7 +37,6 @@ import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar import net.mullvad.mullvadvpn.compose.dialog.DeviceNameInfoDialog import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD -import net.mullvad.mullvadvpn.lib.common.util.capitalizeFirstCharOfEachWord import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens @@ -112,7 +111,7 @@ fun AccountScreen( Row(verticalAlignment = Alignment.CenterVertically) { InformationView( - content = uiState.deviceName?.capitalizeFirstCharOfEachWord() ?: "", + content = uiState.deviceName ?: "", whenMissing = MissingPolicy.SHOW_SPINNER ) IconButton( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt index 37669a98512c..7e76040ac4d2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt @@ -30,7 +30,6 @@ import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar import net.mullvad.mullvadvpn.compose.dialog.ShowDeviceRemovalDialog import net.mullvad.mullvadvpn.compose.state.DeviceListItemUiState import net.mullvad.mullvadvpn.compose.state.DeviceListUiState -import net.mullvad.mullvadvpn.lib.common.util.capitalizeFirstCharOfEachWord import net.mullvad.mullvadvpn.lib.common.util.parseAsDateTime import net.mullvad.mullvadvpn.lib.theme.AlphaInactive import net.mullvad.mullvadvpn.lib.theme.AlphaTopBar @@ -196,7 +195,7 @@ fun DeviceListScreen( state.deviceUiItems.forEach { deviceUiState -> ListItem( text = - deviceUiState.device.name.capitalizeFirstCharOfEachWord(), + deviceUiState.device.name, subText = deviceUiState.device.created.parseAsDateTime()?.let { creationDate -> diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt index fe2ddcb66a0a..6c9b2ea75db0 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.state.WelcomeUiState import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_POLL_INTERVAL -import net.mullvad.mullvadvpn.lib.common.util.capitalizeFirstCharOfEachWord import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.DeviceRepository @@ -61,7 +60,7 @@ class WelcomeViewModel( WelcomeUiState( tunnelState = tunnelState, accountNumber = deviceState.token(), - deviceName = deviceState.deviceName()?.capitalizeFirstCharOfEachWord() + deviceName = deviceState.deviceName() ) } } diff --git a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/CommonStringExtensions.kt b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/CommonStringExtensions.kt index f46664e92930..06a2de91485c 100644 --- a/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/CommonStringExtensions.kt +++ b/android/lib/common/src/main/kotlin/net/mullvad/mullvadvpn/lib/common/util/CommonStringExtensions.kt @@ -7,12 +7,6 @@ private const val EXPIRY_FORMAT = "YYYY-MM-dd HH:mm:ss z" private const val BIG_DOT_CHAR = "●" private const val SPACE_CHAR = ' ' -fun String.capitalizeFirstCharOfEachWord(): String { - return split(" ") - .joinToString(" ") { word -> word.replaceFirstChar { firstChar -> firstChar.uppercase() } } - .trimEnd() -} - fun String.parseAsDateTime(): DateTime? { return try { DateTime.parse(this, DateTimeFormat.forPattern(EXPIRY_FORMAT)) diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountExpiry.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountExpiry.kt index a91ce46148e6..f5738ec21df0 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountExpiry.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountExpiry.kt @@ -1,15 +1,28 @@ package net.mullvad.mullvadvpn.model import android.os.Parcelable +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.DurationUnit import kotlinx.parcelize.Parcelize import org.joda.time.DateTime sealed class AccountExpiry : Parcelable { - @Parcelize data class Available(val expiryDateTime: DateTime) : AccountExpiry() + @Parcelize + data class Available(val expiryDateTime: DateTime) : AccountExpiry() { + override fun daysLeft(): Int = + (expiryDateTime.toInstant().millis - DateTime.now().toInstant().millis) + .milliseconds + .toInt(DurationUnit.DAYS) + } - @Parcelize object Missing : AccountExpiry() + @Parcelize + data object Missing : AccountExpiry() fun date(): DateTime? { return (this as? Available)?.expiryDateTime } + + open fun daysLeft(): Int? { + return (this as? Available)?.daysLeft() + } } diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceState.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceState.kt index 440d03de55ff..2af9b01362f5 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceState.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceState.kt @@ -19,10 +19,16 @@ sealed class DeviceState : Parcelable { } fun deviceName(): String? { - return (this as? LoggedIn)?.accountAndDevice?.device?.name + return (this as? LoggedIn)?.accountAndDevice?.device?.name?.capitalizeFirstCharOfEachWord() } fun token(): String? { return (this as? LoggedIn)?.accountAndDevice?.account_token } } + +private fun String.capitalizeFirstCharOfEachWord(): String { + return split(" ") + .joinToString(" ") { word -> word.replaceFirstChar { firstChar -> firstChar.uppercase() } } + .trimEnd() +} From c07ad9f01246937018ad4ae8021afa208641bfa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20G=C3=B6ransson?= Date: Mon, 9 Oct 2023 09:35:21 +0200 Subject: [PATCH 2/3] Add device name and time left --- .../compose/component/Scaffolding.kt | 47 +++++++ .../mullvadvpn/compose/component/TopBar.kt | 116 ++++++++++++++++++ .../compose/dialog/DeviceRemovalDialog.kt | 5 +- .../compose/screen/ConnectScreen.kt | 7 +- .../compose/screen/DeviceListScreen.kt | 3 +- .../compose/screen/OutOfTimeScreen.kt | 16 ++- .../compose/state/ConnectUiState.kt | 8 +- .../compose/state/OutOfTimeUiState.kt | 5 +- .../net/mullvad/mullvadvpn/di/UiModule.kt | 10 +- .../mullvad/mullvadvpn/util/DateExtensions.kt | 5 + .../net/mullvad/mullvadvpn/util/FlowUtils.kt | 27 ++++ .../mullvadvpn/viewmodel/ConnectViewModel.kt | 14 ++- .../viewmodel/OutOfTimeViewModel.kt | 22 +++- .../mullvad/mullvadvpn/model/AccountExpiry.kt | 17 +-- .../src/main/res/values-da/strings.xml | 2 + .../src/main/res/values-de/strings.xml | 2 + .../src/main/res/values-es/strings.xml | 2 + .../src/main/res/values-fi/strings.xml | 2 + .../src/main/res/values-fr/strings.xml | 2 + .../src/main/res/values-it/strings.xml | 2 + .../src/main/res/values-ja/strings.xml | 2 + .../src/main/res/values-ko/strings.xml | 2 + .../src/main/res/values-my/strings.xml | 2 + .../src/main/res/values-nb/strings.xml | 2 + .../src/main/res/values-nl/strings.xml | 2 + .../src/main/res/values-pl/strings.xml | 2 + .../src/main/res/values-pt/strings.xml | 2 + .../src/main/res/values-ru/strings.xml | 2 + .../src/main/res/values-sv/strings.xml | 2 + .../src/main/res/values-th/strings.xml | 2 + .../src/main/res/values-tr/strings.xml | 2 + .../src/main/res/values-zh-rCN/strings.xml | 2 + .../src/main/res/values-zh-rTW/strings.xml | 2 + .../resource/src/main/res/values/strings.xml | 2 + 34 files changed, 298 insertions(+), 44 deletions(-) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt index eb4d0d19a57d..332c841d8704 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt @@ -1,5 +1,6 @@ package net.mullvad.mullvadvpn.compose.component +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxSize @@ -66,6 +67,52 @@ fun ScaffoldWithTopBar( ) } +@Composable +fun ScaffoldWithTopBarAndDeviceName( + topBarColor: Color, + statusBarColor: Color, + navigationBarColor: Color, + modifier: Modifier = Modifier, + iconTintColor: Color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaTopBar), + onSettingsClicked: (() -> Unit)?, + onAccountClicked: (() -> Unit)?, + isIconAndLogoVisible: Boolean = true, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + deviceName: String?, + timeLeft: Int?, + content: @Composable (PaddingValues) -> Unit, +) { + val systemUiController = rememberSystemUiController() + LaunchedEffect(key1 = statusBarColor, key2 = navigationBarColor) { + systemUiController.setStatusBarColor(statusBarColor) + systemUiController.setNavigationBarColor(navigationBarColor) + } + + Scaffold( + modifier = modifier, + topBar = { + Column { + MullvadTopBarWithDeviceName( + containerColor = topBarColor, + iconTintColor = iconTintColor, + onSettingsClicked = onSettingsClicked, + onAccountClicked = onAccountClicked, + isIconAndLogoVisible = isIconAndLogoVisible, + deviceName = deviceName, + daysLeftUntilExpiry = timeLeft + ) + } + }, + snackbarHost = { + SnackbarHost( + snackbarHostState, + snackbar = { snackbarData -> MullvadSnackbar(snackbarData = snackbarData) } + ) + }, + content = content + ) +} + @Composable fun MullvadSnackbar(snackbarData: SnackbarData) { Snackbar(snackbarData = snackbarData, contentColor = MaterialTheme.colorScheme.secondary) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt index 3c5e0e1bb7e0..93e1e291a2c6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt @@ -2,9 +2,18 @@ package net.mullvad.mullvadvpn.compose.component +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -13,16 +22,19 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumTopAppBar +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color 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 @@ -206,3 +218,107 @@ fun MullvadMediumTopBar( actions = actions ) } + +@Preview +@Composable +fun PreviewMullvadTopBarWithLongDeviceName() { + AppTheme { + Surface { + MullvadTopBarWithDeviceName( + containerColor = MaterialTheme.colorScheme.error, + iconTintColor = MaterialTheme.colorScheme.onError, + onSettingsClicked = null, + onAccountClicked = null, + deviceName = "Superstitious Hippopotamus with extra weight", + daysLeftUntilExpiry = 1 + ) + } + } +} + +@Preview +@Composable +fun PreviewMullvadTopBarWithShortDeviceName() { + AppTheme { + Surface { + MullvadTopBarWithDeviceName( + containerColor = MaterialTheme.colorScheme.error, + iconTintColor = MaterialTheme.colorScheme.onError, + onSettingsClicked = null, + onAccountClicked = null, + deviceName = "Fit Ant", + daysLeftUntilExpiry = 1 + ) + } + } +} + +@Composable +fun MullvadTopBarWithDeviceName( + containerColor: Color, + onSettingsClicked: (() -> Unit)?, + onAccountClicked: (() -> Unit)?, + iconTintColor: Color, + isIconAndLogoVisible: Boolean = true, + deviceName: String?, + daysLeftUntilExpiry: Int? +) { + Column { + MullvadTopBar( + containerColor, + onSettingsClicked, + onAccountClicked, + Modifier, + iconTintColor, + isIconAndLogoVisible, + ) + + // Align animation of extra row with the rest of the Topbar + val appBarContainerColor by + animateColorAsState( + targetValue = containerColor, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "ColorAnimation" + ) + Row( + modifier = + Modifier.background(appBarContainerColor) + .padding( + bottom = Dimens.smallPadding, + start = Dimens.mediumPadding, + end = Dimens.mediumPadding + ) + .fillMaxWidth() + .animateContentSize(), + horizontalArrangement = Arrangement.spacedBy(Dimens.mediumPadding) + ) { + Text( + modifier = Modifier.weight(1f, fill = false), + text = + deviceName?.let { + stringResource(id = R.string.top_bar_device_name, deviceName) + } + ?: "", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall + ) + if (daysLeftUntilExpiry != null) { + Text( + text = + stringResource( + id = R.string.top_bar_time_left, + pluralStringResource( + id = R.plurals.days, + daysLeftUntilExpiry, + daysLeftUntilExpiry + ) + ), + style = MaterialTheme.typography.bodySmall + ) + } else { + Spacer(Modifier) + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceRemovalDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceRemovalDialog.kt index 4cbbc0d292eb..1ac8873fc3d3 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceRemovalDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceRemovalDialog.kt @@ -57,10 +57,7 @@ fun ShowDeviceRemovalDialog(onDismiss: () -> Unit, onConfirm: () -> Unit, device }, text = { val htmlFormattedDialogText = - textResource( - id = R.string.max_devices_confirm_removal_description, - device.name - ) + textResource(id = R.string.max_devices_confirm_removal_description, device.name) HtmlText(htmlFormattedString = htmlFormattedDialogText, textSize = 16.sp.value) }, 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 69d849183e10..9374f4ab9a9a 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 @@ -35,6 +35,7 @@ import net.mullvad.mullvadvpn.compose.button.SwitchLocationButton import net.mullvad.mullvadvpn.compose.component.ConnectionStatusText import net.mullvad.mullvadvpn.compose.component.LocationInfo import net.mullvad.mullvadvpn.compose.component.Notification +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBarAndDeviceName import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.state.ConnectUiState @@ -107,7 +108,7 @@ fun ConnectScreen( } } - ScaffoldWithTopBar( + ScaffoldWithTopBarAndDeviceName( topBarColor = if (uiState.tunnelUiState.isSecured()) { MaterialTheme.colorScheme.inversePrimary @@ -129,7 +130,9 @@ fun ConnectScreen( } .copy(alpha = AlphaTopBar), onSettingsClicked = onSettingsClick, - onAccountClicked = onAccountClick + onAccountClicked = onAccountClick, + deviceName = uiState.deviceName, + timeLeft = uiState.daysLeftUntilExpiry ) { Column( verticalArrangement = Arrangement.Bottom, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt index 7e76040ac4d2..4036d9547c44 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt @@ -194,8 +194,7 @@ fun DeviceListScreen( Column { state.deviceUiItems.forEach { deviceUiState -> ListItem( - text = - deviceUiState.device.name, + text = deviceUiState.device.name, subText = deviceUiState.device.created.parseAsDateTime()?.let { creationDate -> diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt index 49de23228c45..994e45b55673 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt @@ -28,7 +28,7 @@ import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.ActionButton import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton import net.mullvad.mullvadvpn.compose.button.SitePaymentButton -import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBarAndDeviceName import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState @@ -47,7 +47,7 @@ private fun PreviewOutOfTimeScreenDisconnected() { AppTheme { OutOfTimeScreen( showSitePayment = true, - uiState = OutOfTimeUiState(tunnelState = TunnelState.Disconnected), + uiState = OutOfTimeUiState(tunnelState = TunnelState.Disconnected, "Heroic Frog"), uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -59,7 +59,8 @@ private fun PreviewOutOfTimeScreenConnecting() { AppTheme { OutOfTimeScreen( showSitePayment = true, - uiState = OutOfTimeUiState(tunnelState = TunnelState.Connecting(null, null)), + uiState = + OutOfTimeUiState(tunnelState = TunnelState.Connecting(null, null), "Strong Rabbit"), uiSideEffect = MutableSharedFlow().asSharedFlow() ) } @@ -76,7 +77,8 @@ private fun PreviewOutOfTimeScreenError() { tunnelState = TunnelState.Error( ErrorState(cause = ErrorStateCause.IsOffline, isBlocking = true) - ) + ), + deviceName = "Stable Horse" ), uiSideEffect = MutableSharedFlow().asSharedFlow() ) @@ -106,7 +108,7 @@ fun OutOfTimeScreen( } } val scrollState = rememberScrollState() - ScaffoldWithTopBar( + ScaffoldWithTopBarAndDeviceName( topBarColor = if (uiState.tunnelState.isSecured()) { MaterialTheme.colorScheme.inversePrimary @@ -128,7 +130,9 @@ fun OutOfTimeScreen( } .copy(alpha = AlphaTopBar), onSettingsClicked = onSettingsClick, - onAccountClicked = onAccountClick + onAccountClicked = onAccountClick, + deviceName = uiState.deviceName, + timeLeft = null ) { Column( verticalArrangement = Arrangement.Bottom, 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 3c9c7352fe1a..93b9df5b7a6e 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 @@ -14,7 +14,9 @@ data class ConnectUiState( val outAddress: String, val showLocation: Boolean, val connectNotificationState: ConnectNotificationState, - val isTunnelInfoExpanded: Boolean + val isTunnelInfoExpanded: Boolean, + val deviceName: String?, + val daysLeftUntilExpiry: Int? ) { companion object { val INITIAL = @@ -27,7 +29,9 @@ data class ConnectUiState( outAddress = "", showLocation = false, isTunnelInfoExpanded = false, - connectNotificationState = ConnectNotificationState.HideNotification + connectNotificationState = ConnectNotificationState.HideNotification, + deviceName = null, + daysLeftUntilExpiry = null ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt index cc19ac7ca868..f7794e5a5599 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt @@ -2,4 +2,7 @@ package net.mullvad.mullvadvpn.compose.state import net.mullvad.mullvadvpn.model.TunnelState -data class OutOfTimeUiState(val tunnelState: TunnelState = TunnelState.Disconnected) +data class OutOfTimeUiState( + val tunnelState: TunnelState = TunnelState.Disconnected, + val deviceName: String +) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index 63fcf17ad249..7134f7b7d205 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -83,19 +83,21 @@ val uiModule = module { viewModel { ChangelogViewModel(get(), BuildConfig.VERSION_CODE, BuildConfig.ALWAYS_SHOW_CHANGELOG) } - viewModel { ConnectViewModel(get(), BuildConfig.ENABLE_IN_APP_VERSION_NOTIFICATIONS, get()) } + viewModel { + ConnectViewModel(get(), BuildConfig.ENABLE_IN_APP_VERSION_NOTIFICATIONS, get(), get()) + } viewModel { DeviceListViewModel(get(), get()) } viewModel { DeviceRevokedViewModel(get(), get()) } viewModel { LoginViewModel(get(), get()) } - viewModel { OutOfTimeViewModel(get(), get()) } viewModel { PrivacyDisclaimerViewModel(get()) } - viewModel { ReportProblemViewModel(get()) } viewModel { SelectLocationViewModel(get()) } viewModel { SettingsViewModel(get(), get()) } - viewModel { ViewLogsViewModel(get()) } viewModel { VoucherDialogViewModel(get(), get()) } viewModel { VpnSettingsViewModel(get(), get(), get(), get()) } viewModel { WelcomeViewModel(get(), get(), get()) } + viewModel { ReportProblemViewModel(get()) } + viewModel { ViewLogsViewModel(get()) } + viewModel { OutOfTimeViewModel(get(), get(), get()) } } const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/DateExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/DateExtensions.kt index d3be3e09aa6e..e11434257a22 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/DateExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/DateExtensions.kt @@ -1,6 +1,8 @@ package net.mullvad.mullvadvpn.util import java.text.DateFormat +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.DurationUnit import org.joda.time.DateTime import org.joda.time.format.ISODateTimeFormat @@ -8,3 +10,6 @@ fun DateTime.formatDate(): String = ISODateTimeFormat.date().print(this) fun DateTime.toExpiryDateString(): String = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(this.toDate()) + +fun DateTime.daysFromNow() = + (toInstant().millis - DateTime.now().toInstant().millis).milliseconds.toInt(DurationUnit.DAYS) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt index d18e4f8fc988..e782f6f43937 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt @@ -97,3 +97,30 @@ inline fun combine( ) } } + +inline fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + flow8: Flow, + crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8) -> R +): Flow { + return kotlinx.coroutines.flow.combine(flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8) { + args: Array<*> -> + @Suppress("UNCHECKED_CAST") + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + args[7] as T8 + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt index 01a1c848961f..01ba71ff861a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -24,6 +25,7 @@ import net.mullvad.mullvadvpn.compose.state.ConnectUiState import net.mullvad.mullvadvpn.model.AccountExpiry import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.repository.AccountRepository +import net.mullvad.mullvadvpn.repository.DeviceRepository import net.mullvad.mullvadvpn.ui.VersionInfo import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy import net.mullvad.mullvadvpn.ui.serviceconnection.LocationInfoCache @@ -36,6 +38,7 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy import net.mullvad.mullvadvpn.util.appVersionCallbackFlow import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier import net.mullvad.mullvadvpn.util.combine +import net.mullvad.mullvadvpn.util.daysFromNow import net.mullvad.mullvadvpn.util.toInAddress import net.mullvad.mullvadvpn.util.toOutAddress import net.mullvad.talpid.tunnel.ActionAfterDisconnect @@ -47,6 +50,7 @@ class ConnectViewModel( private val serviceConnectionManager: ServiceConnectionManager, private val isVersionInfoNotificationEnabled: Boolean, accountRepository: AccountRepository, + private val deviceRepository: DeviceRepository, ) : ViewModel() { private val _uiSideEffect = MutableSharedFlow(extraBufferCapacity = 1) val uiSideEffect = _uiSideEffect.asSharedFlow() @@ -74,7 +78,8 @@ class ConnectViewModel( serviceConnection.connectionProxy.tunnelUiStateFlow(), serviceConnection.connectionProxy.tunnelRealStateFlow(), accountRepository.accountExpiryState, - _isTunnelInfoExpanded + _isTunnelInfoExpanded, + deviceRepository.deviceState.map { it.deviceName() } ) { location, relayLocation, @@ -82,7 +87,8 @@ class ConnectViewModel( tunnelUiState, tunnelRealState, accountExpiry, - isTunnelInfoExpanded -> + isTunnelInfoExpanded, + deviceName -> if (tunnelRealState.isTunnelErrorStateDueToExpiredAccount()) { _uiSideEffect.tryEmit(UiSideEffect.OpenOutOfTimeView) } @@ -124,7 +130,9 @@ class ConnectViewModel( tunnelUiState = tunnelUiState, versionInfo = versionInfo, accountExpiry = accountExpiry - ) + ), + deviceName = deviceName, + daysLeftUntilExpiry = accountExpiry.date()?.daysFromNow() ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt index 8a789f62fdd4..b1df2d222586 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt @@ -12,13 +12,13 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_POLL_INTERVAL import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.repository.AccountRepository +import net.mullvad.mullvadvpn.repository.DeviceRepository import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState @@ -31,7 +31,8 @@ import org.joda.time.DateTime class OutOfTimeViewModel( private val accountRepository: AccountRepository, private val serviceConnectionManager: ServiceConnectionManager, - private val pollAccountExpiry: Boolean = true + private val deviceRepository: DeviceRepository, + private val pollAccountExpiry: Boolean = true, ) : ViewModel() { private val _uiSideEffect = MutableSharedFlow(extraBufferCapacity = 1) @@ -47,10 +48,21 @@ class OutOfTimeViewModel( } } .flatMapLatest { serviceConnection -> - serviceConnection.connectionProxy.tunnelStateFlow() + kotlinx.coroutines.flow.combine( + serviceConnection.connectionProxy.tunnelStateFlow(), + deviceRepository.deviceState + ) { tunnelState, deviceState -> + OutOfTimeUiState( + tunnelState = tunnelState, + deviceName = deviceState.deviceName() ?: "", + ) + } } - .map { tunnelState -> OutOfTimeUiState(tunnelState = tunnelState) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), OutOfTimeUiState()) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + OutOfTimeUiState(deviceName = "") + ) init { viewModelScope.launch { diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountExpiry.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountExpiry.kt index f5738ec21df0..f856ef8c8961 100644 --- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountExpiry.kt +++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountExpiry.kt @@ -1,28 +1,15 @@ package net.mullvad.mullvadvpn.model import android.os.Parcelable -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.DurationUnit import kotlinx.parcelize.Parcelize import org.joda.time.DateTime sealed class AccountExpiry : Parcelable { - @Parcelize - data class Available(val expiryDateTime: DateTime) : AccountExpiry() { - override fun daysLeft(): Int = - (expiryDateTime.toInstant().millis - DateTime.now().toInstant().millis) - .milliseconds - .toInt(DurationUnit.DAYS) - } + @Parcelize data class Available(val expiryDateTime: DateTime) : AccountExpiry() - @Parcelize - data object Missing : AccountExpiry() + @Parcelize data object Missing : AccountExpiry() fun date(): DateTime? { return (this as? Available)?.expiryDateTime } - - open fun daysLeft(): Int? { - return (this as? Available)?.daysLeft() - } } diff --git a/android/lib/resource/src/main/res/values-da/strings.xml b/android/lib/resource/src/main/res/values-da/strings.xml index bb2608111200..b98455f7d92b 100644 --- a/android/lib/resource/src/main/res/values-da/strings.xml +++ b/android/lib/resource/src/main/res/values-da/strings.xml @@ -175,6 +175,8 @@ Skift placering TCP Slå VPN til/fra + Enhedsnavn: %1$s + Resterende tid: %1$s Prøv igen UDP Hvilken TCP-port UDP-over-TCP tilsløringsprotokollen skal forbinde til på VPN-serveren. diff --git a/android/lib/resource/src/main/res/values-de/strings.xml b/android/lib/resource/src/main/res/values-de/strings.xml index 357e4209e35c..19120efa7b45 100644 --- a/android/lib/resource/src/main/res/values-de/strings.xml +++ b/android/lib/resource/src/main/res/values-de/strings.xml @@ -175,6 +175,8 @@ Ort wechseln TCP VPN umschalten + Gerätename: %1$s + Verbleibende Zeit: %1$s Erneut versuchen UDP Mit welchem TCP-Port sich das UDP-über-TCP-Verschleierungsprotokoll auf dem VPN-Server verbinden soll. diff --git a/android/lib/resource/src/main/res/values-es/strings.xml b/android/lib/resource/src/main/res/values-es/strings.xml index 1ca4ad0fa58e..e5c5f7d6574f 100644 --- a/android/lib/resource/src/main/res/values-es/strings.xml +++ b/android/lib/resource/src/main/res/values-es/strings.xml @@ -175,6 +175,8 @@ Cambiar ubicación TCP Alternar VPN + Nombre del dispositivo: %1$s + Tiempo restante: %1$s Volver a intentarlo UDP El puerto TCP al que se conectará el protocolo de ofuscación de UDP sobre TCP en el servidor VPN. diff --git a/android/lib/resource/src/main/res/values-fi/strings.xml b/android/lib/resource/src/main/res/values-fi/strings.xml index eb02e6375693..379d8dd4bbdd 100644 --- a/android/lib/resource/src/main/res/values-fi/strings.xml +++ b/android/lib/resource/src/main/res/values-fi/strings.xml @@ -175,6 +175,8 @@ Vaihda sijaintia TCP Vaihda VPN:ää + Laitteen nimi: %1$s + Aikaa jäljellä: %1$s Yritä uudelleen UDP Määrittää, mihin VPN-palvelimen TCP-porttiin \"UDP TCP:n kautta\" -hämäysteknologia-protokollan tulee muodostaa yhteys. diff --git a/android/lib/resource/src/main/res/values-fr/strings.xml b/android/lib/resource/src/main/res/values-fr/strings.xml index da970c6d89b1..9da5482c92c6 100644 --- a/android/lib/resource/src/main/res/values-fr/strings.xml +++ b/android/lib/resource/src/main/res/values-fr/strings.xml @@ -175,6 +175,8 @@ Changer de localisation TCP Activer/désactiver le VPN + Nom de l\'appareil : %1$s + Temps restant : %1$s Réessayer UDP Le port TCP auquel le protocole de dissimulation UDP sur TCP doit se connecter sur le serveur VPN. diff --git a/android/lib/resource/src/main/res/values-it/strings.xml b/android/lib/resource/src/main/res/values-it/strings.xml index c988e760cf8e..e91aaecdb9bb 100644 --- a/android/lib/resource/src/main/res/values-it/strings.xml +++ b/android/lib/resource/src/main/res/values-it/strings.xml @@ -175,6 +175,8 @@ Cambia posizione TCP Attiva/disattiva VPN + Nome del dispositivo: %1$s + Tempo rimasto: %1$s Riprova UDP A quale porta TCP deve connettersi il protocollo di offuscamento UDP-over-TCP sul server VPN. diff --git a/android/lib/resource/src/main/res/values-ja/strings.xml b/android/lib/resource/src/main/res/values-ja/strings.xml index 8c9ef84739d1..3112ec2b1c8c 100644 --- a/android/lib/resource/src/main/res/values-ja/strings.xml +++ b/android/lib/resource/src/main/res/values-ja/strings.xml @@ -175,6 +175,8 @@ 場所を切り替える TCP VPNの切り替え + デバイス名: %1$s + 残り時間: %1$s 再試行 UDP UDP-over-TCP難読化プロトコルで接続する必要のあるVPNサーバーのTCPポートです。 diff --git a/android/lib/resource/src/main/res/values-ko/strings.xml b/android/lib/resource/src/main/res/values-ko/strings.xml index 209023a64e8d..b53596691113 100644 --- a/android/lib/resource/src/main/res/values-ko/strings.xml +++ b/android/lib/resource/src/main/res/values-ko/strings.xml @@ -175,6 +175,8 @@ 위치 전환 TCP VPN 전환 + 장치 이름: %1$s + 남은 시간: %1$s 다시 시도 UDP UDP-over-TCP 난독 처리 프로토콜이 VPN 서버에서 연결해야 하는 TCP 포트입니다. diff --git a/android/lib/resource/src/main/res/values-my/strings.xml b/android/lib/resource/src/main/res/values-my/strings.xml index 0aef2a2c2e2f..6a0f2ba37708 100644 --- a/android/lib/resource/src/main/res/values-my/strings.xml +++ b/android/lib/resource/src/main/res/values-my/strings.xml @@ -175,6 +175,8 @@ တည်နေရာ ပြောင်းရန် TCP VPN ရွေးသုံးရန် + စက်အမည်- %1$s + ကျန်သည့် အချိန်- %1$s ထပ်ကြိုးစားရန် UDP VPN ဆာဗာကို ဖွင့်ရန် ၎င်း TCP ပေါ့တ် UDP-over-TCP Obfuscation ပရိုတိုကောလ်နှင့် ချိတ်ဆက်ထားသင့်ပါသည်။ diff --git a/android/lib/resource/src/main/res/values-nb/strings.xml b/android/lib/resource/src/main/res/values-nb/strings.xml index 2b0e370bb3ae..68726083062c 100644 --- a/android/lib/resource/src/main/res/values-nb/strings.xml +++ b/android/lib/resource/src/main/res/values-nb/strings.xml @@ -175,6 +175,8 @@ Bytt plassering TCP Velg VPN + Enhetsnavn: %1$s + Tid igjen: %1$s Prøv på nytt UDP TCP-porten som UDP-over-TCP-tilsløringen skal koble til på VPN-serveren. diff --git a/android/lib/resource/src/main/res/values-nl/strings.xml b/android/lib/resource/src/main/res/values-nl/strings.xml index cdbaa554c3ff..005f1c690763 100644 --- a/android/lib/resource/src/main/res/values-nl/strings.xml +++ b/android/lib/resource/src/main/res/values-nl/strings.xml @@ -175,6 +175,8 @@ Locatie wijzigen TCP VPN in-/uitschakelen + Apparaatnaam: %1$s + Resterende tijd: %1$s Probeer het opnieuw UDP Met welke TCP-poort moet het UDP-over-TCP-obfuscatieprotocol verbinding maken op de VPN-server. diff --git a/android/lib/resource/src/main/res/values-pl/strings.xml b/android/lib/resource/src/main/res/values-pl/strings.xml index 2e2e6ee267fd..98b69a66a869 100644 --- a/android/lib/resource/src/main/res/values-pl/strings.xml +++ b/android/lib/resource/src/main/res/values-pl/strings.xml @@ -175,6 +175,8 @@ Zmień lokalizację TCP Przełącz VPN + Nazwa urządzenia: %1$s + Pozostało: %1$s Spróbuj ponownie UDP Port TCP, z którym powinien łączyć się protokół zaciemniania UDP-przez-TCP na serwerze VPN. diff --git a/android/lib/resource/src/main/res/values-pt/strings.xml b/android/lib/resource/src/main/res/values-pt/strings.xml index 2fee06cab650..5dd4fd61ea28 100644 --- a/android/lib/resource/src/main/res/values-pt/strings.xml +++ b/android/lib/resource/src/main/res/values-pt/strings.xml @@ -175,6 +175,8 @@ Alterar local TCP Alternar VPN + Nome do dispositivo: %1$s + Tempo restante: %1$s Tentar novamente UDP A que porta TCP o protocolo de ofuscação UDP sobre TCP deve ligar-se no servidor VPN. diff --git a/android/lib/resource/src/main/res/values-ru/strings.xml b/android/lib/resource/src/main/res/values-ru/strings.xml index 0fb01c88ad11..7b9acc9195c1 100644 --- a/android/lib/resource/src/main/res/values-ru/strings.xml +++ b/android/lib/resource/src/main/res/values-ru/strings.xml @@ -175,6 +175,8 @@ Сменить местоположение TCP Включение VPN + Имя устройства: %1$s + Осталось времени: %1$s Повторить попытку UDP TCP-порт, к которому должен подключаться протокол обфускации UDP через TCP на VPN-сервере. diff --git a/android/lib/resource/src/main/res/values-sv/strings.xml b/android/lib/resource/src/main/res/values-sv/strings.xml index c65809dc5d20..d8183b2435ce 100644 --- a/android/lib/resource/src/main/res/values-sv/strings.xml +++ b/android/lib/resource/src/main/res/values-sv/strings.xml @@ -175,6 +175,8 @@ Växla plats TCP Växla VPN + Enhetsnamn: %1$s + Tid kvar: %1$s Försök igen UDP Vilken TCP-port som UDP-över-TCP-obfuskeringsprotokoll bör ansluta till på VPN-servern. diff --git a/android/lib/resource/src/main/res/values-th/strings.xml b/android/lib/resource/src/main/res/values-th/strings.xml index 3f01840e0a52..7afc8a7b440c 100644 --- a/android/lib/resource/src/main/res/values-th/strings.xml +++ b/android/lib/resource/src/main/res/values-th/strings.xml @@ -175,6 +175,8 @@ สลับตำแหน่ง TCP เปิด/ปิด VPN + ชื่ออุปกรณ์: %1$s + เหลือเวลา: %1$s ลองอีกครั้ง UDP พอร์ต TCP ใดที่โพรโทคอลการทำให้ข้อมูลยุ่งเหยิง UDP-ผ่าน-TCP ควรเชื่อมต่อบนเซิร์ฟเวอร์ VPN diff --git a/android/lib/resource/src/main/res/values-tr/strings.xml b/android/lib/resource/src/main/res/values-tr/strings.xml index 08ff5f47e68e..908f9ce2d989 100644 --- a/android/lib/resource/src/main/res/values-tr/strings.xml +++ b/android/lib/resource/src/main/res/values-tr/strings.xml @@ -175,6 +175,8 @@ Konum değiştir TCP VPN\'i aç/kapat + Cihaz adı: %1$s + Kalan süre: %1$s Tekrar dene UDP TCP üzerinden UDP gizleme protokolünün VPN sunucusunda hangi TCP portuna bağlanması gerekiyor. diff --git a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml index 174262c638eb..667ffbcde9f5 100644 --- a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml +++ b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml @@ -175,6 +175,8 @@ 切换位置 TCP 切换 VPN + 设备名称:%1$s + 剩余时间:%1$s 重试 UDP UDP-over-TCP 混淆协议应连接到 VPN 服务器上的哪个 TCP 端口。 diff --git a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml index 70b0d42c55a0..5378b92550a3 100644 --- a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml +++ b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml @@ -175,6 +175,8 @@ 切換位置 TCP 切換 VPN + 裝置名稱:%1$s + 剩餘時間:%1$s 再試一次 UDP UDP-over-TCP 混淆通訊協定應連線到 VPN 伺服器上的哪個 TCP 連接埠。 diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index bc9630e9749e..c9c837d38d64 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -224,4 +224,6 @@ Verifying voucher… %s was added to your account. less than one day + Time left: %s + Device name: %s From e72415c31be76c019ec135ccb231560454675e7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20G=C3=B6ransson?= Date: Tue, 10 Oct 2023 15:56:48 +0200 Subject: [PATCH 3/3] Fix tests --- .../compose/screen/ConnectScreenTest.kt | 44 +++++++++++++++++++ .../compose/screen/OutOfTimeScreenTest.kt | 16 ++++--- .../mullvadvpn/compose/component/TopBar.kt | 4 +- .../compose/screen/ConnectScreen.kt | 1 - .../viewmodel/ConnectViewModelTest.kt | 11 +++++ .../viewmodel/OutOfTimeViewModelTest.kt | 9 +++- 6 files changed, 75 insertions(+), 10 deletions(-) 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 02a148b22d85..68cfa2b92c06 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 @@ -83,6 +83,8 @@ class ConnectScreenTest { outAddress = "", showLocation = false, isTunnelInfoExpanded = false, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.ShowTunnelStateNotificationBlocked ), @@ -118,6 +120,8 @@ class ConnectScreenTest { outAddress = "", showLocation = false, isTunnelInfoExpanded = false, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.ShowTunnelStateNotificationBlocked ), @@ -151,6 +155,8 @@ class ConnectScreenTest { outAddress = "", showLocation = false, isTunnelInfoExpanded = false, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.HideNotification ), uiSideEffect = MutableSharedFlow().asSharedFlow() @@ -182,6 +188,8 @@ class ConnectScreenTest { outAddress = "", showLocation = false, isTunnelInfoExpanded = false, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.HideNotification ), uiSideEffect = MutableSharedFlow().asSharedFlow() @@ -214,6 +222,8 @@ class ConnectScreenTest { outAddress = "", showLocation = true, isTunnelInfoExpanded = false, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.HideNotification ), uiSideEffect = MutableSharedFlow().asSharedFlow() @@ -246,6 +256,8 @@ class ConnectScreenTest { outAddress = "", showLocation = true, isTunnelInfoExpanded = false, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.HideNotification ), uiSideEffect = MutableSharedFlow().asSharedFlow() @@ -280,6 +292,8 @@ class ConnectScreenTest { outAddress = "", showLocation = true, isTunnelInfoExpanded = false, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.ShowTunnelStateNotificationError( ErrorState(ErrorStateCause.StartTunnelError, true) @@ -318,6 +332,8 @@ class ConnectScreenTest { outAddress = "", showLocation = true, isTunnelInfoExpanded = false, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.ShowTunnelStateNotificationError( ErrorState(ErrorStateCause.StartTunnelError, false) @@ -353,6 +369,8 @@ class ConnectScreenTest { outAddress = "", showLocation = false, isTunnelInfoExpanded = false, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.ShowTunnelStateNotificationBlocked ), @@ -388,6 +406,8 @@ class ConnectScreenTest { outAddress = "", showLocation = true, isTunnelInfoExpanded = false, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.ShowTunnelStateNotificationBlocked ), @@ -423,6 +443,8 @@ class ConnectScreenTest { outAddress = "", showLocation = false, isTunnelInfoExpanded = false, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.HideNotification ), uiSideEffect = MutableSharedFlow().asSharedFlow(), @@ -454,6 +476,8 @@ class ConnectScreenTest { outAddress = "", showLocation = false, isTunnelInfoExpanded = false, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.HideNotification ), uiSideEffect = MutableSharedFlow().asSharedFlow(), @@ -485,6 +509,8 @@ class ConnectScreenTest { outAddress = "", showLocation = false, isTunnelInfoExpanded = false, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.HideNotification ), uiSideEffect = MutableSharedFlow().asSharedFlow(), @@ -515,6 +541,8 @@ class ConnectScreenTest { outAddress = "", showLocation = false, isTunnelInfoExpanded = false, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.HideNotification ), uiSideEffect = MutableSharedFlow().asSharedFlow(), @@ -545,6 +573,8 @@ class ConnectScreenTest { outAddress = "", showLocation = false, isTunnelInfoExpanded = false, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.HideNotification ), uiSideEffect = MutableSharedFlow().asSharedFlow(), @@ -576,6 +606,8 @@ class ConnectScreenTest { outAddress = "", showLocation = false, isTunnelInfoExpanded = false, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.HideNotification ), uiSideEffect = MutableSharedFlow().asSharedFlow(), @@ -614,6 +646,8 @@ class ConnectScreenTest { outAddress = mockOutAddress, showLocation = false, isTunnelInfoExpanded = true, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.HideNotification ), uiSideEffect = MutableSharedFlow().asSharedFlow() @@ -651,6 +685,8 @@ class ConnectScreenTest { outAddress = "", showLocation = false, isTunnelInfoExpanded = false, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.ShowVersionInfoNotification(versionInfo) ), @@ -687,6 +723,8 @@ class ConnectScreenTest { outAddress = "", showLocation = false, isTunnelInfoExpanded = false, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.ShowVersionInfoNotification(versionInfo) ), @@ -720,6 +758,8 @@ class ConnectScreenTest { outAddress = "", showLocation = false, isTunnelInfoExpanded = false, + deviceName = null, + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.ShowAccountExpiryNotification(expiryDate) ), @@ -758,6 +798,8 @@ class ConnectScreenTest { outAddress = "", showLocation = false, isTunnelInfoExpanded = false, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.ShowVersionInfoNotification(versionInfo) ), @@ -790,6 +832,8 @@ class ConnectScreenTest { outAddress = "", showLocation = false, isTunnelInfoExpanded = false, + deviceName = "", + daysLeftUntilExpiry = null, connectNotificationState = ConnectNotificationState.ShowAccountExpiryNotification(expiryDate) ), diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt index a177aa8ac199..95b44f82864a 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt @@ -29,7 +29,7 @@ class OutOfTimeScreenTest { composeTestRule.setContent { OutOfTimeScreen( showSitePayment = false, - uiState = OutOfTimeUiState(), + uiState = OutOfTimeUiState(deviceName = ""), uiSideEffect = MutableSharedFlow(), onSitePaymentClick = {}, onRedeemVoucherClick = {}, @@ -57,7 +57,7 @@ class OutOfTimeScreenTest { composeTestRule.setContent { OutOfTimeScreen( showSitePayment = true, - uiState = OutOfTimeUiState(), + uiState = OutOfTimeUiState(deviceName = ""), uiSideEffect = MutableStateFlow(OutOfTimeViewModel.UiSideEffect.OpenAccountView("222")), onSitePaymentClick = {}, @@ -80,7 +80,7 @@ class OutOfTimeScreenTest { composeTestRule.setContent { OutOfTimeScreen( showSitePayment = true, - uiState = OutOfTimeUiState(), + uiState = OutOfTimeUiState(deviceName = ""), uiSideEffect = MutableStateFlow(OutOfTimeViewModel.UiSideEffect.OpenConnectScreen), onSitePaymentClick = {}, onRedeemVoucherClick = {}, @@ -102,7 +102,7 @@ class OutOfTimeScreenTest { composeTestRule.setContent { OutOfTimeScreen( showSitePayment = true, - uiState = OutOfTimeUiState(), + uiState = OutOfTimeUiState(deviceName = ""), uiSideEffect = MutableSharedFlow(), onSitePaymentClick = mockClickListener, onRedeemVoucherClick = {}, @@ -127,7 +127,7 @@ class OutOfTimeScreenTest { composeTestRule.setContent { OutOfTimeScreen( showSitePayment = true, - uiState = OutOfTimeUiState(), + uiState = OutOfTimeUiState(deviceName = ""), uiSideEffect = MutableSharedFlow(), onSitePaymentClick = {}, onRedeemVoucherClick = mockClickListener, @@ -152,7 +152,11 @@ class OutOfTimeScreenTest { composeTestRule.setContent { OutOfTimeScreen( showSitePayment = true, - uiState = OutOfTimeUiState(tunnelState = TunnelState.Connecting(null, null)), + uiState = + OutOfTimeUiState( + tunnelState = TunnelState.Connecting(null, null), + deviceName = "" + ), uiSideEffect = MutableSharedFlow(), onSitePaymentClick = {}, onRedeemVoucherClick = {}, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt index 93e1e291a2c6..5e8fc2c78bd1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt @@ -221,7 +221,7 @@ fun MullvadMediumTopBar( @Preview @Composable -fun PreviewMullvadTopBarWithLongDeviceName() { +private fun PreviewMullvadTopBarWithLongDeviceName() { AppTheme { Surface { MullvadTopBarWithDeviceName( @@ -238,7 +238,7 @@ fun PreviewMullvadTopBarWithLongDeviceName() { @Preview @Composable -fun PreviewMullvadTopBarWithShortDeviceName() { +private fun PreviewMullvadTopBarWithShortDeviceName() { AppTheme { Surface { MullvadTopBarWithDeviceName( 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 9374f4ab9a9a..f694079ae31c 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 @@ -36,7 +36,6 @@ import net.mullvad.mullvadvpn.compose.component.ConnectionStatusText import net.mullvad.mullvadvpn.compose.component.LocationInfo import net.mullvad.mullvadvpn.compose.component.Notification import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBarAndDeviceName -import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.state.ConnectUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR 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 18f8447f4434..bddaee353ea9 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 @@ -20,11 +20,13 @@ import net.mullvad.mullvadvpn.compose.state.ConnectNotificationState import net.mullvad.mullvadvpn.compose.state.ConnectUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.model.AccountExpiry +import net.mullvad.mullvadvpn.model.DeviceState import net.mullvad.mullvadvpn.model.GeoIpLocation import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.relaylist.RelayCountry import net.mullvad.mullvadvpn.relaylist.RelayItem import net.mullvad.mullvadvpn.repository.AccountRepository +import net.mullvad.mullvadvpn.repository.DeviceRepository import net.mullvad.mullvadvpn.ui.VersionInfo import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoCache import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache @@ -65,6 +67,7 @@ class ConnectViewModelTest { ) ) private val accountExpiryState = MutableStateFlow(AccountExpiry.Missing) + private val deviceState = MutableStateFlow(DeviceState.Initial) // Service connections private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() @@ -77,6 +80,9 @@ class ConnectViewModelTest { // Account Repository private val mockAccountRepository: AccountRepository = mockk() + // Device Repository + private val mockDeviceRepository: DeviceRepository = mockk() + // Captures private val locationSlot = slot<((GeoIpLocation?) -> Unit)>() private val relaySlot = slot<(List, RelayItem?) -> Unit>() @@ -103,6 +109,8 @@ class ConnectViewModelTest { every { mockAccountRepository.accountExpiryState } returns accountExpiryState + every { mockDeviceRepository.deviceState } returns deviceState + every { mockConnectionProxy.onUiStateChange } returns eventNotifierTunnelUiState every { mockConnectionProxy.onStateChange } returns eventNotifierTunnelRealState @@ -117,6 +125,7 @@ class ConnectViewModelTest { ConnectViewModel( serviceConnectionManager = mockServiceConnectionManager, accountRepository = mockAccountRepository, + deviceRepository = mockDeviceRepository, isVersionInfoNotificationEnabled = true ) } @@ -351,6 +360,7 @@ class ConnectViewModelTest { val expectedConnectNotificationState = ConnectNotificationState.ShowAccountExpiryNotification(mockDateTime) every { mockDateTime.isBefore(any()) } returns true + every { mockDateTime.toInstant().millis } returns 0 // Act, Assert viewModel.uiState.test { @@ -360,6 +370,7 @@ class ConnectViewModelTest { locationSlot.captured.invoke(mockLocation) relaySlot.captured.invoke(mockk(), mockk()) accountExpiryState.value = AccountExpiry.Available(mockDateTime) + val result = awaitItem() assertEquals(expectedConnectNotificationState, result.connectNotificationState) } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt index 5f81032938f0..8c1ec10f5a22 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt @@ -16,8 +16,10 @@ import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.model.AccountExpiry +import net.mullvad.mullvadvpn.model.DeviceState import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.repository.AccountRepository +import net.mullvad.mullvadvpn.repository.DeviceRepository import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer @@ -39,6 +41,7 @@ class OutOfTimeViewModelTest { private val serviceConnectionState = MutableStateFlow(ServiceConnectionState.Disconnected) private val accountExpiryState = MutableStateFlow(AccountExpiry.Missing) + private val deviceState = MutableStateFlow(DeviceState.Initial) // Service connections private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() @@ -48,6 +51,7 @@ class OutOfTimeViewModelTest { private val eventNotifierTunnelRealState = EventNotifier(TunnelState.Disconnected) private val mockAccountRepository: AccountRepository = mockk() + private val mockDeviceRepository: DeviceRepository = mockk() private val mockServiceConnectionManager: ServiceConnectionManager = mockk() private lateinit var viewModel: OutOfTimeViewModel @@ -64,10 +68,13 @@ class OutOfTimeViewModelTest { every { mockAccountRepository.accountExpiryState } returns accountExpiryState + every { mockDeviceRepository.deviceState } returns deviceState + viewModel = OutOfTimeViewModel( accountRepository = mockAccountRepository, serviceConnectionManager = mockServiceConnectionManager, + deviceRepository = mockDeviceRepository, pollAccountExpiry = false ) } @@ -104,7 +111,7 @@ class OutOfTimeViewModelTest { // Act, Assert viewModel.uiState.test { - assertEquals(OutOfTimeUiState(), awaitItem()) + assertEquals(OutOfTimeUiState(deviceName = ""), awaitItem()) serviceConnectionState.value = ServiceConnectionState.ConnectedReady(mockServiceConnectionContainer) eventNotifierTunnelRealState.notify(tunnelRealStateTestItem)