From 7c46729c83c2180d055338d58ea02c9b3f2fb660 Mon Sep 17 00:00:00 2001 From: armv9 <48624112+arm64v8a@users.noreply.github.com> Date: Wed, 3 Sep 2025 17:14:08 +0900 Subject: [PATCH] dev custom rule config --- .../6.json | 373 ++++++++++++++++++ .../sagernet/database/RuleEntity.kt | 9 +- .../sagernet/database/SagerDatabase.kt | 2 +- .../nekohasekai/sagernet/fmt/ConfigBuilder.kt | 92 ++--- .../sagernet/fmt/hysteria/HysteriaFmt.kt | 8 +- .../sagernet/ui/RouteSettingsActivity.kt | 23 +- .../java/moe/matsuri/nb4a/SingBoxOptions.java | 63 ++- .../main/java/moe/matsuri/nb4a/utils/Util.kt | 2 +- app/src/main/res/xml/route_preferences.xml | 29 +- 9 files changed, 520 insertions(+), 81 deletions(-) create mode 100644 app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/6.json diff --git a/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/6.json b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/6.json new file mode 100644 index 0000000..11b6e58 --- /dev/null +++ b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/6.json @@ -0,0 +1,373 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "3d3db9106a89d6f20ef3fde6e81dbaa9", + "entities": [ + { + "tableName": "proxy_groups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `ungrouped` INTEGER NOT NULL, `name` TEXT, `type` INTEGER NOT NULL, `subscription` BLOB, `order` INTEGER NOT NULL, `isSelector` INTEGER NOT NULL, `frontProxy` INTEGER NOT NULL, `landingProxy` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ungrouped", + "columnName": "ungrouped", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscription", + "columnName": "subscription", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSelector", + "columnName": "isSelector", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "frontProxy", + "columnName": "frontProxy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "landingProxy", + "columnName": "landingProxy", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "proxy_entities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL, `type` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `status` INTEGER NOT NULL, `ping` INTEGER NOT NULL, `uuid` TEXT NOT NULL, `error` TEXT, `socksBean` BLOB, `httpBean` BLOB, `ssBean` BLOB, `vmessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `mieruBean` BLOB, `naiveBean` BLOB, `hysteriaBean` BLOB, `tuicBean` BLOB, `sshBean` BLOB, `wgBean` BLOB, `shadowTLSBean` BLOB, `anyTLSBean` BLOB, `chainBean` BLOB, `nekoBean` BLOB, `configBean` BLOB)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "groupId", + "columnName": "groupId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tx", + "columnName": "tx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rx", + "columnName": "rx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ping", + "columnName": "ping", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "error", + "columnName": "error", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "socksBean", + "columnName": "socksBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "httpBean", + "columnName": "httpBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "ssBean", + "columnName": "ssBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "vmessBean", + "columnName": "vmessBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "trojanBean", + "columnName": "trojanBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "trojanGoBean", + "columnName": "trojanGoBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "mieruBean", + "columnName": "mieruBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "naiveBean", + "columnName": "naiveBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "hysteriaBean", + "columnName": "hysteriaBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "tuicBean", + "columnName": "tuicBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "sshBean", + "columnName": "sshBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "wgBean", + "columnName": "wgBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "shadowTLSBean", + "columnName": "shadowTLSBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "anyTLSBean", + "columnName": "anyTLSBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "chainBean", + "columnName": "chainBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "nekoBean", + "columnName": "nekoBean", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "configBean", + "columnName": "configBean", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "groupId", + "unique": false, + "columnNames": [ + "groupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `groupId` ON `${TABLE_NAME}` (`groupId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `config` TEXT NOT NULL DEFAULT '', `userOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `domains` TEXT NOT NULL, `ip` TEXT NOT NULL, `port` TEXT NOT NULL, `sourcePort` TEXT NOT NULL, `network` TEXT NOT NULL, `source` TEXT NOT NULL, `protocol` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `packages` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "config", + "columnName": "config", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domains", + "columnName": "domains", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ip", + "columnName": "ip", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "port", + "columnName": "port", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourcePort", + "columnName": "sourcePort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "network", + "columnName": "network", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "protocol", + "columnName": "protocol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "outbound", + "columnName": "outbound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packages", + "columnName": "packages", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3d3db9106a89d6f20ef3fde6e81dbaa9')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt b/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt index 7fb409d..7d610c5 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt @@ -12,6 +12,8 @@ import kotlinx.parcelize.Parcelize data class RuleEntity( @PrimaryKey(autoGenerate = true) var id: Long = 0L, var name: String = "", + @ColumnInfo(defaultValue = "") + var config: String = "", var userOrder: Long = 0L, var enabled: Boolean = false, var domains: String = "", @@ -31,11 +33,12 @@ data class RuleEntity( fun mkSummary(): String { var summary = "" + if (config.isNotBlank()) summary += "[config]\n" if (domains.isNotBlank()) summary += "$domains\n" if (ip.isNotBlank()) summary += "$ip\n" - if (source.isNotBlank()) summary += "source: $source\n" - if (sourcePort.isNotBlank()) summary += "sourcePort: $sourcePort\n" - if (port.isNotBlank()) summary += "port: $port\n" + if (source.isNotBlank()) summary += "src ip: $source\n" + if (sourcePort.isNotBlank()) summary += "src port: $sourcePort\n" + if (port.isNotBlank()) summary += "dst port: $port\n" if (network.isNotBlank()) summary += "network: $network\n" if (protocol.isNotBlank()) summary += "protocol: $protocol\n" if (packages.isNotEmpty()) summary += app.getString( diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt b/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt index cb9cade..4b5c9b3 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt @@ -16,7 +16,7 @@ import kotlinx.coroutines.launch @Database( entities = [ProxyGroup::class, ProxyEntity::class, RuleEntity::class], - version = 5, + version = 6, autoMigrations = [ AutoMigration(from = 3, to = 4), AutoMigration(from = 4, to = 5) diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt index e7cda94..9882e9e 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -35,7 +35,6 @@ import moe.matsuri.nb4a.proxy.config.ConfigBean import moe.matsuri.nb4a.proxy.shadowtls.ShadowTLSBean import moe.matsuri.nb4a.proxy.shadowtls.buildSingBoxOutboundShadowTLSBean import moe.matsuri.nb4a.utils.JavaUtil.gson -import moe.matsuri.nb4a.utils.Util import moe.matsuri.nb4a.utils.listByLineOrComma import okhttp3.HttpUrl.Companion.toHttpUrlOrNull @@ -82,7 +81,6 @@ fun buildConfig( val globalOutbounds = HashMap() val selectorNames = ArrayList() val group = SagerDatabase.groupDao.getById(proxy.groupId) - val optionsToMerge = proxy.requireBean().customConfigJson ?: "" fun ProxyEntity.resolveChainInternal(): MutableList { val bean = requireBean() @@ -255,13 +253,13 @@ fun buildConfig( add(entity) } - var currentOutbound = mutableMapOf() - lateinit var pastOutbound: MutableMap + var currentOutbound: SingBoxOption + lateinit var pastOutbound: SingBoxOption lateinit var pastInboundTag: String var pastEntity: ProxyEntity? = null val externalChainMap = LinkedHashMap() externalIndexMap.add(IndexEntity(externalChainMap)) - val chainOutbounds = ArrayList>() + val chainOutbounds = ArrayList() // chainTagOut: v2ray outbound tag for this chain var chainTagOut = "" @@ -309,7 +307,7 @@ fun buildConfig( outbound = tagOut }) } else { - pastOutbound["detour"] = tagOut + pastOutbound._hack_config_map["detour"] = tagOut } } else { // index == 0 means last profile in chain / not chain @@ -332,53 +330,51 @@ fun buildConfig( type = "socks" server = LOCALHOST server_port = localPort - }.asMap() - } else { // internal outbound + } + } else { + // internal outbound + currentOutbound = when (bean) { - is ConfigBean -> - gson.fromJson(bean.config, currentOutbound.javaClass) + is ConfigBean -> SingBoxOption().apply { + _hack_custom_config = bean.config + } is ShadowTLSBean -> // before StandardV2RayBean - buildSingBoxOutboundShadowTLSBean(bean).asMap() + buildSingBoxOutboundShadowTLSBean(bean) is StandardV2RayBean -> // http/trojan/vmess/vless - buildSingBoxOutboundStandardV2RayBean(bean).asMap() + buildSingBoxOutboundStandardV2RayBean(bean) is HysteriaBean -> buildSingBoxOutboundHysteriaBean(bean) is TuicBean -> - buildSingBoxOutboundTuicBean(bean).asMap() + buildSingBoxOutboundTuicBean(bean) is SOCKSBean -> - buildSingBoxOutboundSocksBean(bean).asMap() + buildSingBoxOutboundSocksBean(bean) is ShadowsocksBean -> - buildSingBoxOutboundShadowsocksBean(bean).asMap() + buildSingBoxOutboundShadowsocksBean(bean) is WireGuardBean -> - buildSingBoxOutboundWireguardBean(bean).asMap() + buildSingBoxOutboundWireguardBean(bean) is SSHBean -> - buildSingBoxOutboundSSHBean(bean).asMap() + buildSingBoxOutboundSSHBean(bean) is AnyTLSBean -> - buildSingBoxOutboundAnyTLSBean(bean).asMap() + buildSingBoxOutboundAnyTLSBean(bean) else -> throw IllegalStateException("can't reach") - } + } as SingBoxOption - currentOutbound.apply { - // TODO nb4a keepAliveInterval? -// val keepAliveInterval = DataStore.tcpKeepAliveInterval -// val needKeepAliveInterval = keepAliveInterval !in intArrayOf(0, 15) - - if (!muxApplied) { - val muxObj = proxyEntity.singMux() - if (muxObj != null && muxObj.enabled) { - muxApplied = true - currentOutbound["multiplex"] = muxObj.asMap() - } + // internal mux + if (!muxApplied) { + val muxObj = proxyEntity.singMux() + if (muxObj != null && muxObj.enabled) { + muxApplied = true + currentOutbound._hack_config_map["multiplex"] = muxObj.asMap() } } } @@ -388,8 +384,8 @@ fun buildConfig( // udp over tcp try { val sUoT = bean.javaClass.getField("sUoT").get(bean) - if (sUoT is Boolean && sUoT == true) { - currentOutbound["udp_over_tcp"] = true + if (sUoT is Boolean && sUoT) { + _hack_config_map["udp_over_tcp"] = true } } catch (_: Exception) { } @@ -401,19 +397,13 @@ fun buildConfig( domainListDNSDirectForce.add("full:$serverAddress") } } - currentOutbound["domain_strategy"] = + _hack_config_map["domain_strategy"] = if (forTest) "" else defaultServerDomainStrategy - // custom JSON merge - if (bean.customOutboundJson.isNotBlank()) { - Util.mergeJSON( - bean.customOutboundJson, - currentOutbound as MutableMap - ) - } - } + _hack_config_map["tag"] = tagOut - currentOutbound["tag"] = tagOut + _hack_custom_config = bean.customOutboundJson + } // 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. @@ -472,8 +462,8 @@ fun buildConfig( // build outbounds if (buildSelector) { - val list = group?.id?.let { SagerDatabase.proxyDao.getByGroup(it) } - list?.forEach { + val list = group.id.let { SagerDatabase.proxyDao.getByGroup(it) } + list.forEach { tagMap[it.id] = buildChain(it.id, it) } outbounds.add(0, Outbound_SelectorOptions().apply { @@ -481,7 +471,7 @@ fun buildConfig( tag = TAG_PROXY default_ = tagMap[proxy.id] outbounds = tagMap.values.toList() - }.asMap()) + }) } else { buildChain(0, proxy) } @@ -591,6 +581,8 @@ fun buildConfig( -2L -> TAG_BLOCK else -> if (outId == proxy.id) TAG_PROXY else tagMap[outId] ?: "" } + + _hack_custom_config = rule.config } if (!ruleObj.checkEmpty()) { @@ -620,7 +612,7 @@ fun buildConfig( for (freedom in arrayOf(TAG_DIRECT, TAG_BYPASS)) outbounds.add(Outbound().apply { tag = freedom type = "direct" - }.asMap()) + }) // Bypass Lookup for the first profile bypassDNSBeans.forEach { @@ -746,16 +738,16 @@ fun buildConfig( }) } } + + _hack_custom_config = proxy.requireBean().customConfigJson }.let { ConfigBuildResult( - gson.toJson(it.asMap().apply { - Util.mergeJSON(optionsToMerge, this) - }), + gson.toJson(it.asMap()), externalIndexMap, proxy.id, trafficMap, tagMap, - if (buildSelector) group!!.id else -1L + if (buildSelector) group.id else -1L ) } diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt index 22dfeac..1e78a29 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/hysteria/HysteriaFmt.kt @@ -272,7 +272,7 @@ fun HysteriaBean.canUseSingBox(): Boolean { return true } -fun buildSingBoxOutboundHysteriaBean(bean: HysteriaBean): MutableMap { +fun buildSingBoxOutboundHysteriaBean(bean: HysteriaBean): SingBoxOptions.SingBoxOption { return when (bean.protocolVersion) { 1 -> SingBoxOptions.Outbound_HysteriaOptions().apply { type = "hysteria" @@ -311,7 +311,7 @@ fun buildSingBoxOutboundHysteriaBean(bean: HysteriaBean): MutableMap SingBoxOptions.Outbound_Hysteria2Options().apply { type = "hysteria2" @@ -350,9 +350,9 @@ fun buildSingBoxOutboundHysteriaBean(bean: HysteriaBean): MutableMap mutableMapOf("error_version" to bean.protocolVersion) + else -> error("error_version $bean.protocolVersion") } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt index 825d24a..aaa062d 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/RouteSettingsActivity.kt @@ -39,6 +39,7 @@ import io.nekohasekai.sagernet.widget.AppListPreference import io.nekohasekai.sagernet.widget.ListListener import io.nekohasekai.sagernet.widget.OutboundPreference import kotlinx.parcelize.Parcelize +import moe.matsuri.nb4a.ui.EditConfigPreference @Suppress("UNCHECKED_CAST") class RouteSettingsActivity( @@ -57,6 +58,7 @@ class RouteSettingsActivity( fun RuleEntity.init() { DataStore.routeName = name + DataStore.serverConfig = config DataStore.routeDomain = domains DataStore.routeIP = ip DataStore.routePort = port @@ -76,6 +78,7 @@ class RouteSettingsActivity( fun RuleEntity.serialize() { name = DataStore.routeName + config = DataStore.serverConfig domains = DataStore.routeDomain ip = DataStore.routeIP port = DataStore.routePort @@ -96,12 +99,10 @@ class RouteSettingsActivity( } } + private lateinit var editConfigPreference: EditConfigPreference + fun needSave(): Boolean { - if (!DataStore.dirty) return false - if (DataStore.routePackages.isBlank() && DataStore.routeDomain.isBlank() && DataStore.routeIP.isBlank() && DataStore.routePort.isBlank() && DataStore.routeSourcePort.isBlank() && DataStore.routeNetwork.isBlank() && DataStore.routeSource.isBlank() && DataStore.routeProtocol.isBlank()) { - return false - } - return true + return DataStore.dirty } fun PreferenceFragmentCompat.createPreferences( @@ -109,6 +110,16 @@ class RouteSettingsActivity( rootKey: String?, ) { addPreferencesFromResource(R.xml.route_preferences) + + editConfigPreference = findPreference(Key.SERVER_CONFIG)!! + } + + override fun onResume() { + super.onResume() + + if (::editConfigPreference.isInitialized) { + editConfigPreference.notifyChanged() + } } val selectProfileForAdd = registerForActivityResult( @@ -163,7 +174,7 @@ class RouteSettingsActivity( } } - fun PreferenceFragmentCompat.displayPreferenceDialog(preference: Preference): Boolean { + fun displayPreferenceDialog(preference: Preference): Boolean { return false } diff --git a/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java b/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java index 82572f5..ebcc5eb 100644 --- a/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java +++ b/app/src/main/java/moe/matsuri/nb4a/SingBoxOptions.java @@ -1,19 +1,74 @@ package moe.matsuri.nb4a; -import static moe.matsuri.nb4a.utils.JavaUtil.gson; - +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import com.google.gson.ToNumberPolicy; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; import com.google.gson.annotations.SerializedName; +import com.google.gson.reflect.TypeToken; +import java.lang.reflect.Type; +import java.util.HashMap; import java.util.List; import java.util.Map; +import moe.matsuri.nb4a.utils.Util; + public class SingBoxOptions { // base + private static final Gson gsonSingbox = new GsonBuilder() + .registerTypeHierarchyAdapter(SingBoxOption.class, new SingBoxOptionSerializer()) + .setPrettyPrinting() + .setNumberToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .setLenient() + .disableHtmlEscaping() + .create(); + public static class SingBoxOption { + + public transient Map _hack_config_map; // 仍然用普通json方式合并,所以Object内不要使用 _hack + + public transient String _hack_custom_config; + + public SingBoxOption() { + _hack_config_map = new HashMap<>(); + } + public Map asMap() { - return gson.fromJson(gson.toJson(this), Map.class); + return gsonSingbox.fromJson(gsonSingbox.toJson(this), Map.class); + } + + } + + // 自定义序列化器 + public static class SingBoxOptionSerializer implements JsonSerializer { + @Override + public JsonElement serialize(SingBoxOption src, Type typeOfSrc, JsonSerializationContext context) { + // 拿到原始的 delegate(默认序列化器) + TypeAdapter delegate = gsonSingbox.getDelegateAdapter( + new TypeAdapterFactory() { + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + return null; // 返回 null,表示只作为“跳过当前自定义”的 marker + } + }, + TypeToken.get(src.getClass()) + ); + Map map = gsonSingbox.fromJson(((TypeAdapter) delegate).toJson(src), Map.class); + if (src._hack_config_map != null && !src._hack_config_map.isEmpty()) { + Util.INSTANCE.mergeMap(map, src._hack_config_map); + } + if (src._hack_custom_config != null && !src._hack_custom_config.isBlank()) { + Util.INSTANCE.mergeJSON(map, src._hack_custom_config); + } + return gsonSingbox.toJsonTree(map); } } @@ -33,7 +88,7 @@ public class SingBoxOptions { public List inbounds; - public List> outbounds; + public List outbounds; public RouteOptions route; diff --git a/app/src/main/java/moe/matsuri/nb4a/utils/Util.kt b/app/src/main/java/moe/matsuri/nb4a/utils/Util.kt index 811d705..dca08c0 100644 --- a/app/src/main/java/moe/matsuri/nb4a/utils/Util.kt +++ b/app/src/main/java/moe/matsuri/nb4a/utils/Util.kt @@ -152,7 +152,7 @@ object Util { return dst } - fun mergeJSON(j: String, dst: MutableMap) { + fun mergeJSON(dst: MutableMap, j: String) { if (j.isBlank()) return val src = JavaUtil.gson.fromJson(j, dst.javaClass) mergeMap(dst, src) diff --git a/app/src/main/res/xml/route_preferences.xml b/app/src/main/res/xml/route_preferences.xml index ab8a18f..42c60ba 100644 --- a/app/src/main/res/xml/route_preferences.xml +++ b/app/src/main/res/xml/route_preferences.xml @@ -6,11 +6,16 @@ app:key="routeName" app:title="@string/route_name" app:useSimpleSummaryProvider="true" /> - + + + -