summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--android/app/build.gradle.kts21
-rw-r--r--android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreenTest.kt99
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Button.kt51
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt30
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Text.kt54
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Theme.kt13
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt79
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt114
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceRevokedUiState.kt7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/Device.kt26
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceState.kt11
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/TunnelState.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AccountCache.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LaunchFragment.kt66
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt89
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/DeviceRevokedFragment.kt56
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/DeviceRepository.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionDeviceDataSource.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/HeaderBar.kt19
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt55
-rw-r--r--android/app/src/main/kotlin/net/mullvad/talpid/util/EventNotifierExtensions.kt13
-rw-r--r--android/app/src/main/res/anim/fragment_exit_to_left.xml6
-rw-r--r--android/app/src/main/res/layout/fragment_compose.xml10
-rw-r--r--android/app/src/main/res/values/dimensions.xml1
-rw-r--r--android/app/src/main/res/values/strings.xml6
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModelTest.kt141
-rw-r--r--android/buildSrc/src/main/kotlin/Dependencies.kt14
-rw-r--r--android/buildSrc/src/main/kotlin/Versions.kt8
-rw-r--r--android/config/dependency-check-suppression.xml8
-rw-r--r--android/e2e/e2e-suppression.xml8
-rw-r--r--gui/locales/messages.pot3
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 ""