diff options
| author | Albin <albin@mullvad.net> | 2022-04-20 16:55:21 +0200 |
|---|---|---|
| committer | Albin <albin@mullvad.net> | 2022-04-20 16:55:21 +0200 |
| commit | a9f906b082f76410855f0b97e71f45ca60bfa727 (patch) | |
| tree | e566f028e921806334173781acedd4a1942035d2 /android | |
| parent | 2deef36bfb89725bdc3ca78af21c88a312bd9ccd (diff) | |
| parent | 3c8659677155bdc9d4d21392142e139e0fcf7f32 (diff) | |
| download | mullvadvpn-a9f906b082f76410855f0b97e71f45ca60bfa727.tar.xz mullvadvpn-a9f906b082f76410855f0b97e71f45ca60bfa727.zip | |
Merge branch 'refactor-android-login-to-use-vm'
Diffstat (limited to 'android')
15 files changed, 496 insertions, 120 deletions
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index adeecc4c5a..173c9c86b7 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -179,6 +179,7 @@ dependencies { testImplementation(Dependencies.KotlinX.coroutinesTest) testImplementation(Dependencies.MockK.core) testImplementation(Dependencies.junit) + testImplementation(Dependencies.turbine) // UI test dependencies debugImplementation(Dependencies.AndroidX.fragmentTestning) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceState.kt index 04b87a87cd..a4fd52cabb 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceState.kt @@ -17,6 +17,10 @@ sealed class DeviceState : Parcelable { return this is InitialState } + fun deviceName(): String? { + return (this as? DeviceRegistered)?.deviceConfig?.device?.name + } + fun token(): String? { return (this as? DeviceRegistered)?.deviceConfig?.token } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AccountCache.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AccountCache.kt index 435c6bd220..ea72a8379a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AccountCache.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AccountCache.kt @@ -206,6 +206,7 @@ class AccountCache(private val endpoint: ServiceEndpoint) { private suspend fun doLogout() { daemon.await().logoutAccount() loginStatus = null + fetchAccountHistory() } private fun fetchAccountHistory() { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt index 00fa67059c..d94a88ee3c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt @@ -15,6 +15,7 @@ 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.ui.widget.SitePaymentButton +import net.mullvad.mullvadvpn.util.capitalizeFirstCharOfEachWord import net.mullvad.talpid.tunnel.ErrorStateCause import org.joda.time.DateTime @@ -52,6 +53,7 @@ class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) { private lateinit var accountExpiryView: InformationView private lateinit var accountNumberView: CopyableInformationView + private lateinit var deviceNameView: InformationView private lateinit var sitePaymentButton: SitePaymentButton private lateinit var redeemVoucherButton: RedeemVoucherButton private lateinit var titleController: CollapsibleTitleController @@ -88,7 +90,7 @@ class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) { } accountExpiryView = view.findViewById(R.id.account_expiry) - + deviceNameView = view.findViewById(R.id.device_name) titleController = CollapsibleTitleController(view) return view @@ -105,6 +107,16 @@ class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) { } } + jobTracker.newUiJob("updateDeviceName") { + deviceRepository.deviceState + .onEach { state -> + if (state.isInitialState()) deviceRepository.refreshDeviceState() + } + .collect { state -> + deviceNameView.information = state.deviceName()?.capitalizeFirstCharOfEachWord() + } + } + accountCache.onAccountExpiryChange.subscribe(this) { accountExpiry -> jobTracker.newUiJob("updateAccountExpiry") { currentAccountExpiry = accountExpiry diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LaunchFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LaunchFragment.kt index 5f0e417f58..1d1065f81d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LaunchFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LaunchFragment.kt @@ -5,7 +5,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.flow.onEach import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.model.DeviceState import net.mullvad.mullvadvpn.ui.serviceconnection.DeviceRepository @@ -39,7 +39,9 @@ class LaunchFragment : ServiceAwareFragment() { private fun advanceToNextScreen(deviceRepository: DeviceRepository) { jobTracker.newUiJob("advanceToNextScreen") { deviceRepository.deviceState - .onSubscription { deviceRepository.refreshDeviceState() } + .onEach { state -> + if (state.isInitialState()) deviceRepository.refreshDeviceState() + } .first { state -> state.isInitialState().not() } .let { deviceState -> when (deviceState) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginFragment.kt index 4444812179..91fec93d87 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginFragment.kt @@ -8,22 +8,24 @@ import android.view.ViewGroup import android.widget.ScrollView import android.widget.TextView import androidx.core.content.ContextCompat -import kotlinx.coroutines.delay +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.model.LoginResult -import net.mullvad.mullvadvpn.model.LoginStatus +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnection import net.mullvad.mullvadvpn.ui.widget.AccountLogin -import net.mullvad.mullvadvpn.ui.widget.Button +import net.mullvad.mullvadvpn.ui.widget.HeaderBar +import net.mullvad.mullvadvpn.viewmodel.LoginViewModel -class LoginFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen), NavigationBarPainter { - companion object { - private enum class State { - Starting, - Idle, - LoggingIn, - CreatingAccount, - } - } +class LoginFragment : + ServiceDependentFragment(OnNoService.GoToLaunchScreen), + NavigationBarPainter { + + private lateinit var loginViewModel: LoginViewModel private lateinit var title: TextView private lateinit var subtitle: TextView @@ -33,9 +35,7 @@ class LoginFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen), Na private lateinit var accountLogin: AccountLogin private lateinit var scrollArea: ScrollView private lateinit var background: View - - private var loginStatus: LoginStatus? = null - private var state = State.Starting + private lateinit var headerBar: HeaderBar override fun onSafelyCreateView( inflater: LayoutInflater, @@ -44,19 +44,25 @@ class LoginFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen), Na ): View { val view = inflater.inflate(R.layout.login, container, false) + headerBar = view.findViewById(R.id.header_bar) title = view.findViewById(R.id.title) subtitle = view.findViewById(R.id.subtitle) loggingInStatus = view.findViewById(R.id.logging_in_status) loggedInStatus = view.findViewById(R.id.logged_in_status) loginFailStatus = view.findViewById(R.id.login_fail_status) + val factory = LoginViewModel.Factory(requireActivity().application) + loginViewModel = ViewModelProvider(this, factory)[LoginViewModel::class.java].apply { + updateAccountCacheInstance(accountCache) + } + accountLogin = view.findViewById<AccountLogin>(R.id.account_login).apply { - onLogin = { accountToken -> login(accountToken) } - onClearHistory = { -> accountCache.clearAccountHistory() } + onLogin = loginViewModel::login + onClearHistory = loginViewModel::clearAccountHistory } - view.findViewById<Button>(R.id.create_account) - .setOnClickAction("createAccount", jobTracker) { createAccount() } + view.findViewById<net.mullvad.mullvadvpn.ui.widget.Button>(R.id.create_account) + .setOnClickAction("createAccount", jobTracker, loginViewModel::createAccount) scrollArea = view.findViewById(R.id.scroll_area) @@ -69,33 +75,26 @@ class LoginFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen), Na return view } - override fun onSafelyStart() { - accountLogin.state = LoginState.Initial + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setupLifecycleSubscriptionsToViewModel() + } - accountCache.onAccountHistoryChange.subscribe(this) { history -> - jobTracker.newUiJob("updateHistory") { - accountLogin.accountHistory = history - } + override fun onNewServiceConnection(serviceConnection: ServiceConnection) { + super.onNewServiceConnection(serviceConnection) + if (this::loginViewModel.isInitialized) { + loginViewModel.updateAccountCacheInstance(accountCache) } + } - accountCache.onLoginStatusChange.subscribe(this, false) { status -> - jobTracker.newUiJob("updateLoginStatus") { - loginStatus = status - - if (status == null && state == State.CreatingAccount) { - loginFailure(null) - } else if (status?.loginResult != LoginResult.Ok) { - loginFailure(status?.loginResult) - } else { - if (state == State.Starting) { - openNextScreen() - } else { - loggedIn() - } - } - } + override fun onNoServiceConnection() { + super.onNoServiceConnection() + if (this::loginViewModel.isInitialized) { + loginViewModel.updateAccountCacheInstance(null) } + } + override fun onSafelyStart() { parentActivity.backButtonHandler = { if (accountLogin.hasFocus) { background.requestFocus() @@ -104,8 +103,6 @@ class LoginFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen), Na false } } - - state = State.Idle } override fun onResume() { @@ -114,37 +111,86 @@ class LoginFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen), Na } override fun onSafelyStop() { - jobTracker.cancelJob("advanceToNextScreen") - accountCache.onAccountHistoryChange.unsubscribe(this) - accountCache.onLoginStatusChange.unsubscribe(this) parentActivity.backButtonHandler = null } - private fun scrollToShow(view: View) { - val rectangle = Rect(0, 0, view.width, view.height) - - scrollArea.requestChildRectangleOnScreen(view, rectangle, false) + private fun setupLifecycleSubscriptionsToViewModel() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + launch { + loginViewModel.accountHistory.collect { history -> + accountLogin.accountHistory = history + } + } + launch { + loginViewModel.uiState.collect { uiState -> updateUi(uiState) } + } + } + } } - private suspend fun createAccount() { - state = State.CreatingAccount + private fun updateUi(uiState: LoginViewModel.LoginUiState) { + when (uiState) { + is LoginViewModel.LoginUiState.Default -> { + showDefault() + } - title.setText(R.string.logging_in_title) - subtitle.setText(R.string.creating_new_account) + is LoginViewModel.LoginUiState.Success -> { + openFragment( + if (uiState.isOutOfTime) { + OutOfTimeFragment() + } else { + ConnectFragment() + } + ) + } - loggingInStatus.visibility = View.VISIBLE - loginFailStatus.visibility = View.GONE - loggedInStatus.visibility = View.GONE + is LoginViewModel.LoginUiState.AccountCreated -> { + openFragment(WelcomeFragment()) + } - accountLogin.state = LoginState.InProgress + is LoginViewModel.LoginUiState.CreatingAccount -> { + showCreatingAccount() + } - scrollToShow(loggingInStatus) + is LoginViewModel.LoginUiState.Loading -> { + showLoading() + } + + is LoginViewModel.LoginUiState.InvalidAccountError -> { + loginFailure(resources.getString(R.string.login_fail_description)) + } + + is LoginViewModel.LoginUiState.TooManyDevicesError -> { + // TODO: Switch to TooManyDevicesFragment + loginFailure("Too many devices!") + } + + is LoginViewModel.LoginUiState.UnableToCreateAccountError -> { + loginFailure(resources.getString(R.string.failed_to_create_account)) + } - accountCache.createNewAccount() + is LoginViewModel.LoginUiState.OtherError -> { + loginFailure(uiState.errorMessage) + } + } } - private fun login(accountToken: String) { - state = State.LoggingIn + private fun openFragment(fragment: Fragment) { + parentFragmentManager.beginTransaction().apply { + replace(R.id.main_fragment, fragment) + commit() + } + } + + private fun showDefault() { + accountLogin.state = LoginState.Initial + headerBar.tunnelState = null + paintNavigationBar(ContextCompat.getColor(requireContext(), R.color.darkBlue)) + } + + private fun showLoading() { + accountLogin.state = LoginState.InProgress title.setText(R.string.logging_in_title) subtitle.setText(R.string.logging_in_description) @@ -158,65 +204,22 @@ class LoginFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen), Na accountLogin.state = LoginState.InProgress scrollToShow(loggingInStatus) - - accountCache.login(accountToken) } - private suspend fun loggedIn() { - if (loginStatus?.isNewAccount ?: false) { - showLoggedInMessage(resources.getString(R.string.account_created)) - } else { - showLoggedInMessage("") - } - - delay(1000) - openNextScreen() - } - - private fun showLoggedInMessage(subtitleMessage: String) { - title.setText(R.string.logged_in_title) - subtitle.setText(subtitleMessage) + private fun showCreatingAccount() { + title.setText(R.string.logging_in_title) + subtitle.setText(R.string.creating_new_account) - loggingInStatus.visibility = View.GONE + loggingInStatus.visibility = View.VISIBLE loginFailStatus.visibility = View.GONE - loggedInStatus.visibility = View.VISIBLE - - accountLogin.state = LoginState.Success - - scrollToShow(loggedInStatus) - } - - private fun openNextScreen() { - val status = loginStatus + loggedInStatus.visibility = View.GONE - val fragment = when { - status == null -> return - status.isNewAccount -> WelcomeFragment() - status.isExpired -> OutOfTimeFragment() - else -> ConnectFragment() - } + accountLogin.state = LoginState.InProgress - parentFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, fragment) - commit() - } + scrollToShow(loggingInStatus) } - // TODO: This error handling and its messages will change once a VM is introduced in a later - // commit related to the ongoing device adaption. - private fun loginFailure(loginResult: LoginResult?) { - val description = when { - loginResult == LoginResult.MaxDevicesReached -> "Too many devices" - loginResult == LoginResult.RpcError -> "An error occurred" - loginResult == LoginResult.OtherError -> "An error occurred" - loginResult == LoginResult.InvalidAccount -> - resources.getText(R.string.login_fail_description) - state == State.CreatingAccount -> resources.getText(R.string.failed_to_create_account) - else -> return - } - - state = State.Idle - + private fun loginFailure(description: String) { title.setText(R.string.login_fail_title) subtitle.setText(description) @@ -228,4 +231,9 @@ class LoginFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen), Na scrollToShow(accountLogin) } + + private fun scrollToShow(view: View) { + val rectangle = Rect(0, 0, view.width, view.height) + scrollArea.requestChildRectangleOnScreen(view, rectangle, false) + } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/WelcomeFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/WelcomeFragment.kt index 6bf4b5205e..ad3ee9c5e9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/WelcomeFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/WelcomeFragment.kt @@ -10,6 +10,7 @@ import android.view.ViewGroup import android.widget.TextView import android.widget.Toast import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.ui.widget.HeaderBar @@ -57,7 +58,11 @@ class WelcomeFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) { } override fun onSafelyStart() { - updateAccountNumber(deviceRepository.deviceState.value.token()) + jobTracker.newUiJob("updateAccountNumber") { + deviceRepository.deviceState.collect { state -> + updateAccountNumber(state.token()) + } + } accountCache.onAccountExpiryChange.subscribe(this) { expiry -> checkExpiry(expiry) @@ -76,6 +81,7 @@ class WelcomeFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) { override fun onSafelyStop() { accountCache.onAccountExpiryChange.unsubscribe(this) jobTracker.cancelJob("pollAccountData") + jobTracker.cancelJob("updateAccountNumber") } private fun updateAccountNumber(rawAccountNumber: String?) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/DeviceRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/DeviceRepository.kt index de796c5be3..7d1eed2961 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/DeviceRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/DeviceRepository.kt @@ -1,7 +1,7 @@ package net.mullvad.mullvadvpn.ui.serviceconnection import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.SharingStarted.Companion.Lazily +import kotlinx.coroutines.flow.SharingStarted.Companion.Eagerly import kotlinx.coroutines.flow.stateIn import net.mullvad.mullvadvpn.model.DeviceState @@ -12,7 +12,7 @@ class DeviceRepository( val deviceState = dataSource.deviceStateUpdates .stateIn( externalScope, - Lazily, + Eagerly, DeviceState.InitialState ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/StringExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/StringExtensions.kt new file mode 100644 index 0000000000..df15c47e19 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/StringExtensions.kt @@ -0,0 +1,9 @@ +package net.mullvad.mullvadvpn.util + +fun String.capitalizeFirstCharOfEachWord(): String { + return split(" ") + .joinToString(" ") { word -> + word.replaceFirstChar { firstChar -> firstChar.uppercase() } + } + .trimEnd() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt new file mode 100644 index 0000000000..55079458fa --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt @@ -0,0 +1,102 @@ +package net.mullvad.mullvadvpn.viewmodel + +import android.app.Application +import androidx.annotation.RestrictTo +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import net.mullvad.mullvadvpn.model.LoginResult +import net.mullvad.mullvadvpn.ui.serviceconnection.AccountCache + +class LoginViewModel( + application: Application +) : AndroidViewModel(application) { + private val _uiState = MutableStateFlow<LoginUiState>(LoginUiState.Default) + private val _accountHistory = MutableStateFlow<String?>(null) + val uiState: StateFlow<LoginUiState> = _uiState + val accountHistory: StateFlow<String?> = _accountHistory + + private var accountCache: AccountCache? = null + + sealed class LoginUiState { + object Default : LoginUiState() + object Loading : LoginUiState() + data class Success( + val isOutOfTime: Boolean + ) : LoginUiState() + + object CreatingAccount : LoginUiState() + object AccountCreated : LoginUiState() + object UnableToCreateAccountError : LoginUiState() + object InvalidAccountError : LoginUiState() + object TooManyDevicesError : LoginUiState() + data class OtherError(val errorMessage: String) : LoginUiState() + } + + // Ensures the view model has an up-to-date instance of account cache. This is an intermediate + // solution due to limitations in the current app architecture. + fun updateAccountCacheInstance(newAccountCache: AccountCache?) { + accountCache?.unsubscribe() + accountCache = newAccountCache?.apply { subscribe() } + } + + fun clearAccountHistory() { + accountCache?.clearAccountHistory() + } + + fun createAccount() { + _uiState.value = LoginUiState.CreatingAccount + accountCache?.createNewAccount() + } + + fun login(accountToken: String) { + _uiState.value = LoginUiState.Loading + accountCache?.login(accountToken) + } + + @RestrictTo(RestrictTo.Scope.TESTS) + public override fun onCleared() { + accountCache?.unsubscribe() + } + + private fun AccountCache.subscribe() { + onAccountHistoryChange.subscribe(this) { history -> + _accountHistory.value = history + } + + onLoginStatusChange.subscribe(this, startWithLatestEvent = false) { status -> + _uiState.value = when { + status == null -> { + LoginUiState.Default + } + status.isNewAccount -> { + LoginUiState.AccountCreated + } + else -> { + when (status.loginResult) { + LoginResult.Ok -> LoginUiState.Success(false) + LoginResult.InvalidAccount -> LoginUiState.InvalidAccountError + LoginResult.MaxDevicesReached -> LoginUiState.TooManyDevicesError + else -> LoginUiState.OtherError( + errorMessage = status.loginResult?.toString() ?: "" + ) + } + } + } + } + } + + private fun AccountCache.unsubscribe() { + onAccountHistoryChange.unsubscribe(this) + onLoginStatusChange.unsubscribe(this) + } + + class Factory(val application: Application) : + ViewModelProvider.Factory { + override fun <T : ViewModel> create(modelClass: Class<T>): T { + return LoginViewModel(application) as T + } + } +} diff --git a/android/app/src/main/res/layout/account.xml b/android/app/src/main/res/layout/account.xml index 9ca5e6f75b..7f2e2fb598 100644 --- a/android/app/src/main/res/layout/account.xml +++ b/android/app/src/main/res/layout/account.xml @@ -42,6 +42,13 @@ 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="hide" /> <net.mullvad.mullvadvpn.ui.widget.CopyableInformationView android:id="@+id/account_number" android:layout_width="match_parent" android:layout_height="wrap_content" diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 13faeb6a83..6c08df83ee 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -55,6 +55,7 @@ <string name="report_a_problem">Report a problem</string> <string name="faqs_and_guides">FAQs & Guides</string> <string name="account_number">Account number</string> + <string name="device_name">Device name</string> <string name="mullvad_account_number">Mullvad account number</string> <string name="copied_mullvad_account_number">Copied Mullvad account number to clipboard</string> diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt new file mode 100644 index 0000000000..174c378e23 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt @@ -0,0 +1,221 @@ +package net.mullvad.mullvadvpn.viewmodel + +import app.cash.turbine.FlowTurbine +import app.cash.turbine.test +import io.mockk.MockKAnnotations +import io.mockk.Runs +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.invoke +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import junit.framework.Assert.assertEquals +import kotlinx.coroutines.test.runBlockingTest +import net.mullvad.mullvadvpn.model.LoginResult +import net.mullvad.mullvadvpn.model.LoginStatus +import net.mullvad.mullvadvpn.ui.serviceconnection.AccountCache +import net.mullvad.talpid.util.EventNotifier +import org.junit.Before +import org.junit.Test + +class LoginViewModelTest { + + @MockK + private lateinit var mockedAccountCache: AccountCache + + @MockK + private lateinit var mockedLoginStatusNotifier: EventNotifier<LoginStatus?> + + @MockK + private lateinit var mockedAccountHistoryNotifier: EventNotifier<String?> + + private lateinit var loginViewModel: LoginViewModel + private val capturedLoginStatusNotifierCallback = slot<(LoginStatus?) -> Unit>() + private val capturedAccountHistoryNotifierCallback = slot<(String?) -> Unit>() + + @Before + fun setup() { + MockKAnnotations.init(this, relaxUnitFun = true) + + every { + mockedLoginStatusNotifier.subscribe( + any(), + any(), + capture(capturedLoginStatusNotifierCallback) + ) + } just Runs + + every { + mockedAccountHistoryNotifier.subscribe( + any(), + capture(capturedAccountHistoryNotifierCallback) + ) + } just Runs + + every { mockedAccountCache.onLoginStatusChange } returns mockedLoginStatusNotifier + every { mockedAccountCache.onAccountHistoryChange } returns mockedAccountHistoryNotifier + + loginViewModel = LoginViewModel(mockk()) + } + + @Test + fun testDefaultState() = runBlockingTest { + loginViewModel.updateAccountCacheInstance(mockedAccountCache) + loginViewModel.uiState.test { + assertEquals(LoginViewModel.LoginUiState.Default, awaitItem()) + } + } + + @Test + fun testClearingViewModel() { + loginViewModel.updateAccountCacheInstance(mockedAccountCache) + loginViewModel.onCleared() + verify { + mockedLoginStatusNotifier.unsubscribe(any()) + mockedAccountHistoryNotifier.unsubscribe(any()) + } + } + + @Test + fun testCreateAccount() = runBlockingTest { + loginViewModel.updateAccountCacheInstance(mockedAccountCache) + loginViewModel.uiState.test { + skipDefaultItem() + loginViewModel.createAccount() + assertEquals(LoginViewModel.LoginUiState.CreatingAccount, awaitItem()) + capturedLoginStatusNotifierCallback.captured.invoke(DummyLoginStatus.ACCOUNT_CREATED) + assertEquals(LoginViewModel.LoginUiState.AccountCreated, awaitItem()) + } + } + + @Test + fun testLoginWithValidAccount() = runBlockingTest { + loginViewModel.updateAccountCacheInstance(mockedAccountCache) + loginViewModel.uiState.test { + skipDefaultItem() + loginViewModel.login("") + assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem()) + capturedLoginStatusNotifierCallback.captured.invoke(DummyLoginStatus.SUCCESSFUL_LOGIN) + assertEquals(LoginViewModel.LoginUiState.Success(isOutOfTime = false), awaitItem()) + } + } + + @Test + fun testLoginWithInvalidAccount() = runBlockingTest { + loginViewModel.updateAccountCacheInstance(mockedAccountCache) + loginViewModel.uiState.test { + skipDefaultItem() + loginViewModel.login("") + assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem()) + capturedLoginStatusNotifierCallback.captured.invoke( + DummyLoginStatus.INVALID_ACCOUNT_ERROR + ) + assertEquals(LoginViewModel.LoginUiState.InvalidAccountError, awaitItem()) + } + } + + @Test + fun testLoginWithTooManyDevicesError() = runBlockingTest { + loginViewModel.updateAccountCacheInstance(mockedAccountCache) + loginViewModel.uiState.test { + skipDefaultItem() + loginViewModel.login("") + assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem()) + capturedLoginStatusNotifierCallback.captured.invoke(DummyLoginStatus.MAX_DEVICES_ERROR) + assertEquals(LoginViewModel.LoginUiState.TooManyDevicesError, awaitItem()) + } + } + + @Test + fun testLoginWithRpcError() = runBlockingTest { + loginViewModel.updateAccountCacheInstance(mockedAccountCache) + loginViewModel.uiState.test { + skipDefaultItem() + loginViewModel.login("") + assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem()) + capturedLoginStatusNotifierCallback.captured.invoke(DummyLoginStatus.RPC_ERROR) + assertEquals(LoginViewModel.LoginUiState.OtherError("RpcError"), awaitItem()) + } + } + + @Test + fun testLoginWithUnknownError() = runBlockingTest { + loginViewModel.updateAccountCacheInstance(mockedAccountCache) + loginViewModel.uiState.test { + skipDefaultItem() + loginViewModel.login("") + assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem()) + capturedLoginStatusNotifierCallback.captured.invoke(DummyLoginStatus.OTHER_ERROR) + assertEquals(LoginViewModel.LoginUiState.OtherError("OtherError"), awaitItem()) + } + } + + @Test + fun testAccountHistory() = runBlockingTest { + loginViewModel.updateAccountCacheInstance(mockedAccountCache) + loginViewModel.accountHistory.test { skipDefaultItem() } + capturedAccountHistoryNotifierCallback.invoke(DUMMY_ACCOUNT_TOKEN) + loginViewModel.accountHistory.test { assertEquals(DUMMY_ACCOUNT_TOKEN, awaitItem()) } + } + + @Test + fun testClearingAccountHistory() = runBlockingTest { + loginViewModel.updateAccountCacheInstance(mockedAccountCache) + loginViewModel.clearAccountHistory() + verify { mockedAccountCache.clearAccountHistory() } + } + + private suspend fun <T> FlowTurbine<T>.skipDefaultItem() where T : Any? { + awaitItem() + } + + companion object { + private const val DUMMY_ACCOUNT_TOKEN = "DUMMY" + + private object DummyLoginStatus { + val ACCOUNT_CREATED = LoginStatus( + DUMMY_ACCOUNT_TOKEN, + mockk(), + isNewAccount = true, + mockk() + ) + + val SUCCESSFUL_LOGIN = LoginStatus( + DUMMY_ACCOUNT_TOKEN, + mockk(), + isNewAccount = false, + LoginResult.Ok + ) + + val INVALID_ACCOUNT_ERROR = LoginStatus( + DUMMY_ACCOUNT_TOKEN, + mockk(), + isNewAccount = false, + LoginResult.InvalidAccount + ) + + val MAX_DEVICES_ERROR = LoginStatus( + DUMMY_ACCOUNT_TOKEN, + mockk(), + isNewAccount = false, + LoginResult.MaxDevicesReached + ) + + val RPC_ERROR = LoginStatus( + DUMMY_ACCOUNT_TOKEN, + mockk(), + isNewAccount = false, + LoginResult.RpcError + ) + + val OTHER_ERROR = LoginStatus( + DUMMY_ACCOUNT_TOKEN, + mockk(), + isNewAccount = false, + LoginResult.OtherError + ) + } + } +} diff --git a/android/buildSrc/src/main/kotlin/Dependencies.kt b/android/buildSrc/src/main/kotlin/Dependencies.kt index 4c96391c88..e4d78ecd73 100644 --- a/android/buildSrc/src/main/kotlin/Dependencies.kt +++ b/android/buildSrc/src/main/kotlin/Dependencies.kt @@ -4,6 +4,7 @@ object Dependencies { const val jodaTime = "joda-time:joda-time:${Versions.jodaTime}" const val junit = "junit:junit:${Versions.junit}" const val leakCanary = "com.squareup.leakcanary:leakcanary-android:${Versions.leakCanary}" + const val turbine = "app.cash.turbine:turbine:${Versions.turbine}" object AndroidX { const val appcompat = "androidx.appcompat:appcompat:${Versions.AndroidX.appcompat}" diff --git a/android/buildSrc/src/main/kotlin/Versions.kt b/android/buildSrc/src/main/kotlin/Versions.kt index a0ff748841..8db39a305f 100644 --- a/android/buildSrc/src/main/kotlin/Versions.kt +++ b/android/buildSrc/src/main/kotlin/Versions.kt @@ -8,6 +8,7 @@ object Versions { const val kotlinx = "1.5.2" const val leakCanary = "2.8.1" const val mockk = "1.12.3" + const val turbine = "0.7.0" object Android { const val compileSdkVersion = 31 |
