summaryrefslogtreecommitdiffhomepage
path: root/android/app
diff options
context:
space:
mode:
Diffstat (limited to 'android/app')
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt128
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/AccountNumberView.kt15
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt66
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/InformationView.kt78
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt199
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/AccountUiState.kt9
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/extension/ContextExtensions.kt9
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt267
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountManagementButton.kt21
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/Cell.kt131
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CellSwitch.kt235
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CopyableInformationView.kt70
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CustomItemAnimator.kt43
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CustomRecyclerView.kt61
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/InformationView.kt200
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ToggleCell.kt46
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/DateExtensions.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt65
-rw-r--r--android/app/src/main/res/layout/account.xml88
-rw-r--r--android/app/src/main/res/layout/account_buttons.xml15
-rw-r--r--android/app/src/main/res/layout/information_view.xml64
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt97
25 files changed, 708 insertions, 1223 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt
new file mode 100644
index 0000000000..e616f67449
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt
@@ -0,0 +1,128 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import io.mockk.MockKAnnotations
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import net.mullvad.mullvadvpn.compose.state.AccountUiState
+import net.mullvad.mullvadvpn.viewmodel.AccountViewModel
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class AccountScreenTest {
+ @get:Rule val composeTestRule = createComposeRule()
+
+ @Before
+ fun setup() {
+ MockKAnnotations.init(this)
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Test
+ fun testDefaultState() {
+ // Arrange
+ composeTestRule.setContent {
+ AccountScreen(
+ uiState =
+ AccountUiState(
+ deviceName = DUMMY_DEVICE_NAME,
+ accountNumber = DUMMY_ACCOUNT_NUMBER,
+ accountExpiry = null
+ ),
+ viewActions = MutableSharedFlow<AccountViewModel.ViewAction>().asSharedFlow()
+ )
+ }
+
+ // Assert
+ composeTestRule.apply {
+ onNodeWithText("Redeem voucher").assertExists()
+ onNodeWithText("Log out").assertExists()
+ }
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Test
+ fun testManageAccountClick() {
+ // Arrange
+ val mockedClickHandler: () -> Unit = mockk(relaxed = true)
+ composeTestRule.setContent {
+ AccountScreen(
+ uiState =
+ AccountUiState(
+ deviceName = DUMMY_DEVICE_NAME,
+ accountNumber = DUMMY_ACCOUNT_NUMBER,
+ accountExpiry = null
+ ),
+ viewActions = MutableSharedFlow<AccountViewModel.ViewAction>().asSharedFlow(),
+ onManageAccountClick = mockedClickHandler
+ )
+ }
+
+ // Act
+ composeTestRule.onNodeWithText("Manage account").performClick()
+
+ // Assert
+ verify { mockedClickHandler.invoke() }
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Test
+ fun testRedeemVoucherClick() {
+ // Arrange
+ val mockedClickHandler: () -> Unit = mockk(relaxed = true)
+ composeTestRule.setContent {
+ AccountScreen(
+ uiState =
+ AccountUiState(
+ deviceName = DUMMY_DEVICE_NAME,
+ accountNumber = DUMMY_ACCOUNT_NUMBER,
+ accountExpiry = null
+ ),
+ viewActions = MutableSharedFlow<AccountViewModel.ViewAction>().asSharedFlow(),
+ onRedeemVoucherClick = mockedClickHandler
+ )
+ }
+
+ // Act
+ composeTestRule.onNodeWithText("Redeem voucher").performClick()
+
+ // Assert
+ verify { mockedClickHandler.invoke() }
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Test
+ fun testLogoutClick() {
+ // Arrange
+ val mockedClickHandler: () -> Unit = mockk(relaxed = true)
+ composeTestRule.setContent {
+ AccountScreen(
+ uiState =
+ AccountUiState(
+ deviceName = DUMMY_DEVICE_NAME,
+ accountNumber = DUMMY_ACCOUNT_NUMBER,
+ accountExpiry = null
+ ),
+ viewActions = MutableSharedFlow<AccountViewModel.ViewAction>().asSharedFlow(),
+ onLogoutClick = mockedClickHandler
+ )
+ }
+
+ // Act
+ composeTestRule.onNodeWithText("Log out").performClick()
+
+ // Assert
+ verify { mockedClickHandler.invoke() }
+ }
+
+ companion object {
+ private const val DUMMY_DEVICE_NAME = "fake_name"
+ private const val DUMMY_ACCOUNT_NUMBER = "fake_number"
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/AccountNumberView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/AccountNumberView.kt
new file mode 100644
index 0000000000..c4fd068973
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/AccountNumberView.kt
@@ -0,0 +1,15 @@
+package net.mullvad.mullvadvpn.compose.component
+
+import androidx.compose.runtime.Composable
+import net.mullvad.mullvadvpn.lib.common.util.groupPasswordModeWithSpaces
+import net.mullvad.mullvadvpn.lib.common.util.groupWithSpaces
+
+@Composable
+fun AccountNumberView(accountNumber: String, doObfuscateWithPasswordDots: Boolean) {
+ InformationView(
+ content =
+ if (doObfuscateWithPasswordDots) accountNumber.groupPasswordModeWithSpaces()
+ else accountNumber.groupWithSpaces(),
+ whenMissing = MissingPolicy.SHOW_SPINNER
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt
new file mode 100644
index 0000000000..d20f750680
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt
@@ -0,0 +1,66 @@
+package net.mullvad.mullvadvpn.compose.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.theme.Dimens
+import net.mullvad.mullvadvpn.lib.common.util.SdkUtils
+import net.mullvad.mullvadvpn.ui.extension.copyToClipboard
+
+@Preview
+@Composable
+private fun PreviewCopyableObfuscationView() {
+ CopyableObfuscationView("1111222233334444")
+}
+
+@Composable
+fun CopyableObfuscationView(content: String) {
+ val context = LocalContext.current
+ val shouldObfuscated = remember { mutableStateOf(true) }
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ AccountNumberView(
+ accountNumber = content,
+ doObfuscateWithPasswordDots = shouldObfuscated.value
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ Image(
+ painter =
+ painterResource(
+ id = if (shouldObfuscated.value) R.drawable.icon_hide else R.drawable.icon_show
+ ),
+ modifier =
+ Modifier.clickable { shouldObfuscated.value = shouldObfuscated.value.not() }
+ .padding(start = Dimens.sideMargin),
+ contentDescription = stringResource(id = R.string.copy_account_number)
+ )
+ Image(
+ painter = painterResource(id = R.drawable.icon_copy),
+ modifier =
+ Modifier.clickable {
+ context.copyToClipboard(
+ content = content,
+ clipboardLabel = context.getString(R.string.mullvad_account_number)
+ )
+ SdkUtils.showCopyToastIfNeeded(
+ context,
+ context.getString(R.string.copied_mullvad_account_number)
+ )
+ }
+ .padding(start = Dimens.sideMargin, end = Dimens.sideMargin),
+ contentDescription = stringResource(id = R.string.copy_account_number)
+ )
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/InformationView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/InformationView.kt
new file mode 100644
index 0000000000..2d45a02f0c
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/InformationView.kt
@@ -0,0 +1,78 @@
+package net.mullvad.mullvadvpn.compose.component
+
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.compose.theme.Dimens
+
+@Preview
+@Composable
+private fun PreviewInformationView() {
+ InformationView(content = "test content")
+}
+
+@Preview
+@Composable
+private fun PreviewEmptyInformationView() {
+ InformationView(content = "", whenMissing = MissingPolicy.SHOW_SPINNER)
+}
+
+@Composable
+fun InformationView(content: String, whenMissing: MissingPolicy = MissingPolicy.SHOW_VIEW) {
+ return if (content.isNotEmpty()) {
+ Text(
+ style = MaterialTheme.typography.titleSmall,
+ text = content,
+ modifier =
+ Modifier.padding(
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ top = Dimens.smallPadding,
+ bottom = Dimens.mediumPadding
+ )
+ )
+ } else {
+ when (whenMissing) {
+ MissingPolicy.SHOW_VIEW -> {
+ Text(
+ style = MaterialTheme.typography.titleMedium,
+ text = content,
+ modifier =
+ Modifier.padding(
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ top = Dimens.smallPadding,
+ bottom = Dimens.mediumPadding
+ )
+ )
+ }
+ MissingPolicy.HIDE_VIEW -> {}
+ MissingPolicy.SHOW_SPINNER -> {
+ CircularProgressIndicator(
+ modifier =
+ Modifier.padding(
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ top = Dimens.smallPadding,
+ bottom = Dimens.mediumPadding
+ )
+ .height(Dimens.loadingSpinnerSizeMedium)
+ .width(Dimens.loadingSpinnerSizeMedium),
+ color = MaterialTheme.colorScheme.onSecondary
+ )
+ }
+ }
+ }
+}
+
+enum class MissingPolicy {
+ SHOW_VIEW,
+ HIDE_VIEW,
+ SHOW_SPINNER
+}
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
new file mode 100644
index 0000000000..1e8b677e32
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt
@@ -0,0 +1,199 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import me.onebone.toolbar.ScrollStrategy
+import me.onebone.toolbar.rememberCollapsingToolbarScaffoldState
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.button.ActionButton
+import net.mullvad.mullvadvpn.compose.component.CollapsableAwareToolbarScaffold
+import net.mullvad.mullvadvpn.compose.component.CollapsingTopBar
+import net.mullvad.mullvadvpn.compose.component.CopyableObfuscationView
+import net.mullvad.mullvadvpn.compose.component.InformationView
+import net.mullvad.mullvadvpn.compose.component.MissingPolicy
+import net.mullvad.mullvadvpn.compose.state.AccountUiState
+import net.mullvad.mullvadvpn.compose.theme.Dimens
+import net.mullvad.mullvadvpn.lib.common.util.capitalizeFirstCharOfEachWord
+import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser
+import net.mullvad.mullvadvpn.util.toExpiryDateString
+import net.mullvad.mullvadvpn.viewmodel.AccountViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Preview
+@Composable
+private fun PreviewAccountScreen() {
+ AccountScreen(
+ uiState =
+ AccountUiState(
+ deviceName = "Test Name",
+ accountNumber = "1234123412341234",
+ accountExpiry = null
+ ),
+ viewActions = MutableSharedFlow<AccountViewModel.ViewAction>().asSharedFlow(),
+ )
+}
+
+@ExperimentalMaterial3Api
+@Composable
+fun AccountScreen(
+ uiState: AccountUiState,
+ viewActions: SharedFlow<AccountViewModel.ViewAction>,
+ onRedeemVoucherClick: () -> Unit = {},
+ onManageAccountClick: () -> Unit = {},
+ onLogoutClick: () -> Unit = {},
+ onBackClick: () -> Unit = {}
+) {
+ val context = LocalContext.current
+ val state = rememberCollapsingToolbarScaffoldState()
+ val progress = state.toolbarState.progress
+
+ CollapsableAwareToolbarScaffold(
+ backgroundColor = MaterialTheme.colorScheme.background,
+ modifier = Modifier.fillMaxSize(),
+ state = state,
+ scrollStrategy = ScrollStrategy.ExitUntilCollapsed,
+ isEnabledWhenCollapsable = true,
+ toolbar = {
+ val scaffoldModifier =
+ Modifier.road(
+ whenCollapsed = Alignment.TopCenter,
+ whenExpanded = Alignment.BottomStart
+ )
+ CollapsingTopBar(
+ backgroundColor = MaterialTheme.colorScheme.secondary,
+ onBackClicked = { onBackClick() },
+ title = stringResource(id = R.string.settings_account),
+ progress = progress,
+ modifier = scaffoldModifier,
+ backTitle = String(),
+ shouldRotateBackButtonDown = true
+ )
+ },
+ ) {
+ LaunchedEffect(Unit) {
+ viewActions.collect { viewAction ->
+ if (viewAction is AccountViewModel.ViewAction.OpenAccountView) {
+ context.openAccountPageInBrowser(viewAction.token)
+ }
+ }
+ }
+ Column(
+ verticalArrangement = Arrangement.Bottom,
+ horizontalAlignment = Alignment.Start,
+ modifier =
+ Modifier.background(MaterialTheme.colorScheme.background)
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .animateContentSize()
+ ) {
+ Text(
+ style = MaterialTheme.typography.labelMedium,
+ text = stringResource(id = R.string.device_name),
+ modifier = Modifier.padding(start = Dimens.sideMargin, end = Dimens.sideMargin)
+ )
+
+ InformationView(
+ content = uiState.deviceName.capitalizeFirstCharOfEachWord(),
+ whenMissing = MissingPolicy.SHOW_SPINNER
+ )
+
+ Text(
+ style = MaterialTheme.typography.labelMedium,
+ text = stringResource(id = R.string.account_number),
+ modifier =
+ Modifier.padding(
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ top = Dimens.smallPadding
+ )
+ )
+
+ CopyableObfuscationView(content = uiState.accountNumber)
+
+ Text(
+ style = MaterialTheme.typography.labelMedium,
+ text = stringResource(id = R.string.paid_until),
+ modifier = Modifier.padding(start = Dimens.sideMargin, end = Dimens.sideMargin)
+ )
+
+ InformationView(
+ content = uiState.accountExpiry?.toExpiryDateString() ?: "",
+ whenMissing = MissingPolicy.SHOW_SPINNER
+ )
+
+ Spacer(modifier = Modifier.weight(1.0f))
+
+ ActionButton(
+ text = stringResource(id = R.string.manage_account),
+ onClick = { onManageAccountClick() },
+ modifier =
+ Modifier.padding(
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ bottom = Dimens.screenVerticalMargin
+ ),
+ colors =
+ ButtonDefaults.buttonColors(
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ )
+
+ ActionButton(
+ text = stringResource(id = R.string.redeem_voucher),
+ onClick = onRedeemVoucherClick,
+ modifier =
+ Modifier.padding(
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ bottom = Dimens.screenVerticalMargin
+ ),
+ colors =
+ ButtonDefaults.buttonColors(
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ containerColor = MaterialTheme.colorScheme.surface
+ )
+ )
+
+ ActionButton(
+ text = stringResource(id = R.string.log_out),
+ onClick = onLogoutClick,
+ modifier =
+ Modifier.padding(
+ start = Dimens.sideMargin,
+ end = Dimens.sideMargin,
+ bottom = Dimens.screenVerticalMargin
+ ),
+ colors =
+ ButtonDefaults.buttonColors(
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ containerColor = MaterialTheme.colorScheme.error
+ )
+ )
+
+ Spacer(modifier = Modifier.height(Dimens.cellHeight))
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/AccountUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/AccountUiState.kt
new file mode 100644
index 0000000000..a952795571
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/AccountUiState.kt
@@ -0,0 +1,9 @@
+package net.mullvad.mullvadvpn.compose.state
+
+import org.joda.time.DateTime
+
+data class AccountUiState(
+ val deviceName: String,
+ val accountNumber: String,
+ val accountExpiry: DateTime?
+)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt
index a558d817ae..a4d1b26dba 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt
@@ -29,6 +29,12 @@ private val MullvadTypography =
fontWeight = FontWeight.Bold
),
bodySmall = TextStyle(color = MullvadWhite, fontSize = TypeScale.TextSmall),
+ titleSmall =
+ TextStyle(
+ color = MullvadWhite,
+ fontSize = TypeScale.TextMedium,
+ fontWeight = FontWeight.SemiBold
+ ),
titleMedium =
TextStyle(
color = MullvadWhite,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt
index 9881bfe818..9a35a2b32d 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/dimensions/Dimensions.kt
@@ -4,6 +4,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
data class Dimensions(
+ val backButtonSideMargin: Dp = 30.dp,
val buttonHeight: Dp = 44.dp,
val buttonSeparation: Dp = 18.dp,
val cellEndPadding: Dp = 16.dp,
@@ -27,6 +28,7 @@ data class Dimensions(
val listItemHeightExtra: Dp = 60.dp,
val loadingSpinnerPadding: Dp = 12.dp,
val loadingSpinnerSize: Dp = 24.dp,
+ val loadingSpinnerSizeMedium: Dp = 28.dp,
val loadingSpinnerStrokeWidth: Dp = 3.dp,
val mediumPadding: Dp = 16.dp,
val progressIndicatorSize: Dp = 60.dp,
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 ba27a1bd18..76060b8340 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
@@ -21,6 +21,7 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling
import net.mullvad.mullvadvpn.util.ChangelogDataProvider
import net.mullvad.mullvadvpn.util.IChangelogDataProvider
+import net.mullvad.mullvadvpn.viewmodel.AccountViewModel
import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel
import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel
import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel
@@ -78,17 +79,18 @@ val uiModule = module {
single<IChangelogDataProvider> { ChangelogDataProvider(get()) }
// View models
- viewModel { ConnectViewModel(get()) }
- viewModel { DeviceRevokedViewModel(get(), get()) }
- viewModel { DeviceListViewModel(get(), get()) }
- viewModel { LoginViewModel(get(), get()) }
+ viewModel { AccountViewModel(get(), get(), get()) }
viewModel {
ChangelogViewModel(get(), BuildConfig.VERSION_CODE, BuildConfig.ALWAYS_SHOW_CHANGELOG)
}
+ viewModel { ConnectViewModel(get()) }
+ viewModel { DeviceListViewModel(get(), get()) }
+ viewModel { DeviceRevokedViewModel(get(), get()) }
+ viewModel { LoginViewModel(get(), get()) }
viewModel { PrivacyDisclaimerViewModel(get()) }
- viewModel { VpnSettingsViewModel(get(), get(), get(), get()) }
viewModel { SelectLocationViewModel(get()) }
viewModel { SettingsViewModel(get(), get()) }
+ viewModel { VpnSettingsViewModel(get(), get(), get(), get()) }
}
const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME"
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/extension/ContextExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/extension/ContextExtensions.kt
index 11dcaf5067..e2e2f5c44c 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/extension/ContextExtensions.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/extension/ContextExtensions.kt
@@ -1,5 +1,8 @@
package net.mullvad.mullvadvpn.ui.extension
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
import androidx.fragment.app.Fragment
import net.mullvad.mullvadvpn.ui.MainActivity
@@ -12,3 +15,9 @@ fun Fragment.requireMainActivity(): MainActivity {
)
}
}
+
+fun Context.copyToClipboard(content: String, clipboardLabel: String) {
+ val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val clipData = ClipData.newPlainText(clipboardLabel, content)
+ clipboard.setPrimaryClip(clipData)
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt
index bf6dc71b22..4349e8ae64 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/AccountFragment.kt
@@ -1,271 +1,50 @@
package net.mullvad.mullvadvpn.ui.fragment
-import android.app.Activity
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import androidx.core.content.ContextCompat
-import androidx.core.view.isVisible
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.repeatOnLifecycle
-import java.text.DateFormat
-import kotlin.properties.Delegates.observable
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.debounce
-import kotlinx.coroutines.flow.emptyFlow
-import kotlinx.coroutines.flow.flatMapLatest
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.launch
-import net.mullvad.mullvadvpn.BuildConfig
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.platform.ComposeView
import net.mullvad.mullvadvpn.R
-import net.mullvad.mullvadvpn.lib.common.constant.BuildTypes
-import net.mullvad.mullvadvpn.lib.common.util.JobTracker
-import net.mullvad.mullvadvpn.lib.common.util.capitalizeFirstCharOfEachWord
-import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser
-import net.mullvad.mullvadvpn.model.TunnelState
-import net.mullvad.mullvadvpn.repository.AccountRepository
-import net.mullvad.mullvadvpn.repository.DeviceRepository
-import net.mullvad.mullvadvpn.ui.CollapsibleTitleController
-import net.mullvad.mullvadvpn.ui.GroupedPasswordTransformationMethod
-import net.mullvad.mullvadvpn.ui.GroupedTransformationMethod
+import net.mullvad.mullvadvpn.compose.screen.AccountScreen
+import net.mullvad.mullvadvpn.compose.theme.AppTheme
import net.mullvad.mullvadvpn.ui.NavigationBarPainter
import net.mullvad.mullvadvpn.ui.StatusBarPainter
-import net.mullvad.mullvadvpn.ui.extension.requireMainActivity
-import net.mullvad.mullvadvpn.ui.paintNavigationBar
-import net.mullvad.mullvadvpn.ui.paintStatusBar
-import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
-import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
-import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache
-import net.mullvad.mullvadvpn.ui.widget.AccountManagementButton
-import net.mullvad.mullvadvpn.ui.widget.Button
-import net.mullvad.mullvadvpn.ui.widget.CopyableInformationView
-import net.mullvad.mullvadvpn.ui.widget.InformationView
-import net.mullvad.mullvadvpn.ui.widget.RedeemVoucherButton
-import net.mullvad.mullvadvpn.util.UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS
-import net.mullvad.mullvadvpn.util.addDebounceForUnknownState
-import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier
-import net.mullvad.talpid.tunnel.ErrorStateCause
-import org.joda.time.DateTime
-import org.koin.android.ext.android.inject
+import net.mullvad.mullvadvpn.viewmodel.AccountViewModel
+import org.koin.androidx.viewmodel.ext.android.viewModel
class AccountFragment : BaseFragment(), StatusBarPainter, NavigationBarPainter {
+ private val vm by viewModel<AccountViewModel>()
- // Injected dependencies
- private val accountRepository: AccountRepository by inject()
- private val deviceRepository: DeviceRepository by inject()
- private val serviceConnectionManager: ServiceConnectionManager by inject()
-
- private val dateStyle = DateFormat.MEDIUM
- private val timeStyle = DateFormat.SHORT
- private val expiryFormatter = DateFormat.getDateTimeInstance(dateStyle, timeStyle)
-
- private var oldAccountExpiry: DateTime? = null
-
- private var currentAccountExpiry: DateTime? = null
- set(value) {
- field = value
-
- synchronized(this) {
- if (value != oldAccountExpiry) {
- oldAccountExpiry = null
- }
- }
- }
-
- private var hasConnectivity = true
- set(value) {
- field = value
- accountManagementButton.isEnabled = value
- }
-
- private var isOffline = true
- set(value) {
- field = value
- redeemVoucherButton.setEnabled(!value)
- }
-
- private var isAccountNumberShown by
- observable(false) { _, _, doShow ->
- accountNumberView.informationState =
- if (doShow) {
- InformationView.Masking.Show(GroupedTransformationMethod())
- } else {
- InformationView.Masking.Hide(GroupedPasswordTransformationMethod())
- }
- }
-
- private lateinit var accountExpiryView: InformationView
- private lateinit var accountNumberView: CopyableInformationView
- private lateinit var deviceNameView: InformationView
- private lateinit var accountManagementButton: AccountManagementButton
- private lateinit var redeemVoucherButton: RedeemVoucherButton
- private lateinit var titleController: CollapsibleTitleController
-
- @Deprecated("Refactor code to instead rely on Lifecycle.") private val jobTracker = JobTracker()
-
- override fun onAttach(activity: Activity) {
- super.onAttach(activity)
- requireMainActivity().enterSecureScreen(this)
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- lifecycleScope.launchUiSubscriptionsOnResume()
- }
-
+ @OptIn(ExperimentalMaterial3Api::class)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
- ): View {
- val view = inflater.inflate(R.layout.account, container, false)
-
- view.findViewById<View>(R.id.close).setOnClickListener {
- requireMainActivity().onBackPressed()
- }
-
- accountManagementButton =
- view.findViewById<AccountManagementButton>(R.id.account_management).apply {
- setOnClickAction("openAccountPageInBrowser", jobTracker) {
- isEnabled = false
- serviceConnectionManager.authTokenCache()?.fetchAuthToken()?.let { token ->
- context.openAccountPageInBrowser(token)
+ ): View? {
+ return inflater.inflate(R.layout.fragment_compose, container, false).apply {
+ findViewById<ComposeView>(R.id.compose_view).setContent {
+ AppTheme {
+ val state = vm.uiState.collectAsState().value
+ AccountScreen(
+ uiState = state,
+ viewActions = vm.viewActions,
+ onRedeemVoucherClick = { openRedeemVoucherFragment() },
+ onManageAccountClick = vm::onManageAccountClick,
+ onLogoutClick = vm::onLogoutClick
+ ) {
+ activity?.onBackPressed()
}
- isEnabled = true
- checkForAddedTime()
}
}
- accountManagementButton.isVisible = BuildTypes.RELEASE != BuildConfig.BUILD_TYPE
-
- redeemVoucherButton =
- view.findViewById<RedeemVoucherButton>(R.id.redeem_voucher).apply {
- prepare(parentFragmentManager, jobTracker)
- }
-
- view.findViewById<Button>(R.id.logout).setOnClickAction("logout", jobTracker) {
- accountRepository.logout()
}
-
- accountNumberView =
- view.findViewById<CopyableInformationView>(R.id.account_number).apply {
- informationState =
- InformationView.Masking.Hide(GroupedPasswordTransformationMethod())
- onToggleMaskingClicked = { isAccountNumberShown = isAccountNumberShown.not() }
- }
-
- accountExpiryView = view.findViewById(R.id.account_expiry)
- deviceNameView = view.findViewById(R.id.device_name)
- titleController = CollapsibleTitleController(view)
-
- return view
- }
-
- override fun onResume() {
- super.onResume()
- paintNavigationBar(ContextCompat.getColor(requireContext(), R.color.darkBlue))
- }
-
- override fun onStop() {
- jobTracker.cancelAllJobs()
- super.onStop()
}
- override fun onDestroyView() {
- titleController.onDestroy()
- super.onDestroyView()
- }
-
- override fun onDetach() {
- requireMainActivity().leaveSecureScreen(this)
- super.onDetach()
- }
-
- private fun CoroutineScope.launchUiSubscriptionsOnResume() = launch {
- repeatOnLifecycle(Lifecycle.State.RESUMED) {
- launchUpdateTextOnDeviceChanges()
- launchUpdateTextOnExpiryChanges()
- launchTunnelStateSubscription()
- launchRefreshDeviceStateAfterAnimation()
- launchPaintStatusBarAfterTransition()
- }
- }
-
- private fun CoroutineScope.launchUpdateTextOnDeviceChanges() {
- launch {
- deviceRepository.deviceState
- .debounce {
- it.addDebounceForUnknownState(UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS)
- }
- .collect { state ->
- accountNumberView.information = state.token()
- deviceNameView.information = state.deviceName()?.capitalizeFirstCharOfEachWord()
- }
- }
- }
-
- private fun CoroutineScope.launchUpdateTextOnExpiryChanges() {
- launch {
- accountRepository.accountExpiryState
- .map { state -> state.date() }
- .collect { expiryDate ->
- currentAccountExpiry = expiryDate
- updateAccountExpiry(expiryDate)
- }
- }
- }
-
- private fun CoroutineScope.launchTunnelStateSubscription() {
- launch {
- serviceConnectionManager.connectionState
- .flatMapLatest { state ->
- if (state is ServiceConnectionState.ConnectedReady) {
- callbackFlowFromNotifier(state.container.connectionProxy.onUiStateChange)
- } else {
- emptyFlow()
- }
- }
- .collect { uiState ->
- hasConnectivity =
- uiState is TunnelState.Connected ||
- uiState is TunnelState.Disconnected ||
- (uiState is TunnelState.Error && !uiState.errorState.isBlocking)
- isOffline =
- uiState is TunnelState.Error &&
- uiState.errorState.cause is ErrorStateCause.IsOffline
- }
- }
- }
-
- private fun CoroutineScope.launchPaintStatusBarAfterTransition() = launch {
- transitionFinishedFlow.collect {
- paintStatusBar(ContextCompat.getColor(requireContext(), R.color.darkBlue))
- }
- }
-
- private fun CoroutineScope.launchRefreshDeviceStateAfterAnimation() = launch {
- transitionFinishedFlow.collect { deviceRepository.refreshDeviceState() }
- }
-
- private fun checkForAddedTime() {
- currentAccountExpiry?.let { expiry -> oldAccountExpiry = expiry }
- }
-
- private fun updateAccountExpiry(accountExpiry: DateTime?) {
- if (accountExpiry != null) {
- accountExpiryView.information = expiryFormatter.format(accountExpiry.toDate())
- } else {
- accountExpiryView.information = null
- accountRepository.fetchAccountExpiry()
- }
- }
-
- private fun showRedeemVoucherDialog() {
+ private fun openRedeemVoucherFragment() {
val transaction = parentFragmentManager.beginTransaction()
-
transaction.addToBackStack(null)
-
RedeemVoucherDialogFragment().show(transaction, null)
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountManagementButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountManagementButton.kt
deleted file mode 100644
index e9b7d170d7..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountManagementButton.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package net.mullvad.mullvadvpn.ui.widget
-
-import android.content.Context
-import android.util.AttributeSet
-import net.mullvad.mullvadvpn.R
-
-class AccountManagementButton : UrlButton {
- constructor(context: Context) : super(context)
-
- constructor(context: Context, attributes: AttributeSet) : super(context, attributes)
-
- constructor(
- context: Context,
- attributes: AttributeSet,
- defaultStyleAttribute: Int
- ) : super(context, attributes, defaultStyleAttribute)
-
- init {
- label = context.getString(R.string.manage_account)
- }
-}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/Cell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/Cell.kt
deleted file mode 100644
index b78c9bc14c..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/Cell.kt
+++ /dev/null
@@ -1,131 +0,0 @@
-package net.mullvad.mullvadvpn.ui.widget
-
-import android.content.Context
-import android.graphics.Typeface
-import android.util.AttributeSet
-import android.util.TypedValue
-import android.view.Gravity
-import android.widget.LinearLayout
-import android.widget.TextView
-import net.mullvad.mullvadvpn.R
-
-open class Cell : LinearLayout {
- private val label =
- TextView(context).apply {
- val rightPadding = resources.getDimensionPixelSize(R.dimen.cell_inner_spacing)
- val verticalPadding =
- resources.getDimensionPixelSize(R.dimen.cell_label_vertical_padding)
-
- layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1.0f)
- setPadding(0, verticalPadding, rightPadding, verticalPadding)
-
- setTextColor(context.getColor(R.color.white))
- setTextSize(
- TypedValue.COMPLEX_UNIT_PX,
- resources.getDimension(R.dimen.text_medium_plus)
- )
- setTypeface(null, Typeface.BOLD)
- }
-
- protected var footer: TextView? = null
- set(value) {
- field =
- value?.apply {
- val horizontalPadding =
- resources.getDimensionPixelSize(R.dimen.cell_footer_horizontal_padding)
- val topPadding =
- resources.getDimensionPixelSize(R.dimen.cell_footer_top_padding)
-
- layoutParams =
- LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
- setPadding(horizontalPadding, topPadding, horizontalPadding, 0)
-
- setTextColor(context.getColor(R.color.white60))
- setTextSize(
- TypedValue.COMPLEX_UNIT_PX,
- resources.getDimension(R.dimen.text_small)
- )
- }
- }
-
- protected var cell: LinearLayout = this
- set(value) {
- field =
- value.apply {
- val height = resources.getDimensionPixelSize(R.dimen.cell_height)
- val leftPadding = resources.getDimensionPixelSize(R.dimen.cell_left_padding)
- val rightPadding = resources.getDimensionPixelSize(R.dimen.cell_right_padding)
-
- setFocusable(true)
- isClickable = true
- gravity = Gravity.CENTER
- orientation = HORIZONTAL
- minimumHeight = height
-
- setBackgroundResource(R.drawable.cell_button_background)
- setPadding(leftPadding, 0, rightPadding, 0)
-
- addView(label)
-
- setOnClickListener { onClickListener?.invoke() }
- }
- }
-
- var onClickListener: (() -> Unit)? = null
-
- @JvmOverloads
- constructor(
- context: Context,
- attributes: AttributeSet? = null,
- defaultStyleAttribute: Int = 0,
- defaultStyleResource: Int = 0,
- footer: TextView? = null
- ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) {
- this.footer = footer
- loadAttributes(attributes)
- }
-
- private fun loadAttributes(attributes: AttributeSet?) {
- context.theme.obtainStyledAttributes(attributes, R.styleable.TextAttribute, 0, 0).apply {
- try {
- label.text = getString(R.styleable.TextAttribute_text) ?: ""
- } finally {
- recycle()
- }
- }
-
- context.theme.obtainStyledAttributes(attributes, R.styleable.Cell, 0, 0).apply {
- try {
- getString(R.styleable.Cell_footer)?.let { footerText ->
- if (footer == null) {
- footer = TextView(context)
- }
-
- footer?.text = footerText
- }
- } finally {
- recycle()
- }
- }
-
- setUp()
- }
-
- private fun setUp() {
- if (footer != null) {
- cell =
- LinearLayout(context).apply {
- layoutParams =
- LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
- }
-
- isClickable = false
- orientation = VERTICAL
-
- addView(cell)
- addView(footer)
- } else {
- cell = this
- }
- }
-}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CellSwitch.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CellSwitch.kt
deleted file mode 100644
index 8c2312044a..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CellSwitch.kt
+++ /dev/null
@@ -1,235 +0,0 @@
-package net.mullvad.mullvadvpn.ui.widget
-
-import android.animation.ValueAnimator
-import android.content.Context
-import android.graphics.Paint.Style
-import android.graphics.drawable.ShapeDrawable
-import android.graphics.drawable.shapes.OvalShape
-import android.util.AttributeSet
-import android.view.GestureDetector
-import android.view.GestureDetector.OnGestureListener
-import android.view.Gravity
-import android.view.MotionEvent
-import android.widget.ImageView
-import android.widget.LinearLayout
-import kotlin.properties.Delegates.observable
-import net.mullvad.mullvadvpn.R
-
-class CellSwitch : LinearLayout {
- enum class State {
- ON,
- OFF
- }
-
- var state by
- observable(State.OFF) { _, oldState, newState ->
- animateToState()
-
- if (oldState != newState) {
- listener?.invoke(newState)
- }
- }
-
- var listener: ((State) -> Unit)? = null
-
- private val onColor = context.getColor(R.color.green)
- private val offColor = context.getColor(R.color.red)
-
- private val knobSize = resources.getDimensionPixelSize(R.dimen.cell_switch_knob_size)
- private val knobImage =
- ShapeDrawable(OvalShape()).apply {
- paint.apply {
- color = offColor
- style = Style.FILL
- }
-
- intrinsicWidth = knobSize
- intrinsicHeight = knobSize
- }
-
- private val knobView = ImageView(context).apply { setImageDrawable(knobImage) }
-
- private val knobAnimationDuration = 200L
- private val knobMaxTranslation =
- resources.getDimensionPixelOffset(R.dimen.cell_switch_knob_max_translation).toFloat()
-
- private val knobPosition: Float
- get() = knobView.translationX / knobMaxTranslation
-
- private var animationIsReversed = false
-
- private val positionAnimation =
- ValueAnimator.ofFloat(0f, knobMaxTranslation).apply {
- addUpdateListener { animation ->
- knobView.translationX = animation.animatedValue as Float
- }
-
- duration = knobAnimationDuration
- }
-
- private val colorAnimation =
- ValueAnimator.ofArgb(offColor, onColor).apply {
- addUpdateListener { animation ->
- knobImage.paint.color = animation.animatedValue as Int
- knobImage.invalidateSelf()
- }
-
- duration = knobAnimationDuration
- }
-
- private val gestureListener =
- object : OnGestureListener {
- private var isScrolling: Boolean = false
- private var scrollPosition: Float = 0f
-
- override fun onDown(event: MotionEvent): Boolean {
- scrollPosition = knobView.translationX
- return true
- }
-
- override fun onFling(
- downEvent: MotionEvent,
- upEvent: MotionEvent,
- velocityX: Float,
- velocityY: Float
- ): Boolean {
- if (velocityX > 0f) {
- state = State.ON
- } else if (velocityX < 0f) {
- state = State.OFF
- }
-
- return true
- }
-
- override fun onLongPress(event: MotionEvent) {}
-
- override fun onScroll(
- downEvent: MotionEvent,
- moveEvent: MotionEvent,
- distanceX: Float,
- distanceY: Float
- ): Boolean {
- isScrolling = true
- scrollPosition -= distanceX
-
- var fraction = scrollPosition / knobMaxTranslation
- val playTime = (fraction * knobAnimationDuration).toLong()
-
- colorAnimation.pause()
- positionAnimation.pause()
-
- colorAnimation.currentPlayTime = playTime
- positionAnimation.currentPlayTime = playTime
-
- return true
- }
-
- override fun onShowPress(event: MotionEvent) {}
-
- override fun onSingleTapUp(event: MotionEvent): Boolean {
- when (state) {
- State.ON -> state = State.OFF
- State.OFF -> state = State.ON
- }
-
- return true
- }
-
- fun onUp(): Boolean {
- if (!isScrolling) {
- return false
- }
-
- if (knobPosition <= 0.5f) {
- state = State.OFF
- } else {
- state = State.ON
- }
-
- isScrolling = false
- scrollPosition = 0f
-
- return true
- }
- }
-
- private val gestureDetector = GestureDetector(context, gestureListener)
-
- constructor(context: Context) : super(context)
-
- constructor(context: Context, attributes: AttributeSet) : super(context, attributes)
-
- constructor(
- context: Context,
- attributes: AttributeSet,
- defaultStyleAttribute: Int
- ) : super(context, attributes, defaultStyleAttribute)
-
- init {
- setBackground(resources.getDrawable(R.drawable.cell_switch_background, null))
- addView(
- knobView,
- LinearLayout.LayoutParams(knobSize, knobSize).apply {
- gravity = Gravity.CENTER_VERTICAL
- leftMargin = resources.getDimensionPixelSize(R.dimen.cell_switch_knob_margin)
- }
- )
- }
-
- override fun onTouchEvent(event: MotionEvent): Boolean {
- if (gestureDetector.onTouchEvent(event)) {
- return true
- } else if (event.actionMasked == MotionEvent.ACTION_UP) {
- return gestureListener.onUp()
- }
-
- return super.onTouchEvent(event)
- }
-
- fun toggle() {
- when (state) {
- State.ON -> state = State.OFF
- State.OFF -> state = State.ON
- }
- }
-
- fun forcefullySetState(newState: State) {
- when (newState) {
- State.ON -> {
- knobView.translationX = knobMaxTranslation
- knobImage.paint.color = onColor
- }
- State.OFF -> {
- knobView.translationX = 0f
- knobImage.paint.color = offColor
- }
- }
-
- state = newState
- }
-
- private fun animateToState() {
- var playTime = (knobPosition * knobAnimationDuration).toLong()
-
- when (state) {
- State.ON -> {
- animationIsReversed = false
- colorAnimation.start()
- positionAnimation.start()
- }
- State.OFF -> {
- if (!animationIsReversed || !colorAnimation.isRunning()) {
- animationIsReversed = true
- colorAnimation.reverse()
- positionAnimation.reverse()
- }
-
- playTime = knobAnimationDuration - playTime
- }
- }
-
- colorAnimation.currentPlayTime = playTime
- positionAnimation.currentPlayTime = playTime
- }
-}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CopyableInformationView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CopyableInformationView.kt
deleted file mode 100644
index dabc1fb218..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CopyableInformationView.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-package net.mullvad.mullvadvpn.ui.widget
-
-import android.content.ClipData
-import android.content.ClipboardManager
-import android.content.Context
-import android.util.AttributeSet
-import android.view.View
-import android.widget.ImageButton
-import android.widget.Toast
-import net.mullvad.mullvadvpn.R
-
-class CopyableInformationView : InformationView {
- var clipboardLabel: String? = null
-
- var copiedToast: String? = null
-
- constructor(context: Context) : super(context) {}
-
- constructor(context: Context, attributes: AttributeSet) : super(context, attributes) {
- loadAttributes(attributes)
- }
-
- constructor(
- context: Context,
- attributes: AttributeSet,
- defaultStyleAttribute: Int
- ) : super(context, attributes, defaultStyleAttribute) {
- loadAttributes(attributes)
- }
-
- constructor(
- context: Context,
- attributes: AttributeSet,
- defaultStyleAttribute: Int,
- defaultStyleResource: Int
- ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) {
- loadAttributes(attributes)
- }
-
- init {
- findViewById<ImageButton>(R.id.copy_button).apply {
- visibility = View.VISIBLE
- setOnClickListener { copyToClipboard() }
- }
- shouldEnable = false
- }
-
- private fun loadAttributes(attributes: AttributeSet) {
- val styleableId = R.styleable.CopyableInformationView
-
- context.theme.obtainStyledAttributes(attributes, styleableId, 0, 0).apply {
- try {
- clipboardLabel = getString(R.styleable.CopyableInformationView_clipboardLabel)
- copiedToast = getString(R.styleable.CopyableInformationView_copiedToast)
- } finally {
- recycle()
- }
- }
- }
-
- private fun copyToClipboard() {
- val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
- val clipData = ClipData.newPlainText(clipboardLabel, information)
- val toastMessage = copiedToast ?: context.getString(R.string.copied_to_clipboard)
-
- clipboard.setPrimaryClip(clipData)
-
- Toast.makeText(context, toastMessage, Toast.LENGTH_SHORT).show()
- }
-}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CustomItemAnimator.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CustomItemAnimator.kt
deleted file mode 100644
index ef8caf7e7c..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CustomItemAnimator.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package net.mullvad.mullvadvpn.ui.widget
-
-import androidx.recyclerview.widget.DefaultItemAnimator
-import androidx.recyclerview.widget.RecyclerView.LayoutManager
-import androidx.recyclerview.widget.RecyclerView.ViewHolder
-import kotlin.math.round
-
-class CustomItemAnimator : DefaultItemAnimator() {
- var layoutManager: LayoutManager? = null
-
- var onMove: ((Int, Int) -> Unit)? = null
-
- override fun animateMove(
- holder: ViewHolder,
- fromX: Int,
- fromY: Int,
- toX: Int,
- toY: Int
- ): Boolean {
- if (super.animateMove(holder, fromX, fromY, toX, toY)) {
- var view = holder.itemView
-
- if (view == layoutManager?.getChildAt(0)) {
- var translationX = view.translationX
- var translationY = view.translationY
-
- view.animate().setUpdateListener { _ ->
- val deltaX = round(translationX - view.translationX)
- val deltaY = round(translationY - view.translationY)
-
- onMove?.invoke(deltaX.toInt(), deltaY.toInt())
-
- translationX -= deltaX
- translationY -= deltaY
- }
- }
-
- return true
- } else {
- return false
- }
- }
-}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CustomRecyclerView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CustomRecyclerView.kt
deleted file mode 100644
index 902f6c6acb..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CustomRecyclerView.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-package net.mullvad.mullvadvpn.ui.widget
-
-import android.content.Context
-import android.util.AttributeSet
-import androidx.recyclerview.widget.RecyclerView
-import net.mullvad.mullvadvpn.util.ListenableScrollableView
-
-class CustomRecyclerView : RecyclerView, ListenableScrollableView {
- private val customItemAnimator = CustomItemAnimator()
-
- override var horizontalScrollOffset = 0
- override var verticalScrollOffset = 0
-
- override var onScrollListener: ((Int, Int, Int, Int) -> Unit)? = null
-
- constructor(context: Context) : super(context)
-
- constructor(context: Context, attributes: AttributeSet) : super(context, attributes)
-
- constructor(
- context: Context,
- attributes: AttributeSet,
- defaultStyleAttribute: Int
- ) : super(context, attributes, defaultStyleAttribute)
-
- init {
- itemAnimator =
- customItemAnimator.apply {
- onMove = { horizontalDelta, verticalDelta ->
- dispatchScrollEvent(horizontalDelta, verticalDelta)
- }
- }
- }
-
- override fun setLayoutManager(layoutManager: LayoutManager?) {
- super.setLayoutManager(layoutManager)
-
- customItemAnimator.layoutManager = layoutManager
- }
-
- override fun onScrolled(horizontalDelta: Int, verticalDelta: Int) {
- super.onScrolled(horizontalDelta, verticalDelta)
-
- dispatchScrollEvent(horizontalDelta, verticalDelta)
- }
-
- private fun dispatchScrollEvent(horizontalDelta: Int, verticalDelta: Int) {
- val oldHorizontalScrollOffset = horizontalScrollOffset
- val oldVerticalScrollOffset = verticalScrollOffset
-
- horizontalScrollOffset += horizontalDelta
- verticalScrollOffset += verticalDelta
-
- onScrollListener?.invoke(
- horizontalScrollOffset,
- verticalScrollOffset,
- oldHorizontalScrollOffset,
- oldVerticalScrollOffset
- )
- }
-}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/InformationView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/InformationView.kt
deleted file mode 100644
index b7547a8761..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/InformationView.kt
+++ /dev/null
@@ -1,200 +0,0 @@
-package net.mullvad.mullvadvpn.ui.widget
-
-import android.content.Context
-import android.text.method.TransformationMethod
-import android.util.AttributeSet
-import android.util.TypedValue
-import android.view.LayoutInflater
-import android.view.View
-import android.widget.LinearLayout
-import android.widget.TextView
-import androidx.appcompat.content.res.AppCompatResources
-import kotlin.properties.Delegates.observable
-import net.mullvad.mullvadvpn.R
-
-open class InformationView : LinearLayout {
- enum class WhenMissing {
- Nothing,
- Hide,
- ShowSpinner;
-
- companion object {
- internal fun fromCode(code: Int): WhenMissing {
- when (code) {
- 0 -> return Nothing
- 1 -> return Hide
- 2 -> return ShowSpinner
- else -> throw Exception("Invalid whenMissing attribute value")
- }
- }
- }
- }
-
- private val container: View =
- context.getSystemService(Context.LAYOUT_INFLATER_SERVICE).let { service ->
- val inflater = service as LayoutInflater
-
- inflater.inflate(R.layout.information_view, this).apply {
- setOnClickListener { onClick?.invoke() }
- setEnabled(false)
- }
- }
-
- private val description: TextView = findViewById(R.id.description)
- private val informationDisplay: TextView = findViewById(R.id.information_display)
- private val spinner: View = findViewById(R.id.spinner)
- private val toggleMaskingButton: View = findViewById(R.id.toggle_masking_button)
-
- var error by observable<String?>(null) { _, _, _ -> updateStatus() }
- var information by observable<String?>(null) { _, _, _ -> updateStatus() }
-
- var errorColor by observable(context.getColor(R.color.red)) { _, _, _ -> updateStatus() }
- var informationColor by
- observable(context.getColor(R.color.white)) { _, _, _ -> updateStatus() }
-
- var maxLength by observable(0) { _, _, _ -> updateStatus() }
- var whenMissing by observable(WhenMissing.Nothing) { _, _, _ -> updateStatus() }
-
- var shouldEnable by observable(false) { _, _, _ -> updateEnabled() }
-
- var onClick by
- observable<(() -> Unit)?>(null) { _, _, callback ->
- container.setFocusable(callback != null)
- }
-
- sealed class Masking {
- object None : Masking()
- data class Hide(val transformationMethod: TransformationMethod) : Masking()
- data class Show(val transformationMethod: TransformationMethod) : Masking()
- }
-
- var informationState by
- observable<Masking>(Masking.None) { _, _, newState ->
- when (newState) {
- is Masking.Hide -> {
- informationDisplay.transformationMethod = newState.transformationMethod
-
- toggleMaskingButton.apply {
- visibility = VISIBLE
- contentDescription = context.getString(R.string.show_account_number)
- background = AppCompatResources.getDrawable(context, R.drawable.icon_show)
- }
- }
- is Masking.Show -> {
- informationDisplay.transformationMethod = newState.transformationMethod
-
- toggleMaskingButton.apply {
- visibility = VISIBLE
- contentDescription = context.getString(R.string.hide_account_number)
- background = AppCompatResources.getDrawable(context, R.drawable.icon_hide)
- }
- }
- is Masking.None -> {
- informationDisplay.transformationMethod = null
- toggleMaskingButton.visibility = INVISIBLE
- }
- }
-
- updateStatus()
- }
-
- var onToggleMaskingClicked by
- observable<(() -> Unit)?>(null) { _, _, callback ->
- toggleMaskingButton.setOnClickListener { callback?.invoke() }
- }
-
- constructor(context: Context) : super(context) {}
-
- constructor(context: Context, attributes: AttributeSet) : super(context, attributes) {
- loadAttributes(attributes)
- }
-
- constructor(
- context: Context,
- attributes: AttributeSet,
- defaultStyleAttribute: Int
- ) : super(context, attributes, defaultStyleAttribute) {
- loadAttributes(attributes)
- }
-
- constructor(
- context: Context,
- attributes: AttributeSet,
- defaultStyleAttribute: Int,
- defaultStyleResource: Int
- ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) {
- loadAttributes(attributes)
- }
-
- init {
- val backgroundResource = TypedValue()
-
- context.theme.resolveAttribute(
- android.R.attr.selectableItemBackground,
- backgroundResource,
- true
- )
-
- orientation = VERTICAL
- setBackgroundResource(backgroundResource.resourceId)
- }
-
- private fun loadAttributes(attributes: AttributeSet) {
- val styleableId = R.styleable.InformationView
-
- context.theme.obtainStyledAttributes(attributes, styleableId, 0, 0).apply {
- try {
- description.text = getString(R.styleable.InformationView_description) ?: ""
-
- errorColor = getInteger(R.styleable.InformationView_errorColor, errorColor)
- maxLength = getInteger(R.styleable.InformationView_maxLength, 0)
-
- informationColor =
- getInteger(R.styleable.InformationView_informationColor, informationColor)
-
- whenMissing =
- WhenMissing.fromCode(getInteger(R.styleable.InformationView_whenMissing, 0))
- } finally {
- recycle()
- }
- }
- }
-
- private fun updateStatus() {
- val information = this.information
- val hasText = information != null || error != null
-
- if (error != null) {
- informationDisplay.setTextColor(errorColor)
- informationDisplay.text = error
- } else if (information != null) {
- informationDisplay.setTextColor(informationColor)
-
- if (maxLength == 0 || information.length <= maxLength) {
- informationDisplay.text = information
- } else {
- informationDisplay.text = information.substring(0, maxLength) + "..."
- }
- }
-
- if (whenMissing == WhenMissing.Hide && !hasText) {
- visibility = INVISIBLE
- } else {
- visibility = VISIBLE
- }
-
- if (whenMissing == WhenMissing.ShowSpinner && !hasText) {
- spinner.visibility = VISIBLE
- informationDisplay.visibility = INVISIBLE
- } else {
- spinner.visibility = INVISIBLE
- informationDisplay.visibility = VISIBLE
- }
-
- updateEnabled()
- }
-
- private fun updateEnabled() {
- setEnabled(shouldEnable && error == null && information != null)
- }
-}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ToggleCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ToggleCell.kt
deleted file mode 100644
index f47347539f..0000000000
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/ToggleCell.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-package net.mullvad.mullvadvpn.ui.widget
-
-import android.content.Context
-import android.util.AttributeSet
-
-class ToggleCell : Cell {
- private val toggle =
- CellSwitch(context).apply {
- layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT, 0.0f)
- }
-
- var state
- get() = toggle.state
- set(value) {
- toggle.state = value
- }
-
- var listener
- get() = toggle.listener
- set(value) {
- toggle.listener = value
- }
-
- constructor(context: Context) : super(context)
-
- constructor(context: Context, attributes: AttributeSet) : super(context, attributes)
-
- constructor(
- context: Context,
- attributes: AttributeSet,
- defaultStyleAttribute: Int
- ) : super(context, attributes, defaultStyleAttribute)
-
- init {
- onClickListener = { toggle() }
- cell.addView(toggle)
- }
-
- fun toggle() {
- toggle.toggle()
- }
-
- fun forcefullySetState(state: CellSwitch.State) {
- toggle.forcefullySetState(state)
- }
-}
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 614c758794..d3be3e09aa 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,10 @@
package net.mullvad.mullvadvpn.util
+import java.text.DateFormat
import org.joda.time.DateTime
import org.joda.time.format.ISODateTimeFormat
fun DateTime.formatDate(): String = ISODateTimeFormat.date().print(this)
+
+fun DateTime.toExpiryDateString(): String =
+ DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(this.toDate())
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt
new file mode 100644
index 0000000000..79d7ae5428
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt
@@ -0,0 +1,65 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.compose.state.AccountUiState
+import net.mullvad.mullvadvpn.repository.AccountRepository
+import net.mullvad.mullvadvpn.repository.DeviceRepository
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
+import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache
+
+class AccountViewModel(
+ private var accountRepository: AccountRepository,
+ private var serviceConnectionManager: ServiceConnectionManager,
+ deviceRepository: DeviceRepository
+) : ViewModel() {
+
+ private val _viewActions = MutableSharedFlow<ViewAction>(extraBufferCapacity = 1)
+ val viewActions = _viewActions.asSharedFlow()
+
+ private val vmState: StateFlow<AccountUiState> =
+ combine(deviceRepository.deviceState, accountRepository.accountExpiryState) {
+ deviceState,
+ accountExpiry ->
+ AccountUiState(
+ deviceName = deviceState.deviceName() ?: "",
+ accountNumber = deviceState.token() ?: "",
+ accountExpiry = accountExpiry.date()
+ )
+ }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ AccountUiState(deviceName = "", accountNumber = "", accountExpiry = null)
+ )
+ val uiState =
+ vmState.stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(),
+ AccountUiState(deviceName = "", accountNumber = "", accountExpiry = null)
+ )
+
+ fun onManageAccountClick() {
+ viewModelScope.launch {
+ _viewActions.tryEmit(
+ ViewAction.OpenAccountView(
+ serviceConnectionManager.authTokenCache()?.fetchAuthToken() ?: ""
+ )
+ )
+ }
+ }
+ fun onLogoutClick() {
+ accountRepository.logout()
+ }
+
+ sealed class ViewAction {
+ data class OpenAccountView(val token: String) : ViewAction()
+ }
+}
diff --git a/android/app/src/main/res/layout/account.xml b/android/app/src/main/res/layout/account.xml
deleted file mode 100644
index 8d896b2223..0000000000
--- a/android/app/src/main/res/layout/account.xml
+++ /dev/null
@@ -1,88 +0,0 @@
-<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:mullvad="http://schemas.android.com/apk/res-auto"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:background="@color/darkBlue"
- android:gravity="start">
- <TextView android:id="@+id/title"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text="@string/settings_account"
- style="@style/SettingsCollapsedHeader" />
- <LinearLayout android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:orientation="vertical">
- <FrameLayout android:layout_width="match_parent"
- android:layout_height="wrap_content">
- <ImageButton android:id="@+id/close"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:padding="12dp"
- android:background="?android:attr/selectableItemBackground"
- android:src="@drawable/icon_close"
- android:contentDescription="@string/back" />
- <TextView android:id="@+id/collapsed_title"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginHorizontal="4dp"
- android:layout_gravity="center"
- android:text="@string/settings_account"
- style="@style/SettingsCollapsedHeader" />
- </FrameLayout>
- <net.mullvad.mullvadvpn.ui.widget.ListenableScrollView android:id="@+id/scroll_area"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:fillViewport="true">
- <LinearLayout android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:layout_marginTop="2dp"
- android:orientation="vertical">
- <TextView android:id="@+id/expanded_title"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginBottom="@dimen/half_vertical_space"
- android:layout_marginHorizontal="@dimen/side_margin"
- android:lines="1"
- android:text="@string/settings_account"
- style="@style/SettingsExpandedHeader" />
- <net.mullvad.mullvadvpn.ui.widget.InformationView android:id="@+id/device_name"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:paddingHorizontal="@dimen/side_margin"
- android:paddingVertical="@dimen/half_vertical_space"
- mullvad:description="@string/device_name"
- mullvad:whenMissing="showSpinner" />
- <net.mullvad.mullvadvpn.ui.widget.CopyableInformationView android:id="@+id/account_number"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:paddingHorizontal="@dimen/side_margin"
- android:paddingVertical="@dimen/half_vertical_space"
- mullvad:clipboardLabel="@string/mullvad_account_number"
- mullvad:copiedToast="@string/copied_mullvad_account_number"
- mullvad:description="@string/account_number"
- mullvad:whenMissing="showSpinner"
- android:descendantFocusability="afterDescendants" />
- <net.mullvad.mullvadvpn.ui.widget.InformationView android:id="@+id/account_expiry"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginBottom="@dimen/half_vertical_space"
- android:paddingHorizontal="@dimen/side_margin"
- android:paddingVertical="@dimen/half_vertical_space"
- mullvad:description="@string/paid_until"
- mullvad:whenMissing="showSpinner" />
- <Space android:layout_width="match_parent"
- android:layout_height="0dp"
- android:layout_weight="1" />
- <include layout="@layout/account_buttons" />
- <net.mullvad.mullvadvpn.ui.widget.Button android:id="@+id/logout"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginHorizontal="@dimen/side_margin"
- android:layout_marginTop="@dimen/button_separation"
- android:layout_marginBottom="@dimen/screen_vertical_margin"
- mullvad:text="@string/log_out"
- mullvad:buttonColor="red" />
- </LinearLayout>
- </net.mullvad.mullvadvpn.ui.widget.ListenableScrollView>
- </LinearLayout>
-</FrameLayout>
diff --git a/android/app/src/main/res/layout/account_buttons.xml b/android/app/src/main/res/layout/account_buttons.xml
deleted file mode 100644
index 13d7883995..0000000000
--- a/android/app/src/main/res/layout/account_buttons.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-<merge xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:mullvad="http://schemas.android.com/apk/res-auto">
- <net.mullvad.mullvadvpn.ui.widget.AccountManagementButton android:id="@+id/account_management"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginHorizontal="@dimen/side_margin"
- mullvad:buttonColor="green" />
- <net.mullvad.mullvadvpn.ui.widget.RedeemVoucherButton android:id="@+id/redeem_voucher"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginTop="@dimen/button_separation"
- android:layout_marginHorizontal="@dimen/side_margin"
- mullvad:buttonColor="green"
- mullvad:text="@string/redeem_voucher" />
-</merge>
diff --git a/android/app/src/main/res/layout/information_view.xml b/android/app/src/main/res/layout/information_view.xml
deleted file mode 100644
index eb94d7542b..0000000000
--- a/android/app/src/main/res/layout/information_view.xml
+++ /dev/null
@@ -1,64 +0,0 @@
-<merge xmlns:android="http://schemas.android.com/apk/res/android">
- <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:app="http://schemas.android.com/apk/res-auto"
- android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:orientation="vertical">
- <TextView android:id="@+id/description"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:text=""
- android:textColor="@color/white60"
- android:textSize="@dimen/text_small"
- android:textStyle="bold"
- app:layout_constraintTop_toTopOf="parent"
- app:layout_constraintStart_toStartOf="parent" />
- <TextView android:id="@+id/information_display"
- android:layout_width="0dp"
- android:layout_height="wrap_content"
- android:text=""
- android:textColor="@color/white"
- android:textStyle="bold"
- android:maxLines="1"
- android:autoSizeTextType="uniform"
- android:autoSizeMinTextSize="8dp"
- android:layout_marginTop="9dp"
- android:layout_marginEnd="@dimen/information_action_margin"
- app:layout_constraintTop_toBottomOf="@id/description"
- app:layout_constraintStart_toStartOf="parent" />
- <ProgressBar android:id="@+id/spinner"
- android:layout_width="@dimen/information_icon_size"
- android:layout_height="@dimen/information_icon_size"
- android:indeterminate="true"
- android:indeterminateDrawable="@drawable/icon_spinner"
- android:indeterminateDuration="600"
- android:indeterminateOnly="true"
- android:visibility="visible"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/description"
- app:layout_constraintBottom_toBottomOf="parent"
- android:layout_alignEnd="@id/copy_button" />
- <ImageButton android:id="@+id/toggle_masking_button"
- android:layout_width="@dimen/information_icon_size"
- android:layout_height="@dimen/information_icon_size"
- android:background="@drawable/icon_show"
- android:visibility="invisible"
- android:focusable="true"
- android:contentDescription="@string/show_account_number"
- android:layout_marginEnd="@dimen/information_action_margin"
- app:layout_constraintTop_toTopOf="@id/information_display"
- app:layout_constraintBottom_toBottomOf="@id/information_display"
- app:layout_constraintEnd_toStartOf="@id/copy_button" />
- <ImageButton android:id="@+id/copy_button"
- android:layout_width="@dimen/information_icon_size"
- android:layout_height="@dimen/information_icon_size"
- android:background="@drawable/icon_copy"
- android:visibility="invisible"
- android:clickable="true"
- android:focusable="true"
- android:contentDescription="@string/copy_account_number"
- app:layout_constraintTop_toTopOf="@id/information_display"
- app:layout_constraintBottom_toBottomOf="@id/information_display"
- app:layout_constraintEnd_toEndOf="parent" />
- </androidx.constraintlayout.widget.ConstraintLayout>
-</merge>
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt
new file mode 100644
index 0000000000..e5a3f2b397
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt
@@ -0,0 +1,97 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import app.cash.turbine.test
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkAll
+import io.mockk.verify
+import kotlin.test.assertEquals
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.TestCoroutineRule
+import net.mullvad.mullvadvpn.model.AccountAndDevice
+import net.mullvad.mullvadvpn.model.AccountExpiry
+import net.mullvad.mullvadvpn.model.Device
+import net.mullvad.mullvadvpn.model.DeviceState
+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.ServiceConnectionManager
+import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class AccountViewModelTest {
+ @get:Rule val testCoroutineRule = TestCoroutineRule()
+
+ private val mockAccountRepository: AccountRepository = mockk(relaxUnitFun = true)
+ private val mockServiceConnectionManager: ServiceConnectionManager = mockk()
+ private val mockDeviceRepository: DeviceRepository = mockk()
+ private val mockAuthTokenCache: AuthTokenCache = mockk()
+
+ private val deviceState: MutableStateFlow<DeviceState> = MutableStateFlow(DeviceState.Initial)
+ private val accountExpiryState = MutableStateFlow(AccountExpiry.Missing)
+
+ private val dummyAccountAndDevice: AccountAndDevice =
+ AccountAndDevice(
+ DUMMY_DEVICE_NAME,
+ Device(
+ id = "fake_id",
+ name = "fake_name",
+ pubkey = byteArrayOf(),
+ ports = ArrayList(),
+ created = "mock_date"
+ )
+ )
+
+ private lateinit var viewModel: AccountViewModel
+
+ @Before
+ fun setUp() {
+ mockkStatic(CACHE_EXTENSION_CLASS)
+ every { mockServiceConnectionManager.authTokenCache() } returns mockAuthTokenCache
+ every { mockDeviceRepository.deviceState } returns deviceState
+ every { mockAccountRepository.accountExpiryState } returns accountExpiryState
+
+ viewModel =
+ AccountViewModel(
+ accountRepository = mockAccountRepository,
+ serviceConnectionManager = mockServiceConnectionManager,
+ deviceRepository = mockDeviceRepository
+ )
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun testAccountLoggedInState() = runTest {
+ // Act, Assert
+ viewModel.uiState.test {
+ var result = awaitItem()
+ assertEquals("", result.deviceName)
+ deviceState.value = DeviceState.LoggedIn(accountAndDevice = dummyAccountAndDevice)
+ result = awaitItem()
+ assertEquals(DUMMY_DEVICE_NAME, result.accountNumber)
+ }
+ }
+
+ @Test
+ fun testOnLogoutClick() {
+ // Act
+ viewModel.onLogoutClick()
+
+ // Assert
+ verify { mockAccountRepository.logout() }
+ }
+
+ companion object {
+ private const val CACHE_EXTENSION_CLASS = "net.mullvad.mullvadvpn.util.CacheExtensionsKt"
+ private const val DUMMY_DEVICE_NAME = "fake_name"
+ }
+}