summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorAlbin <albin@mullvad.net>2022-05-31 16:59:18 +0200
committerAlbin <albin@mullvad.net>2022-06-15 10:25:32 +0200
commita0c4b0dfe624b9b3e1b61e6b8fa43d5651fff799 (patch)
tree2111dab0055015978f236fa7e8fde5fc223e7d33 /android
parentde883f61d39f99b7a6d636d11ef1a36a4a5217a0 (diff)
downloadmullvadvpn-a0c4b0dfe624b9b3e1b61e6b8fa43d5651fff799.tar.xz
mullvadvpn-a0c4b0dfe624b9b3e1b61e6b8fa43d5651fff799.zip
Add Android device revoked view
Diffstat (limited to 'android')
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt113
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceRevokedUiState.kt11
-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/TunnelState.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt23
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/DeviceRevokedFragment.kt55
-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.kt49
-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.xml17
-rw-r--r--android/app/src/main/res/values/strings.xml6
12 files changed, 299 insertions, 26 deletions
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..a559cd0cf9
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt
@@ -0,0 +1,113 @@
+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.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.isSecured) {
+ 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.isSecured) {
+ 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..f8465423ed
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceRevokedUiState.kt
@@ -0,0 +1,11 @@
+package net.mullvad.mullvadvpn.compose.state
+
+data class DeviceRevokedUiState(
+ val isSecured: Boolean
+) {
+ companion object {
+ val DEFAULT = DeviceRevokedUiState(
+ isSecured = false
+ )
+ }
+}
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/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/ui/MainActivity.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt
index 56533d479a..5e00472bc4 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
@@ -33,7 +33,6 @@ import org.koin.android.ext.android.getKoin
import org.koin.core.context.loadKoinModules
open class MainActivity : FragmentActivity() {
- private val deviceRepository: DeviceRepository by inject()
val problemReport = MullvadProblemReport()
private var visibleSecureScreens = HashSet<Fragment>()
@@ -47,10 +46,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
@@ -164,7 +168,7 @@ open class MainActivity : FragmentActivity() {
is DeviceState.Initial,
is DeviceState.Unknown -> openLaunchView()
is DeviceState.LoggedOut -> openLoginView()
- is DeviceState.Revoked -> openLoginView()
+ is DeviceState.Revoked -> openRevokedView()
is DeviceState.LoggedIn -> openConnectView()
}
currentState = newState
@@ -212,6 +216,19 @@ open class MainActivity : FragmentActivity() {
}
}
+ 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..69b7f31111
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/DeviceRevokedFragment.kt
@@ -0,0 +1,55 @@
+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.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.isSecured) {
+ 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/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..9b66b10ae0
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt
@@ -0,0 +1,49 @@
+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.model.TunnelState
+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,
+ private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO)
+) : ViewModel() {
+
+ val uiState = serviceConnectionManager.connectionState
+ .map { connectionState -> connectionState.readyContainer()?.connectionProxy }
+ .flatMapLatest { proxy ->
+ proxy?.onUiStateChange?.callbackFlowFromSubscription(this)
+ ?: flowOf(TunnelState.Disconnected)
+ }
+ .map { DeviceRevokedUiState(it.isSecured()) }
+ .stateIn(
+ scope,
+ SharingStarted.Lazily,
+ DeviceRevokedUiState.DEFAULT
+ )
+
+ 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
index e54d17348c..3417de83cb 100644
--- a/android/app/src/main/res/layout/fragment_compose.xml
+++ b/android/app/src/main/res/layout/fragment_compose.xml
@@ -1,13 +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" />
-
+ 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/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>