summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorAlbin <albin@mullvad.net>2022-06-22 12:16:54 +0200
committerAlbin <albin@mullvad.net>2022-06-22 12:16:54 +0200
commit748e5f5d1ea5adf9f82ff891f961d3482fbb4e35 (patch)
tree791c40496f517ef28fc172e59c6fe816469c5e10 /android
parente508a91977d4ccf1390f0e002da4e1fd688fa30c (diff)
parentff737d2e5cafeb4f78b3ed6de12dfa41a1aaae83 (diff)
downloadmullvadvpn-748e5f5d1ea5adf9f82ff891f961d3482fbb4e35.tar.xz
mullvadvpn-748e5f5d1ea5adf9f82ff891f961d3482fbb4e35.zip
Merge branch 'add-android-device-list-ui'
Diffstat (limited to 'android')
-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
41 files changed, 1029 insertions, 193 deletions
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() }
}