summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md4
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/dataproxy/KeyStatusListener.kt2
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AccountFragment.kt49
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt22
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/SettingsFragment.kt9
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/WireguardKeyFragment.kt469
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/Button.kt146
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/CopyableInformationView.kt65
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/InformationView.kt182
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/UrlButton.kt102
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/util/JobTracker.kt29
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/util/TimeAgoFormatter.kt36
-rw-r--r--android/src/main/res/layout/account.xml63
-rw-r--r--android/src/main/res/layout/advanced.xml23
-rw-r--r--android/src/main/res/layout/button.xml23
-rw-r--r--android/src/main/res/layout/information_view.xml28
-rw-r--r--android/src/main/res/layout/settings.xml24
-rw-r--r--android/src/main/res/layout/wireguard_key.xml184
-rw-r--r--android/src/main/res/values/attrs.xml30
-rw-r--r--android/src/main/res/values/plurals.xml21
-rw-r--r--android/src/main/res/values/strings.xml27
-rw-r--r--android/src/main/res/values/styles.xml1
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>