From 1f7e7199a05e75e46bd58526e4e9a4b60d0a81e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20G=C3=B6ransson?= Date: Mon, 6 Nov 2023 15:10:54 +0100 Subject: [PATCH] Migrate all navigation to compose navigation --- android/app/build.gradle.kts | 6 +- android/app/src/main/AndroidManifest.xml | 92 +++--- .../compose/screen/AccountScreen.kt | 40 ++- .../compose/screen/ConnectScreen.kt | 54 ++++ .../compose/screen/DeviceListScreen.kt | 30 ++ .../compose/screen/DeviceRevokedScreen.kt | 32 +- .../mullvadvpn/compose/screen/LoginScreen.kt | 58 +++- .../mullvadvpn/compose/screen/MullvadApp.kt | 11 + .../compose/screen/OutOfTimeScreen.kt | 30 ++ .../compose/screen/PrivacyDisclaimerScreen.kt | 41 ++- .../screen/RedeemVoucherDialogScreen.kt | 28 ++ .../compose/screen/ReportProblemScreen.kt | 24 ++ .../compose/screen/SelectLocationScreen.kt | 21 ++ .../compose/screen/SettingsScreen.kt | 26 ++ .../{LoadingScreen.kt => SplashScreen.kt} | 64 +++- .../compose/screen/SplitTunnelingScreen.kt | 22 ++ .../compose/screen/ViewLogsScreen.kt | 14 + .../compose/screen/VpnSettingsScreen.kt | 52 +++ .../compose/screen/WelcomeScreen.kt | 29 ++ .../SlideInFromBottomTransition.kt | 23 ++ .../transitions/SlideInFromRightTransition.kt | 23 ++ .../net/mullvad/mullvadvpn/di/UiModule.kt | 4 +- .../net/mullvad/mullvadvpn/ui/MainActivity.kt | 298 ++++++------------ .../mullvadvpn/ui/fragment/ConnectFragment.kt | 15 +- .../ui/fragment/DeviceListFragment.kt | 51 --- .../mullvadvpn/ui/fragment/LoadingFragment.kt | 4 +- .../mullvadvpn/ui/fragment/LoginFragment.kt | 17 +- .../ui/fragment/PrivacyDisclaimerFragment.kt | 5 +- .../ServiceConnectionManager.kt | 12 +- .../mullvadvpn/viewmodel/AccountViewModel.kt | 3 + .../viewmodel/DeviceListViewModel.kt | 16 +- .../mullvadvpn/viewmodel/LoginViewModel.kt | 3 +- .../viewmodel/PrivacyDisclaimerViewModel.kt | 17 +- .../mullvadvpn/viewmodel/SplashViewModel.kt | 63 ++++ .../viewmodel/SplitTunnelingViewModel.kt | 7 +- .../buildSrc/src/main/kotlin/Dependencies.kt | 2 + android/buildSrc/src/main/kotlin/Versions.kt | 9 +- .../service/ForegroundNotificationManager.kt | 18 +- 38 files changed, 909 insertions(+), 355 deletions(-) create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt rename android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/{LoadingScreen.kt => SplashScreen.kt} (54%) create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromBottomTransition.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f0b0a2f1a0cf..76db98471ff9 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -9,6 +9,7 @@ plugins { id(Dependencies.Plugin.playPublisherId) id(Dependencies.Plugin.kotlinAndroidId) id(Dependencies.Plugin.kotlinParcelizeId) + id(Dependencies.Plugin.ksp) version Versions.Plugin.ksp } val repoRootPath = rootProject.projectDir.absoluteFile.parentFile.absolutePath @@ -176,8 +177,7 @@ android { val enableInAppVersionNotifications = gradleLocalProperties(rootProject.projectDir) - .getProperty("ENABLE_IN_APP_VERSION_NOTIFICATIONS") - ?: "true" + .getProperty("ENABLE_IN_APP_VERSION_NOTIFICATIONS") ?: "true" buildConfigField( "boolean", @@ -316,6 +316,8 @@ dependencies { implementation(Dependencies.Compose.uiController) implementation(Dependencies.Compose.ui) implementation(Dependencies.Compose.uiUtil) + implementation(Dependencies.Compose.destinations) + ksp("io.github.raamcosta.compose-destinations:ksp:1.9.54") implementation(Dependencies.jodaTime) implementation(Dependencies.Koin.core) implementation(Dependencies.Koin.android) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7eede7ed3382..af80ac6f20a2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,42 +1,54 @@ + xmlns:tools="http://schemas.android.com/tools"> + - - - - - - + + + + + + + + + - + + @@ -50,11 +62,14 @@ however as it's protected by the bind vpn permission (android.permission.BIND_VPN_SERVICE) it's protected against third party apps/services. --> - + + @@ -73,12 +88,13 @@ Tile services must be exported and protected by the bind tile permission (android.permission.BIND_QUICK_SETTINGS_TILE). --> - + diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt index e09a9a28dd14..905b29aa8371 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + package net.mullvad.mullvadvpn.compose.screen import androidx.compose.animation.animateContentSize @@ -15,6 +17,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -26,6 +29,9 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -39,6 +45,9 @@ import net.mullvad.mullvadvpn.compose.component.MissingPolicy import net.mullvad.mullvadvpn.compose.component.NavigateBackDownIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar import net.mullvad.mullvadvpn.compose.dialog.DeviceNameInfoDialog +import net.mullvad.mullvadvpn.compose.screen.destinations.LoginDestination +import net.mullvad.mullvadvpn.compose.screen.destinations.RedeemVoucherDestination +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromBottomTransition import net.mullvad.mullvadvpn.compose.util.SecureScreenWhileInView import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser @@ -48,6 +57,7 @@ import net.mullvad.mullvadvpn.util.toExpiryDateString import net.mullvad.mullvadvpn.viewmodel.AccountUiState import net.mullvad.mullvadvpn.viewmodel.AccountViewModel import org.joda.time.DateTime +import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Preview @@ -67,6 +77,28 @@ private fun PreviewAccountScreen() { } } +@Destination(style = SlideInFromBottomTransition::class) +@Composable +fun Account(navigator: DestinationsNavigator) { + val vm = koinViewModel() + val state by vm.uiState.collectAsState() + + AccountScreen( + uiState = state, + uiSideEffect = vm.uiSideEffect, + enterTransitionEndAction = vm.enterTransitionEndAction, + onRedeemVoucherClick = { navigator.navigate(RedeemVoucherDestination) }, + onManageAccountClick = vm::onManageAccountClick, + onLogoutClick = vm::onLogoutClick, + navigateToLogin = { + navigator.navigate(LoginDestination(null)) { + popUpTo(NavGraphs.root) { inclusive = true } + } + }, + onBackClick = { navigator.navigateUp() } + ) +} + @ExperimentalMaterial3Api @Composable fun AccountScreen( @@ -76,6 +108,7 @@ fun AccountScreen( onRedeemVoucherClick: () -> Unit = {}, onManageAccountClick: () -> Unit = {}, onLogoutClick: () -> Unit = {}, + navigateToLogin: () -> Unit = {}, onBackClick: () -> Unit = {} ) { // This will enable SECURE_FLAG while this screen is visible to preview screenshot @@ -97,9 +130,12 @@ fun AccountScreen( LaunchedEffect(Unit) { uiSideEffect.collect { uiSideEffect -> - if (uiSideEffect is AccountViewModel.UiSideEffect.OpenAccountManagementPageInBrowser) { - context.openAccountPageInBrowser(uiSideEffect.token) + when (uiSideEffect) { + AccountViewModel.UiSideEffect.NavigateToLogin -> navigateToLogin() + is AccountViewModel.UiSideEffect.OpenAccountManagementPageInBrowser -> + context.openAccountPageInBrowser(uiSideEffect.token) } + if (uiSideEffect is AccountViewModel.UiSideEffect.OpenAccountManagementPageInBrowser) {} } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt index b2a6bc9b9e3a..b5cb6fa9dcd8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt @@ -1,5 +1,7 @@ package net.mullvad.mullvadvpn.compose.screen +import android.content.Intent +import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -16,6 +18,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember @@ -27,6 +30,9 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -38,6 +44,10 @@ import net.mullvad.mullvadvpn.compose.component.LocationInfo import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBarAndDeviceName import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.component.notificationbanner.NotificationBanner +import net.mullvad.mullvadvpn.compose.screen.destinations.AccountDestination +import net.mullvad.mullvadvpn.compose.screen.destinations.OutOfTimeDestination +import net.mullvad.mullvadvpn.compose.screen.destinations.SelectLocationDestination +import net.mullvad.mullvadvpn.compose.screen.destinations.SettingsDestination import net.mullvad.mullvadvpn.compose.state.ConnectUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.CONNECT_BUTTON_TEST_TAG @@ -51,8 +61,10 @@ import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel import net.mullvad.talpid.tunnel.ActionAfterDisconnect +import org.koin.androidx.compose.koinViewModel private const val CONNECT_BUTTON_THROTTLE_MILLIS = 1000 @@ -68,6 +80,48 @@ private fun PreviewConnectScreen() { } } +@Destination +@Composable +fun Connect(navigator: DestinationsNavigator) { + val connectViewModel: ConnectViewModel = koinViewModel() + + val state = connectViewModel.uiState.collectAsState().value + + val context = LocalContext.current + // val drawNavbar = _setNavigationBar.collectAsState() + ConnectScreen( + uiState = state, + uiSideEffect = connectViewModel.uiSideEffect, + // drawNavigationBar = drawNavbar.value, + onDisconnectClick = connectViewModel::onDisconnectClick, + onReconnectClick = connectViewModel::onReconnectClick, + onConnectClick = connectViewModel::onConnectClick, + onCancelClick = connectViewModel::onCancelClick, + onSwitchLocationClick = { navigator.navigate(SelectLocationDestination) }, + onToggleTunnelInfo = connectViewModel::toggleTunnelInfoExpansion, + onUpdateVersionClick = { + val intent = + Intent( + Intent.ACTION_VIEW, + Uri.parse( + context.getString(R.string.download_url).appendHideNavOnPlayBuild() + ) + ) + .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } + context.startActivity(intent) + }, + onManageAccountClick = connectViewModel::onManageAccountClick, + onOpenOutOfTimeScreen = { + navigator.navigate(OutOfTimeDestination) { + popUpTo(NavGraphs.root) { inclusive = true } + } + }, + onSettingsClick = { navigator.navigate(SettingsDestination) }, + onAccountClick = { navigator.navigate(AccountDestination) }, + onDismissNewDeviceClick = connectViewModel::dismissNewDeviceNotification, + ) +} + @Composable fun ConnectScreen( uiState: ConnectUiState, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt index 19a06e453d89..669a992fcd9c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt @@ -17,18 +17,25 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.button.VariantButton import net.mullvad.mullvadvpn.compose.component.ListItem import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar import net.mullvad.mullvadvpn.compose.dialog.ShowDeviceRemovalDialog +import net.mullvad.mullvadvpn.compose.screen.destinations.LoginDestination +import net.mullvad.mullvadvpn.compose.screen.destinations.SettingsDestination import net.mullvad.mullvadvpn.compose.state.DeviceListItemUiState import net.mullvad.mullvadvpn.compose.state.DeviceListUiState import net.mullvad.mullvadvpn.lib.common.util.parseAsDateTime @@ -37,6 +44,8 @@ import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar import net.mullvad.mullvadvpn.model.Device import net.mullvad.mullvadvpn.util.formatDate +import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel +import org.koin.androidx.compose.koinViewModel @Composable @Preview @@ -65,6 +74,27 @@ private fun PreviewDeviceListScreen() { } } +@Destination +@Composable +fun DeviceList(navigator: DestinationsNavigator, accountToken: String) { + val viewModel = koinViewModel() + val state by viewModel.uiState.collectAsState() + + DeviceListScreen( + state = state, + onBackClick = navigator::navigateUp, + onContinueWithLogin = { + navigator.navigate(LoginDestination(accountToken)) { + popUpTo(LoginDestination) { inclusive = true } + } + }, + onSettingsClicked = { navigator.navigate(SettingsDestination) }, + onDeviceRemovalClicked = viewModel::stageDeviceForRemoval, + onDismissDeviceRemovalDialog = viewModel::clearStagedDevice, + onConfirmDeviceRemovalDialog = { viewModel.confirmRemovalOfStagedDevice(accountToken) } + ) +} + @Composable fun DeviceListScreen( state: DeviceListUiState, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt index 5ec6b9a64ba6..ab5e2e3594b3 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt @@ -3,14 +3,15 @@ package net.mullvad.mullvadvpn.compose.screen import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource @@ -21,12 +22,19 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.DeviceRevokedLoginButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar +import net.mullvad.mullvadvpn.compose.screen.destinations.LoginDestination +import net.mullvad.mullvadvpn.compose.screen.destinations.SettingsDestination import net.mullvad.mullvadvpn.compose.state.DeviceRevokedUiState import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -34,6 +42,23 @@ private fun PreviewDeviceRevokedScreen() { AppTheme { DeviceRevokedScreen(state = DeviceRevokedUiState.SECURED) } } +@Destination +@Composable +fun DeviceRevoked(navigator: DestinationsNavigator) { + val viewModel = koinViewModel() + + val state by viewModel.uiState.collectAsState() + DeviceRevokedScreen( + state = state, + onSettingsClicked = { navigator.navigate(SettingsDestination) }, + onGoToLoginClicked = { + navigator.navigate(LoginDestination(null)) { + popUpTo(NavGraphs.root) { inclusive = true } + } + } + ) +} + @Composable fun DeviceRevokedScreen( state: DeviceRevokedUiState, @@ -56,8 +81,7 @@ fun DeviceRevokedScreen( ) { ConstraintLayout( modifier = - Modifier.fillMaxHeight() - .fillMaxWidth() + Modifier.fillMaxSize() .padding(it) .background(color = MaterialTheme.colorScheme.background) ) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt index f6f1aa3626ce..1ab419a1be26 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt @@ -27,6 +27,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -52,10 +54,17 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.button.VariantButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar +import net.mullvad.mullvadvpn.compose.screen.destinations.ConnectDestination +import net.mullvad.mullvadvpn.compose.screen.destinations.DeviceListDestination +import net.mullvad.mullvadvpn.compose.screen.destinations.SettingsDestination +import net.mullvad.mullvadvpn.compose.screen.destinations.WelcomeDestination import net.mullvad.mullvadvpn.compose.state.LoginError import net.mullvad.mullvadvpn.compose.state.LoginState import net.mullvad.mullvadvpn.compose.state.LoginState.* @@ -66,6 +75,9 @@ import net.mullvad.mullvadvpn.compose.util.accountTokenVisualTransformation import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar +import net.mullvad.mullvadvpn.viewmodel.LoginUiSideEffect +import net.mullvad.mullvadvpn.viewmodel.LoginViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -99,9 +111,53 @@ private fun PreviewLoginSuccess() { AppTheme { LoginScreen(uiState = LoginUiState(loginState = Success)) } } +@Destination +@Composable +fun Login( + navigator: DestinationsNavigator, + accountToken: String? = null, + vm: LoginViewModel = koinViewModel() +) { + val state by vm.uiState.collectAsState() + + LaunchedEffect(accountToken) { + if (accountToken != null) { + vm.onAccountNumberChange(accountToken) + vm.login(accountToken) + } + } + LaunchedEffect(Unit) { + vm.uiSideEffect.collect { + when (it) { + LoginUiSideEffect.NavigateToWelcome -> { + navigator.navigate(WelcomeDestination) { + popUpTo(NavGraphs.root) { inclusive = true } + } + } + LoginUiSideEffect.NavigateToConnect -> { + navigator.navigate(ConnectDestination) { + popUpTo(NavGraphs.root) { inclusive = true } + } + } + is LoginUiSideEffect.TooManyDevices -> { + navigator.navigate(DeviceListDestination(it.accountToken.value)) + } + } + } + } + LoginScreen( + state, + vm::login, + vm::createAccount, + vm::clearAccountHistory, + vm::onAccountNumberChange, + { navigator.navigate(SettingsDestination) } + ) +} + @OptIn(ExperimentalComposeUiApi::class) @Composable -fun LoginScreen( +private fun LoginScreen( uiState: LoginUiState, onLoginClick: (String) -> Unit = {}, onCreateAccountClick: () -> Unit = {}, 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 new file mode 100644 index 000000000000..6ad7617f40c6 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/MullvadApp.kt @@ -0,0 +1,11 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.ramcosta.composedestinations.DestinationsNavHost + +@Composable +fun MullvadApp() { + DestinationsNavHost(modifier = Modifier.fillMaxSize(), navGraph = NavGraphs.root) {} +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt index 7192f5475a5c..36e60ba1a19d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt @@ -14,12 +14,16 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -30,7 +34,12 @@ import net.mullvad.mullvadvpn.compose.button.SitePaymentButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBarAndDeviceName import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook +import net.mullvad.mullvadvpn.compose.screen.destinations.AccountDestination +import net.mullvad.mullvadvpn.compose.screen.destinations.ConnectDestination +import net.mullvad.mullvadvpn.compose.screen.destinations.RedeemVoucherDestination +import net.mullvad.mullvadvpn.compose.screen.destinations.SettingsDestination import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState +import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar @@ -40,6 +49,7 @@ import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel import net.mullvad.talpid.tunnel.ActionAfterDisconnect import net.mullvad.talpid.tunnel.ErrorState import net.mullvad.talpid.tunnel.ErrorStateCause +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -85,6 +95,26 @@ private fun PreviewOutOfTimeScreenError() { } } +@Destination +@Composable +fun OutOfTime(navigator: DestinationsNavigator) { + val vm = koinViewModel() + val state = vm.uiState.collectAsState().value + OutOfTimeScreen( + showSitePayment = IS_PLAY_BUILD.not(), + uiState = state, + uiSideEffect = vm.uiSideEffect, + onSitePaymentClick = vm::onSitePaymentClick, + onRedeemVoucherClick = { navigator.navigate(RedeemVoucherDestination) }, + onSettingsClick = { navigator.navigate(SettingsDestination) }, + onAccountClick = { navigator.navigate(AccountDestination) }, + openConnectScreen = { + navigator.navigate(ConnectDestination) { popUpTo(NavGraphs.root) { inclusive = true } } + }, + onDisconnectClick = vm::onDisconnectClick + ) +} + @Composable fun OutOfTimeScreen( showSitePayment: Boolean, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt index 02250c36632f..dc5e4f398ebd 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt @@ -5,8 +5,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -16,9 +15,11 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -30,14 +31,22 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar +import net.mullvad.mullvadvpn.compose.screen.destinations.LoginDestination import net.mullvad.mullvadvpn.compose.util.toDp import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar +import net.mullvad.mullvadvpn.ui.MainActivity +import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerUiSideEffect +import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -45,6 +54,29 @@ private fun PreviewPrivacyDisclaimerScreen() { AppTheme { PrivacyDisclaimerScreen({}, {}) } } +@Destination +@Composable +fun PrivacyDisclaimer( + navigator: DestinationsNavigator, +) { + val viewModel: PrivacyDisclaimerViewModel = koinViewModel() + + val context = LocalContext.current + LaunchedEffect(Unit) { + viewModel.uiSideEffect.collect { + when (it) { + PrivacyDisclaimerUiSideEffect.NavigateToLogin -> { + (context as MainActivity).initializeStateHandlerAndServiceConnection() + navigator.navigate(LoginDestination(null)) { + popUpTo(NavGraphs.root) { inclusive = true } + } + } + } + } + } + PrivacyDisclaimerScreen({}, viewModel::setPrivacyDisclosureAccepted) +} + @Composable fun PrivacyDisclaimerScreen( onPrivacyPolicyLinkClicked: () -> Unit, @@ -60,9 +92,8 @@ fun PrivacyDisclaimerScreen( ) { ConstraintLayout( modifier = - Modifier.fillMaxHeight() - .fillMaxWidth() - .padding(it) + Modifier.padding(it) + .fillMaxSize() .background(color = MaterialTheme.colorScheme.background) ) { val (body, actionButtons) = createRefs() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogScreen.kt index 1db18b01a31b..f18bb4537853 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogScreen.kt @@ -2,11 +2,19 @@ package net.mullvad.mullvadvpn.compose.screen import android.content.res.Configuration import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo +import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.compose.dialog.RedeemVoucherDialog +import net.mullvad.mullvadvpn.compose.screen.destinations.ConnectDestination import net.mullvad.mullvadvpn.compose.state.VoucherDialogUiState import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.viewmodel.VoucherDialogViewModel +import org.koin.androidx.compose.koinViewModel @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_3) @Composable @@ -21,6 +29,26 @@ private fun PreviewRedeemVoucherDialogScreen() { } } +@Destination(style = DestinationStyle.Dialog::class) +@Composable +fun RedeemVoucher(navigator: DestinationsNavigator) { + val vm = koinViewModel() + RedeemVoucherDialogScreen( + uiState = vm.uiState.collectAsState().value, + onVoucherInputChange = { vm.onVoucherInputChange(it) }, + onRedeem = { vm.onRedeem(it) }, + onDismiss = { wasSuccessful -> + if (!wasSuccessful) { + navigator.navigateUp() + } else { + navigator.navigate(ConnectDestination) { + popUpTo(NavGraphs.root) { inclusive = true } + } + } + } + ) +} + @Composable internal fun RedeemVoucherDialogScreen( uiState: VoucherDialogUiState, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt index f5346fbc7d35..4edc179174da 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ReportProblemScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -31,18 +32,24 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.button.VariantButton import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar import net.mullvad.mullvadvpn.compose.dialog.ReportProblemNoEmailDialog +import net.mullvad.mullvadvpn.compose.screen.destinations.ViewLogsDestination import net.mullvad.mullvadvpn.compose.textfield.mullvadWhiteTextFieldColors +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.dataproxy.SendProblemReportResult import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.viewmodel.ReportProblemUiState +import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel import net.mullvad.mullvadvpn.viewmodel.SendingReportUiState +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -88,6 +95,23 @@ private fun PreviewReportProblemErrorScreen() { } } +@Destination(style = SlideInFromRightTransition::class) +@Composable +fun ReportProblem(navigator: DestinationsNavigator) { + val vm = koinViewModel() + val uiState by vm.uiState.collectAsState() + + ReportProblemScreen( + uiState, + onSendReport = { email, description -> vm.sendReport(email, description) }, + onDismissNoEmailDialog = vm::dismissConfirmNoEmail, + onClearSendResult = vm::clearSendResult, + onNavigateToViewLogs = { navigator.navigate(ViewLogsDestination) } + ) { + navigator.navigateUp() + } +} + @Composable fun ReportProblemScreen( uiState: ReportProblemUiState, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt index 6a46ec351a0e..3a184af36d90 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -38,6 +39,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp import androidx.core.text.HtmlCompat import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import net.mullvad.mullvadvpn.R @@ -49,11 +52,14 @@ import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.textfield.SearchTextField +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromBottomTransition import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.relaylist.RelayCountry import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -72,6 +78,21 @@ private fun PreviewSelectLocationScreen() { } } +@Destination(style = SlideInFromBottomTransition::class) +@Composable +fun SelectLocation(navigator: DestinationsNavigator) { + val vm = koinViewModel() + val state = vm.uiState.collectAsState().value + SelectLocationScreen( + uiState = state, + uiCloseAction = vm.uiCloseAction, + enterTransitionEndAction = vm.enterTransitionEndAction, + onSelectRelay = vm::selectRelay, + onSearchTermInput = vm::onSearchTermInput, + onBackClick = navigator::navigateUp, + ) +} + @OptIn(ExperimentalComposeUiApi::class) @Composable fun SelectLocationScreen( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt index b092ed981b5f..648d05de31ad 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt @@ -12,12 +12,16 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import net.mullvad.mullvadvpn.R @@ -27,13 +31,19 @@ import net.mullvad.mullvadvpn.compose.cell.NavigationComposeCell import net.mullvad.mullvadvpn.compose.component.NavigateBackDownIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider +import net.mullvad.mullvadvpn.compose.screen.destinations.ReportProblemDestination +import net.mullvad.mullvadvpn.compose.screen.destinations.SplitTunnelingDestination +import net.mullvad.mullvadvpn.compose.screen.destinations.VpnSettingsDestination import net.mullvad.mullvadvpn.compose.state.SettingsUiState import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_TEST_TAG +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromBottomTransition import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.lib.common.util.openLink import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild +import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel +import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) @Preview @@ -52,6 +62,22 @@ private fun PreviewSettings() { } } +@OptIn(ExperimentalMaterial3Api::class) +@Destination(style = SlideInFromBottomTransition::class) +@Composable +fun Settings(navigator: DestinationsNavigator) { + val vm = koinViewModel() + val state by vm.uiState.collectAsState() + SettingsScreen( + uiState = state, + enterTransitionEndAction = vm.enterTransitionEndAction, + onVpnSettingCellClick = { navigator.navigate(VpnSettingsDestination) }, + onSplitTunnelingCellClick = { navigator.navigate(SplitTunnelingDestination) }, + onReportProblemCellClick = { navigator.navigate(ReportProblemDestination) }, + onBackClick = { navigator.navigateUp() } + ) +} + @ExperimentalMaterial3Api @Composable fun SettingsScreen( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoadingScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplashScreen.kt similarity index 54% rename from android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoadingScreen.kt rename to android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplashScreen.kt index d27b4196aa21..9532f721c9f9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoadingScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplashScreen.kt @@ -1,5 +1,6 @@ package net.mullvad.mullvadvpn.compose.screen +import android.window.SplashScreen import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -12,34 +13,91 @@ import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootNavGraph +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar +import net.mullvad.mullvadvpn.compose.screen.destinations.ConnectDestination +import net.mullvad.mullvadvpn.compose.screen.destinations.DeviceRevokedDestination +import net.mullvad.mullvadvpn.compose.screen.destinations.LoginDestination +import net.mullvad.mullvadvpn.compose.screen.destinations.PrivacyDisclaimerDestination import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.color.AlphaDescription +import net.mullvad.mullvadvpn.ui.MainActivity +import net.mullvad.mullvadvpn.viewmodel.SplashUiSideEffect +import net.mullvad.mullvadvpn.viewmodel.SplashViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable private fun PreviewLoadingScreen() { - AppTheme { LoadingScreen() } + AppTheme { SplashScreen() } } +@RootNavGraph(start = true) // sets this as the start destination of the default nav graph +@Destination @Composable -fun LoadingScreen(onSettingsCogClicked: () -> Unit = {}) { +fun Splash(navigator: DestinationsNavigator) { + val viewModel: SplashViewModel = koinViewModel() + + val context = LocalContext.current + LaunchedEffect(Unit) { + val mainActivity = context as MainActivity + val initDaemon = { mainActivity.initializeStateHandlerAndServiceConnection() } + viewModel.uiSideEffect.collect { + when (it) { + SplashUiSideEffect.NavigateToConnect -> { + navigator.navigate(ConnectDestination) { + popUpTo(NavGraphs.root) { inclusive = true } + } + } + SplashUiSideEffect.NavigateToLogin -> { + navigator.navigate(LoginDestination()) { + popUpTo(NavGraphs.root) { inclusive = true } + } + } + SplashUiSideEffect.NavigateToPrivacyDisclaimer -> { + navigator.navigate(PrivacyDisclaimerDestination) { + popUpTo(NavGraphs.root) { inclusive = true } + } + } + SplashUiSideEffect.NavigateToRevoked -> { + navigator.navigate(DeviceRevokedDestination) { + popUpTo(NavGraphs.root) { inclusive = true } + } + } + SplashUiSideEffect.StartDaemon -> { + initDaemon() + } + } + } + } + + SplashScreen() +} + +@Composable +private fun SplashScreen() { + val backgroundColor = MaterialTheme.colorScheme.primary ScaffoldWithTopBar( topBarColor = backgroundColor, statusBarColor = backgroundColor, navigationBarColor = backgroundColor, - onSettingsClicked = onSettingsCogClicked, + onSettingsClicked = {}, onAccountClicked = null, isIconAndLogoVisible = false, content = { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt index bf47a7a17f78..93aa7f43aed3 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SplitTunnelingScreen.kt @@ -15,6 +15,8 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection @@ -22,6 +24,8 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.applist.AppData import net.mullvad.mullvadvpn.compose.cell.BaseCell @@ -34,8 +38,11 @@ import net.mullvad.mullvadvpn.compose.constant.ContentType import net.mullvad.mullvadvpn.compose.constant.SplitTunnelingContentKey import net.mullvad.mullvadvpn.compose.extensions.itemWithDivider import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -71,6 +78,21 @@ private fun PreviewSplitTunnelingScreen() { } } +@Destination(style = SlideInFromRightTransition::class) +@Composable +fun SplitTunneling(navigator: DestinationsNavigator) { + val viewModel = koinViewModel() + val state by viewModel.uiState.collectAsState() + SplitTunnelingScreen( + uiState = state, + onShowSystemAppsClick = viewModel::onShowSystemAppsClick, + onExcludeAppClick = viewModel::onExcludeAppClick, + onIncludeAppClick = viewModel::onIncludeAppClick, + onBackClick = navigator::navigateUp, + onResolveIcon = { packageName -> viewModel.resolveIcon(packageName) } + ) +} + @Composable @OptIn(ExperimentalFoundationApi::class) fun SplitTunnelingScreen( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt index 5f45e2535a72..4a6a4bae4dad 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ViewLogsScreen.kt @@ -14,19 +14,25 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.MullvadMediumTopBar import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.viewmodel.ViewLogsUiState +import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -40,6 +46,14 @@ private fun PreviewViewLogsLoadingScreen() { AppTheme { ViewLogsScreen(uiState = ViewLogsUiState()) } } +@Destination(style = SlideInFromRightTransition::class) +@Composable +fun ViewLogs(navigator: DestinationsNavigator) { + val vm = koinViewModel() + val uiState = vm.uiState.collectAsState() + ViewLogsScreen(uiState = uiState.value, onBackClick = navigator::navigateUp) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ViewLogsScreen( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt index 4e08e06a1de2..2944fe7391f1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -30,6 +31,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -70,6 +73,7 @@ import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_WIREGUARD_CUSTOM_PORT_NUMBER_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_WIREGUARD_CUSTOM_PORT_TEXT_TEST_TAG import net.mullvad.mullvadvpn.compose.test.LAZY_LIST_WIREGUARD_PORT_ITEM_X_TEST_TAG +import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.constant.WIREGUARD_PRESET_PORTS import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens @@ -81,6 +85,8 @@ import net.mullvad.mullvadvpn.util.hasValue import net.mullvad.mullvadvpn.util.isCustom import net.mullvad.mullvadvpn.util.toDisplayCustomPort import net.mullvad.mullvadvpn.viewmodel.CustomDnsItem +import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -133,6 +139,52 @@ private fun PreviewVpnSettings() { } } +@Destination(style = SlideInFromRightTransition::class) +@Composable +fun VpnSettings(navigator: DestinationsNavigator) { + + val vm = koinViewModel() + val state = vm.uiState.collectAsState().value + VpnSettingsScreen( + uiState = state, + onMtuCellClick = vm::onMtuCellClick, + onSaveMtuClick = vm::onSaveMtuClick, + onRestoreMtuClick = vm::onRestoreMtuClick, + onCancelMtuDialogClick = vm::onCancelDialogClick, + onToggleAutoConnect = vm::onToggleAutoConnect, + onToggleLocalNetworkSharing = vm::onToggleLocalNetworkSharing, + onToggleDnsClick = vm::onToggleDnsClick, + onToggleBlockAds = vm::onToggleBlockAds, + onToggleBlockTrackers = vm::onToggleBlockTrackers, + onToggleBlockMalware = vm::onToggleBlockMalware, + onToggleBlockAdultContent = vm::onToggleBlockAdultContent, + onToggleBlockGambling = vm::onToggleBlockGambling, + onToggleBlockSocialMedia = vm::onToggleBlockSocialMedia, + onDnsClick = vm::onDnsClick, + onDnsInputChange = vm::onDnsInputChange, + onSaveDnsClick = vm::onSaveDnsClick, + onRemoveDnsClick = vm::onRemoveDnsClick, + onCancelDnsDialogClick = vm::onCancelDns, + onLocalNetworkSharingInfoClick = vm::onLocalNetworkSharingInfoClick, + onContentsBlockersInfoClick = vm::onContentsBlockerInfoClick, + onCustomDnsInfoClick = vm::onCustomDnsInfoClick, + onMalwareInfoClick = vm::onMalwareInfoClick, + onDismissInfoClick = vm::onDismissInfoClick, + onBackClick = navigator::navigateUp, + onStopEvent = vm::onStopEvent, + toastMessagesSharedFlow = vm.toastMessages, + onSelectObfuscationSetting = vm::onSelectObfuscationSetting, + onObfuscationInfoClick = vm::onObfuscationInfoClick, + onSelectQuantumResistanceSetting = vm::onSelectQuantumResistanceSetting, + onQuantumResistanceInfoClicked = vm::onQuantumResistanceInfoClicked, + onWireguardPortSelected = vm::onWireguardPortSelected, + onWireguardPortInfoClicked = vm::onWireguardPortInfoClicked, + onShowCustomPortDialog = vm::onShowCustomPortDialog, + onCancelCustomPortDialogClick = vm::onCancelDialogClick, + onCloseCustomPortDialog = vm::onCancelDialogClick + ) +} + @OptIn(ExperimentalFoundationApi::class) @Composable fun VpnSettingsScreen( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt index c983f69528d7..b91c808f606e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -29,6 +30,9 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.navigation.popUpTo import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -39,8 +43,13 @@ import net.mullvad.mullvadvpn.compose.component.CopyAnimatedIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.dialog.DeviceNameInfoDialog +import net.mullvad.mullvadvpn.compose.screen.destinations.AccountDestination +import net.mullvad.mullvadvpn.compose.screen.destinations.ConnectDestination +import net.mullvad.mullvadvpn.compose.screen.destinations.RedeemVoucherDestination +import net.mullvad.mullvadvpn.compose.screen.destinations.SettingsDestination import net.mullvad.mullvadvpn.compose.state.WelcomeUiState import net.mullvad.mullvadvpn.compose.util.createCopyToClipboardHandle +import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD import net.mullvad.mullvadvpn.lib.common.util.groupWithSpaces import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser import net.mullvad.mullvadvpn.lib.theme.AppTheme @@ -49,6 +58,7 @@ import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar import net.mullvad.mullvadvpn.lib.theme.color.MullvadWhite import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel +import org.koin.androidx.compose.koinViewModel @Preview @Composable @@ -67,6 +77,25 @@ private fun PreviewWelcomeScreen() { } } +@Destination +@Composable +fun Welcome(navigator: DestinationsNavigator) { + val vm = koinViewModel() + val state by vm.uiState.collectAsState() + WelcomeScreen( + showSitePayment = IS_PLAY_BUILD.not(), + uiState = state, + uiSideEffect = vm.uiSideEffect, + onSitePaymentClick = vm::onSitePaymentClick, + onRedeemVoucherClick = { navigator.navigate(RedeemVoucherDestination) }, + onSettingsClick = { navigator.navigate(SettingsDestination) }, + onAccountClick = { navigator.navigate(AccountDestination) }, + openConnectScreen = { + navigator.navigate(ConnectDestination) { popUpTo(NavGraphs.root) { inclusive = true } } + } + ) +} + @Composable fun WelcomeScreen( showSitePayment: Boolean, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromBottomTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromBottomTransition.kt new file mode 100644 index 000000000000..6b404c2b9248 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromBottomTransition.kt @@ -0,0 +1,23 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.spec.DestinationStyle + +object SlideInFromBottomTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope.enterTransition(): + EnterTransition { + return slideInVertically(initialOffsetY = { it }) + } + + override fun AnimatedContentTransitionScope.popEnterTransition() = null + + override fun AnimatedContentTransitionScope.popExitTransition(): + ExitTransition { + return slideOutVertically(targetOffsetY = { it }) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt new file mode 100644 index 000000000000..6fc0045fe743 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/transitions/SlideInFromRightTransition.kt @@ -0,0 +1,23 @@ +package net.mullvad.mullvadvpn.compose.transitions + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.spec.DestinationStyle + +object SlideInFromRightTransition : DestinationStyle.Animated { + override fun AnimatedContentTransitionScope.enterTransition(): + EnterTransition { + return slideInHorizontally(initialOffsetX = { it }) + } + + override fun AnimatedContentTransitionScope.popEnterTransition() = null + + override fun AnimatedContentTransitionScope.popExitTransition(): + ExitTransition { + return slideOutHorizontally(targetOffsetX = { it }) + } +} 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 398e27820e82..89ed99cd6319 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 @@ -37,6 +37,7 @@ import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel +import net.mullvad.mullvadvpn.viewmodel.SplashViewModel import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel import net.mullvad.mullvadvpn.viewmodel.VoucherDialogViewModel @@ -59,7 +60,7 @@ val uiModule = module { single { androidContext().packageManager } single(named(SELF_PACKAGE_NAME)) { androidContext().packageName } - viewModel { SplitTunnelingViewModel(get(), get(), Dispatchers.Default) } + viewModel { SplitTunnelingViewModel(get(), get(), get(), Dispatchers.Default) } single { ApplicationsIconManager(get()) } onClose { it?.dispose() } single { ApplicationsProvider(get(), get(named(SELF_PACKAGE_NAME))) } @@ -105,6 +106,7 @@ val uiModule = module { viewModel { PrivacyDisclaimerViewModel(get()) } viewModel { SelectLocationViewModel(get()) } viewModel { SettingsViewModel(get(), get()) } + viewModel { SplashViewModel(get(), get()) } viewModel { VoucherDialogViewModel(get(), get()) } viewModel { VpnSettingsViewModel(get(), get(), get(), get()) } viewModel { WelcomeViewModel(get(), get(), 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 98b0c0576c37..a9c3745e45f4 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 @@ -4,58 +4,40 @@ import android.Manifest import android.app.Activity import android.app.UiModeManager import android.content.Intent -import android.content.pm.ActivityInfo import android.content.res.Configuration import android.net.VpnService import android.os.Bundle import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.collectAsState import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.fragment.app.FragmentActivity -import androidx.fragment.app.FragmentManager -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onSubscription -import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import net.mullvad.mullvadvpn.BuildConfig import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.dialog.ChangelogDialog +import net.mullvad.mullvadvpn.compose.screen.MullvadApp import net.mullvad.mullvadvpn.di.uiModule import net.mullvad.mullvadvpn.lib.common.util.SdkUtils.isNotificationPermissionGranted -import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration import net.mullvad.mullvadvpn.lib.endpoint.getApiEndpointConfigurationExtras import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.model.AccountExpiry -import net.mullvad.mullvadvpn.model.DeviceState import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.DeviceRepository import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository -import net.mullvad.mullvadvpn.ui.fragment.AccountFragment -import net.mullvad.mullvadvpn.ui.fragment.ConnectFragment -import net.mullvad.mullvadvpn.ui.fragment.DeviceRevokedFragment -import net.mullvad.mullvadvpn.ui.fragment.LoadingFragment -import net.mullvad.mullvadvpn.ui.fragment.LoginFragment -import net.mullvad.mullvadvpn.ui.fragment.OutOfTimeFragment -import net.mullvad.mullvadvpn.ui.fragment.PrivacyDisclaimerFragment -import net.mullvad.mullvadvpn.ui.fragment.SettingsFragment -import net.mullvad.mullvadvpn.ui.fragment.WelcomeFragment import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.util.UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS -import net.mullvad.mullvadvpn.util.addDebounceForUnknownState import net.mullvad.mullvadvpn.viewmodel.ChangelogDialogUiState import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel import org.koin.android.ext.android.getKoin import org.koin.core.context.loadKoinModules -open class MainActivity : FragmentActivity() { +open class MainActivity : ComponentActivity() { private val requestNotificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { // NotificationManager.areNotificationsEnabled is used to check the state rather than @@ -74,9 +56,6 @@ open class MainActivity : FragmentActivity() { private lateinit var serviceConnectionManager: ServiceConnectionManager private lateinit var changelogViewModel: ChangelogViewModel - private var deviceStateJob: Job? = null - private var currentDeviceState: DeviceState? = null - override fun onCreate(savedInstanceState: Bundle?) { loadKoinModules(uiModule) @@ -88,39 +67,37 @@ open class MainActivity : FragmentActivity() { changelogViewModel = get() } - requestedOrientation = - if (deviceIsTv) { - ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE - } else { - ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - } - + // requestedOrientation = + // if (deviceIsTv) { + // ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + // } else { + // ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + // } + // super.onCreate(savedInstanceState) - setContentView(R.layout.main) + setContent { AppTheme { MullvadApp() } } } override fun onStart() { Log.d("mullvad", "Starting main activity") super.onStart() - if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) { - initializeStateHandlerAndServiceConnection( - apiEndpointConfiguration = intent?.getApiEndpointConfigurationExtras() - ) - } else { - openPrivacyDisclaimerFragment() - } + // if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) { + // initializeStateHandlerAndServiceConnection( + // apiEndpointConfiguration = intent?.getApiEndpointConfigurationExtras() + // ) + // } else { + // openPrivacyDisclaimerFragment() + // } } - fun initializeStateHandlerAndServiceConnection( - apiEndpointConfiguration: ApiEndpointConfiguration? - ) { - deviceStateJob = launchDeviceStateHandler() + fun initializeStateHandlerAndServiceConnection() { + // deviceStateJob = launchDeviceStateHandler() checkForNotificationPermission() serviceConnectionManager.bind( vpnPermissionRequestHandler = ::requestVpnPermission, - apiEndpointConfiguration = apiEndpointConfiguration + apiEndpointConfiguration = intent?.getApiEndpointConfigurationExtras() ) } @@ -135,8 +112,6 @@ open class MainActivity : FragmentActivity() { // NOTE: `super.onStop()` must be called before unbinding due to the fragment state handling // otherwise the fragments will believe there was an unexpected disconnect. serviceConnectionManager.unbind() - - deviceStateJob?.cancel() } override fun onDestroy() { @@ -145,86 +120,90 @@ open class MainActivity : FragmentActivity() { } fun openAccount() { - supportFragmentManager.beginTransaction().apply { - setCustomAnimations( - R.anim.fragment_enter_from_bottom, - R.anim.do_nothing, - R.anim.do_nothing, - R.anim.fragment_exit_to_bottom - ) - replace(R.id.main_fragment, AccountFragment()) - addToBackStack(null) - commitAllowingStateLoss() - } + // supportFragmentManager.beginTransaction().apply { + // setCustomAnimations( + // R.anim.fragment_enter_from_bottom, + // R.anim.do_nothing, + // R.anim.do_nothing, + // R.anim.fragment_exit_to_bottom + // ) + // replace(R.id.main_fragment, AccountFragment()) + // addToBackStack(null) + // commitAllowingStateLoss() + // } } fun openSettings() { - supportFragmentManager.beginTransaction().apply { - setCustomAnimations( - R.anim.fragment_enter_from_bottom, - R.anim.do_nothing, - R.anim.do_nothing, - R.anim.fragment_exit_to_bottom - ) - replace(R.id.main_fragment, SettingsFragment()) - addToBackStack(null) - commitAllowingStateLoss() - } - } - - private fun launchDeviceStateHandler(): Job { - return lifecycleScope.launch { - launch { - deviceRepository.deviceState - .debounce { - // Debounce DeviceState.Unknown to delay view transitions during reconnect. - it.addDebounceForUnknownState(UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS) - } - .collect { newState -> - if (newState != currentDeviceState) - when (newState) { - is DeviceState.Initial, - is DeviceState.Unknown -> openLaunchView() - is DeviceState.LoggedOut -> openLoginView() - is DeviceState.Revoked -> openRevokedView() - is DeviceState.LoggedIn -> { - openLoggedInView( - accountToken = newState.accountAndDevice.account_token, - shouldDelayLogin = - currentDeviceState is DeviceState.LoggedOut - ) - } - } - currentDeviceState = newState - } - } - - lifecycleScope.launch { - deviceRepository.deviceState - .filter { it is DeviceState.LoggedIn || it is DeviceState.LoggedOut } - .collect { loadChangelogComponent() } - } - } - } - - private fun loadChangelogComponent() { - findViewById(R.id.compose_view).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) - setContent { - val state = changelogViewModel.uiState.collectAsState().value - if (state is ChangelogDialogUiState.Show) { - AppTheme { - ChangelogDialog( - changesList = state.changes, - version = BuildConfig.VERSION_NAME, - onDismiss = { changelogViewModel.dismissChangelogDialog() } - ) - } - } - } - changelogViewModel.refreshChangelogDialogUiState() - } - } + // supportFragmentManager.beginTransaction().apply { + // setCustomAnimations( + // R.anim.fragment_enter_from_bottom, + // R.anim.do_nothing, + // R.anim.do_nothing, + // R.anim.fragment_exit_to_bottom + // ) + // replace(R.id.main_fragment, SettingsFragment()) + // addToBackStack(null) + // commitAllowingStateLoss() + // } + } + + // private fun launchDeviceStateHandler(): Job { + // return lifecycleScope.launch { + // launch { + // deviceRepository.deviceState + // .debounce { + // // Debounce DeviceState.Unknown to delay view transitions during + // reconnect. + // + // it.addDebounceForUnknownState(UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS) + // } + // .collect { newState -> + // if (newState != currentDeviceState) + // when (newState) { + // is DeviceState.Initial, + // is DeviceState.Unknown -> openLaunchView() + // is DeviceState.LoggedOut -> openLoginView() + // is DeviceState.Revoked -> openRevokedView() + // is DeviceState.LoggedIn -> { + // openLoggedInView( + // accountToken = + // newState.accountAndDevice.account_token, + // shouldDelayLogin = + // currentDeviceState is DeviceState.LoggedOut + // ) + // } + // } + // currentDeviceState = newState + // } + // } + // + // lifecycleScope.launch { + // deviceRepository.deviceState + // .filter { it is DeviceState.LoggedIn || it is DeviceState.LoggedOut } + // .collect { loadChangelogComponent() } + // } + // } + // } + // + +// private fun loadChangelogComponent() { +// findViewById(R.id.compose_view).apply { +// setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow) +// setContent { +// val state = changelogViewModel.uiState.collectAsState().value +// if (state is ChangelogDialogUiState.Show) { +// AppTheme { +// ChangelogDialog( +// changesList = state.changes, +// version = BuildConfig.VERSION_NAME, +// onDismiss = { changelogViewModel.dismissChangelogDialog() } +// ) +// } +// } +// } +// changelogViewModel.refreshChangelogDialogUiState() +// } +// } @Suppress("DEPRECATION") private fun requestVpnPermission() { @@ -233,45 +212,10 @@ open class MainActivity : FragmentActivity() { startActivityForResult(intent, 0) } - private fun openLaunchView() { - supportFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, LoadingFragment()) - commitAllowingStateLoss() - } - } - - private fun openPrivacyDisclaimerFragment() { - supportFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, PrivacyDisclaimerFragment()) - commitAllowingStateLoss() - } - } - + // TODO Make sure this happens (login with no time left) private suspend fun openLoggedInView(accountToken: String, shouldDelayLogin: Boolean) { val isNewAccount = accountToken == accountRepository.cachedCreatedAccount.value val isExpired = isNewAccount.not() && isExpired(LOGIN_AWAIT_EXPIRY_MILLIS) - - val fragment = - when { - isNewAccount -> WelcomeFragment() - isExpired -> { - if (shouldDelayLogin) { - delay(LOGIN_DELAY_MILLIS) - } - OutOfTimeFragment() - } - else -> { - if (shouldDelayLogin) { - delay(LOGIN_DELAY_MILLIS) - } - ConnectFragment() - } - } - - supportFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, fragment) - commitAllowingStateLoss() - } } private suspend fun isExpired(timeoutMillis: Long): Boolean { @@ -281,39 +225,7 @@ open class MainActivity : FragmentActivity() { .filter { it is AccountExpiry.Available } .map { it.date()?.isBeforeNow } .first() - } - ?: false - } - - private fun openLoginView() { - clearBackStack() - supportFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, LoginFragment()) - commitAllowingStateLoss() - } - } - - private fun openRevokedView() { - clearBackStack() - supportFragmentManager.beginTransaction().apply { - setCustomAnimations( - R.anim.fragment_enter_from_right, - R.anim.fragment_exit_to_left, - R.anim.fragment_half_enter_from_left, - R.anim.fragment_exit_to_right - ) - replace(R.id.main_fragment, DeviceRevokedFragment()) - commitAllowingStateLoss() - } - } - - fun clearBackStack() { - supportFragmentManager.apply { - if (backStackEntryCount > 0) { - val firstEntry = getBackStackEntryAt(0) - popBackStack(firstEntry.id, FragmentManager.POP_BACK_STACK_INCLUSIVE) - } - } + } ?: false } private fun checkForNotificationPermission() { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt index 532787ff4f44..ee4ae501791b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConnectFragment.kt @@ -1,7 +1,5 @@ package net.mullvad.mullvadvpn.ui.fragment -import android.content.Intent -import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -13,7 +11,6 @@ import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.screen.ConnectScreen import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.ui.MainActivity -import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel import org.koin.androidx.viewmodel.ext.android.viewModel @@ -57,17 +54,7 @@ class ConnectFragment : BaseFragment() { return view } - private fun openDownloadUrl() { - val intent = - Intent( - Intent.ACTION_VIEW, - Uri.parse( - requireContext().getString(R.string.download_url).appendHideNavOnPlayBuild() - ) - ) - .apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK } - requireContext().startActivity(intent) - } + private fun openDownloadUrl() {} private fun openSwitchLocationScreen() { parentFragmentManager.beginTransaction().apply { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/DeviceListFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/DeviceListFragment.kt index ab6de40dc2c3..13d172cf4188 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/DeviceListFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/DeviceListFragment.kt @@ -1,21 +1,13 @@ package net.mullvad.mullvadvpn.ui.fragment import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup import android.widget.Toast -import androidx.compose.runtime.collectAsState -import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.DeviceListScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.ui.MainActivity import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel import org.koin.androidx.viewmodel.ext.android.viewModel @@ -29,32 +21,6 @@ class DeviceListFragment : Fragment() { lifecycleScope.launchUiSubscriptionsOnResume() } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - deviceListViewModel.accountToken = arguments?.getString(ACCOUNT_TOKEN_ARGUMENT_KEY) - - return inflater.inflate(R.layout.fragment_compose, container, false).apply { - findViewById(R.id.compose_view).setContent { - AppTheme { - val state = deviceListViewModel.uiState.collectAsState().value - DeviceListScreen( - state = state, - onBackClick = { openLoginView(doTriggerAutoLogin = false) }, - onContinueWithLogin = { openLoginView(doTriggerAutoLogin = true) }, - onSettingsClicked = this@DeviceListFragment::openSettings, - onDeviceRemovalClicked = deviceListViewModel::stageDeviceForRemoval, - onDismissDeviceRemovalDialog = deviceListViewModel::clearStagedDevice, - onConfirmDeviceRemovalDialog = - deviceListViewModel::confirmRemovalOfStagedDevice - ) - } - } - } - } - override fun onResume() { super.onResume() deviceListViewModel.clearStagedDevice() @@ -66,23 +32,6 @@ class DeviceListFragment : Fragment() { .collect { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() } } - private fun openLoginView(doTriggerAutoLogin: Boolean) { - parentActivity()?.clearBackStack() - val loginFragment = - LoginFragment().apply { - if (doTriggerAutoLogin && deviceListViewModel.accountToken != null) { - arguments = - Bundle().apply { - putString(ACCOUNT_TOKEN_ARGUMENT_KEY, deviceListViewModel.accountToken) - } - } - } - parentFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, loginFragment) - commitAllowingStateLoss() - } - } - private fun parentActivity(): MainActivity? { return (context as? MainActivity) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoadingFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoadingFragment.kt index d2f0cbfb6e89..49b651af18e1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoadingFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoadingFragment.kt @@ -7,8 +7,6 @@ import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.LoadingScreen -import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.ui.MainActivity class LoadingFragment : Fragment() { @@ -19,7 +17,7 @@ class LoadingFragment : Fragment() { ): View { return inflater.inflate(R.layout.fragment_compose, container, false).apply { findViewById(R.id.compose_view).setContent { - AppTheme { LoadingScreen(this@LoadingFragment::openSettings) } + // AppTheme { SplashScreen(this@LoadingFragment::openSettings) } } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoginFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoginFragment.kt index 92d58066ee0b..2dfa6df7cbf1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoginFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/LoginFragment.kt @@ -4,16 +4,13 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.ComposeView import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.screen.LoginScreen import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.model.AccountToken import net.mullvad.mullvadvpn.ui.MainActivity -import net.mullvad.mullvadvpn.viewmodel.LoginUiSideEffect import net.mullvad.mullvadvpn.viewmodel.LoginViewModel import org.koin.androidx.viewmodel.ext.android.viewModel @@ -38,18 +35,7 @@ class LoginFragment : BaseFragment() { findViewById(R.id.compose_view).setContent { AppTheme { val uiState by vm.uiState.collectAsState() - LaunchedEffect(Unit) { - vm.uiSideEffect.collect { - when (it) { - LoginUiSideEffect.NavigateToWelcome, - LoginUiSideEffect - .NavigateToConnect -> {} // TODO Fix when we redo navigation - is LoginUiSideEffect.TooManyDevices -> { - navigateToDeviceListFragment(it.accountToken) - } - } - } - } + /* LoginScreen( uiState, vm::login, @@ -58,6 +44,7 @@ class LoginFragment : BaseFragment() { vm::onAccountNumberChange, ::openSettingsView ) + */ } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/PrivacyDisclaimerFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/PrivacyDisclaimerFragment.kt index ed4538201363..baa5c149d6dd 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/PrivacyDisclaimerFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/PrivacyDisclaimerFragment.kt @@ -10,7 +10,6 @@ import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.screen.PrivacyDisclaimerScreen -import net.mullvad.mullvadvpn.lib.endpoint.getApiEndpointConfigurationExtras import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.ui.MainActivity import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild @@ -40,9 +39,7 @@ class PrivacyDisclaimerFragment : Fragment() { private fun handleAcceptedPrivacyDisclaimer() { privacyDisclaimerViewModel.setPrivacyDisclosureAccepted() - (activity as? MainActivity)?.initializeStateHandlerAndServiceConnection( - apiEndpointConfiguration = activity?.intent?.getApiEndpointConfigurationExtras() - ) + (activity as? MainActivity)?.initializeStateHandlerAndServiceConnection() } private fun openPrivacyPolicy() { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt index d840b934919c..680dde81e51e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionManager.kt @@ -3,6 +3,8 @@ package net.mullvad.mullvadvpn.ui.serviceconnection import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build import android.os.IBinder import android.os.Messenger import android.util.Log @@ -74,7 +76,15 @@ class ServiceConnectionManager(private val context: Context) : MessageHandler { } context.startService(intent) - context.bindService(intent, serviceConnection, 0) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + context.bindService( + intent, + serviceConnection, + ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED + ) + } else { + context.bindService(intent, serviceConnection, 0) + } isBound = true } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt index fb3e3d6393f5..9d2de0ef48dc 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt @@ -57,6 +57,7 @@ class AccountViewModel( fun onLogoutClick() { accountRepository.logout() + viewModelScope.launch { _uiSideEffect.emit(UiSideEffect.NavigateToLogin) } } fun onTransitionAnimationEnd() { @@ -64,6 +65,8 @@ class AccountViewModel( } sealed class UiSideEffect { + data object NavigateToLogin : UiSideEffect() + data class OpenAccountManagementPageInBrowser(val token: String) : UiSideEffect() } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt index 98648e0015bb..eb2f40b76460 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt @@ -38,10 +38,10 @@ class DeviceListViewModel( private val _toastMessages = MutableSharedFlow(extraBufferCapacity = 1) @Suppress("konsist.ensure public properties use permitted names") + // TODO Fix toasts val toastMessages = _toastMessages.asSharedFlow() @Suppress("konsist.ensure public properties use permitted names") - var accountToken: String? = null private var cachedDeviceList: List? = null val uiState = @@ -82,11 +82,10 @@ class DeviceListViewModel( _stagedDeviceId.value = null } - fun confirmRemovalOfStagedDevice() { - val token = accountToken + fun confirmRemovalOfStagedDevice(accountToken: String) { val stagedDeviceId = _stagedDeviceId.value - if (token != null && stagedDeviceId != null) { + if (stagedDeviceId != null) { viewModelScope.launch { withContext(dispatcher) { val result = @@ -95,7 +94,7 @@ class DeviceListViewModel( .onSubscription { clearStagedDevice() setLoadingDevice(stagedDeviceId) - deviceRepository.removeDevice(token, stagedDeviceId) + deviceRepository.removeDevice(accountToken, stagedDeviceId) } .filter { (deviceId, result) -> deviceId == stagedDeviceId && result == RemoveDeviceResult.Ok @@ -109,7 +108,7 @@ class DeviceListViewModel( _toastMessages.tryEmit( resources.getString(R.string.failed_to_remove_device) ) - refreshDeviceList() + refreshDeviceList(accountToken) } } } @@ -117,14 +116,13 @@ class DeviceListViewModel( _toastMessages.tryEmit(resources.getString(R.string.error_occurred)) clearLoadingDevices() clearStagedDevice() - refreshDeviceList() + refreshDeviceList(accountToken) } } fun refreshDeviceState() = deviceRepository.refreshDeviceState() - fun refreshDeviceList() = - accountToken?.let { token -> deviceRepository.refreshDeviceList(token) } + fun refreshDeviceList(accountToken: String) = deviceRepository.refreshDeviceList(accountToken) private fun setLoadingDevice(deviceId: DeviceId) { _loadingDevices.value = _loadingDevices.value.toMutableList().apply { add(deviceId) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt index b31478ce1a95..05e2827f078c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt @@ -103,10 +103,11 @@ class LoginViewModel( if (refreshResult.isAvailable()) { // Navigate to device list + _uiSideEffect.emit( LoginUiSideEffect.TooManyDevices(AccountToken(accountToken)) ) - return@launch + Idle() } else { // Failed to fetch devices list Idle(LoginError.Unknown(result.toString())) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PrivacyDisclaimerViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PrivacyDisclaimerViewModel.kt index c3b63bb818e4..5ddb172d7fb7 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PrivacyDisclaimerViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PrivacyDisclaimerViewModel.kt @@ -1,10 +1,25 @@ package net.mullvad.mullvadvpn.viewmodel import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository class PrivacyDisclaimerViewModel( private val privacyDisclaimerRepository: PrivacyDisclaimerRepository ) : ViewModel() { - fun setPrivacyDisclosureAccepted() = privacyDisclaimerRepository.setPrivacyDisclosureAccepted() + private val _uiSideEffect = + MutableSharedFlow(extraBufferCapacity = 1) + val uiSideEffect = _uiSideEffect.asSharedFlow() + + fun setPrivacyDisclosureAccepted() { + privacyDisclaimerRepository.setPrivacyDisclosureAccepted() + viewModelScope.launch { _uiSideEffect.emit(PrivacyDisclaimerUiSideEffect.NavigateToLogin) } + } +} + +sealed interface PrivacyDisclaimerUiSideEffect { + data object NavigateToLogin : PrivacyDisclaimerUiSideEffect } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt new file mode 100644 index 000000000000..70c49ff03a20 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplashViewModel.kt @@ -0,0 +1,63 @@ +package net.mullvad.mullvadvpn.viewmodel + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.model.DeviceState +import net.mullvad.mullvadvpn.repository.DeviceRepository +import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository + +class SplashViewModel( + private val privacyDisclaimerRepository: PrivacyDisclaimerRepository, + private val deviceRepository: DeviceRepository +) : ViewModel() { + + private val _uiSideEffect = + MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + val uiSideEffect = _uiSideEffect.asSharedFlow() + + init { + viewModelScope.launch { + if (privacyDisclaimerRepository.hasAcceptedPrivacyDisclosure()) { + // Start Daemon + _uiSideEffect.emit(SplashUiSideEffect.StartDaemon) + _uiSideEffect.emit(getStartDestination()) + } else { + _uiSideEffect.emit(SplashUiSideEffect.NavigateToPrivacyDisclaimer) + } + } + } + + private suspend fun getStartDestination(): SplashUiSideEffect = + deviceRepository.deviceState + .map { deviceState -> + Log.d("SplashViewModel", "getStartDestination: $deviceState") + when (deviceState) { + is DeviceState.Initial, + is DeviceState.Unknown -> null + is DeviceState.LoggedOut -> SplashUiSideEffect.NavigateToLogin + is DeviceState.Revoked -> SplashUiSideEffect.NavigateToRevoked + is DeviceState.LoggedIn -> SplashUiSideEffect.NavigateToConnect + } + } + .filterNotNull() + .first() +} + +sealed interface SplashUiSideEffect { + data object StartDaemon : SplashUiSideEffect + + data object NavigateToPrivacyDisclaimer : SplashUiSideEffect + + data object NavigateToRevoked : SplashUiSideEffect + + data object NavigateToLogin : SplashUiSideEffect + + data object NavigateToConnect : SplashUiSideEffect +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt index bb543d85cfa4..a513f96db730 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SplitTunnelingViewModel.kt @@ -1,5 +1,6 @@ package net.mullvad.mullvadvpn.viewmodel +import android.graphics.Bitmap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher @@ -17,6 +18,7 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.applist.AppData +import net.mullvad.mullvadvpn.applist.ApplicationsIconManager import net.mullvad.mullvadvpn.applist.ApplicationsProvider import net.mullvad.mullvadvpn.compose.state.SplitTunnelingUiState import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer @@ -28,7 +30,8 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.splitTunneling class SplitTunnelingViewModel( private val appsProvider: ApplicationsProvider, private val serviceConnectionManager: ServiceConnectionManager, - private val dispatcher: CoroutineDispatcher + private val applicationsIconManager: ApplicationsIconManager, + private val dispatcher: CoroutineDispatcher, ) : ViewModel() { private val allApps = MutableStateFlow?>(null) @@ -113,4 +116,6 @@ class SplitTunnelingViewModel( excludedAppsChange = { apps -> trySend(apps) } awaitClose { emptySet() } } + + fun resolveIcon(packageName: String): Bitmap = applicationsIconManager.getAppIcon(packageName) } diff --git a/android/buildSrc/src/main/kotlin/Dependencies.kt b/android/buildSrc/src/main/kotlin/Dependencies.kt index d0748afc0a03..255e9ffd6398 100644 --- a/android/buildSrc/src/main/kotlin/Dependencies.kt +++ b/android/buildSrc/src/main/kotlin/Dependencies.kt @@ -44,6 +44,7 @@ object Dependencies { } object Compose { + const val destinations = "io.github.raamcosta.compose-destinations:core:${Versions.Compose.destinations}" const val constrainLayout = "androidx.constraintlayout:constraintlayout-compose:${Versions.Compose.constrainLayout}" const val foundation = @@ -127,5 +128,6 @@ object Dependencies { const val dependencyCheckId = "org.owasp.dependencycheck" const val gradleVersionsId = "com.github.ben-manes.versions" const val ktfmtId = "com.ncorti.ktfmt.gradle" + const val ksp = "com.google.devtools.ksp" } } diff --git a/android/buildSrc/src/main/kotlin/Versions.kt b/android/buildSrc/src/main/kotlin/Versions.kt index 04d8b6b313bf..6d078958fa9c 100644 --- a/android/buildSrc/src/main/kotlin/Versions.kt +++ b/android/buildSrc/src/main/kotlin/Versions.kt @@ -15,10 +15,10 @@ object Versions { const val turbine = "1.0.0" object Android { - const val compileSdkVersion = 33 + const val compileSdkVersion = 34 const val material = "1.9.0" const val minSdkVersion = 26 - const val targetSdkVersion = 33 + const val targetSdkVersion = 34 const val volley = "1.2.1" } @@ -40,6 +40,7 @@ object Versions { } object Compose { + const val destinations = "1.9.54" const val base = "1.5.1" const val constrainLayout = "1.0.1" const val foundation = base @@ -49,6 +50,7 @@ object Versions { } object Plugin { + // The androidAapt plugin version must be in sync with the android plugin version. // Required for Gradle metadata verification to work properly, see: // https://github.com/gradle/gradle/issues/19228 @@ -58,6 +60,9 @@ object Versions { const val dependencyCheck = "8.3.1" const val gradleVersions = "0.47.0" const val ktfmt = "0.13.0" + // Ksp version is linked with kotlin version, find matching release here: + // https://github.com/google/ksp/releases + const val ksp = "${kotlin}-1.0.13" } object Koin { diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt index eacd2d4bc983..9729d3eb93f5 100644 --- a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt +++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt @@ -1,6 +1,8 @@ package net.mullvad.mullvadvpn.service import android.app.Service +import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED +import android.os.Build import kotlin.properties.Delegates.observable import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -90,10 +92,18 @@ class ForegroundNotificationManager( } fun showOnForeground() { - service.startForeground( - TunnelStateNotification.NOTIFICATION_ID, - tunnelStateNotification.build() - ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + service.startForeground( + TunnelStateNotification.NOTIFICATION_ID, + tunnelStateNotification.build(), + FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED + ) + } else { + service.startForeground( + TunnelStateNotification.NOTIFICATION_ID, + tunnelStateNotification.build() + ) + } onForeground = true }