diff options
| author | Albin <albin@mullvad.net> | 2022-07-20 11:25:46 +0200 |
|---|---|---|
| committer | Albin <albin@mullvad.net> | 2022-07-20 11:25:46 +0200 |
| commit | 7880feb099f6ed4855989ad293bbd3f7cd3e23c0 (patch) | |
| tree | 64759a99e751f27e0ed5d3deb5819b5235654c7b | |
| parent | 98af54098d42ef385398a99b13b593da2376528e (diff) | |
| parent | 83aac3304d4b644e0677bec85527988cbf449ddd (diff) | |
| download | mullvadvpn-7880feb099f6ed4855989ad293bbd3f7cd3e23c0.tar.xz mullvadvpn-7880feb099f6ed4855989ad293bbd3f7cd3e23c0.zip | |
Merge branch 'migrate-from-service-dependent-fragments'
29 files changed, 855 insertions, 815 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index 9c0461022f..38fa9117f7 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -6,6 +6,9 @@ import kotlinx.coroutines.Dispatchers import net.mullvad.mullvadvpn.applist.ApplicationsIconManager import net.mullvad.mullvadvpn.applist.ApplicationsProvider import net.mullvad.mullvadvpn.ipc.EventDispatcher +import net.mullvad.mullvadvpn.ui.notification.AccountExpiryNotification +import net.mullvad.mullvadvpn.ui.notification.TunnelStateNotification +import net.mullvad.mullvadvpn.ui.notification.VersionInfoNotification import net.mullvad.mullvadvpn.ui.serviceconnection.AccountRepository import net.mullvad.mullvadvpn.ui.serviceconnection.DeviceRepository import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager @@ -39,6 +42,10 @@ val uiModule = module { single { ServiceConnectionManager(androidContext()) } + single { AccountExpiryNotification(get()) } + single { TunnelStateNotification(get()) } + single { VersionInfoNotification(get()) } + single { AccountRepository(get()) } single { DeviceRepository(get()) } viewModel { LoginViewModel(get(), get()) } 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 c648c3f7e6..415aa54046 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 @@ -1,5 +1,6 @@ package net.mullvad.mullvadvpn.ui +import android.app.Activity import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -10,30 +11,42 @@ import androidx.lifecycle.repeatOnLifecycle import java.text.DateFormat import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.ui.extension.openAccountPageInBrowser +import net.mullvad.mullvadvpn.ui.extension.requireMainActivity +import net.mullvad.mullvadvpn.ui.fragments.BaseFragment import net.mullvad.mullvadvpn.ui.serviceconnection.AccountRepository import net.mullvad.mullvadvpn.ui.serviceconnection.DeviceRepository +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState +import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache import net.mullvad.mullvadvpn.ui.widget.Button import net.mullvad.mullvadvpn.ui.widget.CopyableInformationView import net.mullvad.mullvadvpn.ui.widget.InformationView import net.mullvad.mullvadvpn.ui.widget.RedeemVoucherButton import net.mullvad.mullvadvpn.ui.widget.SitePaymentButton +import net.mullvad.mullvadvpn.util.JobTracker +import net.mullvad.mullvadvpn.util.UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS +import net.mullvad.mullvadvpn.util.addDebounceForUnknownState +import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier import net.mullvad.mullvadvpn.util.capitalizeFirstCharOfEachWord import net.mullvad.talpid.tunnel.ErrorStateCause import org.joda.time.DateTime import org.koin.android.ext.android.inject -class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) { +class AccountFragment : BaseFragment() { // Injected dependencies private val accountRepository: AccountRepository by inject() private val deviceRepository: DeviceRepository by inject() - - override val isSecureScreen = true + private val serviceConnectionManager: ServiceConnectionManager by inject() private val dateStyle = DateFormat.MEDIUM private val timeStyle = DateFormat.SHORT @@ -71,7 +84,20 @@ class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) { private lateinit var redeemVoucherButton: RedeemVoucherButton private lateinit var titleController: CollapsibleTitleController - override fun onSafelyCreateView( + @Deprecated("Refactor code to instead rely on Lifecycle.") + private val jobTracker = JobTracker() + + override fun onAttach(activity: Activity) { + super.onAttach(activity) + requireMainActivity().enterSecureScreen(this) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launchUiSubscriptionsOnResume() + } + + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? @@ -79,13 +105,18 @@ class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) { val view = inflater.inflate(R.layout.account, container, false) view.findViewById<View>(R.id.back).setOnClickListener { - parentActivity.onBackPressed() + requireMainActivity().onBackPressed() } sitePaymentButton = view.findViewById<SitePaymentButton>(R.id.site_payment).apply { newAccount = false - prepare(authTokenCache, jobTracker) { + setOnClickAction("openAccountPageInBrowser", jobTracker) { + setEnabled(false) + serviceConnectionManager.authTokenCache()?.fetchAuthToken()?.let { token -> + context.openAccountPageInBrowser(token) + } + setEnabled(true) checkForAddedTime() } } @@ -109,42 +140,35 @@ class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) { return view } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - lifecycleScope.launchUiSubscriptionsOnResume() - } - - override fun onSafelyStart() { - connectionProxy.onUiStateChange.subscribe(this) { uiState -> - jobTracker.newUiJob("updateHasConnectivity") { - hasConnectivity = uiState is TunnelState.Connected || - uiState is TunnelState.Disconnected || - (uiState is TunnelState.Error && !uiState.errorState.isBlocking) - isOffline = uiState is TunnelState.Error && - uiState.errorState.cause is ErrorStateCause.IsOffline - } - } - - sitePaymentButton.updateAuthTokenCache(authTokenCache) - } - - override fun onSafelyStop() { + override fun onStop() { jobTracker.cancelAllJobs() + super.onStop() } - override fun onSafelyDestroyView() { + override fun onDestroyView() { titleController.onDestroy() + super.onDestroyView() + } + + override fun onDetach() { + requireMainActivity().leaveSecureScreen(this) + super.onDetach() } private fun CoroutineScope.launchUiSubscriptionsOnResume() = launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { launchUpdateTextOnDeviceChanges() launchUpdateTextOnExpiryChanges() + launchTunnelStateSubscription() } } private fun CoroutineScope.launchUpdateTextOnDeviceChanges() { launch { deviceRepository.deviceState + .debounce { + it.addDebounceForUnknownState(UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS) + } .collect { state -> accountNumberView.information = state.token() deviceNameView.information = @@ -165,6 +189,28 @@ class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) { } } + private fun CoroutineScope.launchTunnelStateSubscription() { + launch { + serviceConnectionManager.connectionState + .flatMapLatest { state -> + if (state is ServiceConnectionState.ConnectedReady) { + callbackFlowFromNotifier( + state.container.connectionProxy.onUiStateChange + ) + } else { + emptyFlow() + } + } + .collect { uiState -> + hasConnectivity = uiState is TunnelState.Connected || + uiState is TunnelState.Disconnected || + (uiState is TunnelState.Error && !uiState.errorState.isBlocking) + isOffline = uiState is TunnelState.Error && + uiState.errorState.cause is ErrorStateCause.IsOffline + } + } + } + private fun checkForAddedTime() { currentAccountExpiry?.let { expiry -> oldAccountExpiry = expiry diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt index 275981bd83..12282c30a9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt @@ -4,22 +4,45 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import java.net.InetAddress import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.model.Settings import net.mullvad.mullvadvpn.ui.customdns.CustomDnsAdapter +import net.mullvad.mullvadvpn.ui.extension.requireMainActivity +import net.mullvad.mullvadvpn.ui.fragments.BaseFragment import net.mullvad.mullvadvpn.ui.fragments.SplitTunnelingFragment -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState +import net.mullvad.mullvadvpn.ui.serviceconnection.customDns +import net.mullvad.mullvadvpn.ui.serviceconnection.settingsListener import net.mullvad.mullvadvpn.ui.widget.CellSwitch import net.mullvad.mullvadvpn.ui.widget.CustomRecyclerView import net.mullvad.mullvadvpn.ui.widget.MtuCell import net.mullvad.mullvadvpn.ui.widget.NavigateCell import net.mullvad.mullvadvpn.ui.widget.ToggleCell import net.mullvad.mullvadvpn.util.AdapterWithHeader +import net.mullvad.mullvadvpn.util.JobTracker +import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier +import org.koin.android.ext.android.inject + +class AdvancedFragment : BaseFragment() { + + // Injected dependencies + private val serviceConnectionManager: ServiceConnectionManager by inject() -class AdvancedFragment : ServiceDependentFragment(OnNoService.GoBack) { private var isAllowLanEnabled = false // Both customDnsAdapter and customDnsToggle are nullable since onNewServiceConnection, @@ -30,38 +53,115 @@ class AdvancedFragment : ServiceDependentFragment(OnNoService.GoBack) { private lateinit var wireguardMtuInput: MtuCell private lateinit var titleController: CollapsibleTitleController - override fun onSafelyCreateView( + @Deprecated("Refactor code to instead rely on Lifecycle.") + private val jobTracker = JobTracker() + + val shared = serviceConnectionManager.connectionState + .flatMapLatest { state -> + if (state is ServiceConnectionState.ConnectedReady) { + flowOf(state.container) + } else { + emptyFlow() + } + } + .map { + it.customDns + } + .shareIn(lifecycleScope, SharingStarted.WhileSubscribed()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + launch { + serviceConnectionManager.connectionState + .flatMapLatest { state -> + if (state is ServiceConnectionState.ConnectedReady) { + flowOf(state.container) + } else { + emptyFlow() + } + } + .flatMapLatest { + callbackFlowFromNotifier(it.settingsListener.settingsNotifier) + } + .collect { settings -> + if (settings != null) { + updateUi(settings) + } + } + } + + launch { + shared + .flatMapLatest { + callbackFlowFromNotifier(it.onEnabledChanged) + } + .collect { isEnabled -> + customDnsAdapter?.updateState(isEnabled) + jobTracker.newUiJob("updateEnabled") { + if (isEnabled) { + customDnsToggle?.state = CellSwitch.State.ON + } else { + customDnsToggle?.state = CellSwitch.State.OFF + } + } + } + } + + launch { + shared + .flatMapLatest { + callbackFlowFromNotifier(it.onDnsServersChanged) + } + .collect { servers -> + customDnsAdapter?.updateServers(servers) + } + } + } + } + } + + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { + ): View? { val view = inflater.inflate(R.layout.advanced, container, false) view.findViewById<View>(R.id.back).setOnClickListener { customDnsAdapter?.stopEditing() - parentActivity.onBackPressed() + requireActivity().onBackPressed() } titleController = CollapsibleTitleController(view, R.id.contents) customDnsAdapter = CustomDnsAdapter( - onAddServer = { address -> customDns.addDnsServer(address) }, - onRemoveDnsServer = { address -> customDns.removeDnsServer(address) }, + onAddServer = { address -> + serviceConnectionManager.customDns()?.addDnsServer(address) ?: false + }, + onRemoveDnsServer = { address -> + serviceConnectionManager.customDns()?.removeDnsServer(address) ?: false + }, onSetCustomDnsEnabled = { isEnabled -> if (isEnabled) { - customDns.enable() + serviceConnectionManager.customDns()?.enable() } else { - customDns.disable() + serviceConnectionManager.customDns()?.disable() } }, onReplaceDnsServer = { oldServer, newServer -> - customDns.replaceDnsServer(oldServer, newServer) + serviceConnectionManager.customDns()?.replaceDnsServer( + oldServer, + newServer + ) ?: false } ).also { newCustomDnsAdapter -> + newCustomDnsAdapter.confirmAddAddress = ::confirmAddAddress view.findViewById<CustomRecyclerView>(R.id.contents).apply { - layoutManager = LinearLayoutManager(parentActivity) + layoutManager = LinearLayoutManager(requireContext()) adapter = AdapterWithHeader(newCustomDnsAdapter, R.layout.advanced_header).apply { onHeaderAvailable = { headerView -> @@ -84,21 +184,21 @@ class AdvancedFragment : ServiceDependentFragment(OnNoService.GoBack) { return view } - override fun onNewServiceConnection(serviceConnectionContainer: ServiceConnectionContainer) { - super.onNewServiceConnection(serviceConnectionContainer) - subscribeToCustomDnsChanges() - } - - override fun onSafelyDestroyView() { + override fun onDestroyView() { detachBackButtonHandler() customDnsAdapter?.onDestroy() titleController.onDestroy() - settingsListener.settingsNotifier.unsubscribe(this) + super.onDestroyView() } private fun configureHeader(view: View) { wireguardMtuInput = view.findViewById<MtuCell>(R.id.wireguard_mtu).apply { - onSubmit = { mtu -> settingsListener.wireguardMtu = mtu } + onSubmit = { mtu -> + serviceConnectionManager.settingsListener()?.wireguardMtu = mtu + } + value = serviceConnectionManager.settingsListener()?.let { settingsNotifier -> + settingsNotifier.wireguardMtu + } } view.findViewById<NavigateCell>(R.id.split_tunneling).apply { @@ -109,52 +209,18 @@ class AdvancedFragment : ServiceDependentFragment(OnNoService.GoBack) { listener = { state -> jobTracker.newBackgroundJob("toggleCustomDns") { if (state == CellSwitch.State.ON) { - customDns.enable() + serviceConnectionManager.customDns()?.enable() } else { - customDns.disable() + serviceConnectionManager.customDns()?.disable() } } } } - - settingsListener.settingsNotifier.subscribe(this) { maybeSettings -> - maybeSettings?.let { settings -> - updateUi(settings) - } - - isAllowLanEnabled = maybeSettings?.allowLan ?: false - } - - subscribeToCustomDnsChanges() - } - - private fun subscribeToCustomDnsChanges() { - // Ensure there are no previous subscriptions as this function might be called either when - // there view has been created or when there is a new service connection. - customDns.onEnabledChanged.unsubscribe(this) - customDns.onDnsServersChanged.unsubscribe(this) - - customDns.onEnabledChanged.subscribe(this) { isEnabled -> - customDnsAdapter?.updateState(isEnabled) - jobTracker.newUiJob("updateEnabled") { - if (isEnabled) { - customDnsToggle?.state = CellSwitch.State.ON - } else { - customDnsToggle?.state = CellSwitch.State.OFF - } - } - } - - customDns.onDnsServersChanged.subscribe(this) { servers -> - customDnsAdapter?.updateServers(servers) - } } private fun updateUi(settings: Settings) { - jobTracker.newUiJob("updateUi") { - if (!wireguardMtuInput.hasFocus) { - wireguardMtuInput.value = settings.tunnelOptions.wireguard.options.mtu - } + if (this::wireguardMtuInput.isInitialized && wireguardMtuInput.hasFocus == false) { + wireguardMtuInput.value = settings.tunnelOptions.wireguard.options.mtu } } @@ -182,7 +248,7 @@ class AdvancedFragment : ServiceDependentFragment(OnNoService.GoBack) { } private fun attachBackButtonHandler() { - parentActivity.backButtonHandler = { + requireMainActivity().backButtonHandler = { if (customDnsAdapter?.isEditing == true) { customDnsAdapter?.stopEditing() } @@ -191,6 +257,6 @@ class AdvancedFragment : ServiceDependentFragment(OnNoService.GoBack) { } private fun detachBackButtonHandler() { - parentActivity.backButtonHandler = null + requireMainActivity().backButtonHandler = null } } 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 31d1c0dd9c..fdb0adaf13 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 @@ -1,5 +1,7 @@ package net.mullvad.mullvadvpn.ui +import android.content.Intent +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -9,29 +11,51 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.ui.extension.requireMainActivity +import net.mullvad.mullvadvpn.ui.fragments.BaseFragment import net.mullvad.mullvadvpn.ui.notification.AccountExpiryNotification import net.mullvad.mullvadvpn.ui.notification.TunnelStateNotification import net.mullvad.mullvadvpn.ui.notification.VersionInfoNotification import net.mullvad.mullvadvpn.ui.serviceconnection.AccountRepository +import net.mullvad.mullvadvpn.ui.serviceconnection.LocationInfoCache +import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState +import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache +import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy import net.mullvad.mullvadvpn.ui.widget.HeaderBar import net.mullvad.mullvadvpn.ui.widget.NotificationBanner import net.mullvad.mullvadvpn.ui.widget.SwitchLocationButton +import net.mullvad.mullvadvpn.util.JobTracker +import net.mullvad.mullvadvpn.util.appVersionCallbackFlow +import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier import org.joda.time.DateTime import org.koin.android.ext.android.inject val KEY_IS_TUNNEL_INFO_EXPANDED = "is_tunnel_info_expanded" -class ConnectFragment : - ServiceDependentFragment(OnNoService.GoToLaunchScreen), NavigationBarPainter { +class ConnectFragment : BaseFragment(), NavigationBarPainter { // Injected dependencies private val accountRepository: AccountRepository by inject() + private val accountExpiryNotification: AccountExpiryNotification by inject() + private val serviceConnectionManager: ServiceConnectionManager by inject() + private val tunnelStateNotification: TunnelStateNotification by inject() + private val versionInfoNotification: VersionInfoNotification by inject() private lateinit var actionButton: ConnectActionButton private lateinit var switchLocationButton: SwitchLocationButton @@ -42,49 +66,58 @@ class ConnectFragment : private var isTunnelInfoExpanded = false + @Deprecated("Refactor code to instead rely on Lifecycle.") + private val jobTracker = JobTracker() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) isTunnelInfoExpanded = savedInstanceState?.getBoolean(KEY_IS_TUNNEL_INFO_EXPANDED, false) ?: false + + lifecycleScope.launchUiSubscriptionsOnResume() } - override fun onSafelyCreateView( + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { + ): View? { val view = inflater.inflate(R.layout.connect, container, false) headerBar = view.findViewById<HeaderBar>(R.id.header_bar).apply { tunnelState = TunnelState.Disconnected } + accountExpiryNotification.onClick = { + serviceConnectionManager.authTokenCache()?.fetchAuthToken()?.let { token -> + val url = getString(R.string.account_url) + val ready = Uri.parse("$url?token=$token") + requireContext().startActivity(Intent(Intent.ACTION_VIEW, ready)) + } + } + notificationBanner = view.findViewById<NotificationBanner>(R.id.notification_banner).apply { notifications.apply { - register(TunnelStateNotification(parentActivity, connectionProxy)) - register(VersionInfoNotification(parentActivity, appVersionInfoCache)) - register( - AccountExpiryNotification( - parentActivity, - authTokenCache, - accountRepository - ) - ) + // NOTE: The order of below notifications is significant. + register(tunnelStateNotification) + register(versionInfoNotification) + register(accountExpiryNotification) } } - status = ConnectionStatus(view, parentActivity) + status = ConnectionStatus(view, requireMainActivity()) locationInfo = LocationInfo(view, requireContext()) locationInfo.isTunnelInfoExpanded = isTunnelInfoExpanded actionButton = ConnectActionButton(view) + actionButton.apply { - onConnect = { connectionProxy.connect() } - onCancel = { connectionProxy.disconnect() } - onReconnect = { connectionProxy.reconnect() } - onDisconnect = { connectionProxy.disconnect() } + onConnect = { serviceConnectionManager.connectionProxy()?.connect() } + onCancel = { serviceConnectionManager.connectionProxy()?.disconnect() } + onReconnect = { serviceConnectionManager.connectionProxy()?.reconnect() } + onDisconnect = { serviceConnectionManager.connectionProxy()?.disconnect() } } switchLocationButton = view.findViewById<SwitchLocationButton>(R.id.switch_location).apply { @@ -94,54 +127,10 @@ class ConnectFragment : return view } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - lifecycleScope.launchUiSubscriptionsOnResume() - } - - override fun onSafelyStart() { + override fun onStart() { + super.onStart() locationInfo.isTunnelInfoExpanded = isTunnelInfoExpanded - notificationBanner.onResume() - - locationInfoCache.onNewLocation = { location -> - jobTracker.newUiJob("updateLocationInfo") { - locationInfo.location = location - } - } - - relayListListener.onRelayListChange = { _, selectedRelayItem -> - jobTracker.newUiJob("updateSelectedRelayItem") { - switchLocationButton.location = selectedRelayItem - } - } - - connectionProxy.onUiStateChange.subscribe(this) { uiState -> - jobTracker.newUiJob("updateTunnelState") { - updateTunnelState(uiState, connectionProxy.state) - } - } - } - - override fun onSafelyStop() { - jobTracker.cancelAllJobs() - - locationInfoCache.onNewLocation = null - relayListListener.onRelayListChange = null - - connectionProxy.onUiStateChange.unsubscribe(this) - - notificationBanner.onPause() - - isTunnelInfoExpanded = locationInfo.isTunnelInfoExpanded - } - - override fun onSafelyDestroyView() { - notificationBanner.onDestroy() - } - - override fun onSafelySaveInstanceState(state: Bundle) { - isTunnelInfoExpanded = locationInfo.isTunnelInfoExpanded - state.putBoolean(KEY_IS_TUNNEL_INFO_EXPANDED, isTunnelInfoExpanded) } override fun onResume() { @@ -149,9 +138,24 @@ class ConnectFragment : paintNavigationBar(ContextCompat.getColor(requireContext(), R.color.blue)) } + val shared = serviceConnectionManager.connectionState + .flatMapLatest { state -> + if (state is ServiceConnectionState.ConnectedReady) { + flowOf(state.container) + } else { + emptyFlow() + } + } + .shareIn(lifecycleScope, SharingStarted.WhileSubscribed()) + private fun CoroutineScope.launchUiSubscriptionsOnResume() = launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { launchScheduledExpiryCheck() + launchLocationSubscription() + launchRelayLocationSubscription() + launchTunnelStateSubscription() + launchVersionInfoSubscription() + launchAccountExpirySubscription() } } @@ -166,6 +170,61 @@ class ConnectFragment : } } + private fun CoroutineScope.launchLocationSubscription() = launch { + shared + .flatMapLatest { it.locationInfoCache.locationCallbackFlow() } + .collect { locationInfo.location = it } + } + + private fun LocationInfoCache.locationCallbackFlow() = callbackFlow { + onNewLocation = { + this.trySend(it) + } + awaitClose { onNewLocation = null } + } + + private fun CoroutineScope.launchRelayLocationSubscription() = launch { + shared + .flatMapLatest { it.relayListListener.relayListCallbackFlow() } + .collect { switchLocationButton.location = it } + } + + private fun RelayListListener.relayListCallbackFlow() = callbackFlow { + onRelayListChange = { _, item -> + this.trySend(item) + } + awaitClose { onRelayListChange = null } + } + + private fun CoroutineScope.launchTunnelStateSubscription() = launch { + shared + .flatMapLatest { + combine( + callbackFlowFromNotifier(it.connectionProxy.onUiStateChange), + callbackFlowFromNotifier(it.connectionProxy.onStateChange) + ) { uiState, realState -> + Pair(uiState, realState) + } + } + .collect { (uiState, realState) -> + tunnelStateNotification.updateTunnelState(uiState) + updateTunnelState(uiState, realState) + } + } + + private fun CoroutineScope.launchVersionInfoSubscription() = launch { + shared + .flatMapLatest { it.appVersionInfoCache.appVersionCallbackFlow() } + .collect { versionInfo -> versionInfoNotification.updateVersionInfo(versionInfo) } + } + + private fun CoroutineScope.launchAccountExpirySubscription() = launch { + accountRepository.accountExpiryState + .collect { + accountExpiryNotification.updateAccountExpiry(it.date()) + } + } + private fun updateTunnelState(uiState: TunnelState, realState: TunnelState) { locationInfo.state = realState headerBar.tunnelState = realState 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 d0e6fbf2f3..18e2f08b95 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 @@ -15,16 +15,17 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.ui.extension.requireMainActivity import net.mullvad.mullvadvpn.ui.fragments.ACCOUNT_TOKEN_ARGUMENT_KEY +import net.mullvad.mullvadvpn.ui.fragments.BaseFragment import net.mullvad.mullvadvpn.ui.fragments.DeviceListFragment import net.mullvad.mullvadvpn.ui.widget.AccountLogin import net.mullvad.mullvadvpn.ui.widget.HeaderBar +import net.mullvad.mullvadvpn.util.JobTracker import net.mullvad.mullvadvpn.viewmodel.LoginViewModel import org.koin.androidx.viewmodel.ext.android.viewModel -class LoginFragment : - ServiceDependentFragment(OnNoService.GoToLaunchScreen), - NavigationBarPainter { +class LoginFragment : BaseFragment(), NavigationBarPainter { private val loginViewModel: LoginViewModel by viewModel() @@ -38,11 +39,19 @@ class LoginFragment : private lateinit var background: View private lateinit var headerBar: HeaderBar - override fun onSafelyCreateView( + @Deprecated("Refactor code to instead rely on Lifecycle.") + private val jobTracker = JobTracker() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launchUiSubscriptionsOnResume() + } + + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { + ): View? { val view = inflater.inflate(R.layout.login, container, false) headerBar = view.findViewById(R.id.header_bar) @@ -74,12 +83,9 @@ class LoginFragment : return view } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - lifecycleScope.launchUiSubscriptionsOnResume() - } - - override fun onSafelyStart() { - parentActivity.backButtonHandler = { + override fun onStart() { + super.onStart() + requireMainActivity().backButtonHandler = { if (accountLogin.hasFocus) { background.requestFocus() true @@ -94,8 +100,10 @@ class LoginFragment : paintNavigationBar(ContextCompat.getColor(requireContext(), R.color.darkBlue)) } - override fun onSafelyStop() { - parentActivity.backButtonHandler = null + override fun onStop() { + jobTracker.cancelAllJobs() + requireMainActivity().backButtonHandler = null + super.onStop() } private fun triggerAutoLoginIfAccountTokenPresent() { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt index f1831f0a88..ec31a4c706 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt @@ -27,6 +27,8 @@ import net.mullvad.mullvadvpn.ui.fragments.DeviceRevokedFragment import net.mullvad.mullvadvpn.ui.serviceconnection.AccountRepository import net.mullvad.mullvadvpn.ui.serviceconnection.DeviceRepository import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.util.UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS +import net.mullvad.mullvadvpn.util.addDebounceForUnknownState import org.koin.android.ext.android.getKoin import org.koin.core.context.loadKoinModules @@ -159,7 +161,7 @@ open class MainActivity : FragmentActivity() { .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) .debounce { // Debounce DeviceState.Unknown to delay view transitions during reconnect. - it.addDebounceForUnknownState() + it.addDebounceForUnknownState(UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS) } .collect { newState -> if (newState != currentState) { @@ -178,14 +180,6 @@ open class MainActivity : FragmentActivity() { } } - private fun DeviceState.addDebounceForUnknownState(): Long { - return if (this is DeviceState.Unknown) { - UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS - } else { - ZERO_DEBOUNCE_DELAY_MILLISECONDS - } - } - @Suppress("DEPRECATION") private fun requestVpnPermission() { val intent = VpnService.prepare(this) @@ -243,9 +237,4 @@ open class MainActivity : FragmentActivity() { } } } - - companion object { - private const val ZERO_DEBOUNCE_DELAY_MILLISECONDS = 0L - private const val UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS = 2000L - } } 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 03aa10c264..ce6c2f690f 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 @@ -12,24 +12,35 @@ import kotlin.properties.Delegates.observable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.ui.extension.openAccountPageInBrowser +import net.mullvad.mullvadvpn.ui.fragments.BaseFragment import net.mullvad.mullvadvpn.ui.serviceconnection.AccountRepository +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState +import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache +import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy import net.mullvad.mullvadvpn.ui.widget.Button import net.mullvad.mullvadvpn.ui.widget.HeaderBar import net.mullvad.mullvadvpn.ui.widget.RedeemVoucherButton import net.mullvad.mullvadvpn.ui.widget.SitePaymentButton +import net.mullvad.mullvadvpn.util.JobTracker +import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier import net.mullvad.talpid.tunnel.ActionAfterDisconnect import net.mullvad.talpid.tunnel.ErrorStateCause import org.joda.time.DateTime import org.koin.android.ext.android.inject -class OutOfTimeFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) { +class OutOfTimeFragment : BaseFragment() { // Injected dependencies private val accountRepository: AccountRepository by inject() + private val serviceConnectionManager: ServiceConnectionManager by inject() private lateinit var headerBar: HeaderBar @@ -43,11 +54,19 @@ class OutOfTimeFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) headerBar.tunnelState = state } - override fun onSafelyCreateView( + @Deprecated("Refactor code to instead rely on Lifecycle.") + private val jobTracker = JobTracker() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launchUiSubscriptionsOnResume() + } + + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { + ): View? { val view = inflater.inflate(R.layout.out_of_time, container, false) headerBar = view.findViewById<HeaderBar>(R.id.header_bar).apply { @@ -55,61 +74,48 @@ class OutOfTimeFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) } view.findViewById<TextView>(R.id.account_credit_has_expired).text = buildString { - append(parentActivity.getString(R.string.account_credit_has_expired)) + append(requireActivity().getString(R.string.account_credit_has_expired)) append(" ") - parentActivity.getString(R.string.add_time_to_account) + requireActivity().getString(R.string.add_time_to_account) } disconnectButton = view.findViewById<Button>(R.id.disconnect).apply { setOnClickAction("disconnect", jobTracker) { - connectionProxy.disconnect() + serviceConnectionManager.connectionProxy()?.disconnect() } } sitePaymentButton = view.findViewById<SitePaymentButton>(R.id.site_payment).apply { newAccount = false - prepare(authTokenCache, jobTracker) + + setOnClickAction("openAccountPageInBrowser", jobTracker) { + setEnabled(false) + serviceConnectionManager.authTokenCache()?.fetchAuthToken()?.let { token -> + context.openAccountPageInBrowser(token) + } + setEnabled(true) + } + + isEnabled = true } redeemButton = view.findViewById<RedeemVoucherButton>(R.id.redeem_voucher).apply { prepare(parentFragmentManager, jobTracker) } - connectionProxy.onStateChange.subscribe(this) { newState -> - jobTracker.newUiJob("updateTunnelState") { - tunnelState = newState - } - } - return view } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - lifecycleScope.launchUiSubscriptionsOnResume() - } - - override fun onSafelyStart() { - jobTracker.newBackgroundJob("pollAccountData") { - while (true) { - accountRepository.fetchAccountExpiry() - delay(POLL_INTERVAL) - } - } - - sitePaymentButton.updateAuthTokenCache(authTokenCache) - } - - override fun onSafelyStop() { - jobTracker.cancelJob("pollAccountData") - } - - override fun onSafelyDestroyView() { - connectionProxy.onStateChange.unsubscribe(this) + override fun onStop() { + jobTracker.cancelAllJobs() + super.onStop() } private fun CoroutineScope.launchUiSubscriptionsOnResume() = launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { launchProceedToConnectViewIfExpiryExtended() + launchExpiryPolling() + launchTunnelStateSubscription() } } @@ -121,6 +127,29 @@ class OutOfTimeFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) } } + private fun CoroutineScope.launchExpiryPolling() = launch { + while (true) { + accountRepository.fetchAccountExpiry() + delay(POLL_INTERVAL) + } + } + + private fun CoroutineScope.launchTunnelStateSubscription() = launch { + serviceConnectionManager.connectionState + .flatMapLatest { state -> + if (state is ServiceConnectionState.ConnectedReady) { + callbackFlowFromNotifier( + state.container.connectionProxy.onStateChange + ) + } else { + emptyFlow() + } + } + .collect { newState -> + tunnelState = newState + } + } + private fun updateDisconnectButton() { val state = tunnelState diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/PreferencesFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/PreferencesFragment.kt index b3e85a94cc..5b76def952 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/PreferencesFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/PreferencesFragment.kt @@ -4,59 +4,107 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.model.Settings +import net.mullvad.mullvadvpn.ui.extension.requireMainActivity +import net.mullvad.mullvadvpn.ui.fragments.BaseFragment +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState +import net.mullvad.mullvadvpn.ui.serviceconnection.settingsListener import net.mullvad.mullvadvpn.ui.widget.CellSwitch import net.mullvad.mullvadvpn.ui.widget.ToggleCell +import net.mullvad.mullvadvpn.util.JobTracker +import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier +import org.koin.android.ext.android.inject + +class PreferencesFragment : BaseFragment() { + + // Injected dependencies + private val serviceConnectionManager: ServiceConnectionManager by inject() -class PreferencesFragment : ServiceDependentFragment(OnNoService.GoBack) { private lateinit var allowLanToggle: ToggleCell private lateinit var autoConnectToggle: ToggleCell private lateinit var titleController: CollapsibleTitleController - override fun onSafelyCreateView( + @Deprecated("Refactor code to instead rely on Lifecycle.") + private val jobTracker = JobTracker() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launchUiSubscriptionsOnResume() + } + + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { + ): View? { val view = inflater.inflate(R.layout.preferences, container, false) view.findViewById<View>(R.id.back).setOnClickListener { - parentActivity.onBackPressed() + requireMainActivity().onBackPressed() } allowLanToggle = view.findViewById<ToggleCell>(R.id.allow_lan).apply { listener = { state -> - when (state) { - CellSwitch.State.ON -> settingsListener.allowLan = true - CellSwitch.State.OFF -> settingsListener.allowLan = false + serviceConnectionManager.settingsListener()?.allowLan = when (state) { + CellSwitch.State.ON -> true + else -> false } } } autoConnectToggle = view.findViewById<ToggleCell>(R.id.auto_connect).apply { listener = { state -> - when (state) { - CellSwitch.State.ON -> settingsListener.autoConnect = true - CellSwitch.State.OFF -> settingsListener.autoConnect = false + serviceConnectionManager.settingsListener()?.autoConnect = when (state) { + CellSwitch.State.ON -> true + else -> false } } } - settingsListener.settingsNotifier.subscribe(this) { maybeSettings -> - maybeSettings?.let { settings -> - updateUi(settings) - } - } - titleController = CollapsibleTitleController(view) return view } - override fun onSafelyDestroyView() { + override fun onDestroyView() { titleController.onDestroy() - settingsListener.settingsNotifier.unsubscribe(this) + super.onDestroyView() + } + + private fun CoroutineScope.launchUiSubscriptionsOnResume() = launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + launchSettingsSubscription() + } + } + + private fun CoroutineScope.launchSettingsSubscription() = launch { + serviceConnectionManager.connectionState + .flatMapLatest { state -> + if (state is ServiceConnectionState.ConnectedReady) { + flowOf(state.container) + } else { + emptyFlow() + } + } + .flatMapLatest { + callbackFlowFromNotifier(it.settingsListener.settingsNotifier) + } + .collect { settings -> + if (settings != null) { + updateUi(settings) + } + } } private fun updateUi(settings: Settings) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SelectLocationFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SelectLocationFragment.kt index 68df75417a..da33465ea5 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SelectLocationFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SelectLocationFragment.kt @@ -10,19 +10,38 @@ import android.view.animation.Animation.AnimationListener import android.view.animation.AnimationUtils import android.widget.ImageButton import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.relaylist.RelayItem import net.mullvad.mullvadvpn.relaylist.RelayList import net.mullvad.mullvadvpn.relaylist.RelayListAdapter +import net.mullvad.mullvadvpn.ui.extension.requireMainActivity +import net.mullvad.mullvadvpn.ui.fragments.BaseFragment +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState +import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy +import net.mullvad.mullvadvpn.ui.serviceconnection.relayListListener import net.mullvad.mullvadvpn.ui.widget.CustomRecyclerView import net.mullvad.mullvadvpn.util.AdapterWithHeader +import net.mullvad.mullvadvpn.util.JobTracker +import org.koin.android.ext.android.inject + +class SelectLocationFragment : BaseFragment(), StatusBarPainter, NavigationBarPainter { + + // Injected dependencies + private val serviceConnectionManager: ServiceConnectionManager by inject() -class SelectLocationFragment : - ServiceDependentFragment(OnNoService.GoToLaunchScreen), StatusBarPainter, NavigationBarPainter { private enum class RelayListState { Initializing, Loading, @@ -35,28 +54,32 @@ class SelectLocationFragment : private var loadingSpinner = CompletableDeferred<View>() private var relayListState = RelayListState.Initializing + @Deprecated("Refactor code to instead rely on Lifecycle.") + private val jobTracker = JobTracker() + override fun onAttach(context: Context) { super.onAttach(context) relayListAdapter = RelayListAdapter(context.resources).apply { onSelect = { relayItem -> - jobTracker.newBackgroundJob("selectRelay") { - relayListListener.selectedRelayLocation = relayItem?.location - connectionProxy.connect() - - jobTracker.newUiJob("close") { - close() - } - } + serviceConnectionManager.relayListListener()?.selectedRelayLocation = + relayItem?.location + serviceConnectionManager.connectionProxy()?.connect() + close() } } } - override fun onSafelyCreateView( + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launchUiSubscriptionsOnResume() + } + + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { + ): View? { val view = inflater.inflate(R.layout.select_location, container, false) view.findViewById<ImageButton>(R.id.close).setOnClickListener { close() } @@ -64,7 +87,7 @@ class SelectLocationFragment : titleController = CollapsibleTitleController(view, R.id.relay_list) view.findViewById<CustomRecyclerView>(R.id.relay_list).apply { - layoutManager = LinearLayoutManager(parentActivity) + layoutManager = LinearLayoutManager(requireMainActivity()) adapter = AdapterWithHeader(relayListAdapter, R.layout.select_location_header).apply { onHeaderAvailable = { headerView -> @@ -83,68 +106,75 @@ class SelectLocationFragment : return view } - override fun onSafelyStart() { - // If the relay list is immediately available, setting the listener will cause it to be - // called right away, while the state is still Initializing. In that case we can skip - // showing the spinner animation and go directly to the Visible state. - // - // If it's not immediately available, then when the listener is called later the state will - // have changed to Loading, and an animation from the spinner to the new relay items will be - // shown. - // - // If the state is ready, it means that the relay list has already been shown, and we can - // update it in place. - relayListListener.onRelayListChange = { relayList, selectedItem -> - when (relayListState) { - RelayListState.Initializing -> { - jobTracker.newUiJob("updateRelayList") { - updateRelayList(relayList, selectedItem) - } - - relayListState = RelayListState.Visible - } - RelayListState.Loading -> { - jobTracker.newUiJob("updateRelayList") { - animateRelayListInitialization(relayList, selectedItem) - } - } - RelayListState.Visible -> { - jobTracker.newUiJob("updateRelayList") { - updateRelayList(relayList, selectedItem) - } - } - } - } - - if (relayListState == RelayListState.Initializing) { - relayListState = RelayListState.Loading - } + override fun onResume() { + super.onResume() + paintNavigationBar(ContextCompat.getColor(requireContext(), R.color.darkBlue)) } - override fun onSafelyStop() { - relayListListener.onRelayListChange = null + override fun onDestroyView() { + titleController.onDestroy() + super.onDestroyView() } - override fun onSafelyDestroyView() { - titleController.onDestroy() + fun close() { + activity?.onBackPressed() } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - lifecycleScope.launchWhenResumed { - transitionFinishedFlow.collect { - paintStatusBar(ContextCompat.getColor(requireContext(), R.color.darkBlue)) - } + private fun CoroutineScope.launchUiSubscriptionsOnResume() = launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + launchPaintStatusBarAfterTransition() + launchRelayListSubscription() } } - override fun onResume() { - super.onResume() - paintNavigationBar(ContextCompat.getColor(requireContext(), R.color.darkBlue)) + private fun CoroutineScope.launchPaintStatusBarAfterTransition() = launch { + transitionFinishedFlow.collect { + paintStatusBar(ContextCompat.getColor(requireContext(), R.color.darkBlue)) + } } - fun close() { - activity?.onBackPressed() + private fun CoroutineScope.launchRelayListSubscription() = launch { + serviceConnectionManager.connectionState + .flatMapLatest { state -> + if (state is ServiceConnectionState.ConnectedReady) { + callbackFlow { + state.container.relayListListener.onRelayListChange = + { list, item -> + this.trySend(Pair(list, item)) + } + + awaitClose { + state.container.relayListListener.onRelayListChange = null + } + } + } else { + emptyFlow() + } + } + .collect { (relayList, selectedItem) -> + when (relayListState) { + RelayListState.Initializing -> { + jobTracker.newUiJob("updateRelayList") { + updateRelayList(relayList, selectedItem) + } + relayListState = RelayListState.Visible + } + RelayListState.Loading -> { + jobTracker.newUiJob("updateRelayList") { + animateRelayListInitialization(relayList, selectedItem) + } + } + RelayListState.Visible -> { + jobTracker.newUiJob("updateRelayList") { + updateRelayList(relayList, selectedItem) + } + } + } + + if (relayListState == RelayListState.Initializing) { + relayListState = RelayListState.Loading + } + } } private fun updateRelayList(relayList: RelayList, selectedItem: RelayItem?) { @@ -181,9 +211,10 @@ class SelectLocationFragment : override fun onAnimationRepeat(animation: Animation) {} } - val fadeOut = AnimationUtils.loadAnimation(parentActivity, R.anim.fade_out).apply { - setAnimationListener(animationListener) - } + val fadeOut = + AnimationUtils.loadAnimation(requireMainActivity(), R.anim.fade_out).apply { + setAnimationListener(animationListener) + } loadingSpinner.await().let { spinner -> spinner.startAnimation(fadeOut) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ServiceAwareFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ServiceAwareFragment.kt deleted file mode 100644 index 32ad70daaa..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ServiceAwareFragment.kt +++ /dev/null @@ -1,69 +0,0 @@ -package net.mullvad.mullvadvpn.ui - -import android.content.Context -import net.mullvad.mullvadvpn.ui.fragments.BaseFragment -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.util.JobTracker -import org.koin.android.ext.android.inject - -abstract class ServiceAwareFragment : BaseFragment() { - private val serviceConnectionManager: ServiceConnectionManager by inject() - - val jobTracker = JobTracker() - - open val isSecureScreen = false - - lateinit var parentActivity: MainActivity - private set - - var serviceConnectionContainer: ServiceConnectionContainer? = null - private set - - override fun onAttach(context: Context) { - super.onAttach(context) - - parentActivity = context as MainActivity - - if (isSecureScreen) { - parentActivity.enterSecureScreen(this) - } - - serviceConnectionManager.serviceNotifier.subscribe(this) { connection -> - configureServiceConnection(connection) - } - } - - override fun onDestroyView() { - jobTracker.cancelAllJobs() - - super.onDestroyView() - } - - override fun onDetach() { - serviceConnectionManager.serviceNotifier.unsubscribe(this) - - if (isSecureScreen) { - parentActivity.leaveSecureScreen(this) - } - - super.onDetach() - } - - abstract fun onNewServiceConnection(serviceConnectionContainer: ServiceConnectionContainer) - - open fun onNoServiceConnection() { - } - - private fun configureServiceConnection( - serviceConnectionContainer: ServiceConnectionContainer? - ) { - this.serviceConnectionContainer = serviceConnectionContainer - - if (serviceConnectionContainer != null) { - onNewServiceConnection(serviceConnectionContainer) - } else { - onNoServiceConnection() - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ServiceDependentFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ServiceDependentFragment.kt deleted file mode 100644 index be2998cfad..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/ServiceDependentFragment.kt +++ /dev/null @@ -1,192 +0,0 @@ -package net.mullvad.mullvadvpn.ui - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoCache -import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache -import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy -import net.mullvad.mullvadvpn.ui.serviceconnection.CustomDns -import net.mullvad.mullvadvpn.ui.serviceconnection.LocationInfoCache -import net.mullvad.mullvadvpn.ui.serviceconnection.RelayListListener -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer -import net.mullvad.mullvadvpn.ui.serviceconnection.SettingsListener -import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling - -abstract class ServiceDependentFragment(private val onNoService: OnNoService) : - ServiceAwareFragment() { - enum class OnNoService { - GoBack, GoToLaunchScreen - } - - enum class State { - Uninitialized, - Initialized, - Active, - Stopped, - LostConnection - } - - private var state = State.Uninitialized - - lateinit var appVersionInfoCache: AppVersionInfoCache - private set - - lateinit var authTokenCache: AuthTokenCache - private set - - lateinit var connectionProxy: ConnectionProxy - private set - - lateinit var customDns: CustomDns - private set - - lateinit var locationInfoCache: LocationInfoCache - private set - - lateinit var relayListListener: RelayListListener - private set - - lateinit var settingsListener: SettingsListener - private set - - lateinit var splitTunneling: SplitTunneling - private set - - override fun onNewServiceConnection(serviceConnectionContainer: ServiceConnectionContainer) { - // This method is always either called first or after an `onNoServiceConnection`, so the - // initialization of the fields doesn't have to be synchronized - appVersionInfoCache = serviceConnectionContainer.appVersionInfoCache - authTokenCache = serviceConnectionContainer.authTokenCache - connectionProxy = serviceConnectionContainer.connectionProxy - customDns = serviceConnectionContainer.customDns - locationInfoCache = serviceConnectionContainer.locationInfoCache - relayListListener = serviceConnectionContainer.relayListListener - settingsListener = serviceConnectionContainer.settingsListener - - splitTunneling = serviceConnectionContainer.splitTunneling - - synchronized(this) { - when (state) { - State.Uninitialized -> state = State.Initialized - State.Active -> { - onSafelyStop() - onSafelyStart() - } - else -> Unit - } - } - } - - override fun onNoServiceConnection() { - synchronized(this) { - when (state) { - State.Uninitialized -> { - state = State.LostConnection - leaveFragment() - } - State.Active -> { - state = State.LostConnection - leaveFragment() - } - else -> Unit - } - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - synchronized(this) { - return when (state) { - State.Initialized, State.Active, State.Stopped -> { - onSafelyCreateView(inflater, container, savedInstanceState) - } - State.Uninitialized, State.LostConnection -> { - inflater.inflate(R.layout.missing_service, container, false) - } - } - } - } - - override fun onStart() { - super.onStart() - - synchronized(this) { - when (state) { - State.Initialized, State.Stopped -> { - state = State.Active - onSafelyStart() - } - else -> Unit - } - } - } - - override fun onSaveInstanceState(instanceState: Bundle) { - synchronized(this) { - when (state) { - State.Initialized, State.Stopped, State.Active -> { - onSafelySaveInstanceState(instanceState) - } - else -> Unit - } - } - } - - override fun onStop() { - synchronized(this) { - when (state) { - State.Initialized, State.Active -> { - onSafelyStop() - state = State.Stopped - } - else -> Unit - } - } - - super.onStop() - } - - override fun onDestroyView() { - synchronized(this) { - when (state) { - State.Initialized, State.Stopped, State.Active -> onSafelyDestroyView() - else -> Unit - } - } - - super.onDestroyView() - } - - abstract fun onSafelyCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View - - open fun onSafelyStart() { - } - - open fun onSafelySaveInstanceState(state: Bundle) { - } - - open fun onSafelyStop() { - } - - open fun onSafelyDestroyView() { - } - - private fun leaveFragment() { - jobTracker.newUiJob("leaveFragment") { - when (onNoService) { - OnNoService.GoBack -> parentActivity.onBackPressed() - OnNoService.GoToLaunchScreen -> parentActivity.returnToLaunchScreen() - } - } - } -} 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 e0f619550c..710f894044 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 @@ -11,23 +11,35 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.model.DeviceState +import net.mullvad.mullvadvpn.ui.fragments.BaseFragment import net.mullvad.mullvadvpn.ui.serviceconnection.AccountRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoCache import net.mullvad.mullvadvpn.ui.serviceconnection.DeviceRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState +import net.mullvad.mullvadvpn.ui.serviceconnection.appVersionInfoCache import net.mullvad.mullvadvpn.ui.widget.AccountCell import net.mullvad.mullvadvpn.ui.widget.AppVersionCell import net.mullvad.mullvadvpn.ui.widget.NavigateCell +import net.mullvad.mullvadvpn.util.JobTracker +import net.mullvad.mullvadvpn.util.UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS +import net.mullvad.mullvadvpn.util.addDebounceForUnknownState +import net.mullvad.mullvadvpn.util.appVersionCallbackFlow import org.koin.android.ext.android.inject -class SettingsFragment : ServiceAwareFragment(), StatusBarPainter, NavigationBarPainter { +class SettingsFragment : BaseFragment(), StatusBarPainter, NavigationBarPainter { + + // Injected dependencies private val accountRepository: AccountRepository by inject() private val deviceRepository: DeviceRepository by inject() + private val serviceConnectionManager: ServiceConnectionManager by inject() private lateinit var accountMenu: AccountCell private lateinit var appVersionMenu: AppVersionCell @@ -35,20 +47,12 @@ class SettingsFragment : ServiceAwareFragment(), StatusBarPainter, NavigationBar private lateinit var advancedMenu: View private lateinit var titleController: CollapsibleTitleController - private var active = false - - private var versionInfoCache: AppVersionInfoCache? = null - - override fun onNewServiceConnection(serviceConnectionContainer: ServiceConnectionContainer) { - versionInfoCache = serviceConnectionContainer.appVersionInfoCache - - if (active) { - configureListeners() - } - } + @Deprecated("Refactor code to instead rely on Lifecycle.") + private val jobTracker = JobTracker() - override fun onNoServiceConnection() { - versionInfoCache = null + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launchUiSubscriptionsOnResume() } override fun onCreateView( @@ -86,7 +90,7 @@ class SettingsFragment : ServiceAwareFragment(), StatusBarPainter, NavigationBar } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - lifecycleScope.launchUiSubscriptionsOnResume() + initializeUiState() } override fun onResume() { @@ -94,25 +98,38 @@ class SettingsFragment : ServiceAwareFragment(), StatusBarPainter, NavigationBar paintNavigationBar(ContextCompat.getColor(requireContext(), R.color.darkBlue)) } - override fun onStart() { - super.onStart() - - configureListeners() - active = true - } - override fun onStop() { - active = false - versionInfoCache?.onUpdate = null - jobTracker.cancelAllJobs() - super.onStop() } override fun onDestroyView() { - super.onDestroyView() titleController.onDestroy() + super.onDestroyView() + } + + private fun initializeUiState() { + updateLoggedInStatus(deviceRepository.deviceState.value is DeviceState.LoggedIn) + accountMenu.accountExpiry = accountRepository.accountExpiryState.value.date() + serviceConnectionManager.appVersionInfoCache().let { cache -> + updateVersionInfo( + if (cache != null) { + VersionInfo( + currentVersion = cache.version, + upgradeVersion = cache.upgradeVersion, + isOutdated = cache.isOutdated, + isSupported = cache.isSupported + ) + } else { + VersionInfo( + currentVersion = null, + upgradeVersion = null, + isOutdated = false, + isSupported = true + ) + } + ) + } } private fun CoroutineScope.launchUiSubscriptionsOnResume() = launch { @@ -120,6 +137,7 @@ class SettingsFragment : ServiceAwareFragment(), StatusBarPainter, NavigationBar launchPaintStatusBarAfterTransition() luanchConfigureMenuOnDeviceChanges() launchUpdateExpiryTextOnExpiryChanges() + launchVersionInfoSubscription() } } @@ -131,6 +149,9 @@ class SettingsFragment : ServiceAwareFragment(), StatusBarPainter, NavigationBar private fun CoroutineScope.luanchConfigureMenuOnDeviceChanges() = launch { deviceRepository.deviceState + .debounce { + it.addDebounceForUnknownState(UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS) + } .collect { device -> updateLoggedInStatus(device is DeviceState.LoggedIn) } @@ -145,12 +166,18 @@ class SettingsFragment : ServiceAwareFragment(), StatusBarPainter, NavigationBar } } - private fun configureListeners() { - versionInfoCache?.onUpdate = { - jobTracker.newUiJob("updateVersionInfo") { - updateVersionInfo() + private fun CoroutineScope.launchVersionInfoSubscription() = launch { + serviceConnectionManager.connectionState + .flatMapLatest { state -> + if (state is ServiceConnectionState.ConnectedReady) { + state.container.appVersionInfoCache.appVersionCallbackFlow() + } else { + emptyFlow() + } + } + .collect { versionInfo -> + updateVersionInfo(versionInfo) } - } } private fun updateLoggedInStatus(loggedIn: Boolean) { @@ -165,11 +192,10 @@ class SettingsFragment : ServiceAwareFragment(), StatusBarPainter, NavigationBar advancedMenu.visibility = visibility } - private fun updateVersionInfo() { - val isOutdated = versionInfoCache?.isOutdated ?: false - val isSupported = versionInfoCache?.isSupported ?: true - - appVersionMenu.updateAvailable = isOutdated || !isSupported - appVersionMenu.version = versionInfoCache?.version ?: "" + private fun updateVersionInfo( + versionInfo: VersionInfo + ) { + appVersionMenu.updateAvailable = versionInfo.isOutdated || !versionInfo.isSupported + appVersionMenu.version = versionInfo.currentVersion ?: "" } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/VersionInfo.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/VersionInfo.kt new file mode 100644 index 0000000000..ac52959374 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/VersionInfo.kt @@ -0,0 +1,8 @@ +package net.mullvad.mullvadvpn.ui + +data class VersionInfo( + val currentVersion: String?, + val upgradeVersion: String?, + val isOutdated: Boolean, + val isSupported: Boolean +) 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 3acf9b3eee..925fe67b43 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 @@ -15,36 +15,58 @@ import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.ui.extension.openAccountPageInBrowser +import net.mullvad.mullvadvpn.ui.fragments.BaseFragment import net.mullvad.mullvadvpn.ui.serviceconnection.AccountRepository import net.mullvad.mullvadvpn.ui.serviceconnection.DeviceRepository +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState +import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache import net.mullvad.mullvadvpn.ui.widget.HeaderBar import net.mullvad.mullvadvpn.ui.widget.RedeemVoucherButton import net.mullvad.mullvadvpn.ui.widget.SitePaymentButton +import net.mullvad.mullvadvpn.util.JobTracker +import net.mullvad.mullvadvpn.util.UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS +import net.mullvad.mullvadvpn.util.addDebounceForUnknownState +import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier import org.joda.time.DateTime import org.koin.android.ext.android.inject val POLL_INTERVAL: Long = 15 /* s */ * 1000 /* ms */ -class WelcomeFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) { +class WelcomeFragment : BaseFragment() { // Injected dependencies private val accountRepository: AccountRepository by inject() private val deviceRepository: DeviceRepository by inject() + private val serviceConnectionManager: ServiceConnectionManager by inject() private lateinit var accountLabel: TextView + private lateinit var headerBar: HeaderBar private lateinit var sitePaymentButton: SitePaymentButton - override fun onSafelyCreateView( + @Deprecated("Refactor code to instead rely on Lifecycle.") + private val jobTracker = JobTracker() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launchUiSubscriptionsOnResume() + } + + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { + ): View? { val view = inflater.inflate(R.layout.welcome, container, false) - view.findViewById<HeaderBar>(R.id.header_bar).apply { + headerBar = view.findViewById<HeaderBar>(R.id.header_bar).apply { tunnelState = TunnelState.Disconnected } @@ -53,14 +75,21 @@ class WelcomeFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) { } view.findViewById<TextView>(R.id.pay_to_start_using).text = buildString { - append(parentActivity.getString(R.string.pay_to_start_using)) + append(requireActivity().getString(R.string.pay_to_start_using)) append(" ") - append(parentActivity.getString(R.string.add_time_to_account)) + append(requireActivity().getString(R.string.add_time_to_account)) } sitePaymentButton = view.findViewById<SitePaymentButton>(R.id.site_payment).apply { newAccount = true - prepare(authTokenCache, jobTracker) + + setOnClickAction("openAccountPageInBrowser", jobTracker) { + setEnabled(false) + serviceConnectionManager.authTokenCache()?.fetchAuthToken()?.let { token -> + context.openAccountPageInBrowser(token) + } + setEnabled(true) + } } view.findViewById<RedeemVoucherButton>(R.id.redeem_voucher).apply { @@ -70,34 +99,23 @@ class WelcomeFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) { return view } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - lifecycleScope.launchUiSubscriptionsOnResume() - } - - override fun onSafelyStart() { - jobTracker.newBackgroundJob("pollAccountData") { - while (true) { - accountRepository.fetchAccountExpiry() - delay(POLL_INTERVAL) - } - } - - sitePaymentButton.updateAuthTokenCache(authTokenCache) - } - - override fun onSafelyStop() { - jobTracker.cancelJob("pollAccountData") + override fun onStop() { + jobTracker.cancelAllJobs() + super.onStop() } private fun CoroutineScope.launchUiSubscriptionsOnResume() = launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { launchUpdateAccountNumberOnDeviceChanges() launchAdvanceToConnectViewOnExpiryExtended() + launchExpiryPolling() + launchTunnelStateSubscription() } } private fun CoroutineScope.launchUpdateAccountNumberOnDeviceChanges() = launch { deviceRepository.deviceState + .debounce { it.addDebounceForUnknownState(UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS) } .collect { state -> updateAccountNumber(state.token()) } @@ -109,6 +127,32 @@ class WelcomeFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) { } } + private fun CoroutineScope.launchExpiryPolling() = launch { + while (true) { + accountRepository.fetchAccountExpiry() + delay(POLL_INTERVAL) + } + } + + private fun CoroutineScope.launchTunnelStateSubscription() = launch { + serviceConnectionManager.connectionState + .flatMapLatest { state -> + if (state is ServiceConnectionState.ConnectedReady) { + callbackFlowFromNotifier( + state.container.connectionProxy.onStateChange + ) + } else { + emptyFlow() + } + } + .collect { state -> updateUiForTunnelState(state) } + } + + private fun updateUiForTunnelState(tunnelState: TunnelState) { + headerBar.tunnelState = tunnelState + sitePaymentButton.isEnabled = tunnelState is TunnelState.Disconnected + } + private fun updateAccountNumber(rawAccountNumber: String?) { val accountText = rawAccountNumber?.let { account -> addSpacesToAccountText(account) @@ -142,9 +186,7 @@ class WelcomeFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) { val tomorrow = DateTime.now().plusDays(1) if (expiry.isAfter(tomorrow)) { - jobTracker.newUiJob("advanceToConnectScreen") { - advanceToConnectScreen() - } + advanceToConnectScreen() } } } @@ -161,7 +203,7 @@ class WelcomeFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) { val clipboardLabel = resources.getString(R.string.mullvad_account_number) val toastMessage = resources.getString(R.string.copied_mullvad_account_number) - val context = parentActivity + val context = requireActivity() val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clipData = ClipData.newPlainText(clipboardLabel, accountToken) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/extension/ContextExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/extension/ContextExtensions.kt new file mode 100644 index 0000000000..6314c3eaef --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/extension/ContextExtensions.kt @@ -0,0 +1,27 @@ +package net.mullvad.mullvadvpn.ui.extension + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.fragment.app.Fragment +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.ui.MainActivity + +fun Context.openAccountPageInBrowser(authToken: String) { + startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse(getString(R.string.account_url) + "?token=$authToken") + ) + ) +} + +fun Fragment.requireMainActivity(): MainActivity { + return if (this.activity is MainActivity) { + this.activity as MainActivity + } else { + throw IllegalStateException( + "Fragment $this not attached to ${MainActivity::class.simpleName}." + ) + } +} 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 5db4c17996..38c8f8ed90 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,18 +1,13 @@ 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.AccountRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache import net.mullvad.mullvadvpn.util.TimeLeftFormatter import org.joda.time.DateTime class AccountExpiryNotification( context: Context, - authTokenCache: AuthTokenCache, - private val accountRepository: AccountRepository -) : NotificationWithUrlWithToken(context, authTokenCache, R.string.account_url) { +) : InAppNotification() { private val timeLeftFormatter = TimeLeftFormatter(context.resources) init { @@ -20,19 +15,7 @@ class AccountExpiryNotification( title = context.getString(R.string.account_credit_expires_soon) } - override fun onResume() { - jobTracker.newUiJob("updateAccountExpiry") { - accountRepository.accountExpiryState.collect { state -> - updateAccountExpiry(state.date()) - } - } - } - - override fun onPause() { - jobTracker.cancelJob("updateAccountExpiry") - } - - private fun updateAccountExpiry(expiry: DateTime?) { + fun updateAccountExpiry(expiry: DateTime?) { val threeDaysFromNow = DateTime.now().plusDays(3) if (expiry != null && expiry.isBefore(threeDaysFromNow)) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotification.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotification.kt index aa58b0bbf5..af4d34e9c1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotification.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/InAppNotification.kt @@ -19,7 +19,6 @@ abstract class InAppNotification { protected set var onClick by changeMonitor.monitor<(suspend () -> Unit)?>(null) - protected set var showIcon by changeMonitor.monitor(false) protected set diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/TunnelStateNotification.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/TunnelStateNotification.kt index 8c26c5dc1e..10927cb5e7 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/TunnelStateNotification.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/TunnelStateNotification.kt @@ -3,7 +3,6 @@ package net.mullvad.mullvadvpn.ui.notification import android.content.Context import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy import net.mullvad.talpid.tunnel.ActionAfterDisconnect import net.mullvad.talpid.tunnel.ErrorState import net.mullvad.talpid.tunnel.ErrorStateCause @@ -12,7 +11,6 @@ import net.mullvad.talpid.util.addressString class TunnelStateNotification( private val context: Context, - private val connectionProxy: ConnectionProxy ) : InAppNotification() { private val blockingTitle = context.getString(R.string.blocking_internet) private val notBlockingTitle = context.getString(R.string.not_blocking_internet) @@ -23,19 +21,7 @@ class TunnelStateNotification( showIcon = false } - override fun onResume() { - connectionProxy.onStateChange.subscribe(this) { tunnelState -> - jobTracker.newUiJob("updateTunnelState") { - updateTunnelState(tunnelState) - } - } - } - - override fun onPause() { - connectionProxy.onStateChange.unsubscribe(this) - } - - private fun updateTunnelState(state: TunnelState) { + fun updateTunnelState(state: TunnelState) { when (state) { is TunnelState.Disconnecting -> { when (state.actionAfterDisconnect) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/VersionInfoNotification.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/VersionInfoNotification.kt index 8a8104290f..d85f70ca5b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/VersionInfoNotification.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/notification/VersionInfoNotification.kt @@ -2,37 +2,20 @@ package net.mullvad.mullvadvpn.ui.notification import android.content.Context import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoCache +import net.mullvad.mullvadvpn.ui.VersionInfo class VersionInfoNotification( - context: Context, - private val versionInfoCache: AppVersionInfoCache + context: Context ) : NotificationWithUrl(context, R.string.download_url) { private val unsupportedVersion = context.getString(R.string.unsupported_version) private val updateAvailable = context.getString(R.string.update_available) - override fun onResume() { - versionInfoCache.onUpdate = { - jobTracker.newUiJob("updateVersionInfo") { - updateVersionInfo( - versionInfoCache.isOutdated, - versionInfoCache.isSupported, - versionInfoCache.upgradeVersion - ) - } - } - } - - override fun onPause() { - versionInfoCache.onUpdate = null - } - - private fun updateVersionInfo(isOutdated: Boolean, isSupported: Boolean, upgrade: String?) { - if (isOutdated || !isSupported) { - if (upgrade != null) { + fun updateVersionInfo(versionInfo: VersionInfo) { + if (versionInfo.isOutdated || !versionInfo.isSupported) { + if (versionInfo.upgradeVersion != null) { val template: Int - if (isSupported) { + if (versionInfo.isSupported) { status = StatusLevel.Warning title = updateAvailable template = R.string.update_available_description @@ -42,7 +25,7 @@ class VersionInfoNotification( template = R.string.unsupported_version_description } - message = context.getString(template, upgrade) + message = context.getString(template, versionInfo.upgradeVersion) } else { status = StatusLevel.Error title = unsupportedVersion diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AccountRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AccountRepository.kt index 4d79bfd356..6a58739aec 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AccountRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/AccountRepository.kt @@ -23,9 +23,6 @@ class AccountRepository( private val serviceConnectionManager: ServiceConnectionManager, dispatcher: CoroutineDispatcher = Dispatchers.IO ) { - private val dataSource - get() = serviceConnectionManager.connectionState.value.readyContainer()?.accountDataSource - private val _cachedCreatedAccount = MutableStateFlow<String?>(null) val cachedCreatedAccount = _cachedCreatedAccount.asStateFlow() @@ -78,28 +75,28 @@ class AccountRepository( ) fun createAccount() { - dataSource?.createAccount() + serviceConnectionManager.accountDataSource()?.createAccount() } fun login(accountToken: String) { - dataSource?.login(accountToken) + serviceConnectionManager.accountDataSource()?.login(accountToken) } fun logout() { clearCreatedAccountCache() - dataSource?.logout() + serviceConnectionManager.accountDataSource()?.logout() } fun fetchAccountExpiry() { - dataSource?.fetchAccountExpiry() + serviceConnectionManager.accountDataSource()?.fetchAccountExpiry() } fun fetchAccountHistory() { - dataSource?.fetchAccountHistory() + serviceConnectionManager.accountDataSource()?.fetchAccountHistory() } fun clearAccountHistory() { - dataSource?.clearAccountHistory() + serviceConnectionManager.accountDataSource()?.clearAccountHistory() } private fun clearCreatedAccountCache() { 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 08290ef7d2..790c2404f2 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 @@ -61,20 +61,16 @@ class DeviceRepository( .stateIn(CoroutineScope(Dispatchers.IO), SharingStarted.WhileSubscribed(), emptyList()) fun refreshDeviceState() { - container()?.deviceDataSource?.refreshDevice() - } - - private fun container(): ServiceConnectionContainer? { - return serviceConnectionManager.connectionState.value.readyContainer() + serviceConnectionManager.deviceDataSource()?.refreshDevice() } fun removeDevice(accountToken: String, deviceId: String) { cachedDeviceList.value = emptyList() - container()?.deviceDataSource?.removeDevice(accountToken, deviceId) + serviceConnectionManager.deviceDataSource()?.removeDevice(accountToken, deviceId) } fun refreshDeviceList(accountToken: String) { - container()?.deviceDataSource?.refreshDeviceList(accountToken) + serviceConnectionManager.deviceDataSource()?.refreshDeviceList(accountToken) } suspend fun getDeviceList(accountToken: String): DeviceListEvent { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManagerExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManagerExtensions.kt new file mode 100644 index 0000000000..392b841101 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManagerExtensions.kt @@ -0,0 +1,25 @@ +package net.mullvad.mullvadvpn.ui.serviceconnection + +fun ServiceConnectionManager.accountDataSource() = + this.connectionState.value.readyContainer()?.accountDataSource + +fun ServiceConnectionManager.appVersionInfoCache() = + this.connectionState.value.readyContainer()?.appVersionInfoCache + +fun ServiceConnectionManager.authTokenCache() = + this.connectionState.value.readyContainer()?.authTokenCache + +fun ServiceConnectionManager.connectionProxy() = + this.connectionState.value.readyContainer()?.connectionProxy + +fun ServiceConnectionManager.deviceDataSource() = + this.connectionState.value.readyContainer()?.deviceDataSource + +fun ServiceConnectionManager.customDns() = + this.connectionState.value.readyContainer()?.customDns + +fun ServiceConnectionManager.relayListListener() = + this.connectionState.value.readyContainer()?.relayListListener + +fun ServiceConnectionManager.settingsListener() = + this.connectionState.value.readyContainer()?.settingsListener diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/NavigateCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/NavigateCell.kt index fffe9d3003..31fd5f5095 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/NavigateCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/NavigateCell.kt @@ -40,7 +40,7 @@ open class NavigateCell : Cell { (context as? FragmentActivity)?.supportFragmentManager?.beginTransaction()?.apply { setCustomAnimations( R.anim.fragment_enter_from_right, - R.anim.fragment_half_exit_to_left, + R.anim.fragment_exit_to_left, R.anim.fragment_half_enter_from_left, R.anim.fragment_exit_to_right ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/SitePaymentButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/SitePaymentButton.kt index af936f1686..35d0c2326f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/SitePaymentButton.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/SitePaymentButton.kt @@ -20,9 +20,4 @@ class SitePaymentButton : UrlButton { label = context.getString(R.string.buy_more_credit) } } - - init { - url = context.getString(R.string.account_url) - withToken = true - } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/UrlButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/UrlButton.kt index a7573fc7c4..c1b700e433 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/UrlButton.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/UrlButton.kt @@ -1,119 +1,20 @@ package net.mullvad.mullvadvpn.ui.widget import android.content.Context -import android.content.Intent -import android.net.Uri import android.util.AttributeSet -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.async import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.ui.serviceconnection.AuthTokenCache -import net.mullvad.mullvadvpn.util.JobTracker open class UrlButton : Button { - private lateinit var authTokenCache: AuthTokenCache + constructor(context: Context) : super(context) - private var shouldEnable = true - - var url: String? = null - var withToken = false - - constructor(context: Context) : super(context) {} - - constructor(context: Context, attributes: AttributeSet) : super(context, attributes) { - loadAttributes(attributes) - } + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : - super(context, attributes, defaultStyleAttribute) { - loadAttributes(attributes) - } - - constructor( - context: Context, - attributes: AttributeSet, - defaultStyleAttribute: Int, - defaultStyleResource: Int - ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) { - loadAttributes(attributes) - } + super(context, attributes, defaultStyleAttribute) init { super.setEnabled(false) super.detailImage = context.getDrawable(R.drawable.icon_extlink) super.showSpinner = true } - - fun prepare( - authTokenCache: AuthTokenCache, - jobTracker: JobTracker, - jobName: String = "fetchUrl", - extraOnClickAction: (suspend () -> Unit)? = null - ) { - synchronized(this) { - super.setEnabled(shouldEnable) - - this.authTokenCache = authTokenCache - - setOnClickAction(jobName, jobTracker) { - super.setEnabled(false) - - context.startActivity(buildIntent(jobTracker)) - extraOnClickAction?.invoke() - - super.setEnabled(true) - } - } - } - - fun updateAuthTokenCache(authTokenCache: AuthTokenCache) { - synchronized(this) { - this.authTokenCache = authTokenCache - } - } - - override fun setEnabled(enabled: Boolean) { - synchronized(this) { - shouldEnable = enabled - - if (!withToken || this::authTokenCache.isInitialized) { - super.setEnabled(enabled) - } - } - } - - private fun loadAttributes(attributes: AttributeSet) { - context.theme.obtainStyledAttributes(attributes, R.styleable.Url, 0, 0).apply { - try { - url = getString(R.styleable.Url_url) - } finally { - recycle() - } - } - - context.theme.obtainStyledAttributes(attributes, R.styleable.UrlButton, 0, 0).apply { - try { - withToken = getBoolean(R.styleable.UrlButton_withToken, false) - } finally { - recycle() - } - } - } - - private suspend fun buildIntent(jobTracker: JobTracker): Intent { - val buildIntent = GlobalScope.async(Dispatchers.Default) { - val uri = if (withToken) { - Uri.parse(url + "?token=" + authTokenCache.fetchAuthToken()) - } else { - Uri.parse(url) - } - - Intent(Intent.ACTION_VIEW, uri) - } - - jobTracker.newJob(buildIntent) - - return buildIntent.await() - } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/CacheExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/CacheExtensions.kt new file mode 100644 index 0000000000..62172231f9 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/CacheExtensions.kt @@ -0,0 +1,22 @@ +package net.mullvad.mullvadvpn.util + +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import net.mullvad.mullvadvpn.ui.VersionInfo +import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoCache + +fun AppVersionInfoCache.appVersionCallbackFlow() = callbackFlow { + this@appVersionCallbackFlow.onUpdate = { + trySend( + VersionInfo( + currentVersion = this@appVersionCallbackFlow.version, + upgradeVersion = this@appVersionCallbackFlow.upgradeVersion, + isOutdated = this@appVersionCallbackFlow.isOutdated, + isSupported = this@appVersionCallbackFlow.isSupported, + ) + ) + } + awaitClose { + onUpdate = null + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/DeviceStateExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/DeviceStateExtensions.kt new file mode 100644 index 0000000000..d41a628bec --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/DeviceStateExtensions.kt @@ -0,0 +1,25 @@ +package net.mullvad.mullvadvpn.util + +import kotlin.reflect.KClass +import net.mullvad.mullvadvpn.model.DeviceState + +const val UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS = 2000L +private const val ZERO_DEBOUNCE_DELAY_MILLISECONDS = 0L + +fun DeviceState.addDebounceForUnknownState( + delay: Long +): Long { + return addDebounceForStates(delay, DeviceState.Unknown::class) +} + +fun <T> DeviceState.addDebounceForStates( + delay: Long, + vararg states: KClass<T> +): Long where T : DeviceState { + val result = states.any { this::class == it } + return if (result) { + delay + } else { + ZERO_DEBOUNCE_DELAY_MILLISECONDS + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt index a0a809f8ff..de76ff5b0b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/FlowUtils.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.take import net.mullvad.mullvadvpn.model.ServiceResult import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState +import net.mullvad.talpid.util.EventNotifier fun <T> SendChannel<T>.safeOffer(element: T): Boolean { return runCatching { offer(element) }.getOrDefault(false) @@ -78,3 +79,9 @@ fun <R> Flow<ServiceConnectionState>.flatMapReadyConnectionOrDefault( } } } + +fun <T> callbackFlowFromNotifier(notifier: EventNotifier<T>) = callbackFlow<T> { + val handler: (T) -> Unit = { value -> trySend(value) } + notifier.subscribe(this, handler) + awaitClose { notifier.unsubscribe(this) } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt index 4272a5de35..bf8db5b273 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt @@ -11,8 +11,8 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import net.mullvad.mullvadvpn.compose.state.DeviceRevokedUiState import net.mullvad.mullvadvpn.ui.serviceconnection.AccountRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy import net.mullvad.talpid.util.callbackFlowFromSubscription // TODO: Refactor ConnectionProxy to be easily injectable rather than injecting @@ -44,15 +44,11 @@ class DeviceRevokedViewModel( ) fun onGoToLoginClicked() { - serviceContainer()?.let { container -> - if (container.connectionProxy.state.isSecured()) { - container.connectionProxy.disconnect() + serviceConnectionManager.connectionProxy()?.let { proxy -> + if (proxy.state.isSecured()) { + proxy.disconnect() } accountRepository.logout() } } - - private fun serviceContainer(): ServiceConnectionContainer? { - return serviceConnectionManager.connectionState.value.readyContainer() - } } |
