diff options
Diffstat (limited to 'android/app/src')
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" + } +} |
