summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Button.kt13
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Dialog.kt125
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/HtmlText.kt27
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/List.kt106
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ReadOnlyComposables.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt176
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt20
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceListUiState.kt19
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Event.kt4
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Request.kt12
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountHistory.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/Device.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceListEvent.kt16
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DevicePort.kt7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt23
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt19
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt23
-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/service/endpoint/DaemonDeviceDataSource.kt26
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SettingsListener.kt7
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/AccountExpiryNotification.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt55
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginFragment.kt61
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/MainActivity.kt10
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt19
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/DeviceListFragment.kt76
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/FragmentArgumentConstant.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/DeviceRepository.kt60
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/ServiceConnectionDeviceDataSource.kt22
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SettingsListener.kt13
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountInput.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountLogin.kt2
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/GenericExtensions.kt5
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt50
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt9
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt116
-rw-r--r--android/app/src/main/res/values/colors.xml1
-rw-r--r--android/app/src/main/res/values/strings.xml15
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModelTest.kt3
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt52
-rw-r--r--gui/locales/messages.pot9
-rw-r--r--mullvad-jni/src/classes.rs1
44 files changed, 1043 insertions, 193 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 65ec756e38..bb4771ece1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,6 +33,10 @@ Line wrap the file at 100 chars. Th
#### Windows
- Add CLI command for listing excluded processes.
+#### Android
+- Add device management to the Android app. This simplifies knowing which device is which and adds
+ the option to log other devices out when the account already has five devices.
+
### Changed
- Display consistent colors regardless of monitor color profile.
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
index fac8aa8a49..dc2068c5e5 100644
--- 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
@@ -5,11 +5,10 @@ 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.ButtonColors
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
@@ -21,7 +20,8 @@ import net.mullvad.mullvadvpn.R
fun ActionButton(
text: String,
onClick: () -> Unit,
- buttonColor: Color,
+ colors: ButtonColors,
+ modifier: Modifier = Modifier,
isEnabled: Boolean = true
) {
Button(
@@ -29,17 +29,14 @@ fun ActionButton(
enabled = isEnabled,
// Required along with defaultMinSize to control size and padding.
contentPadding = PaddingValues(0.dp),
- modifier = Modifier
+ 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
- )
+ colors = colors
) {
Text(
text = text,
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Dialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Dialog.kt
new file mode 100644
index 0000000000..fdadbd5b29
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Dialog.kt
@@ -0,0 +1,125 @@
+package net.mullvad.mullvadvpn.compose.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+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.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.AlertDialog
+import androidx.compose.material.Button
+import androidx.compose.material.ButtonDefaults
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusOrder
+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.unit.dp
+import androidx.compose.ui.unit.sp
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.model.Device
+import net.mullvad.mullvadvpn.util.capitalizeFirstCharOfEachWord
+import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel
+
+@Composable
+fun ShowDeviceRemovalDialog(viewModel: DeviceListViewModel, device: Device) {
+ AlertDialog(
+ onDismissRequest = {
+ viewModel.clearStagedDevice()
+ },
+ title = {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .padding(top = 0.dp)
+ .fillMaxWidth()
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.icon_alert),
+ contentDescription = "Remove",
+ modifier = Modifier
+ .width(50.dp)
+ .height(50.dp)
+ )
+ }
+ },
+ text = {
+ val htmlFormattedDialogText = textResource(
+ id = R.string.max_devices_confirm_removal_description,
+ device.name.capitalizeFirstCharOfEachWord()
+ ).let { introText ->
+ if (device.ports.isNotEmpty()) {
+ introText.plus(" " + stringResource(id = R.string.port_removal_notice))
+ } else {
+ introText
+ }
+ }
+
+ HtmlText(
+ htmlFormattedString = htmlFormattedDialogText,
+ textSize = 16.sp.value
+ )
+ },
+ buttons = {
+ Column(
+ Modifier
+ .padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
+ ) {
+ Button(
+ 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 = colorResource(id = R.color.red),
+ contentColor = Color.White
+ ),
+ onClick = {
+ viewModel.confirmRemoval()
+ }
+ ) {
+ Text(
+ text = stringResource(id = R.string.confirm_removal),
+ fontSize = 18.sp
+ )
+ }
+ Button(
+ contentPadding = PaddingValues(0.dp),
+ modifier = Modifier
+ .focusOrder(FocusRequester())
+ .padding(top = 16.dp)
+ .height(dimensionResource(id = R.dimen.button_height))
+ .defaultMinSize(
+ minWidth = 0.dp,
+ minHeight = dimensionResource(id = R.dimen.button_height)
+ )
+ .fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(
+ backgroundColor = colorResource(id = R.color.blue),
+ contentColor = Color.White
+ ),
+ onClick = {
+ viewModel.clearStagedDevice()
+ }
+ ) {
+ Text(
+ text = stringResource(id = R.string.back),
+ fontSize = 18.sp
+ )
+ }
+ }
+ },
+ backgroundColor = colorResource(id = R.color.darkBlue)
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/HtmlText.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/HtmlText.kt
new file mode 100644
index 0000000000..545d724228
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/HtmlText.kt
@@ -0,0 +1,27 @@
+package net.mullvad.mullvadvpn.compose.component
+
+import android.util.TypedValue
+import android.widget.TextView
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.text.HtmlCompat
+
+@Composable
+fun HtmlText(
+ htmlFormattedString: String,
+ textSize: Float,
+ modifier: Modifier = Modifier
+) {
+ AndroidView(
+ modifier = modifier,
+ factory = { context ->
+ TextView(context).apply {
+ setTextSize(TypedValue.COMPLEX_UNIT_SP, textSize)
+ }
+ },
+ update = {
+ it.text = HtmlCompat.fromHtml(htmlFormattedString, HtmlCompat.FROM_HTML_MODE_COMPACT)
+ }
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/List.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/List.kt
new file mode 100644
index 0000000000..887237374b
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/List.kt
@@ -0,0 +1,106 @@
+package net.mullvad.mullvadvpn.compose.component
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.model.Device
+
+@Composable
+fun DeviceList(
+ devices: List<Device>,
+ onItemClicked: (Device) -> Unit
+) {
+ Column(
+ modifier = Modifier.verticalScroll(ScrollState(0))
+ ) {
+ devices.forEach { device ->
+ DeviceRow(device.name) {
+ onItemClicked(device)
+ }
+ }
+ }
+}
+
+@Composable
+fun DeviceRow(
+ name: String,
+ painter: Painter? = null,
+ onItemClicked: () -> Unit
+) {
+ val itemColor = colorResource(id = R.color.blue)
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 1.dp)
+ .height(50.dp)
+ .background(itemColor)
+ .clickable {
+ onItemClicked()
+ },
+ ) {
+ Text(
+ text = name,
+ fontSize = 18.sp,
+ color = Color.White,
+ modifier = Modifier
+ .padding(
+ horizontal = 16.dp
+ )
+ .align(Alignment.CenterStart)
+ )
+
+ if (painter != null) {
+ Image(
+ painter = painter,
+ contentDescription = "Remove",
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .padding(horizontal = 12.dp)
+ )
+ }
+ }
+}
+
+@Composable
+fun <T> ItemList(
+ items: List<T>,
+ itemText: (T) -> String,
+ onItemClicked: (T) -> Unit,
+ itemPainter: Painter? = null,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier
+ .then(
+ Modifier
+ .verticalScroll(
+ rememberScrollState()
+ )
+ )
+ ) {
+ items.forEach { item ->
+ DeviceRow(itemText.invoke(item), itemPainter) {
+ onItemClicked(item)
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ReadOnlyComposables.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ReadOnlyComposables.kt
new file mode 100644
index 0000000000..8fe7d44c75
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ReadOnlyComposables.kt
@@ -0,0 +1,12 @@
+package net.mullvad.mullvadvpn.compose.component
+
+import androidx.annotation.StringRes
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.platform.LocalContext
+
+@Composable
+@ReadOnlyComposable
+fun textResource(@StringRes id: Int, vararg formatArgs: Any): String {
+ return LocalContext.current.resources.getString(id, *formatArgs)
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt
new file mode 100644
index 0000000000..b9dcae7e8e
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt
@@ -0,0 +1,176 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+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.foundation.layout.wrapContentHeight
+import androidx.compose.material.ButtonDefaults
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.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.component.ItemList
+import net.mullvad.mullvadvpn.compose.component.ShowDeviceRemovalDialog
+import net.mullvad.mullvadvpn.util.capitalizeFirstCharOfEachWord
+import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel
+
+@Composable
+fun DeviceListScreen(
+ viewModel: DeviceListViewModel,
+ onBackClick: () -> Unit,
+ onContinueWithLogin: () -> Unit
+) {
+ val state = viewModel.uiState.collectAsState().value
+
+ if (state.deviceStagedForRemoval != null) {
+ ShowDeviceRemovalDialog(
+ viewModel = viewModel,
+ device = state.deviceStagedForRemoval
+ )
+ }
+
+ ConstraintLayout(
+ modifier = Modifier
+ .fillMaxHeight()
+ .fillMaxWidth()
+ .background(colorResource(id = R.color.darkBlue))
+ ) {
+ val (icon, message, list, actionButtons) = createRefs()
+
+ Image(
+ painter = painterResource(
+ id = if (state.hasTooManyDevices) {
+ R.drawable.icon_fail
+ } else {
+ R.drawable.icon_success
+ }
+ ),
+ 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)
+ }
+ .width(64.dp)
+ .height(64.dp)
+ )
+
+ Column(
+ modifier = Modifier
+ .constrainAs(message) {
+ top.linkTo(icon.bottom, margin = 16.dp)
+ start.linkTo(parent.start, margin = 22.dp)
+ end.linkTo(parent.end, margin = 22.dp)
+ width = Dimension.fillToConstraints
+ },
+ ) {
+ Text(
+ text = stringResource(
+ id = if (state.hasTooManyDevices) {
+ R.string.max_devices_warning_title
+ } else {
+ R.string.max_devices_resolved_title
+ }
+ ),
+ fontSize = 24.sp,
+ color = Color.White,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ text = stringResource(
+ id = if (state.hasTooManyDevices) {
+ R.string.max_devices_warning_description
+ } else {
+ R.string.max_devices_resolved_description
+ }
+ ),
+ color = Color.White,
+ fontSize = 14.sp,
+ modifier = Modifier
+ .wrapContentHeight()
+ .animateContentSize()
+ .padding(top = 8.dp)
+ )
+ }
+
+ Box(
+ modifier = Modifier
+ .constrainAs(list) {
+ top.linkTo(message.bottom, margin = 20.dp)
+ bottom.linkTo(actionButtons.top, margin = 5.dp)
+ height = Dimension.fillToConstraints
+ width = Dimension.matchParent
+ }
+ ) {
+ if (state.isLoading) {
+ CircularProgressIndicator(
+ color = Color.White,
+ strokeWidth = 8.dp,
+ modifier = Modifier.align(Alignment.Center)
+ )
+ } else {
+ ItemList(
+ state.devices,
+ itemText = { it.name.capitalizeFirstCharOfEachWord() },
+ onItemClicked = {
+ viewModel.stageDeviceForRemoval(it)
+ },
+ itemPainter = painterResource(id = R.drawable.icon_close)
+ )
+ }
+ }
+
+ 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
+ }
+ ) {
+ ActionButton(
+ text = stringResource(id = R.string.continue_login),
+ onClick = onContinueWithLogin,
+ isEnabled = state.hasTooManyDevices.not() && state.isLoading.not(),
+ colors = ButtonDefaults.buttonColors(
+ backgroundColor = colorResource(id = R.color.green),
+ disabledBackgroundColor = colorResource(id = R.color.green40),
+ disabledContentColor = colorResource(id = R.color.white80),
+ contentColor = Color.White
+ )
+ )
+ ActionButton(
+ text = stringResource(id = R.string.back),
+ onClick = onBackClick,
+ colors = ButtonDefaults.buttonColors(
+ backgroundColor = colorResource(id = R.color.blue),
+ contentColor = Color.White
+ ),
+ modifier = Modifier
+ .padding(top = 16.dp)
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt
index d1ae33d0e5..ce92ef72aa 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceRevokedScreen.kt
@@ -8,6 +8,7 @@ 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.ButtonDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -96,18 +97,19 @@ fun DeviceRevokedScreen(
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
+ colors = ButtonDefaults.buttonColors(
+ contentColor = Color.White,
+ backgroundColor = colorResource(
+ if (state == DeviceRevokedUiState.SECURED) {
+ R.color.red60
+ } else {
+ R.color.blue
+ }
+ )
+ )
)
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceListUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceListUiState.kt
new file mode 100644
index 0000000000..9e048c2926
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/DeviceListUiState.kt
@@ -0,0 +1,19 @@
+package net.mullvad.mullvadvpn.compose.state
+
+import net.mullvad.mullvadvpn.model.Device
+
+data class DeviceListUiState(
+ val devices: List<Device>,
+ val isLoading: Boolean,
+ val deviceStagedForRemoval: Device?
+) {
+ val hasTooManyDevices = devices.count() >= 5
+
+ companion object {
+ val INITIAL = DeviceListUiState(
+ devices = listOf(),
+ isLoading = true,
+ deviceStagedForRemoval = null
+ )
+ }
+}
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 0910754a8d..4f29198652 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.DeviceListViewModel
import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel
import net.mullvad.mullvadvpn.viewmodel.LoginViewModel
import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel
@@ -37,8 +38,9 @@ val uiModule = module {
single { ServiceConnectionManager(androidContext()) }
single { DeviceRepository(get()) }
- viewModel { LoginViewModel() }
+ viewModel { LoginViewModel(get(), get()) }
viewModel { DeviceRevokedViewModel(get()) }
+ viewModel { DeviceListViewModel(get()) }
}
const val APPS_SCOPE = "APPS_SCOPE"
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Event.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Event.kt
index 7871dc2d73..c351cc8130 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Event.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Event.kt
@@ -7,6 +7,7 @@ import net.mullvad.mullvadvpn.model.AccountCreationResult
import net.mullvad.mullvadvpn.model.AccountExpiry
import net.mullvad.mullvadvpn.model.AccountHistory
import net.mullvad.mullvadvpn.model.AppVersionInfo as AppVersionInfoData
+import net.mullvad.mullvadvpn.model.DeviceListEvent
import net.mullvad.mullvadvpn.model.DeviceState
import net.mullvad.mullvadvpn.model.GeoIpLocation
import net.mullvad.mullvadvpn.model.LoginResult
@@ -41,6 +42,9 @@ sealed class Event : Message.EventMessage() {
data class DeviceStateEvent(val newState: DeviceState) : Event()
@Parcelize
+ data class DeviceListUpdate(val event: DeviceListEvent) : Event()
+
+ @Parcelize
data class ListenerReady(val connection: Messenger, val listenerId: Int) : Event()
@Parcelize
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Request.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Request.kt
index 5937954fc2..9584aee506 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Request.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ipc/Request.kt
@@ -44,6 +44,15 @@ sealed class Request : Message.RequestMessage() {
object RefreshDeviceState : Request()
@Parcelize
+ object GetDevice : Request()
+
+ @Parcelize
+ data class GetDeviceList(val accountToken: String) : Request()
+
+ @Parcelize
+ data class RemoveDevice(val accountToken: String, val deviceId: String) : Request()
+
+ @Parcelize
object Logout : Request()
@Parcelize
@@ -68,9 +77,6 @@ sealed class Request : Message.RequestMessage() {
) : Request()
@Parcelize
- data class SetAccount(val account: String?) : Request()
-
- @Parcelize
data class SetAllowLan(val allow: Boolean) : Request()
@Parcelize
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountHistory.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountHistory.kt
index 114463aaaa..11e9b20604 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountHistory.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/AccountHistory.kt
@@ -9,4 +9,6 @@ sealed class AccountHistory : Parcelable {
@Parcelize
object Missing : AccountHistory()
+
+ fun accountToken() = (this as? Available)?.accountToken
}
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 21341dca54..ee34bc968f 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
@@ -8,7 +8,7 @@ data class Device(
val id: String,
val name: String,
val pubkey: ByteArray,
- val ports: ArrayList<String>
+ val ports: ArrayList<DevicePort>
) : Parcelable {
// Generated by Android Studio
override fun equals(other: Any?): Boolean {
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceListEvent.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceListEvent.kt
new file mode 100644
index 0000000000..1e0f78e985
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DeviceListEvent.kt
@@ -0,0 +1,16 @@
+package net.mullvad.mullvadvpn.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+sealed class DeviceListEvent : Parcelable {
+ @Parcelize
+ data class Available(val accountToken: String, val devices: List<Device>) : DeviceListEvent()
+
+ @Parcelize
+ object Error : DeviceListEvent()
+
+ fun isAvailable(): Boolean {
+ return (this is Available)
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DevicePort.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DevicePort.kt
new file mode 100644
index 0000000000..1159fa1a47
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/model/DevicePort.kt
@@ -0,0 +1,7 @@
+package net.mullvad.mullvadvpn.model
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class DevicePort(val id: String) : Parcelable
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt
index 115784c8f3..e98646d34f 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt
@@ -7,14 +7,18 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.actor
import kotlinx.coroutines.channels.sendBlocking
+import kotlinx.coroutines.flow.collect
+import net.mullvad.mullvadvpn.model.DeviceState
import net.mullvad.mullvadvpn.model.TunnelState
import net.mullvad.mullvadvpn.service.endpoint.ConnectionProxy
import net.mullvad.mullvadvpn.service.notifications.TunnelStateNotification
-import net.mullvad.talpid.util.autoSubscribable
+import net.mullvad.mullvadvpn.util.Intermittent
+import net.mullvad.mullvadvpn.util.JobTracker
class ForegroundNotificationManager(
val service: MullvadVpnService,
- val connectionProxy: ConnectionProxy
+ val connectionProxy: ConnectionProxy,
+ val intermittentDaemon: Intermittent<MullvadDaemon>
) {
private sealed class UpdaterMessage {
class UpdateNotification : UpdaterMessage()
@@ -22,6 +26,7 @@ class ForegroundNotificationManager(
class NewTunnelState(val newState: TunnelState) : UpdaterMessage()
}
+ private val jobTracker = JobTracker()
private val updater = runUpdater()
private val tunnelStateNotification = TunnelStateNotification(service)
@@ -36,10 +41,6 @@ class ForegroundNotificationManager(
private val shouldBeOnForeground
get() = lockedToForeground || !(tunnelState is TunnelState.Disconnected)
- var accountNumberEvents by autoSubscribable<String?>(this, null) { accountNumber ->
- loggedIn = accountNumber != null
- }
-
var onForeground = false
private set
@@ -52,10 +53,20 @@ class ForegroundNotificationManager(
updater.sendBlocking(UpdaterMessage.NewTunnelState(newState))
}
+ intermittentDaemon.registerListener(this) { daemon ->
+ jobTracker.newBackgroundJob("notificationLoggedInJob") {
+ daemon?.deviceStateUpdates?.collect { deviceState ->
+ loggedIn = deviceState is DeviceState.LoggedIn
+ }
+ }
+ }
+
updater.sendBlocking(UpdaterMessage.UpdateNotification())
}
fun onDestroy() {
+ jobTracker.cancelAllJobs()
+ intermittentDaemon.unregisterListener(this)
connectionProxy.onStateChange.unsubscribe(this)
updater.close()
}
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 380ae0dedf..8d983ad883 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
@@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.asSharedFlow
import net.mullvad.mullvadvpn.model.AppVersionInfo
import net.mullvad.mullvadvpn.model.Device
import net.mullvad.mullvadvpn.model.DeviceEvent
+import net.mullvad.mullvadvpn.model.DeviceListEvent
import net.mullvad.mullvadvpn.model.DeviceState
import net.mullvad.mullvadvpn.model.DnsOptions
import net.mullvad.mullvadvpn.model.GeoIpLocation
@@ -22,7 +23,6 @@ import net.mullvad.talpid.util.EventNotifier
class MullvadDaemon(vpnService: MullvadVpnService) {
protected var daemonInterfaceAddress = 0L
- var onDeviceRemoved = EventNotifier<RemoveDeviceEvent?>(null)
val onSettingsChange = EventNotifier<Settings?>(null)
var onTunnelStateChange = EventNotifier<TunnelState>(TunnelState.Disconnected)
@@ -33,6 +33,9 @@ class MullvadDaemon(vpnService: MullvadVpnService) {
private val _deviceStateUpdates = MutableSharedFlow<DeviceState>(extraBufferCapacity = 1)
val deviceStateUpdates = _deviceStateUpdates.asSharedFlow()
+ private val _deviceListUpdates = MutableSharedFlow<DeviceListEvent>(extraBufferCapacity = 1)
+ val deviceListUpdates = _deviceListUpdates.asSharedFlow()
+
init {
System.loadLibrary("mullvad_jni")
initialize(vpnService, vpnService.cacheDir.absolutePath, vpnService.filesDir.absolutePath)
@@ -104,8 +107,16 @@ class MullvadDaemon(vpnService: MullvadVpnService) {
fun logoutAccount() = logoutAccount(daemonInterfaceAddress)
- fun listDevices(accountToken: String?): List<Device>? {
- return listDevices(daemonInterfaceAddress, accountToken)
+ fun getAndEmitDeviceList(accountToken: String): List<Device>? {
+ return listDevices(daemonInterfaceAddress, accountToken).also { deviceList ->
+ _deviceListUpdates.tryEmit(
+ if (deviceList == null) {
+ DeviceListEvent.Error
+ } else {
+ DeviceListEvent.Available(accountToken, deviceList)
+ }
+ )
+ }
}
fun getAndEmitDeviceState(): DeviceState {
@@ -247,6 +258,6 @@ class MullvadDaemon(vpnService: MullvadVpnService) {
}
private fun notifyRemoveDeviceEvent(event: RemoveDeviceEvent) {
- onDeviceRemoved.notify(event)
+ _deviceListUpdates.tryEmit(DeviceListEvent.Available(event.accountToken, event.newDevices))
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt
index e057ed9154..9ff1e9f6a5 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt
@@ -82,10 +82,11 @@ class MullvadVpnService : TalpidVpnService() {
connectionProxy.reconnect()
}
- notificationManager =
- ForegroundNotificationManager(this, connectionProxy).apply {
- accountNumberEvents = endpoint.settingsListener.accountNumberNotifier
- }
+ notificationManager = ForegroundNotificationManager(
+ this,
+ connectionProxy,
+ daemonInstance.intermittentDaemon
+ )
accountExpiryNotification = AccountExpiryNotification(
this,
@@ -201,8 +202,7 @@ class MullvadVpnService : TalpidVpnService() {
if (settings != null) {
handlePendingAction(settings)
} else {
- // TODO: Skip until device integration is ready.
- // restart()
+ restart()
}
}
}
@@ -232,12 +232,11 @@ class MullvadVpnService : TalpidVpnService() {
private fun handlePendingAction(settings: Settings) {
when (pendingAction) {
PendingAction.Connect -> {
- // TODO: Skip until device integration is ready.
- // if (settings.accountToken != null) {
- // connectionProxy.connect()
- // } else {
- // openUi()
- // }
+ if (settings != null) {
+ connectionProxy.connect()
+ } else {
+ openUi()
+ }
}
PendingAction.Disconnect -> connectionProxy.disconnect()
null -> return
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 c79ade7891..3c1b2f0bed 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
@@ -35,7 +35,6 @@ class AccountCache(private val endpoint: ServiceEndpoint) {
private val daemon
get() = endpoint.intermittentDaemon
- val onAccountNumberChange = EventNotifier<String?>(null)
val onAccountExpiryChange = EventNotifier<AccountExpiry>(AccountExpiry.Missing)
val onAccountHistoryChange = EventNotifier<AccountHistory>(AccountHistory.Missing)
@@ -97,10 +96,8 @@ class AccountCache(private val endpoint: ServiceEndpoint) {
}
fun onDestroy() {
- endpoint.settingsListener.accountNumberNotifier.unsubscribe(this)
jobTracker.cancelAllJobs()
- onAccountNumberChange.unsubscribeAll()
onAccountExpiryChange.unsubscribeAll()
onAccountHistoryChange.unsubscribeAll()
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/DaemonDeviceDataSource.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/DaemonDeviceDataSource.kt
index cc23b3fe01..cb290a6d27 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/DaemonDeviceDataSource.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/DaemonDeviceDataSource.kt
@@ -22,17 +22,41 @@ class DaemonDeviceDataSource(
}
private fun launchDeviceEndpointJobs(daemon: MullvadDaemon) {
- tracker.newBackgroundJob("propagateDeviceUpdates") {
+ tracker.newBackgroundJob("propagateDeviceUpdatesJob") {
daemon.deviceStateUpdates.collect { newState ->
endpoint.sendEvent(Event.DeviceStateEvent(newState))
}
}
+ tracker.newBackgroundJob("propagateDeviceListUpdatesJob") {
+ daemon.deviceListUpdates.collect { newState ->
+ endpoint.sendEvent(Event.DeviceListUpdate(newState))
+ }
+ }
+
+ endpoint.dispatcher.registerHandler(Request.GetDevice::class) {
+ tracker.newBackgroundJob("getDeviceJob") {
+ daemon.getAndEmitDeviceState()
+ }
+ }
+
endpoint.dispatcher.registerHandler(Request.RefreshDeviceState::class) {
tracker.newBackgroundJob("refreshDeviceJob") {
daemon.refreshDevice()
}
}
+
+ endpoint.dispatcher.registerHandler(Request.RemoveDevice::class) { request ->
+ tracker.newBackgroundJob("removeDeviceJob") {
+ daemon.removeDevice(request.accountToken, request.deviceId)
+ }
+ }
+
+ endpoint.dispatcher.registerHandler(Request.GetDeviceList::class) { request ->
+ tracker.newBackgroundJob("getDeviceListJob") {
+ daemon.getAndEmitDeviceList(request.accountToken)
+ }
+ }
}
fun onDestroy() {
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SettingsListener.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SettingsListener.kt
index cabf03ee5c..c39c64b862 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SettingsListener.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SettingsListener.kt
@@ -24,7 +24,6 @@ class SettingsListener(endpoint: ServiceEndpoint) {
private val commandChannel = spawnActor()
private val daemon = endpoint.intermittentDaemon
- val accountNumberNotifier = EventNotifier<String?>(null)
val dnsOptionsNotifier = EventNotifier<DnsOptions?>(null)
val relaySettingsNotifier = EventNotifier<RelaySettings?>(null)
val settingsNotifier = EventNotifier<Settings?>(null)
@@ -63,7 +62,6 @@ class SettingsListener(endpoint: ServiceEndpoint) {
commandChannel.close()
daemon.unregisterListener(this)
- accountNumberNotifier.unsubscribeAll()
dnsOptionsNotifier.unsubscribeAll()
relaySettingsNotifier.unsubscribeAll()
settingsNotifier.unsubscribeAll()
@@ -94,11 +92,6 @@ class SettingsListener(endpoint: ServiceEndpoint) {
private fun handleNewSettings(newSettings: Settings?) {
if (newSettings != null) {
synchronized(this) {
- // TODO: Skip until device integration is ready.
- // if (settings?.accountToken != newSettings.accountToken) {
- // accountNumberNotifier.notify(newSettings.accountToken)
- // }
-
if (settings?.tunnelOptions?.dnsOptions != newSettings.tunnelOptions.dnsOptions) {
dnsOptionsNotifier.notify(newSettings.tunnelOptions.dnsOptions)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/AccountExpiryNotification.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/AccountExpiryNotification.kt
index 5720c2eee1..2d1e86b46c 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/AccountExpiryNotification.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/AccountExpiryNotification.kt
@@ -59,7 +59,7 @@ class AccountExpiryNotification(
}
fun onDestroy() {
- accountCache.onAccountNumberChange.unsubscribe(this)
+ accountCache.onAccountExpiryChange.unsubscribe(this)
}
private suspend fun update(expiry: AccountExpiry) {
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt
index 4b5fda7bbe..8cd506f13d 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt
@@ -4,7 +4,6 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
@@ -66,6 +65,18 @@ class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) {
private lateinit var redeemVoucherButton: RedeemVoucherButton
private lateinit var titleController: CollapsibleTitleController
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ lifecycleScope.launch {
+ deviceRepository.deviceState
+ .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
+ .collect { state ->
+ accountNumberView.information = state.token()
+ deviceNameView.information = state.deviceName()?.capitalizeFirstCharOfEachWord()
+ }
+ }
+ }
+
override fun onSafelyCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -90,7 +101,7 @@ class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) {
}
view.findViewById<Button>(R.id.logout).setOnClickAction("logout", jobTracker) {
- logout()
+ accountCache.logout()
}
accountNumberView = view.findViewById<CopyableInformationView>(R.id.account_number).apply {
@@ -104,19 +115,6 @@ class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) {
return view
}
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
-
- lifecycleScope.launch {
- deviceRepository.deviceState
- .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
- .collect { state ->
- accountNumberView.information = state.token()
- deviceNameView.information = state.deviceName()?.capitalizeFirstCharOfEachWord()
- }
- }
- }
-
override fun onSafelyStart() {
jobTracker.newUiJob("updateAccountExpiry") {
accountCache.accountExpiryState
@@ -172,33 +170,6 @@ class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) {
RedeemVoucherDialogFragment().show(transaction, null)
}
- private suspend fun logout() {
- accountCache.logout()
- clearBackStack()
- goToLoginScreen()
- }
-
- private fun clearBackStack() {
- parentFragmentManager.apply {
- val firstEntry = getBackStackEntryAt(0)
-
- popBackStack(firstEntry.id, FragmentManager.POP_BACK_STACK_INCLUSIVE)
- }
- }
-
- private fun goToLoginScreen() {
- parentFragmentManager.beginTransaction().apply {
- setCustomAnimations(
- R.anim.do_nothing,
- R.anim.fragment_exit_to_bottom,
- R.anim.do_nothing,
- R.anim.do_nothing
- )
- replace(R.id.main_fragment, LoginFragment())
- commit()
- }
- }
-
private fun addSpacesToAccountNumber(rawAccountNumber: String): String {
return rawAccountNumber
.asSequence()
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginFragment.kt
index d16cf2f0a9..843edf2577 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginFragment.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/LoginFragment.kt
@@ -15,8 +15,8 @@ import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.R
-import net.mullvad.mullvadvpn.model.AccountHistory
-import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer
+import net.mullvad.mullvadvpn.ui.fragments.ACCOUNT_TOKEN_ARGUMENT_KEY
+import net.mullvad.mullvadvpn.ui.fragments.DeviceListFragment
import net.mullvad.mullvadvpn.ui.widget.AccountLogin
import net.mullvad.mullvadvpn.ui.widget.HeaderBar
import net.mullvad.mullvadvpn.viewmodel.LoginViewModel
@@ -38,6 +38,11 @@ class LoginFragment :
private lateinit var background: View
private lateinit var headerBar: HeaderBar
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setupLifecycleSubscriptionsToViewModel()
+ }
+
override fun onSafelyCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -52,8 +57,6 @@ class LoginFragment :
loggedInStatus = view.findViewById(R.id.logged_in_status)
loginFailStatus = view.findViewById(R.id.login_fail_status)
- loginViewModel.updateAccountCacheInstance(accountCache)
-
accountLogin = view.findViewById<AccountLogin>(R.id.account_login).apply {
onLogin = loginViewModel::login
onClearHistory = loginViewModel::clearAccountHistory
@@ -70,21 +73,12 @@ class LoginFragment :
scrollToShow(accountLogin)
- setupLifecycleSubscriptionsToViewModel()
+ loginViewModel.clearState()
+ triggerAutoLoginIfAccountTokenPresent()
return view
}
- override fun onNewServiceConnection(serviceConnectionContainer: ServiceConnectionContainer) {
- super.onNewServiceConnection(serviceConnectionContainer)
- loginViewModel.updateAccountCacheInstance(accountCache)
- }
-
- override fun onNoServiceConnection() {
- super.onNoServiceConnection()
- loginViewModel.updateAccountCacheInstance(null)
- }
-
override fun onSafelyStart() {
parentActivity.backButtonHandler = {
if (accountLogin.hasFocus) {
@@ -105,13 +99,19 @@ class LoginFragment :
parentActivity.backButtonHandler = null
}
+ private fun triggerAutoLoginIfAccountTokenPresent() {
+ arguments?.getString(ACCOUNT_TOKEN_ARGUMENT_KEY)?.also { accountToken ->
+ accountLogin.setAccountToken(accountToken)
+ loginViewModel.login(accountToken)
+ }
+ }
+
private fun setupLifecycleSubscriptionsToViewModel() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
launch {
loginViewModel.accountHistory.collect { history ->
- accountLogin.accountHistory = history
- .let { it as? AccountHistory.Available }?.accountToken
+ accountLogin.accountHistory = history.accountToken()
}
}
launch {
@@ -154,8 +154,11 @@ class LoginFragment :
}
is LoginViewModel.LoginUiState.TooManyDevicesError -> {
- // TODO: Switch to TooManyDevicesFragment
- loginFailure("Too many devices!")
+ openDeviceListFragment(uiState.accountToken)
+ }
+
+ is LoginViewModel.LoginUiState.TooManyDevicesMissingListError -> {
+ loginFailure(context?.getString(R.string.failed_to_fetch_devices))
}
is LoginViewModel.LoginUiState.UnableToCreateAccountError -> {
@@ -175,6 +178,24 @@ class LoginFragment :
}
}
+ private fun openDeviceListFragment(accountToken: String) {
+ val deviceFragment = DeviceListFragment().apply {
+ arguments = Bundle().apply { putString(ACCOUNT_TOKEN_ARGUMENT_KEY, accountToken) }
+ }
+
+ parentFragmentManager.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, deviceFragment)
+ addToBackStack(null)
+ commit()
+ }
+ }
+
private fun showDefault() {
accountLogin.state = LoginState.Initial
headerBar.tunnelState = null
@@ -211,7 +232,7 @@ class LoginFragment :
scrollToShow(loggingInStatus)
}
- private fun loginFailure(description: String) {
+ private fun loginFailure(description: String? = "") {
title.setText(R.string.login_fail_title)
subtitle.setText(description)
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 204635a161..c5efb3e984 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
@@ -203,6 +203,7 @@ open class MainActivity : FragmentActivity() {
}
private fun openLoginView() {
+ clearBackStack()
supportFragmentManager.beginTransaction().apply {
replace(R.id.main_fragment, LoginFragment())
commit()
@@ -222,6 +223,15 @@ open class MainActivity : FragmentActivity() {
}
}
+ fun clearBackStack() {
+ supportFragmentManager.apply {
+ if (backStackEntryCount > 0) {
+ val firstEntry = getBackStackEntryAt(0)
+ popBackStack(firstEntry.id, FragmentManager.POP_BACK_STACK_INCLUSIVE)
+ }
+ }
+ }
+
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/SettingsFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt
index 2cf83fc4c7..5d78098eb8 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt
@@ -51,6 +51,17 @@ class SettingsFragment : ServiceAwareFragment(), StatusBarPainter, NavigationBar
versionInfoCache = null
}
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ lifecycleScope.launch {
+ deviceRepository.deviceState
+ .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
+ .collect { device ->
+ updateLoggedInStatus(device is DeviceState.LoggedIn)
+ }
+ }
+ }
+
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -92,14 +103,6 @@ class SettingsFragment : ServiceAwareFragment(), StatusBarPainter, NavigationBar
paintStatusBar(ContextCompat.getColor(requireContext(), R.color.darkBlue))
}
}
-
- lifecycleScope.launch {
- deviceRepository.deviceState
- .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
- .collect { device ->
- updateLoggedInStatus(device is DeviceState.LoggedIn)
- }
- }
}
override fun onResume() {
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/DeviceListFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/DeviceListFragment.kt
new file mode 100644
index 0000000000..6d5e47f8dd
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/DeviceListFragment.kt
@@ -0,0 +1,76 @@
+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.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.ScaffoldWithTopBar
+import net.mullvad.mullvadvpn.compose.screen.DeviceListScreen
+import net.mullvad.mullvadvpn.ui.LoginFragment
+import net.mullvad.mullvadvpn.ui.MainActivity
+import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel
+import org.koin.androidx.viewmodel.ext.android.viewModel
+
+class DeviceListFragment : Fragment() {
+
+ private val deviceListViewModel by viewModel<DeviceListViewModel>()
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ deviceListViewModel.accountToken = arguments?.getString(ACCOUNT_TOKEN_ARGUMENT_KEY)
+
+ return inflater.inflate(R.layout.fragment_compose, container, false).apply {
+ findViewById<ComposeView>(R.id.compose_view).setContent {
+ val topColor = colorResource(R.color.blue)
+ ScaffoldWithTopBar(
+ topBarColor = topColor,
+ statusBarColor = topColor,
+ navigationBarColor = colorResource(id = R.color.darkBlue),
+ onSettingsClicked = this@DeviceListFragment::openSettings,
+ content = {
+ DeviceListScreen(
+ viewModel = deviceListViewModel,
+ onBackClick = this@DeviceListFragment::goBack,
+ onContinueWithLogin = this@DeviceListFragment::openLoginView
+ )
+ }
+ )
+ }
+ }
+ }
+
+ private fun openLoginView() {
+ parentActivity()?.clearBackStack()
+ val loginFragment = LoginFragment().apply {
+ if (deviceListViewModel.accountToken != null) {
+ arguments = Bundle().apply {
+ putString(
+ ACCOUNT_TOKEN_ARGUMENT_KEY,
+ deviceListViewModel.accountToken
+ )
+ }
+ }
+ }
+ parentFragmentManager.beginTransaction().apply {
+ replace(R.id.main_fragment, loginFragment)
+ commit()
+ }
+ }
+
+ private fun goBack() {
+ parentActivity()?.onBackPressed()
+ }
+
+ private fun parentActivity(): MainActivity? {
+ return (context as? MainActivity)
+ }
+
+ private fun openSettings() = parentActivity()?.openSettings()
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/FragmentArgumentConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/FragmentArgumentConstant.kt
new file mode 100644
index 0000000000..e6ba0c7c3b
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragments/FragmentArgumentConstant.kt
@@ -0,0 +1,3 @@
+package net.mullvad.mullvadvpn.ui.fragments
+
+const val ACCOUNT_TOKEN_ARGUMENT_KEY = "accountToken"
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 9975b48ef9..08290ef7d2 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
@@ -3,29 +3,62 @@ package net.mullvad.mullvadvpn.ui.serviceconnection
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.withTimeoutOrNull
+import net.mullvad.mullvadvpn.model.Device
+import net.mullvad.mullvadvpn.model.DeviceListEvent
import net.mullvad.mullvadvpn.model.DeviceState
class DeviceRepository(
private val serviceConnectionManager: ServiceConnectionManager,
+ private val deviceListTimeoutMillis: Long = 5000L,
dispatcher: CoroutineDispatcher = Dispatchers.IO
) {
+ private val cachedDeviceList = MutableStateFlow<List<Device>>(emptyList())
+
val deviceState = serviceConnectionManager.connectionState
.flatMapLatest { state ->
if (state is ServiceConnectionState.ConnectedReady) {
state.container.deviceDataSource.deviceStateUpdates
.onStart {
- state.container.deviceDataSource.refreshDevice()
+ state.container.deviceDataSource.getDevice()
}
} else {
flowOf(DeviceState.Unknown)
}
}
- .stateIn(CoroutineScope(dispatcher), SharingStarted.Lazily, DeviceState.Initial)
+ .stateIn(
+ CoroutineScope(dispatcher),
+ SharingStarted.WhileSubscribed(),
+ DeviceState.Initial
+ )
+
+ private val deviceListEvents = serviceConnectionManager.connectionState
+ .flatMapLatest { state ->
+ if (state is ServiceConnectionState.ConnectedReady) {
+ state.container.deviceDataSource.deviceListUpdates
+ } else {
+ emptyFlow()
+ }
+ }
+
+ val deviceList = deviceListEvents
+ .map { (it as? DeviceListEvent.Available)?.devices ?: emptyList() }
+ .onStart {
+ if (cachedDeviceList.value.isNotEmpty()) {
+ emit(cachedDeviceList.value)
+ }
+ }
+ .stateIn(CoroutineScope(Dispatchers.IO), SharingStarted.WhileSubscribed(), emptyList())
fun refreshDeviceState() {
container()?.deviceDataSource?.refreshDevice()
@@ -34,4 +67,27 @@ class DeviceRepository(
private fun container(): ServiceConnectionContainer? {
return serviceConnectionManager.connectionState.value.readyContainer()
}
+
+ fun removeDevice(accountToken: String, deviceId: String) {
+ cachedDeviceList.value = emptyList()
+ container()?.deviceDataSource?.removeDevice(accountToken, deviceId)
+ }
+
+ fun refreshDeviceList(accountToken: String) {
+ container()?.deviceDataSource?.refreshDeviceList(accountToken)
+ }
+
+ suspend fun getDeviceList(accountToken: String): DeviceListEvent {
+ return withTimeoutOrNull(deviceListTimeoutMillis) {
+ deviceListEvents
+ .onStart {
+ refreshDeviceList(accountToken)
+ }
+ .onEach {
+ cachedDeviceList.value =
+ (it as? DeviceListEvent.Available)?.devices ?: emptyList()
+ }
+ .firstOrNull() ?: DeviceListEvent.Error
+ } ?: DeviceListEvent.Error
+ }
}
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 23892257d6..6f018a27b1 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
@@ -21,8 +21,30 @@ class ServiceConnectionDeviceDataSource(
}
}
+ val deviceListUpdates = callbackFlow {
+ val handler: (Event.DeviceListUpdate) -> Unit = { event ->
+ trySend(event.event)
+ }
+ dispatcher.registerHandler(Event.DeviceListUpdate::class, handler)
+ awaitClose {
+ // The current dispatcher doesn't support unregistration of handlers.
+ }
+ }
+
// Async result: Event.DeviceChanged
fun refreshDevice() {
connection.send(Request.RefreshDeviceState.message)
}
+
+ fun getDevice() {
+ connection.send(Request.GetDevice.message)
+ }
+
+ fun removeDevice(accountToken: String, deviceId: String) {
+ connection.send(Request.RemoveDevice(accountToken, deviceId).message)
+ }
+
+ fun refreshDeviceList(accountToken: String) {
+ connection.send(Request.GetDeviceList(accountToken).message)
+ }
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SettingsListener.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SettingsListener.kt
index 9446f51dfd..a1464a7745 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SettingsListener.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/serviceconnection/SettingsListener.kt
@@ -10,19 +10,12 @@ import net.mullvad.mullvadvpn.model.Settings
import net.mullvad.talpid.util.EventNotifier
class SettingsListener(private val connection: Messenger, eventDispatcher: EventDispatcher) {
- val accountNumberNotifier = EventNotifier<String?>(null)
val dnsOptionsNotifier = EventNotifier<DnsOptions?>(null)
val relaySettingsNotifier = EventNotifier<RelaySettings?>(null)
val settingsNotifier = EventNotifier<Settings?>(null)
private var settings by settingsNotifier.notifiable()
- var account: String?
- get() = accountNumberNotifier.latestEvent
- set(value) {
- connection.send(Request.SetAccount(value).message)
- }
-
var allowLan: Boolean
get() = settingsNotifier.latestEvent?.allowLan ?: false
set(value) {
@@ -46,7 +39,6 @@ class SettingsListener(private val connection: Messenger, eventDispatcher: Event
}
fun onDestroy() {
- accountNumberNotifier.unsubscribeAll()
dnsOptionsNotifier.unsubscribeAll()
relaySettingsNotifier.unsubscribeAll()
settingsNotifier.unsubscribeAll()
@@ -57,11 +49,6 @@ class SettingsListener(private val connection: Messenger, eventDispatcher: Event
}
private fun handleNewSettings(newSettings: Settings) {
- // TODO: Skip until device integration is ready.
- // if (settings?.accountToken != newSettings.accountToken) {
- // accountNumberNotifier.notify(newSettings.accountToken)
- // }
-
if (settings?.tunnelOptions?.dnsOptions != newSettings.tunnelOptions.dnsOptions) {
dnsOptionsNotifier.notify(newSettings.tunnelOptions.dnsOptions)
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountInput.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountInput.kt
index 970e9e9d0a..1a496933a4 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountInput.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountInput.kt
@@ -45,7 +45,7 @@ class AccountInput : LinearLayout {
}
}
- private val input = container.findViewById<EditText>(R.id.login_input).apply {
+ val input = container.findViewById<EditText>(R.id.login_input).apply {
addTextChangedListener(inputWatcher)
setOnEnterOrDoneAction(::login)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountLogin.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountLogin.kt
index d18d8f5b39..f3eca196f2 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountLogin.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/AccountLogin.kt
@@ -23,6 +23,8 @@ class AccountLogin : RelativeLayout {
private val MAX_ACCOUNT_HISTORY_ENTRIES = 3
}
+ fun setAccountToken(accountToken: String) { input.input.setText(accountToken) }
+
private val focusDebouncer = Debouncer(false).apply {
listener = { hasFocus -> focused = hasFocus }
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/GenericExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/GenericExtensions.kt
new file mode 100644
index 0000000000..0ab0485c79
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/GenericExtensions.kt
@@ -0,0 +1,5 @@
+package net.mullvad.mullvadvpn.util
+
+inline fun <T1 : Any, T2 : Any, R : Any> safeLet(p1: T1?, p2: T2?, block: (T1, T2) -> R?): R? {
+ return if (p1 != null && p2 != null) block(p1, p2) else null
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt
new file mode 100644
index 0000000000..027520d293
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceListViewModel.kt
@@ -0,0 +1,50 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import net.mullvad.mullvadvpn.compose.state.DeviceListUiState
+import net.mullvad.mullvadvpn.model.Device
+import net.mullvad.mullvadvpn.ui.serviceconnection.DeviceRepository
+import net.mullvad.mullvadvpn.util.safeLet
+
+class DeviceListViewModel(
+ private val deviceRepository: DeviceRepository
+) : ViewModel() {
+ private val _stagedForRemoval = MutableStateFlow<Device?>(null)
+ var accountToken: String? = null
+
+ val uiState = deviceRepository.deviceList
+ .combine(_stagedForRemoval) { deviceList, deviceStagedForRemoval ->
+ DeviceListUiState(
+ devices = deviceList,
+ isLoading = false,
+ deviceStagedForRemoval = deviceStagedForRemoval
+ )
+ }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), DeviceListUiState.INITIAL)
+
+ fun stageDeviceForRemoval(device: Device) {
+ _stagedForRemoval.value = device
+ }
+
+ fun clearStagedDevice() {
+ _stagedForRemoval.value = null
+ }
+
+ fun confirmRemoval() {
+ safeLet(accountToken, _stagedForRemoval.value) { token, device ->
+ deviceRepository.removeDevice(token, device.id)
+ _stagedForRemoval.value = null
+ }
+ }
+
+ fun refreshDeviceState() = deviceRepository.refreshDeviceState()
+
+ fun refreshDeviceList() = accountToken?.let { token ->
+ deviceRepository.refreshDeviceList(token)
+ }
+}
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
index d3975fbd08..d1749ee249 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModel.kt
@@ -1,6 +1,7 @@
package net.mullvad.mullvadvpn.viewmodel
import androidx.lifecycle.ViewModel
+import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
@@ -17,7 +18,7 @@ import net.mullvad.talpid.util.callbackFlowFromSubscription
// ServiceConnectionManager here.
class DeviceRevokedViewModel(
private val serviceConnectionManager: ServiceConnectionManager,
- scope: CoroutineScope = CoroutineScope(Dispatchers.IO)
+ dispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel() {
val uiState = serviceConnectionManager.connectionState
@@ -35,9 +36,9 @@ class DeviceRevokedViewModel(
?: flowOf(DeviceRevokedUiState.UNKNOWN)
}
.stateIn(
- scope,
- SharingStarted.Lazily,
- DeviceRevokedUiState.UNKNOWN
+ scope = CoroutineScope(dispatcher),
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = DeviceRevokedUiState.UNKNOWN
)
fun onGoToLoginClicked() {
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt
index e9cb27fda6..bcfd042580 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModel.kt
@@ -2,24 +2,56 @@ package net.mullvad.mullvadvpn.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.model.AccountCreationResult
import net.mullvad.mullvadvpn.model.AccountHistory
import net.mullvad.mullvadvpn.model.LoginResult
import net.mullvad.mullvadvpn.ui.serviceconnection.AccountCache
+import net.mullvad.mullvadvpn.ui.serviceconnection.DeviceRepository
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
-class LoginViewModel : ViewModel() {
+class LoginViewModel(
+ private val deviceRepository: DeviceRepository,
+ private val serviceConnectionManager: ServiceConnectionManager,
+ private val dispatcher: CoroutineDispatcher = Dispatchers.IO
+) : ViewModel() {
private val _uiState = MutableStateFlow<LoginUiState>(LoginUiState.Default)
val uiState: StateFlow<LoginUiState> = _uiState
- private val _accountHistory = MutableStateFlow<AccountHistory>(AccountHistory.Missing)
- val accountHistory: StateFlow<AccountHistory> = _accountHistory
+ private val accountCache: AccountCache?
+ get() {
+ return serviceConnectionManager.connectionState.value.readyContainer()?.accountCache
+ }
- private var accountCache: AccountCache? = null
+ val accountHistory = serviceConnectionManager.connectionState
+ .flatMapLatest { state ->
+ if (state is ServiceConnectionState.ConnectedReady) {
+ state.container.accountCache.accountHistoryEvents
+ .onStart {
+ state.container.accountCache.fetchAccountHistory()
+ }
+ } else {
+ emptyFlow()
+ }
+ }
+ .stateIn(
+ scope = CoroutineScope(dispatcher),
+ started = SharingStarted.WhileSubscribed(),
+ initialValue = AccountHistory.Missing
+ )
sealed class LoginUiState {
object Default : LoginUiState()
@@ -32,49 +64,59 @@ class LoginViewModel : ViewModel() {
object AccountCreated : LoginUiState()
object UnableToCreateAccountError : LoginUiState()
object InvalidAccountError : LoginUiState()
- object TooManyDevicesError : LoginUiState()
+ data class TooManyDevicesError(val accountToken: String) : LoginUiState()
+ object TooManyDevicesMissingListError : LoginUiState()
data class OtherError(val errorMessage: String) : LoginUiState()
}
- // Ensures the view model has an up-to-date instance of account cache. This is an intermediate
- // solution due to limitations in the current app architecture.
- fun updateAccountCacheInstance(newAccountCache: AccountCache?) {
- accountCache = newAccountCache?.apply {
- viewModelScope.launch {
- accountHistoryEvents.collect {
- _accountHistory.value = it
- }
- }
-
- fetchAccountHistory()
+ fun clearAccountHistory() {
+ accountCache.tryPerformAction(
+ errorMessageIfAccountCacheNotAvailable = SERVICE_NOT_CONNECTED_ERROR_MESSAGE
+ ) { cache ->
+ cache.clearAccountHistory()
}
}
- fun clearAccountHistory() {
- accountCache?.clearAccountHistory()
+ fun clearState() {
+ _uiState.value = LoginUiState.Default
}
fun createAccount() {
- accountCache?.apply {
+ accountCache.tryPerformAction(
+ errorMessageIfAccountCacheNotAvailable = SERVICE_NOT_CONNECTED_ERROR_MESSAGE
+ ) { cache ->
_uiState.value = LoginUiState.CreatingAccount
-
- viewModelScope.launch {
- _uiState.value = accountCreationEvents.first().mapToUiState()
+ viewModelScope.launch(dispatcher) {
+ _uiState.value = cache.accountCreationEvents
+ .onStart { cache.createNewAccount() }
+ .first()
+ .mapToUiState()
}
-
- createNewAccount()
}
}
fun login(accountToken: String) {
- accountCache?.apply {
+ accountCache.tryPerformAction(
+ errorMessageIfAccountCacheNotAvailable = SERVICE_NOT_CONNECTED_ERROR_MESSAGE
+ ) { cache ->
_uiState.value = LoginUiState.Loading
-
- viewModelScope.launch {
- _uiState.value = loginEvents.first().result.mapToUiState()
+ viewModelScope.launch(dispatcher) {
+ _uiState.value = cache.loginEvents
+ .onStart { cache.login(accountToken) }
+ .map { it.result.mapToUiState(accountToken) }
+ .first()
}
+ }
+ }
- login(accountToken)
+ private fun AccountCache?.tryPerformAction(
+ errorMessageIfAccountCacheNotAvailable: String,
+ action: (AccountCache) -> Unit
+ ) {
+ if (this != null) {
+ action(this)
+ } else {
+ _uiState.value = LoginUiState.OtherError(errorMessageIfAccountCacheNotAvailable)
}
}
@@ -86,12 +128,22 @@ class LoginViewModel : ViewModel() {
}
}
- private fun LoginResult.mapToUiState(): LoginUiState {
+ private suspend fun LoginResult.mapToUiState(accountToken: String): LoginUiState {
return when (this) {
LoginResult.Ok -> LoginUiState.Success(false)
LoginResult.InvalidAccount -> LoginUiState.InvalidAccountError
- LoginResult.MaxDevicesReached -> LoginUiState.TooManyDevicesError
+ LoginResult.MaxDevicesReached -> {
+ if (deviceRepository.getDeviceList(accountToken).isAvailable()) {
+ LoginUiState.TooManyDevicesError(accountToken)
+ } else {
+ LoginUiState.TooManyDevicesMissingListError
+ }
+ }
else -> LoginUiState.OtherError(errorMessage = this.toString())
}
}
+
+ companion object {
+ private const val SERVICE_NOT_CONNECTED_ERROR_MESSAGE = "Not connected to service!"
+ }
}
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
index 8f53508029..de78bd2b7a 100644
--- a/android/app/src/main/res/values/colors.xml
+++ b/android/app/src/main/res/values/colors.xml
@@ -15,6 +15,7 @@
<color name="green">#44AD4D</color>
<color name="green90">#E644AD4D</color>
<color name="green80">#CC44AD4D</color>
+ <color name="green40">#6644AD4D</color>
<color name="red">#FFE34039</color>
<color name="red95">#F2E34039</color>
<color name="red80">#CCE34039</color>
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index bcdee0d5c8..8a04ad5bb9 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -180,4 +180,19 @@
will need to log back in.</string>
<string name="device_inactive_unblock_warning">Going to login will unblock the internet on this
device.</string>
+ <string name="max_devices_warning_title">Too many devices</string>
+ <string name="max_devices_resolved_title">Super!</string>
+ <string name="max_devices_warning_description">You have too many active devices. Please log out
+ of at least one by removing it from the list below. You can find the corresponding nickname
+ under the device\'s Account settings.</string>
+ <string name="max_devices_resolved_description">You can now continue logging in on this
+ device.</string>
+ <string name="max_devices_confirm_removal_description">
+<![CDATA[Are you sure you want to log out of <b>%s</b>?]]>
+ </string>
+ <string name="confirm_removal">Yes, log out device</string>
+ <string name="continue_login">Continue with login</string>
+ <string name="failed_to_fetch_devices">Failed to fetch list of devices</string>
+ <string name="port_removal_notice">This will delete all forwarded ports. Local settings will be
+ saved.</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
index 69941a474d..f507aa5b35 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/DeviceRevokedViewModelTest.kt
@@ -12,7 +12,6 @@ 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
@@ -45,7 +44,7 @@ class DeviceRevokedViewModelTest {
every { mockedServiceConnectionManager.connectionState } returns serviceConnectionState
viewModel = DeviceRevokedViewModel(
mockedServiceConnectionManager,
- CoroutineScope(TestCoroutineDispatcher())
+ TestCoroutineDispatcher()
)
}
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt
index a0dc80957b..59c52aab7d 100644
--- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/LoginViewModelTest.kt
@@ -3,20 +3,27 @@ package net.mullvad.mullvadvpn.viewmodel
import app.cash.turbine.FlowTurbine
import app.cash.turbine.test
import io.mockk.MockKAnnotations
+import io.mockk.coEvery
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.verify
import junit.framework.Assert.assertEquals
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.setMain
import net.mullvad.mullvadvpn.ipc.Event
import net.mullvad.mullvadvpn.model.AccountCreationResult
import net.mullvad.mullvadvpn.model.AccountHistory
+import net.mullvad.mullvadvpn.model.DeviceListEvent
import net.mullvad.mullvadvpn.model.LoginResult
import net.mullvad.mullvadvpn.ui.serviceconnection.AccountCache
+import net.mullvad.mullvadvpn.ui.serviceconnection.DeviceRepository
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
import org.junit.Before
import org.junit.Test
@@ -25,12 +32,24 @@ class LoginViewModelTest {
@MockK
private lateinit var mockedAccountCache: AccountCache
+ @MockK
+ private lateinit var mockedDeviceRepository: DeviceRepository
+
+ @MockK
+ private lateinit var mockedServiceConnectionManager: ServiceConnectionManager
+
+ @MockK
+ private lateinit var mockedServiceConnectionContainer: ServiceConnectionContainer
+
private lateinit var loginViewModel: LoginViewModel
private val accountCreationTestEvents = MutableSharedFlow<AccountCreationResult>()
private val accountHistoryTestEvents = MutableSharedFlow<AccountHistory>()
private val loginTestEvents = MutableSharedFlow<Event.LoginEvent>()
+ private val serviceConnectionState =
+ MutableStateFlow<ServiceConnectionState>(ServiceConnectionState.Disconnected)
+
@Before
fun setup() {
Dispatchers.setMain(TestCoroutineDispatcher())
@@ -39,13 +58,21 @@ class LoginViewModelTest {
every { mockedAccountCache.accountCreationEvents } returns accountCreationTestEvents
every { mockedAccountCache.accountHistoryEvents } returns accountHistoryTestEvents
every { mockedAccountCache.loginEvents } returns loginTestEvents
+ every { mockedServiceConnectionManager.connectionState } returns serviceConnectionState
+ every { mockedServiceConnectionContainer.accountCache } returns mockedAccountCache
- loginViewModel = LoginViewModel()
+ serviceConnectionState.value =
+ ServiceConnectionState.ConnectedReady(mockedServiceConnectionContainer)
+
+ loginViewModel = LoginViewModel(
+ mockedDeviceRepository,
+ mockedServiceConnectionManager,
+ TestCoroutineDispatcher()
+ )
}
@Test
fun testDefaultState() = runBlockingTest {
- loginViewModel.updateAccountCacheInstance(mockedAccountCache)
loginViewModel.uiState.test {
assertEquals(LoginViewModel.LoginUiState.Default, awaitItem())
}
@@ -53,19 +80,18 @@ class LoginViewModelTest {
@Test
fun testCreateAccount() = runBlockingTest {
- loginViewModel.updateAccountCacheInstance(mockedAccountCache)
loginViewModel.uiState.test {
skipDefaultItem()
loginViewModel.createAccount()
assertEquals(LoginViewModel.LoginUiState.CreatingAccount, awaitItem())
accountCreationTestEvents.emit(AccountCreationResult.Success(DUMMY_ACCOUNT_TOKEN))
+
assertEquals(LoginViewModel.LoginUiState.AccountCreated, awaitItem())
}
}
@Test
fun testLoginWithValidAccount() = runBlockingTest {
- loginViewModel.updateAccountCacheInstance(mockedAccountCache)
loginViewModel.uiState.test {
skipDefaultItem()
loginViewModel.login(DUMMY_ACCOUNT_TOKEN)
@@ -77,7 +103,6 @@ class LoginViewModelTest {
@Test
fun testLoginWithInvalidAccount() = runBlockingTest {
- loginViewModel.updateAccountCacheInstance(mockedAccountCache)
loginViewModel.uiState.test {
skipDefaultItem()
loginViewModel.login(DUMMY_ACCOUNT_TOKEN)
@@ -89,19 +114,23 @@ class LoginViewModelTest {
@Test
fun testLoginWithTooManyDevicesError() = runBlockingTest {
- loginViewModel.updateAccountCacheInstance(mockedAccountCache)
+ coEvery { mockedDeviceRepository.getDeviceList(any()) } returns DeviceListEvent.Available(
+ DUMMY_ACCOUNT_TOKEN, listOf()
+ )
+
loginViewModel.uiState.test {
skipDefaultItem()
loginViewModel.login(DUMMY_ACCOUNT_TOKEN)
assertEquals(LoginViewModel.LoginUiState.Loading, awaitItem())
loginTestEvents.emit(Event.LoginEvent(LoginResult.MaxDevicesReached))
- assertEquals(LoginViewModel.LoginUiState.TooManyDevicesError, awaitItem())
+ assertEquals(
+ LoginViewModel.LoginUiState.TooManyDevicesError(DUMMY_ACCOUNT_TOKEN), awaitItem()
+ )
}
}
@Test
fun testLoginWithRpcError() = runBlockingTest {
- loginViewModel.updateAccountCacheInstance(mockedAccountCache)
loginViewModel.uiState.test {
skipDefaultItem()
loginViewModel.login(DUMMY_ACCOUNT_TOKEN)
@@ -116,7 +145,6 @@ class LoginViewModelTest {
@Test
fun testLoginWithUnknownError() = runBlockingTest {
- loginViewModel.updateAccountCacheInstance(mockedAccountCache)
loginViewModel.uiState.test {
skipDefaultItem()
loginViewModel.login(DUMMY_ACCOUNT_TOKEN)
@@ -131,17 +159,15 @@ class LoginViewModelTest {
@Test
fun testAccountHistory() = runBlockingTest {
- loginViewModel.updateAccountCacheInstance(mockedAccountCache)
- loginViewModel.accountHistory.test { skipDefaultItem() }
- accountHistoryTestEvents.emit(AccountHistory.Available(DUMMY_ACCOUNT_TOKEN))
loginViewModel.accountHistory.test {
+ skipDefaultItem()
+ accountHistoryTestEvents.emit(AccountHistory.Available(DUMMY_ACCOUNT_TOKEN))
assertEquals(AccountHistory.Available(DUMMY_ACCOUNT_TOKEN), awaitItem())
}
}
@Test
fun testClearingAccountHistory() = runBlockingTest {
- loginViewModel.updateAccountCacheInstance(mockedAccountCache)
loginViewModel.clearAccountHistory()
verify { mockedAccountCache.clearAccountHistory() }
}
diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot
index ef26be8473..f787a66cb3 100644
--- a/gui/locales/messages.pot
+++ b/gui/locales/messages.pot
@@ -1508,6 +1508,9 @@ msgstr ""
msgid "All applications"
msgstr ""
+msgid "Are you sure you want to log out of <b>%s</b>?"
+msgstr ""
+
msgid "Blocking all connections"
msgstr ""
@@ -1541,6 +1544,9 @@ msgstr ""
msgid "Failed to block all network traffic. Please troubleshoot or report the problem to us."
msgstr ""
+msgid "Failed to fetch list of devices"
+msgstr ""
+
msgid "Failed to generate a key"
msgstr ""
@@ -1679,6 +1685,9 @@ msgstr ""
msgid "You are running an unsupported app version. Please upgrade to %s now to ensure your security"
msgstr ""
+msgid "You have too many active devices. Please log out of at least one by removing it from the list below. You can find the corresponding nickname under the device's Account settings."
+msgstr ""
+
msgid "You may need to go back to the app's main screen and click Disconnect before trying again. Don't worry, the information you entered will remain in the form."
msgstr ""
diff --git a/mullvad-jni/src/classes.rs b/mullvad-jni/src/classes.rs
index 2668c6a213..026d7462c5 100644
--- a/mullvad-jni/src/classes.rs
+++ b/mullvad-jni/src/classes.rs
@@ -12,6 +12,7 @@ pub const CLASSES: &[&str] = &[
"net/mullvad/mullvadvpn/model/Device",
"net/mullvad/mullvadvpn/model/DeviceEvent",
"net/mullvad/mullvadvpn/model/DeviceEventCause",
+ "net/mullvad/mullvadvpn/model/DevicePort",
"net/mullvad/mullvadvpn/model/DeviceState$LoggedIn",
"net/mullvad/mullvadvpn/model/DeviceState$LoggedOut",
"net/mullvad/mullvadvpn/model/DeviceState$Revoked",