tuic v5 support

This commit is contained in:
arm64v8a 2023-06-08 20:30:59 +09:00
parent 34a3012772
commit 4b1800d782
11 changed files with 223 additions and 65 deletions

View File

@ -78,14 +78,11 @@ object Key {
const val SERVER_USERNAME = "serverUsername" const val SERVER_USERNAME = "serverUsername"
const val SERVER_PASSWORD = "serverPassword" const val SERVER_PASSWORD = "serverPassword"
const val SERVER_METHOD = "serverMethod" const val SERVER_METHOD = "serverMethod"
const val SERVER_PLUGIN = "serverPlugin"
const val SERVER_PLUGIN_CONFIGURE = "serverPluginConfigure"
const val SERVER_PASSWORD1 = "serverPassword1" const val SERVER_PASSWORD1 = "serverPassword1"
const val SERVER_PROTOCOL = "serverProtocol" const val SERVER_PROTOCOL = "serverProtocol"
const val SERVER_OBFS = "serverObfs" const val SERVER_OBFS = "serverObfs"
const val SERVER_SECURITY = "serverSecurity"
const val SERVER_NETWORK = "serverNetwork" const val SERVER_NETWORK = "serverNetwork"
const val SERVER_HOST = "serverHost" const val SERVER_HOST = "serverHost"
const val SERVER_PATH = "serverPath" const val SERVER_PATH = "serverPath"

View File

@ -17,6 +17,7 @@ import io.nekohasekai.sagernet.fmt.trojan_go.TrojanGoBean
import io.nekohasekai.sagernet.fmt.trojan_go.buildTrojanGoConfig import io.nekohasekai.sagernet.fmt.trojan_go.buildTrojanGoConfig
import io.nekohasekai.sagernet.fmt.tuic.TuicBean import io.nekohasekai.sagernet.fmt.tuic.TuicBean
import io.nekohasekai.sagernet.fmt.tuic.buildTuicConfig import io.nekohasekai.sagernet.fmt.tuic.buildTuicConfig
import io.nekohasekai.sagernet.fmt.tuic.pluginId
import io.nekohasekai.sagernet.ktx.* import io.nekohasekai.sagernet.ktx.*
import io.nekohasekai.sagernet.plugin.PluginManager import io.nekohasekai.sagernet.plugin.PluginManager
import kotlinx.coroutines.* import kotlinx.coroutines.*
@ -86,7 +87,7 @@ abstract class BoxInstance(
} }
is TuicBean -> { is TuicBean -> {
initPlugin("tuic-plugin") initPlugin(bean.pluginId())
pluginConfigs[port] = profile.type to bean.buildTuicConfig(port) { pluginConfigs[port] = profile.type to bean.buildTuicConfig(port) {
File( File(
app.noBackupFilesDir, app.noBackupFilesDir,
@ -252,7 +253,7 @@ abstract class BoxInstance(
cacheFiles.add(configFile) cacheFiles.add(configFile)
val commands = mutableListOf( val commands = mutableListOf(
initPlugin("tuic-plugin").path, initPlugin(bean.pluginId()).path,
"-c", "-c",
configFile.absolutePath, configFile.absolutePath,
) )

View File

@ -19,6 +19,7 @@ import io.nekohasekai.sagernet.fmt.socks.buildSingBoxOutboundSocksBean
import io.nekohasekai.sagernet.fmt.ssh.SSHBean import io.nekohasekai.sagernet.fmt.ssh.SSHBean
import io.nekohasekai.sagernet.fmt.ssh.buildSingBoxOutboundSSHBean import io.nekohasekai.sagernet.fmt.ssh.buildSingBoxOutboundSSHBean
import io.nekohasekai.sagernet.fmt.tuic.TuicBean import io.nekohasekai.sagernet.fmt.tuic.TuicBean
import io.nekohasekai.sagernet.fmt.tuic.pluginId
import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean
import io.nekohasekai.sagernet.fmt.v2ray.buildSingBoxOutboundStandardV2RayBean import io.nekohasekai.sagernet.fmt.v2ray.buildSingBoxOutboundStandardV2RayBean
import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean
@ -35,6 +36,7 @@ import moe.matsuri.nb4a.proxy.config.ConfigBean
import moe.matsuri.nb4a.proxy.shadowtls.ShadowTLSBean import moe.matsuri.nb4a.proxy.shadowtls.ShadowTLSBean
import moe.matsuri.nb4a.proxy.shadowtls.buildSingBoxOutboundShadowTLSBean import moe.matsuri.nb4a.proxy.shadowtls.buildSingBoxOutboundShadowTLSBean
import moe.matsuri.nb4a.utils.JavaUtil.gson import moe.matsuri.nb4a.utils.JavaUtil.gson
import moe.matsuri.nb4a.utils.Util
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
const val TAG_MIXED = "mixed-in" const val TAG_MIXED = "mixed-in"
@ -63,20 +65,6 @@ class ConfigBuildResult(
data class IndexEntity(var chain: LinkedHashMap<Int, ProxyEntity>) data class IndexEntity(var chain: LinkedHashMap<Int, ProxyEntity>)
} }
fun mergeJSON(j: String, to: MutableMap<String, Any>) {
if (j.isBlank()) return
val m = gson.fromJson(j, to.javaClass)
m.forEach { (k, v) ->
if (v is Map<*, *> && to[k] is Map<*, *>) {
val currentMap = (to[k] as Map<*, *>).toMutableMap()
currentMap += v
to[k] = currentMap
} else {
to[k] = v
}
}
}
fun buildConfig( fun buildConfig(
proxy: ProxyEntity, forTest: Boolean = false, forExport: Boolean = false proxy: ProxyEntity, forTest: Boolean = false, forExport: Boolean = false
): ConfigBuildResult { ): ConfigBuildResult {
@ -439,7 +427,7 @@ fun buildConfig(
// custom JSON merge // custom JSON merge
if (bean.customOutboundJson.isNotBlank()) { if (bean.customOutboundJson.isNotBlank()) {
mergeJSON(bean.customOutboundJson, currentOutbound) Util.mergeJSON(bean.customOutboundJson, currentOutbound)
} }
} }
@ -458,18 +446,19 @@ fun buildConfig(
// External proxy need a dokodemo-door inbound to forward the traffic // External proxy need a dokodemo-door inbound to forward the traffic
// For external proxy software, their traffic must goes to v2ray-core to use protected fd. // For external proxy software, their traffic must goes to v2ray-core to use protected fd.
bean.finalPort = bean.serverPort
if (bean.canMapping() && proxyEntity.needExternal()) { if (bean.canMapping() && proxyEntity.needExternal()) {
// With ss protect, don't use mapping // With ss protect, don't use mapping
var needExternal = true var needExternal = true
if (index == profileList.lastIndex) { if (index == profileList.lastIndex) {
val pluginId = when (bean) { val pluginId = when (bean) {
is HysteriaBean -> "hysteria-plugin" is HysteriaBean -> "hysteria-plugin"
is TuicBean -> "tuic-plugin" is TuicBean -> bean.pluginId()
else -> "" else -> ""
} }
if (Plugins.isUsingMatsuriExe(pluginId)) { if (Plugins.isUsingMatsuriExe(pluginId)) {
needExternal = false needExternal = false
} else if (pluginId.isNotBlank()) { } else if (Plugins.getPlugin(pluginId) != null) {
throw Exception("You are using an unsupported $pluginId, please download the correct plugin.") throw Exception("You are using an unsupported $pluginId, please download the correct plugin.")
} }
} }
@ -593,9 +582,12 @@ fun buildConfig(
if (uidList.isNotEmpty()) user_id = uidList if (uidList.isNotEmpty()) user_id = uidList
domainList?.let { makeSingBoxRule(it) } domainList?.let { makeSingBoxRule(it) }
} }
if (rule.outbound == -1L) { when (rule.outbound) {
-1L -> {
userDNSRuleList += dnsRuleObj.apply { server = "dns-direct" } userDNSRuleList += dnsRuleObj.apply { server = "dns-direct" }
} else if (rule.outbound == 0L) { }
0L -> {
if (useFakeDns) userDNSRuleList += dnsRuleObj.apply { if (useFakeDns) userDNSRuleList += dnsRuleObj.apply {
server = "dns-fake" server = "dns-fake"
inbound = listOf("tun-in") inbound = listOf("tun-in")
@ -604,9 +596,12 @@ fun buildConfig(
server = "dns-remote" server = "dns-remote"
inbound = null inbound = null
} }
} else if (rule.outbound == -2L) { }
-2L -> {
userDNSRuleList += dnsRuleObj.apply { server = "dns-block" } userDNSRuleList += dnsRuleObj.apply { server = "dns-block" }
} }
}
outbound = when (val outId = rule.outbound) { outbound = when (val outId = rule.outbound) {
0L -> TAG_PROXY 0L -> TAG_PROXY
@ -786,7 +781,7 @@ fun buildConfig(
}.let { }.let {
ConfigBuildResult( ConfigBuildResult(
gson.toJson(it.asMap().apply { gson.toJson(it.asMap().apply {
mergeJSON(optionsToMerge, this) Util.mergeJSON(optionsToMerge, this)
}), }),
externalIndexMap, externalIndexMap,
proxy.id, proxy.id,

View File

@ -22,7 +22,8 @@ enum class PluginEntry(
Hysteria( Hysteria(
"hysteria-plugin", "hysteria-plugin",
SagerNet.application.getString(R.string.action_hysteria), SagerNet.application.getString(R.string.action_hysteria),
"moe.matsuri.exe.hysteria", DownloadSource( "moe.matsuri.exe.hysteria",
DownloadSource(
playStore = false, playStore = false,
fdroid = false, fdroid = false,
downloadLink = "https://github.com/MatsuriDayo/plugins/releases?q=Hysteria" downloadLink = "https://github.com/MatsuriDayo/plugins/releases?q=Hysteria"
@ -30,8 +31,19 @@ enum class PluginEntry(
), ),
TUIC( TUIC(
"tuic-plugin", "tuic-plugin",
SagerNet.application.getString(R.string.action_tuic), "TUIC(v4)",
"moe.matsuri.exe.tuic", DownloadSource( "moe.matsuri.exe.tuic",
DownloadSource(
playStore = false,
fdroid = false,
downloadLink = "https://github.com/MatsuriDayo/plugins/releases?q=tuic"
)
),
TUIC5(
"tuic-v5-plugin",
"TUIC(v5)",
"moe.matsuri.exe.tuic5",
DownloadSource(
playStore = false, playStore = false,
fdroid = false, fdroid = false,
downloadLink = "https://github.com/MatsuriDayo/plugins/releases?q=tuic" downloadLink = "https://github.com/MatsuriDayo/plugins/releases?q=tuic"

View File

@ -24,6 +24,12 @@ public class TuicBean extends AbstractBean {
public Boolean fastConnect; public Boolean fastConnect;
public Boolean allowInsecure; public Boolean allowInsecure;
// TUIC v5
public String customJSON;
public Integer protocolVersion;
public String uuid;
@Override @Override
public void initializeDefaultValues() { public void initializeDefaultValues() {
super.initializeDefaultValues(); super.initializeDefaultValues();
@ -38,11 +44,14 @@ public class TuicBean extends AbstractBean {
if (sni == null) sni = ""; if (sni == null) sni = "";
if (fastConnect == null) fastConnect = false; if (fastConnect == null) fastConnect = false;
if (allowInsecure == null) allowInsecure = false; if (allowInsecure == null) allowInsecure = false;
if (customJSON == null) customJSON = "";
if (protocolVersion == null) protocolVersion = 4;
if (uuid == null) uuid = "";
} }
@Override @Override
public void serialize(ByteBufferOutput output) { public void serialize(ByteBufferOutput output) {
output.writeInt(1); output.writeInt(2);
super.serialize(output); super.serialize(output);
output.writeString(token); output.writeString(token);
output.writeString(caText); output.writeString(caText);
@ -55,6 +64,9 @@ public class TuicBean extends AbstractBean {
output.writeString(sni); output.writeString(sni);
output.writeBoolean(fastConnect); output.writeBoolean(fastConnect);
output.writeBoolean(allowInsecure); output.writeBoolean(allowInsecure);
output.writeString(customJSON);
output.writeInt(protocolVersion);
output.writeString(uuid);
} }
@Override @Override
@ -74,6 +86,11 @@ public class TuicBean extends AbstractBean {
fastConnect = input.readBoolean(); fastConnect = input.readBoolean();
allowInsecure = input.readBoolean(); allowInsecure = input.readBoolean();
} }
if (version >= 2) {
customJSON = input.readString();
protocolVersion = input.readInt();
uuid = input.readString();
}
} }
@Override @Override

View File

@ -1,39 +1,82 @@
package io.nekohasekai.sagernet.fmt.tuic package io.nekohasekai.sagernet.fmt.tuic
import io.nekohasekai.sagernet.database.DataStore
import io.nekohasekai.sagernet.fmt.LOCALHOST import io.nekohasekai.sagernet.fmt.LOCALHOST
import io.nekohasekai.sagernet.ktx.isIpAddress import io.nekohasekai.sagernet.ktx.isIpAddress
import io.nekohasekai.sagernet.ktx.toStringPretty import io.nekohasekai.sagernet.ktx.wrapIPV6Host
import kotlinx.coroutines.Dispatchers import moe.matsuri.nb4a.utils.JavaUtil
import kotlinx.coroutines.runBlocking import moe.matsuri.nb4a.utils.Util
import kotlinx.coroutines.withContext
import moe.matsuri.nb4a.plugin.Plugins
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.io.File import java.io.File
import java.net.InetAddress
fun TuicBean.pluginId(): String {
return when (protocolVersion) {
5 -> "tuic-v5-plugin"
else -> "tuic-plugin"
}
}
fun TuicBean.buildTuicConfig(port: Int, cacheFile: (() -> File)?): String { fun TuicBean.buildTuicConfig(port: Int, cacheFile: (() -> File)?): String {
if (Plugins.isUsingMatsuriExe("tuic-plugin")) { val config = when (protocolVersion) {
if (!serverAddress.isIpAddress()) { 5 -> buildTuicConfigV5(port, cacheFile)
runBlocking { else -> buildTuicConfigV4(port, cacheFile)
finalAddress = withContext(Dispatchers.IO) { }.toString()
InetAddress.getAllByName(serverAddress) var gsonMap = mutableMapOf<String, Any>()
}?.firstOrNull()?.hostAddress ?: "127.0.0.1" gsonMap = JavaUtil.gson.fromJson(config, gsonMap.javaClass)
// TODO network on main thread, tuic don't support "sni" Util.mergeJSON(customJSON, gsonMap)
} return JavaUtil.gson.toJson(gsonMap)
} }
}
fun TuicBean.buildTuicConfigV5(port: Int, cacheFile: (() -> File)?): JSONObject {
return JSONObject().apply { return JSONObject().apply {
put("relay", JSONObject().apply { put("relay", JSONObject().apply {
if (sni.isNotBlank()) { if (sni.isNotBlank() && !disableSNI) {
put("server", sni) put("server", "$sni:$finalPort")
if (finalAddress.isIpAddress()) {
put("ip", finalAddress) put("ip", finalAddress)
} else if (serverAddress.isIpAddress()) {
put("server", finalAddress)
} else { } else {
put("server", serverAddress) throw Exception("TUIC must use IP address when you need spoof SNI.")
}
} else {
put("server", serverAddress.wrapIPV6Host() + ":" + finalPort)
}
put("uuid", uuid)
put("password", token)
if (caText.isNotBlank() && cacheFile != null) {
val caFile = cacheFile()
caFile.writeText(caText)
put("certificates", JSONArray(listOf(caFile.absolutePath)))
}
put("udp_relay_mode", udpRelayMode)
if (alpn.isNotBlank()) {
put("alpn", JSONArray(alpn.split("\n")))
}
put("congestion_control", congestionController)
put("disable_sni", disableSNI)
put("zero_rtt_handshake", disableSNI)
})
put("local", JSONObject().apply {
put("server", "127.0.0.1:$port")
})
put("log_level", "debug")
}
}
fun TuicBean.buildTuicConfigV4(port: Int, cacheFile: (() -> File)?): JSONObject {
return JSONObject().apply {
put("relay", JSONObject().apply {
if (sni.isNotBlank() && !disableSNI) {
put("server", sni)
if (finalAddress.isIpAddress()) {
put("ip", finalAddress) put("ip", finalAddress)
} else {
throw Exception("TUIC must use IP address when you need spoof SNI.")
}
} else {
put("server", finalAddress)
} }
put("port", finalPort) put("port", finalPort)
put("token", token) put("token", token)
@ -59,6 +102,6 @@ fun TuicBean.buildTuicConfig(port: Int, cacheFile: (() -> File)?): String {
put("ip", LOCALHOST) put("ip", LOCALHOST)
put("port", port) put("port", port)
}) })
put("log_level", if (DataStore.logLevel > 0) "debug" else "info") put("log_level", "debug")
}.toStringPretty() }
} }

View File

@ -9,6 +9,8 @@ import io.nekohasekai.sagernet.R
import io.nekohasekai.sagernet.database.DataStore import io.nekohasekai.sagernet.database.DataStore
import io.nekohasekai.sagernet.fmt.tuic.TuicBean import io.nekohasekai.sagernet.fmt.tuic.TuicBean
import io.nekohasekai.sagernet.ktx.applyDefaultValues import io.nekohasekai.sagernet.ktx.applyDefaultValues
import moe.matsuri.nb4a.ui.EditConfigPreference
import moe.matsuri.nb4a.ui.SimpleMenuPreference
class TuicSettingsActivity : ProfileSettingsActivity<TuicBean>() { class TuicSettingsActivity : ProfileSettingsActivity<TuicBean>() {
@ -27,8 +29,13 @@ class TuicSettingsActivity : ProfileSettingsActivity<TuicBean>() {
DataStore.serverSNI = sni DataStore.serverSNI = sni
DataStore.serverReduceRTT = reduceRTT DataStore.serverReduceRTT = reduceRTT
DataStore.serverMTU = mtu DataStore.serverMTU = mtu
//
DataStore.serverFastConnect = fastConnect DataStore.serverFastConnect = fastConnect
DataStore.serverAllowInsecure = allowInsecure DataStore.serverAllowInsecure = allowInsecure
//
DataStore.serverConfig = customJSON
DataStore.serverProtocolVersion = protocolVersion
DataStore.serverUsername = uuid
} }
override fun TuicBean.serialize() { override fun TuicBean.serialize() {
@ -44,16 +51,48 @@ class TuicSettingsActivity : ProfileSettingsActivity<TuicBean>() {
sni = DataStore.serverSNI sni = DataStore.serverSNI
reduceRTT = DataStore.serverReduceRTT reduceRTT = DataStore.serverReduceRTT
mtu = DataStore.serverMTU mtu = DataStore.serverMTU
//
fastConnect = DataStore.serverFastConnect fastConnect = DataStore.serverFastConnect
allowInsecure = DataStore.serverAllowInsecure allowInsecure = DataStore.serverAllowInsecure
//
customJSON = DataStore.serverConfig
protocolVersion = DataStore.serverProtocolVersion
uuid = DataStore.serverUsername
} }
private lateinit var editConfigPreference: EditConfigPreference
override fun PreferenceFragmentCompat.createPreferences( override fun PreferenceFragmentCompat.createPreferences(
savedInstanceState: Bundle?, savedInstanceState: Bundle?,
rootKey: String?, rootKey: String?,
) { ) {
addPreferencesFromResource(R.xml.tuic_preferences) addPreferencesFromResource(R.xml.tuic_preferences)
editConfigPreference = findPreference(Key.SERVER_CONFIG)!!
val uuid = findPreference<EditTextPreference>(Key.SERVER_USERNAME)!!
val mtu = findPreference<EditTextPreference>(Key.SERVER_MTU)!!
val fastConnect = findPreference<SwitchPreference>(Key.SERVER_FAST_CONNECT)!!
val allowInsecure = findPreference<SwitchPreference>(Key.SERVER_ALLOW_INSECURE)!!
fun updateVersion(v: Int) {
if (v == 5) {
uuid.isVisible = true
mtu.isVisible = false
fastConnect.isVisible = false
allowInsecure.isVisible = false
} else {
uuid.isVisible = false
mtu.isVisible = true
fastConnect.isVisible = true
allowInsecure.isVisible = true
}
}
findPreference<SimpleMenuPreference>(Key.SERVER_PROTOCOL)!!.setOnPreferenceChangeListener { _, newValue ->
updateVersion(newValue.toString().toIntOrNull() ?: 4)
true
}
updateVersion(DataStore.serverProtocolVersion)
val disableSNI = findPreference<SwitchPreference>(Key.SERVER_DISABLE_SNI)!! val disableSNI = findPreference<SwitchPreference>(Key.SERVER_DISABLE_SNI)!!
val sni = findPreference<EditTextPreference>(Key.SERVER_SNI)!! val sni = findPreference<EditTextPreference>(Key.SERVER_SNI)!!
sni.isEnabled = !disableSNI.isChecked sni.isEnabled = !disableSNI.isChecked
@ -67,4 +106,12 @@ class TuicSettingsActivity : ProfileSettingsActivity<TuicBean>() {
} }
} }
override fun onResume() {
super.onResume()
if (::editConfigPreference.isInitialized) {
editConfigPreference.notifyChanged()
}
}
} }

View File

@ -1,5 +1,6 @@
package moe.matsuri.nb4a.utils package moe.matsuri.nb4a.utils
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
@ -34,6 +35,7 @@ fun File.recreate(dir: Boolean) {
// Context utils // Context utils
@SuppressLint("DiscouragedApi")
fun Context.getDrawableByName(name: String?): Drawable? { fun Context.getDrawableByName(name: String?): Drawable? {
val resourceId: Int = resources.getIdentifier(name, "drawable", packageName) val resourceId: Int = resources.getIdentifier(name, "drawable", packageName)
return AppCompatResources.getDrawable(this, resourceId) return AppCompatResources.getDrawable(this, resourceId)

View File

@ -113,6 +113,21 @@ object Util {
} }
} }
fun mergeJSON(j: String, to: MutableMap<String, Any>) {
if (j.isBlank()) return
val m = JavaUtil.gson.fromJson(j, to.javaClass)
m.forEach { (k, v) ->
if (v is Map<*, *> && to[k] is Map<*, *>) {
val currentMap = (to[k] as Map<*, *>).toMutableMap()
currentMap += v
to[k] = currentMap
} else {
to[k] = v
}
}
}
// Format Time // Format Time
@SuppressLint("SimpleDateFormat") @SuppressLint("SimpleDateFormat")

View File

@ -386,6 +386,16 @@
<item>BASE64</item> <item>BASE64</item>
</string-array> </string-array>
<string-array name="tuic_version">
<item>v4</item>
<item>v5</item>
</string-array>
<string-array name="tuic_version_value">
<item>4</item>
<item>5</item>
</string-array>
<string-array name="socks_versions"> <string-array name="socks_versions">
<item>SOCKS4</item> <item>SOCKS4</item>
<item>SOCKS4A</item> <item>SOCKS4A</item>

View File

@ -6,6 +6,15 @@
app:title="@string/profile_name" app:title="@string/profile_name"
app:useSimpleSummaryProvider="true" /> app:useSimpleSummaryProvider="true" />
<moe.matsuri.nb4a.ui.SimpleMenuPreference
app:defaultValue="4"
app:entries="@array/tuic_version"
app:entryValues="@array/tuic_version_value"
app:icon="@drawable/ic_baseline_nfc_24"
app:key="serverProtocol"
app:title="@string/app_version"
app:useSimpleSummaryProvider="true" />
<PreferenceCategory app:title="@string/proxy_cat"> <PreferenceCategory app:title="@string/proxy_cat">
<EditTextPreference <EditTextPreference
@ -18,11 +27,16 @@
app:key="serverPort" app:key="serverPort"
app:title="@string/server_port" app:title="@string/server_port"
app:useSimpleSummaryProvider="true" /> app:useSimpleSummaryProvider="true" />
<EditTextPreference
app:icon="@drawable/ic_baseline_person_24"
app:key="serverUsername"
app:title="@string/uuid"
app:useSimpleSummaryProvider="true" />
<EditTextPreference <EditTextPreference
app:dialogLayout="@layout/layout_password_dialog" app:dialogLayout="@layout/layout_password_dialog"
app:icon="@drawable/ic_settings_password" app:icon="@drawable/ic_settings_password"
app:key="serverPassword" app:key="serverPassword"
app:title="@string/tuic_token" /> app:title="@string/password" />
<EditTextPreference <EditTextPreference
app:icon="@drawable/ic_baseline_legend_toggle_24" app:icon="@drawable/ic_baseline_legend_toggle_24"
app:key="serverALPN" app:key="serverALPN"
@ -77,6 +91,11 @@
app:key="serverMTU" app:key="serverMTU"
app:title="@string/mtu" app:title="@string/mtu"
app:useSimpleSummaryProvider="true" /> app:useSimpleSummaryProvider="true" />
<moe.matsuri.nb4a.ui.EditConfigPreference
app:icon="@drawable/ic_baseline_layers_24"
app:key="serverConfig"
app:title="@string/custom_config"
app:useSimpleSummaryProvider="true" />
</PreferenceCategory> </PreferenceCategory>
</PreferenceScreen> </PreferenceScreen>