diff options
Diffstat (limited to 'android')
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() } } |
