summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorAlbin <albin@mullvad.net>2022-04-20 16:55:21 +0200
committerAlbin <albin@mullvad.net>2022-04-20 16:55:21 +0200
commita9f906b082f76410855f0b97e71f45ca60bfa727 (patch)
treee566f028e921806334173781acedd4a1942035d2 /android
parent2deef36bfb89725bdc3ca78af21c88a312bd9ccd (diff)
parent3c8659677155bdc9d4d21392142e139e0fcf7f32 (diff)
downloadmullvadvpn-a9f906b082f76410855f0b97e71f45ca60bfa727.tar.xz
mullvadvpn-a9f906b082f76410855f0b97e71f45ca60bfa727.zip
Merge branch 'refactor-android-login-to-use-vm'
Diffstat (limited to 'android')
-rw-r--r--android/app/build.gradle.kts1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceState.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AccountCache.kt1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt14
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LaunchFragment.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginFragment.kt236
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/WelcomeFragment.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/DeviceRepository.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/StringExtensions.kt9
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt102
-rw-r--r--android/app/src/main/res/layout/account.xml7
-rw-r--r--android/app/src/main/res/values/strings.xml1
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt221
-rw-r--r--android/buildSrc/src/main/kotlin/Dependencies.kt1
-rw-r--r--android/buildSrc/src/main/kotlin/Versions.kt1
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 &amp; 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