Commit a85c5c20 authored by Mygod's avatar Mygod

Add hosts to global settings

parent a1834e63
...@@ -23,10 +23,12 @@ package com.github.shadowsocks.bg ...@@ -23,10 +23,12 @@ package com.github.shadowsocks.bg
import com.github.shadowsocks.Core.app import com.github.shadowsocks.Core.app
import com.github.shadowsocks.acl.Acl import com.github.shadowsocks.acl.Acl
import com.github.shadowsocks.core.R import com.github.shadowsocks.core.R
import com.github.shadowsocks.net.HostsFile
import com.github.shadowsocks.net.LocalDnsServer import com.github.shadowsocks.net.LocalDnsServer
import com.github.shadowsocks.net.Socks5Endpoint import com.github.shadowsocks.net.Socks5Endpoint
import com.github.shadowsocks.net.Subnet import com.github.shadowsocks.net.Subnet
import com.github.shadowsocks.preference.DataStore import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.Key
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.URI import java.net.URI
...@@ -49,7 +51,8 @@ object LocalDnsService { ...@@ -49,7 +51,8 @@ object LocalDnsService {
val dns = URI("dns://${profile.remoteDns}") val dns = URI("dns://${profile.remoteDns}")
LocalDnsServer(this::resolver, LocalDnsServer(this::resolver,
Socks5Endpoint(dns.host, if (dns.port < 0) 53 else dns.port), Socks5Endpoint(dns.host, if (dns.port < 0) 53 else dns.port),
DataStore.proxyAddress).apply { DataStore.proxyAddress,
HostsFile(DataStore.publicStore.getString(Key.hosts) ?: "")).apply {
tcp = !profile.udpdns tcp = !profile.udpdns
when (profile.route) { when (profile.route) {
Acl.BYPASS_CHN, Acl.BYPASS_LAN_CHN, Acl.GFWLIST, Acl.CUSTOM_RULES -> { Acl.BYPASS_CHN, Acl.BYPASS_LAN_CHN, Acl.GFWLIST, Acl.CUSTOM_RULES -> {
......
/*******************************************************************************
* *
* Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package com.github.shadowsocks.net
import com.github.shadowsocks.utils.computeIfAbsentCompat
import com.github.shadowsocks.utils.parseNumericAddress
import java.net.InetAddress
class HostsFile(input: String = "") {
private val map = mutableMapOf<String, MutableSet<InetAddress>>()
init {
for (line in input.lineSequence()) {
val entries = line.substringBefore('#').splitToSequence(' ', '\t').filter { it.isNotEmpty() }
val address = entries.firstOrNull()?.parseNumericAddress() ?: continue
for (hostname in entries.drop(1)) map.computeIfAbsentCompat(hostname) { LinkedHashSet(1) }.add(address)
}
}
val configuredHostnames get() = map.size
fun resolve(hostname: String) = map[hostname]?.shuffled() ?: emptyList()
}
...@@ -22,13 +22,10 @@ package com.github.shadowsocks.net ...@@ -22,13 +22,10 @@ package com.github.shadowsocks.net
import android.util.Log import android.util.Log
import com.crashlytics.android.Crashlytics import com.crashlytics.android.Crashlytics
import com.github.shadowsocks.Core
import com.github.shadowsocks.utils.parseNumericAddress
import com.github.shadowsocks.utils.printLog import com.github.shadowsocks.utils.printLog
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.xbill.DNS.* import org.xbill.DNS.*
import java.io.EOFException import java.io.EOFException
import java.io.File
import java.io.IOException import java.io.IOException
import java.net.* import java.net.*
import java.nio.ByteBuffer import java.nio.ByteBuffer
...@@ -46,7 +43,9 @@ import java.nio.channels.SocketChannel ...@@ -46,7 +43,9 @@ import java.nio.channels.SocketChannel
* https://github.com/shadowsocks/overture/tree/874f22613c334a3b78e40155a55479b7b69fee04 * https://github.com/shadowsocks/overture/tree/874f22613c334a3b78e40155a55479b7b69fee04
*/ */
class LocalDnsServer(private val localResolver: suspend (String) -> Array<InetAddress>, class LocalDnsServer(private val localResolver: suspend (String) -> Array<InetAddress>,
private val remoteDns: Socks5Endpoint, private val proxy: SocketAddress) : CoroutineScope { private val remoteDns: Socks5Endpoint,
private val proxy: SocketAddress,
private val hosts: HostsFile) : CoroutineScope {
/** /**
* Forward all requests to remote and ignore localResolver. * Forward all requests to remote and ignore localResolver.
*/ */
...@@ -58,8 +57,6 @@ class LocalDnsServer(private val localResolver: suspend (String) -> Array<InetAd ...@@ -58,8 +57,6 @@ class LocalDnsServer(private val localResolver: suspend (String) -> Array<InetAd
var remoteDomainMatcher: Regex? = null var remoteDomainMatcher: Regex? = null
var localIpMatcher: List<Subnet> = emptyList() var localIpMatcher: List<Subnet> = emptyList()
private val hostsMap: Map<String, List<InetAddress>> = readHosts()
companion object { companion object {
private const val TAG = "LocalDnsServer" private const val TAG = "LocalDnsServer"
private const val TIMEOUT = 10_000L private const val TIMEOUT = 10_000L
...@@ -70,15 +67,13 @@ class LocalDnsServer(private val localResolver: suspend (String) -> Array<InetAd ...@@ -70,15 +67,13 @@ class LocalDnsServer(private val localResolver: suspend (String) -> Array<InetAd
private const val TTL = 120L private const val TTL = 120L
private const val UDP_PACKET_SIZE = 512 private const val UDP_PACKET_SIZE = 512
private val hostsDelimiter = "[ \t]".toRegex()
private fun prepareDnsResponse(request: Message) = Message(request.header.id).apply { private fun prepareDnsResponse(request: Message) = Message(request.header.id).apply {
header.setFlag(Flags.QR.toInt()) // this is a response header.setFlag(Flags.QR.toInt()) // this is a response
if (request.header.getFlag(Flags.RD.toInt())) header.setFlag(Flags.RD.toInt()) if (request.header.getFlag(Flags.RD.toInt())) header.setFlag(Flags.RD.toInt())
request.question?.also { addRecord(it, Section.QUESTION) } request.question?.also { addRecord(it, Section.QUESTION) }
} }
private fun prepareDnsResponseWithResults(request: Message, results: Array<InetAddress>): ByteBuffer = private fun cookDnsResponse(request: Message, results: Iterable<InetAddress>) =
ByteBuffer.wrap(prepareDnsResponse(request).apply { ByteBuffer.wrap(prepareDnsResponse(request).apply {
header.setFlag(Flags.RA.toInt()) // recursion available header.setFlag(Flags.RA.toInt()) // recursion available
for (address in results) addRecord(when (address) { for (address in results) addRecord(when (address) {
...@@ -124,10 +119,10 @@ class LocalDnsServer(private val localResolver: suspend (String) -> Array<InetAd ...@@ -124,10 +119,10 @@ class LocalDnsServer(private val localResolver: suspend (String) -> Array<InetAd
val question = request.question val question = request.question
if (question?.type != Type.A) return@supervisorScope remote.await() if (question?.type != Type.A) return@supervisorScope remote.await()
val host = question.name.toString(true) val host = question.name.toString(true)
val hostsResults = hostsResolver(host) val hostsResults = hosts.resolve(host)
if (hostsResults.isNotEmpty()) { if (hostsResults.isNotEmpty()) {
remote.cancel() remote.cancel()
return@supervisorScope prepareDnsResponseWithResults(request, hostsResults) return@supervisorScope cookDnsResponse(request, hostsResults)
} }
if (forwardOnly) return@supervisorScope remote.await() if (forwardOnly) return@supervisorScope remote.await()
if (remoteDomainMatcher?.containsMatchIn(host) == true) return@supervisorScope remote.await() if (remoteDomainMatcher?.containsMatchIn(host) == true) return@supervisorScope remote.await()
...@@ -142,7 +137,7 @@ class LocalDnsServer(private val localResolver: suspend (String) -> Array<InetAd ...@@ -142,7 +137,7 @@ class LocalDnsServer(private val localResolver: suspend (String) -> Array<InetAd
if (localResults.isEmpty()) return@supervisorScope remote.await() if (localResults.isEmpty()) return@supervisorScope remote.await()
if (localIpMatcher.isEmpty() || localIpMatcher.any { subnet -> localResults.any(subnet::matches) }) { if (localIpMatcher.isEmpty() || localIpMatcher.any { subnet -> localResults.any(subnet::matches) }) {
remote.cancel() remote.cancel()
prepareDnsResponseWithResults(request, localResults) cookDnsResponse(request, localResults.asIterable())
} else remote.await() } else remote.await()
} catch (e: Exception) { } catch (e: Exception) {
remote.cancel() remote.cancel()
...@@ -159,41 +154,6 @@ class LocalDnsServer(private val localResolver: suspend (String) -> Array<InetAd ...@@ -159,41 +154,6 @@ class LocalDnsServer(private val localResolver: suspend (String) -> Array<InetAd
} }
} }
private fun hostsResolver(h: String): Array<InetAddress> =
this.hostsMap[h]?.toTypedArray() ?: emptyArray()
private fun readHosts(): Map<String, List<InetAddress>> {
val hostsMap: MutableMap<String, MutableSet<InetAddress>> = HashMap()
try {
val hostsFile = File(Core.deviceStorage.getExternalFilesDir(null), "hosts")
hostsFile.createNewFile()
hostsFile.forEachLine {
val line = it.substringBefore('#')
if (line.isEmpty()) {
return@forEachLine
}
val splitted = line.trim().split(hostsDelimiter)
if (splitted.size < 2) {
return@forEachLine
}
val addr = splitted[0].parseNumericAddress() ?: return@forEachLine
for (d in splitted.asSequence().drop(1)) {
var el = hostsMap[d]
if (el == null) {
el = HashSet(1)
hostsMap[d] = el
}
el.add(addr)
}
}
return hostsMap.mapValues { it.value.toList() }
} catch (e: IOException) {
printLog(e)
}
return emptyMap()
}
private suspend fun forward(packet: ByteBuffer): ByteBuffer { private suspend fun forward(packet: ByteBuffer): ByteBuffer {
packet.position(0) // the packet might have been parsed, reset to beginning packet.position(0) // the packet might have been parsed, reset to beginning
return if (tcp) SocketChannel.open().use { channel -> return if (tcp) SocketChannel.open().use { channel ->
......
/*******************************************************************************
* *
* Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package com.github.shadowsocks.preference
import androidx.preference.EditTextPreference
import androidx.preference.Preference
import com.github.shadowsocks.core.R
import com.github.shadowsocks.net.HostsFile
object HostsSummaryProvider : Preference.SummaryProvider<EditTextPreference> {
override fun provideSummary(preference: EditTextPreference?): CharSequence {
val count = HostsFile(preference!!.text ?: "").configuredHostnames
return preference.context.resources.getQuantityString(R.plurals.hosts_summary, count, count)
}
}
...@@ -65,6 +65,7 @@ object Key { ...@@ -65,6 +65,7 @@ object Key {
const val dirty = "profileDirty" const val dirty = "profileDirty"
const val tfo = "tcp_fastopen" const val tfo = "tcp_fastopen"
const val hosts = "hosts"
const val assetUpdateTime = "assetUpdateTime" const val assetUpdateTime = "assetUpdateTime"
// TV specific values // TV specific values
......
...@@ -54,9 +54,12 @@ private val parseNumericAddress by lazy { ...@@ -54,9 +54,12 @@ private val parseNumericAddress by lazy {
* *
* Bug: https://issuetracker.google.com/issues/123456213 * Bug: https://issuetracker.google.com/issues/123456213
*/ */
fun String?.parseNumericAddress(): InetAddress? = Os.inet_pton(OsConstants.AF_INET, this) fun String.parseNumericAddress(): InetAddress? = Os.inet_pton(OsConstants.AF_INET, this)
?: Os.inet_pton(OsConstants.AF_INET6, this)?.let { parseNumericAddress.invoke(null, this) as InetAddress } ?: Os.inet_pton(OsConstants.AF_INET6, this)?.let { parseNumericAddress.invoke(null, this) as InetAddress }
fun <K, V> MutableMap<K, V>.computeIfAbsentCompat(key: K, value: () -> V) = if (Build.VERSION.SDK_INT >= 24)
computeIfAbsent(key) { value() } else this[key] ?: value().also { put(key, it) }
fun HttpURLConnection.disconnectFromMain() { fun HttpURLConnection.disconnectFromMain() {
if (Build.VERSION.SDK_INT >= 26) disconnect() else GlobalScope.launch(Dispatchers.IO) { disconnect() } if (Build.VERSION.SDK_INT >= 26) disconnect() else GlobalScope.launch(Dispatchers.IO) { disconnect() }
} }
......
...@@ -61,6 +61,10 @@ ...@@ -61,6 +61,10 @@
<string name="tcp_fastopen_summary">Toggling might require ROOT permission</string> <string name="tcp_fastopen_summary">Toggling might require ROOT permission</string>
<string name="tcp_fastopen_summary_unsupported">Unsupported kernel version: %s &lt; 3.7.1</string> <string name="tcp_fastopen_summary_unsupported">Unsupported kernel version: %s &lt; 3.7.1</string>
<string name="tcp_fastopen_failure">Toggle failed</string> <string name="tcp_fastopen_failure">Toggle failed</string>
<plurals name="hosts_summary">
<item quantity="one">1 hostname configured</item>
<item quantity="other">%d hostnames configured</item>
</plurals>
<string name="udp_dns">Send DNS over UDP</string> <string name="udp_dns">Send DNS over UDP</string>
<string name="udp_dns_summary">Requires UDP forwarding on server side</string> <string name="udp_dns_summary">Requires UDP forwarding on server side</string>
<string name="udp_fallback">UDP Fallback</string> <string name="udp_fallback">UDP Fallback</string>
...@@ -82,7 +86,6 @@ ...@@ -82,7 +86,6 @@
<!-- alert category --> <!-- alert category -->
<string name="profile_empty">Please select a profile</string> <string name="profile_empty">Please select a profile</string>
<string name="proxy_empty">Proxy/Password should not be empty</string> <string name="proxy_empty">Proxy/Password should not be empty</string>
<string name="file_manager_missing">Please install a file manager like MiXplorer</string>
<string name="connect">Connect</string> <string name="connect">Connect</string>
<!-- menu category --> <!-- menu category -->
......
...@@ -20,6 +20,8 @@ ...@@ -20,6 +20,8 @@
package com.github.shadowsocks package com.github.shadowsocks
import android.app.Activity
import android.content.Intent
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.preference.EditTextPreference import androidx.preference.EditTextPreference
...@@ -31,10 +33,18 @@ import com.github.shadowsocks.preference.DataStore ...@@ -31,10 +33,18 @@ import com.github.shadowsocks.preference.DataStore
import com.github.shadowsocks.utils.DirectBoot import com.github.shadowsocks.utils.DirectBoot
import com.github.shadowsocks.utils.Key import com.github.shadowsocks.utils.Key
import com.github.shadowsocks.net.TcpFastOpen import com.github.shadowsocks.net.TcpFastOpen
import com.github.shadowsocks.preference.BrowsableEditTextPreferenceDialogFragment
import com.github.shadowsocks.preference.HostsSummaryProvider
import com.github.shadowsocks.preference.PortPreferenceListener import com.github.shadowsocks.preference.PortPreferenceListener
import com.github.shadowsocks.utils.remove import com.github.shadowsocks.utils.remove
class GlobalSettingsPreferenceFragment : PreferenceFragmentCompat() { class GlobalSettingsPreferenceFragment : PreferenceFragmentCompat() {
companion object {
private const val REQUEST_BROWSE = 1
}
private val hosts by lazy { findPreference<EditTextPreference>(Key.hosts)!! }
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
preferenceManager.preferenceDataStore = DataStore.publicStore preferenceManager.preferenceDataStore = DataStore.publicStore
DataStore.initGlobal() DataStore.initGlobal()
...@@ -70,6 +80,7 @@ class GlobalSettingsPreferenceFragment : PreferenceFragmentCompat() { ...@@ -70,6 +80,7 @@ class GlobalSettingsPreferenceFragment : PreferenceFragmentCompat() {
tfo.summary = getString(R.string.tcp_fastopen_summary_unsupported, System.getProperty("os.version")) tfo.summary = getString(R.string.tcp_fastopen_summary_unsupported, System.getProperty("os.version"))
} }
hosts.summaryProvider = HostsSummaryProvider
val serviceMode = findPreference<Preference>(Key.serviceMode)!! val serviceMode = findPreference<Preference>(Key.serviceMode)!!
val portProxy = findPreference<EditTextPreference>(Key.portProxy)!! val portProxy = findPreference<EditTextPreference>(Key.portProxy)!!
portProxy.onBindEditTextListener = PortPreferenceListener portProxy.onBindEditTextListener = PortPreferenceListener
...@@ -84,20 +95,18 @@ class GlobalSettingsPreferenceFragment : PreferenceFragmentCompat() { ...@@ -84,20 +95,18 @@ class GlobalSettingsPreferenceFragment : PreferenceFragmentCompat() {
Key.modeTransproxy -> Pair(true, true) Key.modeTransproxy -> Pair(true, true)
else -> throw IllegalArgumentException("newValue: $newValue") else -> throw IllegalArgumentException("newValue: $newValue")
} }
hosts.isEnabled = enabledLocalDns
portLocalDns.isEnabled = enabledLocalDns portLocalDns.isEnabled = enabledLocalDns
portTransproxy.isEnabled = enabledTransproxy portTransproxy.isEnabled = enabledTransproxy
true true
} }
val listener: (BaseService.State) -> Unit = { val listener: (BaseService.State) -> Unit = {
if (it == BaseService.State.Stopped) { val stopped = it == BaseService.State.Stopped
tfo.isEnabled = true tfo.isEnabled = stopped
serviceMode.isEnabled = true serviceMode.isEnabled = stopped
portProxy.isEnabled = true portProxy.isEnabled = stopped
onServiceModeChange.onPreferenceChange(null, DataStore.serviceMode) if (stopped) onServiceModeChange.onPreferenceChange(null, DataStore.serviceMode) else {
} else { hosts.isEnabled = false
tfo.isEnabled = false
serviceMode.isEnabled = false
portProxy.isEnabled = false
portLocalDns.isEnabled = false portLocalDns.isEnabled = false
portTransproxy.isEnabled = false portTransproxy.isEnabled = false
} }
...@@ -107,6 +116,29 @@ class GlobalSettingsPreferenceFragment : PreferenceFragmentCompat() { ...@@ -107,6 +116,29 @@ class GlobalSettingsPreferenceFragment : PreferenceFragmentCompat() {
serviceMode.onPreferenceChangeListener = onServiceModeChange serviceMode.onPreferenceChangeListener = onServiceModeChange
} }
override fun onDisplayPreferenceDialog(preference: Preference?) {
if (preference == hosts) BrowsableEditTextPreferenceDialogFragment().apply {
setKey(hosts.key)
setTargetFragment(this@GlobalSettingsPreferenceFragment, REQUEST_BROWSE)
}.show(fragmentManager ?: return, hosts.key) else super.onDisplayPreferenceDialog(preference)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
REQUEST_BROWSE -> {
if (resultCode != Activity.RESULT_OK) return
val activity = activity as MainActivity
try {
// we read and persist all its content here to avoid content URL permission issues
hosts.text = activity.contentResolver.openInputStream(data!!.data!!)!!.bufferedReader().readText()
} catch (e: RuntimeException) {
activity.snackbar(e.localizedMessage).show()
}
}
else -> super.onActivityResult(requestCode, resultCode, data)
}
}
override fun onDestroy() { override fun onDestroy() {
MainActivity.stateListener = null MainActivity.stateListener = null
super.onDestroy() super.onDestroy()
......
/*******************************************************************************
* *
* Copyright (C) 2019 by Max Lv <max.c.lv@gmail.com> *
* Copyright (C) 2019 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* This program is distributed in the hope that it will be useful, *
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
* GNU General Public License for more details. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
* *
*******************************************************************************/
package com.github.shadowsocks.preference
import android.content.ActivityNotFoundException
import android.content.Intent
import android.view.View
import androidx.appcompat.app.AlertDialog
import androidx.core.os.bundleOf
import androidx.preference.EditTextPreferenceDialogFragmentCompat
import com.github.shadowsocks.R
import com.google.android.material.snackbar.Snackbar
class BrowsableEditTextPreferenceDialogFragment : EditTextPreferenceDialogFragmentCompat() {
fun setKey(key: String) {
arguments = bundleOf(Pair(ARG_KEY, key))
}
override fun onPrepareDialogBuilder(builder: AlertDialog.Builder) {
super.onPrepareDialogBuilder(builder)
builder.setNeutralButton(R.string.browse) { _, _ ->
val activity = requireActivity()
try {
targetFragment!!.startActivityForResult(Intent(Intent.ACTION_GET_CONTENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
}, targetRequestCode)
return@setNeutralButton
} catch (_: ActivityNotFoundException) { } catch (_: SecurityException) { }
Snackbar.make(activity.findViewById<View>(R.id.content),
R.string.file_manager_missing, Snackbar.LENGTH_SHORT).show()
}
}
}
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto" <PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto"
app:initialExpandedChildrenCount="3"> app:initialExpandedChildrenCount="4">
<SwitchPreference <SwitchPreference
app:key="isAutoConnect" app:key="isAutoConnect"
app:persistent="false" app:persistent="false"
...@@ -16,6 +16,9 @@ ...@@ -16,6 +16,9 @@
app:icon="@drawable/ic_action_offline_bolt" app:icon="@drawable/ic_action_offline_bolt"
app:summary="@string/tcp_fastopen_summary" app:summary="@string/tcp_fastopen_summary"
app:title="TCP Fast Open"/> app:title="TCP Fast Open"/>
<EditTextPreference
app:key="hosts"
app:title="hosts"/>
<com.takisoft.preferencex.SimpleMenuPreference <com.takisoft.preferencex.SimpleMenuPreference
app:key="serviceMode" app:key="serviceMode"
......
...@@ -6,4 +6,6 @@ ...@@ -6,4 +6,6 @@
<string name="yes">Yes</string> <string name="yes">Yes</string>
<string name="no">No</string> <string name="no">No</string>
<string name="apply">Apply</string> <string name="apply">Apply</string>
<string name="browse">Browse…</string>
<string name="file_manager_missing">Please install a file manager like MiXplorer</string>
</resources> </resources>
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment