diff options
78 files changed, 2531 insertions, 1158 deletions
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 02cb680b08..46b70c0607 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -206,12 +206,15 @@ dependencies { implementation(Dependencies.AndroidX.lifecycleRuntimeKtx) implementation(Dependencies.AndroidX.lifecycleViewmodelKtx) implementation(Dependencies.AndroidX.recyclerview) + implementation(Dependencies.Compose.composeCollapsingToolbar) implementation(Dependencies.Compose.constrainLayout) implementation(Dependencies.Compose.foundation) implementation(Dependencies.Compose.viewModelLifecycle) implementation(Dependencies.Compose.material) + implementation(Dependencies.Compose.material3) implementation(Dependencies.Compose.uiController) implementation(Dependencies.Compose.ui) + implementation(Dependencies.Compose.uiUtil) implementation(Dependencies.jodaTime) implementation(Dependencies.Koin.core) implementation(Dependencies.Koin.coreExt) diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt index dab5bf0a60..4c94fced01 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ChangelogDialogTest.kt @@ -11,7 +11,7 @@ import io.mockk.just import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import net.mullvad.mullvadvpn.compose.component.AppTheme -import net.mullvad.mullvadvpn.compose.component.ChangelogDialog +import net.mullvad.mullvadvpn.compose.dialog.ChangelogDialog import net.mullvad.mullvadvpn.viewmodel.ChangelogDialogUiState import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel import org.junit.Before diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt new file mode 100644 index 0000000000..cd63483d45 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt @@ -0,0 +1,87 @@ +package net.mullvad.mullvadvpn.compose.cell + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +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.res.dimensionResource +import androidx.compose.ui.unit.Dp +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.theme.MullvadBlue +import net.mullvad.mullvadvpn.compose.theme.MullvadDarkBlue + +@Composable +fun BaseCell( + title: @Composable () -> Unit, + bodyView: @Composable () -> Unit, + modifier: Modifier = Modifier, + onCellClicked: () -> Unit = {}, + subtitle: @Composable (() -> Unit)? = null, + subtitleModifier: Modifier = Modifier, + background: Color = MullvadBlue, + startPadding: Dp = dimensionResource(id = R.dimen.cell_left_padding), + endPadding: Dp = dimensionResource(id = R.dimen.cell_right_padding) +) { + val cellHeight = dimensionResource(id = R.dimen.cell_height) + val cellVerticalSpacing = dimensionResource(id = R.dimen.cell_label_vertical_padding) + val subtitleVerticalSpacing = dimensionResource(id = R.dimen.cell_footer_top_padding) + + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .background(background) + ) { + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = Modifier + .height(cellHeight) + .fillMaxWidth() + .clickable { onCellClicked.invoke() } + .padding(start = startPadding, end = endPadding) + + ) { + title() + + Spacer(modifier = Modifier.weight(1.0f)) + + Column( + modifier = modifier + .wrapContentWidth() + .wrapContentHeight() + ) { + bodyView() + } + } + + if (subtitle != null) { + Row( + modifier = subtitleModifier + .background(MullvadDarkBlue) + .padding( + start = startPadding, + top = subtitleVerticalSpacing, + end = endPadding, + bottom = cellVerticalSpacing + ) + .fillMaxWidth() + .wrapContentHeight() + ) { + subtitle() + } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomDnsComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomDnsComposeCell.kt new file mode 100644 index 0000000000..d5fa79fe09 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomDnsComposeCell.kt @@ -0,0 +1,99 @@ +package net.mullvad.mullvadvpn.compose.cell + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.component.CellSwitch +import net.mullvad.mullvadvpn.compose.theme.MullvadWhite +import net.mullvad.mullvadvpn.compose.theme.MullvadWhite60 + +@Preview +@Composable +private fun PreviewDnsComposeCell() { + CustomDnsComposeCell( + checkboxDefaultState = true, + onToggle = {} + ) +} + +@Composable +fun CustomDnsComposeCell( + checkboxDefaultState: Boolean, + onToggle: (Boolean) -> Unit +) { + val titleModifier = Modifier + val bodyViewModifier = Modifier + val subtitleModifier = Modifier + + BaseCell( + title = { CustomDnsCellTitle(modifier = titleModifier) }, + bodyView = { + CustomDnsCellView( + switchTriggered = { + onToggle(it) + }, + isToggled = checkboxDefaultState, + modifier = bodyViewModifier + ) + }, + onCellClicked = { onToggle(!checkboxDefaultState) }, + subtitleModifier = subtitleModifier + ) +} + +@Composable +fun CustomDnsCellTitle( + modifier: Modifier +) { + val textSize = dimensionResource(id = R.dimen.text_medium_plus).value.sp + Text( + text = stringResource(R.string.enable_custom_dns), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + fontSize = textSize, + color = MullvadWhite, + modifier = modifier + .wrapContentWidth(align = Alignment.End) + .wrapContentHeight() + ) +} + +@Composable +fun CustomDnsCellView( + switchTriggered: (Boolean) -> Unit, + isToggled: Boolean, + modifier: Modifier +) { + Row( + modifier = modifier + .wrapContentWidth() + .wrapContentHeight() + ) { + CellSwitch( + isChecked = isToggled, + onCheckedChange = null + ) + } +} + +@Composable +fun CustomDnsCellSubtitle(modifier: Modifier) { + val textSize = dimensionResource(id = R.dimen.text_small).value.sp + Text( + text = stringResource(R.string.custom_dns_footer), + fontSize = textSize, + color = MullvadWhite60, + modifier = modifier + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt new file mode 100644 index 0000000000..238ecb8d8e --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt @@ -0,0 +1,82 @@ +package net.mullvad.mullvadvpn.compose.cell + +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material.Icon +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.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.theme.MullvadHelmetYellow + +@Preview +@Composable +private fun PreviewDnsCell() { + DnsCell( + address = "0.0.0.0", + isUnreachableLocalDnsWarningVisible = true, + onClick = {} + ) +} + +@Composable +fun DnsCell( + address: String, + isUnreachableLocalDnsWarningVisible: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val titleModifier = Modifier + val startPadding = 54.dp + + BaseCell( + title = { + DnsTitle( + address = address, + modifier = titleModifier + ) + }, + bodyView = { + if (isUnreachableLocalDnsWarningVisible) { + Icon( + painter = painterResource(id = R.drawable.icon_alert), + contentDescription = stringResource(id = R.string.confirm_local_dns), + tint = MullvadHelmetYellow + ) + } + }, + onCellClicked = { onClick.invoke() }, + background = colorResource(id = R.color.blue20), + startPadding = startPadding, + modifier = modifier + ) +} + +@Composable +private fun DnsTitle( + address: String, + modifier: Modifier = Modifier +) { + val textSize = dimensionResource(id = R.dimen.text_medium).value.sp + Text( + text = address, + color = Color.White, + fontSize = textSize, + fontStyle = FontStyle.Normal, + textAlign = TextAlign.Start, + modifier = modifier + .wrapContentWidth(align = Alignment.End) + .wrapContentHeight() + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt new file mode 100644 index 0000000000..5be7f04d3b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt @@ -0,0 +1,98 @@ +package net.mullvad.mullvadvpn.compose.cell + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth as wrapContentWidth1 +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.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.theme.MullvadWhite60 +import net.mullvad.mullvadvpn.constant.MTU_MAX_VALUE +import net.mullvad.mullvadvpn.constant.MTU_MIN_VALUE + +@Preview +@Composable +fun MtuComposeCellPreview() { + MtuComposeCell( + mtuValue = "1300", + onEditMtu = {} + ) +} + +@Composable +fun MtuComposeCell( + mtuValue: String, + onEditMtu: () -> Unit, +) { + val titleModifier = Modifier + val subtitleModifier = Modifier + + BaseCell( + title = { MtuTitle(modifier = titleModifier) }, + bodyView = { + MtuBodyView( + mtuValue = mtuValue, + modifier = titleModifier + ) + }, + subtitle = { MtuSubtitle(subtitleModifier) }, + subtitleModifier = subtitleModifier, + onCellClicked = { + onEditMtu.invoke() + } + ) +} + +@Composable +private fun MtuTitle( + modifier: Modifier +) { + val textSize = dimensionResource(id = R.dimen.text_medium_plus).value.sp + Text( + text = stringResource(R.string.wireguard_mtu), + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + fontSize = textSize, + color = Color.White, + modifier = modifier + .wrapContentWidth1(align = Alignment.End) + .wrapContentHeight() + ) +} + +@Composable +private fun MtuBodyView( + mtuValue: String, + modifier: Modifier +) { + Row( + modifier = modifier + .wrapContentWidth1() + .wrapContentHeight() + ) { + Text( + text = mtuValue.ifEmpty { stringResource(id = R.string.hint_default) }, + color = Color.White + ) + } +} + +@Composable +private fun MtuSubtitle(modifier: Modifier) { + val textSize = dimensionResource(id = R.dimen.text_small).value.sp + Text( + text = stringResource(R.string.wireguard_mtu_footer, MTU_MIN_VALUE, MTU_MAX_VALUE), + fontSize = textSize, + color = MullvadWhite60, + modifier = modifier + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt new file mode 100644 index 0000000000..b4e05ebbd3 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt @@ -0,0 +1,71 @@ +package net.mullvad.mullvadvpn.compose.cell + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +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.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import net.mullvad.mullvadvpn.R + +@Preview +@Composable +private fun PreviewNavigationCell() { + NavigationComposeCell( + title = "Navigation sample", + onClick = {} + ) +} + +@Composable +fun NavigationComposeCell( + title: String, + modifier: Modifier = Modifier, + bodyView: @Composable () -> Unit = { + DefaultNavigationView(chevronContentDescription = title) + }, + onClick: () -> Unit +) { + BaseCell( + onCellClicked = onClick, + title = { NavigationTitleView(title = title, modifier = modifier) }, + bodyView = { + bodyView() + }, + subtitle = null, + ) +} + +@Composable +private fun NavigationTitleView( + title: String, + modifier: Modifier = Modifier +) { + val textMediumSize = dimensionResource(id = R.dimen.text_medium_plus).value.sp + Text( + text = title, + textAlign = TextAlign.Center, + fontWeight = FontWeight.Bold, + fontSize = textMediumSize, + color = Color.White, + modifier = modifier + .wrapContentWidth(align = Alignment.End) + .wrapContentHeight() + ) +} + +@Composable +private fun DefaultNavigationView(chevronContentDescription: String) { + Image( + painter = painterResource(id = R.drawable.icon_chevron), + contentDescription = chevronContentDescription + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CollapsingTopBar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CollapsingTopBar.kt new file mode 100644 index 0000000000..b117f7cbf3 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CollapsingTopBar.kt @@ -0,0 +1,126 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +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.foundation.layout.wrapContentWidth +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.theme.MullvadDarkBlue +import net.mullvad.mullvadvpn.compose.theme.MullvadWhite60 + +@Preview +@Composable +private fun PreviewTopBar() { + CollapsingTopBar( + backgroundColor = MullvadDarkBlue, + onBackClicked = {}, + title = "View title", + progress = 1.0f, + backTitle = "Back", + modifier = Modifier.height(102.dp) + ) +} + +@Composable +fun CollapsingTopBar( + backgroundColor: Color, + onBackClicked: () -> Unit, + title: String, + progress: Float, + backTitle: String, + modifier: Modifier +) { + val expandedToolbarHeight = dimensionResource(id = R.dimen.expanded_toolbar_height) + val iconSize = dimensionResource(id = R.dimen.icon_size) + val iconPadding = dimensionResource(id = R.dimen.small_padding) + val sideMargin = dimensionResource(id = R.dimen.side_margin) + val verticalMargin = dimensionResource(id = R.dimen.cell_label_vertical_padding) + val textSize = dimensionResource(id = R.dimen.text_small).value.sp + val maxTopPadding = 48 + val minTopPadding = 14 + val maxTitleSize = 30 + val minTitleSize = 20 + + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(expandedToolbarHeight) + .background(backgroundColor) + ) + + Button( + modifier = Modifier + .wrapContentWidth() + .wrapContentHeight(), + onClick = onBackClicked, + colors = ButtonDefaults.buttonColors( + contentColor = Color.White, + backgroundColor = MullvadDarkBlue + ) + ) { + Image( + painter = painterResource(id = R.drawable.icon_back), + contentDescription = stringResource(id = R.string.back), + modifier = Modifier + .width(iconSize) + .height(iconSize) + ) + Spacer( + modifier = Modifier + .width(iconPadding) + .fillMaxHeight() + ) + Text( + text = backTitle, + color = MullvadWhite60, + fontWeight = FontWeight.Bold, + fontSize = textSize + ) + } + + Text( + text = title, + style = TextStyle( + color = Color.White, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.End + ), + modifier = modifier + .padding( + start = sideMargin, + end = sideMargin, + top = (minTopPadding + (maxTopPadding - minTopPadding) * progress).dp, + bottom = verticalMargin + ), + fontSize = topBarSize( + progress = progress, + minTitleSize = minTitleSize, + maxTitleSize = maxTitleSize + ).sp + ) +} + +private fun topBarSize(progress: Float, minTitleSize: Int, maxTitleSize: Int): Float { + return (minTitleSize + ((maxTitleSize - minTitleSize) * progress)) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt index 05cd60cba1..c6d32a4df9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt @@ -1,10 +1,25 @@ package net.mullvad.mullvadvpn.compose.component +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import com.google.accompanist.systemuicontroller.rememberSystemUiController +import me.onebone.toolbar.CollapsingToolbarScaffold +import me.onebone.toolbar.CollapsingToolbarScaffoldScope +import me.onebone.toolbar.CollapsingToolbarScaffoldState +import me.onebone.toolbar.CollapsingToolbarScope +import me.onebone.toolbar.ExperimentalToolbarApi +import me.onebone.toolbar.ScrollStrategy @Composable fun ScaffoldWithTopBar( @@ -30,3 +45,48 @@ fun ScaffoldWithTopBar( content = content ) } + +@Composable +@OptIn(ExperimentalToolbarApi::class) +fun CollapsableAwareToolbarScaffold( + modifier: Modifier, + state: CollapsingToolbarScaffoldState, + scrollStrategy: ScrollStrategy, + isEnabledWhenCollapsable: Boolean = true, + toolbarModifier: Modifier = Modifier, + toolbar: @Composable CollapsingToolbarScope.() -> Unit, + body: @Composable CollapsingToolbarScaffoldScope.() -> Unit +) { + var isCollapsable by remember { mutableStateOf(false) } + + LaunchedEffect(isCollapsable) { + if (!isCollapsable) { + state.toolbarState.expand() + } + } + + CollapsingToolbarScaffold( + modifier = modifier, + state = state, + scrollStrategy = scrollStrategy, + enabled = isEnabledWhenCollapsable && isCollapsable, + toolbarModifier = toolbarModifier, + toolbar = toolbar, + body = { + var bodyHeight by remember { mutableStateOf(0) } + + BoxWithConstraints( + modifier = Modifier.onGloballyPositioned { bodyHeight = it.size.height } + ) { + val minMaxToolbarHeightDiff = with(state) { + toolbarState.maxHeight - toolbarState.minHeight + } + val isContentHigherThanCollapseThreshold = with(LocalDensity.current) { + bodyHeight > maxHeight.toPx() - minMaxToolbarHeightDiff + } + isCollapsable = isContentHigherThanCollapseThreshold + body() + } + } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scrollbar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scrollbar.kt new file mode 100644 index 0000000000..1ca651a9a9 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scrollbar.kt @@ -0,0 +1,336 @@ +package net.mullvad.mullvadvpn.compose.component + +/* + * Code snippet taken from https://gist.github.com/mxalbert1996/33a360fcab2105a31e5355af98216f5a + * + * MIT License + * + * Copyright (c) 2022 Albert Chang + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import android.view.ViewConfiguration +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastSumBy +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collectLatest + +fun Modifier.drawHorizontalScrollbar( + state: ScrollState, + reverseScrolling: Boolean = false +): Modifier = drawScrollbar(state, Orientation.Horizontal, reverseScrolling) + +fun Modifier.drawVerticalScrollbar( + state: ScrollState, + reverseScrolling: Boolean = false +): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling) + +private fun Modifier.drawScrollbar( + state: ScrollState, + orientation: Orientation, + reverseScrolling: Boolean +): Modifier = drawScrollbar( + orientation, reverseScrolling +) { reverseDirection, atEnd, color, alpha -> + if (state.maxValue > 0) { + val canvasSize = if (orientation == Orientation.Horizontal) size.width else size.height + val totalSize = canvasSize + state.maxValue + val thumbSize = canvasSize / totalSize * canvasSize + val startOffset = state.value / totalSize * canvasSize + drawScrollbar( + orientation, reverseDirection, atEnd, color, alpha, thumbSize, startOffset + ) + } +} + +fun Modifier.drawHorizontalScrollbar( + state: LazyListState, + reverseScrolling: Boolean = false +): Modifier = drawScrollbar(state, Orientation.Horizontal, reverseScrolling) + +fun Modifier.drawVerticalScrollbar( + state: LazyListState, + reverseScrolling: Boolean = false +): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling) + +private fun Modifier.drawScrollbar( + state: LazyListState, + orientation: Orientation, + reverseScrolling: Boolean +): Modifier = drawScrollbar( + orientation, reverseScrolling +) { reverseDirection, atEnd, color, alpha -> + val layoutInfo = state.layoutInfo + val viewportSize = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset + val items = layoutInfo.visibleItemsInfo + val itemsSize = items.fastSumBy { it.size } + if (items.size < layoutInfo.totalItemsCount || itemsSize > viewportSize) { + val estimatedItemSize = if (items.isEmpty()) 0f else itemsSize.toFloat() / items.size + val totalSize = estimatedItemSize * layoutInfo.totalItemsCount + val canvasSize = if (orientation == Orientation.Horizontal) size.width else size.height + val thumbSize = viewportSize / totalSize * canvasSize + val startOffset = if (items.isEmpty()) 0f else items.first().run { + (estimatedItemSize * index - offset) / totalSize * canvasSize + } + drawScrollbar( + orientation, reverseDirection, atEnd, color, alpha, thumbSize, startOffset + ) + } +} + +fun Modifier.drawVerticalScrollbar( + state: LazyGridState, + spanCount: Int, + reverseScrolling: Boolean = false +): Modifier = drawScrollbar( + Orientation.Vertical, reverseScrolling +) { reverseDirection, atEnd, color, alpha -> + val layoutInfo = state.layoutInfo + val viewportSize = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset + val items = layoutInfo.visibleItemsInfo + val rowCount = (items.size + spanCount - 1) / spanCount + var itemsSize = 0 + for (i in 0 until rowCount) { + itemsSize += items[i * spanCount].size.height + } + if (items.size < layoutInfo.totalItemsCount || itemsSize > viewportSize) { + val estimatedItemSize = if (rowCount == 0) 0f else itemsSize.toFloat() / rowCount + val totalRow = (layoutInfo.totalItemsCount + spanCount - 1) / spanCount + val totalSize = estimatedItemSize * totalRow + val canvasSize = size.height + val thumbSize = viewportSize / totalSize * canvasSize + val startOffset = if (rowCount == 0) 0f else items.first().run { + val rowIndex = index / spanCount + (estimatedItemSize * rowIndex - offset.y) / totalSize * canvasSize + } + drawScrollbar( + Orientation.Vertical, reverseDirection, atEnd, color, alpha, thumbSize, startOffset + ) + } +} + +private fun DrawScope.drawScrollbar( + orientation: Orientation, + reverseDirection: Boolean, + atEnd: Boolean, + color: Color, + alpha: () -> Float, + thumbSize: Float, + startOffset: Float +) { + val thicknessPx = Thickness.toPx() + val topLeft = if (orientation == Orientation.Horizontal) { + Offset( + if (reverseDirection) size.width - startOffset - thumbSize else startOffset, + if (atEnd) size.height - thicknessPx else 0f + ) + } else { + Offset( + if (atEnd) size.width - thicknessPx else 0f, + if (reverseDirection) size.height - startOffset - thumbSize else startOffset + ) + } + val size = if (orientation == Orientation.Horizontal) { + Size(thumbSize, thicknessPx) + } else { + Size(thicknessPx, thumbSize) + } + + drawRect( + color = color, + topLeft = topLeft, + size = size, + alpha = alpha() + ) +} + +private fun Modifier.drawScrollbar( + orientation: Orientation, + reverseScrolling: Boolean, + onDraw: DrawScope.( + reverseDirection: Boolean, + atEnd: Boolean, + color: Color, + alpha: () -> Float + ) -> Unit +): Modifier = composed { + val scrolled = remember { + MutableSharedFlow<Unit>( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + } + val nestedScrollConnection = remember(orientation, scrolled) { + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + val delta = if (orientation == Orientation.Horizontal) consumed.x else consumed.y + if (delta != 0f) scrolled.tryEmit(Unit) + return Offset.Zero + } + } + } + + val alpha = remember { Animatable(0f) } + LaunchedEffect(scrolled, alpha) { + scrolled.collectLatest { + alpha.snapTo(1f) + delay(ViewConfiguration.getScrollDefaultDelay().toLong()) + alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec) + } + } + + val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + val reverseDirection = if (orientation == Orientation.Horizontal) { + if (isLtr) reverseScrolling else !reverseScrolling + } else reverseScrolling + val atEnd = if (orientation == Orientation.Vertical) isLtr else true + + val color = BarColor + + Modifier + .nestedScroll(nestedScrollConnection) + .drawWithContent { + drawContent() + onDraw(reverseDirection, atEnd, color, alpha::value) + } +} + +private val BarColor: Color + @Composable get() = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + +private val Thickness = 4.dp +private val FadeOutAnimationSpec = + tween<Float>(durationMillis = ViewConfiguration.getScrollBarFadeDuration()) + +@Preview(widthDp = 400, heightDp = 400, showBackground = true) +@Composable +internal fun ScrollbarPreview() { + val state = rememberScrollState() + Column( + modifier = Modifier + .drawVerticalScrollbar(state) + .verticalScroll(state), + ) { + repeat(50) { + Text( + text = "Item ${it + 1}", + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) + } + } +} + +@Preview(widthDp = 400, heightDp = 400, showBackground = true) +@Composable +internal fun LazyListScrollbarPreview() { + val state = rememberLazyListState() + LazyColumn( + modifier = Modifier.drawVerticalScrollbar(state), + state = state + ) { + items(50) { + Text( + text = "Item ${it + 1}", + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) + } + } +} + +@Preview(widthDp = 400, showBackground = true) +@Composable +internal fun HorizontalScrollbarPreview() { + val state = rememberScrollState() + Row( + modifier = Modifier + .drawHorizontalScrollbar(state) + .horizontalScroll(state) + ) { + repeat(50) { + Text( + text = (it + 1).toString(), + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 16.dp) + ) + } + } +} + +@Preview(widthDp = 400, showBackground = true) +@Composable +internal fun LazyListHorizontalScrollbarPreview() { + val state = rememberLazyListState() + LazyRow( + modifier = Modifier.drawHorizontalScrollbar(state), + state = state + ) { + items(50) { + Text( + text = (it + 1).toString(), + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 16.dp) + ) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Switch.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Switch.kt new file mode 100644 index 0000000000..b30ca26f22 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Switch.kt @@ -0,0 +1,99 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import net.mullvad.mullvadvpn.compose.theme.MullvadGreen +import net.mullvad.mullvadvpn.compose.theme.MullvadRed +import net.mullvad.mullvadvpn.compose.theme.MullvadWhite + +@Preview +@Composable +private fun PreviewSwitch() { + CellSwitch( + isChecked = false, + onCheckedChange = null + ) +} + +@Composable +fun CellSwitch( + isChecked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier, + scale: Float = 1f, + thumbCheckedTrackColor: Color = MullvadGreen, + thumbUncheckedTrackColor: Color = MullvadRed, + thumbColor: Color = MullvadWhite +) { + val gapBetweenThumbAndTrackEdge: Dp = 2.dp + val width: Dp = 46.dp + val height: Dp = 28.dp + val thumbRadius = 11.dp + + // To move the thumb, we need to calculate the position (along x axis) + val animatePosition = animateFloatAsState( + targetValue = if (isChecked) + with(LocalDensity.current) { + (width - thumbRadius - gapBetweenThumbAndTrackEdge - 1.dp).toPx() + } + else + with(LocalDensity.current) { (thumbRadius + gapBetweenThumbAndTrackEdge + 1.dp).toPx() } + ) + + Canvas( + modifier = modifier + .padding(1.dp) + .size(width = width, height = height) + .scale(scale = scale) + .pointerInput(Unit) { + if (onCheckedChange != null) { + detectTapGestures( + onTap = { + onCheckedChange(!isChecked) + } + ) + } + } + ) { + // Track + drawRoundRect( + color = thumbColor, + cornerRadius = CornerRadius(x = 15.dp.toPx(), y = 15.dp.toPx()), + style = Stroke( + width = 2.dp.toPx(), + miter = 6.dp.toPx(), + cap = StrokeCap.Square, + ), + ) + + // Thumb + drawCircle( + color = if (isChecked) thumbCheckedTrackColor else thumbUncheckedTrackColor, + radius = thumbRadius.toPx(), + center = Offset( + x = animatePosition.value, + y = size.height / 2 + ) + ) + } + + Spacer(modifier = Modifier.height(18.dp)) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt index 55d015a7db..a0a9d4dd90 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/TopBar.kt @@ -23,7 +23,7 @@ import net.mullvad.mullvadvpn.R @Preview @Composable -fun PreviewTopBar() { +private fun PreviewTopBar() { TopBar( backgroundColor = colorResource(R.color.blue), onSettingsClicked = {} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ChangelogDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt index 9f2dabae5f..419f0fc7f7 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/ChangelogDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ChangelogDialog.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.compose.component +package net.mullvad.mullvadvpn.compose.dialog import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.defaultMinSize @@ -21,6 +21,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.component.ChangeListItem @Composable fun ChangelogDialog( 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/dialog/DeviceRemovalDialog.kt index edfcebac67..228487e3cc 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Dialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DeviceRemovalDialog.kt @@ -1,4 +1,4 @@ -package net.mullvad.mullvadvpn.compose.component +package net.mullvad.mullvadvpn.compose.dialog import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Column @@ -25,6 +25,8 @@ 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.compose.component.HtmlText +import net.mullvad.mullvadvpn.compose.component.textResource import net.mullvad.mullvadvpn.model.Device import net.mullvad.mullvadvpn.util.capitalizeFirstCharOfEachWord import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt new file mode 100644 index 0000000000..b70aa2fddc --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DnsDialog.kt @@ -0,0 +1,187 @@ +package net.mullvad.mullvadvpn.compose.dialog + +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.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.textfield.DnsTextField +import net.mullvad.mullvadvpn.compose.theme.MullvadBlue +import net.mullvad.mullvadvpn.compose.theme.MullvadDarkBlue +import net.mullvad.mullvadvpn.compose.theme.MullvadRed +import net.mullvad.mullvadvpn.compose.theme.MullvadWhite +import net.mullvad.mullvadvpn.compose.theme.MullvadWhite20 +import net.mullvad.mullvadvpn.compose.theme.MullvadWhite60 +import net.mullvad.mullvadvpn.viewmodel.StagedDns + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun DnsDialog( + stagedDns: StagedDns, + isAllowLanEnabled: Boolean, + onIpAddressChanged: (String) -> Unit, + onAttemptToSave: () -> Unit, + onRemove: () -> Unit, + onDismiss: () -> Unit +) { + val buttonSize = dimensionResource(id = R.dimen.button_height) + val mediumPadding = dimensionResource(id = R.dimen.medium_padding) + val textMediumSize = dimensionResource(id = R.dimen.text_medium_plus).value.sp + val textFieldFocusRequester = FocusRequester() + + val textSmallSize = dimensionResource(id = R.dimen.text_small).value.sp + val textBigSize = dimensionResource(id = R.dimen.text_big).value.sp + val dialogPadding = 20.dp + val midPadding = 10.dp + val smallPadding = 5.dp + + Dialog( + // Fix for https://issuetracker.google.com/issues/221643630 + properties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest = { + onDismiss() + }, + content = { + Column( + Modifier + // Related to the fix for https://issuetracker.google.com/issues/221643630 + .fillMaxWidth(0.8f) + .background(color = MullvadDarkBlue) + .padding(dialogPadding) + ) { + Text( + text = if (stagedDns is StagedDns.NewDns) { + stringResource(R.string.add_dns_server_dialog_title) + } else { + stringResource(R.string.update_dns_server_dialog_title) + }, + color = Color.White, + fontSize = textBigSize + ) + + Box( + Modifier + .wrapContentSize() + .clickable { textFieldFocusRequester.requestFocus() } + ) { + DnsTextField( + value = stagedDns.item.address, + isValidValue = stagedDns.isValid(), + onValueChanged = { newMtuValue -> + onIpAddressChanged(newMtuValue) + }, + onFocusChanges = {}, + onSubmit = { onAttemptToSave() }, + isEnabled = true, + placeholderText = stringResource(R.string.enter_value_placeholder), + modifier = Modifier + .padding(top = midPadding) + .focusRequester(textFieldFocusRequester) + ) + } + + val errorMessage = when { + stagedDns.validationResult is StagedDns.ValidationResult.DuplicateAddress -> { + stringResource(R.string.duplicate_address_warning) + } + stagedDns.item.isLocal && isAllowLanEnabled.not() -> { + stringResource(id = R.string.confirm_local_dns) + } + else -> { + null + } + } + + if (errorMessage != null) { + Text( + text = errorMessage, + fontSize = textSmallSize, + color = MullvadRed, + modifier = Modifier.padding(top = smallPadding) + ) + } + + Button( + modifier = Modifier + .padding(top = mediumPadding) + .height(buttonSize) + .defaultMinSize(minHeight = buttonSize) + .fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + backgroundColor = MullvadBlue, + contentColor = MullvadWhite, + disabledContentColor = MullvadWhite60, + disabledBackgroundColor = MullvadWhite20 + ), + onClick = { onAttemptToSave() }, + enabled = stagedDns.isValid() + ) { + Text( + text = stringResource(id = R.string.submit_button), + fontSize = textMediumSize + ) + } + + if (stagedDns is StagedDns.EditDns) { + Button( + modifier = Modifier + .padding(top = mediumPadding) + .height(buttonSize) + .defaultMinSize(minHeight = buttonSize) + .fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + backgroundColor = MullvadBlue, + contentColor = MullvadWhite + ), + onClick = { onRemove() } + ) { + Text( + text = stringResource(id = R.string.remove_button), + fontSize = textMediumSize + ) + } + } + + Button( + modifier = Modifier + .padding(top = mediumPadding) + .height(buttonSize) + .defaultMinSize(minHeight = buttonSize) + .fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + backgroundColor = MullvadBlue, + contentColor = Color.White + ), + onClick = { + onDismiss() + } + ) { + Text( + text = stringResource(id = R.string.cancel), + fontSize = textMediumSize + ) + } + } + } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialog.kt new file mode 100644 index 0000000000..e4df8af467 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/MtuDialog.kt @@ -0,0 +1,175 @@ +package net.mullvad.mullvadvpn.compose.dialog + +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.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.textfield.MtuTextField +import net.mullvad.mullvadvpn.compose.theme.MullvadBlue +import net.mullvad.mullvadvpn.compose.theme.MullvadDarkBlue +import net.mullvad.mullvadvpn.compose.theme.MullvadWhite +import net.mullvad.mullvadvpn.compose.theme.MullvadWhite20 +import net.mullvad.mullvadvpn.compose.theme.MullvadWhite60 +import net.mullvad.mullvadvpn.constant.MTU_MAX_VALUE +import net.mullvad.mullvadvpn.constant.MTU_MIN_VALUE +import net.mullvad.mullvadvpn.util.isValidMtu + +@Composable +fun MtuDialog( + mtuValue: String, + onMtuValueChanged: (String) -> Unit, + onSave: () -> Unit, + onRestoreDefaultValue: () -> Unit, + onDismiss: () -> Unit, +) { + val buttonSize = dimensionResource(id = R.dimen.button_height) + val mediumPadding = dimensionResource(id = R.dimen.medium_padding) + val textMediumSize = dimensionResource(id = R.dimen.text_medium_plus).value.sp + val isValidMtu = mtuValue.toIntOrNull()?.isValidMtu() == true + val textFieldFocusRequester = FocusRequester() + + val textSmallSize = dimensionResource(id = R.dimen.text_small).value.sp + val dialogPadding = 10.dp + val smallPadding = 5.dp + + Dialog( + onDismissRequest = { + onDismiss() + }, + content = { + Column( + Modifier + .background(color = MullvadDarkBlue) + .padding(dialogPadding) + ) { + Text( + text = stringResource(id = R.string.wireguard_mtu), + color = Color.White, + fontSize = textMediumSize + ) + + Box( + Modifier + .wrapContentSize() + .clickable { textFieldFocusRequester.requestFocus() } + .padding(top = dialogPadding) + ) { + MtuTextField( + value = mtuValue, + onValueChanged = { newMtuValue -> + onMtuValueChanged(newMtuValue) + }, + onFocusChange = {}, + onSubmit = { newMtuValue -> + if (newMtuValue.toIntOrNull()?.isValidMtu() == true) { + onSave() + } + }, + isEnabled = true, + placeholderText = stringResource(R.string.enter_value_placeholder), + maxCharLength = 4, + isValidValue = isValidMtu, + modifier = Modifier + .focusRequester(textFieldFocusRequester) + ) + } + + Text( + text = stringResource( + id = R.string.wireguard_mtu_footer, + MTU_MIN_VALUE, + MTU_MAX_VALUE + ), + fontSize = textSmallSize, + color = MullvadWhite60, + modifier = Modifier.padding(top = smallPadding) + ) + + Button( + modifier = Modifier + .padding(top = mediumPadding) + .height(buttonSize) + .fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + backgroundColor = MullvadBlue, + contentColor = MullvadWhite, + disabledContentColor = MullvadWhite60, + disabledBackgroundColor = MullvadWhite20 + ), + enabled = isValidMtu, + onClick = { + onSave() + } + ) { + Text( + text = stringResource(R.string.submit_button), + fontSize = textMediumSize + ) + } + + Button( + modifier = Modifier + .padding(top = mediumPadding) + .height(buttonSize) + .defaultMinSize( + minHeight = buttonSize + ) + .fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + backgroundColor = MullvadBlue, + contentColor = MullvadWhite + ), + onClick = { + onRestoreDefaultValue() + } + ) { + Text( + text = stringResource(R.string.reset_to_default_button), + fontSize = textMediumSize + ) + } + + Button( + modifier = Modifier + .padding(top = mediumPadding) + .height(buttonSize) + .defaultMinSize( + minHeight = buttonSize + ) + .fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + backgroundColor = MullvadBlue, + contentColor = Color.White + ), + onClick = { + onDismiss() + } + ) { + Text( + text = stringResource(R.string.cancel), + fontSize = textMediumSize + ) + } + } + } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AdvancedSettingScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AdvancedSettingScreen.kt new file mode 100644 index 0000000000..6d67718f1a --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AdvancedSettingScreen.kt @@ -0,0 +1,234 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +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.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.onebone.toolbar.ScrollStrategy +import me.onebone.toolbar.rememberCollapsingToolbarScaffoldState +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.BaseCell +import net.mullvad.mullvadvpn.compose.cell.CustomDnsCellSubtitle +import net.mullvad.mullvadvpn.compose.cell.CustomDnsComposeCell +import net.mullvad.mullvadvpn.compose.cell.DnsCell +import net.mullvad.mullvadvpn.compose.cell.MtuComposeCell +import net.mullvad.mullvadvpn.compose.cell.NavigationComposeCell +import net.mullvad.mullvadvpn.compose.component.CollapsableAwareToolbarScaffold +import net.mullvad.mullvadvpn.compose.component.CollapsingTopBar +import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar +import net.mullvad.mullvadvpn.compose.dialog.DnsDialog +import net.mullvad.mullvadvpn.compose.dialog.MtuDialog +import net.mullvad.mullvadvpn.compose.state.AdvancedSettingsUiState +import net.mullvad.mullvadvpn.compose.theme.CollapsingToolbarTheme +import net.mullvad.mullvadvpn.compose.theme.MullvadBlue20 +import net.mullvad.mullvadvpn.compose.theme.MullvadDarkBlue +import net.mullvad.mullvadvpn.viewmodel.CustomDnsItem + +@OptIn(ExperimentalMaterialApi::class) +@Preview +@Composable +private fun PreviewAdvancedSettings() { + AdvancedSettingScreen( + uiState = AdvancedSettingsUiState.DefaultUiState( + mtu = "1337", + isCustomDnsEnabled = true, + customDnsItems = listOf( + CustomDnsItem("0.0.0.0", false) + ) + ), + onMtuCellClick = {}, + onMtuInputChange = {}, + onSaveMtuClick = {}, + onRestoreMtuClick = {}, + onCancelMtuDialogClicked = {}, + onSplitTunnelingNavigationClick = {}, + onToggleDnsClick = {}, + onDnsClick = {}, + onDnsInputChange = {}, + onSaveDnsClick = {}, + onRemoveDnsClick = {}, + onCancelDnsDialogClick = {}, + onBackClick = {}, + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@ExperimentalMaterialApi +@Composable +fun AdvancedSettingScreen( + uiState: AdvancedSettingsUiState, + onMtuCellClick: () -> Unit, + onMtuInputChange: (String) -> Unit, + onSaveMtuClick: () -> Unit, + onRestoreMtuClick: () -> Unit, + onCancelMtuDialogClicked: () -> Unit, + onSplitTunnelingNavigationClick: () -> Unit, + onToggleDnsClick: (Boolean) -> Unit, + onDnsClick: (index: Int?) -> Unit, + onDnsInputChange: (String) -> Unit, + onSaveDnsClick: () -> Unit, + onRemoveDnsClick: () -> Unit, + onCancelDnsDialogClick: () -> Unit, + onBackClick: () -> Unit +) { + val cellVerticalSpacing = dimensionResource(id = R.dimen.cell_label_vertical_padding) + val cellHorizontalSpacing = dimensionResource(id = R.dimen.cell_left_padding) + + when (uiState) { + is AdvancedSettingsUiState.MtuDialogUiState -> { + MtuDialog( + mtuValue = uiState.mtuEditValue, + onMtuValueChanged = { onMtuInputChange(it) }, + onSave = { onSaveMtuClick() }, + onRestoreDefaultValue = { onRestoreMtuClick() }, + onDismiss = { onCancelMtuDialogClicked() } + ) + } + is AdvancedSettingsUiState.DnsDialogUiState -> { + DnsDialog( + stagedDns = uiState.stagedDns, + isAllowLanEnabled = uiState.isAllowLanEnabled, + onIpAddressChanged = { onDnsInputChange(it) }, + onAttemptToSave = { onSaveDnsClick() }, + onRemove = { onRemoveDnsClick() }, + onDismiss = { onCancelDnsDialogClick() }, + ) + } + else -> { + // NOOP + } + } + + val lazyListState = rememberLazyListState() + val biggerPadding = 54.dp + val topPadding = 6.dp + + CollapsingToolbarTheme { + + val state = rememberCollapsingToolbarScaffoldState() + val progress = state.toolbarState.progress + + CollapsableAwareToolbarScaffold( + modifier = Modifier + .background(MullvadDarkBlue) + .fillMaxSize(), + state = state, + scrollStrategy = ScrollStrategy.ExitUntilCollapsed, + isEnabledWhenCollapsable = true, + toolbar = { + val scaffoldModifier = Modifier + .road( + whenCollapsed = Alignment.TopCenter, + whenExpanded = Alignment.BottomStart + ) + CollapsingTopBar( + backgroundColor = MullvadDarkBlue, + onBackClicked = { + onBackClick() + }, + title = stringResource(id = R.string.settings_advanced), + progress = progress, + modifier = scaffoldModifier, + backTitle = stringResource(id = R.string.settings), + ) + } + ) { + LazyColumn( + modifier = Modifier + .drawVerticalScrollbar(lazyListState) + .fillMaxWidth() + .wrapContentHeight() + .animateContentSize(), + state = lazyListState + + ) { + item { + MtuComposeCell( + mtuValue = uiState.mtu, + onEditMtu = { onMtuCellClick() } + ) + } + + item { + NavigationComposeCell( + title = stringResource(id = R.string.split_tunneling), + onClick = { + onSplitTunnelingNavigationClick.invoke() + } + ) + Divider() + } + + item { + CustomDnsComposeCell( + checkboxDefaultState = uiState.isCustomDnsEnabled, + onToggle = { newValue -> + onToggleDnsClick(newValue) + } + ) + Divider() + } + + if (uiState.isCustomDnsEnabled) { + itemsIndexed(uiState.customDnsItems) { index, item -> + DnsCell( + address = item.address, + isUnreachableLocalDnsWarningVisible = item.isLocal && + uiState.isAllowLanEnabled.not(), + onClick = { onDnsClick(index) }, + modifier = Modifier.animateItemPlacement(), + ) + Divider() + } + + item { + BaseCell( + onCellClicked = { onDnsClick(null) }, + title = { + Text( + text = stringResource(id = R.string.add_a_server), + color = Color.White + ) + }, + bodyView = { }, + subtitle = null, + background = MullvadBlue20, + startPadding = biggerPadding + ) + Divider() + } + } + + item { + CustomDnsCellSubtitle( + Modifier + .background(MullvadDarkBlue) + .padding( + start = cellHorizontalSpacing, + top = topPadding, + end = cellHorizontalSpacing, + bottom = cellVerticalSpacing + ) + ) + } + } + } + } +} 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 index 99218a8837..d0fb48348e 100644 --- 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 @@ -30,7 +30,7 @@ import androidx.constraintlayout.compose.Dimension import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.ActionButton import net.mullvad.mullvadvpn.compose.component.ListItem -import net.mullvad.mullvadvpn.compose.component.ShowDeviceRemovalDialog +import net.mullvad.mullvadvpn.compose.dialog.ShowDeviceRemovalDialog import net.mullvad.mullvadvpn.util.capitalizeFirstCharOfEachWord import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/AdvancedSettingsUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/AdvancedSettingsUiState.kt new file mode 100644 index 0000000000..ce554115d2 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/AdvancedSettingsUiState.kt @@ -0,0 +1,34 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.viewmodel.CustomDnsItem +import net.mullvad.mullvadvpn.viewmodel.StagedDns + +sealed interface AdvancedSettingsUiState { + val mtu: String + val isCustomDnsEnabled: Boolean + val customDnsItems: List<CustomDnsItem> + val isAllowLanEnabled: Boolean + + data class DefaultUiState( + override val mtu: String = "", + override val isCustomDnsEnabled: Boolean = false, + override val isAllowLanEnabled: Boolean = false, + override val customDnsItems: List<CustomDnsItem> = listOf() + ) : AdvancedSettingsUiState + + data class MtuDialogUiState( + override val mtu: String, + override val isCustomDnsEnabled: Boolean, + override val isAllowLanEnabled: Boolean, + override val customDnsItems: List<CustomDnsItem>, + val mtuEditValue: String + ) : AdvancedSettingsUiState + + data class DnsDialogUiState( + override val mtu: String, + override val isCustomDnsEnabled: Boolean, + override val isAllowLanEnabled: Boolean, + override val customDnsItems: List<CustomDnsItem>, + val stagedDns: StagedDns, + ) : AdvancedSettingsUiState +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt new file mode 100644 index 0000000000..3a263a6886 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt @@ -0,0 +1,172 @@ +package net.mullvad.mullvadvpn.compose.textfield + +import android.text.TextUtils +import android.view.KeyEvent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.theme.MullvadBlue +import net.mullvad.mullvadvpn.compose.theme.MullvadWhite10 + +private const val EMPTY_STRING = "" +private const val NEWLINE_STRING = "\n" + +@Composable +@OptIn(ExperimentalComposeUiApi::class) +fun CustomTextField( + value: String, + modifier: Modifier = Modifier, + onValueChanged: (String) -> Unit, + onFocusChange: (Boolean) -> Unit, + onSubmit: (String) -> Unit, + isEnabled: Boolean = true, + placeholderText: String = "", + placeHolderColor: Color = MullvadBlue, + maxCharLength: Int = Int.MAX_VALUE, + isValidValue: Boolean, + isDigitsOnlyAllowed: Boolean, + defaultTextColor: Color = Color.White, + textAlign: TextAlign = TextAlign.Start +) { + val fontSize = dimensionResource(id = R.dimen.text_medium_plus).value.sp + val shape = RoundedCornerShape(4.dp) + val textFieldHeight = 44.dp + + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + var isFocused by remember { mutableStateOf(false) } + + val textColor = when { + isValidValue.not() -> Color.Red + isFocused -> MullvadBlue + else -> defaultTextColor + } + + val placeholderTextColor = if (isFocused) { + placeHolderColor + } else { + Color.White + } + + val backgroundColor = if (isFocused) { + Color.White + } else { + MullvadWhite10 + } + + fun triggerSubmit() { + keyboardController?.hide() + focusManager.moveFocus(FocusDirection.Previous) + onSubmit(value) + } + + BasicTextField( + value = value, + onValueChange = { input -> + val isValidInput = if (isDigitsOnlyAllowed) TextUtils.isDigitsOnly(input) else true + if (input.length <= maxCharLength && isValidInput) { + // Remove any newline chars added by enter key clicks + onValueChanged(input.replace(NEWLINE_STRING, EMPTY_STRING)) + } + }, + textStyle = TextStyle( + color = textColor, + fontSize = fontSize, + textAlign = textAlign + ), + enabled = isEnabled, + singleLine = true, + maxLines = 1, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + autoCorrect = false, + ), + keyboardActions = KeyboardActions( + onDone = { triggerSubmit() } + ), + decorationBox = { decorationBox -> + Box( + modifier = Modifier + .padding(PaddingValues(12.dp, 10.dp)) + .fillMaxWidth() + ) { + if (value.isBlank()) { + Text( + text = placeholderText, + color = placeholderTextColor, + fontSize = fontSize, + textAlign = textAlign, + modifier = Modifier.fillMaxWidth() + ) + } + decorationBox() + } + }, + cursorBrush = SolidColor(MullvadBlue), + modifier = modifier + .background(backgroundColor) + .clip(shape) + .onFocusChanged { focusState -> + isFocused = focusState.isFocused + onFocusChange(focusState.isFocused) + } + .height(textFieldHeight) + .onKeyEvent { keyEvent -> + return@onKeyEvent when (keyEvent.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_ENTER -> { + triggerSubmit() + true + } + KeyEvent.KEYCODE_ESCAPE -> { + focusManager.clearFocus(force = true) + keyboardController?.hide() + true + } + KeyEvent.KEYCODE_DPAD_DOWN -> { + focusManager.moveFocus(FocusDirection.Down) + true + } + KeyEvent.KEYCODE_DPAD_UP -> { + focusManager.moveFocus(FocusDirection.Up) + true + } + else -> { + false + } + } + } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/DnsTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/DnsTextField.kt new file mode 100644 index 0000000000..198bb59159 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/DnsTextField.kt @@ -0,0 +1,31 @@ +package net.mullvad.mullvadvpn.compose.textfield + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign + +@Composable +fun DnsTextField( + value: String, + isValidValue: Boolean, + modifier: Modifier = Modifier, + onValueChanged: (String) -> Unit = { }, + onFocusChanges: (Boolean) -> Unit = { }, + onSubmit: (String) -> Unit = { }, + placeholderText: String = "", + isEnabled: Boolean = true +) { + CustomTextField( + value = value, + modifier = modifier, + onValueChanged = onValueChanged, + onFocusChange = onFocusChanges, + onSubmit = onSubmit, + isEnabled = isEnabled, + placeholderText = placeholderText, + maxCharLength = Int.MAX_VALUE, + isValidValue = isValidValue, + isDigitsOnlyAllowed = false, + textAlign = TextAlign.Start + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/MtuTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/MtuTextField.kt new file mode 100644 index 0000000000..c44c16911c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/MtuTextField.kt @@ -0,0 +1,30 @@ +package net.mullvad.mullvadvpn.compose.textfield + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun MtuTextField( + value: String, + isValidValue: Boolean, + modifier: Modifier = Modifier, + onValueChanged: (String) -> Unit = { }, + onFocusChange: (Boolean) -> Unit = { }, + onSubmit: (String) -> Unit = { }, + isEnabled: Boolean = true, + placeholderText: String = "", + maxCharLength: Int +) { + CustomTextField( + value = value, + modifier = modifier, + onValueChanged = onValueChanged, + onFocusChange = onFocusChange, + onSubmit = onSubmit, + isEnabled = isEnabled, + placeholderText = placeholderText, + maxCharLength = maxCharLength, + isValidValue = isValidValue, + isDigitsOnlyAllowed = true + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Color.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Color.kt new file mode 100644 index 0000000000..2c542951ab --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Color.kt @@ -0,0 +1,19 @@ +package net.mullvad.mullvadvpn.compose.theme + +import androidx.compose.ui.graphics.Color + +val MullvadBeige = Color(0xFFFFCD86) +val MullvadBlue = Color(0xFF294D73) +val MullvadBlue60 = Color(0x99294D73) +val MullvadBlue20 = Color(0x33294D73) +val MullvadBrown = Color(0xFFD2943B) +val MullvadDarkBlue = Color(0xFF192E45) +val MullvadGreen = Color(0xFF44AD4D) +val MullvadRed = Color(0xFFE34039) +val MullvadYellow = Color(0xFFFFD524) +val MullvadHelmetYellow = Color(0xFFFFD524) +val MullvadWhite = Color(0xFFFFFFFF) +val MullvadWhite10 = Color(0x1AFFFFFF) +val MullvadWhite20 = Color(0x33FFFFFF) +val MullvadWhite40 = Color(0x66FFFFFF) +val MullvadWhite60 = Color(0x99FFFFFF) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt new file mode 100644 index 0000000000..95e6d312a0 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/theme/Theme.kt @@ -0,0 +1,33 @@ +package net.mullvad.mullvadvpn.compose.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Shapes +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp + +private val MullvadColorPalette = lightColors( + primary = MullvadBlue, + primaryVariant = MullvadDarkBlue, + secondary = MullvadRed +) + +val Shapes = Shapes( + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(4.dp), + large = RoundedCornerShape(0.dp) +) + +@Composable +fun CollapsingToolbarTheme( + content: @Composable () -> Unit +) { + val colors = MullvadColorPalette + + MaterialTheme( + colors = colors, + shapes = Shapes, + content = content + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/MtuConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/MtuConstant.kt new file mode 100644 index 0000000000..b6d04b87b5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/MtuConstant.kt @@ -0,0 +1,4 @@ +package net.mullvad.mullvadvpn.constant + +const val MTU_MIN_VALUE = 1280 +const val MTU_MAX_VALUE = 1420 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 bbe12baf2e..4a95b2046c 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 @@ -13,6 +13,7 @@ import net.mullvad.mullvadvpn.repository.AccountRepository import net.mullvad.mullvadvpn.repository.ChangelogRepository import net.mullvad.mullvadvpn.repository.DeviceRepository import net.mullvad.mullvadvpn.repository.PrivacyDisclaimerRepository +import net.mullvad.mullvadvpn.repository.SettingsRepository import net.mullvad.mullvadvpn.ui.notification.AccountExpiryNotification import net.mullvad.mullvadvpn.ui.notification.TunnelStateNotification import net.mullvad.mullvadvpn.ui.notification.VersionInfoNotification @@ -20,6 +21,7 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.SplitTunneling import net.mullvad.mullvadvpn.util.ChangelogDataProvider import net.mullvad.mullvadvpn.util.IChangelogDataProvider +import net.mullvad.mullvadvpn.viewmodel.AdvancedSettingsViewModel import net.mullvad.mullvadvpn.viewmodel.ChangelogViewModel import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel @@ -27,6 +29,7 @@ import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel import net.mullvad.mullvadvpn.viewmodel.LoginViewModel import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel +import org.apache.commons.validator.routines.InetAddressValidator import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidContext import org.koin.androidx.viewmodel.dsl.viewModel @@ -56,6 +59,7 @@ val uiModule = module { } single { ServiceConnectionManager(androidContext()) } + single { InetAddressValidator.getInstance() } single { androidContext().resources } single { androidContext().assets } @@ -75,6 +79,7 @@ val uiModule = module { ) ) } + single { SettingsRepository(get()) } single<IChangelogDataProvider> { ChangelogDataProvider(get()) } @@ -91,6 +96,12 @@ val uiModule = module { ) } viewModel { PrivacyDisclaimerViewModel(get()) } + viewModel { + AdvancedSettingsViewModel( + repository = get(), + inetAddressValidator = get() + ) + } } const val APPS_SCOPE = "APPS_SCOPE" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt index 926c3543d3..59d42cf476 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/SettingsRepository.kt @@ -1,13 +1,58 @@ package net.mullvad.mullvadvpn.repository +import java.net.InetAddress +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.model.CustomDnsOptions +import net.mullvad.mullvadvpn.model.DefaultDnsOptions import net.mullvad.mullvadvpn.model.DnsOptions +import net.mullvad.mullvadvpn.model.DnsState +import net.mullvad.mullvadvpn.model.Settings import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.customDns +import net.mullvad.mullvadvpn.ui.serviceconnection.settingsListener +import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier +import net.mullvad.mullvadvpn.util.flatMapReadyConnectionOrDefault class SettingsRepository( - private val serviceConnectionManager: ServiceConnectionManager + private val serviceConnectionManager: ServiceConnectionManager, + dispatcher: CoroutineDispatcher = Dispatchers.IO ) { - fun setDnsOptions(dnsOptions: DnsOptions) { - serviceConnectionManager.customDns()?.setDnsOptions(dnsOptions) + val settingsUpdates: StateFlow<Settings?> = serviceConnectionManager.connectionState + .flatMapReadyConnectionOrDefault(flowOf()) { state -> + callbackFlowFromNotifier(state.container.settingsListener.settingsNotifier) + } + .onStart { serviceConnectionManager.settingsListener()?.settingsNotifier?.latestEvent } + .stateIn( + CoroutineScope(dispatcher), + SharingStarted.WhileSubscribed(), + null + ) + + fun setDnsOptions( + isCustomDnsEnabled: Boolean, + dnsList: List<InetAddress> + ) { + serviceConnectionManager.customDns()?.setDnsOptions( + dnsOptions = DnsOptions( + state = if (isCustomDnsEnabled) DnsState.Custom else DnsState.Default, + customOptions = CustomDnsOptions(ArrayList(dnsList)), + defaultOptions = DefaultDnsOptions() + ) + ) + } + + fun isLocalNetworkSharingEnabled(): Boolean { + return serviceConnectionManager.settingsListener()?.allowLan ?: false + } + + fun setWireguardMtu(value: Int?) { + serviceConnectionManager.settingsListener()?.wireguardMtu = value } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt index 10a4f2b5d7..8abb712ff8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt @@ -4,268 +4,59 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.LinearLayoutManager -import java.net.InetAddress -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.shareIn -import kotlinx.coroutines.launch +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.model.Settings -import net.mullvad.mullvadvpn.ui.customdns.CustomDnsAdapter -import net.mullvad.mullvadvpn.ui.extension.requireMainActivity +import net.mullvad.mullvadvpn.compose.screen.AdvancedSettingScreen import net.mullvad.mullvadvpn.ui.fragment.BaseFragment -import net.mullvad.mullvadvpn.ui.fragment.ConfirmDnsDialogFragment import net.mullvad.mullvadvpn.ui.fragment.SplitTunnelingFragment -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState -import net.mullvad.mullvadvpn.ui.serviceconnection.customDns -import net.mullvad.mullvadvpn.ui.serviceconnection.settingsListener -import net.mullvad.mullvadvpn.ui.widget.CellSwitch -import net.mullvad.mullvadvpn.ui.widget.CustomRecyclerView -import net.mullvad.mullvadvpn.ui.widget.MtuCell -import net.mullvad.mullvadvpn.ui.widget.NavigateCell -import net.mullvad.mullvadvpn.ui.widget.ToggleCell -import net.mullvad.mullvadvpn.util.AdapterWithHeader -import net.mullvad.mullvadvpn.util.JobTracker -import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier -import org.koin.android.ext.android.inject +import net.mullvad.mullvadvpn.viewmodel.AdvancedSettingsViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel -// TODO: Move as part of refactoring to compose. class AdvancedFragment : BaseFragment() { + private val vm by viewModel<AdvancedSettingsViewModel>() - // Injected dependencies - private val serviceConnectionManager: ServiceConnectionManager by inject() - - private var isAllowLanEnabled = false - - // Both customDnsAdapter and customDnsToggle are nullable since onNewServiceConnection, - // which sets up custom dns subscriptions, is called before onSafelyCreateView. - private var customDnsAdapter: CustomDnsAdapter? = null - private var customDnsToggle: ToggleCell? = null - - private lateinit var wireguardMtuInput: MtuCell - private lateinit var titleController: CollapsibleTitleController - - @Deprecated("Refactor code to instead rely on Lifecycle.") - private val jobTracker = JobTracker() - - val shared = serviceConnectionManager.connectionState - .flatMapLatest { state -> - if (state is ServiceConnectionState.ConnectedReady) { - flowOf(state.container) - } else { - emptyFlow() - } - } - .map { - it.customDns - } - .shareIn(lifecycleScope, SharingStarted.WhileSubscribed()) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.RESUMED) { - launch { - serviceConnectionManager.connectionState - .flatMapLatest { state -> - if (state is ServiceConnectionState.ConnectedReady) { - flowOf(state.container) - } else { - emptyFlow() - } - } - .flatMapLatest { - callbackFlowFromNotifier(it.settingsListener.settingsNotifier) - } - .collect { settings -> - if (settings != null) { - updateUi(settings) - } - } - } - - launch { - shared - .flatMapLatest { - callbackFlowFromNotifier(it.onEnabledChanged) - } - .collect { isEnabled -> - customDnsAdapter?.updateState(isEnabled) - jobTracker.newUiJob("updateEnabled") { - if (isEnabled) { - customDnsToggle?.state = CellSwitch.State.ON - } else { - customDnsToggle?.state = CellSwitch.State.OFF - } - } - } - } - - launch { - shared - .flatMapLatest { - callbackFlowFromNotifier(it.onDnsServersChanged) - } - .collect { servers -> - customDnsAdapter?.updateServers(servers) - } - } - } - } - } - + @OptIn(ExperimentalMaterialApi::class) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - val view = inflater.inflate(R.layout.advanced, container, false) - - view.findViewById<View>(R.id.back).setOnClickListener { - customDnsAdapter?.stopEditing() - requireActivity().onBackPressed() - } - - titleController = CollapsibleTitleController(view, R.id.contents) - - customDnsAdapter = CustomDnsAdapter( - onAddServer = { address -> - serviceConnectionManager.customDns()?.addDnsServer(address) ?: false - }, - onRemoveDnsServer = { address -> - serviceConnectionManager.customDns()?.removeDnsServer(address) ?: false - }, - onSetCustomDnsEnabled = { isEnabled -> - if (isEnabled) { - serviceConnectionManager.customDns()?.enable() - } else { - serviceConnectionManager.customDns()?.disable() - } - }, - onReplaceDnsServer = { oldServer, newServer -> - serviceConnectionManager.customDns()?.replaceDnsServer( - oldServer, - newServer - ) ?: false - } - ).also { newCustomDnsAdapter -> - - newCustomDnsAdapter.confirmAddAddress = ::confirmAddAddress - - view.findViewById<CustomRecyclerView>(R.id.contents).apply { - layoutManager = LinearLayoutManager(requireContext()) - - adapter = AdapterWithHeader(newCustomDnsAdapter, R.layout.advanced_header).apply { - onHeaderAvailable = { headerView -> - configureHeader(headerView) - titleController.expandedTitleView = - headerView.findViewById(R.id.expanded_title) - } - } - - addItemDecoration( - ListItemDividerDecoration( - topOffset = resources.getDimensionPixelSize(R.dimen.list_item_divider) - ) + return inflater.inflate(R.layout.fragment_compose, container, false).apply { + findViewById<ComposeView>(R.id.compose_view).setContent { + val state = vm.uiState.collectAsState().value + AdvancedSettingScreen( + uiState = state, + onMtuCellClick = vm::onMtuCellClick, + onMtuInputChange = vm::onMtuInputChange, + onSaveMtuClick = vm::onSaveMtuClick, + onRestoreMtuClick = vm::onRestoreMtuClick, + onCancelMtuDialogClicked = vm::onCancelDialogClick, + onSplitTunnelingNavigationClick = ::openSplitTunnelingFragment, + onToggleDnsClick = vm::onToggleDnsClick, + onDnsClick = vm::onDnsClick, + onDnsInputChange = vm::onDnsInputChange, + onSaveDnsClick = vm::onSaveDnsClick, + onRemoveDnsClick = vm::onRemoveDnsClick, + onCancelDnsDialogClick = vm::onCancelDialogClick, + onBackClick = { activity?.onBackPressed() } ) } } - - attachBackButtonHandler() - - return view - } - - override fun onDestroyView() { - detachBackButtonHandler() - customDnsAdapter?.onDestroy() - titleController.onDestroy() - super.onDestroyView() - } - - private fun configureHeader(view: View) { - wireguardMtuInput = view.findViewById<MtuCell>(R.id.wireguard_mtu).apply { - onSubmit = { mtu -> - serviceConnectionManager.settingsListener()?.wireguardMtu = mtu - } - value = serviceConnectionManager.settingsListener()?.let { settingsNotifier -> - settingsNotifier.wireguardMtu - } - } - - view.findViewById<NavigateCell>(R.id.split_tunneling).apply { - targetFragment = SplitTunnelingFragment::class - } - - customDnsToggle = view.findViewById<ToggleCell>(R.id.enable_custom_dns).apply { - state = serviceConnectionManager.customDns().let { customDns -> - if (customDns?.isCustomDnsEnabled() == true) { - CellSwitch.State.ON - } else { - CellSwitch.State.OFF - } - } - - listener = { state -> - jobTracker.newBackgroundJob("toggleCustomDns") { - if (state == CellSwitch.State.ON) { - serviceConnectionManager.customDns()?.enable() - } else { - serviceConnectionManager.customDns()?.disable() - } - } - } - } - } - - private fun updateUi(settings: Settings) { - if (this::wireguardMtuInput.isInitialized && wireguardMtuInput.hasFocus == false) { - wireguardMtuInput.value = settings.tunnelOptions.wireguard.mtu - } - } - - private suspend fun confirmAddAddress(address: InetAddress): Boolean { - val isLocalAddress = address.isLinkLocalAddress() || address.isSiteLocalAddress() - - return !isLocalAddress || isAllowLanEnabled || showConfirmDnsServerDialog() - } - - private suspend fun showConfirmDnsServerDialog(): Boolean { - val confirmation = CompletableDeferred<Boolean>() - val transaction = parentFragmentManager.beginTransaction() - - detachBackButtonHandler() - transaction.addToBackStack(null) - - ConfirmDnsDialogFragment(confirmation) - .show(transaction, null) - - val result = confirmation.await() - - attachBackButtonHandler() - - return result } - private fun attachBackButtonHandler() { - requireMainActivity().backButtonHandler = { - if (customDnsAdapter?.isEditing == true) { - customDnsAdapter?.stopEditing() - } - false + private fun openSplitTunnelingFragment() { + 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, SplitTunnelingFragment()) + addToBackStack(null) + commitAllowingStateLoss() } } - - private fun detachBackButtonHandler() { - requireMainActivity().backButtonHandler = null - } } 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 7401ed9f68..a0fa0e613e 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 @@ -30,7 +30,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull import net.mullvad.mullvadvpn.BuildConfig import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.component.ChangelogDialog +import net.mullvad.mullvadvpn.compose.dialog.ChangelogDialog import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport import net.mullvad.mullvadvpn.di.uiModule import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/AddCustomDnsServerHolder.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/AddCustomDnsServerHolder.kt deleted file mode 100644 index 1d0f940d4b..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/AddCustomDnsServerHolder.kt +++ /dev/null @@ -1,16 +0,0 @@ -package net.mullvad.mullvadvpn.ui.customdns - -import android.view.View -import net.mullvad.mullvadvpn.R - -class AddCustomDnsServerHolder(view: View, adapter: CustomDnsAdapter) : CustomDnsItemHolder(view) { - init { - view.findViewById<View>(R.id.add).setOnClickListener { - adapter.newDnsServer() - } - - view.findViewById<View>(R.id.click_area).setOnClickListener { - adapter.newDnsServer() - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsAdapter.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsAdapter.kt deleted file mode 100644 index 1d44ca3a50..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsAdapter.kt +++ /dev/null @@ -1,302 +0,0 @@ -package net.mullvad.mullvadvpn.ui.customdns - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView.Adapter -import java.net.InetAddress -import kotlin.properties.Delegates.observable -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.util.JobTracker -import org.apache.commons.validator.routines.InetAddressValidator - -class CustomDnsAdapter( - val onSetCustomDnsEnabled: (Boolean) -> Unit, - val onAddServer: (InetAddress) -> Boolean, - val onRemoveDnsServer: (InetAddress) -> Unit, - val onReplaceDnsServer: (InetAddress, InetAddress) -> Boolean -) : Adapter<CustomDnsItemHolder>() { - private enum class ViewTypes { - ADD_SERVER, - EDIT_SERVER, - SHOW_SERVER, - FOOTER, - } - - private val customDnsServersLock = Mutex() - private val inetAddressValidator = InetAddressValidator.getInstance() - private val jobTracker = JobTracker() - - private var editingPosition: Int? = null - - private var activeCustomDnsServers by observable<List<InetAddress>>( - emptyList() - ) { _, _, servers -> - if (servers != cachedCustomDnsServers) { - cachedCustomDnsServers = servers.toMutableList() - notifyDataSetChanged() - } - } - - private var cachedCustomDnsServers = emptyList<InetAddress>().toMutableList() - - private var enabled by observable(false) { _, oldValue, newValue -> - if (oldValue != newValue) { - if (newValue == true) { - notifyItemRangeInserted(0, cachedCustomDnsServers.size + 1) - } else { - notifyItemRangeRemoved(0, cachedCustomDnsServers.size + 1) - editingPosition = null - } - } - } - - val isEditing - get() = editingPosition != null - - // By default, refuse the address so that the dialog can be recreated by the user if needed - var confirmAddAddress: suspend (InetAddress) -> Boolean = { false } - - fun updateServers(servers: List<InetAddress>) { - jobTracker.newBackgroundJob("toggleCustomDns") { - if (servers.isEmpty()) { - onSetCustomDnsEnabled(false) - } - } - - jobTracker.newUiJob("updateDnsServers") { - customDnsServersLock.withLock { - activeCustomDnsServers = servers - } - } - } - - fun updateState(isEnabled: Boolean) { - jobTracker.newUiJob("updateEnabled") { - customDnsServersLock.withLock { - enabled = isEnabled - } - } - } - - override fun getItemCount() = - if (enabled) { - cachedCustomDnsServers.size + 2 - } else { - 1 - } - - override fun getItemViewType(position: Int): Int { - val count = getItemCount() - val footer = count - 1 - val addServer = count - 2 - - if (position == footer) { - return ViewTypes.FOOTER.ordinal - } else if (position == editingPosition) { - return ViewTypes.EDIT_SERVER.ordinal - } else if (position == addServer) { - return ViewTypes.ADD_SERVER.ordinal - } else { - return ViewTypes.SHOW_SERVER.ordinal - } - } - - override fun onCreateViewHolder(parentView: ViewGroup, type: Int): CustomDnsItemHolder { - val inflater = LayoutInflater.from(parentView.context) - when (ViewTypes.values()[type]) { - ViewTypes.FOOTER -> { - val view = inflater.inflate(R.layout.custom_dns_footer, parentView, false) - return CustomDnsFooterHolder(view) - } - ViewTypes.ADD_SERVER -> { - val view = inflater.inflate(R.layout.add_custom_dns_server, parentView, false) - return AddCustomDnsServerHolder(view, this) - } - ViewTypes.EDIT_SERVER -> { - val view = inflater.inflate(R.layout.edit_custom_dns_server, parentView, false) - return EditCustomDnsServerHolder(view, this) - } - ViewTypes.SHOW_SERVER -> { - val view = inflater.inflate(R.layout.custom_dns_server, parentView, false) - return CustomDnsServerHolder(view, this) - } - } - } - - override fun onBindViewHolder(holder: CustomDnsItemHolder, position: Int) { - if (holder is CustomDnsServerHolder) { - holder.serverAddress = cachedCustomDnsServers[position] - } else if (holder is EditCustomDnsServerHolder) { - if (position >= cachedCustomDnsServers.size) { - holder.serverAddress = null - } else { - holder.serverAddress = cachedCustomDnsServers[position] - } - } - } - - fun onDestroy() { - jobTracker.newBackgroundJob("toggleCustomDns") { - if (cachedCustomDnsServers.isEmpty()) { - onSetCustomDnsEnabled(false) - } - } - } - - fun newDnsServer() { - jobTracker.newUiJob("newDnsServer") { - customDnsServersLock.withLock { - if (enabled) { - val count = getItemCount() - - editDnsServerAt(count - 2) - } - } - } - } - - fun saveDnsServer(address: String, errorCallback: () -> Unit) { - jobTracker.newUiJob("saveDnsServer $address") { - customDnsServersLock.withLock { - editingPosition?.let { position -> - var validAddress: Boolean - - if (position >= cachedCustomDnsServers.size) { - validAddress = addDnsServer(address) - } else { - validAddress = replaceDnsServer(address, position) - } - - if (!validAddress) { - errorCallback() - } - } - } - } - } - - fun editDnsServer(address: InetAddress) { - jobTracker.newUiJob("editDnsServer $address") { - customDnsServersLock.withLock { - if (enabled) { - val position = cachedCustomDnsServers.indexOf(address) - - editDnsServerAt(position) - } - } - } - } - - fun stopEditing() { - jobTracker.newUiJob("stopEditing") { - customDnsServersLock.withLock { - if (enabled) { - editDnsServerAt(null) - } - } - } - } - - fun stopEditing(address: InetAddress) { - jobTracker.newUiJob("stopEditing $address") { - customDnsServersLock.withLock { - if (enabled) { - editingPosition?.let { position -> - if (cachedCustomDnsServers.getOrNull(position) == address) { - editDnsServerAt(null) - } - } - } - } - } - } - - fun removeDnsServer(address: InetAddress) { - jobTracker.newUiJob("removeDnsServer $address") { - customDnsServersLock.withLock { - val position = jobTracker.runOnBackground { - val index = cachedCustomDnsServers.indexOf(address) - cachedCustomDnsServers.removeAt(index) - onRemoveDnsServer(address) - index - } - - // Immediately disable custom dns in the ui when the last server in the list has - // been removed to avoid glitches with the ADD_SERVER view. - if (cachedCustomDnsServers.size == 0) { - enabled = false - } - - notifyItemRemoved(position) - } - } - } - - private suspend fun addDnsServer(addressText: String): Boolean { - var added = false - - withValidAddress(addressText) { address -> - if (onAddServer(address)) { - cachedCustomDnsServers.add(address) - added = true - } - } - - if (added) { - editingPosition = null - - val count = getItemCount() - - notifyItemChanged(count - 3) - notifyItemInserted(count - 2) - } - - return added - } - - private suspend fun replaceDnsServer(address: String, position: Int): Boolean { - var replaced = false - - withValidAddress(address) { newAddress -> - val oldAddress = cachedCustomDnsServers[position] - - if (onReplaceDnsServer(oldAddress, newAddress)) { - cachedCustomDnsServers[position] = newAddress - replaced = true - } - } - - if (replaced) { - editingPosition = null - notifyItemChanged(position) - } - - return replaced - } - - private fun editDnsServerAt(position: Int?) { - editingPosition?.let { oldPosition -> - notifyItemChanged(oldPosition) - } - - editingPosition = position - - position?.let { newPosition -> - notifyItemChanged(newPosition) - } - } - - private suspend fun withValidAddress(addressText: String, handler: (InetAddress) -> Unit) { - jobTracker.runOnBackground { - if (inetAddressValidator.isValid(addressText)) { - val address = InetAddress.getByName(addressText) - - if (!address.isLoopbackAddress() && confirmAddAddress(address)) { - handler(address) - } - } - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsFooterHolder.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsFooterHolder.kt deleted file mode 100644 index d09beffbce..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsFooterHolder.kt +++ /dev/null @@ -1,5 +0,0 @@ -package net.mullvad.mullvadvpn.ui.customdns - -import android.view.View - -class CustomDnsFooterHolder(view: View) : CustomDnsItemHolder(view) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsItemHolder.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsItemHolder.kt deleted file mode 100644 index cfaf9399cc..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsItemHolder.kt +++ /dev/null @@ -1,6 +0,0 @@ -package net.mullvad.mullvadvpn.ui.customdns - -import android.view.View -import androidx.recyclerview.widget.RecyclerView.ViewHolder - -abstract class CustomDnsItemHolder(view: View) : ViewHolder(view) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsServerHolder.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsServerHolder.kt deleted file mode 100644 index 49efad9310..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsServerHolder.kt +++ /dev/null @@ -1,30 +0,0 @@ -package net.mullvad.mullvadvpn.ui.customdns - -import android.view.View -import android.widget.TextView -import java.net.InetAddress -import kotlin.properties.Delegates.observable -import net.mullvad.mullvadvpn.R -import net.mullvad.talpid.util.addressString - -class CustomDnsServerHolder(view: View, adapter: CustomDnsAdapter) : CustomDnsItemHolder(view) { - private val label: TextView = view.findViewById(R.id.label) - - var serverAddress by observable<InetAddress?>(null) { _, _, address -> - label.text = address?.addressString() ?: "" - } - - init { - view.findViewById<View>(R.id.click_area).setOnClickListener { - serverAddress?.let { address -> - adapter.editDnsServer(address) - } - } - - view.findViewById<View>(R.id.remove).setOnClickListener { - serverAddress?.let { address -> - adapter.removeDnsServer(address) - } - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/EditCustomDnsServerHolder.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/EditCustomDnsServerHolder.kt deleted file mode 100644 index 5e62f47209..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/EditCustomDnsServerHolder.kt +++ /dev/null @@ -1,89 +0,0 @@ -package net.mullvad.mullvadvpn.ui.customdns - -import android.text.Editable -import android.text.TextWatcher -import android.view.View -import android.view.View.OnFocusChangeListener -import android.widget.EditText -import java.net.InetAddress -import kotlin.properties.Delegates.observable -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.util.setOnEnterOrDoneAction -import net.mullvad.talpid.util.addressString - -class EditCustomDnsServerHolder( - view: View, - val adapter: CustomDnsAdapter -) : CustomDnsItemHolder(view) { - private enum class State { - Normal, - Error, - } - - private val errorColor = view.context.getColor(R.color.red) - private val normalColor = view.context.getColor(R.color.blue) - - private val input: EditText = view.findViewById<EditText>(R.id.input).apply { - onFocusChangeListener = OnFocusChangeListener { _, hasFocus -> - if (!hasFocus) { - serverAddress?.let { address -> - adapter.stopEditing(address) - } - } - } - - setOnEnterOrDoneAction(::saveDnsServer) - } - - private val watcher: TextWatcher = object : TextWatcher { - override fun beforeTextChanged(text: CharSequence, start: Int, count: Int, after: Int) {} - - override fun afterTextChanged(text: Editable) { - state = State.Normal - } - - override fun onTextChanged(text: CharSequence, start: Int, before: Int, count: Int) {} - } - - private var state by observable(State.Normal) { _, oldState, newState -> - if (oldState != newState) { - input.apply { - when (newState) { - State.Normal -> { - setTextColor(normalColor) - removeTextChangedListener(watcher) - } - State.Error -> { - setTextColor(errorColor) - addTextChangedListener(watcher) - } - } - } - } - } - - var serverAddress by observable<InetAddress?>(null) { _, _, address -> - if (address != null) { - val addressString = address.addressString() - - input.setText(addressString) - input.setSelection(addressString.length) - } else { - input.setText("") - } - - input.requestFocus() - } - - init { - view.findViewById<View>(R.id.save).setOnClickListener { - saveDnsServer() - } - } - - private fun saveDnsServer() { - val onFailCallback = { state = State.Error } - - adapter.saveDnsServer(input.text.toString(), onFailCallback) - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConfirmDnsDialogFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConfirmDnsDialogFragment.kt deleted file mode 100644 index 0e26163ac2..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/ConfirmDnsDialogFragment.kt +++ /dev/null @@ -1,66 +0,0 @@ -package net.mullvad.mullvadvpn.ui.fragment - -import android.app.Dialog -import android.content.DialogInterface -import android.graphics.drawable.ColorDrawable -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams -import android.widget.Button -import androidx.fragment.app.DialogFragment -import kotlinx.coroutines.CompletableDeferred -import net.mullvad.mullvadvpn.R - -class ConfirmDnsDialogFragment @JvmOverloads constructor( - private var confirmation: CompletableDeferred<Boolean>? = null -) : DialogFragment() { - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val view = inflater.inflate(R.layout.confirm_dns, container, false) - - view.findViewById<Button>(R.id.back_button).setOnClickListener { - activity?.onBackPressed() - } - - view.findViewById<Button>(R.id.confirm_button).setOnClickListener { - confirmation?.complete(true) - confirmation = null - dismiss() - } - - return view - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val dialog = super.onCreateDialog(savedInstanceState) - - dialog.window?.setBackgroundDrawable(ColorDrawable(android.R.color.transparent)) - - return dialog - } - - override fun onStart() { - super.onStart() - - dialog?.window?.setLayout(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) - - if (confirmation == null) { - dismiss() - } - } - - override fun onDismiss(dialogInterface: DialogInterface) { - confirmation?.complete(false) - } - - override fun onDestroy() { - confirmation?.cancel() - - super.onDestroy() - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/MtuCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/MtuCell.kt deleted file mode 100644 index 93daba0856..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/MtuCell.kt +++ /dev/null @@ -1,85 +0,0 @@ -package net.mullvad.mullvadvpn.ui.widget - -import android.content.Context -import android.text.Editable -import android.text.TextWatcher -import android.util.AttributeSet -import android.view.LayoutInflater -import android.widget.EditText -import android.widget.TextView -import kotlin.properties.Delegates.observable -import net.mullvad.mullvadvpn.R - -private const val MIN_MTU_VALUE = 1280 -private const val MAX_MTU_VALUE = 1420 - -class MtuCell : Cell { - private val input = - (LayoutInflater.from(context).inflate(R.layout.mtu_edit_text, null) as EditText).apply { - val width = resources.getDimensionPixelSize(R.dimen.cell_input_width) - val height = resources.getDimensionPixelSize(R.dimen.cell_input_height) - - layoutParams = LayoutParams(width, height, 0.0f) - - addTextChangedListener(InputWatcher()) - setOnFocusChangeListener { _, newHasFocus -> hasFocus = newHasFocus } - } - - private val validInputColor = context.getColor(R.color.white) - private val invalidInputColor = context.getColor(R.color.red) - - var value: Int? - get() = input.text.toString().trim().toIntOrNull() - set(value) = input.setText(value?.toString() ?: "") - - var onSubmit: ((Int?) -> Unit)? = null - - var hasFocus by observable(false) { _, oldValue, newValue -> - if (oldValue && !newValue) { - val mtu = value - - if (mtu == null || (mtu in MIN_MTU_VALUE..MAX_MTU_VALUE)) { - onSubmit?.invoke(mtu) - } - } - } - - @JvmOverloads - constructor( - context: Context, - attributes: AttributeSet? = null, - defaultStyleAttribute: Int = 0, - defaultStyleResource: Int = 0 - ) : super( - context, - attributes, - defaultStyleAttribute, - defaultStyleResource, - TextView(context) - ) { - cell.apply { - setEnabled(false) - setFocusable(false) - addView(input) - } - - footer?.text = - context.getString(R.string.wireguard_mtu_footer, MIN_MTU_VALUE, MAX_MTU_VALUE) - } - - inner class InputWatcher : TextWatcher { - override fun beforeTextChanged(text: CharSequence, start: Int, count: Int, after: Int) {} - - override fun onTextChanged(text: CharSequence, start: Int, count: Int, after: Int) {} - - override fun afterTextChanged(text: Editable) { - val value = text.toString().trim().toIntOrNull() - - if (value != null && value >= MIN_MTU_VALUE && value <= MAX_MTU_VALUE) { - input.setTextColor(validInputColor) - } else { - input.setTextColor(invalidInputColor) - } - } - } -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/IntegerExtension.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/IntegerExtension.kt new file mode 100644 index 0000000000..a1a1d54b36 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/IntegerExtension.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.util + +fun Int.isValidMtu(): Boolean { + return this in 1280..1420 +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AdvancedSettingsViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AdvancedSettingsViewModel.kt new file mode 100644 index 0000000000..7d456fb680 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AdvancedSettingsViewModel.kt @@ -0,0 +1,249 @@ +package net.mullvad.mullvadvpn.viewmodel + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import java.net.InetAddress +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.state.AdvancedSettingsUiState +import net.mullvad.mullvadvpn.model.DnsState +import net.mullvad.mullvadvpn.model.Settings +import net.mullvad.mullvadvpn.repository.SettingsRepository +import net.mullvad.mullvadvpn.util.isValidMtu +import org.apache.commons.validator.routines.InetAddressValidator + +class AdvancedSettingsViewModel( + private val repository: SettingsRepository, + private val inetAddressValidator: InetAddressValidator, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) : ViewModel() { + + private val dialogState = + MutableStateFlow<AdvancedSettingsDialogState>(AdvancedSettingsDialogState.NoDialog) + + private val vmState = combine( + repository.settingsUpdates, + dialogState + ) { settings, interaction -> + AdvancedSettingsViewModelState( + mtuValue = settings?.mtuString() ?: "", + isCustomDnsEnabled = settings?.isCustomDnsEnabled() ?: false, + customDnsList = settings?.addresses()?.asStringAddressList() ?: listOf(), + isAllowLanEnabled = settings?.allowLan ?: false, + dialogState = interaction + ) + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + AdvancedSettingsViewModelState.default() + ) + + val uiState = vmState + .map(AdvancedSettingsViewModelState::toUiState) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(), + AdvancedSettingsUiState.DefaultUiState() + ) + + fun onMtuCellClick() { + dialogState.update { AdvancedSettingsDialogState.MtuDialog(vmState.value.mtuValue) } + } + + fun onMtuInputChange(value: String) { + dialogState.update { AdvancedSettingsDialogState.MtuDialog(value) } + } + + fun onSaveMtuClick() = viewModelScope.launch(dispatcher) { + val dialog = dialogState.value as? AdvancedSettingsDialogState.MtuDialog + dialog?.mtuEditValue?.toIntOrNull()?.takeIf { it.isValidMtu() }?.let { mtu -> + repository.setWireguardMtu(mtu) + } + hideDialog() + } + + fun onRestoreMtuClick() = viewModelScope.launch(dispatcher) { + repository.setWireguardMtu(null) + hideDialog() + } + + fun onCancelDialogClick() { + hideDialog() + } + + fun onDnsClick(index: Int? = null) { + val stagedDns = if (index == null) { + StagedDns.NewDns(CustomDnsItem.default()) + } else { + vmState.value.customDnsList.getOrNull(index)?.let { listItem -> + StagedDns.EditDns( + item = listItem, + index = index + ) + } + } + + if (stagedDns != null) { + dialogState.update { AdvancedSettingsDialogState.DnsDialog(stagedDns) } + } + } + + fun onDnsInputChange(ipAddress: String) { + dialogState.update { state -> + val dialog = state as? AdvancedSettingsDialogState.DnsDialog ?: return + + val error = when { + ipAddress.isBlank() || ipAddress.isValidIp().not() -> { + StagedDns.ValidationResult.InvalidAddress + } + ipAddress.isDuplicateDns((state.stagedDns as? StagedDns.EditDns)?.index) -> { + StagedDns.ValidationResult.DuplicateAddress + } + else -> StagedDns.ValidationResult.Success + } + + return@update AdvancedSettingsDialogState.DnsDialog( + stagedDns = if (dialog.stagedDns is StagedDns.EditDns) { + StagedDns.EditDns( + item = CustomDnsItem( + address = ipAddress, + isLocal = ipAddress.isLocalAddress() + ), + validationResult = error, + index = dialog.stagedDns.index + ) + } else { + StagedDns.NewDns( + item = CustomDnsItem( + address = ipAddress, + isLocal = ipAddress.isLocalAddress() + ), + validationResult = error + ) + } + ) + } + } + + fun onSaveDnsClick() = viewModelScope.launch(dispatcher) { + val dialog = + vmState.value.dialogState as? AdvancedSettingsDialogState.DnsDialog ?: return@launch + + if (dialog.stagedDns.isValid().not()) return@launch + + val updatedList = + vmState.value.customDnsList.toMutableList() + .map { it.address } + .toMutableList() + .let { activeList -> + if (dialog.stagedDns is StagedDns.EditDns) { + activeList + .apply { + set(dialog.stagedDns.index, dialog.stagedDns.item.address) + } + .asInetAddressList() + } else { + activeList + .apply { + add(dialog.stagedDns.item.address) + } + .asInetAddressList() + } + } + + repository.setDnsOptions( + isCustomDnsEnabled = true, + dnsList = updatedList + ) + + hideDialog() + } + + fun onToggleDnsClick(isEnabled: Boolean) = viewModelScope.launch(dispatcher) { + repository.setDnsOptions( + isEnabled, + dnsList = vmState.value.customDnsList + .map { it.address } + .asInetAddressList() + ) + } + + fun onRemoveDnsClick() = viewModelScope.launch(dispatcher) { + val dialog = vmState.value.dialogState as? AdvancedSettingsDialogState.DnsDialog + ?: return@launch + + val updatedList = vmState.value.customDnsList.toMutableList() + .filter { + it.address != dialog.stagedDns.item.address + } + .map { it.address } + .asInetAddressList() + + repository.setDnsOptions( + isCustomDnsEnabled = vmState.value.isCustomDnsEnabled && updatedList.isNotEmpty(), + dnsList = updatedList + ) + + hideDialog() + } + + private fun hideDialog() { + dialogState.update { AdvancedSettingsDialogState.NoDialog } + } + + private fun String.isDuplicateDns(stagedIndex: Int? = null): Boolean { + return vmState.value.customDnsList + .filterIndexed { index, listItem -> + index != stagedIndex && listItem.address == this + } + .isNotEmpty() + } + + private fun List<String>.asInetAddressList(): List<InetAddress> { + return try { + map { InetAddress.getByName(it) } + } catch (ex: Exception) { + Log.e("mullvad", "Error parsing the DNS address list.") + emptyList() + } + } + + private fun List<InetAddress>.asStringAddressList(): List<CustomDnsItem> { + return map { + CustomDnsItem( + address = it.hostAddress ?: EMPTY_STRING, + isLocal = it.isLocalAddress() + ) + } + } + + private fun Settings.mtuString() = tunnelOptions.wireguard.mtu?.toString() ?: EMPTY_STRING + + private fun Settings.isCustomDnsEnabled() = tunnelOptions.dnsOptions.state == DnsState.Custom + + private fun Settings.addresses() = tunnelOptions.dnsOptions.customOptions.addresses + + private fun String.isValidIp(): Boolean { + return inetAddressValidator.isValid(this) + } + + private fun String.isLocalAddress(): Boolean { + return isValidIp() && InetAddress.getByName(this).isLocalAddress() + } + + private fun InetAddress.isLocalAddress(): Boolean { + return isLinkLocalAddress || isSiteLocalAddress + } + + companion object { + private const val EMPTY_STRING = "" + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AdvancedSettingsViewModelState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AdvancedSettingsViewModelState.kt new file mode 100644 index 0000000000..4db0c012fd --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AdvancedSettingsViewModelState.kt @@ -0,0 +1,100 @@ +package net.mullvad.mullvadvpn.viewmodel + +import net.mullvad.mullvadvpn.compose.state.AdvancedSettingsUiState + +data class AdvancedSettingsViewModelState( + val mtuValue: String, + val isCustomDnsEnabled: Boolean, + val isAllowLanEnabled: Boolean, + val customDnsList: List<CustomDnsItem>, + val dialogState: AdvancedSettingsDialogState +) { + fun toUiState(): AdvancedSettingsUiState { + return when (dialogState) { + is AdvancedSettingsDialogState.MtuDialog -> AdvancedSettingsUiState.MtuDialogUiState( + mtu = mtuValue, + isCustomDnsEnabled = isCustomDnsEnabled, + isAllowLanEnabled = isAllowLanEnabled, + customDnsItems = customDnsList, + mtuEditValue = dialogState.mtuEditValue, + ) + is AdvancedSettingsDialogState.DnsDialog -> AdvancedSettingsUiState.DnsDialogUiState( + mtu = mtuValue, + isCustomDnsEnabled = isCustomDnsEnabled, + isAllowLanEnabled = isAllowLanEnabled, + customDnsItems = customDnsList, + stagedDns = dialogState.stagedDns, + ) + else -> AdvancedSettingsUiState.DefaultUiState( + mtu = mtuValue, + isCustomDnsEnabled = isCustomDnsEnabled, + isAllowLanEnabled = isAllowLanEnabled, + customDnsItems = customDnsList, + ) + } + } + + companion object { + private const val EMPTY_STRING = "" + + fun default() = AdvancedSettingsViewModelState( + mtuValue = EMPTY_STRING, + isCustomDnsEnabled = false, + customDnsList = listOf(), + isAllowLanEnabled = false, + dialogState = AdvancedSettingsDialogState.NoDialog + ) + } +} + +sealed class AdvancedSettingsDialogState { + object NoDialog : AdvancedSettingsDialogState() + + data class MtuDialog( + val mtuEditValue: String + ) : AdvancedSettingsDialogState() + + data class DnsDialog( + val stagedDns: StagedDns + ) : AdvancedSettingsDialogState() +} + +sealed interface StagedDns { + val item: CustomDnsItem + val validationResult: ValidationResult + + data class NewDns( + override val item: CustomDnsItem, + override val validationResult: ValidationResult = ValidationResult.Success, + ) : StagedDns + + data class EditDns( + override val item: CustomDnsItem, + override val validationResult: ValidationResult = ValidationResult.Success, + val index: Int + ) : StagedDns + + sealed class ValidationResult { + object Success : ValidationResult() + object InvalidAddress : ValidationResult() + object DuplicateAddress : ValidationResult() + } + + fun isValid() = (validationResult is ValidationResult.Success) +} + +data class CustomDnsItem( + val address: String, + val isLocal: Boolean +) { + companion object { + private const val EMPTY_STRING = "" + + fun default(): CustomDnsItem { + return CustomDnsItem( + address = EMPTY_STRING, + isLocal = false + ) + } + } +} diff --git a/android/app/src/main/res/drawable/cell_input_background.xml b/android/app/src/main/res/drawable/cell_input_background.xml deleted file mode 100644 index 436b3adb6e..0000000000 --- a/android/app/src/main/res/drawable/cell_input_background.xml +++ /dev/null @@ -1,5 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<shape xmlns:android="http://schemas.android.com/apk/res/android"> - <corners android:radius="4dp" /> - <solid android:color="@color/white10" /> -</shape> diff --git a/android/app/src/main/res/drawable/cell_input_cursor.xml b/android/app/src/main/res/drawable/cell_input_cursor.xml deleted file mode 100644 index 781c1d9b87..0000000000 --- a/android/app/src/main/res/drawable/cell_input_cursor.xml +++ /dev/null @@ -1,6 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<shape xmlns:android="http://schemas.android.com/apk/res/android"> - <solid android:color="@color/white" /> - <size android:width="1sp" - android:height="24sp" /> -</shape> diff --git a/android/app/src/main/res/drawable/icon_add.xml b/android/app/src/main/res/drawable/icon_add.xml deleted file mode 100644 index f44a660a95..0000000000 --- a/android/app/src/main/res/drawable/icon_add.xml +++ /dev/null @@ -1,7 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<rotate xmlns:android="http://schemas.android.com/apk/res/android" - android:fromDegrees="45" - android:toDegrees="45" - android:pivotX="50%" - android:pivotY="50%" - android:drawable="@drawable/icon_close" /> diff --git a/android/app/src/main/res/drawable/icon_check.xml b/android/app/src/main/res/drawable/icon_check.xml deleted file mode 100644 index b5bbbc6dd2..0000000000 --- a/android/app/src/main/res/drawable/icon_check.xml +++ /dev/null @@ -1,11 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<vector xmlns:android="http://schemas.android.com/apk/res/android" - android:width="24dp" - android:height="24dp" - android:viewportWidth="24.0" - android:viewportHeight="24.0"> - <group> - <path android:fillColor="@color/colorPrimary" - android:pathData="M12,24A12,12 0,0 1,3.515 3.515a12,12 0,1 1,16.97 16.97A11.922,11.922 0,0 1,12 24zM5.345,10.9a1.108,1.108 0,0 0,-0.785 0.322,1.095 1.095,0 0,0 0,1.556L9,17.177a1.115,1.115 0,0 0,1.569 0l8.874,-8.8a1.095,1.095 0,0 0,0 -1.556,1.116 1.116,0 0,0 -1.569,0l-8.092,8.024 -3.653,-3.623a1.106,1.106 0,0 0,-0.784 -0.322z" /> - </group> -</vector> diff --git a/android/app/src/main/res/layout/add_custom_dns_server.xml b/android/app/src/main/res/layout/add_custom_dns_server.xml deleted file mode 100644 index 892b48a6fe..0000000000 --- a/android/app/src/main/res/layout/add_custom_dns_server.xml +++ /dev/null @@ -1,31 +0,0 @@ -<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:background="@color/blue40" - android:orientation="horizontal"> - <TextView android:layout_width="wrap_content" - android:layout_height="match_parent" - android:layout_marginLeft="54dp" - android:layout_marginVertical="14dp" - android:background="?android:attr/selectableItemBackground" - android:gravity="center_vertical" - android:textColor="@color/white" - android:textSize="@dimen/text_medium" - android:text="@string/add_a_server" /> - <View android:id="@+id/click_area" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_alignParentLeft="true" - android:layout_alignParentRight="true" - android:focusable="true" - android:clickable="true" - android:background="?android:attr/selectableItemBackground" /> - <ImageButton android:id="@+id/add" - android:layout_width="wrap_content" - android:layout_height="match_parent" - android:layout_gravity="right" - android:paddingHorizontal="16dp" - android:paddingVertical="14dp" - android:background="?android:attr/selectableItemBackground" - android:src="@drawable/icon_add" /> -</FrameLayout> diff --git a/android/app/src/main/res/layout/advanced.xml b/android/app/src/main/res/layout/advanced.xml deleted file mode 100644 index 42f94b7b7f..0000000000 --- a/android/app/src/main/res/layout/advanced.xml +++ /dev/null @@ -1,34 +0,0 @@ -<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:mullvad="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:background="@color/darkBlue" - android:gravity="left"> - <TextView android:id="@+id/title" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="@string/settings_advanced" - style="@style/SettingsCollapsedHeader" /> - <LinearLayout android:layout_width="match_parent" - android:layout_height="match_parent" - android:orientation="vertical"> - <FrameLayout android:layout_width="match_parent" - android:layout_height="wrap_content"> - <net.mullvad.mullvadvpn.ui.widget.BackButton android:id="@+id/back" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - mullvad:text="@string/settings" /> - <TextView android:id="@+id/collapsed_title" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginHorizontal="4dp" - android:layout_gravity="center" - android:text="@string/settings_advanced" - style="@style/SettingsCollapsedHeader" /> - </FrameLayout> - <net.mullvad.mullvadvpn.ui.widget.CustomRecyclerView android:id="@+id/contents" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:scrollbars="vertical" /> - </LinearLayout> -</FrameLayout> diff --git a/android/app/src/main/res/layout/advanced_header.xml b/android/app/src/main/res/layout/advanced_header.xml deleted file mode 100644 index 70a583ea7c..0000000000 --- a/android/app/src/main/res/layout/advanced_header.xml +++ /dev/null @@ -1,32 +0,0 @@ -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:mullvad="http://schemas.android.com/apk/res-auto" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:orientation="vertical" - android:gravity="left"> - <TextView android:id="@+id/expanded_title" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="2dp" - android:layout_marginLeft="@dimen/side_margin" - android:lines="1" - android:text="@string/settings_advanced" - style="@style/SettingsExpandedHeader" /> - <net.mullvad.mullvadvpn.ui.widget.MtuCell android:id="@+id/wireguard_mtu" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/vertical_space" - android:focusable="true" - android:focusableInTouchMode="true" - mullvad:text="@string/wireguard_mtu" /> - <net.mullvad.mullvadvpn.ui.widget.NavigateCell android:id="@+id/split_tunneling" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/vertical_space" - mullvad:text="@string/split_tunneling" /> - <net.mullvad.mullvadvpn.ui.widget.ToggleCell android:id="@+id/enable_custom_dns" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/vertical_space" - mullvad:text="@string/enable_custom_dns" /> -</LinearLayout> diff --git a/android/app/src/main/res/layout/confirm_dns.xml b/android/app/src/main/res/layout/confirm_dns.xml deleted file mode 100644 index 6c7266eae9..0000000000 --- a/android/app/src/main/res/layout/confirm_dns.xml +++ /dev/null @@ -1,31 +0,0 @@ -<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:scrollbars="none"> - <LinearLayout android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:padding="30dp" - android:background="@drawable/dialog_background" - android:orientation="vertical" - android:gravity="left"> - <ImageView android:layout_width="44dp" - android:layout_height="44dp" - android:layout_marginTop="8dp" - android:layout_gravity="center" - android:src="@drawable/icon_alert" /> - <TextView android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_weight="0" - android:layout_marginTop="16dp" - android:textColor="@color/white80" - android:textSize="@dimen/text_small" - android:text="@string/confirm_local_dns" /> - <Button android:id="@+id/confirm_button" - android:layout_marginVertical="@dimen/button_separation" - android:text="@string/add_anyway" - style="@style/RedButton" /> - <Button android:id="@+id/back_button" - android:text="@string/back" - style="@style/BlueButton" /> - </LinearLayout> -</ScrollView> diff --git a/android/app/src/main/res/layout/custom_dns_footer.xml b/android/app/src/main/res/layout/custom_dns_footer.xml deleted file mode 100644 index c939eebb7f..0000000000 --- a/android/app/src/main/res/layout/custom_dns_footer.xml +++ /dev/null @@ -1,14 +0,0 @@ -<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:gravity="center"> - <TextView android:id="@+id/name" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:paddingHorizontal="@dimen/cell_footer_horizontal_padding" - android:paddingBottom="@dimen/screen_vertical_margin" - android:paddingTop="@dimen/cell_footer_top_padding" - android:textColor="@color/white60" - android:textSize="@dimen/text_small" - android:text="@string/custom_dns_footer" /> -</FrameLayout> diff --git a/android/app/src/main/res/layout/custom_dns_server.xml b/android/app/src/main/res/layout/custom_dns_server.xml deleted file mode 100644 index 54d7e9f01e..0000000000 --- a/android/app/src/main/res/layout/custom_dns_server.xml +++ /dev/null @@ -1,31 +0,0 @@ -<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:background="@color/blue40" - android:orientation="horizontal"> - <TextView android:id="@+id/label" - android:layout_width="wrap_content" - android:layout_height="match_parent" - android:layout_marginLeft="54dp" - android:layout_marginVertical="14dp" - android:background="?android:attr/selectableItemBackground" - android:gravity="center_vertical" - android:textColor="@color/white" - android:textSize="@dimen/text_medium" /> - <View android:id="@+id/click_area" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_alignParentLeft="true" - android:layout_alignParentRight="true" - android:focusable="true" - android:clickable="true" - android:background="?android:attr/selectableItemBackground" /> - <ImageButton android:id="@+id/remove" - android:layout_width="wrap_content" - android:layout_height="match_parent" - android:layout_gravity="right" - android:paddingHorizontal="16dp" - android:paddingVertical="14dp" - android:background="?android:attr/selectableItemBackground" - android:src="@drawable/icon_close" /> -</FrameLayout> diff --git a/android/app/src/main/res/layout/edit_custom_dns_server.xml b/android/app/src/main/res/layout/edit_custom_dns_server.xml deleted file mode 100644 index 855504f077..0000000000 --- a/android/app/src/main/res/layout/edit_custom_dns_server.xml +++ /dev/null @@ -1,30 +0,0 @@ -<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:background="@color/white" - android:orientation="horizontal"> - <EditText android:id="@+id/input" - android:layout_width="0dp" - android:layout_height="match_parent" - android:layout_weight="1" - android:layout_marginLeft="54dp" - android:layout_marginVertical="14dp" - android:gravity="center_vertical" - android:background="@android:color/transparent" - android:singleLine="true" - android:imeOptions="flagNoPersonalizedLearning" - android:textCursorDrawable="@drawable/text_input_cursor" - android:textColorHint="@color/blue60" - android:textColor="@color/blue" - android:textSize="@dimen/text_medium" - android:hint="@string/custom_dns_hint" /> - <ImageButton android:id="@+id/save" - android:layout_width="wrap_content" - android:layout_height="match_parent" - android:layout_weight="0" - android:layout_gravity="right" - android:paddingHorizontal="16dp" - android:paddingVertical="14dp" - android:background="?android:attr/selectableItemBackground" - android:src="@drawable/icon_check" /> -</LinearLayout> diff --git a/android/app/src/main/res/layout/mtu_edit_text.xml b/android/app/src/main/res/layout/mtu_edit_text.xml deleted file mode 100644 index 11334cf4c1..0000000000 --- a/android/app/src/main/res/layout/mtu_edit_text.xml +++ /dev/null @@ -1,14 +0,0 @@ -<EditText xmlns:android="http://schemas.android.com/apk/res/android" - android:paddingHorizontal="8dp" - android:paddingVertical="4dp" - android:background="@drawable/cell_input_background" - android:digits="0123456789" - android:inputType="number" - android:singleLine="true" - android:imeOptions="flagNoPersonalizedLearning" - android:textCursorDrawable="@drawable/cell_input_cursor" - android:gravity="center" - android:hint="@string/hint_default" - android:textColorHint="@color/white80" - android:textColor="@color/white" - android:textSize="@dimen/text_medium_plus" /> diff --git a/android/app/src/main/res/values-da/strings.xml b/android/app/src/main/res/values-da/strings.xml index b7ed47f668..41e79658f5 100644 --- a/android/app/src/main/res/values-da/strings.xml +++ b/android/app/src/main/res/values-da/strings.xml @@ -8,7 +8,6 @@ <string name="account_time_notification_channel_description">Viser påmindelser, når kontotiden er ved at udløbe</string> <string name="account_time_notification_channel_name">Påmindelser om kontotid</string> <string name="add_a_server">Tilføj en server</string> - <string name="add_anyway">Tilføj alligevel</string> <string name="add_time_to_account">Køb enten kredit på vores hjemmeside, eller indløs en kupon.</string> <string name="all_applications">Alle applikationer</string> <string name="allow_lan_footer">Giver adgang til andre enheder på det samme netværk til deling, udskrivning osv.</string> @@ -43,7 +42,6 @@ <string name="creating_secure_connection">OPRETTER SIKKER FORBINDELSE</string> <string name="critical_error">Kritisk fejl (som kræver din opmærksomhed)</string> <string name="custom_dns_footer">Aktiver for at tilføje mindst én DNS-server.</string> - <string name="custom_dns_hint">Indtast IP</string> <string name="custom_tunnel_host_resolution_error">Kunne ikke fortolke værtsnavnet på den tilpassede server</string> <string name="device_inactive_description">Du har fjernet denne enhed. For at oprette forbindelse igen skal du logge ind igen.</string> <string name="device_inactive_title">Enheden er inaktiv</string> diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 476caab0bc..072de1fe8a 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -8,7 +8,6 @@ <string name="account_time_notification_channel_description">Erinnerungen anzeigen, wenn die Kontozeit bald abläuft</string> <string name="account_time_notification_channel_name">Erinnerungen an die Kontozeit</string> <string name="add_a_server">Server hinzufügen</string> - <string name="add_anyway">Trotzdem hinzufügen</string> <string name="add_time_to_account">Kaufen Sie entweder Guthaben über unsere Seite oder lösen Sie einen Gutschein ein.</string> <string name="all_applications">Alle Anwendungen</string> <string name="allow_lan_footer">Ermöglicht den Zugriff auf andere Geräte im selben Netzwerk zum Teilen von Dateien, Drucken etc.</string> @@ -43,7 +42,6 @@ <string name="creating_secure_connection">SICHERE VERBINDUNG WIRD ERSTELLT</string> <string name="critical_error">Kritischer Fehler (Ihre Aufmerksamkeit ist erforderlich)</string> <string name="custom_dns_footer">Aktivieren, um mindestens einen DNS-Server hinzuzufügen.</string> - <string name="custom_dns_hint">IP eingeben</string> <string name="custom_tunnel_host_resolution_error">Der Hostname des benutzerdefinierten Servers konnte nicht aufgelöst werden</string> <string name="device_inactive_description">Sie haben dieses Gerät entfernt. Um sich erneut zu verbinden, müssen Sie sich erneut anmelden.</string> <string name="device_inactive_title">Gerät ist inaktiv</string> diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 13bdc1d505..52b5381c0d 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -8,7 +8,6 @@ <string name="account_time_notification_channel_description">Muestra avisos cuando el tiempo de la cuenta está a punto de caducar</string> <string name="account_time_notification_channel_name">Recordatorios de tiempo de la cuenta</string> <string name="add_a_server">Añadir un servidor</string> - <string name="add_anyway">Añadir de todos modos</string> <string name="add_time_to_account">Compre crédito en nuestro sitio web o canjee un cupón.</string> <string name="all_applications">Todas las aplicaciones</string> <string name="allow_lan_footer">Permite el acceso a otros dispositivos de la misma red para compartir archivos, imprimir, etc.</string> @@ -43,7 +42,6 @@ <string name="creating_secure_connection">CREANDO CONEXIÓN SEGURA</string> <string name="critical_error">Error crítico (precisa su atención)</string> <string name="custom_dns_footer">Active esta opción para agregar como mínimo un servidor DNS.</string> - <string name="custom_dns_hint">Escriba la IP</string> <string name="custom_tunnel_host_resolution_error">No se puede resolver el nombre de host del servidor personalizado</string> <string name="device_inactive_description">Ha quitado este dispositivo. Vuelva a iniciar la sesión para conectarse.</string> <string name="device_inactive_title">El dispositivo está inactivo</string> diff --git a/android/app/src/main/res/values-fi/strings.xml b/android/app/src/main/res/values-fi/strings.xml index 5847fe930f..fa4d54f1d7 100644 --- a/android/app/src/main/res/values-fi/strings.xml +++ b/android/app/src/main/res/values-fi/strings.xml @@ -8,7 +8,6 @@ <string name="account_time_notification_channel_description">Näyttää muistutuksia, kun tilin käyttöaika on umpeutumassa</string> <string name="account_time_notification_channel_name">Muistutukset tilin käyttöajasta</string> <string name="add_a_server">Lisää palvelin</string> - <string name="add_anyway">Lisää silti</string> <string name="add_time_to_account">Osta käyttöaikaa verkkosivustoltamme tai lunasta kuponki.</string> <string name="all_applications">Kaikki sovellukset</string> <string name="allow_lan_footer">Sallii jakamisen, tulostuksen ym. saman verkon muille laitteille.</string> @@ -43,7 +42,6 @@ <string name="creating_secure_connection">LUODAAN SUOJATTU YHTEYS</string> <string name="critical_error">Vakava virhe (vaatii huomiotasi)</string> <string name="custom_dns_footer">Ota käyttöön lisätäksesi vähintään yhden DNS-palvelimen.</string> - <string name="custom_dns_hint">Anna IP-osoite</string> <string name="custom_tunnel_host_resolution_error">Mukautetun palvelimen isäntänimen selvittäminen epäonnistui</string> <string name="device_inactive_description">Olet poistanut tämän laitteen. Jos haluat muodostaa yhteyden uudelleen, sinun täytyy kirjautua takaisin sisään.</string> <string name="device_inactive_title">Laite ei ole aktiivinen</string> diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 0c8ee02884..8c32c7ddc6 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -8,7 +8,6 @@ <string name="account_time_notification_channel_description">Affiche des rappels lorsque le temps du compte va expirer</string> <string name="account_time_notification_channel_name">Rappels de temps pour le compte</string> <string name="add_a_server">Ajouter un serveur</string> - <string name="add_anyway">Ajouter quand même</string> <string name="add_time_to_account">Achetez du crédit sur notre site web ou échangez un bon.</string> <string name="all_applications">Toutes les applications</string> <string name="allow_lan_footer">Autorise l\'accès aux autres appareils sur le même réseau pour partager, imprimer, etc.</string> @@ -43,7 +42,6 @@ <string name="creating_secure_connection">CRÉATION D\'UNE CONNEXION SÉCURISÉE</string> <string name="critical_error">Erreur critique (votre attention est requise)</string> <string name="custom_dns_footer">Activez pour ajouter au moins un serveur DNS.</string> - <string name="custom_dns_hint">Saisir l\'IP</string> <string name="custom_tunnel_host_resolution_error">Échec de la résolution du nom d\'hôte du serveur personnalisé</string> <string name="device_inactive_description">Vous avez supprimé cet appareil. Vous devrez vous reconnecter pour connecter cet appareil à nouveau.</string> <string name="device_inactive_title">L\'appareil est inactif</string> diff --git a/android/app/src/main/res/values-it/strings.xml b/android/app/src/main/res/values-it/strings.xml index 51bcdaff09..be38a145fe 100644 --- a/android/app/src/main/res/values-it/strings.xml +++ b/android/app/src/main/res/values-it/strings.xml @@ -8,7 +8,6 @@ <string name="account_time_notification_channel_description">Mostra promemoria quando il tempo dell\'account sta per scadere</string> <string name="account_time_notification_channel_name">Promemoria temporali per l\'account</string> <string name="add_a_server">Aggiungi un server</string> - <string name="add_anyway">Aggiungi comunque</string> <string name="add_time_to_account">Acquista credito sul nostro sito web o riscatta un voucher.</string> <string name="all_applications">Tutte le applicazioni</string> <string name="allow_lan_footer">Consenti l\'accesso ad altri dispositivi sulla stessa rete per condividere, stampare e altro.</string> @@ -43,7 +42,6 @@ <string name="creating_secure_connection">CREAZIONE CONNESSIONE PROTETTA</string> <string name="critical_error">Errore critico (è necessario intervenire)</string> <string name="custom_dns_footer">Abilita per aggiungere almeno un server DNS.</string> - <string name="custom_dns_hint">Inserisci IP</string> <string name="custom_tunnel_host_resolution_error">Impossibile risolvere il nome host del server personalizzato</string> <string name="device_inactive_description">Hai rimosso questo dispositivo. Per riconnetterti, dovrai effettuare nuovamente il login.</string> <string name="device_inactive_title">Il dispositivo è inattivo</string> diff --git a/android/app/src/main/res/values-ja/strings.xml b/android/app/src/main/res/values-ja/strings.xml index 8072f2ae86..d48abcd90a 100644 --- a/android/app/src/main/res/values-ja/strings.xml +++ b/android/app/src/main/res/values-ja/strings.xml @@ -8,7 +8,6 @@ <string name="account_time_notification_channel_description">アカウントの期限切れが迫っているときにリマインダーを表示します</string> <string name="account_time_notification_channel_name">アカウント時間のリマインダー</string> <string name="add_a_server">サーバーを追加</string> - <string name="add_anyway">追加を続ける</string> <string name="add_time_to_account">当社ウェブサイトでクレジットを購入するか、バウチャーを使用してください。</string> <string name="all_applications">すべてのアプリケーション</string> <string name="allow_lan_footer">共有や印刷などのため、同一ネットワーク上の他のデバイスへのアクセスを許可します。</string> @@ -43,7 +42,6 @@ <string name="creating_secure_connection">セキュリティ保護接続を確立中</string> <string name="critical_error">重大なエラー (ご注意ください)</string> <string name="custom_dns_footer">1つ以上のDNSサーバーを追加するには有効にしてください。</string> - <string name="custom_dns_hint">IPを入力</string> <string name="custom_tunnel_host_resolution_error">カスタムサーバーのホスト名を解決できませんでした</string> <string name="device_inactive_description">このデバイスを削除しました。再度接続するには、ログインし直す必要があります。</string> <string name="device_inactive_title">デバイスが無効です</string> diff --git a/android/app/src/main/res/values-ko/strings.xml b/android/app/src/main/res/values-ko/strings.xml index e40286aa59..4b76cc5175 100644 --- a/android/app/src/main/res/values-ko/strings.xml +++ b/android/app/src/main/res/values-ko/strings.xml @@ -8,7 +8,6 @@ <string name="account_time_notification_channel_description">계정 시간이 만료되려고 할 때 알림 표시</string> <string name="account_time_notification_channel_name">계정 시간 알림</string> <string name="add_a_server">서버 추가</string> - <string name="add_anyway">추가</string> <string name="add_time_to_account">웹 사이트에서 크레딧을 구매하거나 바우처를 사용하세요.</string> <string name="all_applications">모든 애플리케이션</string> <string name="allow_lan_footer">공유, 인쇄 등을 위해 동일한 네트워크의 다른 장치에 액세스할 수 있습니다.</string> @@ -43,7 +42,6 @@ <string name="creating_secure_connection">보안 연결 생성 중</string> <string name="critical_error">심각한 오류(주의가 필요함)</string> <string name="custom_dns_footer">하나 이상의 DNS 서버를 추가하려면 활성화합니다.</string> - <string name="custom_dns_hint">IP 입력</string> <string name="custom_tunnel_host_resolution_error">사용자 지정 서버의 호스트 이름을 확인하지 못함</string> <string name="device_inactive_description">이 장치를 제거했습니다. 다시 연결하려면 다시 로그인해야 합니다.</string> <string name="device_inactive_title">장치가 비활성 상태입니다.</string> diff --git a/android/app/src/main/res/values-my/strings.xml b/android/app/src/main/res/values-my/strings.xml index cdf8a4f56c..1feff0574f 100644 --- a/android/app/src/main/res/values-my/strings.xml +++ b/android/app/src/main/res/values-my/strings.xml @@ -8,7 +8,6 @@ <string name="account_time_notification_channel_description">အကောင့်အချိန် သက်တမ်းကုန်ခါနီးချိန်၌ သတိပေးချက်များ ပြသပေးပါသည်</string> <string name="account_time_notification_channel_name">အကောင့်အချိန် သတိပေးချက်များ</string> <string name="add_a_server">ဆာဗာ ပေါင်းထည့်ရန်</string> - <string name="add_anyway">မည်သို့ပင်ဖြစ်စေ ပေါင်းထည့်ရန်</string> <string name="add_time_to_account">ကျွန်ုပ်တို့၏ ဝက်ဘ်ဆိုက်တွင် ခရက်ဒစ် ဝယ်ယူပါ သို့မဟုတ် ဘောက်ချာဖြင့် လဲယူပါ။</string> <string name="all_applications">အပလီကေးရှင်း အားလုံး</string> <string name="allow_lan_footer">ဝေမျှရန်၊ ပရင့်ထုတ်ရန်စသည်တို့အတွက် တူညီသည့် ကွန်ရက်ရှိ အခြားစက်များ ရယူသုံးစွဲခွင့်ပြုပေးပါသည်။</string> @@ -43,7 +42,6 @@ <string name="creating_secure_connection">လုံခြုံသည့် ချိတ်ဆက်မှုကို ဖန်တီးနေပါသည်</string> <string name="critical_error">အလွန်အရေးပါသည့် ချို့ယွင်းချက် (သင့်အာရုံစိုက်မှု လိုအပ်ပါသည်)</string> <string name="custom_dns_footer">အနည်းဆုံး DNS ဆာဗာတစ်ခုကို ပေါင်းထည့်ပါ။</string> - <string name="custom_dns_hint">IP ဖြည့်ပါ</string> <string name="custom_tunnel_host_resolution_error">စိတ်ကြိုက် ဆာဗာ၏ Hostname ကို ဖြေရှင်း၍ မရနိုင်ပါ</string> <string name="device_inactive_description">ဤစက်ကို ဖယ်ရှားပြီး ဖြစ်သည်။ ထပ်မံချိတ်ဆက်ရန်အတွက် ပြန်လည် ဝင်ရောက်ရန် လိုပါသည်။</string> <string name="device_inactive_title">စက်သည် သက်ဝင်လုပ်ဆောင်မှု မရှိပါ</string> diff --git a/android/app/src/main/res/values-nb/strings.xml b/android/app/src/main/res/values-nb/strings.xml index 3fed35b942..07d6cbd657 100644 --- a/android/app/src/main/res/values-nb/strings.xml +++ b/android/app/src/main/res/values-nb/strings.xml @@ -8,7 +8,6 @@ <string name="account_time_notification_channel_description">Viser påminnelser når tidsavbrudd for kontoen er i ferd med å inntreffe</string> <string name="account_time_notification_channel_name">Påminnelser om tidsavbrudd for konto</string> <string name="add_a_server">Legg til en server</string> - <string name="add_anyway">Legg til likevel</string> <string name="add_time_to_account">Du kan enten kjøpe kreditt på nettsiden vår eller løse inn en kupong.</string> <string name="all_applications">Alle applikasjoner</string> <string name="allow_lan_footer">Gir tilgang til andre enheter på samme nettverk for deling, utskrift osv.</string> @@ -43,7 +42,6 @@ <string name="creating_secure_connection">OPPRETTER SIKKER TILKOBLING</string> <string name="critical_error">Kritisk feil (krever din oppmerksomhet)</string> <string name="custom_dns_footer">Aktiver for å legge til minst én DNS-server.</string> - <string name="custom_dns_hint">Angi IP</string> <string name="custom_tunnel_host_resolution_error">Kunne ikke løse vertsnavnet til den egendefinerte serveren</string> <string name="device_inactive_description">Du har fjernet denne enheten. For å koble til igjen, må du logge inn på nytt.</string> <string name="device_inactive_title">Enheten er inaktiv</string> diff --git a/android/app/src/main/res/values-nl/strings.xml b/android/app/src/main/res/values-nl/strings.xml index 442d0de4c3..dda01f6c11 100644 --- a/android/app/src/main/res/values-nl/strings.xml +++ b/android/app/src/main/res/values-nl/strings.xml @@ -8,7 +8,6 @@ <string name="account_time_notification_channel_description">Toont herinneringen wanneer de accounttijd op het punt staat te verlopen</string> <string name="account_time_notification_channel_name">Accounttijdherinneringen</string> <string name="add_a_server">Server toevoegen</string> - <string name="add_anyway">Toch toevoegen</string> <string name="add_time_to_account">Koop krediet op onze website of wissel een voucher in.</string> <string name="all_applications">Alle toepassingen</string> <string name="allow_lan_footer">Biedt toegang tot andere apparaten op hetzelfde netwerk voor delen, afdrukken en dergelijke</string> @@ -43,7 +42,6 @@ <string name="creating_secure_connection">BEVEILIGDE VERBINDING AANMAKEN</string> <string name="critical_error">Kritieke fout (uw aandacht is vereist)</string> <string name="custom_dns_footer">Schakel in om minimaal één DNS-server toe te voegen.</string> - <string name="custom_dns_hint">Voer IP-adres in</string> <string name="custom_tunnel_host_resolution_error">Kon de hostnaam van de aangepaste server niet omzetten</string> <string name="device_inactive_description">U hebt dit apparaat verwijderd. U moet zich opnieuw aanmelden om het opnieuw te verbinden.</string> <string name="device_inactive_title">Apparaat is niet actief</string> diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index d07c6eb5a7..2e94cb447c 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -8,7 +8,6 @@ <string name="account_time_notification_channel_description">Pokazuje przypomnienia, gdy kończy się czas na koncie</string> <string name="account_time_notification_channel_name">Przypomnienia o czasie na koncie</string> <string name="add_a_server">Dodaj serwer</string> - <string name="add_anyway">Mimo to dodaj</string> <string name="add_time_to_account">Doładuj w naszej witrynie internetowej lub zrealizuj kupon.</string> <string name="all_applications">Wszystkie aplikacje</string> <string name="allow_lan_footer">Umożliwia dostęp do innych urządzeń w tej samej sieci w celu udostępniania, drukowania itd.</string> @@ -43,7 +42,6 @@ <string name="creating_secure_connection">TWORZENIE BEZPIECZNEGO POŁĄCZENIA</string> <string name="critical_error">Błąd krytyczny (wymagana uwaga)</string> <string name="custom_dns_footer">Włącz, aby dodać co najmniej jeden serwer DNS.</string> - <string name="custom_dns_hint">Wprowadź adres IP</string> <string name="custom_tunnel_host_resolution_error">Nie można rozpoznać nazwy hosta serwera niestandardowego</string> <string name="device_inactive_description">Urządzenie usunięto. Aby połączyć się ponownie, musisz się ponownie zalogować.</string> <string name="device_inactive_title">Urządzenie nieaktywne</string> diff --git a/android/app/src/main/res/values-pt/strings.xml b/android/app/src/main/res/values-pt/strings.xml index c99e0fc70c..50f688114a 100644 --- a/android/app/src/main/res/values-pt/strings.xml +++ b/android/app/src/main/res/values-pt/strings.xml @@ -8,7 +8,6 @@ <string name="account_time_notification_channel_description">Mostra lembretes quando o tempo da conta está prestes a expirar</string> <string name="account_time_notification_channel_name">Lembretes de tempo da conta</string> <string name="add_a_server">Adicionar um servidor</string> - <string name="add_anyway">Adicionar mesmo assim</string> <string name="add_time_to_account">Compre crédito no nosso sítio da web ou reclame um voucher.</string> <string name="all_applications">Todas as aplicações</string> <string name="allow_lan_footer">Permite o acesso a outros dispositivos na mesma rede para partilha, impressão, etc.</string> @@ -43,7 +42,6 @@ <string name="creating_secure_connection">A CRIAR LIGAÇÃO SEGURA</string> <string name="critical_error">Erro crítico (é necessária a sua atenção)</string> <string name="custom_dns_footer">Ativar para adicionar pelo menos um servidor DNS.</string> - <string name="custom_dns_hint">Introduzir IP</string> <string name="custom_tunnel_host_resolution_error">Não foi possível resolver o nome do anfitrião do servidor personalizado</string> <string name="device_inactive_description">Removeu este dispositivo. Para voltar a ligar o dispositivo, terá de voltar a iniciar a sessão.</string> <string name="device_inactive_title">O dispositivo está desativado</string> diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index ffe73a4aae..2431264cd9 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -8,7 +8,6 @@ <string name="account_time_notification_channel_description">Показывает уведомления, когда время на учетной записи скоро закончится</string> <string name="account_time_notification_channel_name">Напоминания о времени на учетной записи</string> <string name="add_a_server">Добавить сервер</string> - <string name="add_anyway">Всё равно добавить</string> <string name="add_time_to_account">Пополните баланс у нас на сайте или погасите ваучер.</string> <string name="all_applications">Все приложения</string> <string name="allow_lan_footer">Разрешить доступ к другим устройствам в той же сети для передачи данных, печати и т. д.</string> @@ -43,7 +42,6 @@ <string name="creating_secure_connection">СОЗДАНИЕ ЗАЩИЩЕННОГО ПОДКЛЮЧЕНИЯ</string> <string name="critical_error">Критическая ошибка (требуется ваше участие)</string> <string name="custom_dns_footer">Чтобы добавить как минимум один DNS-сервер, включите этот параметр.</string> - <string name="custom_dns_hint">Введите IP-адрес</string> <string name="custom_tunnel_host_resolution_error">Не удалось преобразовать имя узла пользовательского сервера</string> <string name="device_inactive_description">Вы удалили это устройство. Чтобы снова подключиться, нужно будет выполнить вход.</string> <string name="device_inactive_title">Устройство неактивно</string> diff --git a/android/app/src/main/res/values-sv/strings.xml b/android/app/src/main/res/values-sv/strings.xml index 116c34f7d4..f132492fb0 100644 --- a/android/app/src/main/res/values-sv/strings.xml +++ b/android/app/src/main/res/values-sv/strings.xml @@ -8,7 +8,6 @@ <string name="account_time_notification_channel_description">Visar påminnelser när kontots tidsgräns uppnås</string> <string name="account_time_notification_channel_name">Påminnelser om kontotid</string> <string name="add_a_server">Lägg till en server</string> - <string name="add_anyway">Lägg till ändå</string> <string name="add_time_to_account">Du kan antingen köpa kredit på vår webbplats eller lösa in en kupong.</string> <string name="all_applications">Alla applikationer</string> <string name="allow_lan_footer">Tillåter åtkomst till andra enheter i samma nätverk för delning, utskrift etc.</string> @@ -43,7 +42,6 @@ <string name="creating_secure_connection">SKAPAR SÄKER ANSLUTNING</string> <string name="critical_error">Kritiskt fel (kräver din uppmärksamhet)</string> <string name="custom_dns_footer">Aktivera för att lägga till minst en DNS-server.</string> - <string name="custom_dns_hint">Ange IP</string> <string name="custom_tunnel_host_resolution_error">Det gick inte att lösa värdnamnet för den anpassade servern</string> <string name="device_inactive_description">Du har tagit bort den här enheten. Du måste logga in igen för att återansluta.</string> <string name="device_inactive_title">Enheten är inaktiv</string> diff --git a/android/app/src/main/res/values-th/strings.xml b/android/app/src/main/res/values-th/strings.xml index 4472175d61..c3e826cc5e 100644 --- a/android/app/src/main/res/values-th/strings.xml +++ b/android/app/src/main/res/values-th/strings.xml @@ -8,7 +8,6 @@ <string name="account_time_notification_channel_description">แสดงการแจ้งเตือน ในขณะที่เวลาบัญชีใกล้หมดอายุ</string> <string name="account_time_notification_channel_name">การแจ้งเตือนเวลาบัญชี</string> <string name="add_a_server">เพิ่มเซิร์ฟเวอร์</string> - <string name="add_anyway">เพิ่มต่อไป</string> <string name="add_time_to_account">ซื้อเครดิตบนเว็บไซต์ของเรา หรือแลกรับบัตรกำนัล</string> <string name="all_applications">แอปพลิเคชันทั้งหมด</string> <string name="allow_lan_footer">อนุญาตให้เข้าถึงอุปกรณ์อื่นๆ บนเครือข่ายเดียวกัน เพื่อแชร์ พิมพ์ ฯลฯ</string> @@ -43,7 +42,6 @@ <string name="creating_secure_connection">กำลังสร้างการเชื่อมต่อที่ปลอดภัย</string> <string name="critical_error">ข้อผิดพลาดร้ายแรง (คุณจำเป็นต้องตรวจสอบ)</string> <string name="custom_dns_footer">เปิดเพื่อเพิ่มเซิร์ฟเวอร์ DNS อย่างน้อยหนึ่งรายการ</string> - <string name="custom_dns_hint">ป้อน IP</string> <string name="custom_tunnel_host_resolution_error">ไม่พบชื่อโฮสต์ของเซิร์ฟเวอร์แบบกำหนดเอง</string> <string name="device_inactive_description">คุณได้ลบอุปกรณ์เครื่องนี้แล้ว หากต้องการเชื่อมต่ออีกครั้ง คุณจะต้องเข้าสู่ระบบใหม่อีกครั้ง</string> <string name="device_inactive_title">อุปกรณ์ไม่ได้ใช้งาน</string> diff --git a/android/app/src/main/res/values-tr/strings.xml b/android/app/src/main/res/values-tr/strings.xml index 756baf5dbe..7acbbed720 100644 --- a/android/app/src/main/res/values-tr/strings.xml +++ b/android/app/src/main/res/values-tr/strings.xml @@ -8,7 +8,6 @@ <string name="account_time_notification_channel_description">Hesap süresinin dolmak üzere olduğunu bildiren hatırlatıcıları gösterir</string> <string name="account_time_notification_channel_name">Hesap süresi hatırlatıcıları</string> <string name="add_a_server">Sunucu ekle</string> - <string name="add_anyway">Yine de ekle</string> <string name="add_time_to_account">Web sitemizden kredi satın alın veya kupon kullanın.</string> <string name="all_applications">Tüm uygulamalar</string> <string name="allow_lan_footer">Paylaşım, yazdırma vb. için aynı ağdaki diğer cihazlara erişime izin verir.</string> @@ -43,7 +42,6 @@ <string name="creating_secure_connection">GÜVENLİ BAĞLANTI OLUŞTURULUYOR</string> <string name="critical_error">Kritik hata (Lütfen dikkatli olun)</string> <string name="custom_dns_footer">En az bir DNS sunucusu eklemek için etkinleştirin.</string> - <string name="custom_dns_hint">IP\'yi girin</string> <string name="custom_tunnel_host_resolution_error">Özel sunucu ana bilgisayar adı çözülemiyor</string> <string name="device_inactive_description">Bu cihazı kaldırdın. Tekrar bağlanmak için yeniden giriş yapmanız gerekecek.</string> <string name="device_inactive_title">Cihaz etkin değil</string> diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index fa1b258fb3..16394cc3fb 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -8,7 +8,6 @@ <string name="account_time_notification_channel_description">在帐户时间即将到期时显示提醒</string> <string name="account_time_notification_channel_name">帐户时间提醒</string> <string name="add_a_server">添加服务器</string> - <string name="add_anyway">仍然添加</string> <string name="add_time_to_account">在我们的网站上购买额度或兑换优惠券。</string> <string name="all_applications">所有应用程序</string> <string name="allow_lan_footer">允许访问同一个网络上的其他设备以进行共享和打印等。</string> @@ -43,7 +42,6 @@ <string name="creating_secure_connection">正在创建安全连接</string> <string name="critical_error">严重错误(需要注意)</string> <string name="custom_dns_footer">启用以添加至少一个 DNS 服务器。</string> - <string name="custom_dns_hint">输入 IP</string> <string name="custom_tunnel_host_resolution_error">无法解析自定义服务器的主机名</string> <string name="device_inactive_description">您已移除此设备。要重新连接,您需要重新登录。</string> <string name="device_inactive_title">设备处于非活动状态</string> diff --git a/android/app/src/main/res/values-zh-rTW/strings.xml b/android/app/src/main/res/values-zh-rTW/strings.xml index 68526a45ac..6e32faf157 100644 --- a/android/app/src/main/res/values-zh-rTW/strings.xml +++ b/android/app/src/main/res/values-zh-rTW/strings.xml @@ -8,7 +8,6 @@ <string name="account_time_notification_channel_description">在帳戶時間即將到期時顯示提醒</string> <string name="account_time_notification_channel_name">帳戶時間提醒</string> <string name="add_a_server">新增伺服器</string> - <string name="add_anyway">仍要新增</string> <string name="add_time_to_account">在我們網站上購買點數或兌換憑證。</string> <string name="all_applications">所有應用程式</string> <string name="allow_lan_footer">允許存取同一網路上的其他裝置,以進行共用、列印等。</string> @@ -43,7 +42,6 @@ <string name="creating_secure_connection">建立安全連線</string> <string name="critical_error">嚴重錯誤 (需注意)</string> <string name="custom_dns_footer">啟用以新增至少一個 DNS 伺服器。</string> - <string name="custom_dns_hint">輸入 IP</string> <string name="custom_tunnel_host_resolution_error">無法解析自訂伺服器的主機名稱</string> <string name="device_inactive_description">您已移除此裝置。若要重新連線,您需要重新登入。</string> <string name="device_inactive_title">裝置處於非活動狀態</string> diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml index ddd2fb88e0..bf1608f2ee 100644 --- a/android/app/src/main/res/values/colors.xml +++ b/android/app/src/main/res/values/colors.xml @@ -11,7 +11,6 @@ <color name="white60">#99FFFFFF</color> <color name="white40">#66FFFFFF</color> <color name="white20">#33FFFFFF</color> - <color name="white10">#1AFFFFFF</color> <color name="green">#44AD4D</color> <color name="green90">#E644AD4D</color> <color name="green80">#CC44AD4D</color> diff --git a/android/app/src/main/res/values/dimensions.xml b/android/app/src/main/res/values/dimensions.xml index 4f35637a64..76fa24032d 100644 --- a/android/app/src/main/res/values/dimensions.xml +++ b/android/app/src/main/res/values/dimensions.xml @@ -23,8 +23,6 @@ <dimen name="cell_right_padding">16dp</dimen> <dimen name="cell_inner_spacing">8dp</dimen> <dimen name="cell_label_vertical_padding">14dp</dimen> - <dimen name="cell_input_width">80dp</dimen> - <dimen name="cell_input_height">34dp</dimen> <dimen name="cell_footer_top_padding">6dp</dimen> <dimen name="cell_footer_horizontal_padding">@dimen/side_margin</dimen> <dimen name="app_version_warning_icon_size">28dp</dimen> diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 2e4c5f0275..bce6426629 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -147,11 +147,9 @@ <string name="enable">Enable</string> <string name="enable_custom_dns">Use custom DNS server</string> <string name="add_a_server">Add a server</string> - <string name="custom_dns_hint">Enter IP</string> <string name="custom_dns_footer">Enable to add at least one DNS server.</string> <string name="confirm_local_dns">The local DNS server will not work unless you enable \"Local Network Sharing\" under Preferences.</string> - <string name="add_anyway">Add anyway</string> <string name="exclude_applications">Excluded applications</string> <string name="all_applications">All applications</string> <string name="copied_to_clipboard">Copied to clipboard</string> @@ -203,4 +201,11 @@ then the app queries your system for a list of all installed applications. This list is only retrieved in the split tunneling view. The list of installed applications is never sent from the device.</string> + <string name="submit_button">Submit</string> + <string name="remove_button">Remove</string> + <string name="enter_value_placeholder">Enter…</string> + <string name="reset_to_default_button">Reset to default</string> + <string name="add_dns_server_dialog_title">Add DNS server</string> + <string name="update_dns_server_dialog_title">Update DNS server</string> + <string name="duplicate_address_warning">This address has already been entered.</string> </resources> diff --git a/android/buildSrc/src/main/kotlin/Dependencies.kt b/android/buildSrc/src/main/kotlin/Dependencies.kt index 4a99bbc0bc..de1be2d9b7 100644 --- a/android/buildSrc/src/main/kotlin/Dependencies.kt +++ b/android/buildSrc/src/main/kotlin/Dependencies.kt @@ -43,6 +43,8 @@ object Dependencies { } object Compose { + const val composeCollapsingToolbar = + "me.onebone:toolbar-compose:${Versions.Compose.composeCollapsingToolbar}" const val constrainLayout = "androidx.constraintlayout:constraintlayout-compose:${Versions.Compose.constrainLayout}" const val foundation = @@ -51,10 +53,12 @@ object Dependencies { const val viewModelLifecycle = "androidx.lifecycle:lifecycle-viewmodel-compose:${Versions.Compose.viewModelLifecycle}" const val material = "androidx.compose.material:material:${Versions.Compose.material}" + const val material3 = "androidx.compose.material3:material3:${Versions.Compose.material3}" const val testManifest = "androidx.compose.ui:ui-test-manifest:${Versions.Compose.base}" const val uiController = "com.google.accompanist:accompanist-systemuicontroller:${Versions.Compose.uiController}" const val ui = "androidx.compose.ui:ui:${Versions.Compose.base}" + const val uiUtil = "androidx.compose.ui:ui-util:${Versions.Compose.base}" const val uiTooling = "androidx.compose.ui:ui-tooling:${Versions.Compose.base}" const val uiToolingPreview = "androidx.compose.ui:ui-tooling-preview:${Versions.Compose.base}" diff --git a/android/buildSrc/src/main/kotlin/Versions.kt b/android/buildSrc/src/main/kotlin/Versions.kt index 35bc19d1bb..b786f40d3c 100644 --- a/android/buildSrc/src/main/kotlin/Versions.kt +++ b/android/buildSrc/src/main/kotlin/Versions.kt @@ -39,9 +39,11 @@ object Versions { object Compose { const val base = "1.3.2" + const val composeCollapsingToolbar = "2.3.5" const val constrainLayout = "1.0.1" const val foundation = "1.3.1" const val material = "1.3.1" + const val material3 = "1.0.1" const val uiController = "0.28.0" const val viewModelLifecycle = "2.5.1" } diff --git a/android/gradle/verification-metadata.xml b/android/gradle/verification-metadata.xml index f684f65b10..e0bf7583a8 100644 --- a/android/gradle/verification-metadata.xml +++ b/android/gradle/verification-metadata.xml @@ -171,6 +171,14 @@ <sha256 value="078b4dcd5f09689281415d9ea0e09d2775d80f016041dacbcee22d54c43a5fa1" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.animation" name="animation" version="1.3.0"> + <artifact name="animation-1.3.0.aar"> + <sha256 value="3b640dc729a7686bedeb33061e38a10294f495bb0040aeaaa970ae1560119f41" origin="Generated by Gradle"/> + </artifact> + <artifact name="animation-1.3.0.module"> + <sha256 value="c4e548544f21d977ef7a80aeb8e388feb628c6bbf9099325559bbd8adb6992d0" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.animation" name="animation" version="1.3.2"> <artifact name="animation-1.3.2.aar"> <sha256 value="7c52b01c26c9ab8946d4cb6bbf819a54ac48038e1a3e741d30a6beac5457547f" origin="Generated by Gradle"/> @@ -192,6 +200,14 @@ <sha256 value="6834b1b466930369a6cb9f76df6257eff28428e42ef42a26515319638cceee3d" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.animation" name="animation-core" version="1.3.0"> + <artifact name="animation-core-1.3.0.aar"> + <sha256 value="bfd9839872589c9d409e4eb943c9055d929c06f0673a3ed186b40645eba8e68f" origin="Generated by Gradle"/> + </artifact> + <artifact name="animation-core-1.3.0.module"> + <sha256 value="56c39013b7c99ccd0339badfef4452972d412dcd9862447ebf0788cde77eb698" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.animation" name="animation-core" version="1.3.2"> <artifact name="animation-core-1.3.2.aar"> <sha256 value="68cd10277608095d2f365b31945b640815f8e98fb53a17b84dc85fe2e52a70cf" origin="Generated by Gradle"/> @@ -256,6 +272,14 @@ <sha256 value="2d89e99ae979853bd2359a7d5da16405479bce776d176168c2c7e8b431398d80" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.material3" name="material3" version="1.0.1"> + <artifact name="material3-1.0.1.aar"> + <sha256 value="7204378ecadec4089da57492fbdb4cb637758e4bc740f26fe6f2db4d8876af05" origin="Generated by Gradle"/> + </artifact> + <artifact name="material3-1.0.1.module"> + <sha256 value="993a826a5cb89f2932d7e0d9dc2dc071c7b6f684420f93b304bd07ddbbfb902b" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.runtime" name="runtime" version="1.1.1"> <artifact name="runtime-1.1.1.module"> <sha256 value="b7e9c3cc6034d099c9160ef49d2dc03eac66d3fe7ea0df7aa0abfa258368de63" origin="Generated by Gradle"/> @@ -298,6 +322,11 @@ <sha256 value="ad2262144f81040a09bfdec039010dca2cb5026821e4b27403519303b59ae7bf" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.ui" name="ui-graphics" version="1.0.1"> + <artifact name="ui-graphics-1.0.1.module"> + <sha256 value="ad9ce40deec721b8988c43ab847d803d00bee88c67cfc838dee565691f35db95" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.ui" name="ui-graphics" version="1.1.1"> <artifact name="ui-graphics-1.1.1.module"> <sha256 value="170b229a8012c6724b4101058dc4c2cfe0f11e78a34aac4a4627cce8888f1a59" origin="Generated by Gradle"/> @@ -345,6 +374,11 @@ <sha256 value="1b74b6a3275e3bd794a21b6403247455cc3c3978ade66dba214035d9fbabab2d" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.ui" name="ui-text" version="1.3.1"> + <artifact name="ui-text-1.3.1.module"> + <sha256 value="7808e6a9bb1ebd8b208b0ab3b206ddc5d4254ace63657d636b463b71983c54d3" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.ui" name="ui-text" version="1.3.2"> <artifact name="ui-text-1.3.2.aar"> <sha256 value="988fe4ea7f042ab83073a65a4448f2a0b8593b95b27d8708c41745af5aa10e00" origin="Generated by Gradle"/> @@ -353,6 +387,14 @@ <sha256 value="9934a053e86e4847f7bc3c60be46187c9834d7815c27f12a369905170fc08d3e" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.ui" name="ui-tooling" version="1.3.0"> + <artifact name="ui-tooling-1.3.0.aar"> + <sha256 value="67d511e5b9c4251cd5d1c7129618738e24765446a367eed18e8d0abba4ef3413" origin="Generated by Gradle"/> + </artifact> + <artifact name="ui-tooling-1.3.0.module"> + <sha256 value="f367f5f746b14e8fb3d7371c5e012a1f922c27de33927a350985043cab3039e9" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.ui" name="ui-tooling" version="1.3.2"> <artifact name="ui-tooling-1.3.2.aar"> <sha256 value="e9fcb88bb28ee67b2ef8d8d995ffd94caafbeb619a6cd09e391857867a17ce5b" origin="Generated by Gradle"/> @@ -361,6 +403,14 @@ <sha256 value="5922f4576b36c7fa8be7d9d1d229376b20d5ef25a3410185e8562baad43d1788" origin="Generated by Gradle"/> </artifact> </component> + <component group="androidx.compose.ui" name="ui-tooling-data" version="1.3.0"> + <artifact name="ui-tooling-data-1.3.0.aar"> + <sha256 value="98a9c11622fa3abeaf881cfef2e0f4ab6d2ccdcb7ea9d7d27297b8597184e17f" origin="Generated by Gradle"/> + </artifact> + <artifact name="ui-tooling-data-1.3.0.module"> + <sha256 value="770acffe3600f98be1f18f967842bf86a7e0b20fa431d66d4ad7ba038192bc60" origin="Generated by Gradle"/> + </artifact> + </component> <component group="androidx.compose.ui" name="ui-tooling-data" version="1.3.2"> <artifact name="ui-tooling-data-1.3.2.aar"> <sha256 value="a60c7c89e0461660b657c4502894266c089c10c017eee4e21078a31e46d9b6d8" origin="Generated by Gradle"/> @@ -390,11 +440,6 @@ <sha256 value="eecb5446872b5cd3caa1acce0e704780d1a3fa9feb2f06c6728ef9fb231b4cb5" origin="Generated by Gradle"/> </artifact> </component> - <component group="androidx.compose.ui" name="ui-util" version="1.0.0"> - <artifact name="ui-util-1.0.0.module"> - <sha256 value="a09871728e5a9d050d2fdcb99a875ef2120dc6deea808f5a6d443dd887e081ca" origin="Generated by Gradle"/> - </artifact> - </component> <component group="androidx.compose.ui" name="ui-util" version="1.3.2"> <artifact name="ui-util-1.3.2.aar"> <sha256 value="b2f15225c1f59482445b1bc59a6dcb067cee62edaa611140aeaf27587bf41077" origin="Generated by Gradle"/> @@ -2304,6 +2349,14 @@ <sha256 value="8e495b634469d64fb8acfa3495a065cbacc8a0fff55ce1e31007be4c16dc57d3" origin="Generated by Gradle"/> </artifact> </component> + <component group="me.onebone" name="toolbar-compose" version="2.3.5"> + <artifact name="toolbar-compose-2.3.5.aar"> + <sha256 value="5454801b0407039f58406626d387e8abb8cc8f888efe498644d89b8d4a33f1b1" origin="Generated by Gradle"/> + </artifact> + <artifact name="toolbar-compose-2.3.5.module"> + <sha256 value="12cfcb37f4d8d5414eb405271681e9805a1085b5ad794356088f369ebb9c9b87" origin="Generated by Gradle"/> + </artifact> + </component> <component group="net.bytebuddy" name="byte-buddy" version="1.12.18"> <artifact name="byte-buddy-1.12.18.jar"> <sha256 value="39200c13a72b6a3f4ec43c7b6d2fb78ecbeb25c29e986f4efa572636b39d750e" origin="Generated by Gradle"/> @@ -2915,11 +2968,6 @@ <sha256 value="2aedcdc6b69b33bdf5cc235bcea88e7cf6601146bb6bcdffdb312bbacd7be261" origin="Generated by Gradle"/> </artifact> </component> - <component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk7" version="1.6.21"> - <artifact name="kotlin-stdlib-jdk7-1.6.21.jar"> - <sha256 value="f1b0634dbb94172038463020bb2dd45ca26849f8ce29d625acb0f1569d11dbee" origin="Generated by Gradle"/> - </artifact> - </component> <component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk7" version="1.7.10"> <artifact name="kotlin-stdlib-jdk7-1.7.10.jar"> <sha256 value="54f61351b1936ad88f4e53059fe781e723eae51d78ed9e7422d8b403574ec682" origin="Generated by Gradle"/> @@ -2940,11 +2988,6 @@ <sha256 value="1456d82d039ea30d8485b032901f52bbf07e7cdbe8bb1f8708ad32a8574c41ce" origin="Generated by Gradle"/> </artifact> </component> - <component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk8" version="1.6.21"> - <artifact name="kotlin-stdlib-jdk8-1.6.21.jar"> - <sha256 value="dab45489b47736d59fce44b80676f1947a9b6bcab10fd60e878a83bd82a6954c" origin="Generated by Gradle"/> - </artifact> - </component> <component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk8" version="1.7.10"> <artifact name="kotlin-stdlib-jdk8-1.7.10.jar"> <sha256 value="8aafdd60c94f454c92e5066d266a5ed53ecc63c78f623b3fd9db56fea4032873" origin="Generated by Gradle"/> diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index c235886bb0..74a69734fd 100644 --- a/gui/locales/messages.pot +++ b/gui/locales/messages.pot @@ -1605,6 +1605,9 @@ msgstr "" msgid "Account time reminders" msgstr "" +msgid "Add DNS server" +msgstr "" + msgid "Advanced" msgstr "" @@ -1641,6 +1644,9 @@ msgstr "" msgid "Enable" msgstr "" +msgid "Enter…" +msgstr "" + msgid "Excluded applications" msgstr "" @@ -1686,6 +1692,12 @@ msgstr "" msgid "Privacy policy" msgstr "" +msgid "Remove" +msgstr "" + +msgid "Reset to default" +msgstr "" + msgid "Secured" msgstr "" @@ -1704,9 +1716,15 @@ msgstr "" msgid "Split tunneling makes it possible to select which applications should not be routed through the VPN tunnel." msgstr "" +msgid "Submit" +msgstr "" + msgid "The local DNS server will not work unless you enable \"Local Network Sharing\" under Preferences." msgstr "" +msgid "This address has already been entered." +msgstr "" + msgid "This device is offline, no tunnels can be established" msgstr "" @@ -1722,6 +1740,9 @@ msgstr "" msgid "Unsecured" msgstr "" +msgid "Update DNS server" +msgstr "" + msgid "Update available, download to remain safe." msgstr "" |
