diff options
| author | Albin <albin@mullvad.net> | 2022-05-17 15:07:03 +0200 |
|---|---|---|
| committer | Albin <albin@mullvad.net> | 2022-05-17 15:07:03 +0200 |
| commit | e59802548263b33bb428e5172cdb2b7833caea5f (patch) | |
| tree | 549cd17a26dc041635968c049bf8d999d3c15832 /android/app | |
| parent | 7f8ad3e7dfe097580e48ce2567111fb0d47962da (diff) | |
| parent | 79f21dafb28dd2c0b6a41c5459c8907657fbacc5 (diff) | |
| download | mullvadvpn-e59802548263b33bb428e5172cdb2b7833caea5f.tar.xz mullvadvpn-e59802548263b33bb428e5172cdb2b7833caea5f.zip | |
Merge branch 'refactor-android-account-handling'
Diffstat (limited to 'android/app')
24 files changed, 353 insertions, 424 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Event.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Event.kt index 7fe971e678..bbf84bf143 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Event.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Event.kt @@ -3,11 +3,14 @@ package net.mullvad.mullvadvpn.ipc import android.os.Message as RawMessage import android.os.Messenger import kotlinx.parcelize.Parcelize +import net.mullvad.mullvadvpn.model.AccountCreationResult +import net.mullvad.mullvadvpn.model.AccountExpiry +import net.mullvad.mullvadvpn.model.AccountHistory import net.mullvad.mullvadvpn.model.AppVersionInfo as AppVersionInfoData import net.mullvad.mullvadvpn.model.DeviceState import net.mullvad.mullvadvpn.model.GeoIpLocation import net.mullvad.mullvadvpn.model.KeygenEvent -import net.mullvad.mullvadvpn.model.LoginStatus as LoginStatusData +import net.mullvad.mullvadvpn.model.LoginResult import net.mullvad.mullvadvpn.model.RelayList import net.mullvad.mullvadvpn.model.Settings import net.mullvad.mullvadvpn.model.TunnelState @@ -18,7 +21,13 @@ sealed class Event : Message.EventMessage() { protected override val messageKey = MESSAGE_KEY @Parcelize - data class AccountHistory(val history: String?) : Event() + data class AccountCreationEvent(val result: AccountCreationResult) : Event() + + @Parcelize + data class AccountExpiryEvent(val expiry: AccountExpiry) : Event() + + @Parcelize + data class AccountHistoryEvent(val history: AccountHistory) : Event() @Parcelize data class AppVersionInfo(val versionInfo: AppVersionInfoData?) : Event() @@ -36,7 +45,7 @@ sealed class Event : Message.EventMessage() { data class ListenerReady(val connection: Messenger, val listenerId: Int) : Event() @Parcelize - data class LoginStatus(val status: LoginStatusData?) : Event() + data class LoginEvent(val result: LoginResult) : Event() @Parcelize data class NewLocation(val location: GeoIpLocation?) : Event() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Request.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Request.kt index 78a6baa072..212f79fba4 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Request.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Request.kt @@ -5,7 +5,6 @@ import android.os.Messenger import java.net.InetAddress import kotlinx.parcelize.Parcelize import net.mullvad.mullvadvpn.model.LocationConstraint -import org.joda.time.DateTime // Requests that the service can handle sealed class Request : Message.RequestMessage() { @@ -30,13 +29,13 @@ sealed class Request : Message.RequestMessage() { object FetchAccountExpiry : Request() @Parcelize - object FetchAuthToken : Request() + object FetchAccountHistory : Request() @Parcelize - data class IncludeApp(val packageName: String) : Request() + object FetchAuthToken : Request() @Parcelize - data class InvalidateAccountExpiry(val expiry: DateTime) : Request() + data class IncludeApp(val packageName: String) : Request() @Parcelize data class Login(val account: String?) : Request() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountCreationResult.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountCreationResult.kt new file mode 100644 index 0000000000..23115b606d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountCreationResult.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +sealed class AccountCreationResult : Parcelable { + @Parcelize + data class Success(val accountToken: String) : AccountCreationResult() + + @Parcelize + object Failure : AccountCreationResult() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountExpiry.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountExpiry.kt new file mode 100644 index 0000000000..b057308192 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountExpiry.kt @@ -0,0 +1,17 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.joda.time.DateTime + +sealed class AccountExpiry : Parcelable { + @Parcelize + data class Available(val expiryDateTime: DateTime) : AccountExpiry() + + @Parcelize + object Missing : AccountExpiry() + + fun date(): DateTime? { + return (this as? Available)?.expiryDateTime + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountHistory.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountHistory.kt new file mode 100644 index 0000000000..114463aaaa --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountHistory.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.model + +import android.os.Parcelable +import kotlinx.android.parcel.Parcelize + +sealed class AccountHistory : Parcelable { + @Parcelize + data class Available(val accountToken: String) : AccountHistory() + + @Parcelize + object Missing : AccountHistory() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/LoginStatus.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/LoginStatus.kt deleted file mode 100644 index 8e3b8f841d..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/LoginStatus.kt +++ /dev/null @@ -1,16 +0,0 @@ -package net.mullvad.mullvadvpn.model - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize -import org.joda.time.DateTime - -@Parcelize -data class LoginStatus( - val account: String, - val expiry: DateTime?, - val isNewAccount: Boolean, - val loginResult: LoginResult? -) : Parcelable { - val isExpired: Boolean - get() = expiry != null && expiry.isAfterNow() -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt index 649ddd65b1..a7ad35ef6d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt @@ -1,8 +1,7 @@ package net.mullvad.mullvadvpn.service -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import net.mullvad.mullvadvpn.model.AppVersionInfo import net.mullvad.mullvadvpn.model.Device import net.mullvad.mullvadvpn.model.DeviceEvent @@ -34,11 +33,8 @@ class MullvadDaemon(vpnService: MullvadVpnService) { var onRelayListChange: ((RelayList) -> Unit)? = null var onDaemonStopped: (() -> Unit)? = null - private val _deviceStateUpdates = MutableSharedFlow<DeviceState>( - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - val deviceStateUpdates = _deviceStateUpdates.asSharedFlow() + private val _deviceStateUpdates = MutableStateFlow<DeviceState>(DeviceState.InitialState) + val deviceStateUpdates = _deviceStateUpdates.asStateFlow() init { System.loadLibrary("mullvad_jni") @@ -125,9 +121,16 @@ class MullvadDaemon(vpnService: MullvadVpnService) { return listDevices(daemonInterfaceAddress, accountToken) } - fun getDevice(): DeviceState = getDevice(daemonInterfaceAddress) + fun getAndEmitDeviceState(): DeviceState { + return getDevice(daemonInterfaceAddress).also { deviceState -> + _deviceStateUpdates.tryEmit(deviceState) + } + } - fun updateDevice() = updateDevice(daemonInterfaceAddress) + fun refreshDevice() { + updateDevice(daemonInterfaceAddress) + getAndEmitDeviceState() + } fun removeDevice(accountToken: String, deviceId: String): RemoveDeviceResult { return removeDevice(daemonInterfaceAddress, accountToken, deviceId) 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 ea72a8379a..c1b5b47ded 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 @@ -6,13 +6,13 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.channels.actor import kotlinx.coroutines.channels.sendBlocking -import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect import net.mullvad.mullvadvpn.ipc.Event import net.mullvad.mullvadvpn.ipc.Request +import net.mullvad.mullvadvpn.model.AccountCreationResult +import net.mullvad.mullvadvpn.model.AccountExpiry +import net.mullvad.mullvadvpn.model.AccountHistory import net.mullvad.mullvadvpn.model.GetAccountDataResult -import net.mullvad.mullvadvpn.model.LoginResult -import net.mullvad.mullvadvpn.model.LoginStatus -import net.mullvad.mullvadvpn.util.ExponentialBackoff import net.mullvad.mullvadvpn.util.JobTracker import net.mullvad.talpid.util.EventNotifier import org.joda.time.DateTime @@ -20,14 +20,7 @@ import org.joda.time.format.DateTimeFormat class AccountCache(private val endpoint: ServiceEndpoint) { companion object { - public val EXPIRY_FORMAT = DateTimeFormat.forPattern("YYYY-MM-dd HH:mm:ss z") - - // Number of retry attempts to check for a changed expiry before giving up. - // Current value will force the cache to keep fetching for about four minutes or until a new - // expiry value is received. - // This is only used if the expiry was invalidated and fetching a new expiry returns the - // same value as before the invalidation. - private const val MAX_INVALIDATED_RETRIES = 7 + private val EXPIRY_FORMAT = DateTimeFormat.forPattern("YYYY-MM-dd HH:mm:ss z") private sealed class Command { object CreateAccount : Command() @@ -42,36 +35,28 @@ class AccountCache(private val endpoint: ServiceEndpoint) { get() = endpoint.intermittentDaemon val onAccountNumberChange = EventNotifier<String?>(null) - val onAccountExpiryChange = EventNotifier<DateTime?>(null) - val onAccountHistoryChange = EventNotifier<String?>(null) - val onLoginStatusChange = EventNotifier<LoginStatus?>(null) - - var newlyCreatedAccount = false - private set + val onAccountExpiryChange = EventNotifier<AccountExpiry>(AccountExpiry.Missing) + val onAccountHistoryChange = EventNotifier<AccountHistory>(AccountHistory.Missing) private val jobTracker = JobTracker() - private var accountNumber by onAccountNumberChange.notifiable() private var accountExpiry by onAccountExpiryChange.notifiable() private var accountHistory by onAccountHistoryChange.notifiable() - private var createdAccountExpiry: DateTime? = null - private var oldAccountExpiry: DateTime? = null - - var loginStatus by onLoginStatusChange.notifiable() - private set - init { - endpoint.settingsListener.accountNumberNotifier.subscribe(this) { accountNumber -> - handleNewAccountNumber(accountNumber) + jobTracker.newBackgroundJob("autoFetchAccountExpiry") { + daemon.await().deviceStateUpdates.collect { deviceState -> + accountExpiry = deviceState.token() + ?.let { fetchAccountExpiry(it) } ?: AccountExpiry.Missing + } } onAccountHistoryChange.subscribe(this) { history -> - endpoint.sendEvent(Event.AccountHistory(history)) + endpoint.sendEvent(Event.AccountHistoryEvent(history)) } - onLoginStatusChange.subscribe(this) { status -> - endpoint.sendEvent(Event.LoginStatus(status)) + onAccountExpiryChange.subscribe(this) { + endpoint.sendEvent(Event.AccountExpiryEvent(it)) } endpoint.dispatcher.apply { @@ -90,15 +75,22 @@ class AccountCache(private val endpoint: ServiceEndpoint) { } registerHandler(Request.FetchAccountExpiry::class) { _ -> - fetchAccountExpiry() + jobTracker.newBackgroundJob("fetchAccountExpiry") { + accountExpiry = + accountToken()?.let { fetchAccountExpiry(it) } ?: AccountExpiry.Missing + } } - registerHandler(Request.InvalidateAccountExpiry::class) { request -> - invalidateAccountExpiry(request.expiry) + registerHandler(Request.FetchAccountHistory::class) { _ -> + jobTracker.newBackgroundJob("fetchAccountHistory") { + accountHistory = fetchAccountHistory() + } } registerHandler(Request.ClearAccountHistory::class) { _ -> - clearAccountHistory() + jobTracker.newBackgroundJob("clearAccountHistory") { + clearAccountHistory() + } } } } @@ -110,54 +102,12 @@ class AccountCache(private val endpoint: ServiceEndpoint) { onAccountNumberChange.unsubscribeAll() onAccountExpiryChange.unsubscribeAll() onAccountHistoryChange.unsubscribeAll() - onLoginStatusChange.unsubscribeAll() commandChannel.close() } - private fun fetchAccountExpiry() { - synchronized(this) { - accountNumber?.let { account -> - jobTracker.newBackgroundJob("fetch") { - val delays = ExponentialBackoff().apply { - cap = 2 /* h */ * 60 /* min */ * 60 /* s */ * 1000 /* ms */ - } - - do { - val result = daemon.await().getAccountData(account) - - if (result is GetAccountDataResult.Ok) { - val expiry = result.accountData.expiry - val retryAttempt = delays.iteration - - if (handleNewExpiry(account, expiry, retryAttempt)) { - break - } - } else if (result is GetAccountDataResult.InvalidAccount) { - break - } - - delay(delays.next()) - } while (onAccountExpiryChange.hasListeners()) - } - } - } - } - - private fun invalidateAccountExpiry(accountExpiryToInvalidate: DateTime) { - synchronized(this) { - if (accountExpiry == accountExpiryToInvalidate) { - oldAccountExpiry = accountExpiryToInvalidate - fetchAccountExpiry() - } - } - } - - private fun clearAccountHistory() { - jobTracker.newBackgroundJob("clearAccountHistory") { - daemon.await().clearAccountHistory() - fetchAccountHistory() - } + private suspend fun accountToken(): String? { + return daemon.await().deviceStateUpdates.value.token() } private fun spawnActor() = GlobalScope.actor<Command>(Dispatchers.Default, Channel.UNLIMITED) { @@ -174,105 +124,61 @@ class AccountCache(private val endpoint: ServiceEndpoint) { } } - private suspend fun doCreateAccount() { - newlyCreatedAccount = true - createdAccountExpiry = null + private suspend fun clearAccountHistory() { + daemon.await().clearAccountHistory() + accountHistory = fetchAccountHistory() + } + private suspend fun doCreateAccount() { daemon.await().createNewAccount() + .let { newAccountNumber -> + if (newAccountNumber != null) { + AccountCreationResult.Success(newAccountNumber) + } else { + AccountCreationResult.Failure + } + } + .also { result -> + endpoint.sendEvent(Event.AccountCreationEvent(result)) + } } private suspend fun doLogin(account: String) { - val loginResult = daemon.await().loginAccount(account) - - val accountExpiryDate = loginResult - .takeIf { it == LoginResult.Ok } - .let { daemon.await().getAccountData(account) as? GetAccountDataResult.Ok } - ?.let { DateTime.parse(it.accountData.expiry, EXPIRY_FORMAT) } - - synchronized(this) { - markAccountAsNotNew() - accountNumber = account - accountExpiry = accountExpiryDate - - loginStatus = LoginStatus( - account = account, - expiry = accountExpiryDate, - isNewAccount = newlyCreatedAccount, - loginResult - ) + daemon.await().loginAccount(account).also { result -> + endpoint.sendEvent(Event.LoginEvent(result)) } } private suspend fun doLogout() { daemon.await().logoutAccount() - loginStatus = null - fetchAccountHistory() + accountHistory = fetchAccountHistory() } - private fun fetchAccountHistory() { - jobTracker.newBackgroundJob("fetchHistory") { - daemon.await().getAccountHistory().let { history -> - accountHistory = history + private suspend fun fetchAccountHistory(): AccountHistory { + return daemon.await().getAccountHistory().let { history -> + if (history != null) { + AccountHistory.Available(history) + } else { + AccountHistory.Missing } } } - private fun markAccountAsNotNew() { - newlyCreatedAccount = false - createdAccountExpiry = null - } - - private fun handleNewAccountNumber(newAccountNumber: String?) { - synchronized(this) { - accountExpiry = null - accountNumber = newAccountNumber - - loginStatus = newAccountNumber?.let { account -> - LoginStatus(account, null, newlyCreatedAccount, null) + private suspend fun fetchAccountExpiry(accountToken: String): AccountExpiry { + return fetchAccountData(accountToken).let { result -> + if (result is GetAccountDataResult.Ok) { + AccountExpiry.Available(result.parseExpiryDate()) + } else { + AccountExpiry.Missing } - - fetchAccountExpiry() - fetchAccountHistory() } } - private fun handleNewExpiry( - accountNumberUsedForFetch: String, - expiryString: String, - retryAttempt: Int - ): Boolean { - synchronized(this) { - if (accountNumber !== accountNumberUsedForFetch) { - return true - } - - val newAccountExpiry = DateTime.parse(expiryString, EXPIRY_FORMAT) - - if (newAccountExpiry != oldAccountExpiry || retryAttempt >= MAX_INVALIDATED_RETRIES) { - accountExpiry = newAccountExpiry - oldAccountExpiry = null - - loginStatus = loginStatus?.let { currentStatus -> - LoginStatus( - currentStatus.account, - newAccountExpiry, - currentStatus.isNewAccount, - null - ) - } - - if (accountExpiry != null && newlyCreatedAccount) { - if (createdAccountExpiry == null) { - createdAccountExpiry = accountExpiry - } else if (accountExpiry != createdAccountExpiry) { - markAccountAsNotNew() - } - } - - return true - } + private suspend fun fetchAccountData(accountToken: String): GetAccountDataResult { + return daemon.await().getAccountData(accountToken) + } - return false - } + private fun GetAccountDataResult.Ok.parseExpiryDate(): DateTime { + return DateTime.parse(this.accountData.expiry, EXPIRY_FORMAT) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/DaemonDeviceDataSource.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/DaemonDeviceDataSource.kt index a61bb15ed2..cc23b3fe01 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/DaemonDeviceDataSource.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/DaemonDeviceDataSource.kt @@ -30,9 +30,7 @@ class DaemonDeviceDataSource( endpoint.dispatcher.registerHandler(Request.RefreshDeviceState::class) { tracker.newBackgroundJob("refreshDeviceJob") { - daemon.getDevice() - .let { deviceState -> Event.DeviceStateEvent(deviceState) } - .also { event -> endpoint.sendEvent(event) } + daemon.refreshDevice() } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt index f53454b401..2592ffa3ec 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt @@ -134,8 +134,7 @@ class ServiceEndpoint( val initialEvents = mutableListOf( Event.TunnelStateChange(connectionProxy.state), - Event.LoginStatus(accountCache.onLoginStatusChange.latestEvent), - Event.AccountHistory(accountCache.onAccountHistoryChange.latestEvent), + Event.AccountHistoryEvent(accountCache.onAccountHistoryChange.latestEvent), Event.SettingsUpdate(settingsListener.settings), Event.NewLocation(locationInfoCache.location), Event.WireGuardKeyStatus(keyStatusListener.keyStatus), diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/AccountExpiryNotification.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/AccountExpiryNotification.kt index 7b835bccdc..5720c2eee1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/AccountExpiryNotification.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/AccountExpiryNotification.kt @@ -10,7 +10,7 @@ import androidx.core.app.NotificationCompat import kotlin.properties.Delegates.observable import kotlinx.coroutines.delay import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.model.LoginStatus +import net.mullvad.mullvadvpn.model.AccountExpiry import net.mullvad.mullvadvpn.service.MullvadDaemon import net.mullvad.mullvadvpn.service.endpoint.AccountCache import net.mullvad.mullvadvpn.util.Intermittent @@ -44,33 +44,31 @@ class AccountExpiryNotification( true ) - var loginStatus by observable<LoginStatus?>(null) { _, oldValue, newValue -> + var accountExpiry by observable<AccountExpiry>( + AccountExpiry.Missing + ) { _, oldValue, newValue -> if (oldValue != newValue) { jobTracker.newUiJob("update") { update(newValue) } } } init { - accountCache.onLoginStatusChange.subscribe(this) { newStatus -> - loginStatus = newStatus + accountCache.onAccountExpiryChange.subscribe(this) { expiry -> + accountExpiry = expiry } } fun onDestroy() { accountCache.onAccountNumberChange.unsubscribe(this) - loginStatus = null } - private suspend fun update(loginStatus: LoginStatus?) { - val remainingTime = loginStatus?.expiry?.let { expiry -> Duration(DateTime.now(), expiry) } - val closeToExpire = remainingTime?.isShorterThan(REMAINING_TIME_FOR_REMINDERS) ?: false - val accountIsNew = loginStatus?.isNewAccount ?: false - - if (closeToExpire && !accountIsNew) { - val notification = build(loginStatus!!.expiry!!, remainingTime!!) + private suspend fun update(expiry: AccountExpiry) { + val expiryDate = expiry.date() + val durationUntilExpiry = expiryDate?.remainingTime() + if (durationUntilExpiry?.isCloseToExpiry() == true) { + val notification = build(expiryDate, durationUntilExpiry) channel.notificationManager.notify(NOTIFICATION_ID, notification) - jobTracker.newUiJob("scheduleUpdate") { scheduleUpdate() } } else { channel.notificationManager.cancel(NOTIFICATION_ID) @@ -78,9 +76,17 @@ class AccountExpiryNotification( } } + private fun DateTime.remainingTime(): Duration { + return Duration(DateTime.now(), this) + } + + private fun Duration.isCloseToExpiry(): Boolean { + return isShorterThan(REMAINING_TIME_FOR_REMINDERS) + } + private suspend fun scheduleUpdate() { delay(TIME_BETWEEN_CHECKS) - update(loginStatus) + update(accountExpiry) } private suspend fun build(expiry: DateTime, remainingTime: Duration): Notification { 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 4b76bb2b6a..526b5356f4 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 @@ -7,6 +7,7 @@ import android.view.ViewGroup import androidx.fragment.app.FragmentManager import java.text.DateFormat import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.model.TunnelState @@ -117,11 +118,13 @@ class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) { } } - accountCache.onAccountExpiryChange.subscribe(this) { accountExpiry -> - jobTracker.newUiJob("updateAccountExpiry") { - currentAccountExpiry = accountExpiry - updateAccountExpiry(accountExpiry) - } + jobTracker.newUiJob("updateAccountExpiry") { + accountCache.accountExpiryState + .map { state -> state.date() } + .collect { expiryDate -> + currentAccountExpiry = expiryDate + updateAccountExpiry(expiryDate) + } } connectionProxy.onUiStateChange.subscribe(this) { uiState -> @@ -134,15 +137,11 @@ class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) { } } - oldAccountExpiry?.let { expiry -> - accountCache.invalidateAccountExpiry(expiry) - } - sitePaymentButton.updateAuthTokenCache(authTokenCache) + accountCache.fetchAccountExpiry() } override fun onSafelyStop() { - accountCache.onAccountExpiryChange.unsubscribe(this) jobTracker.cancelAllJobs() } @@ -153,7 +152,6 @@ class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) { private fun checkForAddedTime() { currentAccountExpiry?.let { expiry -> oldAccountExpiry = expiry - accountCache.invalidateAccountExpiry(expiry) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectFragment.kt index 6f701c9ba7..629c5942c3 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ConnectFragment.kt @@ -7,6 +7,8 @@ import android.view.ViewGroup import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.ui.notification.AccountExpiryNotification @@ -101,20 +103,24 @@ class ConnectFragment : } } - accountCache.onAccountExpiryChange.subscribe(this) { expiry -> - if (expiry?.isBeforeNow() ?: false) { - openOutOfTimeScreen() - } else if (expiry != null) { - scheduleNextAccountExpiryCheck(expiry) - } + jobTracker.newUiJob("updateAccountExpiry") { + accountCache.accountExpiryState + .map { state -> state.date() } + .collect { expiryDate -> + if (expiryDate?.isBeforeNow == true) { + openOutOfTimeScreen() + } else if (expiryDate != null) + scheduleNextAccountExpiryCheck(expiryDate) + } } } override fun onSafelyStop() { + jobTracker.cancelJob("updateAccountExpiry") + locationInfoCache.onNewLocation = null relayListListener.onRelayListChange = null - accountCache.onAccountExpiryChange.unsubscribe(this) keyStatusListener.onKeyStatusChange.unsubscribe(this) connectionProxy.onUiStateChange.unsubscribe(this) 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 f2e441d96a..9499d1c9f1 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 @@ -47,6 +47,7 @@ class LaunchFragment : ServiceAwareFragment() { when (deviceState) { is DeviceState.LoggedIn -> advanceToConnectScreen() is DeviceState.LoggedOut -> advanceToLoginScreen() + is DeviceState.Revoked -> advanceToRevokedScreen() else -> Unit } } @@ -66,4 +67,12 @@ class LaunchFragment : ServiceAwareFragment() { commit() } } + + private fun advanceToRevokedScreen() { + // TODO: Open revoked screen. + parentFragmentManager.beginTransaction().apply { + replace(R.id.main_fragment, LoginFragment()) + commit() + } + } } 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 91fec93d87..056bff9227 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 @@ -16,6 +16,7 @@ import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.AccountHistory import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnection import net.mullvad.mullvadvpn.ui.widget.AccountLogin import net.mullvad.mullvadvpn.ui.widget.HeaderBar @@ -120,6 +121,7 @@ class LoginFragment : launch { loginViewModel.accountHistory.collect { history -> accountLogin.accountHistory = history + .let { it as? AccountHistory.Available }?.accountToken } } launch { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/OutOfTimeFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/OutOfTimeFragment.kt index 658315773e..bb65aa2135 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/OutOfTimeFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/OutOfTimeFragment.kt @@ -7,6 +7,8 @@ import android.view.ViewGroup import android.widget.TextView import kotlin.properties.Delegates.observable import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.ui.widget.Button @@ -72,8 +74,12 @@ class OutOfTimeFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) } override fun onSafelyStart() { - accountCache.onAccountExpiryChange.subscribe(this) { expiry -> - checkExpiry(expiry) + jobTracker.newUiJob("updateAccountExpiry") { + accountCache.accountExpiryState + .map { state -> state.date() } + .collect { expiryDate -> + checkExpiry(expiryDate) + } } jobTracker.newBackgroundJob("pollAccountData") { @@ -87,7 +93,7 @@ class OutOfTimeFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) } override fun onSafelyStop() { - accountCache.onAccountExpiryChange.unsubscribe(this) + jobTracker.cancelJob("updateAccountExpiry") jobTracker.cancelJob("pollAccountData") } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/RedeemVoucherDialogFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/RedeemVoucherDialogFragment.kt index a25ac6a1d8..eb64642a45 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/RedeemVoucherDialogFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/RedeemVoucherDialogFragment.kt @@ -13,6 +13,7 @@ import android.view.ViewGroup.LayoutParams import android.widget.EditText import android.widget.TextView import androidx.fragment.app.DialogFragment +import kotlinx.coroutines.flow.collect import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.model.VoucherSubmissionError import net.mullvad.mullvadvpn.model.VoucherSubmissionResult @@ -49,19 +50,19 @@ class RedeemVoucherDialogFragment : DialogFragment() { parentActivity = context as MainActivity parentActivity.serviceNotifier.subscribe(this) { connection -> - accountCache?.onAccountExpiryChange?.unsubscribe(this@RedeemVoucherDialogFragment) - - accountCache = connection?.accountCache?.apply { - onAccountExpiryChange - .subscribe(this@RedeemVoucherDialogFragment) { newAccountExpiry -> - accountExpiry = newAccountExpiry - } - } - + accountCache = connection?.accountCache voucherRedeemer = connection?.voucherRedeemer + } - updateRedeemButton() + accountCache?.apply { + jobTracker.newUiJob("updateExpiry") { + accountCache?.accountExpiryState?.collect { state -> + accountExpiry = state.date() + } + } } + + updateRedeemButton() } override fun onCreateView( @@ -121,6 +122,7 @@ class RedeemVoucherDialogFragment : DialogFragment() { } override fun onDetach() { + jobTracker.cancelJob("updateExpiry") parentActivity.serviceNotifier.unsubscribe(this) super.onDetach() @@ -143,10 +145,6 @@ class RedeemVoucherDialogFragment : DialogFragment() { private fun handleAddedTime(timeAdded: Long) { if (timeAdded > 0) { - accountExpiry?.let { oldAccountExpiry -> - accountCache?.invalidateAccountExpiry(oldAccountExpiry) - } - dismiss() } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt index dea5d900db..08afd5c59e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt @@ -8,6 +8,7 @@ import android.widget.ImageButton import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.model.DeviceState @@ -107,10 +108,6 @@ class SettingsFragment : ServiceAwareFragment(), StatusBarPainter, NavigationBar active = false versionInfoCache?.onUpdate = null - accountCache?.apply { - onAccountExpiryChange.unsubscribe(this@SettingsFragment) - } - jobTracker.cancelAllJobs() super.onStop() @@ -123,10 +120,12 @@ class SettingsFragment : ServiceAwareFragment(), StatusBarPainter, NavigationBar private fun configureListeners() { accountCache?.apply { - onAccountExpiryChange.subscribe(this@SettingsFragment) { expiry -> - jobTracker.newUiJob("updateAccountInfo") { - accountMenu.accountExpiry = expiry - } + jobTracker.newUiJob("updateAccountExpiry") { + accountExpiryState + .map { state -> state.date() } + .collect { expiryDate -> + accountMenu.accountExpiry = expiryDate + } } fetchAccountExpiry() 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 ad3ee9c5e9..92364fdf0f 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 @@ -64,8 +64,10 @@ class WelcomeFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) { } } - accountCache.onAccountExpiryChange.subscribe(this) { expiry -> - checkExpiry(expiry) + jobTracker.newUiJob("checkAccountExpiry") { + accountCache.accountExpiryState.collect { + checkExpiry(it.date()) + } } jobTracker.newBackgroundJob("pollAccountData") { @@ -79,7 +81,7 @@ class WelcomeFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) { } override fun onSafelyStop() { - accountCache.onAccountExpiryChange.unsubscribe(this) + jobTracker.cancelJob("checkAccountExpiry") jobTracker.cancelJob("pollAccountData") jobTracker.cancelJob("updateAccountNumber") } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/AccountExpiryNotification.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/AccountExpiryNotification.kt index b959a06607..794c372f72 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/AccountExpiryNotification.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/AccountExpiryNotification.kt @@ -1,6 +1,7 @@ package net.mullvad.mullvadvpn.ui.notification import android.content.Context +import kotlinx.coroutines.flow.collect import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.ui.serviceconnection.AccountCache import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache @@ -20,15 +21,15 @@ class AccountExpiryNotification( } override fun onResume() { - accountCache.onAccountExpiryChange.subscribe(this) { accountExpiry -> - jobTracker.newUiJob("updateAccountExpiry") { - updateAccountExpiry(accountExpiry) + jobTracker.newUiJob("updateAccountExpiry") { + accountCache.accountExpiryState.collect { state -> + updateAccountExpiry(state.date()) } } } override fun onPause() { - accountCache.onAccountExpiryChange.unsubscribe(this) + jobTracker.cancelJob("updateAccountExpiry") } private fun updateAccountExpiry(expiry: DateTime?) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AccountCache.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AccountCache.kt index 6bec5c88b5..ed42b34a7d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AccountCache.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AccountCache.kt @@ -1,30 +1,57 @@ package net.mullvad.mullvadvpn.ui.serviceconnection import android.os.Messenger +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import net.mullvad.mullvadvpn.ipc.Event import net.mullvad.mullvadvpn.ipc.EventDispatcher import net.mullvad.mullvadvpn.ipc.Request -import net.mullvad.mullvadvpn.model.LoginStatus -import net.mullvad.talpid.util.EventNotifier -import org.joda.time.DateTime +import net.mullvad.mullvadvpn.model.AccountCreationResult +import net.mullvad.mullvadvpn.model.AccountExpiry +import net.mullvad.mullvadvpn.model.AccountHistory class AccountCache(private val connection: Messenger, eventDispatcher: EventDispatcher) { - val onAccountExpiryChange = EventNotifier<DateTime?>(null) - val onAccountHistoryChange = EventNotifier<String?>(null) - val onLoginStatusChange = EventNotifier<LoginStatus?>(null) - private var accountHistory by onAccountHistoryChange.notifiable() - private var loginStatus by onLoginStatusChange.notifiable() + private val _accountCreationEvents = MutableSharedFlow<AccountCreationResult>( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val accountCreationEvents = _accountCreationEvents.asSharedFlow() + + private val _accountExpiryState = MutableStateFlow<AccountExpiry>(AccountExpiry.Missing) + val accountExpiryState = _accountExpiryState.asStateFlow() + + private val _accountHistoryEvents = MutableSharedFlow<AccountHistory>( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val accountHistoryEvents = _accountHistoryEvents.asSharedFlow() + + private val _loginEvents = MutableSharedFlow<Event.LoginEvent>( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val loginEvents = _loginEvents.asSharedFlow() init { eventDispatcher.apply { - registerHandler(Event.AccountHistory::class) { event -> - accountHistory = event.history + registerHandler(Event.AccountCreationEvent::class) { event -> + _accountCreationEvents.tryEmit(event.result) + } + + registerHandler(Event.AccountExpiryEvent::class) { event -> + _accountExpiryState.tryEmit(event.expiry) } - registerHandler(Event.LoginStatus::class) { event -> - loginStatus = event.status - onAccountExpiryChange.notifyIfChanged(loginStatus?.expiry) + registerHandler(Event.AccountHistoryEvent::class) { event -> + _accountHistoryEvents.tryEmit(event.history) + } + + registerHandler(Event.LoginEvent::class) { event -> + _loginEvents.tryEmit(event) } } } @@ -45,19 +72,11 @@ class AccountCache(private val connection: Messenger, eventDispatcher: EventDisp connection.send(Request.FetchAccountExpiry.message) } - fun invalidateAccountExpiry(accountExpiryToInvalidate: DateTime) { - val request = Request.InvalidateAccountExpiry(accountExpiryToInvalidate) - - connection.send(request.message) + fun fetchAccountHistory() { + connection.send(Request.FetchAccountHistory.message) } fun clearAccountHistory() { connection.send(Request.ClearAccountHistory.message) } - - fun onDestroy() { - onAccountExpiryChange.unsubscribeAll() - onAccountHistoryChange.unsubscribeAll() - onLoginStatusChange.unsubscribeAll() - } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnection.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnection.kt index bea6c34ecb..7c44013bca 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnection.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnection.kt @@ -61,7 +61,6 @@ class ServiceConnection( closeScope() dispatcher.onDestroy() - accountCache.onDestroy() authTokenCache.onDestroy() connectionProxy.onDestroy() keyStatusListener.onDestroy() 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 index 55079458fa..cb4f151fd1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt @@ -1,12 +1,17 @@ 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 androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.model.AccountCreationResult +import net.mullvad.mullvadvpn.model.AccountHistory import net.mullvad.mullvadvpn.model.LoginResult import net.mullvad.mullvadvpn.ui.serviceconnection.AccountCache @@ -14,9 +19,10 @@ 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 val _accountHistory = MutableStateFlow<AccountHistory>(AccountHistory.Missing) + val accountHistory: StateFlow<AccountHistory> = _accountHistory private var accountCache: AccountCache? = null @@ -38,8 +44,15 @@ class LoginViewModel( // 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() } + accountCache = newAccountCache?.apply { + viewModelScope.launch { + accountHistoryEvents.collect { + _accountHistory.value = it + } + } + + fetchAccountHistory() + } } fun clearAccountHistory() { @@ -47,50 +60,44 @@ class LoginViewModel( } fun createAccount() { - _uiState.value = LoginUiState.CreatingAccount - accountCache?.createNewAccount() + accountCache?.apply { + _uiState.value = LoginUiState.CreatingAccount + + viewModelScope.launch { + _uiState.value = accountCreationEvents.first().mapToUiState() + } + + createNewAccount() + } } fun login(accountToken: String) { - _uiState.value = LoginUiState.Loading - accountCache?.login(accountToken) - } + accountCache?.apply { + _uiState.value = LoginUiState.Loading - @RestrictTo(RestrictTo.Scope.TESTS) - public override fun onCleared() { - accountCache?.unsubscribe() - } + viewModelScope.launch { + _uiState.value = loginEvents.first().result.mapToUiState() + } - private fun AccountCache.subscribe() { - onAccountHistoryChange.subscribe(this) { history -> - _accountHistory.value = history + login(accountToken) } + } - 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 AccountCreationResult.mapToUiState(): LoginUiState { + return if (this is AccountCreationResult.Success) { + LoginUiState.AccountCreated + } else { + LoginUiState.UnableToCreateAccountError } } - private fun AccountCache.unsubscribe() { - onAccountHistoryChange.unsubscribe(this) - onLoginStatusChange.unsubscribe(this) + private fun LoginResult.mapToUiState(): LoginUiState { + return when (this) { + LoginResult.Ok -> LoginUiState.Success(false) + LoginResult.InvalidAccount -> LoginUiState.InvalidAccountError + LoginResult.MaxDevicesReached -> LoginUiState.TooManyDevicesError + else -> LoginUiState.OtherError(errorMessage = this.toString()) + } } class Factory(val application: Application) : 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 index 174c378e23..2a615fcc72 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt @@ -3,20 +3,21 @@ 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.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.TestCoroutineDispatcher import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.setMain +import net.mullvad.mullvadvpn.ipc.Event +import net.mullvad.mullvadvpn.model.AccountCreationResult +import net.mullvad.mullvadvpn.model.AccountHistory 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 @@ -25,37 +26,20 @@ 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>() + + private val accountCreationTestEvents = MutableSharedFlow<AccountCreationResult>() + private val accountHistoryTestEvents = MutableSharedFlow<AccountHistory>() + private val loginTestEvents = MutableSharedFlow<Event.LoginEvent>() @Before fun setup() { + Dispatchers.setMain(TestCoroutineDispatcher()) 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 + every { mockedAccountCache.accountCreationEvents } returns accountCreationTestEvents + every { mockedAccountCache.accountHistoryEvents } returns accountHistoryTestEvents + every { mockedAccountCache.loginEvents } returns loginTestEvents loginViewModel = LoginViewModel(mockk()) } @@ -69,23 +53,13 @@ class LoginViewModelTest { } @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) + accountCreationTestEvents.emit(AccountCreationResult.Success(DUMMY_ACCOUNT_TOKEN)) assertEquals(LoginViewModel.LoginUiState.AccountCreated, awaitItem()) } } @@ -95,9 +69,9 @@ class LoginViewModelTest { loginViewModel.updateAccountCacheInstance(mockedAccountCache) loginViewModel.uiState.test { skipDefaultItem() - loginViewModel.login("") + loginViewModel.login(DUMMY_ACCOUNT_TOKEN) assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem()) - capturedLoginStatusNotifierCallback.captured.invoke(DummyLoginStatus.SUCCESSFUL_LOGIN) + loginTestEvents.emit(Event.LoginEvent(LoginResult.Ok)) assertEquals(LoginViewModel.LoginUiState.Success(isOutOfTime = false), awaitItem()) } } @@ -107,11 +81,9 @@ class LoginViewModelTest { loginViewModel.updateAccountCacheInstance(mockedAccountCache) loginViewModel.uiState.test { skipDefaultItem() - loginViewModel.login("") + loginViewModel.login(DUMMY_ACCOUNT_TOKEN) assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem()) - capturedLoginStatusNotifierCallback.captured.invoke( - DummyLoginStatus.INVALID_ACCOUNT_ERROR - ) + loginTestEvents.emit(Event.LoginEvent(LoginResult.InvalidAccount)) assertEquals(LoginViewModel.LoginUiState.InvalidAccountError, awaitItem()) } } @@ -121,9 +93,9 @@ class LoginViewModelTest { loginViewModel.updateAccountCacheInstance(mockedAccountCache) loginViewModel.uiState.test { skipDefaultItem() - loginViewModel.login("") + loginViewModel.login(DUMMY_ACCOUNT_TOKEN) assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem()) - capturedLoginStatusNotifierCallback.captured.invoke(DummyLoginStatus.MAX_DEVICES_ERROR) + loginTestEvents.emit(Event.LoginEvent(LoginResult.MaxDevicesReached)) assertEquals(LoginViewModel.LoginUiState.TooManyDevicesError, awaitItem()) } } @@ -133,10 +105,13 @@ class LoginViewModelTest { loginViewModel.updateAccountCacheInstance(mockedAccountCache) loginViewModel.uiState.test { skipDefaultItem() - loginViewModel.login("") + loginViewModel.login(DUMMY_ACCOUNT_TOKEN) assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem()) - capturedLoginStatusNotifierCallback.captured.invoke(DummyLoginStatus.RPC_ERROR) - assertEquals(LoginViewModel.LoginUiState.OtherError("RpcError"), awaitItem()) + loginTestEvents.emit(Event.LoginEvent(LoginResult.RpcError)) + assertEquals( + LoginViewModel.LoginUiState.OtherError(EXPECTED_RPC_ERROR_MESSAGE), + awaitItem() + ) } } @@ -145,10 +120,13 @@ class LoginViewModelTest { loginViewModel.updateAccountCacheInstance(mockedAccountCache) loginViewModel.uiState.test { skipDefaultItem() - loginViewModel.login("") + loginViewModel.login(DUMMY_ACCOUNT_TOKEN) assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem()) - capturedLoginStatusNotifierCallback.captured.invoke(DummyLoginStatus.OTHER_ERROR) - assertEquals(LoginViewModel.LoginUiState.OtherError("OtherError"), awaitItem()) + loginTestEvents.emit(Event.LoginEvent(LoginResult.OtherError)) + assertEquals( + LoginViewModel.LoginUiState.OtherError(EXPECTED_OTHER_ERROR_MESSAGE), + awaitItem() + ) } } @@ -156,8 +134,10 @@ class LoginViewModelTest { fun testAccountHistory() = runBlockingTest { loginViewModel.updateAccountCacheInstance(mockedAccountCache) loginViewModel.accountHistory.test { skipDefaultItem() } - capturedAccountHistoryNotifierCallback.invoke(DUMMY_ACCOUNT_TOKEN) - loginViewModel.accountHistory.test { assertEquals(DUMMY_ACCOUNT_TOKEN, awaitItem()) } + accountHistoryTestEvents.emit(AccountHistory.Available(DUMMY_ACCOUNT_TOKEN)) + loginViewModel.accountHistory.test { + assertEquals(AccountHistory.Available(DUMMY_ACCOUNT_TOKEN), awaitItem()) + } } @Test @@ -173,49 +153,7 @@ class LoginViewModelTest { 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 - ) - } + private const val EXPECTED_RPC_ERROR_MESSAGE = "RpcError" + private const val EXPECTED_OTHER_ERROR_MESSAGE = "OtherError" } } |
