diff options
22 files changed, 1051 insertions, 488 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index d3133899a6..d7797dba36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,10 @@ Line wrap the file at 100 chars. Th #### Windows - When required, attempt to enable IPv6 for network adapters instead of failing. +#### Android +- Update the WireGuard Key screen so that it looks the same as on the desktop app. It is now reached + through the Advanced settings screen. + ### Fixed - Enable IPv6 in WireGuard regardless of the specified MTU value, previously IPv6 was disabled if the MTU was below 1380. diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/KeyStatusListener.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/KeyStatusListener.kt index a4fe30ff30..bee9244fe2 100644 --- a/android/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/KeyStatusListener.kt +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/KeyStatusListener.kt @@ -47,7 +47,7 @@ class KeyStatusListener(val daemon: MullvadDaemon) { oldStatus.verified, newFailure) } else { - keyStatus = newStatus + keyStatus = newStatus ?: KeygenEvent.GenerationFailure() } } diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt index 572a2a0b65..6d993a8a55 100644 --- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt @@ -1,28 +1,23 @@ package net.mullvad.mullvadvpn.ui -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import android.os.Bundle import android.support.v4.app.FragmentManager import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.TextView -import android.widget.Toast import java.text.DateFormat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.ui.widget.CopyableInformationView +import net.mullvad.mullvadvpn.ui.widget.InformationView import org.joda.time.DateTime class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) { - private lateinit var accountExpiryContainer: View - private lateinit var accountExpiryDisplay: TextView - private lateinit var accountNumberContainer: View - private lateinit var accountNumberDisplay: TextView + private lateinit var accountExpiryView: InformationView + private lateinit var accountNumberView: CopyableInformationView private var updateViewJob: Job? = null @@ -39,13 +34,8 @@ class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) { view.findViewById<View>(R.id.logout).setOnClickListener { logout() } - accountExpiryContainer = view.findViewById<View>(R.id.account_expiry_container) - accountNumberContainer = view.findViewById<View>(R.id.account_number_container) - - accountExpiryDisplay = view.findViewById<TextView>(R.id.account_expiry_display) - accountNumberDisplay = view.findViewById<TextView>(R.id.account_number_display) - - accountNumberContainer.setOnClickListener { copyAccountNumberToClipboard() } + accountNumberView = view.findViewById(R.id.account_number) + accountExpiryView = view.findViewById(R.id.account_expiry) return view } @@ -62,19 +52,8 @@ class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) { private fun updateView(accountNumber: String?, accountExpiry: DateTime?) = GlobalScope.launch(Dispatchers.Main) { - if (accountNumber != null) { - accountNumberDisplay.setText(accountNumber) - accountNumberContainer.visibility = View.VISIBLE - } else { - accountNumberContainer.visibility = View.INVISIBLE - } - - if (accountExpiry != null) { - accountExpiryDisplay.setText(formatExpiry(accountExpiry)) - accountExpiryContainer.visibility = View.VISIBLE - } else { - accountExpiryContainer.visibility = View.INVISIBLE - } + accountNumberView.information = accountNumber + accountExpiryView.information = accountExpiry?.let { expiry -> formatExpiry(expiry) } } private fun formatExpiry(expiry: DateTime): String { @@ -90,18 +69,6 @@ class AccountFragment : ServiceDependentFragment(OnNoService.GoBack) { goToLoginScreen() } - private fun copyAccountNumberToClipboard() { - val clipboard = - parentActivity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clipLabel = parentActivity.resources.getString(R.string.mullvad_account_number) - val clipData = ClipData.newPlainText(clipLabel, accountNumberDisplay.text) - - clipboard.primaryClip = clipData - - Toast.makeText(parentActivity, R.string.copied_mullvad_account_number, Toast.LENGTH_SHORT) - .show() - } - private fun clearAccountNumber() = GlobalScope.launch(Dispatchers.Default) { daemon.setAccount(null) } diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt index ec39ee2100..1921546951 100644 --- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt @@ -1,6 +1,7 @@ package net.mullvad.mullvadvpn.ui import android.os.Bundle +import android.support.v4.app.Fragment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -17,6 +18,7 @@ private const val MAX_MTU_VALUE = 1420 class AdvancedFragment : ServiceDependentFragment(OnNoService.GoBack) { private lateinit var wireguardMtuInput: CellInput + private lateinit var wireguardKeysMenu: View private var subscriptionId: Int? = null private var updateUiJob: Job? = null @@ -45,6 +47,12 @@ class AdvancedFragment : ServiceDependentFragment(OnNoService.GoBack) { text = context.getString(R.string.wireguard_mtu_footer, MIN_MTU_VALUE, MAX_MTU_VALUE) } + wireguardKeysMenu = view.findViewById<View>(R.id.wireguard_keys).apply { + setOnClickListener { + openSubFragment(WireguardKeyFragment()) + } + } + settingsListener.subscribe({ settings -> updateUi(settings) }) return view @@ -63,4 +71,18 @@ class AdvancedFragment : ServiceDependentFragment(OnNoService.GoBack) { subscriptionId?.let { id -> settingsListener.unsubscribe(id) } updateUiJob?.cancel() } + + private fun openSubFragment(fragment: Fragment) { + fragmentManager?.beginTransaction()?.apply { + setCustomAnimations( + R.anim.fragment_enter_from_right, + R.anim.fragment_half_exit_to_left, + R.anim.fragment_half_enter_from_left, + R.anim.fragment_exit_to_right + ) + replace(R.id.main_fragment, fragment) + addToBackStack(null) + commit() + } + } } diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt index 8b9a30bd32..55f8b24c89 100644 --- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt @@ -27,7 +27,6 @@ class SettingsFragment : ServiceAwareFragment() { private lateinit var preferencesMenu: View private lateinit var advancedMenu: View private lateinit var remainingTimeLabel: RemainingTimeLabel - private lateinit var wireguardKeysMenu: View private var active = false @@ -83,15 +82,10 @@ class SettingsFragment : ServiceAwareFragment() { } } - wireguardKeysMenu = view.findViewById<View>(R.id.wireguard_keys).apply { - setOnClickListener { - openSubFragment(WireguardKeyFragment()) - } - } - view.findViewById<View>(R.id.app_version).setOnClickListener { openLink(R.string.download_url) } + view.findViewById<View>(R.id.report_a_problem).setOnClickListener { openSubFragment(ProblemReportFragment()) } @@ -181,7 +175,6 @@ class SettingsFragment : ServiceAwareFragment() { accountMenu.visibility = visibility preferencesMenu.visibility = visibility advancedMenu.visibility = visibility - wireguardKeysMenu.visibility = visibility } private fun updateVersionInfo() = GlobalScope.launch(Dispatchers.Main) { diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/WireguardKeyFragment.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/WireguardKeyFragment.kt index a44da07d27..94abcf2ee5 100644 --- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/WireguardKeyFragment.kt +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/WireguardKeyFragment.kt @@ -1,75 +1,102 @@ package net.mullvad.mullvadvpn.ui -import android.content.ClipData -import android.content.ClipboardManager import android.content.Context -import android.content.Intent -import android.net.Uri import android.os.Bundle import android.util.Base64 import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.Button -import android.widget.ProgressBar import android.widget.TextView -import android.widget.Toast -import java.util.TimeZone -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.model.KeygenEvent import net.mullvad.mullvadvpn.model.KeygenFailure import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.ui.widget.Button +import net.mullvad.mullvadvpn.ui.widget.CopyableInformationView +import net.mullvad.mullvadvpn.ui.widget.InformationView +import net.mullvad.mullvadvpn.ui.widget.InformationView.WhenMissing +import net.mullvad.mullvadvpn.ui.widget.UrlButton +import net.mullvad.mullvadvpn.util.JobTracker +import net.mullvad.mullvadvpn.util.TimeAgoFormatter import org.joda.time.DateTime import org.joda.time.DateTimeZone import org.joda.time.format.DateTimeFormat val RFC3339_FORMAT = DateTimeFormat.forPattern("YYYY-MM-dd HH:mm:ss.SSSSSSSSSS z") -val KEY_AGE_FORMAT = DateTimeFormat.forPattern("YYYY-MM-dd HH:mm") class WireguardKeyFragment : ServiceDependentFragment(OnNoService.GoToLaunchScreen) { - private var currentJob: Job? = null - private var updateViewsJob: Job? = null + sealed class ActionState { + class Idle(val verified: Boolean) : ActionState() + class Generating(val replacing: Boolean) : ActionState() + class Verifying() : ActionState() + } + + private val jobTracker = JobTracker() + + private lateinit var timeAgoFormatter: TimeAgoFormatter + private var tunnelStateListener: Int? = null private var tunnelState: TunnelState = TunnelState.Disconnected() - private lateinit var urlController: BlockingController - private var generatingKey = false - private var validatingKey = false - private var resetReconnectionExpectedJob: Job? = null + private var actionState: ActionState = ActionState.Idle(false) + set(value) { + if (field != value) { + field = value + updateKeySpinners() + updateStatusMessage() + updateGenerateKeyButtonState() + updateGenerateKeyButtonText() + updateVerifyKeyButtonState() + updateVerifyingKeySpinner() + } + } + + private var keyStatus: KeygenEvent? = null + set(value) { + if (field != value) { + field = value + updateKeyInformation() + updateStatusMessage() + updateGenerateKeyButtonText() + updateVerifyKeyButtonState() + } + } + + private var hasConnectivity = true + set(value) { + if (field != value) { + field = value + updateStatusMessage() + updateGenerateKeyButtonState() + updateVerifyKeyButtonState() + manageKeysButton.setEnabled(value) + } + } + private var reconnectionExpected = false set(value) { field = value - resetReconnectionExpectedJob?.cancel() + jobTracker.cancelJob("resetReconnectionExpected") if (value == true) { resetReconnectionExpected() } } - private lateinit var publicKey: TextView - private lateinit var publicKeyAge: TextView + private lateinit var publicKey: CopyableInformationView + private lateinit var keyAge: InformationView private lateinit var statusMessage: TextView - private lateinit var visitWebsiteView: View - private lateinit var generateButton: Button - private lateinit var generateSpinner: ProgressBar - private lateinit var verifyButton: Button - private lateinit var verifySpinner: ProgressBar + private lateinit var verifyingKeySpinner: View + private lateinit var manageKeysButton: UrlButton + private lateinit var generateKeyButton: Button + private lateinit var verifyKeyButton: Button - private fun resetReconnectionExpected() { - resetReconnectionExpectedJob = GlobalScope.launch(Dispatchers.Main) { - delay(20_000) + override fun onAttach(context: Context) { + super.onAttach(context) - if (reconnectionExpected) { - reconnectionExpected = false - updateViews() - } - } + timeAgoFormatter = TimeAgoFormatter(context.resources) } override fun onSafelyCreateView( @@ -84,273 +111,227 @@ class WireguardKeyFragment : ServiceDependentFragment(OnNoService.GoToLaunchScre } statusMessage = view.findViewById<TextView>(R.id.wireguard_key_status) - visitWebsiteView = view.findViewById<View>(R.id.wireguard_manage_keys) - publicKey = view.findViewById<TextView>(R.id.wireguard_public_key) - generateButton = view.findViewById<Button>(R.id.wg_generate_key_button) - generateSpinner = view.findViewById<ProgressBar>(R.id.wg_generate_key_spinner) - verifyButton = view.findViewById<Button>(R.id.wg_verify_key_button) - verifySpinner = view.findViewById<ProgressBar>(R.id.wg_verify_key_spinner) - publicKeyAge = view.findViewById<TextView>(R.id.wireguard_key_age) + publicKey = view.findViewById(R.id.public_key) + keyAge = view.findViewById(R.id.key_age) - visitWebsiteView.visibility = View.VISIBLE - val keyUrl = parentActivity.getString(R.string.wg_key_url) - - urlController = BlockingController( - object : BlockableView { - override fun setEnabled(enabled: Boolean) { - if (!enabled || tunnelState is TunnelState.Error) { - visitWebsiteView.setClickable(false) - visitWebsiteView.setAlpha(0.5f) - } else { - visitWebsiteView.setClickable(true) - visitWebsiteView.setAlpha(1f) - } - } + generateKeyButton = view.findViewById<Button>(R.id.generate_key).apply { + setOnClickAction("action", jobTracker) { + onGenerateKeyPress() + } + } - override fun onClick(): Job { - return GlobalScope.launch(Dispatchers.Default) { - val token = daemon.getWwwAuthToken() - val intent = Intent(Intent.ACTION_VIEW, - Uri.parse(keyUrl + "?token=" + token)) - startActivity(intent) - } - } + verifyKeyButton = view.findViewById<Button>(R.id.verify_key).apply { + setOnClickAction("action", jobTracker) { + onValidateKeyPress() } - ) - visitWebsiteView.setOnClickListener { - urlController.action() } - updateViews() + verifyingKeySpinner = view.findViewById(R.id.verifying_key_spinner) - return view - } + manageKeysButton = view.findViewById<UrlButton>(R.id.manage_keys).apply { + prepare(daemon, jobTracker) + } - private fun updateViewJob() = GlobalScope.launch(Dispatchers.Main) { - updateViews() + return view } - private fun updateViews() { - clearErrorMessage() + override fun onSafelyResume() { + tunnelStateListener = connectionProxy.onUiStateChange.subscribe { uiState -> + jobTracker.newUiJob("tunnelStateUpdate") { + synchronized(this@WireguardKeyFragment) { + tunnelState = uiState - setGenerateButton() - setVerifyButton() + if (actionState is ActionState.Generating) { + reconnectionExpected = !(tunnelState is TunnelState.Disconnected) + } else if (tunnelState is TunnelState.Connected) { + reconnectionExpected = false + } - when (val keyState = keyStatusListener.keyStatus) { - null -> { - publicKey.visibility = View.INVISIBLE + hasConnectivity = uiState is TunnelState.Connected || + uiState is TunnelState.Disconnected || + (uiState is TunnelState.Error && !uiState.errorState.isBlocking) + } } + } - is KeygenEvent.NewKey -> { - val key = keyState.publicKey - val publicKeyString = Base64.encodeToString(key.key, Base64.NO_WRAP) - publicKey.visibility = View.VISIBLE - publicKey.setText(publicKeyString.substring(0, 20) + "...") + keyStatusListener.onKeyStatusChange = { newKeyStatus -> + jobTracker.newUiJob("keyStatusUpdate") { + keyStatus = newKeyStatus + } + } - publicKey.setOnClickListener { - val label = parentActivity.getString(R.string.wireguard_key_copied_to_clibpoard) - val clipboard = parentActivity - .getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText(label, publicKeyString)) + actionState = ActionState.Idle(false) + } - Toast.makeText(parentActivity, label, Toast.LENGTH_SHORT) - .show() - } + override fun onSafelyPause() { + tunnelStateListener?.let { listener -> + connectionProxy.onUiStateChange.unsubscribe(listener) + } - publicKeyAge.setText(formatKeyDateCreated(key.dateCreated)) + if (!(actionState is ActionState.Idle)) { + actionState = ActionState.Idle(false) + } - keyState.verified?.let { verified -> - if (verified) { - setStatusMessage(R.string.wireguard_key_valid, R.color.green) - } else { - setStatusMessage(R.string.wireguard_key_invalid, R.color.red) - } - } + keyStatusListener.onKeyStatusChange = null + jobTracker.cancelAllJobs() + } - keyState.replacementFailure?.let { error -> showKeygenFailure(error) } + private fun updateKeySpinners() { + when (actionState) { + is ActionState.Generating -> { + publicKey.whenMissing = WhenMissing.ShowSpinner + keyAge.whenMissing = WhenMissing.ShowSpinner } - else -> { - showKeygenFailure(keyState.failure()) + is ActionState.Verifying, is ActionState.Idle -> { + publicKey.whenMissing = WhenMissing.Nothing + keyAge.whenMissing = WhenMissing.Nothing } } - drawNoConnectionState() - } - - private fun setStatusMessage(message: Int, color: Int) { - statusMessage.setText(message) - statusMessage.setTextColor(resources.getColor(color)) - statusMessage.visibility = View.VISIBLE } - private fun clearErrorMessage() { - statusMessage.visibility = View.GONE - } + private fun updateKeyInformation() { + when (val keyState = keyStatus) { + is KeygenEvent.NewKey -> { + val key = keyState.publicKey + val publicKeyString = Base64.encodeToString(key.key, Base64.NO_WRAP) + val publicKeyAge = + DateTime.parse(key.dateCreated, RFC3339_FORMAT).withZone(DateTimeZone.UTC) - private fun showKeygenFailure(failure: KeygenFailure?) { - when (failure) { - is KeygenFailure.TooManyKeys -> { - setStatusMessage(R.string.too_many_keys, R.color.red) + publicKey.error = null + publicKey.information = publicKeyString + keyAge.information = timeAgoFormatter.format(publicKeyAge) + } + is KeygenEvent.TooManyKeys, is KeygenEvent.GenerationFailure -> { + publicKey.error = resources.getString(failureMessage(keyState.failure()!!)) + publicKey.information = null + keyAge.information = null } - is KeygenFailure.GenerationFailure -> { - setStatusMessage(R.string.failed_to_generate_key, R.color.red) + null -> { + publicKey.error = null + publicKey.information = null + keyAge.information = null } } } - private fun setGenerateButton() { - generateButton.setClickable(true) - generateButton.setAlpha(1f) - if (validatingKey) { - generateButton.setClickable(false) - generateButton.setAlpha(0.5f) - return - } - if (generatingKey) { - generateButton.visibility = View.GONE - generateSpinner.visibility = View.VISIBLE - return - } - generateSpinner.visibility = View.GONE - generateButton.visibility = View.VISIBLE - if (keyStatusListener.keyStatus is KeygenEvent.NewKey) { - generateButton.setText(R.string.wireguard_replace_key) - } else { - generateButton.setText(R.string.wireguard_generate_key) + private fun updateStatusMessage() { + when (val state = actionState) { + is ActionState.Generating -> statusMessage.visibility = View.GONE + is ActionState.Verifying -> statusMessage.visibility = View.GONE + is ActionState.Idle -> { + if (hasConnectivity) { + updateKeyStatus(state.verified, keyStatus) + } else { + updateOfflineStatus() + } + } } + } - generateButton.setOnClickListener { - onGenerateKeyPress() + private fun updateOfflineStatus() { + if (reconnectionExpected) { + setStatusMessage(R.string.wireguard_key_reconnecting, R.color.green) + } else { + setStatusMessage(R.string.wireguard_key_blocked_state_message, R.color.red) } } - private fun setVerifyButton() { - verifyButton.setClickable(true) - verifyButton.setAlpha(1f) - val keyState = keyStatusListener.keyStatus - if (generatingKey || keyState?.failure() != null) { - verifyButton.setClickable(false) - verifyButton.setAlpha(0.5f) - return - } - if (validatingKey) { - verifyButton.visibility = View.GONE - verifySpinner.visibility = View.VISIBLE - return - } - verifySpinner.visibility = View.GONE - verifyButton.visibility = View.VISIBLE - verifyButton.setText(R.string.wireguard_verify_key) - verifyButton.setOnClickListener { - onValidateKeyPress() + private fun updateKeyStatus(verificationWasDone: Boolean, keyStatus: KeygenEvent?) { + if (keyStatus is KeygenEvent.NewKey) { + val replacementFailure = keyStatus.replacementFailure + + if (replacementFailure != null) { + setStatusMessage(failureMessage(replacementFailure), R.color.red) + } else { + updateKeyIsValid(verificationWasDone, keyStatus.verified) + } + } else { + statusMessage.visibility = View.GONE } } - private fun drawNoConnectionState() { - visitWebsiteView.setClickable(true) - visitWebsiteView.setAlpha(1f) - - when (tunnelState) { - is TunnelState.Connecting, is TunnelState.Disconnecting -> { - if (!reconnectionExpected) { - setStatusMessage(R.string.wireguard_key_connectivity, R.color.red) - generateButton.visibility = View.GONE - generateSpinner.visibility = View.VISIBLE - verifyButton.visibility = View.GONE - verifySpinner.visibility = View.VISIBLE + private fun updateKeyIsValid(verificationWasDone: Boolean, verified: Boolean?) { + when (verified) { + true -> setStatusMessage(R.string.wireguard_key_valid, R.color.green) + false -> setStatusMessage(R.string.wireguard_key_invalid, R.color.red) + null -> { + if (verificationWasDone) { + setStatusMessage(R.string.wireguard_key_verification_failure, R.color.red) + } else { + statusMessage.visibility = View.GONE } } - is TunnelState.Error -> { - setStatusMessage(R.string.wireguard_key_blocked_state_message, R.color.red) - generateButton.setClickable(false) - generateButton.setAlpha(0.5f) - verifyButton.setClickable(false) - verifyButton.setAlpha(0.5f) - visitWebsiteView.setClickable(false) - visitWebsiteView.setAlpha(0.5f) - } } } - private fun onGenerateKeyPress() { - currentJob?.cancel() + private fun updateGenerateKeyButtonState() { + generateKeyButton.setEnabled(actionState is ActionState.Idle && hasConnectivity) + } - synchronized(this) { - generatingKey = true - validatingKey = false - reconnectionExpected = !(tunnelState is TunnelState.Disconnected) + private fun updateGenerateKeyButtonText() { + val state = actionState + val replacingKey = state is ActionState.Generating && state.replacing + val hasKey = keyStatus is KeygenEvent.NewKey + + if (hasKey || replacingKey) { + generateKeyButton.setText(R.string.wireguard_replace_key) + } else { + generateKeyButton.setText(R.string.wireguard_generate_key) } + } - updateViews() + private fun updateVerifyKeyButtonState() { + val isIdle = actionState is ActionState.Idle + val hasKey = keyStatus is KeygenEvent.NewKey - currentJob = GlobalScope.launch(Dispatchers.Main) { - keyStatusListener.generateKey().join() - generatingKey = false - updateViews() - } + verifyKeyButton.setEnabled(isIdle && hasConnectivity && hasKey) } - private fun onValidateKeyPress() { - currentJob?.cancel() - validatingKey = true - generatingKey = false - updateViews() - currentJob = GlobalScope.launch(Dispatchers.Main) { - keyStatusListener.verifyKey().join() - validatingKey = false - when (val state = keyStatusListener.keyStatus) { - is KeygenEvent.NewKey -> { - if (state.verified == null) { - Toast.makeText(parentActivity, - R.string.wireguard_key_verification_failure, - Toast.LENGTH_SHORT).show() - } - } - } - updateViews() + private fun updateVerifyingKeySpinner() { + verifyingKeySpinner.visibility = when (actionState) { + is ActionState.Verifying -> View.VISIBLE + else -> View.GONE } } - override fun onSafelyPause() { - tunnelStateListener?.let { listener -> - connectionProxy.onUiStateChange.unsubscribe(listener) - } + private fun setStatusMessage(message: Int, color: Int) { + statusMessage.setText(message) + statusMessage.setTextColor(resources.getColor(color)) + statusMessage.visibility = View.VISIBLE + } - keyStatusListener.onKeyStatusChange = null - currentJob?.cancel() - updateViewsJob?.cancel() - resetReconnectionExpectedJob?.cancel() - validatingKey = false - generatingKey = false - urlController.onPause() + private fun failureMessage(failure: KeygenFailure): Int { + when (failure) { + is KeygenFailure.TooManyKeys -> return R.string.too_many_keys + is KeygenFailure.GenerationFailure -> return R.string.failed_to_generate_key + } } - override fun onSafelyResume() { - tunnelStateListener = connectionProxy.onUiStateChange.subscribe { uiState -> - synchronized(this@WireguardKeyFragment) { - tunnelState = uiState + private suspend fun onGenerateKeyPress() { + synchronized(this) { + actionState = ActionState.Generating(keyStatus is KeygenEvent.NewKey) + reconnectionExpected = !(tunnelState is TunnelState.Disconnected) + } - if (generatingKey) { - reconnectionExpected = !(tunnelState is TunnelState.Disconnected) - } else if (tunnelState is TunnelState.Connected) { - reconnectionExpected = false - } - } + keyStatus = null + keyStatusListener.generateKey().join() - updateViewsJob?.cancel() - updateViewsJob = updateViewJob() - } + actionState = ActionState.Idle(false) + } - keyStatusListener.onKeyStatusChange = { _ -> - updateViewsJob?.cancel() - updateViewsJob = updateViewJob() - } + private suspend fun onValidateKeyPress() { + actionState = ActionState.Verifying() + keyStatusListener.verifyKey().join() + actionState = ActionState.Idle(true) } - private fun formatKeyDateCreated(rfc3339: String): String { - val dateCreated = DateTime.parse(rfc3339, RFC3339_FORMAT).withZone(DateTimeZone.UTC) - val localTimezone = DateTimeZone.forTimeZone(TimeZone.getDefault()) - return parentActivity.getString(R.string.wireguard_key_age) + - " " + - KEY_AGE_FORMAT.print(dateCreated.withZone(localTimezone)) + private fun resetReconnectionExpected() { + jobTracker.newBackgroundJob("resetReconnectionExpected") { + delay(20_000) + + if (reconnectionExpected) { + reconnectionExpected = false + } + } } } 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 new file mode 100644 index 0000000000..826c93e1e4 --- /dev/null +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/Button.kt @@ -0,0 +1,146 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageView +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.util.JobTracker + +open class Button : FrameLayout { + enum class ButtonColor { + Blue, + Green; + + companion object { + internal fun fromCode(code: Int): ButtonColor { + when (code) { + 0 -> return Blue + 1 -> return Green + else -> throw Exception("Invalid buttonColor attribute value") + } + } + } + } + + private val container = + context.getSystemService(Context.LAYOUT_INFLATER_SERVICE).let { service -> + val inflater = service as LayoutInflater + + inflater.inflate(R.layout.button, this) + } + + private val button = container.findViewById<android.widget.Button>(R.id.button) + private val spinner: View = container.findViewById(R.id.spinner) + private val image: ImageView = container.findViewById(R.id.image) + + private var clickJobName: String? = null + private var onClickAction: (suspend () -> Unit)? = null + + protected var jobTracker: JobTracker? = null + + var buttonColor: ButtonColor = ButtonColor.Blue + set(value) { + field = value + + val backgroundResource = when (value) { + ButtonColor.Blue -> R.drawable.blue_button_background + ButtonColor.Green -> R.drawable.green_button_background + } + + button.setBackgroundResource(backgroundResource) + } + + var detailImage: Drawable? = null + set(value) { + field = value + + image.apply { + if (value == null) { + visibility = GONE + } else { + visibility = VISIBLE + setImageDrawable(value) + } + } + } + + var showSpinner = false + + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) { + loadAttributes(attributes) + } + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) { + loadAttributes(attributes) + } + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) { + loadAttributes(attributes) + } + + override fun setEnabled(enabled: Boolean) { + super.setEnabled(enabled) + + if (enabled) { + alpha = 1.0f + } else { + alpha = 0.5f + } + } + + init { + button.setOnClickListener { + jobTracker?.newUiJob(clickJobName!!) { + if (showSpinner) { + image.visibility = GONE + spinner.visibility = VISIBLE + } + + onClickAction!!.invoke() + + spinner.visibility = GONE + + if (detailImage != null) { + image.visibility = VISIBLE + } + } + } + } + + fun setOnClickAction(jobName: String, tracker: JobTracker, action: suspend () -> Unit) { + clickJobName = jobName + jobTracker = tracker + onClickAction = action + } + + fun setText(textResource: Int) { + button.setText(textResource) + } + + private fun loadAttributes(attributes: AttributeSet) { + var styleableId = R.styleable.Button + + context.theme.obtainStyledAttributes(attributes, styleableId, 0, 0).apply { + try { + button.text = getString(R.styleable.Button_text) ?: "" + buttonColor = ButtonColor.fromCode(getInteger(R.styleable.Button_buttonColor, 0)) + detailImage = getDrawable(R.styleable.Button_detailImage) + showSpinner = getBoolean(R.styleable.Button_showSpinner, false) + } finally { + recycle() + } + } + } +} diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CopyableInformationView.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CopyableInformationView.kt new file mode 100644 index 0000000000..ac1b7e8125 --- /dev/null +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CopyableInformationView.kt @@ -0,0 +1,65 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.util.AttributeSet +import android.widget.Toast +import net.mullvad.mullvadvpn.R + +class CopyableInformationView : InformationView { + var clipboardLabel: String? = null + set(value) { + field = value + shouldEnable = value != null + } + + var copiedToast: String? = null + + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) { + loadAttributes(attributes) + } + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) { + loadAttributes(attributes) + } + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) { + loadAttributes(attributes) + } + + init { + onClick = { copyToClipboard() } + } + + private fun loadAttributes(attributes: AttributeSet) { + val styleableId = R.styleable.CopyableInformationView + + context.theme.obtainStyledAttributes(attributes, styleableId, 0, 0).apply { + try { + clipboardLabel = getString(R.styleable.CopyableInformationView_clipboardLabel) + copiedToast = getString(R.styleable.CopyableInformationView_copiedToast) + } finally { + recycle() + } + } + } + + private fun copyToClipboard() { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clipData = ClipData.newPlainText(clipboardLabel, information) + val toastMessage = copiedToast ?: context.getString(R.string.copied_to_clipboard) + + clipboard.primaryClip = clipData + + Toast.makeText(context, toastMessage, Toast.LENGTH_SHORT).show() + } +} diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/InformationView.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/InformationView.kt new file mode 100644 index 0000000000..67d2dd07cc --- /dev/null +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/InformationView.kt @@ -0,0 +1,182 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.util.AttributeSet +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import net.mullvad.mullvadvpn.R + +open class InformationView : LinearLayout { + enum class WhenMissing { + Nothing, + Hide, + ShowSpinner; + + companion object { + internal fun fromCode(code: Int): WhenMissing { + when (code) { + 0 -> return Nothing + 1 -> return Hide + 2 -> return ShowSpinner + else -> throw Exception("Invalid whenMissing attribute value") + } + } + } + } + + private val container = + context.getSystemService(Context.LAYOUT_INFLATER_SERVICE).let { service -> + val inflater = service as LayoutInflater + + inflater.inflate(R.layout.information_view, this).apply { + setOnClickListener { onClick?.invoke() } + setEnabled(false) + } + } + + private val description: TextView = findViewById(R.id.description) + private val informationDisplay: TextView = findViewById(R.id.information_display) + private val spinner: View = findViewById(R.id.spinner) + + var shouldEnable = false + set(value) { + field = value + updateEnabled() + } + + var error: String? = null + set(value) { + field = value + updateStatus() + } + + var errorColor = context.resources.getColor(R.color.red) + set(value) { + field = value + updateStatus() + } + + var information: String? = null + set(value) { + field = value + updateStatus() + } + + var informationColor = context.resources.getColor(R.color.white) + set(value) { + field = value + updateStatus() + } + + var maxLength = 0 + set(value) { + field = value + updateStatus() + } + + var whenMissing = WhenMissing.Nothing + set(value) { + field = value + updateStatus() + } + + var onClick: (() -> Unit)? = null + + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) { + loadAttributes(attributes) + } + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) { + loadAttributes(attributes) + } + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) { + loadAttributes(attributes) + } + + init { + val backgroundResource = TypedValue() + + context.theme.resolveAttribute( + android.R.attr.selectableItemBackground, + backgroundResource, + true + ) + + orientation = VERTICAL + setBackgroundResource(backgroundResource.resourceId) + } + + private fun loadAttributes(attributes: AttributeSet) { + val styleableId = R.styleable.InformationView + + context.theme.obtainStyledAttributes(attributes, styleableId, 0, 0).apply { + try { + description.text = getString(R.styleable.InformationView_description) ?: "" + + errorColor = getInteger(R.styleable.InformationView_errorColor, errorColor) + maxLength = getInteger(R.styleable.InformationView_maxLength, 0) + + informationColor = getInteger( + R.styleable.InformationView_informationColor, + informationColor + ) + + whenMissing = WhenMissing.fromCode( + getInteger(R.styleable.InformationView_whenMissing, 0) + ) + } finally { + recycle() + } + } + } + + private fun updateStatus() { + val information = this.information + val hasText = information != null || error != null + + if (error != null) { + informationDisplay.setTextColor(errorColor) + informationDisplay.text = error + } else if (information != null) { + informationDisplay.setTextColor(informationColor) + + if (maxLength == 0 || information.length <= maxLength) { + informationDisplay.text = information + } else { + informationDisplay.text = information.substring(0, maxLength) + "..." + } + } + + if (whenMissing == WhenMissing.Hide && !hasText) { + visibility = INVISIBLE + } else { + visibility = VISIBLE + } + + if (whenMissing == WhenMissing.ShowSpinner && !hasText) { + spinner.visibility = VISIBLE + informationDisplay.visibility = INVISIBLE + } else { + spinner.visibility = INVISIBLE + informationDisplay.visibility = VISIBLE + } + + updateEnabled() + } + + private fun updateEnabled() { + setEnabled(shouldEnable && error == null && information != null) + } +} diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/UrlButton.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/UrlButton.kt new file mode 100644 index 0000000000..3215e6f616 --- /dev/null +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/UrlButton.kt @@ -0,0 +1,102 @@ +package net.mullvad.mullvadvpn.ui.widget + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.AttributeSet +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.async +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.service.MullvadDaemon +import net.mullvad.mullvadvpn.util.JobTracker + +class UrlButton : Button { + private lateinit var daemon: MullvadDaemon + + private var shouldEnable = true + + var url: String? = null + var withToken = false + + constructor(context: Context) : super(context) {} + + constructor(context: Context, attributes: AttributeSet) : super(context, attributes) { + loadAttributes(attributes) + } + + constructor(context: Context, attributes: AttributeSet, defaultStyleAttribute: Int) : + super(context, attributes, defaultStyleAttribute) { + loadAttributes(attributes) + } + + constructor( + context: Context, + attributes: AttributeSet, + defaultStyleAttribute: Int, + defaultStyleResource: Int + ) : super(context, attributes, defaultStyleAttribute, defaultStyleResource) { + loadAttributes(attributes) + } + + init { + super.setEnabled(false) + super.detailImage = context.resources.getDrawable(R.drawable.icon_extlink) + super.showSpinner = true + } + + fun prepare(daemon: MullvadDaemon, jobTracker: JobTracker, jobName: String = "fetchUrl") { + synchronized(this) { + super.setEnabled(shouldEnable) + + this.daemon = daemon + + setOnClickAction(jobName, jobTracker) { + super.setEnabled(false) + + context.startActivity(buildIntent(jobTracker)) + + super.setEnabled(true) + } + } + } + + override fun setEnabled(enabled: Boolean) { + synchronized(this) { + shouldEnable = enabled + + if (!withToken || this::daemon.isInitialized) { + super.setEnabled(enabled) + } + } + } + + private fun loadAttributes(attributes: AttributeSet) { + val styleableId = R.styleable.UrlButton + + context.theme.obtainStyledAttributes(attributes, styleableId, 0, 0).apply { + try { + url = getString(R.styleable.UrlButton_url) + withToken = getBoolean(R.styleable.UrlButton_withToken, false) + } finally { + recycle() + } + } + } + + private suspend fun buildIntent(jobTracker: JobTracker): Intent { + val buildIntent = GlobalScope.async(Dispatchers.Default) { + val uri = if (withToken) { + Uri.parse(url + "?token=" + daemon.getWwwAuthToken()) + } else { + Uri.parse(url) + } + + Intent(Intent.ACTION_VIEW, uri) + } + + jobTracker.newJob(buildIntent) + + return buildIntent.await() + } +} diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/util/JobTracker.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/util/JobTracker.kt index 9bbe21fa8b..29802b5bce 100644 --- a/android/src/main/kotlin/net/mullvad/mullvadvpn/util/JobTracker.kt +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/util/JobTracker.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.launch class JobTracker { private val jobs = HashMap<Long, Job>() + private val namedJobs = HashMap<String, Long>() private var jobIdCounter = 0L @@ -28,6 +29,34 @@ class JobTracker { } } + fun newJob(name: String, job: Job): Long { + synchronized(namedJobs) { + cancelJob(name) + + val newJobId = newJob(job) + + namedJobs.put(name, newJobId) + + return newJobId + } + } + + fun newBackgroundJob(name: String, jobBody: suspend () -> Unit): Long { + return newJob(name, GlobalScope.launch(Dispatchers.Default) { jobBody() }) + } + + fun newUiJob(name: String, jobBody: suspend () -> Unit): Long { + return newJob(name, GlobalScope.launch(Dispatchers.Main) { jobBody() }) + } + + fun cancelJob(name: String) { + synchronized(namedJobs) { + namedJobs.remove(name)?.let { oldJobId -> + cancelJob(oldJobId) + } + } + } + fun cancelJob(jobId: Long) { synchronized(jobs) { jobs.remove(jobId)?.cancel() diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/util/TimeAgoFormatter.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/util/TimeAgoFormatter.kt new file mode 100644 index 0000000000..1136a21814 --- /dev/null +++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/util/TimeAgoFormatter.kt @@ -0,0 +1,36 @@ +package net.mullvad.mullvadvpn.util + +import android.content.res.Resources +import net.mullvad.mullvadvpn.R +import org.joda.time.DateTime +import org.joda.time.Duration +import org.joda.time.PeriodType + +class TimeAgoFormatter(val resources: Resources) { + private val periodType = PeriodType.standard() + .withMillisRemoved() + .withSecondsRemoved() + + fun format(instant: DateTime): String { + val elapsedTime = Duration(instant, DateTime.now()) + val elapsedTimeInfo = elapsedTime.toPeriodTo(instant, periodType) + + if (elapsedTimeInfo.years > 0) { + return getRemainingText(R.plurals.years_ago, elapsedTimeInfo.years) + } else if (elapsedTimeInfo.months > 0) { + return getRemainingText(R.plurals.months_ago, elapsedTimeInfo.months) + } else if (elapsedTimeInfo.days > 0) { + return getRemainingText(R.plurals.days_ago, elapsedTimeInfo.days) + } else if (elapsedTimeInfo.hours > 0) { + return getRemainingText(R.plurals.hours_ago, elapsedTimeInfo.hours) + } else if (elapsedTimeInfo.minutes > 0) { + return getRemainingText(R.plurals.minutes_ago, elapsedTimeInfo.minutes) + } else { + return resources.getString(R.string.less_than_a_minute_ago) + } + } + + private fun getRemainingText(pluralId: Int, quantity: Int): String { + return resources.getQuantityString(pluralId, quantity, quantity) + } +} diff --git a/android/src/main/res/layout/account.xml b/android/src/main/res/layout/account.xml index e3df59acf3..092b4d2817 100644 --- a/android/src/main/res/layout/account.xml +++ b/android/src/main/res/layout/account.xml @@ -1,4 +1,5 @@ <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="match_parent" android:background="@color/darkBlue" @@ -38,52 +39,22 @@ android:textSize="32sp" android:textStyle="bold" android:text="@string/settings_account" /> - <LinearLayout android:id="@+id/account_number_container" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:paddingHorizontal="24dp" - android:paddingVertical="12dp" - android:orientation="vertical" - android:visibility="invisible" - android:background="?android:attr/selectableItemBackground" - android:clickable="true"> - <TextView android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginBottom="9dp" - android:textColor="@color/white60" - android:textSize="13sp" - android:textStyle="bold" - android:text="@string/account_number" /> - <TextView android:id="@+id/account_number_display" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:textColor="@color/white" - android:textSize="16sp" - android:textStyle="bold" - android:text="" /> - </LinearLayout> - <LinearLayout android:id="@+id/account_expiry_container" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:paddingHorizontal="24dp" - android:paddingVertical="12dp" - android:orientation="vertical" - android:visibility="invisible"> - <TextView android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginBottom="9dp" - android:textColor="@color/white60" - android:textSize="13sp" - android:textStyle="bold" - android:text="@string/paid_until" /> - <TextView android:id="@+id/account_expiry_display" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:textColor="@color/white" - android:textSize="16sp" - android:textStyle="bold" - android:text="" /> - </LinearLayout> + <net.mullvad.mullvadvpn.ui.widget.CopyableInformationView android:id="@+id/account_number" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingHorizontal="24dp" + android:paddingVertical="12dp" + mullvad:clipboardLabel="@string/mullvad_account_number" + mullvad:copiedToast="@string/copied_mullvad_account_number" + mullvad:description="@string/account_number" + mullvad:whenMissing="hide"/> + <net.mullvad.mullvadvpn.ui.widget.InformationView android:id="@+id/account_expiry" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingHorizontal="24dp" + android:paddingVertical="12dp" + mullvad:description="@string/paid_until" + mullvad:whenMissing="hide"/> <Button android:id="@+id/logout" android:layout_marginTop="12dp" android:layout_marginHorizontal="24dp" diff --git a/android/src/main/res/layout/advanced.xml b/android/src/main/res/layout/advanced.xml index 966200fee1..e6a029ae74 100644 --- a/android/src/main/res/layout/advanced.xml +++ b/android/src/main/res/layout/advanced.xml @@ -73,4 +73,27 @@ android:paddingHorizontal="24dp" android:textColor="@color/white60" android:textSize="13sp" /> + <LinearLayout android:id="@+id/wireguard_keys" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="24dp" + android:paddingHorizontal="16dp" + android:background="@drawable/cell_button_background" + android:clickable="true" + android:gravity="center"> + <TextView android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_weight="1" + android:paddingHorizontal="8dp" + android:paddingVertical="17dp" + android:textColor="@color/white" + android:textSize="20sp" + android:textStyle="bold" + android:text="@string/wireguard_key" /> + <ImageView android:layout_width="14dp" + android:layout_height="24dp" + android:layout_weight="0" + android:alpha="0.6" + android:src="@drawable/icon_chevron" /> + </LinearLayout> </LinearLayout> diff --git a/android/src/main/res/layout/button.xml b/android/src/main/res/layout/button.xml new file mode 100644 index 0000000000..51d273af97 --- /dev/null +++ b/android/src/main/res/layout/button.xml @@ -0,0 +1,23 @@ +<merge xmlns:android="http://schemas.android.com/apk/res/android"> + <Button android:id="@+id/button" + android:gravity="center" + android:text="" + style="@style/Button" /> + <ProgressBar android:id="@+id/spinner" + android:layout_width="20dp" + android:layout_height="20dp" + android:layout_marginHorizontal="9dp" + android:layout_gravity="right|center_vertical" + android:indeterminate="true" + android:indeterminateOnly="true" + android:indeterminateDuration="600" + android:indeterminateDrawable="@drawable/icon_spinner" + android:visibility="gone" /> + <ImageView android:id="@+id/image" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginHorizontal="9dp" + android:layout_gravity="right|center_vertical" + android:src="@android:color/transparent" + android:visibility="gone" /> +</merge> diff --git a/android/src/main/res/layout/information_view.xml b/android/src/main/res/layout/information_view.xml new file mode 100644 index 0000000000..2b49e35b3c --- /dev/null +++ b/android/src/main/res/layout/information_view.xml @@ -0,0 +1,28 @@ +<merge xmlns:android="http://schemas.android.com/apk/res/android"> + <TextView android:id="@+id/description" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="9dp" + android:textColor="@color/white60" + android:textSize="13sp" + android:textStyle="bold" + android:text="" /> + <FrameLayout android:layout_width="wrap_content" + android:layout_height="wrap_content"> + <TextView android:id="@+id/information_display" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textColor="@color/white" + android:textSize="16sp" + android:textStyle="bold" + android:text="" /> + <ProgressBar android:id="@+id/spinner" + android:layout_width="20dp" + android:layout_height="20dp" + android:indeterminate="true" + android:indeterminateOnly="true" + android:indeterminateDuration="600" + android:indeterminateDrawable="@drawable/icon_spinner" + android:visibility="invisible" /> + </FrameLayout> +</merge> diff --git a/android/src/main/res/layout/settings.xml b/android/src/main/res/layout/settings.xml index 5909f54faa..a516111dc5 100644 --- a/android/src/main/res/layout/settings.xml +++ b/android/src/main/res/layout/settings.xml @@ -108,30 +108,6 @@ android:alpha="0.6" android:src="@drawable/icon_chevron" /> </LinearLayout> - <LinearLayout android:id="@+id/wireguard_keys" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="24dp" - android:paddingHorizontal="16dp" - android:background="@drawable/cell_button_background" - android:clickable="true" - android:gravity="center" - android:visibility="gone"> - <TextView android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_weight="1" - android:paddingHorizontal="8dp" - android:paddingVertical="17dp" - android:textColor="@color/white" - android:textSize="20sp" - android:textStyle="bold" - android:text="@string/wireguard_key" /> - <ImageView android:layout_width="14dp" - android:layout_height="24dp" - android:layout_weight="0" - android:alpha="0.6" - android:src="@drawable/icon_chevron" /> - </LinearLayout> <LinearLayout android:id="@+id/app_version" android:layout_width="match_parent" android:layout_height="wrap_content" diff --git a/android/src/main/res/layout/wireguard_key.xml b/android/src/main/res/layout/wireguard_key.xml index a16cee6231..554e709fdb 100644 --- a/android/src/main/res/layout/wireguard_key.xml +++ b/android/src/main/res/layout/wireguard_key.xml @@ -1,4 +1,5 @@ <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="match_parent" android:background="@color/darkBlue" @@ -8,6 +9,7 @@ <LinearLayout android:id="@+id/back" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:layout_weight="0" android:padding="12dp" android:orientation="horizontal" android:gravity="center_vertical | left" @@ -23,133 +25,87 @@ android:textColor="@color/white60" android:textSize="13sp" android:textStyle="bold" - android:text="@string/settings" /> + android:text="@string/settings_advanced" /> </LinearLayout> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" + android:layout_weight="0" android:layout_marginLeft="24dp" android:layout_marginTop="4dp" - android:layout_marginBottom="24dp" + android:layout_marginBottom="12dp" android:text="@string/wireguard_key" android:textColor="@color/white" android:textSize="32sp" android:textStyle="bold" /> - <LinearLayout android:id="@+id/wireguard_public_key_layout" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:background="@drawable/cell_button_background" - android:clickable="true" - android:gravity="center" - android:orientation="vertical" - android:paddingHorizontal="4dp"> - <TextView android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_weight="1" - android:paddingTop="10dp" - android:paddingBottom="5dp" - android:text="@string/wireguard_public_key" - android:textColor="@color/white" - android:textSize="20sp" - android:textStyle="bold" /> - <TextView android:id="@+id/wireguard_public_key" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_weight="1" - android:gravity="center" - android:textAllCaps="false" - android:textColor="@color/white60" - android:textSize="14sp" - android:textStyle="bold" /> - <TextView android:id="@+id/wireguard_key_age" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:paddingVertical="2dp" - android:textSize="14sp" - android:gravity="center" /> + <net.mullvad.mullvadvpn.ui.widget.CopyableInformationView android:id="@+id/public_key" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="0" + android:paddingHorizontal="24dp" + android:paddingVertical="12dp" + mullvad:clipboardLabel="@string/wireguard_public_key" + mullvad:copiedToast="@string/copied_wireguard_public_key" + mullvad:description="@string/public_key" + mullvad:maxLength="20" + mullvad:whenMissing="showSpinner"/> + <net.mullvad.mullvadvpn.ui.widget.InformationView android:id="@+id/key_age" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="0" + android:paddingHorizontal="24dp" + android:paddingVertical="12dp" + mullvad:description="@string/wireguard_key_generated" + mullvad:whenMissing="showSpinner"/> + <FrameLayout android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:paddingHorizontal="24dp" + android:paddingVertical="12dp"> <TextView android:id="@+id/wireguard_key_status" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:paddingBottom="5dp" android:textColor="@color/red" - android:textSize="20sp" + android:textSize="13sp" android:textStyle="bold" android:visibility="gone" /> - </LinearLayout> - <LinearLayout android:id="@+id/wireguard_generate_button_container" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="15dp" - android:layout_marginBottom="15dp" - android:background="@drawable/cell_button_background" - android:clickable="true" - android:gravity="center" - android:orientation="vertical"> - <RelativeLayout android:layout_width="wrap_content" - android:layout_height="50dp" - android:gravity="center" - android:orientation="vertical"> - <Button android:id="@+id/wg_generate_key_button" - style="@style/Button" - android:layout_width="fill_parent" - android:layout_height="fill_parent" - android:text="@string/wireguard_generate_key" /> - <ProgressBar android:id="@+id/wg_generate_key_spinner" - android:layout_width="30dp" - android:layout_height="30dp" - android:indeterminate="true" - android:indeterminateDrawable="@drawable/icon_spinner" - android:indeterminateDuration="600" - android:indeterminateOnly="true" - android:visibility="gone" /> - </RelativeLayout> - </LinearLayout> - <LinearLayout android:id="@+id/wireguard_verify_button_container" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="15dp" - android:layout_marginBottom="15dp" - android:background="@drawable/cell_button_background" - android:clickable="true" - android:gravity="center" - android:orientation="vertical"> - <RelativeLayout android:layout_width="wrap_content" - android:layout_height="50dp" - android:gravity="center" - android:orientation="vertical"> - <Button android:id="@+id/wg_verify_key_button" - style="@style/Button" - android:layout_width="fill_parent" - android:layout_height="fill_parent" - android:text="@string/wireguard_verify_key" /> - <ProgressBar android:id="@+id/wg_verify_key_spinner" - android:layout_width="30dp" - android:layout_height="30dp" - android:indeterminate="true" - android:indeterminateDrawable="@drawable/icon_spinner" - android:indeterminateDuration="600" - android:indeterminateOnly="true" - android:visibility="gone" /> - </RelativeLayout> - </LinearLayout> - <LinearLayout android:id="@+id/wireguard_manage_keys" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="24dp" - android:background="@drawable/cell_button_background" - android:clickable="true" - android:gravity="center" - android:paddingHorizontal="16dp"> - <TextView android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:paddingHorizontal="8dp" - android:paddingVertical="17dp" - android:text="@string/wireguard_manage_keys" - android:textColor="@color/white" - android:textSize="20sp" - android:textStyle="bold" /> - <ImageView android:layout_width="16dp" - android:layout_height="16dp" - android:alpha="0.6" - android:src="@drawable/icon_extlink" /> - </LinearLayout> + <ProgressBar android:id="@+id/verifying_key_spinner" + android:layout_width="20dp" + android:layout_height="20dp" + android:layout_gravity="center" + android:indeterminate="true" + android:indeterminateOnly="true" + android:indeterminateDuration="600" + android:indeterminateDrawable="@drawable/icon_spinner" + android:visibility="gone" /> + </FrameLayout> + <Space android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" /> + <net.mullvad.mullvadvpn.ui.widget.Button android:id="@+id/generate_key" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="0" + android:layout_marginHorizontal="24dp" + mullvad:buttonColor="green" + mullvad:text="@string/wireguard_generate_key" + mullvad:showSpinner="true" /> + <net.mullvad.mullvadvpn.ui.widget.Button android:id="@+id/verify_key" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="0" + android:layout_marginTop="24dp" + android:layout_marginHorizontal="24dp" + mullvad:buttonColor="blue" + mullvad:text="@string/wireguard_verify_key" + mullvad:showSpinner="true" /> + <net.mullvad.mullvadvpn.ui.widget.UrlButton android:id="@+id/manage_keys" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_weight="0" + android:layout_marginTop="24dp" + android:layout_marginBottom="24dp" + android:layout_marginHorizontal="24dp" + mullvad:text="@string/wireguard_manage_keys" + mullvad:buttonColor="blue" + mullvad:url="@string/wg_key_url" + mullvad:withToken="true" /> </LinearLayout> diff --git a/android/src/main/res/values/attrs.xml b/android/src/main/res/values/attrs.xml new file mode 100644 index 0000000000..85e9c24dd5 --- /dev/null +++ b/android/src/main/res/values/attrs.xml @@ -0,0 +1,30 @@ +<resources> + <declare-styleable name="Button"> + <attr name="buttonColor" format="enum"> + <enum name="blue" value="0"/> + <enum name="green" value="1"/> + </attr> + <attr name="detailImage" format="reference"/> + <attr name="showSpinner" format="boolean"/> + <attr name="text" format="reference|string"/> + </declare-styleable> + <declare-styleable name="CopyableInformationView"> + <attr name="clipboardLabel" format="reference|string"/> + <attr name="copiedToast" format="reference|string"/> + </declare-styleable> + <declare-styleable name="InformationView"> + <attr name="description" format="reference|string"/> + <attr name="errorColor" format="reference|color"/> + <attr name="informationColor" format="reference|color"/> + <attr name="maxLength" format="integer"/> + <attr name="whenMissing" format="enum"> + <enum name="nothing" value="0"/> + <enum name="hide" value="1"/> + <enum name="showSpinner" value="2"/> + </attr> + </declare-styleable> + <declare-styleable name="UrlButton"> + <attr name="url" format="reference|string"/> + <attr name="withToken" format="boolean"/> + </declare-styleable> +</resources> diff --git a/android/src/main/res/values/plurals.xml b/android/src/main/res/values/plurals.xml index 5395e2f4fa..679c4f46db 100644 --- a/android/src/main/res/values/plurals.xml +++ b/android/src/main/res/values/plurals.xml @@ -11,4 +11,25 @@ <item quantity="one">%d day left</item> <item quantity="other">%d days left</item> </plurals> + <plurals name="minutes_ago"> + <item quantity="zero">less than a minute ago</item> + <item quantity="one">a minute ago</item> + <item quantity="other">%d minutes ago</item> + </plurals> + <plurals name="hours_ago"> + <item quantity="one">an hour ago</item> + <item quantity="other">%d hours ago</item> + </plurals> + <plurals name="days_ago"> + <item quantity="one">a day ago</item> + <item quantity="other">%d days ago</item> + </plurals> + <plurals name="months_ago"> + <item quantity="one">a month ago</item> + <item quantity="other">%d months ago</item> + </plurals> + <plurals name="years_ago"> + <item quantity="one">a year ago</item> + <item quantity="other">%d years ago</item> + </plurals> </resources> diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 744ae5a0c1..cbef0fe68c 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -33,6 +33,8 @@ <string name="settings_account">Account</string> <string name="less_than_a_day_left">less than a day left</string> + <string name="less_than_a_minute_ago">less than a minute + ago</string> <string name="out_of_time">Out of time</string> <string name="settings_preferences">Preferences</string> <string name="settings_advanced">Advanced</string> @@ -45,7 +47,7 @@ <string name="mullvad_account_number">Mullvad account number</string> <string name="copied_mullvad_account_number">Copied Mullvad - account number</string> + account number to clipboard</string> <string name="paid_until">Paid until</string> <string name="log_out">Log out</string> <string name="local_network_sharing">Local network @@ -150,22 +152,25 @@ your real location is masked with a private and secure location in the selected region</string> <string name="wireguard_key">WireGuard key</string> - <string name="wireguard_key_copied_to_clibpoard">Key copied to - clipboard</string> - <string name="wireguard_public_key">Public key</string> + <string name="public_key">Public key</string> + <string name="wireguard_key_generated">Key generated</string> <string name="wireguard_verify_key">Verify key</string> <string name="wireguard_generate_key">Generate key</string> <string name="wireguard_replace_key">Regenerate key</string> <string name="wireguard_manage_keys">Manage keys</string> <string name="wireguard_key_age">Key generated on</string> - <string name="wireguard_key_connectivity">Connectivity required - to manage your key.</string> - <string name="wireguard_key_blocked_state_message">Can\'t - manage keys in blocked state</string> + <string name="wireguard_key_reconnecting">Reconnecting with new + WireGuard key...</string> + <string name="wireguard_key_blocked_state_message">Unable to + manage keys while in a blocked state</string> <string name="wireguard_key_valid">Key is valid</string> <string name="wireguard_key_invalid">Key is invalid</string> - <string name="wireguard_key_verification_failure">Failed to - validate key</string> + <string name="wireguard_key_verification_failure">Key verification + failed</string> + <string name="wireguard_public_key">WireGuard public key + </string> + <string name="copied_wireguard_public_key">Copied WireGuard + public key to clipboard</string> <string name="account_url"> https://mullvad.net/en/account</string> <string name="wg_key_url"> @@ -174,4 +179,6 @@ https://mullvad.net/en/account/create</string> <string name="download_url"> https://mullvad.net/en/download</string> + <string name="copied_to_clipboard">Copied to + clipboard</string> </resources> diff --git a/android/src/main/res/values/styles.xml b/android/src/main/res/values/styles.xml index d20f758ad9..c380a2f51c 100644 --- a/android/src/main/res/values/styles.xml +++ b/android/src/main/res/values/styles.xml @@ -20,6 +20,7 @@ <item name="android:layout_height"> @dimen/normal_button_height</item> <item name="android:layout_width">match_parent</item> + <item name="android:padding">0dp</item> <item name="android:textAllCaps">false</item> <item name="android:textColor">@color/white</item> <item name="android:textSize">20sp</item> |
