diff options
| author | Albin <albin@mullvad.net> | 2022-06-15 10:33:24 +0200 |
|---|---|---|
| committer | Albin <albin@mullvad.net> | 2022-06-15 10:33:24 +0200 |
| commit | 2e84af0e9bfd4efb2ff39248c0af04d6575592ed (patch) | |
| tree | abe3b960b07ec25cce4ac4df051934d51616e87a | |
| parent | d05e8a02403d32203412d1b7c16a5267d9b43055 (diff) | |
| parent | 2d5c5269d814558c9ef511d90a027176fe498057 (diff) | |
| download | mullvadvpn-2e84af0e9bfd4efb2ff39248c0af04d6575592ed.tar.xz mullvadvpn-2e84af0e9bfd4efb2ff39248c0af04d6575592ed.zip | |
Merge branch 'add-android-device-revoked-view'
33 files changed, 956 insertions, 89 deletions
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2e0f62423e..c7baf5b7d0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -83,6 +83,15 @@ android { } } + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerVersion = Versions.kotlin + kotlinCompilerExtensionVersion = Versions.kotlinCompilerExtensionVersion + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 @@ -111,6 +120,10 @@ android { packagingOptions { jniLibs.useLegacyPackaging = true + + // Fixes packaging error caused by: androidx.compose.ui:ui-test-junit4 + pickFirst("META-INF/AL2.0") + pickFirst("META-INF/LGPL2.1") } project.tasks.preBuild.dependsOn("ensureJniDirectoryExist") @@ -164,6 +177,12 @@ dependencies { implementation(Dependencies.AndroidX.lifecycleRuntimeKtx) implementation(Dependencies.AndroidX.lifecycleViewmodelKtx) implementation(Dependencies.AndroidX.recyclerview) + implementation(Dependencies.Compose.constrainLayout) + implementation(Dependencies.Compose.foundation) + implementation(Dependencies.Compose.viewModelLifecycle) + implementation(Dependencies.Compose.material) + implementation(Dependencies.Compose.uiController) + implementation(Dependencies.Compose.ui) implementation(Dependencies.jodaTime) implementation(Dependencies.Koin.core) implementation(Dependencies.Koin.coreExt) @@ -187,8 +206,10 @@ dependencies { // UI test dependencies debugImplementation(Dependencies.AndroidX.fragmentTestning) + debugImplementation(Dependencies.Compose.testManifest) androidTestImplementation(Dependencies.AndroidX.espressoContrib) androidTestImplementation(Dependencies.AndroidX.espressoCore) + androidTestImplementation(Dependencies.Compose.junit) androidTestImplementation(Dependencies.Koin.test) androidTestImplementation(Dependencies.Kotlin.test) androidTestImplementation(Dependencies.MockK.android) diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreenTest.kt new file mode 100644 index 0000000000..13d98455c5 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreenTest.kt @@ -0,0 +1,99 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import io.mockk.MockKAnnotations +import io.mockk.Runs +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import net.mullvad.mullvadvpn.compose.component.AppTheme +import net.mullvad.mullvadvpn.compose.state.DeviceRevokedUiState +import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class DeviceRevokedScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + @MockK + lateinit var mockedViewModel: DeviceRevokedViewModel + + @Before + fun setup() { + MockKAnnotations.init(this) + every { mockedViewModel.onGoToLoginClicked() } just Runs + } + + @Test + fun testUnblockWarningShowingWhenSecured() { + // Arrange + every { + mockedViewModel.uiState + } returns MutableStateFlow(DeviceRevokedUiState.SECURED) + + // Act + composeTestRule.setContent { + AppTheme { + DeviceRevokedScreen(mockedViewModel) + } + } + + // Assert + composeTestRule + .onNodeWithText(UNBLOCK_WARNING) + .assertExists() + } + + @Test + fun testUnblockWarningNotShowingWhenNotSecured() { + // Arrange + every { + mockedViewModel.uiState + } returns MutableStateFlow(DeviceRevokedUiState.UNSECURED) + + // Act + composeTestRule.setContent { + AppTheme { + DeviceRevokedScreen(mockedViewModel) + } + } + + // Assert + composeTestRule + .onNodeWithText(UNBLOCK_WARNING) + .assertDoesNotExist() + } + + @Test + fun testGoToLogin() { + // Arrange + every { + mockedViewModel.uiState + } returns MutableStateFlow(DeviceRevokedUiState.UNSECURED) + composeTestRule.setContent { + AppTheme { + DeviceRevokedScreen(mockedViewModel) + } + } + + // Act + composeTestRule + .onNodeWithText(GO_TO_LOGIN_BUTTON_TEXT) + .performClick() + + // Assert + verify { mockedViewModel.onGoToLoginClicked() } + } + + companion object { + private const val GO_TO_LOGIN_BUTTON_TEXT = "Go to login" + private const val UNBLOCK_WARNING = + "Going to login will unblock the internet on this device." + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Button.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Button.kt new file mode 100644 index 0000000000..fac8aa8a49 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Button.kt @@ -0,0 +1,51 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.mullvad.mullvadvpn.R + +@Composable +fun ActionButton( + text: String, + onClick: () -> Unit, + buttonColor: Color, + isEnabled: Boolean = true +) { + Button( + onClick = onClick, + enabled = isEnabled, + // Required along with defaultMinSize to control size and padding. + contentPadding = PaddingValues(0.dp), + modifier = Modifier + .height(dimensionResource(id = R.dimen.button_height)) + .defaultMinSize( + minWidth = 0.dp, + minHeight = dimensionResource(id = R.dimen.button_height) + ) + .fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + backgroundColor = buttonColor, + contentColor = Color.White + ) + ) { + Text( + text = text, + textAlign = TextAlign.Center, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt new file mode 100644 index 0000000000..67e4a3ec19 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt @@ -0,0 +1,30 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.google.accompanist.systemuicontroller.rememberSystemUiController + +@Composable +fun ScaffoldWithTopBar( + topBarColor: Color, + statusBarColor: Color, + navigationBarColor: Color, + onSettingsClicked: () -> Unit, + content: @Composable (PaddingValues) -> Unit, +) { + val systemUiController = rememberSystemUiController() + systemUiController.setStatusBarColor(statusBarColor) + systemUiController.setNavigationBarColor(navigationBarColor) + + Scaffold( + topBar = { + TopBar( + backgroundColor = topBarColor, + onSettingsClicked = onSettingsClicked + ) + }, + content = content + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Text.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Text.kt new file mode 100644 index 0000000000..cfa999194e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Text.kt @@ -0,0 +1,54 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.TextUnit + +@Composable +fun CapsText( + text: String, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontSize: TextUnit = TextUnit.Unspecified, + fontStyle: androidx.compose.ui.text.font.FontStyle? = null, + fontWeight: FontWeight? = null, + fontFamily: FontFamily? = null, + letterSpacing: TextUnit = TextUnit.Unspecified, + textDecoration: TextDecoration? = null, + textAlign: TextAlign? = null, + lineHeight: TextUnit = TextUnit.Unspecified, + overflow: TextOverflow = TextOverflow.Clip, + softWrap: Boolean = true, + maxLines: Int = Int.MAX_VALUE, + onTextLayout: (TextLayoutResult) -> Unit = {}, + style: TextStyle = LocalTextStyle.current +) { + Text( + text = text.uppercase(), + modifier = modifier, + color = color, + fontSize = fontSize, + fontStyle = fontStyle, + fontWeight = fontWeight, + fontFamily = fontFamily, + letterSpacing = letterSpacing, + textDecoration = textDecoration, + textAlign = textAlign, + lineHeight = lineHeight, + overflow = overflow, + softWrap = softWrap, + maxLines = maxLines, + onTextLayout = onTextLayout, + style = style, + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Theme.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Theme.kt new file mode 100644 index 0000000000..4cb68f9d17 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Theme.kt @@ -0,0 +1,13 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable + +@Composable +fun AppTheme( + content: @Composable () -> Unit +) { + MaterialTheme( + content = content + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt new file mode 100644 index 0000000000..d44a06b19c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt @@ -0,0 +1,79 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.constraintlayout.compose.ConstraintLayout +import net.mullvad.mullvadvpn.R + +@Composable +fun TopBar( + backgroundColor: Color, + onSettingsClicked: () -> Unit, + modifier: Modifier = Modifier +) { + ConstraintLayout( + modifier = Modifier + .fillMaxWidth() + .height(dimensionResource(id = R.dimen.top_bar_height)) + .background(backgroundColor) + .then(modifier), + ) { + val (logo, appName, settingsIcon) = createRefs() + + Image( + painter = painterResource(id = R.drawable.logo_icon), + contentDescription = null, // No meaningful user info or action. + modifier = Modifier + .width(44.dp) + .height(44.dp) + .constrainAs(logo) { + start.linkTo(parent.start, margin = 16.dp) + top.linkTo(parent.top, margin = 12.dp) + bottom.linkTo(parent.bottom, margin = 12.dp) + } + ) + + CapsText( + text = stringResource(id = R.string.app_name), + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + color = colorResource(id = R.color.white80), + modifier = Modifier + .constrainAs(appName) { + start.linkTo(logo.end, margin = 9.dp) + top.linkTo(parent.top, margin = 12.dp) + } + ) + + Icon( + painter = painterResource(R.drawable.icon_settings), + contentDescription = stringResource(id = R.string.settings), + tint = Color.White, + + modifier = Modifier + .clickable { onSettingsClicked() } + .fillMaxHeight() + .padding(horizontal = 16.dp) + .constrainAs(settingsIcon) { + end.linkTo(parent.end) + } + ) + } +} 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 new file mode 100644 index 0000000000..d1ae33d0e5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt @@ -0,0 +1,114 @@ +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.component.ActionButton +import net.mullvad.mullvadvpn.compose.state.DeviceRevokedUiState +import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel + +@Composable +fun DeviceRevokedScreen( + deviceRevokedViewModel: DeviceRevokedViewModel +) { + val state = deviceRevokedViewModel.uiState.collectAsState().value + + ConstraintLayout( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth() + .background(colorResource(id = R.color.darkBlue)) + ) { + val (icon, body, actionButtons) = createRefs() + + Image( + painter = painterResource(id = R.drawable.icon_fail), + contentDescription = null, // No meaningful user info or action. + modifier = Modifier + .constrainAs(icon) { + top.linkTo(parent.top, margin = 30.dp) + start.linkTo(parent.start) + end.linkTo(parent.end) + } + .padding(horizontal = 12.dp) + .width(80.dp) + .height(80.dp) + ) + + Column( + modifier = Modifier + .constrainAs(body) { + top.linkTo(icon.bottom, margin = 22.dp) + start.linkTo(parent.start, margin = 22.dp) + end.linkTo(parent.end, margin = 22.dp) + width = Dimension.fillToConstraints + }, + ) { + Text( + text = stringResource(id = R.string.device_inactive_title), + fontSize = 24.sp, + color = Color.White, + fontWeight = FontWeight.Bold + ) + + Text( + text = stringResource(id = R.string.device_inactive_description), + fontSize = 12.sp, + color = Color.White, + modifier = Modifier.padding(top = 10.dp) + ) + + if (state == DeviceRevokedUiState.SECURED) { + Text( + text = stringResource(id = R.string.device_inactive_unblock_warning), + fontSize = 12.sp, + color = Color.White, + modifier = Modifier.padding(top = 10.dp) + ) + } + } + + Column( + modifier = Modifier + .constrainAs(actionButtons) { + bottom.linkTo(parent.bottom, margin = 22.dp) + start.linkTo(parent.start, margin = 22.dp) + end.linkTo(parent.end, margin = 22.dp) + width = Dimension.fillToConstraints + } + ) { + val buttonColor = colorResource( + if (state == DeviceRevokedUiState.SECURED) { + R.color.red60 + } else { + R.color.blue + } + ) + + ActionButton( + text = stringResource(id = R.string.go_to_login), + onClick = { deviceRevokedViewModel.onGoToLoginClicked() }, + buttonColor = buttonColor + ) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceRevokedUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceRevokedUiState.kt new file mode 100644 index 0000000000..b2223e77e4 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceRevokedUiState.kt @@ -0,0 +1,7 @@ +package net.mullvad.mullvadvpn.compose.state + +enum class DeviceRevokedUiState { + SECURED, + UNSECURED, + UNKNOWN +} 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 7e503e4a33..0910754a8d 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 @@ -9,6 +9,7 @@ import net.mullvad.mullvadvpn.ipc.EventDispatcher import net.mullvad.mullvadvpn.ui.serviceconnection.DeviceRepository import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling +import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel import net.mullvad.mullvadvpn.viewmodel.LoginViewModel import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel import org.koin.android.ext.koin.androidContext @@ -37,7 +38,9 @@ val uiModule = module { single { ServiceConnectionManager(androidContext()) } single { DeviceRepository(get()) } viewModel { LoginViewModel() } + viewModel { DeviceRevokedViewModel(get()) } } + const val APPS_SCOPE = "APPS_SCOPE" const val SERVICE_CONNECTION_SCOPE = "SERVICE_CONNECTION_SCOPE" const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/Device.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/Device.kt index 01a8e6ee37..21341dca54 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/Device.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/Device.kt @@ -9,4 +9,28 @@ data class Device( val name: String, val pubkey: ByteArray, val ports: ArrayList<String> -) : Parcelable +) : Parcelable { + // Generated by Android Studio + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Device + + if (id != other.id) return false + if (name != other.name) return false + if (!pubkey.contentEquals(other.pubkey)) return false + if (ports != other.ports) return false + + return true + } + + // Generated by Android Studio + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + pubkey.contentHashCode() + result = 31 * result + ports.hashCode() + return result + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceState.kt index 567f5f233f..e23f0857d1 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceState.kt @@ -5,10 +5,13 @@ import kotlinx.parcelize.Parcelize sealed class DeviceState : Parcelable { @Parcelize - object InitialState : DeviceState() + object Initial : DeviceState() @Parcelize - class LoggedIn(val accountAndDevice: AccountAndDevice) : DeviceState() + object Unknown : DeviceState() + + @Parcelize + data class LoggedIn(val accountAndDevice: AccountAndDevice) : DeviceState() @Parcelize object LoggedOut : DeviceState() @@ -16,8 +19,8 @@ sealed class DeviceState : Parcelable { @Parcelize object Revoked : DeviceState() - fun isInitialState(): Boolean { - return this is InitialState + fun isUnknown(): Boolean { + return this is Unknown } fun deviceName(): String? { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelState.kt index 918396a263..6edfb2dc24 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelState.kt @@ -31,6 +31,16 @@ sealed class TunnelState() : Parcelable { @Parcelize class Error(val errorState: ErrorState) : TunnelState(), Parcelable + fun isSecured(): Boolean { + return when (this) { + is Connected, + is Connecting, + is Disconnecting, -> true + is Disconnected -> false + is Error -> this.errorState.isBlocking + } + } + companion object { const val DISCONNECTED = "disconnected" const val CONNECTING = "connecting" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt index 551e61961f..380ae0dedf 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt @@ -1,7 +1,7 @@ package net.mullvad.mullvadvpn.service -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import net.mullvad.mullvadvpn.model.AppVersionInfo import net.mullvad.mullvadvpn.model.Device import net.mullvad.mullvadvpn.model.DeviceEvent @@ -30,8 +30,8 @@ class MullvadDaemon(vpnService: MullvadVpnService) { var onRelayListChange: ((RelayList) -> Unit)? = null var onDaemonStopped: (() -> Unit)? = null - private val _deviceStateUpdates = MutableStateFlow<DeviceState>(DeviceState.InitialState) - val deviceStateUpdates = _deviceStateUpdates.asStateFlow() + private val _deviceStateUpdates = MutableSharedFlow<DeviceState>(extraBufferCapacity = 1) + val deviceStateUpdates = _deviceStateUpdates.asSharedFlow() init { System.loadLibrary("mullvad_jni") diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AccountCache.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AccountCache.kt index c1b5b47ded..c79ade7891 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AccountCache.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AccountCache.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.channels.ClosedReceiveChannelException import kotlinx.coroutines.channels.actor import kotlinx.coroutines.channels.sendBlocking import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.lastOrNull import net.mullvad.mullvadvpn.ipc.Event import net.mullvad.mullvadvpn.ipc.Request import net.mullvad.mullvadvpn.model.AccountCreationResult @@ -107,7 +108,7 @@ class AccountCache(private val endpoint: ServiceEndpoint) { } private suspend fun accountToken(): String? { - return daemon.await().deviceStateUpdates.value.token() + return daemon.await().deviceStateUpdates.lastOrNull()?.token() } private fun spawnActor() = GlobalScope.actor<Command>(Dispatchers.Default, Channel.UNLIMITED) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LaunchFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LaunchFragment.kt index 420581785b..4699576169 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LaunchFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LaunchFragment.kt @@ -4,20 +4,11 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.lifecycleScope -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.launch +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.model.DeviceState -import net.mullvad.mullvadvpn.ui.serviceconnection.DeviceRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer -import org.koin.android.ext.android.inject - -class LaunchFragment : ServiceAwareFragment() { - private val deviceRepository: DeviceRepository by inject() +class LaunchFragment : Fragment(), StatusBarPainter, NavigationBarPainter { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -26,51 +17,16 @@ class LaunchFragment : ServiceAwareFragment() { val view = inflater.inflate(R.layout.launch, container, false) view.findViewById<View>(R.id.settings).setOnClickListener { - parentActivity.openSettings() + (context as? MainActivity)?.openSettings() } - return view - } - - override fun onNewServiceConnection(serviceConnectionContainer: ServiceConnectionContainer) { - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - lifecycleScope.launch { - deviceRepository.deviceState - .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) - .collect { deviceState -> - when (deviceState) { - is DeviceState.LoggedIn -> advanceToConnectScreen() - is DeviceState.LoggedOut -> advanceToLoginScreen() - is DeviceState.Revoked -> advanceToRevokedScreen() - else -> Unit - } - } - } - } + context + ?.let { ContextCompat.getColor(it, R.color.blue) } + ?.let { color -> + paintStatusBar(color) + paintNavigationBar(color) + } - private fun advanceToLoginScreen() { - parentFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, LoginFragment()) - commit() - } - } - - private fun advanceToConnectScreen() { - parentFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, ConnectFragment()) - commit() - } - } - - private fun advanceToRevokedScreen() { - // TODO: Open revoked screen. - parentFragmentManager.beginTransaction().apply { - replace(R.id.main_fragment, LoginFragment()) - commit() - } + return view } } 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 cb39336651..204635a161 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 @@ -12,10 +12,19 @@ import android.view.WindowManager import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.BuildConfig import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport import net.mullvad.mullvadvpn.di.uiModule +import net.mullvad.mullvadvpn.model.DeviceState +import net.mullvad.mullvadvpn.ui.fragments.DeviceRevokedFragment +import net.mullvad.mullvadvpn.ui.serviceconnection.DeviceRepository import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import org.koin.android.ext.android.getKoin import org.koin.core.context.loadKoinModules @@ -34,10 +43,15 @@ open class MainActivity : FragmentActivity() { var backButtonHandler: (() -> Boolean)? = null private lateinit var serviceConnectionManager: ServiceConnectionManager + private lateinit var deviceRepository: DeviceRepository override fun onCreate(savedInstanceState: Bundle?) { loadKoinModules(uiModule) - serviceConnectionManager = getKoin().get() + + getKoin().apply { + serviceConnectionManager = get() + deviceRepository = get() + } requestedOrientation = if (deviceIsTv) { ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE @@ -54,9 +68,7 @@ open class MainActivity : FragmentActivity() { setContentView(R.layout.main) - if (savedInstanceState == null) { - addInitialFragment() - } + launchDeviceStateHandler() } override fun onStart() { @@ -136,6 +148,39 @@ open class MainActivity : FragmentActivity() { } } + private fun launchDeviceStateHandler() { + var currentState: DeviceState? = null + + lifecycleScope.launch { + deviceRepository.deviceState + .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) + .debounce { + // Debounce DeviceState.Unknown to delay view transitions during reconnect. + it.addDebounceForUnknownState() + } + .collect { newState -> + if (newState != currentState) { + when (newState) { + is DeviceState.Initial, + is DeviceState.Unknown -> openLaunchView() + is DeviceState.LoggedOut -> openLoginView() + is DeviceState.Revoked -> openRevokedView() + is DeviceState.LoggedIn -> openConnectView() + } + currentState = newState + } + } + } + } + + private fun DeviceState.addDebounceForUnknownState(): Long { + return if (this is DeviceState.Unknown) { + UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS + } else { + ZERO_DEBOUNCE_DELAY_MILLISECONDS + } + } + @Suppress("DEPRECATION") private fun requestVpnPermission() { val intent = VpnService.prepare(this) @@ -143,10 +188,42 @@ open class MainActivity : FragmentActivity() { startActivityForResult(intent, 0) } - private fun addInitialFragment() { + private fun openLaunchView() { + supportFragmentManager.beginTransaction().apply { + replace(R.id.main_fragment, LaunchFragment()) + commit() + } + } + + private fun openConnectView() { + supportFragmentManager.beginTransaction().apply { + replace(R.id.main_fragment, ConnectFragment()) + commit() + } + } + + private fun openLoginView() { supportFragmentManager.beginTransaction().apply { - add(R.id.main_fragment, LaunchFragment()) + replace(R.id.main_fragment, LoginFragment()) commit() } } + + private fun openRevokedView() { + 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()) + commit() + } + } + + companion object { + private const val ZERO_DEBOUNCE_DELAY_MILLISECONDS = 0L + private const val UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS = 2000L + } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/DeviceRevokedFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/DeviceRevokedFragment.kt new file mode 100644 index 0000000000..3b614be03c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/DeviceRevokedFragment.kt @@ -0,0 +1,56 @@ +package net.mullvad.mullvadvpn.ui.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.colorResource +import androidx.fragment.app.Fragment +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.component.AppTheme +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar +import net.mullvad.mullvadvpn.compose.screen.DeviceRevokedScreen +import net.mullvad.mullvadvpn.compose.state.DeviceRevokedUiState +import net.mullvad.mullvadvpn.ui.MainActivity +import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel + +class DeviceRevokedFragment : Fragment() { + private val deviceRevokedViewModel: DeviceRevokedViewModel by viewModel() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_compose, container, false).apply { + findViewById<ComposeView>(R.id.compose_view).setContent { + AppTheme { + val state = deviceRevokedViewModel.uiState.collectAsState().value + + val topColor = colorResource( + if (state == DeviceRevokedUiState.SECURED) { + R.color.green + } else { + R.color.red + } + ) + + ScaffoldWithTopBar( + topBarColor = topColor, + statusBarColor = topColor, + navigationBarColor = colorResource(id = R.color.darkBlue), + onSettingsClicked = this@DeviceRevokedFragment::openSettingsView, + content = { DeviceRevokedScreen(deviceRevokedViewModel) } + ) + } + } + } + } + + private fun openSettingsView() { + (context as? MainActivity)?.openSettings() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/DeviceRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/DeviceRepository.kt index f2a6fe7ff1..9975b48ef9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/DeviceRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/DeviceRepository.kt @@ -1,16 +1,18 @@ package net.mullvad.mullvadvpn.ui.serviceconnection +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import net.mullvad.mullvadvpn.model.DeviceState class DeviceRepository( - private val serviceConnectionManager: ServiceConnectionManager + private val serviceConnectionManager: ServiceConnectionManager, + dispatcher: CoroutineDispatcher = Dispatchers.IO ) { val deviceState = serviceConnectionManager.connectionState .flatMapLatest { state -> @@ -20,10 +22,10 @@ class DeviceRepository( state.container.deviceDataSource.refreshDevice() } } else { - emptyFlow() + flowOf(DeviceState.Unknown) } } - .stateIn(CoroutineScope(Dispatchers.IO), SharingStarted.Lazily, DeviceState.InitialState) + .stateIn(CoroutineScope(dispatcher), SharingStarted.Lazily, DeviceState.Initial) fun refreshDeviceState() { container()?.deviceDataSource?.refreshDevice() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionDeviceDataSource.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionDeviceDataSource.kt index 60a2eb68e9..23892257d6 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionDeviceDataSource.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionDeviceDataSource.kt @@ -12,9 +12,10 @@ class ServiceConnectionDeviceDataSource( private val dispatcher: EventDispatcher ) { val deviceStateUpdates = callbackFlow { - dispatcher.registerHandler(Event.DeviceStateEvent::class) { event -> + val handler: (Event.DeviceStateEvent) -> Unit = { event -> trySend(event.newState) } + dispatcher.registerHandler(Event.DeviceStateEvent::class, handler) awaitClose { // The current dispatcher doesn't support unregistration of handlers. } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/HeaderBar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/HeaderBar.kt index 877fcd9c66..052739c826 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/HeaderBar.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/HeaderBar.kt @@ -27,19 +27,12 @@ class HeaderBar @JvmOverloads constructor( private val unsecuredColor = ContextCompat.getColor(context, R.color.red) var tunnelState by observable<TunnelState?>(null) { _, _, state -> - val backgroundColor = when (state) { - null -> disabledColor - is TunnelState.Disconnected -> unsecuredColor - is TunnelState.Connecting -> securedColor - is TunnelState.Connected -> securedColor - is TunnelState.Disconnecting -> securedColor - is TunnelState.Error -> { - if (state.errorState.isBlocking) { - securedColor - } else { - unsecuredColor - } - } + val backgroundColor = if (state == null) { + disabledColor + } else if (state.isSecured()) { + securedColor + } else { + unsecuredColor } container.setBackgroundColor(backgroundColor) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt new file mode 100644 index 0000000000..d3975fbd08 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt @@ -0,0 +1,55 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.compose.state.DeviceRevokedUiState +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.talpid.util.callbackFlowFromSubscription + +// TODO: Refactor AccountCache and ConnectionProxy and inject those rather than injecting +// ServiceConnectionManager here. +class DeviceRevokedViewModel( + private val serviceConnectionManager: ServiceConnectionManager, + scope: CoroutineScope = CoroutineScope(Dispatchers.IO) +) : ViewModel() { + + val uiState = serviceConnectionManager.connectionState + .map { connectionState -> connectionState.readyContainer()?.connectionProxy } + .flatMapLatest { proxy -> + proxy?.onUiStateChange + ?.callbackFlowFromSubscription(this) + ?.map { + if (it.isSecured()) { + DeviceRevokedUiState.SECURED + } else { + DeviceRevokedUiState.UNSECURED + } + } + ?: flowOf(DeviceRevokedUiState.UNKNOWN) + } + .stateIn( + scope, + SharingStarted.Lazily, + DeviceRevokedUiState.UNKNOWN + ) + + fun onGoToLoginClicked() { + serviceContainer()?.let { container -> + if (container.connectionProxy.state.isSecured()) { + container.connectionProxy.disconnect() + } + container.accountCache.logout() + } + } + + private fun serviceContainer(): ServiceConnectionContainer? { + return serviceConnectionManager.connectionState.value.readyContainer() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/talpid/util/EventNotifierExtensions.kt b/android/app/src/main/kotlin/net/mullvad/talpid/util/EventNotifierExtensions.kt new file mode 100644 index 0000000000..454cae6133 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/talpid/util/EventNotifierExtensions.kt @@ -0,0 +1,13 @@ +package net.mullvad.talpid.util + +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow + +fun <T> EventNotifier<T>.callbackFlowFromSubscription(id: Any) = callbackFlow { + this@callbackFlowFromSubscription.subscribe(id) { + this.trySend(it) + } + awaitClose { + this@callbackFlowFromSubscription.unsubscribe(id) + } +} diff --git a/android/app/src/main/res/anim/fragment_exit_to_left.xml b/android/app/src/main/res/anim/fragment_exit_to_left.xml new file mode 100644 index 0000000000..9ffa2c9877 --- /dev/null +++ b/android/app/src/main/res/anim/fragment_exit_to_left.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="utf-8"?> +<set xmlns:android="http://schemas.android.com/apk/res/android"> + <translate android:fromXDelta="0%p" + android:toXDelta="-100%p" + android:duration="@integer/transition_animation_duration" /> +</set> diff --git a/android/app/src/main/res/layout/fragment_compose.xml b/android/app/src/main/res/layout/fragment_compose.xml new file mode 100644 index 0000000000..3417de83cb --- /dev/null +++ b/android/app/src/main/res/layout/fragment_compose.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:context=".DeviceInactiveFragment"> + <androidx.compose.ui.platform.ComposeView android:id="@+id/compose_view" + android:layout_width="match_parent" + android:layout_height="match_parent" /> +</FrameLayout> diff --git a/android/app/src/main/res/values/dimensions.xml b/android/app/src/main/res/values/dimensions.xml index c8e8b2ff33..e3f619db33 100644 --- a/android/app/src/main/res/values/dimensions.xml +++ b/android/app/src/main/res/values/dimensions.xml @@ -53,4 +53,5 @@ <dimen name="switch_thumb_padding">8dp</dimen> <dimen name="switch_track_radius">16dp</dimen> <dimen name="switch_track_stroke">2dp</dimen> + <dimen name="top_bar_height">64dp</dimen> </resources> diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 6c08df83ee..bcdee0d5c8 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -174,4 +174,10 @@ <string name="copied_to_clipboard">Copied to clipboard</string> <string name="show_system_apps">Show system apps</string> <string name="toggle_vpn">Toggle VPN</string> + <string name="go_to_login">Go to login</string> + <string name="device_inactive_title">Device is inactive</string> + <string name="device_inactive_description">You have removed this device. To connect again, you + will need to log back in.</string> + <string name="device_inactive_unblock_warning">Going to login will unblock the internet on this + device.</string> </resources> diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModelTest.kt new file mode 100644 index 0000000000..69941a474d --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModelTest.kt @@ -0,0 +1,141 @@ +package net.mullvad.mullvadvpn.viewmodel + +import app.cash.turbine.test +import io.mockk.MockKAnnotations +import io.mockk.Runs +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import io.mockk.verifyOrder +import junit.framework.Assert.assertEquals +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.runBlockingTest +import net.mullvad.mullvadvpn.compose.state.DeviceRevokedUiState +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.ui.serviceconnection.ConnectionProxy +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState +import net.mullvad.talpid.util.EventNotifier +import net.mullvad.talpid.util.callbackFlowFromSubscription +import org.junit.After +import org.junit.Before +import org.junit.Test + +class DeviceRevokedViewModelTest { + + @MockK + private lateinit var mockedServiceConnectionManager: ServiceConnectionManager + + private val serviceConnectionState = + MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected) + + private lateinit var viewModel: DeviceRevokedViewModel + + @Before + fun setup() { + MockKAnnotations.init(this) + mockkStatic(EVENT_NOTIFIER_EXTENSION_CLASS) + every { mockedServiceConnectionManager.connectionState } returns serviceConnectionState + viewModel = DeviceRevokedViewModel( + mockedServiceConnectionManager, + CoroutineScope(TestCoroutineDispatcher()) + ) + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun testUiStateWhenServiceNotConnected() = runBlockingTest { + // Arrange, Act, Assert + viewModel.uiState.test { + serviceConnectionState.value = ServiceConnectionState.Disconnected + assertEquals(DeviceRevokedUiState.UNKNOWN, awaitItem()) + } + } + + @Test + fun testUiStateWhenServiceConnectedButNotReady() = runBlockingTest { + // Arrange, Act, Assert + viewModel.uiState.test { + serviceConnectionState.value = ServiceConnectionState.ConnectedNotReady(mockk()) + assertEquals(DeviceRevokedUiState.UNKNOWN, awaitItem()) + } + } + + @Test + fun testUiStateWhenServiceConnectedAndReady() = runBlockingTest { + // Arrange + val mockedContainer = mockk<ServiceConnectionContainer>().apply { + val eventNotifierMock = mockk<EventNotifier<TunnelState>>().apply { + every { callbackFlowFromSubscription(any()) } returns MutableStateFlow( + TunnelState.Connected(mockk(), mockk()) + ) + } + val mockedConnectionProxy = mockk<ConnectionProxy>().apply { + every { onUiStateChange } returns eventNotifierMock + } + every { connectionProxy } returns mockedConnectionProxy + } + + // Act, Assert + viewModel.uiState.test { + assertEquals(DeviceRevokedUiState.UNKNOWN, awaitItem()) + serviceConnectionState.value = ServiceConnectionState.ConnectedReady(mockedContainer) + assertEquals(DeviceRevokedUiState.SECURED, awaitItem()) + } + } + + @Test + fun testGoToLoginWhenDisconnected() { + // Arrange + val mockedContainer = mockk<ServiceConnectionContainer>().also { + every { it.connectionProxy.state } returns TunnelState.Disconnected + every { it.connectionProxy.disconnect() } just Runs + every { it.accountCache.logout() } just Runs + } + serviceConnectionState.value = ServiceConnectionState.ConnectedReady(mockedContainer) + + // Act + viewModel.onGoToLoginClicked() + + // Assert + verify { + mockedContainer.accountCache.logout() + } + } + + @Test + fun testGoToLoginWhenConnected() { + // Arrange + val mockedContainer = mockk<ServiceConnectionContainer>().also { + every { it.connectionProxy.state } returns TunnelState.Connected(mockk(), mockk()) + every { it.connectionProxy.disconnect() } just Runs + every { it.accountCache.logout() } just Runs + } + serviceConnectionState.value = ServiceConnectionState.ConnectedReady(mockedContainer) + + // Act + viewModel.onGoToLoginClicked() + + // Assert + verifyOrder { + mockedContainer.connectionProxy.disconnect() + mockedContainer.accountCache.logout() + } + } + + companion object { + private const val EVENT_NOTIFIER_EXTENSION_CLASS = + "net.mullvad.talpid.util.EventNotifierExtensionsKt" + } +} diff --git a/android/buildSrc/src/main/kotlin/Dependencies.kt b/android/buildSrc/src/main/kotlin/Dependencies.kt index 6d019cadf9..38d5b8ddbd 100644 --- a/android/buildSrc/src/main/kotlin/Dependencies.kt +++ b/android/buildSrc/src/main/kotlin/Dependencies.kt @@ -39,6 +39,20 @@ object Dependencies { "androidx.test:orchestrator:${Versions.AndroidX.test}" } + object Compose { + const val constrainLayout = + "androidx.constraintlayout:constraintlayout-compose:${Versions.Compose.constrainLayout}" + const val foundation = "androidx.compose.foundation:foundation:${Versions.Compose.base}" + const val junit = "androidx.compose.ui:ui-test-junit4:${Versions.Compose.base}" + const val viewModelLifecycle = + "androidx.lifecycle:lifecycle-viewmodel-compose:${Versions.Compose.viewModelLifecycle}" + const val material = "androidx.compose.material:material:${Versions.Compose.base}" + const val testManifest = "androidx.compose.ui:ui-test-manifest:${Versions.Compose.base}" + const val uiController = + "com.google.accompanist:accompanist-systemuicontroller:${Versions.Compose.uiController}" + const val ui = "androidx.compose.ui:ui:${Versions.Compose.base}" + } + object Koin { const val core = "io.insert-koin:koin-core:${Versions.koin}" const val coreExt = "io.insert-koin:koin-core-ext:${Versions.koin}" diff --git a/android/buildSrc/src/main/kotlin/Versions.kt b/android/buildSrc/src/main/kotlin/Versions.kt index 4101a4f572..fcdf7573e7 100644 --- a/android/buildSrc/src/main/kotlin/Versions.kt +++ b/android/buildSrc/src/main/kotlin/Versions.kt @@ -5,6 +5,7 @@ object Versions { const val jvmTarget = "1.8" const val koin = "2.2.3" const val kotlin = "1.5.31" + const val kotlinCompilerExtensionVersion = "1.0.5" const val kotlinx = "1.5.2" const val leakCanary = "2.8.1" const val mockk = "1.12.3" @@ -32,6 +33,13 @@ object Versions { const val uiautomator = "2.2.0" } + object Compose { + const val base = "1.1.1" + const val viewModelLifecycle = "2.4.1" + const val uiController = "0.23.1" + const val constrainLayout = "1.0.1" + } + object Plugin { const val android = "4.2.2" const val playPublisher = "2.7.5" diff --git a/android/config/dependency-check-suppression.xml b/android/config/dependency-check-suppression.xml index 31c3e293ad..2efc7cff12 100644 --- a/android/config/dependency-check-suppression.xml +++ b/android/config/dependency-check-suppression.xml @@ -6,4 +6,12 @@ ]]></notes> <cve>CVE-2022-24329</cve> </suppress> + <suppress> + <notes><![CDATA[ + This CVE is a false positive as javalite isn't affected according to: + https://cloud.google.com/support/bulletins#gcp-2022-001 + ]]></notes> + <packageUrl regex="true">^pkg:maven/com\.google\.protobuf/protobuf\-javalite@.*$</packageUrl> + <cve>CVE-2021-22569</cve> + </suppress> </suppressions> diff --git a/android/e2e/e2e-suppression.xml b/android/e2e/e2e-suppression.xml index a3be14e7b4..42ee64cfcb 100644 --- a/android/e2e/e2e-suppression.xml +++ b/android/e2e/e2e-suppression.xml @@ -13,4 +13,12 @@ <packageUrl regex="true">^pkg:maven/androidx\.test\.services/storage@.*$</packageUrl> <cve>CVE-2021-20291</cve> </suppress> + <suppress> + <notes><![CDATA[ + This CVE is a false positive as javalite isn't affected according to: + https://cloud.google.com/support/bulletins#gcp-2022-001 + ]]></notes> + <packageUrl regex="true">^pkg:maven/com\.google\.protobuf/protobuf\-javalite@.*$</packageUrl> + <cve>CVE-2021-22569</cve> + </suppress> </suppressions> diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index 30997c9290..ef26be8473 100644 --- a/gui/locales/messages.pot +++ b/gui/locales/messages.pot @@ -1556,6 +1556,9 @@ msgstr "" msgid "Generate key" msgstr "" +msgid "Going to login will unblock the internet on this device." +msgstr "" + msgid "If needed we will contact you on %s" msgstr "" |
