summaryrefslogtreecommitdiffhomepage
path: root/android
diff options
context:
space:
mode:
authorJanito Vaqueiro Ferreira Filho <janito@mullvad.net>2020-12-10 10:30:09 -0300
committerJanito Vaqueiro Ferreira Filho <janito@mullvad.net>2020-12-10 10:30:09 -0300
commit36b126a1b8c92445508e901ddcdca6d8ebf940c4 (patch)
tree0803e5b8c118969acc239c8adda6a9119f881f2b /android
parent4aa77f22a715758c77888f6817d11068c071d9c1 (diff)
parentec89a7de3c3aec6260f27c5ce12a768ebee965be (diff)
downloadmullvadvpn-36b126a1b8c92445508e901ddcdca6d8ebf940c4.tar.xz
mullvadvpn-36b126a1b8c92445508e901ddcdca6d8ebf940c4.zip
Merge branch 'android-custom-dns-ui'
Diffstat (limited to 'android')
-rw-r--r--android/build.gradle1
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/service/CustomDns.kt26
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt85
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/AddCustomDnsServerHolder.kt16
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsAdapter.kt291
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsFooterHolder.kt5
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsItemHolder.kt6
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsServerHolder.kt30
-rw-r--r--android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/EditCustomDnsServerHolder.kt62
-rw-r--r--android/src/main/kotlin/net/mullvad/talpid/util/InetAddressExt.kt10
-rw-r--r--android/src/main/res/drawable/icon_add.xml7
-rw-r--r--android/src/main/res/drawable/icon_tick_green.xml11
-rw-r--r--android/src/main/res/layout/add_custom_dns_server.xml31
-rw-r--r--android/src/main/res/layout/advanced.xml36
-rw-r--r--android/src/main/res/layout/advanced_header.xml34
-rw-r--r--android/src/main/res/layout/custom_dns_footer.xml14
-rw-r--r--android/src/main/res/layout/custom_dns_server.xml31
-rw-r--r--android/src/main/res/layout/edit_custom_dns_server.xml30
-rw-r--r--android/src/main/res/values/strings.xml4
19 files changed, 690 insertions, 40 deletions
diff --git a/android/build.gradle b/android/build.gradle
index 9b45866a31..9a7a8e0d0b 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -94,6 +94,7 @@ dependencies {
implementation 'com.android.support:appcompat-v7:28.0.0'
implementation 'com.android.support:design:28.0.0'
implementation 'com.android.support:recyclerview-v7:28.0.0'
+ implementation 'commons-validator:commons-validator:1.7'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.4.10'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
implementation 'joda-time:joda-time:2.10.2'
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/service/CustomDns.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/service/CustomDns.kt
index 95a4be3cb9..9b41877f4c 100644
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/service/CustomDns.kt
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/service/CustomDns.kt
@@ -43,13 +43,37 @@ class CustomDns(val daemon: MullvadDaemon, val settingsListener: SettingsListene
}
}
- fun addDnsServer(server: InetAddress) {
+ fun addDnsServer(server: InetAddress): Boolean {
synchronized(this) {
if (!dnsServers.contains(server)) {
dnsServers.add(server)
changeDnsOptions(enabled, dnsServers)
+
+ return true
+ }
+ }
+
+ return false
+ }
+
+ fun replaceDnsServer(oldServer: InetAddress, newServer: InetAddress): Boolean {
+ synchronized(this) {
+ if (oldServer == newServer) {
+ return true
+ } else if (!dnsServers.contains(newServer)) {
+ val index = dnsServers.indexOf(oldServer)
+
+ if (index >= 0) {
+ dnsServers.removeAt(index)
+ dnsServers.add(index, newServer)
+ changeDnsOptions(enabled, dnsServers)
+
+ return true
+ }
}
}
+
+ return false
}
fun removeDnsServer(server: InetAddress) {
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 2e31639676..a63de128fa 100644
--- a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/AdvancedFragment.kt
@@ -1,15 +1,23 @@
package net.mullvad.mullvadvpn.ui
import android.os.Bundle
+import android.support.v7.widget.LinearLayoutManager
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.model.Settings
+import net.mullvad.mullvadvpn.ui.customdns.CustomDnsAdapter
+import net.mullvad.mullvadvpn.ui.widget.CellSwitch
+import net.mullvad.mullvadvpn.ui.widget.CustomRecyclerView
import net.mullvad.mullvadvpn.ui.widget.MtuCell
import net.mullvad.mullvadvpn.ui.widget.NavigateCell
+import net.mullvad.mullvadvpn.ui.widget.ToggleCell
+import net.mullvad.mullvadvpn.util.AdapterWithHeader
class AdvancedFragment : ServiceDependentFragment(OnNoService.GoBack) {
+ private lateinit var customDnsAdapter: CustomDnsAdapter
+ private lateinit var customDnsToggle: ToggleCell
private lateinit var wireguardMtuInput: MtuCell
private lateinit var titleController: CollapsibleTitleController
@@ -21,9 +29,44 @@ class AdvancedFragment : ServiceDependentFragment(OnNoService.GoBack) {
val view = inflater.inflate(R.layout.advanced, container, false)
view.findViewById<View>(R.id.back).setOnClickListener {
+ customDnsAdapter.stopEditing()
parentActivity.onBackPressed()
}
+ titleController = CollapsibleTitleController(view, R.id.contents)
+
+ customDnsAdapter = CustomDnsAdapter(customDns)
+
+ view.findViewById<CustomRecyclerView>(R.id.contents).apply {
+ layoutManager = LinearLayoutManager(parentActivity)
+
+ adapter = AdapterWithHeader(customDnsAdapter, R.layout.advanced_header).apply {
+ onHeaderAvailable = { headerView ->
+ configureHeader(headerView)
+ titleController.expandedTitleView = headerView.findViewById(R.id.expanded_title)
+ }
+ }
+
+ addItemDecoration(
+ ListItemDividerDecoration(parentActivity).apply {
+ topOffsetId = R.dimen.list_item_divider
+ }
+ )
+ }
+
+ attachBackButtonHandler()
+
+ return view
+ }
+
+ override fun onSafelyDestroyView() {
+ detachBackButtonHandler()
+ customDnsAdapter.onDestroy()
+ titleController.onDestroy()
+ settingsListener.unsubscribe(this)
+ }
+
+ private fun configureHeader(view: View) {
wireguardMtuInput = view.findViewById<MtuCell>(R.id.wireguard_mtu).apply {
onSubmit = { mtu ->
jobTracker.newBackgroundJob("updateMtu") {
@@ -40,13 +83,31 @@ class AdvancedFragment : ServiceDependentFragment(OnNoService.GoBack) {
targetFragment = SplitTunnelingFragment::class
}
- settingsListener.subscribe(this) { settings ->
- updateUi(settings)
+ customDnsToggle = view.findViewById<ToggleCell>(R.id.enable_custom_dns).apply {
+ listener = { state ->
+ jobTracker.newBackgroundJob("toggleCustomDns") {
+ if (state == CellSwitch.State.ON) {
+ customDns.enable()
+ } else {
+ customDns.disable()
+ }
+ }
+ }
}
- titleController = CollapsibleTitleController(view)
+ customDns.onEnabledChanged.subscribe(this) { enabled ->
+ jobTracker.newUiJob("updateEnabled") {
+ if (enabled) {
+ customDnsToggle.state = CellSwitch.State.ON
+ } else {
+ customDnsToggle.state = CellSwitch.State.OFF
+ }
+ }
+ }
- return view
+ settingsListener.subscribe(this) { settings ->
+ updateUi(settings)
+ }
}
private fun updateUi(settings: Settings) {
@@ -57,8 +118,18 @@ class AdvancedFragment : ServiceDependentFragment(OnNoService.GoBack) {
}
}
- override fun onSafelyDestroyView() {
- titleController.onDestroy()
- settingsListener.unsubscribe(this)
+ private fun attachBackButtonHandler() {
+ parentActivity.backButtonHandler = {
+ if (customDnsAdapter.isEditing) {
+ customDnsAdapter.stopEditing()
+ true
+ } else {
+ false
+ }
+ }
+ }
+
+ private fun detachBackButtonHandler() {
+ parentActivity.backButtonHandler = null
}
}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/AddCustomDnsServerHolder.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/AddCustomDnsServerHolder.kt
new file mode 100644
index 0000000000..1d0f940d4b
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/AddCustomDnsServerHolder.kt
@@ -0,0 +1,16 @@
+package net.mullvad.mullvadvpn.ui.customdns
+
+import android.view.View
+import net.mullvad.mullvadvpn.R
+
+class AddCustomDnsServerHolder(view: View, adapter: CustomDnsAdapter) : CustomDnsItemHolder(view) {
+ init {
+ view.findViewById<View>(R.id.add).setOnClickListener {
+ adapter.newDnsServer()
+ }
+
+ view.findViewById<View>(R.id.click_area).setOnClickListener {
+ adapter.newDnsServer()
+ }
+ }
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsAdapter.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsAdapter.kt
new file mode 100644
index 0000000000..5c3eeee3a5
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsAdapter.kt
@@ -0,0 +1,291 @@
+package net.mullvad.mullvadvpn.ui.customdns
+
+import android.support.v7.widget.RecyclerView.Adapter
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import java.net.InetAddress
+import kotlin.properties.Delegates.observable
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import net.mullvad.mullvadvpn.R
+import net.mullvad.mullvadvpn.service.CustomDns
+import net.mullvad.mullvadvpn.util.JobTracker
+import org.apache.commons.validator.routines.InetAddressValidator
+
+class CustomDnsAdapter(val customDns: CustomDns) : Adapter<CustomDnsItemHolder>() {
+ private enum class ViewTypes {
+ ADD_SERVER,
+ EDIT_SERVER,
+ SHOW_SERVER,
+ FOOTER,
+ }
+
+ private val customDnsServersLock = Mutex()
+ private val inetAddressValidator = InetAddressValidator.getInstance()
+ private val jobTracker = JobTracker()
+
+ private var editingPosition: Int? = null
+
+ private var activeCustomDnsServers
+ by observable<List<InetAddress>>(emptyList()) { _, _, servers ->
+ if (servers != cachedCustomDnsServers) {
+ cachedCustomDnsServers = servers.toMutableList()
+ notifyDataSetChanged()
+ }
+ }
+
+ private var cachedCustomDnsServers = emptyList<InetAddress>().toMutableList()
+
+ private var enabled by observable(false) { _, oldValue, newValue ->
+ if (oldValue != newValue) {
+ if (newValue == true) {
+ notifyItemRangeInserted(0, cachedCustomDnsServers.size + 1)
+
+ if (cachedCustomDnsServers.isEmpty()) {
+ editingPosition = 0
+ }
+ } else {
+ notifyItemRangeRemoved(0, cachedCustomDnsServers.size + 1)
+ }
+ }
+ }
+
+ val isEditing
+ get() = editingPosition != null
+
+ init {
+ customDns.apply {
+ onDnsServersChanged.subscribe(this) { dnsServers ->
+ jobTracker.newUiJob("updateDnsServers") {
+ customDnsServersLock.withLock {
+ activeCustomDnsServers = dnsServers
+ }
+ }
+ }
+
+ onEnabledChanged.subscribe(this) { value ->
+ jobTracker.newUiJob("updateEnabled") {
+ customDnsServersLock.withLock {
+ enabled = value
+ }
+ }
+ }
+ }
+ }
+
+ override fun getItemCount() =
+ if (enabled) {
+ cachedCustomDnsServers.size + 2
+ } else {
+ 1
+ }
+
+ override fun getItemViewType(position: Int): Int {
+ val count = getItemCount()
+ val footer = count - 1
+ val addServer = count - 2
+
+ if (position == footer) {
+ return ViewTypes.FOOTER.ordinal
+ } else if (position == editingPosition) {
+ return ViewTypes.EDIT_SERVER.ordinal
+ } else if (position == addServer) {
+ return ViewTypes.ADD_SERVER.ordinal
+ } else {
+ return ViewTypes.SHOW_SERVER.ordinal
+ }
+ }
+
+ override fun onCreateViewHolder(parentView: ViewGroup, type: Int): CustomDnsItemHolder {
+ val inflater = LayoutInflater.from(parentView.context)
+
+ when (ViewTypes.values()[type]) {
+ ViewTypes.FOOTER -> {
+ val view = inflater.inflate(R.layout.custom_dns_footer, parentView, false)
+ return CustomDnsFooterHolder(view)
+ }
+ ViewTypes.ADD_SERVER -> {
+ val view = inflater.inflate(R.layout.add_custom_dns_server, parentView, false)
+ return AddCustomDnsServerHolder(view, this)
+ }
+ ViewTypes.EDIT_SERVER -> {
+ val view = inflater.inflate(R.layout.edit_custom_dns_server, parentView, false)
+ return EditCustomDnsServerHolder(view, this)
+ }
+ ViewTypes.SHOW_SERVER -> {
+ val view = inflater.inflate(R.layout.custom_dns_server, parentView, false)
+ return CustomDnsServerHolder(view, this)
+ }
+ }
+ }
+
+ override fun onBindViewHolder(holder: CustomDnsItemHolder, position: Int) {
+ if (holder is CustomDnsServerHolder) {
+ holder.serverAddress = cachedCustomDnsServers[position]
+ } else if (holder is EditCustomDnsServerHolder) {
+ if (position >= cachedCustomDnsServers.size) {
+ holder.serverAddress = null
+ } else {
+ holder.serverAddress = cachedCustomDnsServers[position]
+ }
+ }
+ }
+
+ fun onDestroy() {
+ customDns.apply {
+ onDnsServersChanged.unsubscribe(this)
+ onEnabledChanged.unsubscribe(this)
+ }
+ }
+
+ fun newDnsServer() {
+ jobTracker.newUiJob("newDnsServer") {
+ customDnsServersLock.withLock {
+ if (enabled) {
+ val count = getItemCount()
+
+ editDnsServerAt(count - 2)
+ }
+ }
+ }
+ }
+
+ fun saveDnsServer(address: String, errorCallback: () -> Unit) {
+ jobTracker.newUiJob("saveDnsServer $address") {
+ customDnsServersLock.withLock {
+ editingPosition?.let { position ->
+ var validAddress: Boolean
+
+ if (position >= cachedCustomDnsServers.size) {
+ validAddress = addDnsServer(address)
+ } else {
+ validAddress = replaceDnsServer(address, position)
+ }
+
+ if (!validAddress) {
+ errorCallback()
+ }
+ }
+ }
+ }
+ }
+
+ fun editDnsServer(address: InetAddress) {
+ jobTracker.newUiJob("editDnsServer $address") {
+ customDnsServersLock.withLock {
+ if (enabled) {
+ val position = cachedCustomDnsServers.indexOf(address)
+
+ editDnsServerAt(position)
+ }
+ }
+ }
+ }
+
+ fun stopEditing() {
+ jobTracker.newUiJob("stopEditing") {
+ customDnsServersLock.withLock {
+ if (enabled) {
+ editDnsServerAt(null)
+ }
+ }
+ }
+ }
+
+ fun stopEditing(address: InetAddress) {
+ jobTracker.newUiJob("stopEditing $address") {
+ customDnsServersLock.withLock {
+ if (enabled) {
+ editingPosition?.let { position ->
+ if (cachedCustomDnsServers.getOrNull(position) == address) {
+ editDnsServerAt(null)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fun removeDnsServer(address: InetAddress) {
+ jobTracker.newUiJob("removeDnsServer $address") {
+ customDnsServersLock.withLock {
+ val position = jobTracker.runOnBackground {
+ val index = cachedCustomDnsServers.indexOf(address)
+
+ cachedCustomDnsServers.removeAt(index)
+ customDns.removeDnsServer(address)
+
+ index
+ }
+
+ notifyItemRemoved(position)
+ }
+ }
+ }
+
+ private suspend fun addDnsServer(addressText: String): Boolean {
+ var added = false
+
+ withValidAddress(addressText) { address ->
+ if (customDns.addDnsServer(address)) {
+ cachedCustomDnsServers.add(address)
+ added = true
+ }
+ }
+
+ if (added) {
+ editingPosition = null
+
+ val count = getItemCount()
+
+ notifyItemChanged(count - 3)
+ notifyItemInserted(count - 2)
+ }
+
+ return added
+ }
+
+ private suspend fun replaceDnsServer(address: String, position: Int): Boolean {
+ var replaced = false
+
+ withValidAddress(address) { newAddress ->
+ val oldAddress = cachedCustomDnsServers[position]
+
+ if (customDns.replaceDnsServer(oldAddress, newAddress)) {
+ cachedCustomDnsServers[position] = newAddress
+ replaced = true
+ }
+ }
+
+ if (replaced) {
+ editingPosition = null
+ notifyItemChanged(position)
+ }
+
+ return replaced
+ }
+
+ private fun editDnsServerAt(position: Int?) {
+ editingPosition?.let { oldPosition ->
+ notifyItemChanged(oldPosition)
+ }
+
+ editingPosition = position
+
+ position?.let { newPosition ->
+ notifyItemChanged(newPosition)
+ }
+ }
+
+ private suspend fun withValidAddress(addressText: String, handler: (InetAddress) -> Unit) {
+ jobTracker.runOnBackground {
+ if (inetAddressValidator.isValid(addressText)) {
+ val address = InetAddress.getByName(addressText)
+
+ if (!address.isLoopbackAddress()) {
+ handler(address)
+ }
+ }
+ }
+ }
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsFooterHolder.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsFooterHolder.kt
new file mode 100644
index 0000000000..d09beffbce
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsFooterHolder.kt
@@ -0,0 +1,5 @@
+package net.mullvad.mullvadvpn.ui.customdns
+
+import android.view.View
+
+class CustomDnsFooterHolder(view: View) : CustomDnsItemHolder(view)
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsItemHolder.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsItemHolder.kt
new file mode 100644
index 0000000000..3276737e5d
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsItemHolder.kt
@@ -0,0 +1,6 @@
+package net.mullvad.mullvadvpn.ui.customdns
+
+import android.support.v7.widget.RecyclerView.ViewHolder
+import android.view.View
+
+abstract class CustomDnsItemHolder(view: View) : ViewHolder(view)
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsServerHolder.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsServerHolder.kt
new file mode 100644
index 0000000000..49efad9310
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/CustomDnsServerHolder.kt
@@ -0,0 +1,30 @@
+package net.mullvad.mullvadvpn.ui.customdns
+
+import android.view.View
+import android.widget.TextView
+import java.net.InetAddress
+import kotlin.properties.Delegates.observable
+import net.mullvad.mullvadvpn.R
+import net.mullvad.talpid.util.addressString
+
+class CustomDnsServerHolder(view: View, adapter: CustomDnsAdapter) : CustomDnsItemHolder(view) {
+ private val label: TextView = view.findViewById(R.id.label)
+
+ var serverAddress by observable<InetAddress?>(null) { _, _, address ->
+ label.text = address?.addressString() ?: ""
+ }
+
+ init {
+ view.findViewById<View>(R.id.click_area).setOnClickListener {
+ serverAddress?.let { address ->
+ adapter.editDnsServer(address)
+ }
+ }
+
+ view.findViewById<View>(R.id.remove).setOnClickListener {
+ serverAddress?.let { address ->
+ adapter.removeDnsServer(address)
+ }
+ }
+ }
+}
diff --git a/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/EditCustomDnsServerHolder.kt b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/EditCustomDnsServerHolder.kt
new file mode 100644
index 0000000000..560bfb22d8
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/mullvadvpn/ui/customdns/EditCustomDnsServerHolder.kt
@@ -0,0 +1,62 @@
+package net.mullvad.mullvadvpn.ui.customdns
+
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.View
+import android.view.View.OnFocusChangeListener
+import android.widget.EditText
+import java.net.InetAddress
+import kotlin.properties.Delegates.observable
+import net.mullvad.mullvadvpn.R
+import net.mullvad.talpid.util.addressString
+
+class EditCustomDnsServerHolder(view: View, adapter: CustomDnsAdapter) : CustomDnsItemHolder(view) {
+ private val context = view.context
+ private val errorColor = context.getColor(R.color.red)
+ private val normalColor = context.getColor(R.color.blue)
+
+ private val input: EditText = view.findViewById<EditText>(R.id.input).apply {
+ onFocusChangeListener = OnFocusChangeListener { _, hasFocus ->
+ if (!hasFocus) {
+ serverAddress?.let { address ->
+ adapter.stopEditing(address)
+ }
+ }
+ }
+ }
+
+ private val watcher = object : TextWatcher {
+ override fun beforeTextChanged(text: CharSequence, start: Int, count: Int, after: Int) {}
+
+ override fun afterTextChanged(text: Editable) {
+ input.setTextColor(normalColor)
+ input.removeTextChangedListener(this)
+ }
+
+ override fun onTextChanged(text: CharSequence, start: Int, before: Int, count: Int) {}
+ }
+
+ var serverAddress by observable<InetAddress?>(null) { _, _, address ->
+ if (address != null) {
+ val addressString = address.addressString()
+
+ input.setText(addressString)
+ input.setSelection(addressString.length)
+ } else {
+ input.setText("")
+ }
+
+ input.requestFocus()
+ }
+
+ init {
+ view.findViewById<View>(R.id.save).setOnClickListener {
+ adapter.saveDnsServer(input.text.toString()) {
+ input.apply {
+ setTextColor(errorColor)
+ addTextChangedListener(watcher)
+ }
+ }
+ }
+ }
+}
diff --git a/android/src/main/kotlin/net/mullvad/talpid/util/InetAddressExt.kt b/android/src/main/kotlin/net/mullvad/talpid/util/InetAddressExt.kt
new file mode 100644
index 0000000000..d310deb884
--- /dev/null
+++ b/android/src/main/kotlin/net/mullvad/talpid/util/InetAddressExt.kt
@@ -0,0 +1,10 @@
+package net.mullvad.talpid.util
+
+import java.net.InetAddress
+
+fun InetAddress.addressString(): String {
+ val hostNameAndAddress = this.toString().split('/', limit = 2)
+ val address = hostNameAndAddress[1]
+
+ return address
+}
diff --git a/android/src/main/res/drawable/icon_add.xml b/android/src/main/res/drawable/icon_add.xml
new file mode 100644
index 0000000000..f44a660a95
--- /dev/null
+++ b/android/src/main/res/drawable/icon_add.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<rotate xmlns:android="http://schemas.android.com/apk/res/android"
+ android:fromDegrees="45"
+ android:toDegrees="45"
+ android:pivotX="50%"
+ android:pivotY="50%"
+ android:drawable="@drawable/icon_close" />
diff --git a/android/src/main/res/drawable/icon_tick_green.xml b/android/src/main/res/drawable/icon_tick_green.xml
new file mode 100644
index 0000000000..a761a863ba
--- /dev/null
+++ b/android/src/main/res/drawable/icon_tick_green.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24.0"
+ android:viewportHeight="24.0">
+ <group>
+ <path android:fillColor="@color/green"
+ android:pathData="M2.92646877,10.7979185 C2.25699855,10.1340272 1.17157288,10.1340272 0.502102661,10.7979185 C-0.167367554,11.4618098 -0.167367554,12.5381902 0.502102661,13.2020815 L7.35924552,20.0020815 C8.02871573,20.6659728 9.11414141,20.6659728 9.78361162,20.0020815 L23.4978973,6.40208153 C24.1673676,5.73819023 24.1673676,4.66180977 23.4978973,3.99791847 C22.8284271,3.33402718 21.7430014,3.33402718 21.0735312,3.99791847 L8.57142857,16.3958369 L2.92646877,10.7979185 Z" />
+ </group>
+</vector>
diff --git a/android/src/main/res/layout/add_custom_dns_server.xml b/android/src/main/res/layout/add_custom_dns_server.xml
new file mode 100644
index 0000000000..892b48a6fe
--- /dev/null
+++ b/android/src/main/res/layout/add_custom_dns_server.xml
@@ -0,0 +1,31 @@
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:background="@color/blue40"
+ android:orientation="horizontal">
+ <TextView android:layout_width="wrap_content"
+ android:layout_height="match_parent"
+ android:layout_marginLeft="54dp"
+ android:layout_marginVertical="14dp"
+ android:background="?android:attr/selectableItemBackground"
+ android:gravity="center_vertical"
+ android:textColor="@color/white"
+ android:textSize="@dimen/text_medium"
+ android:text="@string/add_a_server" />
+ <View android:id="@+id/click_area"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_alignParentLeft="true"
+ android:layout_alignParentRight="true"
+ android:focusable="true"
+ android:clickable="true"
+ android:background="?android:attr/selectableItemBackground" />
+ <ImageButton android:id="@+id/add"
+ android:layout_width="wrap_content"
+ android:layout_height="match_parent"
+ android:layout_gravity="right"
+ android:paddingHorizontal="16dp"
+ android:paddingVertical="14dp"
+ android:background="?android:attr/selectableItemBackground"
+ android:src="@drawable/icon_add" />
+</FrameLayout>
diff --git a/android/src/main/res/layout/advanced.xml b/android/src/main/res/layout/advanced.xml
index c96c1b3d03..70359352e2 100644
--- a/android/src/main/res/layout/advanced.xml
+++ b/android/src/main/res/layout/advanced.xml
@@ -27,37 +27,9 @@
android:text="@string/settings_advanced"
style="@style/SettingsCollapsedHeader" />
</FrameLayout>
- <net.mullvad.mullvadvpn.ui.widget.ListenableScrollView android:id="@+id/scroll_area"
- android:layout_width="match_parent"
- android:layout_height="match_parent">
-
- <LinearLayout android:layout_width="match_parent"
- android:layout_height="match_parent"
- android:paddingBottom="@dimen/screen_vertical_margin"
- android:orientation="vertical">
- <TextView android:id="@+id/expanded_title"
- android:layout_width="wrap_content"
- android:layout_height="wrap_content"
- android:layout_marginTop="2dp"
- android:layout_marginLeft="@dimen/side_margin"
- android:text="@string/settings_advanced"
- style="@style/SettingsExpandedHeader" />
- <net.mullvad.mullvadvpn.ui.widget.MtuCell android:id="@+id/wireguard_mtu"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginTop="@dimen/vertical_space"
- mullvad:text="@string/wireguard_mtu" />
- <net.mullvad.mullvadvpn.ui.widget.NavigateCell android:id="@+id/wireguard_keys"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginTop="@dimen/vertical_space"
- mullvad:text="@string/wireguard_key" />
- <net.mullvad.mullvadvpn.ui.widget.NavigateCell android:id="@+id/split_tunneling"
- android:layout_width="match_parent"
- android:layout_height="wrap_content"
- android:layout_marginTop="1dp"
- mullvad:text="@string/split_tunneling" />
- </LinearLayout>
- </net.mullvad.mullvadvpn.ui.widget.ListenableScrollView>
+ <net.mullvad.mullvadvpn.ui.widget.CustomRecyclerView android:id="@+id/contents"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:scrollbars="vertical" />
</LinearLayout>
</FrameLayout>
diff --git a/android/src/main/res/layout/advanced_header.xml b/android/src/main/res/layout/advanced_header.xml
new file mode 100644
index 0000000000..825e8285a4
--- /dev/null
+++ b/android/src/main/res/layout/advanced_header.xml
@@ -0,0 +1,34 @@
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:mullvad="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:gravity="left">
+ <TextView android:id="@+id/expanded_title"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="2dp"
+ android:layout_marginLeft="@dimen/side_margin"
+ android:text="@string/settings_advanced"
+ style="@style/SettingsExpandedHeader" />
+ <net.mullvad.mullvadvpn.ui.widget.MtuCell android:id="@+id/wireguard_mtu"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="@dimen/vertical_space"
+ mullvad:text="@string/wireguard_mtu" />
+ <net.mullvad.mullvadvpn.ui.widget.NavigateCell android:id="@+id/wireguard_keys"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="@dimen/vertical_space"
+ mullvad:text="@string/wireguard_key" />
+ <net.mullvad.mullvadvpn.ui.widget.NavigateCell android:id="@+id/split_tunneling"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="1dp"
+ mullvad:text="@string/split_tunneling" />
+ <net.mullvad.mullvadvpn.ui.widget.ToggleCell android:id="@+id/enable_custom_dns"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="@dimen/vertical_space"
+ mullvad:text="@string/enable_custom_dns" />
+</LinearLayout>
diff --git a/android/src/main/res/layout/custom_dns_footer.xml b/android/src/main/res/layout/custom_dns_footer.xml
new file mode 100644
index 0000000000..c939eebb7f
--- /dev/null
+++ b/android/src/main/res/layout/custom_dns_footer.xml
@@ -0,0 +1,14 @@
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:gravity="center">
+ <TextView android:id="@+id/name"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:paddingHorizontal="@dimen/cell_footer_horizontal_padding"
+ android:paddingBottom="@dimen/screen_vertical_margin"
+ android:paddingTop="@dimen/cell_footer_top_padding"
+ android:textColor="@color/white60"
+ android:textSize="@dimen/text_small"
+ android:text="@string/custom_dns_footer" />
+</FrameLayout>
diff --git a/android/src/main/res/layout/custom_dns_server.xml b/android/src/main/res/layout/custom_dns_server.xml
new file mode 100644
index 0000000000..54d7e9f01e
--- /dev/null
+++ b/android/src/main/res/layout/custom_dns_server.xml
@@ -0,0 +1,31 @@
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:background="@color/blue40"
+ android:orientation="horizontal">
+ <TextView android:id="@+id/label"
+ android:layout_width="wrap_content"
+ android:layout_height="match_parent"
+ android:layout_marginLeft="54dp"
+ android:layout_marginVertical="14dp"
+ android:background="?android:attr/selectableItemBackground"
+ android:gravity="center_vertical"
+ android:textColor="@color/white"
+ android:textSize="@dimen/text_medium" />
+ <View android:id="@+id/click_area"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_alignParentLeft="true"
+ android:layout_alignParentRight="true"
+ android:focusable="true"
+ android:clickable="true"
+ android:background="?android:attr/selectableItemBackground" />
+ <ImageButton android:id="@+id/remove"
+ android:layout_width="wrap_content"
+ android:layout_height="match_parent"
+ android:layout_gravity="right"
+ android:paddingHorizontal="16dp"
+ android:paddingVertical="14dp"
+ android:background="?android:attr/selectableItemBackground"
+ android:src="@drawable/icon_close" />
+</FrameLayout>
diff --git a/android/src/main/res/layout/edit_custom_dns_server.xml b/android/src/main/res/layout/edit_custom_dns_server.xml
new file mode 100644
index 0000000000..91090efb9e
--- /dev/null
+++ b/android/src/main/res/layout/edit_custom_dns_server.xml
@@ -0,0 +1,30 @@
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:background="@color/white"
+ android:orientation="horizontal">
+ <EditText android:id="@+id/input"
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_weight="1"
+ android:layout_marginLeft="54dp"
+ android:layout_marginVertical="14dp"
+ android:gravity="center_vertical"
+ android:background="@android:color/transparent"
+ android:singleLine="true"
+ android:imeOptions="flagNoPersonalizedLearning"
+ android:textCursorDrawable="@drawable/text_input_cursor"
+ android:textColorHint="@color/blue60"
+ android:textColor="@color/blue"
+ android:textSize="@dimen/text_medium"
+ android:hint="@string/custom_dns_example" />
+ <ImageButton android:id="@+id/save"
+ android:layout_width="wrap_content"
+ android:layout_height="match_parent"
+ android:layout_weight="0"
+ android:layout_gravity="right"
+ android:paddingHorizontal="16dp"
+ android:paddingVertical="14dp"
+ android:background="?android:attr/selectableItemBackground"
+ android:src="@drawable/icon_tick_green" />
+</LinearLayout>
diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml
index f39e93bba0..fb442a9e9b 100644
--- a/android/src/main/res/values/strings.xml
+++ b/android/src/main/res/values/strings.xml
@@ -164,6 +164,10 @@
<string name="split_tunneling_description">Split tunneling makes it possible to select which
applications should not be routed through the VPN tunnel.</string>
<string name="enable">Enable</string>
+ <string name="enable_custom_dns">Use custom DNS server</string>
+ <string name="add_a_server">Add a server</string>
+ <string name="custom_dns_example">e.g. 123.456.789.111</string>
+ <string name="custom_dns_footer">Enable to add at least one DNS server.</string>
<string name="exclude_applications">Exclude applications</string>
<string name="account_url">https://mullvad.net/en/account</string>
<string name="wg_key_url">https://mullvad.net/en/account/ports</string>