diff options
24 files changed, 548 insertions, 25 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 24be9f238c..c92c9464b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Line wrap the file at 100 chars. Th #### Android - Add possibility to create account from the login screen. - Add welcome screen for newly created accounts. +- Allow submitting voucher codes to add time to the account. ### Changed - Move location of the account data (including the WireGuard keys), so that it isn't lost when the diff --git a/Cargo.lock b/Cargo.lock index 40d529824b..5bace2d4e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -982,18 +982,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" [[package]] name = "jnix" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ "jni 0.14.0 (registry+https://github.com/rust-lang/crates.io-index)", - "jnix-macros 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", + "jnix-macros 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)", "once_cell 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)", "parking_lot 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] name = "jnix-macros" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ "heck 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1436,7 +1436,7 @@ dependencies = [ "err-derive 0.2.4 (registry+https://github.com/rust-lang/crates.io-index)", "futures 0.1.29 (registry+https://github.com/rust-lang/crates.io-index)", "ipnetwork 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", - "jnix 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", + "jnix 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)", "jsonrpc-client-core 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", "jsonrpc-core 8.0.1 (registry+https://github.com/rust-lang/crates.io-index)", "lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1551,7 +1551,7 @@ dependencies = [ "chrono 0.4.9 (registry+https://github.com/rust-lang/crates.io-index)", "err-derive 0.2.4 (registry+https://github.com/rust-lang/crates.io-index)", "ipnetwork 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", - "jnix 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", + "jnix 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)", "lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)", "mullvad-paths 0.1.0", @@ -2720,7 +2720,7 @@ dependencies = [ "futures 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)", "hex 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "ipnetwork 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", - "jnix 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", + "jnix 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)", "jsonrpc-core 8.0.2 (git+https://github.com/mullvad/jsonrpc?branch=mullvad-fork)", "jsonrpc-macros 8.0.1 (git+https://github.com/mullvad/jsonrpc?branch=mullvad-fork)", "lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -2809,7 +2809,7 @@ dependencies = [ "base64 0.10.1 (registry+https://github.com/rust-lang/crates.io-index)", "err-derive 0.2.4 (registry+https://github.com/rust-lang/crates.io-index)", "ipnetwork 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)", - "jnix 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", + "jnix 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)", "rand 0.7.2 (registry+https://github.com/rust-lang/crates.io-index)", "serde 1.0.101 (registry+https://github.com/rust-lang/crates.io-index)", "x25519-dalek 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", @@ -3672,8 +3672,8 @@ dependencies = [ "checksum itoa 0.4.4 (registry+https://github.com/rust-lang/crates.io-index)" = "501266b7edd0174f8530248f87f99c88fbe60ca4ef3dd486835b8d8d53136f7f" "checksum jni 0.14.0 (registry+https://github.com/rust-lang/crates.io-index)" = "1981310da491a4f0f815238097d0d43d8072732b5ae5f8bd0d8eadf5bf245402" "checksum jni-sys 0.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" -"checksum jnix 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "cd26c8a9ccd3b8c1d47e17f290237ae1c4bd3a89ef31fafc61bc09804cc2b1ec" -"checksum jnix-macros 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)" = "a819d1d2905045ab6da444b4aa612e82a028508e6148acd9b61ce4985b4d6f8f" +"checksum jnix 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "6354ae923ca4df982181ae2cd77eb4214f8c11d11d0c0cd8606c9347ac2abc57" +"checksum jnix-macros 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "c78132fe420156f13b30518fcda9449b0ab8ae3b5584e8a1c53ce390fe770b44" "checksum js-sys 0.3.37 (registry+https://github.com/rust-lang/crates.io-index)" = "6a27d435371a2fa5b6d2b028a74bbdb1234f308da363226a2854ca3ff8ba7055" "checksum jsonrpc-client-core 0.5.0 (git+https://github.com/mullvad/jsonrpc-client-rs?rev=68aac55b)" = "<none>" "checksum jsonrpc-client-core 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)" = "f29cb249837420fb0cee7fb0fbf1d22679e121b160e71bb5e0d90b9df241c23e" diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmission.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmission.kt new file mode 100644 index 0000000000..9a14c4cf7e --- /dev/null +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmission.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.model + +data class VoucherSubmission(val timeAdded: Long, val newExpiry: String) diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmissionResult.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmissionResult.kt new file mode 100644 index 0000000000..33c57a595a --- /dev/null +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/model/VoucherSubmissionResult.kt @@ -0,0 +1,9 @@ +package net.mullvad.mullvadvpn.model + +sealed class VoucherSubmissionResult { + class Ok(val submission: VoucherSubmission) : VoucherSubmissionResult() + class InvalidVoucher : VoucherSubmissionResult() + class VoucherAlreadyUsed : VoucherSubmissionResult() + class RpcError : VoucherSubmissionResult() + class OtherError : VoucherSubmissionResult() +} diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt index 593139a8a9..3c55f76b7f 100644 --- a/android/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt @@ -9,6 +9,7 @@ import net.mullvad.mullvadvpn.model.RelayList import net.mullvad.mullvadvpn.model.RelaySettingsUpdate import net.mullvad.mullvadvpn.model.Settings import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.model.VoucherSubmissionResult import net.mullvad.talpid.util.EventNotifier class MullvadDaemon(val vpnService: MullvadVpnService) { @@ -109,6 +110,10 @@ class MullvadDaemon(val vpnService: MullvadVpnService) { shutdown(daemonInterfaceAddress) } + fun submitVoucher(voucher: String): VoucherSubmissionResult { + return submitVoucher(daemonInterfaceAddress, voucher) + } + fun updateRelaySettings(update: RelaySettingsUpdate) { updateRelaySettings(daemonInterfaceAddress, update) } @@ -147,6 +152,10 @@ class MullvadDaemon(val vpnService: MullvadVpnService) { private external fun setAutoConnect(daemonInterfaceAddress: Long, alwaysOn: Boolean) private external fun setWireguardMtu(daemonInterfaceAddress: Long, wireguardMtu: Int?) private external fun shutdown(daemonInterfaceAddress: Long) + private external fun submitVoucher( + daemonInterfaceAddress: Long, + voucher: String + ): VoucherSubmissionResult private external fun updateRelaySettings( daemonInterfaceAddress: Long, update: RelaySettingsUpdate diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/RedeemVoucherDialogFragment.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/RedeemVoucherDialogFragment.kt new file mode 100644 index 0000000000..7dde157903 --- /dev/null +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/RedeemVoucherDialogFragment.kt @@ -0,0 +1,172 @@ +package net.mullvad.mullvadvpn.ui + +import android.app.Dialog +import android.content.Context +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.support.v4.app.DialogFragment +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 net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.model.VoucherSubmissionResult +import net.mullvad.mullvadvpn.service.MullvadDaemon +import net.mullvad.mullvadvpn.ui.widget.Button +import net.mullvad.mullvadvpn.util.JobTracker +import net.mullvad.mullvadvpn.util.SegmentedInputFormatter + +const val FULL_VOUCHER_CODE_LENGTH = "XXXX-XXXX-XXXX-XXXX".length + +class RedeemVoucherDialogFragment : DialogFragment() { + private val jobTracker = JobTracker() + + private lateinit var parentActivity: MainActivity + private lateinit var errorMessage: TextView + private lateinit var voucherInput: EditText + + private var redeemButton: Button? = null + private var subscriptionId: Int? = null + + private var daemon: MullvadDaemon? = null + set(value) { + field = value + updateRedeemButton() + } + + private var voucherInputIsValid = false + set(value) { + field = value + updateRedeemButton() + } + + override fun onAttach(context: Context) { + super.onAttach(context) + + parentActivity = context as MainActivity + + subscriptionId = parentActivity.serviceNotifier.subscribe { connection -> + daemon = connection?.daemon + } + } + + 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()) + } + + SegmentedInputFormatter(voucherInput, '-').apply { + allCaps = true + + isValidInputCharacter = { character -> + ('A' <= character && character <= 'Z') || ('0' <= character && character <= '9') + } + } + + redeemButton = view.findViewById<Button>(R.id.redeem).apply { + setEnabled(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() { + subscriptionId?.let { id -> + parentActivity.serviceNotifier.unsubscribe(id) + } + + super.onDetach() + } + + private fun updateRedeemButton() { + redeemButton?.apply { + setEnabled(voucherInputIsValid && daemon != null) + } + } + + private suspend fun submitVoucher() { + errorMessage.visibility = View.INVISIBLE + + val result = jobTracker.runOnBackground { + daemon?.submitVoucher(voucherInput.text.toString()) + } + + when (result) { + is VoucherSubmissionResult.Ok -> { + if (result.submission.timeAdded > 0) { + dismiss() + } + } + is VoucherSubmissionResult.InvalidVoucher -> showError(R.string.invalid_voucher) + is VoucherSubmissionResult.VoucherAlreadyUsed -> { + showError(R.string.voucher_already_used) + } + else -> showError(R.string.error_occurred) + } + } + + private fun showError(message: Int) { + 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 + } + } + } +} diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/WelcomeFragment.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/WelcomeFragment.kt index a30b8b1dea..43e47dfd9b 100644 --- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/WelcomeFragment.kt +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/WelcomeFragment.kt @@ -11,6 +11,7 @@ import android.widget.TextView import android.widget.Toast import kotlinx.coroutines.delay import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.ui.widget.Button import net.mullvad.mullvadvpn.ui.widget.UrlButton import net.mullvad.mullvadvpn.util.JobTracker import org.joda.time.DateTime @@ -41,6 +42,12 @@ class WelcomeFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) { prepare(daemon, jobTracker) } + view.findViewById<Button>(R.id.redeem_voucher).apply { + setOnClickAction("openRedeemVoucherDialog", jobTracker) { + showRedeemVoucherDialog() + } + } + return view } @@ -127,4 +134,12 @@ class WelcomeFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) { Toast.makeText(context, toastMessage, Toast.LENGTH_SHORT).show() } + + private fun showRedeemVoucherDialog() { + val transaction = fragmentManager?.beginTransaction() + + transaction?.addToBackStack(null) + + RedeemVoucherDialogFragment().show(transaction, null) + } } diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/Button.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/Button.kt index 106be5b28a..ac510d0735 100644 --- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/Button.kt +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/Button.kt @@ -104,6 +104,8 @@ open class Button : FrameLayout { init { button.setOnClickListener { jobTracker?.newUiJob(clickJobName!!) { + setEnabled(false) + if (showSpinner) { image.visibility = GONE spinner.visibility = VISIBLE @@ -116,6 +118,8 @@ open class Button : FrameLayout { if (detailImage != null) { image.visibility = VISIBLE } + + setEnabled(true) } } } diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/util/SegmentedInputFormatter.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/util/SegmentedInputFormatter.kt new file mode 100644 index 0000000000..ed471778eb --- /dev/null +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/util/SegmentedInputFormatter.kt @@ -0,0 +1,137 @@ +package net.mullvad.mullvadvpn.util + +import android.text.Editable +import android.text.TextWatcher +import android.widget.EditText + +class SegmentedInputFormatter(val input: EditText, var separator: Char) : TextWatcher { + private var editing = false + private var removing = false + private var separatorSkipCount = 5 + + var allCaps = false + var isValidInputCharacter: (Char) -> Boolean = { _ -> true } + + var segmentSize = 4 + set(value) { + field = value + separatorSkipCount = value + 1 + } + + init { + input.addTextChangedListener(this) + } + + override fun beforeTextChanged(text: CharSequence, start: Int, count: Int, after: Int) { + if (!editing) { + editing = true + removing = after < count + } + } + + override fun onTextChanged(text: CharSequence, start: Int, before: Int, count: Int) {} + + override fun afterTextChanged(text: Editable) { + val string = text.toString() + + if (isValidInput(string)) { + editing = false + maybeUpdateSelection() + } else { + formatInput(text) + } + } + + private fun maybeUpdateSelection() { + if (removing) { + var start = input.selectionStart + var end = input.selectionEnd + var changed = false + + if (start % separatorSkipCount == 0) { + start -= 1 + changed = true + } + + if (end % separatorSkipCount == 0) { + end -= 1 + changed = true + } + + if (changed) { + input.setSelection(start, end) + } + } + } + + private fun isValidInput(string: String): Boolean { + return string + .asSequence() + .withIndex() + .all { item -> + val index = item.index + val character = item.value + + if ((index + 1) % separatorSkipCount == 0) { + character == separator + } else { + isValidInputCharacter(character) + } + } + } + + private fun formatInput(input: Editable) { + var index = 0 + val length = input.length + var changed = false + + while (index < length && !changed) { + val segmentStart = index + val segmentEnd = index + segmentSize - 1 + val separatorPosition = segmentEnd + 1 + + changed = formatSegment(input, segmentStart..segmentEnd) || + formatSeparator(input, separatorPosition) + + index = separatorPosition + 1 + } + } + + private fun formatSegment(input: Editable, range: IntRange): Boolean { + val length = input.length + val start = range.start + var end = range.endInclusive + + if (start < length) { + end = minOf(end, length - 1) + + for (index in start..end) { + val character = input[index] + + if (allCaps && character >= 'a' && character <= 'z') { + input.replace(index, index + 1, character.toString().toUpperCase()) + } else if (!isValidInputCharacter(character)) { + input.delete(index, index + 1) + } else { + // Only continue looping if no changes were made to the string + continue + } + + // Abort loop because the input was edited and `afterTextChanged` will be called + // again + return true + } + } + + return false + } + + private fun formatSeparator(input: Editable, index: Int): Boolean { + if (index < input.length && input[index] != '-') { + input.insert(index, "-") + return true + } else { + return false + } + } +} diff --git a/android/src/main/res/drawable/dialog_background.xml b/android/src/main/res/drawable/dialog_background.xml index d0dee092f5..a552adc351 100644 --- a/android/src/main/res/drawable/dialog_background.xml +++ b/android/src/main/res/drawable/dialog_background.xml @@ -1,6 +1,11 @@ <?xml version="1.0" encoding="utf-8"?> -<shape xmlns:android="http://schemas.android.com/apk/res/android" - android:shape="rectangle"> - <corners android:radius="11dp" /> - <solid android:color="@color/darkBlue" /> -</shape> +<inset xmlns:android="http://schemas.android.com/apk/res/android" + android:insetTop="@dimen/dialog_margin" + android:insetLeft="@dimen/dialog_margin" + android:insetRight="@dimen/dialog_margin" + android:insetBottom="@dimen/dialog_margin"> + <shape android:shape="rectangle"> + <corners android:radius="11dp" /> + <solid android:color="@color/darkBlue" /> + </shape> +</inset> diff --git a/android/src/main/res/drawable/edit_text_background.xml b/android/src/main/res/drawable/edit_text_background.xml new file mode 100644 index 0000000000..06252ac37c --- /dev/null +++ b/android/src/main/res/drawable/edit_text_background.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<selector xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <item android:state_enabled="false"> + <inset android:insetTop="1dp" + android:insetBottom="1dp" + android:insetLeft="1dp" + android:insetRight="1dp"> + <shape android:shape="rectangle"> + <corners android:radius="@dimen/edit_text_corner_radius" /> + <solid android:color="@color/white20" /> + </shape> + </inset> + </item> + <item android:state_enabled="true"> + <inset android:insetTop="1dp" + android:insetBottom="1dp" + android:insetLeft="1dp"> + <shape android:shape="rectangle"> + <corners android:radius="@dimen/edit_text_corner_radius" /> + <solid android:color="@color/white" /> + </shape> + </inset> + </item> +</selector> diff --git a/android/src/main/res/layout/confirm_no_email.xml b/android/src/main/res/layout/confirm_no_email.xml index 10955cf0f6..7ae862302f 100644 --- a/android/src/main/res/layout/confirm_no_email.xml +++ b/android/src/main/res/layout/confirm_no_email.xml @@ -1,7 +1,7 @@ <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:padding="16dp" + android:padding="30dp" android:background="@drawable/dialog_background" android:orientation="vertical" android:gravity="left" diff --git a/android/src/main/res/layout/redeem_voucher.xml b/android/src/main/res/layout/redeem_voucher.xml new file mode 100644 index 0000000000..2728d543d3 --- /dev/null +++ b/android/src/main/res/layout/redeem_voucher.xml @@ -0,0 +1,55 @@ +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:mullvad="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="30dp" + android:background="@drawable/dialog_background" + android:orientation="vertical" + android:gravity="left" + android:elevation="2dp"> + <TextView android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_weight="0" + android:layout_marginBottom="12dp" + android:textColor="@color/white80" + android:textSize="16sp" + 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="13sp" + 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="13sp" + 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="16dp" + 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> diff --git a/android/src/main/res/layout/welcome.xml b/android/src/main/res/layout/welcome.xml index 521bfc4dff..34fe031622 100644 --- a/android/src/main/res/layout/welcome.xml +++ b/android/src/main/res/layout/welcome.xml @@ -53,7 +53,7 @@ android:textSize="13sp" android:text="@string/here_is_your_account_number" /> <TextView android:id="@+id/account_number" - android:layout_width="wrap_content" + android:layout_width="match_parent" android:layout_height="wrap_content" android:paddingHorizontal="24dp" android:paddingVertical="11dp" @@ -82,9 +82,15 @@ <net.mullvad.mullvadvpn.ui.widget.UrlButton android:id="@+id/buy_credit" android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_marginBottom="24dp" mullvad:buttonColor="green" mullvad:text="@string/buy_credit" mullvad:url="@string/account_url" mullvad:withToken="true" /> + <net.mullvad.mullvadvpn.ui.widget.Button android:id="@+id/redeem_voucher" + android:layout_width="match_parent" + android:layout_height="wrap_content" + mullvad:buttonColor="green" + mullvad:text="@string/redeem_voucher" /> </LinearLayout> </LinearLayout> diff --git a/android/src/main/res/values/dimensions.xml b/android/src/main/res/values/dimensions.xml index 5d106210ea..4342bec6c5 100644 --- a/android/src/main/res/values/dimensions.xml +++ b/android/src/main/res/values/dimensions.xml @@ -3,7 +3,9 @@ <dimen name="city_row_padding">40dp</dimen> <dimen name="relay_row_padding">60dp</dimen> <dimen name="relay_list_divider">1dp</dimen> + <dimen name="dialog_margin">14dp</dimen> <dimen name="account_input_corner_radius">4dp</dimen> + <dimen name="edit_text_corner_radius">4dp</dimen> <dimen name="normal_button_height">44dp</dimen> <dimen name="tall_button_height">64dp</dimen> <dimen name="cell_switch_border_radius">16dp</dimen> diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 4e03f847ee..27d7350a0c 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -26,9 +26,16 @@ <string name="account_created">Account created</string> <string name="congrats">Congrats!</string> <string name="here_is_your_account_number">Here\'s your account number. Save it!</string> - <string name="pay_to_start_using">To start using the app, you first need to add time to you - account. Buy credit on our website.</string> + <string name="pay_to_start_using">To start using the app, you first need to add time to your + account. Either buy credit on our website or redeem a voucher.</string> <string name="buy_credit">Buy credit</string> + <string name="redeem_voucher">Redeem voucher</string> + <string name="enter_voucher_code">Enter voucher code</string> + <string name="voucher_hint">XXXX-XXXX-XXXX-XXXX</string> + <string name="redeem">Redeem</string> + <string name="invalid_voucher">Voucher code is invalid.</string> + <string name="voucher_already_used">Voucher code has already been used.</string> + <string name="error_occurred">An error occurred.</string> <string name="settings">Settings</string> <string name="settings_account">Account</string> <string name="less_than_a_day_left">less than a day left</string> diff --git a/mullvad-jni/Cargo.toml b/mullvad-jni/Cargo.toml index de207328b9..a778680d17 100644 --- a/mullvad-jni/Cargo.toml +++ b/mullvad-jni/Cargo.toml @@ -14,7 +14,7 @@ crate_type = ["cdylib"] err-derive = "0.2.1" futures = "0.1" ipnetwork = "0.15" -jnix = { version = "0.2.2", features = ["derive"] } +jnix = { version = "0.2.3", features = ["derive"] } jsonrpc-client-core = "0.5" jsonrpc-core = "8" lazy_static = "1" diff --git a/mullvad-jni/src/classes.rs b/mullvad-jni/src/classes.rs index 15e27d3a11..cc2a836626 100644 --- a/mullvad-jni/src/classes.rs +++ b/mullvad-jni/src/classes.rs @@ -36,6 +36,8 @@ pub const CLASSES: &[&str] = &[ "net/mullvad/mullvadvpn/model/TunnelState$Connecting", "net/mullvad/mullvadvpn/model/TunnelState$Disconnected", "net/mullvad/mullvadvpn/model/TunnelState$Disconnecting", + "net/mullvad/mullvadvpn/model/VoucherSubmission", + "net/mullvad/mullvadvpn/model/VoucherSubmissionResult", "net/mullvad/mullvadvpn/model/WireguardEndpointData", "net/mullvad/mullvadvpn/service/MullvadDaemon", "net/mullvad/mullvadvpn/service/MullvadVpnService", diff --git a/mullvad-jni/src/daemon_interface.rs b/mullvad-jni/src/daemon_interface.rs index 45454c2f72..c8294fa744 100644 --- a/mullvad-jni/src/daemon_interface.rs +++ b/mullvad-jni/src/daemon_interface.rs @@ -1,7 +1,7 @@ use futures::{sync::oneshot, Future}; use mullvad_daemon::{DaemonCommand, DaemonCommandSender}; use mullvad_types::{ - account::AccountData, + account::{AccountData, VoucherSubmission}, location::GeoIpLocation, relay_constraints::RelaySettingsUpdate, relay_list::RelayList, @@ -210,6 +210,17 @@ impl DaemonInterface { self.send_command(DaemonCommand::Shutdown) } + pub fn submit_voucher(&self, voucher: String) -> Result<VoucherSubmission> { + let (tx, rx) = oneshot::channel(); + + self.send_command(DaemonCommand::SubmitVoucher(tx, voucher))?; + + rx.wait() + .map_err(|_| Error::NoResponse)? + .wait() + .map_err(Error::RpcError) + } + pub fn update_relay_settings(&self, update: RelaySettingsUpdate) -> Result<()> { let (tx, rx) = oneshot::channel(); diff --git a/mullvad-jni/src/lib.rs b/mullvad-jni/src/lib.rs index c46c2ba99f..15b72f8f6b 100644 --- a/mullvad-jni/src/lib.rs +++ b/mullvad-jni/src/lib.rs @@ -18,7 +18,7 @@ use jnix::{ }; use mullvad_daemon::{exception_logging, logging, version, Daemon, DaemonCommandChannel}; use mullvad_rpc::{rest::Error as RestError, StatusCode}; -use mullvad_types::account::AccountData; +use mullvad_types::account::{AccountData, VoucherSubmission}; use std::{ path::{Path, PathBuf}, ptr, @@ -75,6 +75,33 @@ impl From<Result<AccountData, daemon_interface::Error>> for GetAccountDataResult } } +#[derive(IntoJava)] +#[jnix(package = "net.mullvad.mullvadvpn.model")] +pub enum VoucherSubmissionResult { + Ok(VoucherSubmission), + InvalidVoucher, + VoucherAlreadyUsed, + RpcError, + OtherError, +} + +impl From<Result<VoucherSubmission, daemon_interface::Error>> for VoucherSubmissionResult { + fn from(result: Result<VoucherSubmission, daemon_interface::Error>) -> Self { + match result { + Ok(submission) => VoucherSubmissionResult::Ok(submission), + Err(daemon_interface::Error::RpcError(RestError::ApiError(_, code))) => { + match code.as_str() { + "INVALID_VOUCHER" => VoucherSubmissionResult::InvalidVoucher, + "VOUCHER_USED" => VoucherSubmissionResult::VoucherAlreadyUsed, + _ => VoucherSubmissionResult::RpcError, + } + } + Err(daemon_interface::Error::RpcError(_)) => VoucherSubmissionResult::RpcError, + _ => VoucherSubmissionResult::OtherError, + } + } +} + #[no_mangle] #[allow(non_snake_case)] pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_initialize( @@ -797,6 +824,35 @@ pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_shutdow #[no_mangle] #[allow(non_snake_case)] +pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_submitVoucher<'env>( + env: JNIEnv<'env>, + _: JObject<'_>, + daemon_interface_address: jlong, + voucher: JString<'_>, +) -> JObject<'env> { + let env = JnixEnv::from(env); + + let result = if let Some(daemon_interface) = get_daemon_interface(daemon_interface_address) { + let voucher = String::from_java(&env, voucher); + let raw_result = daemon_interface.submit_voucher(voucher); + + if let Err(ref error) = &raw_result { + log::error!( + "{}", + error.display_chain_with_msg("Failed to submit voucher code") + ); + } + + VoucherSubmissionResult::from(raw_result) + } else { + VoucherSubmissionResult::OtherError + }; + + result.into_java(&env).forget() +} + +#[no_mangle] +#[allow(non_snake_case)] pub extern "system" fn Java_net_mullvad_mullvadvpn_service_MullvadDaemon_updateRelaySettings( env: JNIEnv<'_>, _: JObject<'_>, diff --git a/mullvad-types/Cargo.toml b/mullvad-types/Cargo.toml index 51226e37be..cd6114675e 100644 --- a/mullvad-types/Cargo.toml +++ b/mullvad-types/Cargo.toml @@ -21,4 +21,4 @@ talpid-types = { path = "../talpid-types" } mullvad-paths = { path = "../mullvad-paths" } [target.'cfg(target_os = "android")'.dependencies] -jnix = { version = "0.2.2", features = ["derive"] } +jnix = { version = "0.2.3", features = ["derive"] } diff --git a/mullvad-types/src/account.rs b/mullvad-types/src/account.rs index 67fb6ce2d0..f5dc450e76 100644 --- a/mullvad-types/src/account.rs +++ b/mullvad-types/src/account.rs @@ -17,11 +17,15 @@ pub struct AccountData { /// Data structure that's returned from successful invocation of the mullvad API's /// `/v1/submit-voucher` RPC. -#[derive(serde::Deserialize, serde::Serialize, Debug)] +#[derive(Deserialize, Serialize, Debug)] +#[cfg_attr(target_os = "android", derive(IntoJava))] +#[cfg_attr(target_os = "android", jnix(package = "net.mullvad.mullvadvpn.model"))] pub struct VoucherSubmission { /// Amount of time added to the account + #[cfg_attr(target_os = "android", jnix(map = "|time_added| time_added as i64"))] pub time_added: u64, /// Updated expiry time + #[cfg_attr(target_os = "android", jnix(map = "|expiry| expiry.to_string()"))] pub new_expiry: DateTime<Utc>, } diff --git a/talpid-core/Cargo.toml b/talpid-core/Cargo.toml index 3e85b7dfe5..90a8ed9cf5 100644 --- a/talpid-core/Cargo.toml +++ b/talpid-core/Cargo.toml @@ -42,7 +42,7 @@ tokio-io = "0.1" [target.'cfg(target_os = "android")'.dependencies] -jnix = { version = "0.2.2", features = ["derive"] } +jnix = { version = "0.2.3", features = ["derive"] } rand = "0.7" diff --git a/talpid-types/Cargo.toml b/talpid-types/Cargo.toml index 61b9bb8db4..bdd0c9003c 100644 --- a/talpid-types/Cargo.toml +++ b/talpid-types/Cargo.toml @@ -16,4 +16,4 @@ rand = "0.7" err-derive = "0.2.1" [target.'cfg(target_os = "android")'.dependencies] -jnix = { version = "0.2.2", features = ["derive"] } +jnix = { version = "0.2.3", features = ["derive"] } |
