summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJonatan Rhodin <jonatan.rhodin@mullvad.net>2023-10-11 11:13:07 +0200
committerJonatan Rhodin <jonatan.rhodin@mullvad.net>2023-10-11 11:13:07 +0200
commit8f35cd09b7a5178acc8d471bec76950d9144a9de (patch)
treec6c95d299c477abc4caeb9f383d6e8ee71a8ef62
parent4446d1fda07a35596901d7d3f614b9d033129f24 (diff)
parent955fdfdb2cfaeffa7929a2203476c0499d422460 (diff)
downloadmullvadvpn-8f35cd09b7a5178acc8d471bec76950d9144a9de.tar.xz
mullvadvpn-8f35cd09b7a5178acc8d471bec76950d9144a9de.zip
Merge branch 'migrate-voucher-redeem-dialog-to-compose-droid-59'
-rw-r--r--CHANGELOG.md1
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt286
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogScreen.kt32
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VoucherDialogUiState.kt21
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt6
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/GroupedTextField.kt47
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/VoucherVisualTransformation.kt38
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/CommonConstant.kt3
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt8
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/RedeemVoucherDialogFragment.kt182
-rw-r--r--android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModel.kt92
-rw-r--r--android/app/src/main/res/layout/redeem_voucher.xml58
-rw-r--r--android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModelTest.kt71
-rw-r--r--android/lib/resource/src/main/res/values-da/strings.xml1
-rw-r--r--android/lib/resource/src/main/res/values-de/strings.xml1
-rw-r--r--android/lib/resource/src/main/res/values-es/strings.xml1
-rw-r--r--android/lib/resource/src/main/res/values-fi/strings.xml1
-rw-r--r--android/lib/resource/src/main/res/values-fr/strings.xml1
-rw-r--r--android/lib/resource/src/main/res/values-it/strings.xml1
-rw-r--r--android/lib/resource/src/main/res/values-ja/strings.xml1
-rw-r--r--android/lib/resource/src/main/res/values-ko/strings.xml1
-rw-r--r--android/lib/resource/src/main/res/values-my/strings.xml1
-rw-r--r--android/lib/resource/src/main/res/values-nb/strings.xml1
-rw-r--r--android/lib/resource/src/main/res/values-nl/strings.xml1
-rw-r--r--android/lib/resource/src/main/res/values-pl/strings.xml1
-rw-r--r--android/lib/resource/src/main/res/values-pt/strings.xml1
-rw-r--r--android/lib/resource/src/main/res/values-ru/strings.xml1
-rw-r--r--android/lib/resource/src/main/res/values-sv/strings.xml1
-rw-r--r--android/lib/resource/src/main/res/values-th/strings.xml1
-rw-r--r--android/lib/resource/src/main/res/values-tr/strings.xml1
-rw-r--r--android/lib/resource/src/main/res/values-zh-rCN/strings.xml1
-rw-r--r--android/lib/resource/src/main/res/values-zh-rTW/strings.xml1
-rw-r--r--android/lib/resource/src/main/res/values/plurals.xml8
-rw-r--r--android/lib/resource/src/main/res/values/strings.xml4
-rw-r--r--android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt1
-rw-r--r--gui/locales/messages.pot19
36 files changed, 672 insertions, 224 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 097410fffb..3c1d7053a0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -39,6 +39,7 @@ Line wrap the file at 100 chars. Th
- Add Social media to content blockers.
- Migrate Report Problem view to compose.
- Migrate View Logs view to compose.
+- Migrate voucher dialog to compose.
#### Linux
- Don't block forwarding of traffic when the split tunnel mark (ct mark) is set.
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt
new file mode 100644
index 0000000000..4d6f3f1261
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt
@@ -0,0 +1,286 @@
+package net.mullvad.mullvadvpn.compose.dialog
+
+import android.content.res.Configuration
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+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.width
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.compositeOver
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.button.ActionButton
+import net.mullvad.mullvadvpn.compose.state.VoucherDialogState
+import net.mullvad.mullvadvpn.compose.state.VoucherDialogUiState
+import net.mullvad.mullvadvpn.compose.textfield.GroupedTextField
+import net.mullvad.mullvadvpn.compose.util.vouchersVisualTransformation
+import net.mullvad.mullvadvpn.constant.VOUCHER_LENGTH
+import net.mullvad.mullvadvpn.lib.theme.AlphaDescription
+import net.mullvad.mullvadvpn.lib.theme.AlphaDisabled
+import net.mullvad.mullvadvpn.lib.theme.AlphaInactive
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.lib.theme.Dimens
+import org.joda.time.DateTimeConstants
+
+@Preview(device = Devices.TV_720p)
+@Composable
+private fun PreviewRedeemVoucherDialog() {
+ AppTheme {
+ RedeemVoucherDialog(
+ uiState = VoucherDialogUiState.INITIAL,
+ onVoucherInputChange = {},
+ onRedeem = {},
+ onDismiss = {}
+ )
+ }
+}
+
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_3)
+@Composable
+private fun PreviewRedeemVoucherDialogVerifying() {
+ AppTheme {
+ RedeemVoucherDialog(
+ uiState = VoucherDialogUiState("", VoucherDialogState.Verifying),
+ onVoucherInputChange = {},
+ onRedeem = {},
+ onDismiss = {}
+ )
+ }
+}
+
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_3)
+@Composable
+private fun PreviewRedeemVoucherDialogError() {
+ AppTheme {
+ RedeemVoucherDialog(
+ uiState = VoucherDialogUiState("", VoucherDialogState.Error("An Error message")),
+ onVoucherInputChange = {},
+ onRedeem = {},
+ onDismiss = {}
+ )
+ }
+}
+
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_3)
+@Composable
+private fun PreviewRedeemVoucherDialogSuccess() {
+ AppTheme {
+ RedeemVoucherDialog(
+ uiState = VoucherDialogUiState("", VoucherDialogState.Success(3600)),
+ onVoucherInputChange = {},
+ onRedeem = {},
+ onDismiss = {}
+ )
+ }
+}
+
+@Composable
+fun RedeemVoucherDialog(
+ uiState: VoucherDialogUiState,
+ onVoucherInputChange: (String) -> Unit = {},
+ onRedeem: (voucherCode: String) -> Unit,
+ onDismiss: () -> Unit
+) {
+ AlertDialog(
+ title = {
+ if (uiState.voucherViewModelState !is VoucherDialogState.Success)
+ Text(
+ text = stringResource(id = R.string.enter_voucher_code),
+ style = MaterialTheme.typography.titleMedium
+ )
+ },
+ confirmButton = {
+ Column {
+ if (uiState.voucherViewModelState !is VoucherDialogState.Success) {
+ ActionButton(
+ text = stringResource(id = R.string.redeem),
+ onClick = { onRedeem(uiState.voucherInput) },
+ modifier = Modifier.padding(bottom = Dimens.mediumPadding),
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ disabledContentColor =
+ MaterialTheme.colorScheme.onSurface
+ .copy(alpha = AlphaInactive)
+ .compositeOver(MaterialTheme.colorScheme.surface),
+ disabledContainerColor =
+ MaterialTheme.colorScheme.surface
+ .copy(alpha = AlphaDisabled)
+ .compositeOver(MaterialTheme.colorScheme.surface)
+ ),
+ isEnabled = uiState.voucherInput.length == VOUCHER_LENGTH
+ )
+ }
+ ActionButton(
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ ),
+ text =
+ stringResource(
+ id =
+ if (uiState.voucherViewModelState is VoucherDialogState.Success)
+ R.string.changes_dialog_dismiss_button
+ else R.string.cancel
+ ),
+ onClick = onDismiss
+ )
+ }
+ },
+ text = {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ if (uiState.voucherViewModelState is VoucherDialogState.Success) {
+ val days: Int =
+ (uiState.voucherViewModelState.addedTime /
+ DateTimeConstants.SECONDS_PER_DAY)
+ .toInt()
+ val message =
+ stringResource(
+ R.string.added_to_your_account,
+ when (days) {
+ 0 -> {
+ stringResource(R.string.less_than_one_day)
+ }
+ in 1..59 -> {
+ pluralStringResource(id = R.plurals.days, count = days, days)
+ }
+ else -> {
+ pluralStringResource(
+ id = R.plurals.months,
+ count = days / 30,
+ days / 30
+ )
+ }
+ }
+ )
+ RedeemSuccessBody(message = message)
+ } else {
+
+ EnterVoucherBody(
+ uiState = uiState,
+ onVoucherInputChange = onVoucherInputChange,
+ onRedeem = onRedeem
+ )
+ }
+ }
+ },
+ containerColor = MaterialTheme.colorScheme.background,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ onDismissRequest = onDismiss
+ )
+}
+
+@Composable
+private fun RedeemSuccessBody(message: String) {
+ Image(
+ painter = painterResource(R.drawable.icon_success),
+ contentDescription = null,
+ modifier = Modifier.fillMaxWidth().height(Dimens.buttonHeight)
+ )
+ Text(
+ text = stringResource(id = R.string.voucher_success_title),
+ modifier =
+ Modifier.padding(
+ start = Dimens.smallPadding,
+ top = Dimens.successIconVerticalPadding,
+ )
+ .fillMaxWidth(),
+ color = MaterialTheme.colorScheme.onPrimary,
+ style = MaterialTheme.typography.titleMedium
+ )
+
+ Text(
+ text = message,
+ modifier =
+ Modifier.padding(start = Dimens.smallPadding, top = Dimens.cellTopPadding)
+ .fillMaxWidth(),
+ color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaDescription),
+ style = MaterialTheme.typography.labelMedium
+ )
+}
+
+@Composable
+private fun EnterVoucherBody(
+ uiState: VoucherDialogUiState,
+ onVoucherInputChange: (String) -> Unit = {},
+ onRedeem: (voucherCode: String) -> Unit
+) {
+ val textFieldFocusRequester = FocusRequester()
+ Box(Modifier.wrapContentSize().clickable { textFieldFocusRequester.requestFocus() }) {
+ GroupedTextField(
+ value = uiState.voucherInput,
+ onSubmit = { input ->
+ if (uiState.voucherInput.length == VOUCHER_LENGTH) {
+ onRedeem(input)
+ }
+ },
+ onValueChanged = { input -> onVoucherInputChange(input.uppercase()) },
+ isValidValue = uiState.voucherInput.isNotEmpty(),
+ keyboardType = KeyboardType.Password,
+ placeholderText = stringResource(id = R.string.voucher_hint),
+ placeHolderColor =
+ MaterialTheme.colorScheme.onPrimary
+ .copy(alpha = AlphaDisabled)
+ .compositeOver(MaterialTheme.colorScheme.primary),
+ visualTransformation = vouchersVisualTransformation(),
+ maxCharLength = VOUCHER_LENGTH,
+ onFocusChange = {},
+ isDigitsOnlyAllowed = false,
+ isEnabled = true,
+ modifier = Modifier.focusRequester(textFieldFocusRequester),
+ validateRegex = "^[A-Za-z0-9]*$".toRegex()
+ )
+ }
+ Spacer(modifier = Modifier.height(Dimens.smallPadding))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.height(Dimens.listIconSize).fillMaxWidth()
+ ) {
+ if (uiState.voucherViewModelState is VoucherDialogState.Verifying) {
+ CircularProgressIndicator(
+ modifier =
+ Modifier.height(Dimens.loadingSpinnerSizeMedium)
+ .width(Dimens.loadingSpinnerSizeMedium),
+ color = MaterialTheme.colorScheme.onSecondary
+ )
+ Text(
+ text = stringResource(id = R.string.verifying_voucher),
+ modifier = Modifier.padding(start = Dimens.smallPadding),
+ color = MaterialTheme.colorScheme.onPrimary,
+ style = MaterialTheme.typography.bodySmall
+ )
+ } else if (uiState.voucherViewModelState is VoucherDialogState.Error) {
+ Text(
+ text = uiState.voucherViewModelState.errorMessage,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogScreen.kt
new file mode 100644
index 0000000000..1344db5b20
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogScreen.kt
@@ -0,0 +1,32 @@
+package net.mullvad.mullvadvpn.compose.screen
+
+import android.content.res.Configuration
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+import net.mullvad.mullvadvpn.compose.dialog.RedeemVoucherDialog
+import net.mullvad.mullvadvpn.compose.state.VoucherDialogUiState
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_3)
+@Composable
+private fun PreviewRedeemVoucherDialogScreen() {
+ AppTheme {
+ RedeemVoucherDialogScreen(
+ uiState = VoucherDialogUiState.INITIAL,
+ onVoucherInputChange = {},
+ onRedeem = {},
+ onDismiss = {}
+ )
+ }
+}
+
+@Composable
+internal fun RedeemVoucherDialogScreen(
+ uiState: VoucherDialogUiState,
+ onVoucherInputChange: (String) -> Unit = {},
+ onRedeem: (voucherCode: String) -> Unit,
+ onDismiss: () -> Unit
+) {
+ RedeemVoucherDialog(uiState, onVoucherInputChange, onRedeem, onDismiss)
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VoucherDialogUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VoucherDialogUiState.kt
new file mode 100644
index 0000000000..e719bda529
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VoucherDialogUiState.kt
@@ -0,0 +1,21 @@
+package net.mullvad.mullvadvpn.compose.state
+
+data class VoucherDialogUiState(
+ val voucherInput: String = "",
+ val voucherViewModelState: VoucherDialogState = VoucherDialogState.Default
+) {
+ companion object {
+ val INITIAL = VoucherDialogUiState()
+ }
+}
+
+sealed interface VoucherDialogState {
+
+ data object Default : VoucherDialogState
+
+ data object Verifying : VoucherDialogState
+
+ data class Success(val addedTime: Long) : VoucherDialogState
+
+ data class Error(val errorMessage: String) : VoucherDialogState
+}
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
index 13d91df68b..e5350f9844 100644
--- 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
@@ -32,6 +32,7 @@ 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.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -58,7 +59,8 @@ fun CustomTextField(
isValidValue: Boolean,
isDigitsOnlyAllowed: Boolean,
defaultTextColor: Color = Color.White,
- textAlign: TextAlign = TextAlign.Start
+ textAlign: TextAlign = TextAlign.Start,
+ visualTransformation: VisualTransformation = VisualTransformation.None
) {
val fontSize = dimensionResource(id = R.dimen.text_medium_plus).value.sp
val shape = RoundedCornerShape(4.dp)
@@ -122,6 +124,7 @@ fun CustomTextField(
Text(
text = placeholderText,
color = placeholderTextColor,
+ style = TextStyle(fontSize = fontSize, textAlign = textAlign),
fontSize = fontSize,
textAlign = textAlign,
modifier = Modifier.fillMaxWidth()
@@ -131,6 +134,7 @@ fun CustomTextField(
}
},
cursorBrush = SolidColor(MullvadBlue),
+ visualTransformation = visualTransformation,
modifier =
modifier
.background(backgroundColor)
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/GroupedTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/GroupedTextField.kt
new file mode 100644
index 0000000000..d9fcecc597
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/GroupedTextField.kt
@@ -0,0 +1,47 @@
+package net.mullvad.mullvadvpn.compose.textfield
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.text.style.TextAlign
+
+@Composable
+fun GroupedTextField(
+ value: String,
+ keyboardType: KeyboardType,
+ modifier: Modifier = Modifier,
+ onValueChanged: (String) -> Unit,
+ onFocusChange: (Boolean) -> Unit,
+ onSubmit: (String) -> Unit,
+ isEnabled: Boolean = true,
+ visualTransformation: VisualTransformation,
+ placeholderText: String = "",
+ placeHolderColor: Color = MaterialTheme.colorScheme.primary,
+ maxCharLength: Int = Int.MAX_VALUE,
+ isValidValue: Boolean,
+ isDigitsOnlyAllowed: Boolean,
+ validateRegex: Regex,
+ defaultTextColor: Color = MaterialTheme.colorScheme.onPrimary,
+ textAlign: TextAlign = TextAlign.Start
+) {
+ CustomTextField(
+ value = value,
+ keyboardType = keyboardType,
+ onValueChanged = { if (validateRegex.matches(it)) onValueChanged(it) },
+ onFocusChange = onFocusChange,
+ onSubmit = onSubmit,
+ isValidValue = isValidValue,
+ isDigitsOnlyAllowed = isDigitsOnlyAllowed,
+ modifier = modifier,
+ isEnabled = isEnabled,
+ visualTransformation = visualTransformation,
+ placeholderText = placeholderText,
+ placeHolderColor = placeHolderColor,
+ maxCharLength = maxCharLength,
+ defaultTextColor = defaultTextColor,
+ textAlign = textAlign
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/VoucherVisualTransformation.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/VoucherVisualTransformation.kt
new file mode 100644
index 0000000000..c4d5eec0a1
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/VoucherVisualTransformation.kt
@@ -0,0 +1,38 @@
+package net.mullvad.mullvadvpn.compose.util
+
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.input.OffsetMapping
+import androidx.compose.ui.text.input.TransformedText
+import androidx.compose.ui.text.input.VisualTransformation
+import java.lang.Integer.min
+
+const val VOUCHER_SEPARATOR = "-"
+const val VOUCHER_CHUNK_SIZE = 4
+const val MAX_VOUCHER_LENGTH = 16
+
+fun vouchersVisualTransformation() = VisualTransformation { text ->
+ var out = text.chunked(VOUCHER_CHUNK_SIZE).joinToString(VOUCHER_SEPARATOR)
+ if (
+ text.length % VOUCHER_CHUNK_SIZE == 0 &&
+ text.isNotEmpty() &&
+ text.length < MAX_VOUCHER_LENGTH
+ ) {
+ out += VOUCHER_SEPARATOR
+ }
+ TransformedText(
+ AnnotatedString(out),
+ object : OffsetMapping {
+ override fun originalToTransformed(offset: Int): Int {
+ val res = offset + offset / ACCOUNT_TOKEN_CHUNK_SIZE
+ // Limit max input to 19 characters (16 voucher - 3 dividers)
+ return min(
+ res,
+ MAX_VOUCHER_LENGTH + MAX_VOUCHER_LENGTH / ACCOUNT_TOKEN_CHUNK_SIZE - 1
+ )
+ }
+
+ override fun transformedToOriginal(offset: Int): Int =
+ offset - offset / (ACCOUNT_TOKEN_CHUNK_SIZE + 1)
+ }
+ )
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/CommonConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/CommonConstant.kt
new file mode 100644
index 0000000000..a01aa08d8b
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/CommonConstant.kt
@@ -0,0 +1,3 @@
+package net.mullvad.mullvadvpn.constant
+
+const val VOUCHER_LENGTH = 16
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 18e98964e8..63fcf17ad2 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
@@ -32,6 +32,7 @@ import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel
import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel
import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel
import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel
+import net.mullvad.mullvadvpn.viewmodel.VoucherDialogViewModel
import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel
import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel
import org.apache.commons.validator.routines.InetAddressValidator
@@ -86,14 +87,15 @@ val uiModule = module {
viewModel { DeviceListViewModel(get(), get()) }
viewModel { DeviceRevokedViewModel(get(), get()) }
viewModel { LoginViewModel(get(), get()) }
+ viewModel { OutOfTimeViewModel(get(), get()) }
viewModel { PrivacyDisclaimerViewModel(get()) }
+ viewModel { ReportProblemViewModel(get()) }
viewModel { SelectLocationViewModel(get()) }
viewModel { SettingsViewModel(get(), get()) }
+ viewModel { ViewLogsViewModel(get()) }
+ viewModel { VoucherDialogViewModel(get(), get()) }
viewModel { VpnSettingsViewModel(get(), get(), get(), get()) }
viewModel { WelcomeViewModel(get(), get(), get()) }
- viewModel { ReportProblemViewModel(get()) }
- viewModel { ViewLogsViewModel(get()) }
- viewModel { OutOfTimeViewModel(get(), get()) }
}
const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME"
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/RedeemVoucherDialogFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/RedeemVoucherDialogFragment.kt
index 46472ea6cc..61f24e45fc 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/RedeemVoucherDialogFragment.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/RedeemVoucherDialogFragment.kt
@@ -1,187 +1,45 @@
package net.mullvad.mullvadvpn.ui.fragment
import android.app.Dialog
-import android.content.Context
-import android.graphics.drawable.ColorDrawable
import android.os.Bundle
-import android.text.Editable
-import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
-import android.view.ViewGroup.LayoutParams
-import android.widget.EditText
-import android.widget.TextView
+import androidx.compose.runtime.collectAsState
+import androidx.compose.ui.platform.ComposeView
import androidx.fragment.app.DialogFragment
import net.mullvad.mullvadvpn.R
-import net.mullvad.mullvadvpn.lib.common.util.JobTracker
-import net.mullvad.mullvadvpn.model.VoucherSubmissionError
-import net.mullvad.mullvadvpn.model.VoucherSubmissionResult
-import net.mullvad.mullvadvpn.repository.AccountRepository
-import net.mullvad.mullvadvpn.ui.MainActivity
-import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
-import net.mullvad.mullvadvpn.ui.serviceconnection.VoucherRedeemer
-import net.mullvad.mullvadvpn.ui.widget.Button
-import net.mullvad.mullvadvpn.util.SegmentedInputFormatter
-import org.joda.time.DateTime
-import org.koin.android.ext.android.inject
-
-const val FULL_VOUCHER_CODE_LENGTH = "XXXX-XXXX-XXXX-XXXX".length
+import net.mullvad.mullvadvpn.compose.screen.RedeemVoucherDialogScreen
+import net.mullvad.mullvadvpn.lib.theme.AppTheme
+import net.mullvad.mullvadvpn.viewmodel.VoucherDialogViewModel
+import org.koin.androidx.viewmodel.ext.android.viewModel
class RedeemVoucherDialogFragment : DialogFragment() {
- // Injected dependencies
- private val accountRepository: AccountRepository by inject()
- private val serviceConnectionManager: ServiceConnectionManager by inject()
-
- private val jobTracker = JobTracker()
-
- private lateinit var parentActivity: MainActivity
- private lateinit var errorMessage: TextView
- private lateinit var voucherInput: EditText
-
- private var accountExpiry: DateTime? = null
- private var redeemButton: Button? = null
- private var voucherRedeemer: VoucherRedeemer? = null
-
- private var voucherInputIsValid = false
- set(value) {
- field = value
- updateRedeemButton()
- }
-
- override fun onAttach(context: Context) {
- super.onAttach(context)
-
- parentActivity = context as MainActivity
-
- serviceConnectionManager.serviceNotifier.subscribe(this) { connection ->
- voucherRedeemer = connection?.voucherRedeemer
- }
-
- jobTracker.newUiJob("updateExpiry") {
- accountRepository.accountExpiryState.collect { accountExpiry = it.date() }
- }
-
- updateRedeemButton()
- }
+ private val vm by viewModel<VoucherDialogViewModel>()
+ private lateinit var voucherDialog: Dialog
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
- val view = inflater.inflate(R.layout.redeem_voucher, container, false)
-
- voucherInput =
- view.findViewById<EditText>(R.id.voucher_code).apply {
- addTextChangedListener(ValidVoucherCodeChecker())
+ return inflater.inflate(R.layout.fragment_compose, container, false).apply {
+ findViewById<ComposeView>(R.id.compose_view).setContent {
+ AppTheme {
+ RedeemVoucherDialogScreen(
+ uiState = vm.uiState.collectAsState().value,
+ onVoucherInputChange = { vm.onVoucherInputChange(it) },
+ onRedeem = { vm.onRedeem(it) },
+ onDismiss = { onDismiss(voucherDialog) }
+ )
+ }
}
-
- SegmentedInputFormatter(voucherInput, '-').apply {
- allCaps = true
-
- isValidInputCharacter = { character ->
- ('A' <= character && character <= 'Z') || ('0' <= character && character <= '9')
- }
- }
-
- redeemButton =
- view.findViewById<Button>(R.id.redeem).apply {
- isEnabled = false
-
- setOnClickAction("action", jobTracker) { submitVoucher() }
- }
-
- errorMessage = view.findViewById(R.id.error)
-
- view.findViewById<Button>(R.id.cancel).setOnClickAction("action", jobTracker) {
- activity?.onBackPressed()
}
-
- 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)
- }
-
- override fun onDestroyView() {
- jobTracker.cancelAllJobs()
-
- super.onDestroyView()
- }
-
- override fun onDetach() {
- jobTracker.cancelJob("updateExpiry")
- serviceConnectionManager.serviceNotifier.unsubscribe(this)
-
- super.onDetach()
- }
-
- private fun updateRedeemButton() {
- redeemButton?.isEnabled = voucherInputIsValid && voucherRedeemer != null
- }
-
- private suspend fun submitVoucher() {
- errorMessage.visibility = View.INVISIBLE
-
- val result = voucherRedeemer?.submit(voucherInput.text.toString())
-
- when (result) {
- is VoucherSubmissionResult.Ok -> handleAddedTime(result.submission.timeAdded)
- is VoucherSubmissionResult.Error -> showError(result.error)
- else -> {
- /* NOOP */
- }
- }
- }
-
- private fun handleAddedTime(timeAdded: Long) {
- if (timeAdded > 0) {
- dismiss()
- }
- }
-
- private fun showError(error: VoucherSubmissionError) {
- val message =
- when (error) {
- VoucherSubmissionError.InvalidVoucher -> R.string.invalid_voucher
- VoucherSubmissionError.VoucherAlreadyUsed -> R.string.voucher_already_used
- else -> R.string.error_occurred
- }
-
- errorMessage.apply {
- setText(message)
- visibility = View.VISIBLE
- }
- }
-
- inner class ValidVoucherCodeChecker : TextWatcher {
- private var editRecursionCount = 0
-
- override fun beforeTextChanged(text: CharSequence, start: Int, count: Int, after: Int) {
- editRecursionCount += 1
- }
-
- override fun onTextChanged(text: CharSequence, start: Int, before: Int, count: Int) {}
-
- override fun afterTextChanged(text: Editable) {
- editRecursionCount -= 1
-
- if (editRecursionCount == 0) {
- voucherInputIsValid = text.length == FULL_VOUCHER_CODE_LENGTH
- }
- }
+ voucherDialog = super.onCreateDialog(savedInstanceState)
+ return voucherDialog
}
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModel.kt
new file mode 100644
index 0000000000..07c56ff954
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModel.kt
@@ -0,0 +1,92 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import android.content.res.Resources
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.compose.state.LoginUiState
+import net.mullvad.mullvadvpn.compose.state.VoucherDialogState
+import net.mullvad.mullvadvpn.compose.state.VoucherDialogUiState
+import net.mullvad.mullvadvpn.model.VoucherSubmissionError
+import net.mullvad.mullvadvpn.model.VoucherSubmissionResult
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState
+import net.mullvad.mullvadvpn.ui.serviceconnection.VoucherRedeemer
+
+class VoucherDialogViewModel(
+ serviceConnectionManager: ServiceConnectionManager,
+ private val resources: Resources
+) : ViewModel() {
+
+ private val vmState = MutableStateFlow<VoucherDialogState>(VoucherDialogState.Default)
+ private val voucherInput = MutableStateFlow(LoginUiState.INITIAL.accountNumberInput)
+
+ private lateinit var voucherRedeemer: VoucherRedeemer
+ private val _shared: SharedFlow<ServiceConnectionContainer> =
+ serviceConnectionManager.connectionState
+ .flatMapLatest { state ->
+ if (state is ServiceConnectionState.ConnectedReady) {
+ flowOf(state.container)
+ } else {
+ emptyFlow()
+ }
+ }
+ .shareIn(viewModelScope, SharingStarted.WhileSubscribed())
+
+ var uiState =
+ _shared
+ .flatMapLatest { serviceConnection ->
+ voucherRedeemer = serviceConnection.voucherRedeemer
+ combine(vmState, voucherInput) { state, input ->
+ VoucherDialogUiState(voucherInput = input, voucherViewModelState = state)
+ }
+ }
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), VoucherDialogUiState.INITIAL)
+
+ fun onRedeem(voucherCode: String) {
+ vmState.update { VoucherDialogState.Verifying }
+ viewModelScope.launch {
+ when (val result = voucherRedeemer.submit(voucherCode)) {
+ is VoucherSubmissionResult.Ok -> handleAddedTime(result.submission.timeAdded)
+ is VoucherSubmissionResult.Error -> setError(result.error)
+ else -> {
+ vmState.update { VoucherDialogState.Default }
+ }
+ }
+ }
+ }
+
+ fun onVoucherInputChange(voucherString: String) {
+ voucherInput.value = voucherString
+ }
+
+ private fun handleAddedTime(timeAdded: Long) {
+ viewModelScope.launch { vmState.update { VoucherDialogState.Success(timeAdded) } }
+ }
+
+ private fun setError(error: VoucherSubmissionError) {
+ viewModelScope.launch {
+ val message =
+ resources.getString(
+ when (error) {
+ VoucherSubmissionError.InvalidVoucher -> R.string.invalid_voucher
+ VoucherSubmissionError.VoucherAlreadyUsed -> R.string.voucher_already_used
+ else -> R.string.error_occurred
+ }
+ )
+ vmState.update { VoucherDialogState.Error(message) }
+ }
+ }
+}
diff --git a/android/app/src/main/res/layout/redeem_voucher.xml b/android/app/src/main/res/layout/redeem_voucher.xml
deleted file mode 100644
index 3f6d91d183..0000000000
--- a/android/app/src/main/res/layout/redeem_voucher.xml
+++ /dev/null
@@ -1,58 +0,0 @@
-<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
- xmlns:mullvad="http://schemas.android.com/apk/res-auto"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:scrollbars="none">
- <LinearLayout android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:padding="30dp"
- android:background="@drawable/dialog_background"
- android:orientation="vertical"
- android:gravity="start">
- <TextView android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_weight="0"
- android:layout_marginBottom="9dp"
- android:textColor="@color/white"
- android:textSize="@dimen/text_medium"
- android:text="@string/enter_voucher_code" />
- <EditText android:id="@+id/voucher_code"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:padding="14dp"
- android:background="@drawable/edit_text_background"
- android:singleLine="true"
- android:imeActionLabel="@string/redeem"
- android:imeOptions="flagNoPersonalizedLearning"
- android:inputType="textCapCharacters"
- android:textCursorDrawable="@drawable/text_input_cursor"
- android:hint="@string/voucher_hint"
- android:maxLength="19"
- android:digits="0123456789-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
- android:textAllCaps="true"
- android:textColorHint="@color/blue40"
- android:textColor="@color/blue"
- android:textSize="@dimen/text_small"
- android:textStyle="bold" />
- <TextView android:id="@+id/error"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginTop="8dp"
- android:textColor="@color/red"
- android:textSize="@dimen/text_small"
- android:textStyle="bold"
- android:visibility="invisible" />
- <net.mullvad.mullvadvpn.ui.widget.Button android:id="@+id/redeem"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginVertical="@dimen/button_separation"
- mullvad:showSpinner="true"
- mullvad:buttonColor="green"
- mullvad:text="@string/redeem" />
- <net.mullvad.mullvadvpn.ui.widget.Button android:id="@+id/cancel"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- mullvad:buttonColor="blue"
- mullvad:text="@string/cancel" />
- </LinearLayout>
-</ScrollView>
diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModelTest.kt
new file mode 100644
index 0000000000..1c6240ba76
--- /dev/null
+++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModelTest.kt
@@ -0,0 +1,71 @@
+package net.mullvad.mullvadvpn.viewmodel
+
+import android.content.res.Resources
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.unmockkAll
+import kotlinx.coroutines.test.runTest
+import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
+import net.mullvad.mullvadvpn.model.VoucherSubmissionError
+import net.mullvad.mullvadvpn.model.VoucherSubmissionResult
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer
+import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
+import net.mullvad.mullvadvpn.ui.serviceconnection.VoucherRedeemer
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+
+class VoucherDialogViewModelTest {
+ @get:Rule val testCoroutineRule = TestCoroutineRule()
+
+ private val mockServiceConnectionManager: ServiceConnectionManager = mockk()
+ private val mockVoucherRedeemer: VoucherRedeemer = mockk()
+ private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk()
+ private val mockResources: Resources = mockk()
+
+ private val mockVoucherSubmissionErrorResult: VoucherSubmissionResult =
+ VoucherSubmissionResult.Error(VoucherSubmissionError.OtherError)
+
+ private lateinit var viewModel: VoucherDialogViewModel
+
+ @Before
+ fun setUp() {
+ mockkStatic(CACHE_EXTENSION_CLASS)
+ every { mockServiceConnectionManager.connectionState.value.readyContainer() } returns
+ mockServiceConnectionContainer
+ every { mockServiceConnectionContainer.voucherRedeemer } returns mockVoucherRedeemer
+
+ viewModel =
+ VoucherDialogViewModel(
+ serviceConnectionManager = mockServiceConnectionManager,
+ resources = mockResources
+ )
+ }
+
+ @After
+ fun tearDown() {
+ unmockkAll()
+ }
+
+ @Test
+ fun test_submit_invalid_voucher() = runTest {
+ val voucher = DUMMY_VALID_VOUCHER
+ val dummyStringResource = DUMMY_STRING_RESOURCE
+ // Arrange
+ every { mockResources.getString(any()) } returns dummyStringResource
+ coEvery { mockVoucherRedeemer.submit(voucher) } returns mockVoucherSubmissionErrorResult
+ // Act, Assert
+ viewModel.onRedeem(voucher)
+ coVerify(exactly = 1) { mockVoucherRedeemer.submit(voucher) }
+ }
+
+ companion object {
+ private const val CACHE_EXTENSION_CLASS = "net.mullvad.mullvadvpn.util.CacheExtensionsKt"
+ private const val DUMMY_VALID_VOUCHER = "DUMMY_VALID_VOUCHER"
+ private const val DUMMY_STRING_RESOURCE = "DUMMY_STRING_RESOURCE"
+ }
+}
diff --git a/android/lib/resource/src/main/res/values-da/strings.xml b/android/lib/resource/src/main/res/values-da/strings.xml
index 83d38568fa..bb26081112 100644
--- a/android/lib/resource/src/main/res/values-da/strings.xml
+++ b/android/lib/resource/src/main/res/values-da/strings.xml
@@ -192,6 +192,7 @@
<string name="view_logs">Se app-logfiler</string>
<string name="virtual_adapter_problem">Fejl ved virtuel adapter</string>
<string name="voucher_already_used">Kuponkode er allerede brugt.</string>
+ <string name="voucher_success_title">Indløsning af kuponen lykkedes.</string>
<string name="vpn_permission_denied_error">VPN-tilladelse blev nægtet, da tunnelen blev oprettet. Prøv at oprette forbindelse igen.</string>
<string name="vpn_permission_error_notification_message">Altid-til VPN er måske aktiveret for en anden app</string>
<string name="vpn_permission_error_notification_title">VPN-tilladelsesfejl</string>
diff --git a/android/lib/resource/src/main/res/values-de/strings.xml b/android/lib/resource/src/main/res/values-de/strings.xml
index cac9b2897a..357e4209e3 100644
--- a/android/lib/resource/src/main/res/values-de/strings.xml
+++ b/android/lib/resource/src/main/res/values-de/strings.xml
@@ -192,6 +192,7 @@
<string name="view_logs">App-Protokolle anzeigen</string>
<string name="virtual_adapter_problem">Virtueller Adapterfehler</string>
<string name="voucher_already_used">Der Gutscheincode wurde bereits verwendet.</string>
+ <string name="voucher_success_title">Der Gutschein wurde erfolgreich eingelöst.</string>
<string name="vpn_permission_denied_error">VPN-Berechtigungen wurden beim Erstellen des Tunnels abgelehnt.</string>
<string name="vpn_permission_error_notification_message">Always-on VPN könnte für eine andere App aktiviert sein</string>
<string name="vpn_permission_error_notification_title">VPN-Berechtigungsfehler</string>
diff --git a/android/lib/resource/src/main/res/values-es/strings.xml b/android/lib/resource/src/main/res/values-es/strings.xml
index 9fa273193b..1ca4ad0fa5 100644
--- a/android/lib/resource/src/main/res/values-es/strings.xml
+++ b/android/lib/resource/src/main/res/values-es/strings.xml
@@ -192,6 +192,7 @@
<string name="view_logs">Ver registros de la aplicación</string>
<string name="virtual_adapter_problem">Error del adaptador virtual</string>
<string name="voucher_already_used">El código del cupón ya se ha usado.</string>
+ <string name="voucher_success_title">El cupón se canjeó correctamente.</string>
<string name="vpn_permission_denied_error">Se denegó el permiso para usar una conexión VPN al crear el túnel. Intente volver a establecer la conexión.</string>
<string name="vpn_permission_error_notification_message">La VPN siempre activa podría estar habilitada en otra aplicación</string>
<string name="vpn_permission_error_notification_title">Error en la autorización de la VPN</string>
diff --git a/android/lib/resource/src/main/res/values-fi/strings.xml b/android/lib/resource/src/main/res/values-fi/strings.xml
index 90226d37cc..eb02e63756 100644
--- a/android/lib/resource/src/main/res/values-fi/strings.xml
+++ b/android/lib/resource/src/main/res/values-fi/strings.xml
@@ -192,6 +192,7 @@
<string name="view_logs">Tarkastele sovelluslokeja</string>
<string name="virtual_adapter_problem">Virtuaalisovittimen virhe</string>
<string name="voucher_already_used">Kuponkikoodi on jo käytetty.</string>
+ <string name="voucher_success_title">Kupongin lunastus onnistui.</string>
<string name="vpn_permission_denied_error">VPN-lupa evättiin tunnelia luotaessa. Yritä muodostaa yhteys uudelleen.</string>
<string name="vpn_permission_error_notification_message">Aina päällä oleva VPN on mahdollisesti otettu käyttöön toiselle sovellukselle</string>
<string name="vpn_permission_error_notification_title">VPN-lupavirhe</string>
diff --git a/android/lib/resource/src/main/res/values-fr/strings.xml b/android/lib/resource/src/main/res/values-fr/strings.xml
index 6ddea69f93..da970c6d89 100644
--- a/android/lib/resource/src/main/res/values-fr/strings.xml
+++ b/android/lib/resource/src/main/res/values-fr/strings.xml
@@ -192,6 +192,7 @@
<string name="view_logs">Afficher les journaux de l\'application</string>
<string name="virtual_adapter_problem">Erreur d\'adaptateur virtuel</string>
<string name="voucher_already_used">Le code du bon a déjà été utilisé.</string>
+ <string name="voucher_success_title">Le bon a bien été échangé.</string>
<string name="vpn_permission_denied_error">La permission VPN a été refusée lors de la création du tunnel. Veuillez essayer de vous reconnecter.</string>
<string name="vpn_permission_error_notification_message">« Toujours exiger un VPN » est peut-être activé pour une autre application</string>
<string name="vpn_permission_error_notification_title">Erreur de permission VPN</string>
diff --git a/android/lib/resource/src/main/res/values-it/strings.xml b/android/lib/resource/src/main/res/values-it/strings.xml
index 4a5c8854fa..c988e760cf 100644
--- a/android/lib/resource/src/main/res/values-it/strings.xml
+++ b/android/lib/resource/src/main/res/values-it/strings.xml
@@ -192,6 +192,7 @@
<string name="view_logs">Visualizza registri app</string>
<string name="virtual_adapter_problem">Errore scheda virtuale</string>
<string name="voucher_already_used">Il codice voucher è già stato utilizzato.</string>
+ <string name="voucher_success_title">Il voucher è stato riscattato correttamente.</string>
<string name="vpn_permission_denied_error">L\'autorizzazione VPN è stata negata durante la creazione del tunnel. Prova a connetterti di nuovo.</string>
<string name="vpn_permission_error_notification_message">La VPN sempre attiva potrebbe essere abilitata per un\'altra app</string>
<string name="vpn_permission_error_notification_title">Errore di autorizzazione VPN</string>
diff --git a/android/lib/resource/src/main/res/values-ja/strings.xml b/android/lib/resource/src/main/res/values-ja/strings.xml
index b00d94dd7d..8c9ef84739 100644
--- a/android/lib/resource/src/main/res/values-ja/strings.xml
+++ b/android/lib/resource/src/main/res/values-ja/strings.xml
@@ -192,6 +192,7 @@
<string name="view_logs">アプリのログを表示</string>
<string name="virtual_adapter_problem">仮想アダプタのエラー</string>
<string name="voucher_already_used">バウチャーコードはすでに使用されています。</string>
+ <string name="voucher_success_title">バウチャーを正常に使用しました。</string>
<string name="vpn_permission_denied_error">トンネルを作成中にVPNへのアクセスが拒否されました。もう一度接続してみてください。</string>
<string name="vpn_permission_error_notification_message">Always-on VPNが別のアプリで有効になっている可能性があります</string>
<string name="vpn_permission_error_notification_title">VPN許可エラー</string>
diff --git a/android/lib/resource/src/main/res/values-ko/strings.xml b/android/lib/resource/src/main/res/values-ko/strings.xml
index c72dabb4f0..209023a64e 100644
--- a/android/lib/resource/src/main/res/values-ko/strings.xml
+++ b/android/lib/resource/src/main/res/values-ko/strings.xml
@@ -192,6 +192,7 @@
<string name="view_logs">앱 로그 보기</string>
<string name="virtual_adapter_problem">가상 어댑터 오류</string>
<string name="voucher_already_used">이미 사용된 바우처 코드입니다.</string>
+ <string name="voucher_success_title">바우처가 성공적으로 사용되었습니다.</string>
<string name="vpn_permission_denied_error">터널을 만드는 동안 VPN 사용 권한이 거부되었습니다. 다시 연결해 보세요.</string>
<string name="vpn_permission_error_notification_message">상시 접속 VPN이 다른 앱에 활성화되었을 수 있습니다.</string>
<string name="vpn_permission_error_notification_title">VPN 권한 오류</string>
diff --git a/android/lib/resource/src/main/res/values-my/strings.xml b/android/lib/resource/src/main/res/values-my/strings.xml
index 7ce418ab09..0aef2a2c2e 100644
--- a/android/lib/resource/src/main/res/values-my/strings.xml
+++ b/android/lib/resource/src/main/res/values-my/strings.xml
@@ -192,6 +192,7 @@
<string name="view_logs">အက်ပ်မှတ်တမ်းများ ကြည့်ရန်</string>
<string name="virtual_adapter_problem">စက်တွင်း အဒက်တာ ချို့ယွင်းချက်</string>
<string name="voucher_already_used">ဘောက်ချာကုဒ် သုံးထားပြီးသား ဖြစ်ပါသည်။</string>
+ <string name="voucher_success_title">ဘောက်ချာကို အောင်မြင်စွာ လဲယူခဲ့ပါသည်။</string>
<string name="vpn_permission_denied_error">Tunnel ဖန်တီးနေစဉ် VPN ခွင့်ပြုချက်ကို ပယ်ချခဲ့ပါသည်။ ထပ်မံချိတ်ဆက်ပေးပါ။</string>
<string name="vpn_permission_error_notification_message">အမြဲဖွင့် VPN ကို နောက်ထပ်အက်ပ်အတွက် ဖွင့်ထားနိုင်ပါသည်</string>
<string name="vpn_permission_error_notification_title">VPN ခွင့်ပြုချက် ချို့ယွင်းချက်</string>
diff --git a/android/lib/resource/src/main/res/values-nb/strings.xml b/android/lib/resource/src/main/res/values-nb/strings.xml
index c852f8916f..2b0e370bb3 100644
--- a/android/lib/resource/src/main/res/values-nb/strings.xml
+++ b/android/lib/resource/src/main/res/values-nb/strings.xml
@@ -192,6 +192,7 @@
<string name="view_logs">Se applogger</string>
<string name="virtual_adapter_problem">Virtuell adapterfeil</string>
<string name="voucher_already_used">Kupongkoden er allerede brukt.</string>
+ <string name="voucher_success_title">Kupongkoden er løst inn.</string>
<string name="vpn_permission_denied_error">VPN-tillatelse ble avvist under opprettelsen av tunnelen. Prøv å koble til igjen.</string>
<string name="vpn_permission_error_notification_message">VPN som alltid er på, kan være aktivert for en annen app</string>
<string name="vpn_permission_error_notification_title">Feil med VPN-tillatelse</string>
diff --git a/android/lib/resource/src/main/res/values-nl/strings.xml b/android/lib/resource/src/main/res/values-nl/strings.xml
index 3d518bae5e..cdbaa554c3 100644
--- a/android/lib/resource/src/main/res/values-nl/strings.xml
+++ b/android/lib/resource/src/main/res/values-nl/strings.xml
@@ -192,6 +192,7 @@
<string name="view_logs">Applogboeken weergeven</string>
<string name="virtual_adapter_problem">Fout virtuele adapter</string>
<string name="voucher_already_used">Vouchercode is al gebruikt.</string>
+ <string name="voucher_success_title">Voucher is ingewisseld.</string>
<string name="vpn_permission_denied_error">VPN-toestemming is geweigerd tijdens maken van de tunnel. Probeer opnieuw verbinding te maken.</string>
<string name="vpn_permission_error_notification_message">Altijd-aan VPN is mogelijk ingeschakeld voor een andere app</string>
<string name="vpn_permission_error_notification_title">VPN-machtigingsfout</string>
diff --git a/android/lib/resource/src/main/res/values-pl/strings.xml b/android/lib/resource/src/main/res/values-pl/strings.xml
index eb8a91d7e1..2e2e6ee267 100644
--- a/android/lib/resource/src/main/res/values-pl/strings.xml
+++ b/android/lib/resource/src/main/res/values-pl/strings.xml
@@ -192,6 +192,7 @@
<string name="view_logs">Wyświetl dzienniki aplikacji</string>
<string name="virtual_adapter_problem">Błąd wirtualnej karty sieciowej</string>
<string name="voucher_already_used">Kod z tego kuponu został już użyty.</string>
+ <string name="voucher_success_title">Kupon został zrealizowany.</string>
<string name="vpn_permission_denied_error">Uprawnienie VPN zostało odrzucone podczas tworzenia tunelu. Spróbuj połączyć się ponownie.</string>
<string name="vpn_permission_error_notification_message">Opcja „Zawsze włączony VPN” może być włączona dla innej aplikacji</string>
<string name="vpn_permission_error_notification_title">Błąd uprawnienia VPN</string>
diff --git a/android/lib/resource/src/main/res/values-pt/strings.xml b/android/lib/resource/src/main/res/values-pt/strings.xml
index a3ac329681..2fee06cab6 100644
--- a/android/lib/resource/src/main/res/values-pt/strings.xml
+++ b/android/lib/resource/src/main/res/values-pt/strings.xml
@@ -192,6 +192,7 @@
<string name="view_logs">Ver os registos da app</string>
<string name="virtual_adapter_problem">Erro de adaptador virtual</string>
<string name="voucher_already_used">O código do voucher já foi utilizado.</string>
+ <string name="voucher_success_title">O voucher foi reclamado com sucesso.</string>
<string name="vpn_permission_denied_error">A transmissão foi negada durante a criação do túnel. Tente fazer novamente a ligação.</string>
<string name="vpn_permission_error_notification_message">A VPN sempre ligada pode estar ativada para outra app</string>
<string name="vpn_permission_error_notification_title">Erro de permissão da VPN</string>
diff --git a/android/lib/resource/src/main/res/values-ru/strings.xml b/android/lib/resource/src/main/res/values-ru/strings.xml
index e6b8c35e67..0fb01c88ad 100644
--- a/android/lib/resource/src/main/res/values-ru/strings.xml
+++ b/android/lib/resource/src/main/res/values-ru/strings.xml
@@ -192,6 +192,7 @@
<string name="view_logs">Открыть журналы</string>
<string name="virtual_adapter_problem">Ошибка виртуального адаптера</string>
<string name="voucher_already_used">Этот код ваучера уже использовался.</string>
+ <string name="voucher_success_title">Ваучер погашен.</string>
<string name="vpn_permission_denied_error">При создании туннеля в доступе к VPN было отказано. Попробуйте подключиться снова.</string>
<string name="vpn_permission_error_notification_message">Опцию «VPN всегда вкл.» может быть включена для другого приложения</string>
<string name="vpn_permission_error_notification_title">Ошибка разрешения для VPN</string>
diff --git a/android/lib/resource/src/main/res/values-sv/strings.xml b/android/lib/resource/src/main/res/values-sv/strings.xml
index 7606a30e6b..c65809dc5d 100644
--- a/android/lib/resource/src/main/res/values-sv/strings.xml
+++ b/android/lib/resource/src/main/res/values-sv/strings.xml
@@ -192,6 +192,7 @@
<string name="view_logs">Visa appens loggar</string>
<string name="virtual_adapter_problem">Fel med virtuell adapter</string>
<string name="voucher_already_used">Kupongkoden har redan använts.</string>
+ <string name="voucher_success_title">Kupongen har lösts in.</string>
<string name="vpn_permission_denied_error">VPN-behörighet nekades när tunneln skapades. Försök att ansluta igen.</string>
<string name="vpn_permission_error_notification_message">VPN som alltid är på kan ha aktiverats för annan app</string>
<string name="vpn_permission_error_notification_title">Behörighetsfel med VPN</string>
diff --git a/android/lib/resource/src/main/res/values-th/strings.xml b/android/lib/resource/src/main/res/values-th/strings.xml
index 86484e4137..3f01840e0a 100644
--- a/android/lib/resource/src/main/res/values-th/strings.xml
+++ b/android/lib/resource/src/main/res/values-th/strings.xml
@@ -192,6 +192,7 @@
<string name="view_logs">ดูบันทึกของแอป</string>
<string name="virtual_adapter_problem">ข้อผิดพลาดของอะแดปเตอร์เสมือน</string>
<string name="voucher_already_used">รหัสบัตรกำนัลถูกใช้ไปแล้ว</string>
+ <string name="voucher_success_title">แลกบัตรกำนัลสำเร็จแล้ว</string>
<string name="vpn_permission_denied_error">การให้สิทธิ์ VPN ถูกปฏิเสธ ในขณะที่สร้างอุโมงค์ โปรดลองเชื่อมต่อใหม่อีกครั้ง</string>
<string name="vpn_permission_error_notification_message">Always-on VPN อาจได้รับการเปิดใช้งานสำหรับแอปอื่น</string>
<string name="vpn_permission_error_notification_title">เกิดข้อผิดพลาดในการอนุญาต VPN</string>
diff --git a/android/lib/resource/src/main/res/values-tr/strings.xml b/android/lib/resource/src/main/res/values-tr/strings.xml
index b91a7e0bc0..08ff5f47e6 100644
--- a/android/lib/resource/src/main/res/values-tr/strings.xml
+++ b/android/lib/resource/src/main/res/values-tr/strings.xml
@@ -192,6 +192,7 @@
<string name="view_logs">Uygulama kayıtlarını görüntüle</string>
<string name="virtual_adapter_problem">Sanal adaptör hatası</string>
<string name="voucher_already_used">Kupon kodu zaten kullanılmış.</string>
+ <string name="voucher_success_title">Kupon başarıyla kullanıldı.</string>
<string name="vpn_permission_denied_error">Tünel oluşturulurken VPN izni reddedildi. Lütfen tekrar bağlanmayı deneyin.</string>
<string name="vpn_permission_error_notification_message">Her zaman açık VPN başka bir uygulama için etkinleştirilebilir</string>
<string name="vpn_permission_error_notification_title">VPN izin hatası</string>
diff --git a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml
index 5082b2e004..174262c638 100644
--- a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml
+++ b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml
@@ -192,6 +192,7 @@
<string name="view_logs">查看应用日志</string>
<string name="virtual_adapter_problem">虚拟适配器错误</string>
<string name="voucher_already_used">该优惠券码已被使用。</string>
+ <string name="voucher_success_title">优惠券已成功兑换。</string>
<string name="vpn_permission_denied_error">创建隧道时,VPN 权限被拒绝。请尝试重新连接。</string>
<string name="vpn_permission_error_notification_message">可能为另一个应用启用了“始终启用 VPN”</string>
<string name="vpn_permission_error_notification_title">VPN 权限错误</string>
diff --git a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml
index e1daa0c67e..70b0d42c55 100644
--- a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml
+++ b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml
@@ -192,6 +192,7 @@
<string name="view_logs">檢視應用程式日誌</string>
<string name="virtual_adapter_problem">虛擬配接器錯誤</string>
<string name="voucher_already_used">此憑證兌換碼已有人用過。</string>
+ <string name="voucher_success_title">憑證已成功兌換。</string>
<string name="vpn_permission_denied_error">建立通道時,VPN 權限被拒絕。請嘗試重新連線。</string>
<string name="vpn_permission_error_notification_message">可能已為另一個應用程式啟用了「始終啟用 VPN」</string>
<string name="vpn_permission_error_notification_title">VPN 權限錯誤</string>
diff --git a/android/lib/resource/src/main/res/values/plurals.xml b/android/lib/resource/src/main/res/values/plurals.xml
index b9aa90441e..455d42c1f2 100644
--- a/android/lib/resource/src/main/res/values/plurals.xml
+++ b/android/lib/resource/src/main/res/values/plurals.xml
@@ -40,4 +40,12 @@
<item quantity="one">Account credit expires in an hour</item>
<item quantity="other">Account credit expires in %d hours</item>
</plurals>
+ <plurals name="days">
+ <item quantity="one">%d day</item>
+ <item quantity="other">%d days</item>
+ </plurals>
+ <plurals name="months">
+ <item quantity="one">%d month</item>
+ <item quantity="other">%d months</item>
+ </plurals>
</resources>
diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml
index 988b343c19..bc9630e974 100644
--- a/android/lib/resource/src/main/res/values/strings.xml
+++ b/android/lib/resource/src/main/res/values/strings.xml
@@ -220,4 +220,8 @@
<string name="custom_port_dialog_remove">Remove custom port</string>
<string name="custom_port_dialog_valid_ranges">Valid ranges: %s</string>
<string name="custom_port_dialog_placeholder">Enter port</string>
+ <string name="voucher_success_title">Voucher was successfully redeemed.</string>
+ <string name="verifying_voucher">Verifying voucher…</string>
+ <string name="added_to_your_account">%s was added to your account.</string>
+ <string name="less_than_one_day">less than one day</string>
</resources>
diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
index c3fd722a12..bb56f7df48 100644
--- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
+++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt
@@ -55,6 +55,7 @@ data class Dimensions(
val sideMargin: Dp = 22.dp,
val smallPadding: Dp = 8.dp,
val spacingAboveButton: Dp = 22.dp,
+ val successIconVerticalPadding: Dp = 26.dp,
val titleIconSize: Dp = 24.dp,
val topBarHeight: Dp = 64.dp,
val verticalSpace: Dp = 20.dp,
diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot
index 61f4d4be5a..d28aac8520 100644
--- a/gui/locales/messages.pot
+++ b/gui/locales/messages.pot
@@ -1653,6 +1653,9 @@ msgctxt "wireguard-settings-view"
msgid "Which TCP port the UDP-over-TCP obfuscation protocol should connect to on the VPN server."
msgstr ""
+msgid "%s was added to your account."
+msgstr ""
+
msgid "Account credit expires in a few minutes"
msgstr ""
@@ -1818,6 +1821,9 @@ msgstr ""
msgid "Valid ranges: %s"
msgstr ""
+msgid "Verifying voucher…"
+msgstr ""
+
msgid "Virtual adapter error"
msgstr ""
@@ -1845,6 +1851,19 @@ msgstr ""
msgid "less than a minute ago"
msgstr ""
+msgid "less than one day"
+msgstr ""
+
+msgid "%d day"
+msgid_plural "%d days"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "%d month"
+msgid_plural "%d months"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "Account credit expires in a day"
msgid_plural "Account credit expires in %d days"
msgstr[0] ""