summaryrefslogtreecommitdiffhomepage
path: root/android/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'android/app/src')
-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
12 files changed, 620 insertions, 224 deletions
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"
+ }
+}