diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt index 0fb165ea89b9..538f9cc49fad 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt @@ -1,5 +1,7 @@ package net.mullvad.mullvadvpn.compose.screen +import android.content.Intent +import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -7,6 +9,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.navigation.NavHostController @@ -21,14 +24,17 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.rememberNavHostEngine import com.ramcosta.composedestinations.utils.destination import com.ramcosta.composedestinations.utils.rememberDestinationsNavigator +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import net.mullvad.mullvadvpn.compose.util.RequestVpnPermission -import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel +import net.mullvad.mullvadvpn.lib.common.constant.KEY_REQUEST_VPN_PERMISSION +import net.mullvad.mullvadvpn.util.getActivity import net.mullvad.mullvadvpn.viewmodel.DaemonScreenEvent +import net.mullvad.mullvadvpn.viewmodel.MullvadAppViewModel import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel -import net.mullvad.mullvadvpn.viewmodel.VpnPermissionSideEffect -import net.mullvad.mullvadvpn.viewmodel.VpnPermissionViewModel import org.koin.androidx.compose.koinViewModel private val changeLogDestinations = listOf(ConnectDestination, OutOfTimeDestination) @@ -41,13 +47,27 @@ fun MullvadApp() { val navigator: DestinationsNavigator = navHostController.rememberDestinationsNavigator() val serviceVm = koinViewModel<NoDaemonViewModel>() - val permissionVm = koinViewModel<VpnPermissionViewModel>() + val mullvadAppViewModel = koinViewModel<MullvadAppViewModel>() DisposableEffect(Unit) { navHostController.addOnDestinationChangedListener(serviceVm) onDispose { navHostController.removeOnDestinationChangedListener(serviceVm) } } + // Get intents + val launchVpnPermission = + rememberLauncherForActivityResult(RequestVpnPermission()) { _ -> + mullvadAppViewModel.connect() + } + val context = LocalContext.current + val activity = (context.getActivity() as ComponentActivity) + LaunchedEffect(navHostController) { + activity + .intents() + .filter { it.action == KEY_REQUEST_VPN_PERMISSION } + .collect { launchVpnPermission.launch(Unit) } + } + DestinationsNavHost( modifier = Modifier.semantics { testTagsAsResourceId = true }.fillMaxSize(), engine = engine, @@ -72,9 +92,8 @@ fun MullvadApp() { } // Globally show the changelog - val changeLogsViewModel = koinViewModel<ChangelogViewModel>() LaunchedEffect(Unit) { - changeLogsViewModel.uiSideEffect.collect { + mullvadAppViewModel.uiSideEffect.collect { // Wait until we are in an acceptable destination navHostController.currentBackStackEntryFlow .map { it.destination() } @@ -83,15 +102,15 @@ fun MullvadApp() { navigator.navigate(ChangelogDestination(it)) } } +} - // Ask for VPN Permission - val launchVpnPermission = - rememberLauncherForActivityResult(RequestVpnPermission()) { _ -> permissionVm.connect() } - LaunchedEffect(Unit) { - changeLogsViewModel.uiSideEffect.collect { - if (it is VpnPermissionSideEffect.ShowDialog) { - launchVpnPermission.launch(Unit) - } - } +private fun ComponentActivity.intents() = + callbackFlow<Intent> { + send(intent) + + val listener: (Intent) -> Unit = { trySend(it) } + + addOnNewIntentListener(listener) + + awaitClose { removeOnNewIntentListener(listener) } } -} 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 6b909d394d40..af314345fd4c 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 @@ -66,6 +66,7 @@ import net.mullvad.mullvadvpn.viewmodel.EditCustomListViewModel import net.mullvad.mullvadvpn.viewmodel.FilterViewModel import net.mullvad.mullvadvpn.viewmodel.LoginViewModel import net.mullvad.mullvadvpn.viewmodel.MtuDialogViewModel +import net.mullvad.mullvadvpn.viewmodel.MullvadAppViewModel import net.mullvad.mullvadvpn.viewmodel.NoDaemonViewModel import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel import net.mullvad.mullvadvpn.viewmodel.PaymentViewModel @@ -80,7 +81,6 @@ import net.mullvad.mullvadvpn.viewmodel.SplashViewModel import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel import net.mullvad.mullvadvpn.viewmodel.VoucherDialogViewModel -import net.mullvad.mullvadvpn.viewmodel.VpnPermissionViewModel import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel import org.apache.commons.validator.routines.InetAddressValidator @@ -162,7 +162,7 @@ val uiModule = module { // View models viewModel { AccountViewModel(get(), get(), get(), IS_PLAY_BUILD) } - viewModel { ChangelogViewModel(get(), get(), BuildConfig.ALWAYS_SHOW_CHANGELOG) } + viewModel { MullvadAppViewModel(get(), get(), get(), BuildConfig.ALWAYS_SHOW_CHANGELOG) } viewModel { ConnectViewModel( get(), @@ -205,12 +205,12 @@ val uiModule = module { viewModel { DeleteCustomListConfirmationViewModel(get(), get()) } viewModel { ServerIpOverridesViewModel(get(), get()) } viewModel { ResetServerIpOverridesConfirmationViewModel(get()) } - viewModel { VpnPermissionViewModel(get(), get()) } viewModel { ApiAccessListViewModel(get()) } viewModel { EditApiAccessMethodViewModel(get(), get(), get()) } viewModel { SaveApiAccessMethodViewModel(get(), get()) } viewModel { ApiAccessMethodDetailsViewModel(get(), get()) } viewModel { DeleteApiAccessMethodConfirmationViewModel(get(), get()) } + viewModel { ChangelogViewModel(get(), get()) } // This view model must be single so we correctly attach lifecycle and share it with activity single { NoDaemonViewModel(get()) } 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 a4ae13113902..c3cd5a20dfdd 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 @@ -1,6 +1,5 @@ package net.mullvad.mullvadvpn.ui -import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -63,11 +62,11 @@ class MainActivity : ComponentActivity(), AndroidScopeComponent { } super.onCreate(savedInstanceState) - // Needs to be before set content since we want to access the intent in compose + setContent { AppTheme { MullvadApp() } } + if (savedInstanceState == null) { intentProvider.setStartIntent(intent) } - setContent { AppTheme { MullvadApp() } } // This is to protect against tapjacking attacks window.decorView.filterTouchesWhenObscured = true @@ -98,11 +97,6 @@ class MainActivity : ComponentActivity(), AndroidScopeComponent { } } - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - intentProvider.setStartIntent(intent) - } - fun bindService() { requestNotificationPermissionIfMissing(requestNotificationPermissionLauncher) serviceConnectionManager.bind() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt index f0817ea4feb9..b0001a164aca 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModel.kt @@ -1,40 +1,15 @@ package net.mullvad.mullvadvpn.viewmodel -import android.os.Parcelable import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.launch -import kotlinx.parcelize.Parcelize import net.mullvad.mullvadvpn.lib.model.BuildVersion import net.mullvad.mullvadvpn.repository.ChangelogRepository class ChangelogViewModel( private val changelogRepository: ChangelogRepository, private val buildVersion: BuildVersion, - private val alwaysShowChangelog: Boolean, ) : ViewModel() { - private val _uiSideEffect = MutableSharedFlow<Changelog>(replay = 1, extraBufferCapacity = 1) - val uiSideEffect: SharedFlow<Changelog> = _uiSideEffect - - init { - if (shouldShowChangelog()) { - val changelog = - Changelog(buildVersion.name, changelogRepository.getLastVersionChanges()) - viewModelScope.launch { _uiSideEffect.emit(changelog) } - } - } - fun markChangelogAsRead() { changelogRepository.setVersionCodeOfMostRecentChangelogShowed(buildVersion.code) } - - private fun shouldShowChangelog(): Boolean = - alwaysShowChangelog || - (changelogRepository.getVersionCodeOfMostRecentChangelogShowed() < buildVersion.code && - changelogRepository.getLastVersionChanges().isNotEmpty()) } - -@Parcelize data class Changelog(val version: String, val changes: List<String>) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MullvadAppViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MullvadAppViewModel.kt new file mode 100644 index 000000000000..88dd7f1a46ae --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/MullvadAppViewModel.kt @@ -0,0 +1,42 @@ +package net.mullvad.mullvadvpn.viewmodel + +import android.os.Parcelable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize +import net.mullvad.mullvadvpn.lib.model.BuildVersion +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy +import net.mullvad.mullvadvpn.repository.ChangelogRepository + +class MullvadAppViewModel( + private val changelogRepository: ChangelogRepository, + private val connectionProxy: ConnectionProxy, + private val buildVersion: BuildVersion, + private val alwaysShowChangelog: Boolean, +) : ViewModel() { + + private val _uiSideEffect = MutableSharedFlow<Changelog>(replay = 1, extraBufferCapacity = 1) + val uiSideEffect: SharedFlow<Changelog> = _uiSideEffect + + init { + if (shouldShowChangelog()) { + val changelog = + Changelog(buildVersion.name, changelogRepository.getLastVersionChanges()) + viewModelScope.launch { _uiSideEffect.emit(changelog) } + } + } + + fun connect() { + viewModelScope.launch { connectionProxy.connectWithoutPermissionCheck() } + } + + private fun shouldShowChangelog(): Boolean = + alwaysShowChangelog || + (changelogRepository.getVersionCodeOfMostRecentChangelogShowed() < buildVersion.code && + changelogRepository.getLastVersionChanges().isNotEmpty()) +} + +@Parcelize data class Changelog(val version: String, val changes: List<String>) : Parcelable diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnPermissionViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnPermissionViewModel.kt deleted file mode 100644 index 1e5972b53897..000000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VpnPermissionViewModel.kt +++ /dev/null @@ -1,34 +0,0 @@ -package net.mullvad.mullvadvpn.viewmodel - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.shareIn -import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.lib.common.constant.KEY_REQUEST_VPN_PERMISSION -import net.mullvad.mullvadvpn.lib.intent.IntentProvider -import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy - -class VpnPermissionViewModel( - intentProvider: IntentProvider, - private val connectionProxy: ConnectionProxy, -) : ViewModel() { - val uiSideEffect: Flow<VpnPermissionSideEffect> = - intentProvider.intents - .filter { it?.action == KEY_REQUEST_VPN_PERMISSION } - .distinctUntilChanged() - .map { VpnPermissionSideEffect.ShowDialog } - .shareIn(viewModelScope, SharingStarted.WhileSubscribed()) - - fun connect() { - viewModelScope.launch { connectionProxy.connectWithoutPermissionCheck() } - } -} - -sealed interface VpnPermissionSideEffect { - data object ShowDialog : VpnPermissionSideEffect -} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/MullvadAppViewModelTest.kt similarity index 81% rename from android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModelTest.kt rename to android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/MullvadAppViewModelTest.kt index 7888f02a4d18..8e5af38a7c16 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ChangelogViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/MullvadAppViewModelTest.kt @@ -11,6 +11,7 @@ import kotlin.test.assertEquals import kotlinx.coroutines.test.runTest import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule import net.mullvad.mullvadvpn.lib.model.BuildVersion +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy import net.mullvad.mullvadvpn.repository.ChangelogRepository import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -18,11 +19,12 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @ExtendWith(TestCoroutineRule::class) -class ChangelogViewModelTest { +class MullvadAppViewModelTest { @MockK private lateinit var mockedChangelogRepository: ChangelogRepository + @MockK private lateinit var connectionProxy: ConnectionProxy - private lateinit var viewModel: ChangelogViewModel + private lateinit var viewModel: MullvadAppViewModel private val buildVersion = BuildVersion("1.0", 10) @@ -43,7 +45,8 @@ class ChangelogViewModelTest { // Arrange every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns buildVersion.code - viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersion, false) + viewModel = + MullvadAppViewModel(mockedChangelogRepository, connectionProxy, buildVersion, false) // If we have the most up to date version code, we should not show the changelog dialog viewModel.uiSideEffect.test { expectNoEvents() } @@ -58,7 +61,8 @@ class ChangelogViewModelTest { version every { mockedChangelogRepository.getLastVersionChanges() } returns changes - viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersion, false) + viewModel = + MullvadAppViewModel(mockedChangelogRepository, connectionProxy, buildVersion, false) // Given a new version with a change log we should return it viewModel.uiSideEffect.test { assertEquals(awaitItem(), Changelog(version = buildVersion.name, changes = changes)) @@ -71,7 +75,8 @@ class ChangelogViewModelTest { every { mockedChangelogRepository.getVersionCodeOfMostRecentChangelogShowed() } returns -1 every { mockedChangelogRepository.getLastVersionChanges() } returns emptyList() - viewModel = ChangelogViewModel(mockedChangelogRepository, buildVersion, false) + viewModel = + MullvadAppViewModel(mockedChangelogRepository, connectionProxy, buildVersion, false) // Given a new version with a change log we should not return it viewModel.uiSideEffect.test { expectNoEvents() } }