From 81439e94ae40632839f775b2afc4a8b6abc8c6d3 Mon Sep 17 00:00:00 2001 From: Minis <285644962+Minis233@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:12:13 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20add=20Snell=20v3=E2=80=93v6=20proto?= =?UTF-8?q?col=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire Snell into the Android client: profile UI, share links, Clash import, ConfigBuilder outbound, Room migration 36→37. Requires exclave-core with proxy/snell outbound (protocol: "snell"). Supports Snell v3–v6 (v6 modes: default / unshaped / unsafe-raw). --- app/src/main/AndroidManifest.xml | 2 + .../sagernet/database/Migrations.kt | 23 +++- .../sagernet/database/ProxyEntity.kt | 15 +++ .../sagernet/database/SagerDatabase.kt | 5 +- .../nekohasekai/sagernet/fmt/ConfigBuilder.kt | 13 +++ .../sagernet/fmt/KryoConverters.java | 7 ++ .../io/nekohasekai/sagernet/fmt/TypeMap.kt | 1 + .../sagernet/fmt/snell/SnellBean.java | 110 ++++++++++++++++++ .../sagernet/fmt/snell/SnellFmt.kt | 57 +++++++++ .../sagernet/fmt/v2ray/V2RayConfig.java | 18 ++- .../sagernet/group/ClashYAMLParser.kt | 16 +++ .../io/nekohasekai/sagernet/ktx/Formats.kt | 5 + .../sagernet/ui/ConfigurationFragment.kt | 3 + .../ui/profile/SnellSettingsActivity.kt | 65 +++++++++++ app/src/main/res/menu/add_profile_menu.xml | 3 + app/src/main/res/values/snell_arrays.xml | 35 ++++++ app/src/main/res/values/snell_strings.xml | 9 ++ app/src/main/res/xml/snell_preferences.xml | 65 +++++++++++ 18 files changed, 448 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBean.java create mode 100644 app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellFmt.kt create mode 100644 app/src/main/java/io/nekohasekai/sagernet/ui/profile/SnellSettingsActivity.kt create mode 100644 app/src/main/res/values/snell_arrays.xml create mode 100644 app/src/main/res/values/snell_strings.xml create mode 100644 app/src/main/res/xml/snell_preferences.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9ffd2d425ce..b07ea31cd3a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -198,6 +198,8 @@ android:name="io.nekohasekai.sagernet.ui.profile.ShadowQUICSettingsActivity" /> + anytlsBean = KryoConverters.anytlsDeserialize(byteArray) TYPE_SHADOWQUIC -> shadowquicBean = KryoConverters.shadowquicDeserialize(byteArray) TYPE_TRUSTTUNNEL -> trustTunnelBean = KryoConverters.trusttunnelDeserialize(byteArray) + TYPE_SNELL -> snellBean = KryoConverters.snellDeserialize(byteArray) TYPE_CONFIG -> configBean = KryoConverters.configDeserialize(byteArray) TYPE_CHAIN -> chainBean = KryoConverters.chainDeserialize(byteArray) @@ -246,6 +252,7 @@ data class ProxyEntity( TYPE_ANYTLS -> "AnyTLS" TYPE_SHADOWQUIC -> "ShadowQUIC" TYPE_TRUSTTUNNEL -> "TrustTunnel" + TYPE_SNELL -> "Snell" TYPE_CHAIN -> chainName TYPE_CONFIG -> configName @@ -276,6 +283,7 @@ data class ProxyEntity( TYPE_ANYTLS -> anytlsBean TYPE_SHADOWQUIC -> shadowquicBean TYPE_TRUSTTUNNEL -> trustTunnelBean + TYPE_SNELL -> snellBean TYPE_CONFIG -> configBean TYPE_CHAIN -> chainBean @@ -317,6 +325,7 @@ data class ProxyEntity( is Http3Bean -> toUri() is AnyTLSBean -> toUri() is TrustTunnelBean -> toUri() + is SnellBean -> toUri() is ShadowQUICBean -> toUri() else -> null } @@ -382,6 +391,7 @@ data class ProxyEntity( anytlsBean = null shadowquicBean = null trustTunnelBean = null + snellBean = null configBean = null chainBean = null @@ -460,6 +470,10 @@ data class ProxyEntity( type = TYPE_TRUSTTUNNEL trustTunnelBean = bean } + is SnellBean -> { + type = TYPE_SNELL + snellBean = bean + } is ConfigBean -> { type = TYPE_CONFIG @@ -498,6 +512,7 @@ data class ProxyEntity( TYPE_ANYTLS -> AnyTLSSettingsActivity::class.java TYPE_SHADOWQUIC -> ShadowQUICSettingsActivity::class.java TYPE_TRUSTTUNNEL -> TrustTunnelSettingsActivity::class.java + TYPE_SNELL -> SnellSettingsActivity::class.java TYPE_CONFIG -> ConfigSettingsActivity::class.java TYPE_CHAIN -> ChainSettingsActivity::class.java 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 a941276e17f..bfb36bba699 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt @@ -29,7 +29,7 @@ import kotlinx.coroutines.launch @Database( entities = [ProxyGroup::class, ProxyEntity::class, RuleEntity::class, StatsEntity::class, AssetEntity::class], - version = 36, + version = 37, autoMigrations = [AutoMigration( from = 12, to = 14, @@ -126,7 +126,8 @@ abstract class SagerDatabase : RoomDatabase() { SagerDatabase_Migration_8_9, SagerDatabase_Migration_9_10, SagerDatabase_Migration_10_11, - SagerDatabase_Migration_11_12 + SagerDatabase_Migration_11_12, + SagerDatabase_Migration_36_37 ) .fallbackToDestructiveMigrationOnDowngrade() .allowMainThreadQueries() 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 4b1a06edf4c..03358193711 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -54,6 +54,7 @@ import io.nekohasekai.sagernet.fmt.socks.SOCKSBean import io.nekohasekai.sagernet.fmt.ssh.SSHBean import io.nekohasekai.sagernet.fmt.trojan.TrojanBean import io.nekohasekai.sagernet.fmt.trusttunnel.TrustTunnelBean +import io.nekohasekai.sagernet.fmt.snell.SnellBean import io.nekohasekai.sagernet.fmt.tuic5.Tuic5Bean import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean import io.nekohasekai.sagernet.fmt.v2ray.V2RayConfig @@ -1740,6 +1741,18 @@ fun buildV2RayConfig( } } } + } else if (bean is SnellBean) { + protocol = "snell" + settings = LazyOutboundConfigurationObject(this, V2RayConfig.SnellOutboundConfigurationObject().apply { + address = bean.serverAddress + port = bean.serverPort + psk = bean.psk + obfs = bean.obfs + obfsHost = bean.obfsHost + version = bean.version + reuse = bean.reuse + mode = bean.mode + }) } else if (bean is MieruBean) { protocol = "mieru" settings = LazyOutboundConfigurationObject(this, diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java index 5d99f76628b..60052ce3df4 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java @@ -46,6 +46,7 @@ import io.nekohasekai.sagernet.fmt.ssh.SSHBean; import io.nekohasekai.sagernet.fmt.trojan.TrojanBean; import io.nekohasekai.sagernet.fmt.trusttunnel.TrustTunnelBean; +import io.nekohasekai.sagernet.fmt.snell.SnellBean; import io.nekohasekai.sagernet.fmt.tuic5.Tuic5Bean; import io.nekohasekai.sagernet.fmt.v2ray.VLESSBean; import io.nekohasekai.sagernet.fmt.v2ray.VMessBean; @@ -189,6 +190,12 @@ public static TrustTunnelBean trusttunnelDeserialize(byte[] bytes) { return deserialize(new TrustTunnelBean(), bytes); } + @TypeConverter + public static SnellBean snellDeserialize(byte[] bytes) { + if (bytes == null || bytes.length == 0) return null; + return deserialize(new SnellBean(), bytes); + } + @TypeConverter public static ConfigBean configDeserialize(byte[] bytes) { if (bytes == null || bytes.length == 0) return null; diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/TypeMap.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/TypeMap.kt index 9f7498c7dd8..ce2be09f484 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/TypeMap.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/TypeMap.kt @@ -42,6 +42,7 @@ object TypeMap : HashMap() { this["anytls"] = ProxyEntity.TYPE_ANYTLS this["shadowquic"] = ProxyEntity.TYPE_SHADOWQUIC this["trusttunnel"] = ProxyEntity.TYPE_TRUSTTUNNEL + this["snell"] = ProxyEntity.TYPE_SNELL } val reversed = HashMap() diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBean.java new file mode 100644 index 00000000000..7d0d0777c5f --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBean.java @@ -0,0 +1,110 @@ +/****************************************************************************** + * * + * Copyright (C) 2026 Snell support for Exclave * + * * + * 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. * + * * + ******************************************************************************/ + +package io.nekohasekai.sagernet.fmt.snell; + +import androidx.annotation.NonNull; + +import com.esotericsoftware.kryo.io.ByteBufferInput; +import com.esotericsoftware.kryo.io.ByteBufferOutput; + +import org.jetbrains.annotations.NotNull; + +import io.nekohasekai.sagernet.fmt.AbstractBean; +import io.nekohasekai.sagernet.fmt.KryoConverters; + +public class SnellBean extends AbstractBean { + + public static final int VERSION_3 = 3; + public static final int VERSION_4 = 4; + public static final int VERSION_5 = 5; + public static final int VERSION_6 = 6; + + public static final String OBFS_OFF = "off"; + public static final String OBFS_HTTP = "http"; + public static final String OBFS_TLS = "tls"; + + /** v6 modes: default | unshaped | unsafe-raw */ + public static final String MODE_DEFAULT = "default"; + public static final String MODE_UNSHAPED = "unshaped"; + public static final String MODE_UNSAFE_RAW = "unsafe-raw"; + + public String psk; + public String obfs; + public String obfsHost; + public Integer version; + public Boolean reuse; + public String mode; + + @Override + public void initializeDefaultValues() { + super.initializeDefaultValues(); + if (psk == null) psk = ""; + if (obfs == null || obfs.isEmpty()) obfs = OBFS_OFF; + if (obfsHost == null) obfsHost = ""; + if (version == null || version == 0) version = VERSION_4; + if (reuse == null) reuse = true; + if (mode == null || mode.isEmpty()) mode = MODE_DEFAULT; + } + + @Override + public void serialize(ByteBufferOutput output) { + // v2: +obfsHost +mode + output.writeInt(2); + super.serialize(output); + output.writeString(psk); + output.writeString(obfs); + output.writeInt(version); + output.writeBoolean(reuse); + output.writeString(obfsHost); + output.writeString(mode); + } + + @Override + public void deserialize(ByteBufferInput input) { + int ver = input.readInt(); + super.deserialize(input); + psk = input.readString(); + obfs = input.readString(); + version = input.readInt(); + if (ver >= 1) { + reuse = input.readBoolean(); + } + if (ver >= 2) { + obfsHost = input.readString(); + mode = input.readString(); + } + } + + @Override + public String network() { + return "tcp,udp"; + } + + @NotNull + @Override + public SnellBean clone() { + return KryoConverters.deserialize(new SnellBean(), KryoConverters.serialize(this)); + } + + public static final Creator CREATOR = new CREATOR<>() { + @NonNull + @Override + public SnellBean newInstance() { + return new SnellBean(); + } + + @Override + public SnellBean[] newArray(int size) { + return new SnellBean[size]; + } + }; +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellFmt.kt new file mode 100644 index 00000000000..98158c49fba --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellFmt.kt @@ -0,0 +1,57 @@ +/****************************************************************************** + * Snell share-link helpers for Exclave (v3–v6). + * snell://psk@host:port?version=6&obfs=tls&obfs-host=...&mode=default&reuse=1#name + ******************************************************************************/ + +package io.nekohasekai.sagernet.fmt.snell + +import io.nekohasekai.sagernet.ktx.* +import libexclavecore.Libexclavecore + +fun parseSnell(url: String): SnellBean { + val link = Libexclavecore.parseURL(url) + return SnellBean().apply { + name = link.fragment + serverAddress = link.host.ifEmpty { error("empty host") } + serverPort = link.port + psk = when { + link.username.isNotEmpty() -> link.username + link.password.isNotEmpty() -> link.password + else -> link.queryParameter("psk") ?: error("empty psk") + } + link.queryParameter("version")?.toIntOrNull()?.also { version = it } + link.queryParameter("obfs")?.also { obfs = it } + link.queryParameter("obfs-mode")?.also { obfs = it } + link.queryParameter("obfs-host")?.also { obfsHost = it } + link.queryParameter("host")?.also { if (obfsHost.isNullOrEmpty()) obfsHost = it } + link.queryParameter("mode")?.also { mode = it } + link.queryParameter("reuse")?.also { + reuse = it == "1" || it.equals("true", ignoreCase = true) + } + initializeDefaultValues() + } +} + +fun SnellBean.toUri(): String? { + val builder = Libexclavecore.newURL("snell").apply { + setHostPort(serverAddress.ifEmpty { error("empty server address") }, serverPort) + username = psk.ifEmpty { error("empty psk") } + if (name.isNotEmpty()) { + fragment = name + } + } + builder.addQueryParameter("version", (version ?: 4).toString()) + if (!obfs.isNullOrEmpty() && obfs != SnellBean.OBFS_OFF && obfs != "none") { + builder.addQueryParameter("obfs", obfs) + } + if (!obfsHost.isNullOrEmpty()) { + builder.addQueryParameter("obfs-host", obfsHost) + } + if ((version ?: 4) >= 6 && !mode.isNullOrEmpty() && mode != SnellBean.MODE_DEFAULT) { + builder.addQueryParameter("mode", mode) + } + if (reuse == true) { + builder.addQueryParameter("reuse", "1") + } + return builder.string +} diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayConfig.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayConfig.java index 0ecbbd9906a..48a322e628e 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayConfig.java +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayConfig.java @@ -696,6 +696,8 @@ protected Class getType() { return MieruOutboundConfigurationObject.class; case "trusttunnel": return TrustTunnelOutboundConfigurationObject.class; + case "snell": + return SnellOutboundConfigurationObject.class; } return null; } @@ -985,7 +987,21 @@ public static class JuicityOutboundConfigurationObject implements OutboundConfig } - public static class MieruOutboundConfigurationObject implements OutboundConfigurationObject { + + public static class SnellOutboundConfigurationObject implements OutboundConfigurationObject { + + public String address; + public Integer port; + public String psk; + public String obfs; + public String obfsHost; + public Integer version; + public Boolean reuse; + public String mode; + + } + +public static class MieruOutboundConfigurationObject implements OutboundConfigurationObject { public String address; public Integer port; diff --git a/app/src/main/java/io/nekohasekai/sagernet/group/ClashYAMLParser.kt b/app/src/main/java/io/nekohasekai/sagernet/group/ClashYAMLParser.kt index bcc79e8fe94..13ebd35d361 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/group/ClashYAMLParser.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/group/ClashYAMLParser.kt @@ -38,6 +38,7 @@ import io.nekohasekai.sagernet.fmt.socks.SOCKSBean import io.nekohasekai.sagernet.fmt.ssh.SSHBean import io.nekohasekai.sagernet.fmt.trojan.TrojanBean import io.nekohasekai.sagernet.fmt.trusttunnel.TrustTunnelBean +import io.nekohasekai.sagernet.fmt.snell.SnellBean import io.nekohasekai.sagernet.fmt.tuic5.Tuic5Bean import io.nekohasekai.sagernet.fmt.tuic5.supportedTuic5CongestionControl import io.nekohasekai.sagernet.fmt.tuic5.supportedTuic5RelayMode @@ -771,6 +772,21 @@ fun parseClashProxy(proxy: Map): List { }*/ name = proxy.getString("name") }) + } + "snell" -> { + return listOf(SnellBean().apply { + serverAddress = proxy.getString("server") ?: return listOf() + serverPort = proxy.getInt("port")?.takeIf { it > 0 } ?: return listOf() + psk = proxy.getString("psk") ?: return listOf() + proxy.getInt("version")?.also { version = it } + proxy.getString("obfs")?.also { obfs = it } + proxy.getObject("obfs-opts")?.also { opts -> + opts.getString("mode")?.also { obfs = it } + opts.getString("host")?.also { obfsHost = it } + } + proxy.getString("mode")?.also { mode = it } + name = proxy.getString("name") + }) } "anytls" -> { return listOf(AnyTLSBean().apply { diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt index 3b822747c73..9e45aa5a772 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Formats.kt @@ -36,6 +36,7 @@ import io.nekohasekai.sagernet.fmt.shadowsocksr.parseShadowsocksR import io.nekohasekai.sagernet.fmt.socks.parseSOCKS import io.nekohasekai.sagernet.fmt.ssh.parseSSH import io.nekohasekai.sagernet.fmt.trusttunnel.parseTrustTunnel +import io.nekohasekai.sagernet.fmt.snell.parseSnell import io.nekohasekai.sagernet.fmt.tuic5.parseTuic import io.nekohasekai.sagernet.fmt.v2ray.parseV2Ray import io.nekohasekai.sagernet.fmt.wireguard.parseWireGuard @@ -125,6 +126,10 @@ fun parseShareLinks(text: String): List { runCatching { entities.add(parseAnyTLS(this)) } + } else if (startsWith("snell://", ignoreCase = true)) { + runCatching { + entities.add(parseSnell(this)) + } } else if (startsWith("ssh://", ignoreCase = true)) { runCatching { entities.add(parseSSH(this)) diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt index a524a223a70..c812e240c81 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt @@ -520,6 +520,9 @@ class ConfigurationFragment @JvmOverloads constructor( R.id.action_new_trusttunnel -> { startActivity(Intent(requireActivity(), TrustTunnelSettingsActivity::class.java)) } + R.id.action_new_snell -> { + startActivity(Intent(requireActivity(), SnellSettingsActivity::class.java)) + } R.id.action_new_config -> { startActivity(Intent(requireActivity(), ConfigSettingsActivity::class.java)) } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/SnellSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/SnellSettingsActivity.kt new file mode 100644 index 00000000000..71e7f501fbc --- /dev/null +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/SnellSettingsActivity.kt @@ -0,0 +1,65 @@ +package io.nekohasekai.sagernet.ui.profile + +import android.os.Bundle +import androidx.preference.EditTextPreference +import androidx.preference.PreferenceFragmentCompat +import io.nekohasekai.sagernet.Key +import io.nekohasekai.sagernet.R +import io.nekohasekai.sagernet.database.DataStore +import io.nekohasekai.sagernet.database.preference.EditTextPreferenceModifiers +import io.nekohasekai.sagernet.fmt.snell.SnellBean +import io.nekohasekai.sagernet.ktx.unwrapIDN +import io.nekohasekai.sagernet.widget.SimpleMenuPreference + +class SnellSettingsActivity : ProfileSettingsActivity() { + + override fun createEntity() = SnellBean() + + override fun SnellBean.init() { + DataStore.profileName = name + DataStore.serverAddress = serverAddress + DataStore.serverPort = serverPort + DataStore.serverPassword = psk + DataStore.serverObfs = obfs + DataStore.serverHost = obfsHost + DataStore.serverProtocolVersion = version ?: SnellBean.VERSION_4 + DataStore.serverMux = reuse == true + DataStore.serverProtocolParam = mode + } + + override fun SnellBean.serialize() { + name = DataStore.profileName + serverAddress = DataStore.serverAddress.unwrapIDN() + serverPort = DataStore.serverPort + psk = DataStore.serverPassword + obfs = DataStore.serverObfs?.ifEmpty { SnellBean.OBFS_OFF } ?: SnellBean.OBFS_OFF + obfsHost = DataStore.serverHost + version = DataStore.serverProtocolVersion.takeIf { it in 3..6 } ?: SnellBean.VERSION_4 + reuse = DataStore.serverMux + mode = DataStore.serverProtocolParam?.ifEmpty { SnellBean.MODE_DEFAULT } ?: SnellBean.MODE_DEFAULT + } + + override fun PreferenceFragmentCompat.createPreferences( + savedInstanceState: Bundle?, + rootKey: String?, + ) { + addPreferencesFromResource(R.xml.snell_preferences) + findPreference(Key.SERVER_PORT)!!.apply { + setOnBindEditTextListener(EditTextPreferenceModifiers.Port) + } + findPreference(Key.SERVER_PASSWORD)!!.apply { + summaryProvider = PasswordSummaryProvider + } + val versionPref = findPreference(Key.SERVER_PROTOCOL)!! + val modePref = findPreference(Key.SERVER_PROTOCOL_PARAM)!! + fun updateModeVisibility() { + val v = versionPref.value?.toIntOrNull() ?: DataStore.serverProtocolVersion + modePref.isVisible = v >= 6 + } + updateModeVisibility() + versionPref.setOnPreferenceChangeListener { _, newValue -> + modePref.isVisible = (newValue as? String)?.toIntOrNull()?.let { it >= 6 } == true + true + } + } +} diff --git a/app/src/main/res/menu/add_profile_menu.xml b/app/src/main/res/menu/add_profile_menu.xml index 195413bdde9..fe3a91319f4 100644 --- a/app/src/main/res/menu/add_profile_menu.xml +++ b/app/src/main/res/menu/add_profile_menu.xml @@ -55,6 +55,9 @@ + diff --git a/app/src/main/res/values/snell_arrays.xml b/app/src/main/res/values/snell_arrays.xml new file mode 100644 index 00000000000..e28dbafd6b3 --- /dev/null +++ b/app/src/main/res/values/snell_arrays.xml @@ -0,0 +1,35 @@ + + + + 3 + 4 + 5 + 6 + + + 3 + 4 + 5 + 6 + + + off + http + tls + + + off + http + tls + + + default + unshaped + unsafe-raw + + + default + unshaped + unsafe-raw + + diff --git a/app/src/main/res/values/snell_strings.xml b/app/src/main/res/values/snell_strings.xml new file mode 100644 index 00000000000..417e6cef9b6 --- /dev/null +++ b/app/src/main/res/values/snell_strings.xml @@ -0,0 +1,9 @@ + + + Snell version + Connection reuse + Reuse the TCP connection (ConnectV2 / v6 reuse) + Snell + Obfs host + Snell v6 mode + diff --git a/app/src/main/res/xml/snell_preferences.xml b/app/src/main/res/xml/snell_preferences.xml new file mode 100644 index 00000000000..9a2033d2e79 --- /dev/null +++ b/app/src/main/res/xml/snell_preferences.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + From 8d6ba282ee3fc31baa63f2dd1369aa351ee667a3 Mon Sep 17 00:00:00 2001 From: Minis <285644962+Minis233@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:29:30 +0800 Subject: [PATCH 2/2] refactor(snell): align app with core review (v4/v6, obfsMode, userPSK) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Version picker 4 / 6 only (label v6 as beta) - Rename obfs → obfsMode; values none|http|tls (no "off") - Add userPSK (sing-box extension) - Hide v4-only / v6-only fields based on selected version --- .../nekohasekai/sagernet/fmt/ConfigBuilder.kt | 3 +- .../sagernet/fmt/snell/SnellBean.java | 34 ++++++++++++----- .../sagernet/fmt/snell/SnellFmt.kt | 37 +++++++++++-------- .../sagernet/fmt/v2ray/V2RayConfig.java | 5 ++- .../sagernet/group/ClashYAMLParser.kt | 16 ++++++-- .../ui/profile/SnellSettingsActivity.kt | 27 ++++++++++---- app/src/main/res/values/snell_arrays.xml | 10 ++--- app/src/main/res/values/snell_strings.xml | 6 ++- app/src/main/res/xml/snell_preferences.xml | 10 ++++- 9 files changed, 97 insertions(+), 51 deletions(-) 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 03358193711..4e57be74d08 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt @@ -1747,7 +1747,8 @@ fun buildV2RayConfig( address = bean.serverAddress port = bean.serverPort psk = bean.psk - obfs = bean.obfs + userPSK = bean.userPSK + obfsMode = bean.obfsMode obfsHost = bean.obfsHost version = bean.version reuse = bean.reuse diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBean.java index 7d0d0777c5f..b1966da5f76 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBean.java +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBean.java @@ -23,32 +23,34 @@ public class SnellBean extends AbstractBean { - public static final int VERSION_3 = 3; public static final int VERSION_4 = 4; - public static final int VERSION_5 = 5; public static final int VERSION_6 = 6; - public static final String OBFS_OFF = "off"; + public static final String OBFS_NONE = "none"; public static final String OBFS_HTTP = "http"; public static final String OBFS_TLS = "tls"; - /** v6 modes: default | unshaped | unsafe-raw */ public static final String MODE_DEFAULT = "default"; public static final String MODE_UNSHAPED = "unshaped"; public static final String MODE_UNSAFE_RAW = "unsafe-raw"; public String psk; - public String obfs; + /** sing-box private extension; only compatible with sing-box Snell server. */ + public String userPSK; + public String obfsMode; + /** v4-only */ public String obfsHost; public Integer version; public Boolean reuse; + /** v6-only */ public String mode; @Override public void initializeDefaultValues() { super.initializeDefaultValues(); if (psk == null) psk = ""; - if (obfs == null || obfs.isEmpty()) obfs = OBFS_OFF; + if (userPSK == null) userPSK = ""; + if (obfsMode == null || obfsMode.isEmpty()) obfsMode = OBFS_NONE; if (obfsHost == null) obfsHost = ""; if (version == null || version == 0) version = VERSION_4; if (reuse == null) reuse = true; @@ -57,15 +59,16 @@ public void initializeDefaultValues() { @Override public void serialize(ByteBufferOutput output) { - // v2: +obfsHost +mode - output.writeInt(2); + // v3: rename obfs->obfsMode, add userPSK; version 4/6 only + output.writeInt(3); super.serialize(output); output.writeString(psk); - output.writeString(obfs); + output.writeString(obfsMode); output.writeInt(version); output.writeBoolean(reuse); output.writeString(obfsHost); output.writeString(mode); + output.writeString(userPSK); } @Override @@ -73,8 +76,16 @@ public void deserialize(ByteBufferInput input) { int ver = input.readInt(); super.deserialize(input); psk = input.readString(); - obfs = input.readString(); + obfsMode = input.readString(); + // migrate legacy "off" -> "none" for old profiles only + if ("off".equals(obfsMode)) { + obfsMode = OBFS_NONE; + } version = input.readInt(); + // migrate legacy 3/5 to 4 + if (version != null && version != VERSION_4 && version != VERSION_6) { + version = VERSION_4; + } if (ver >= 1) { reuse = input.readBoolean(); } @@ -82,6 +93,9 @@ public void deserialize(ByteBufferInput input) { obfsHost = input.readString(); mode = input.readString(); } + if (ver >= 3) { + userPSK = input.readString(); + } } @Override diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellFmt.kt index 98158c49fba..4ad6568a578 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellFmt.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellFmt.kt @@ -1,8 +1,3 @@ -/****************************************************************************** - * Snell share-link helpers for Exclave (v3–v6). - * snell://psk@host:port?version=6&obfs=tls&obfs-host=...&mode=default&reuse=1#name - ******************************************************************************/ - package io.nekohasekai.sagernet.fmt.snell import io.nekohasekai.sagernet.ktx.* @@ -17,14 +12,20 @@ fun parseSnell(url: String): SnellBean { psk = when { link.username.isNotEmpty() -> link.username link.password.isNotEmpty() -> link.password - else -> link.queryParameter("psk") ?: error("empty psk") + else -> link.queryParameter("psk") ?: "" + } + link.queryParameter("version")?.toIntOrNull()?.also { + if (it != 4 && it != 6) error("snell version must be 4 or 6") + version = it + } + // accept only exact values; map legacy "off" only from share links that used it historically + (link.queryParameter("obfsMode") ?: link.queryParameter("obfs"))?.also { + obfsMode = if (it == "off") SnellBean.OBFS_NONE else it } - link.queryParameter("version")?.toIntOrNull()?.also { version = it } - link.queryParameter("obfs")?.also { obfs = it } - link.queryParameter("obfs-mode")?.also { obfs = it } link.queryParameter("obfs-host")?.also { obfsHost = it } - link.queryParameter("host")?.also { if (obfsHost.isNullOrEmpty()) obfsHost = it } link.queryParameter("mode")?.also { mode = it } + link.queryParameter("user-psk")?.also { userPSK = it } + link.queryParameter("userPSK")?.also { userPSK = it } link.queryParameter("reuse")?.also { reuse = it == "1" || it.equals("true", ignoreCase = true) } @@ -35,21 +36,27 @@ fun parseSnell(url: String): SnellBean { fun SnellBean.toUri(): String? { val builder = Libexclavecore.newURL("snell").apply { setHostPort(serverAddress.ifEmpty { error("empty server address") }, serverPort) - username = psk.ifEmpty { error("empty psk") } + if (!psk.isNullOrEmpty()) { + username = psk + } if (name.isNotEmpty()) { fragment = name } } - builder.addQueryParameter("version", (version ?: 4).toString()) - if (!obfs.isNullOrEmpty() && obfs != SnellBean.OBFS_OFF && obfs != "none") { - builder.addQueryParameter("obfs", obfs) + val v = version ?: 4 + builder.addQueryParameter("version", v.toString()) + if (!obfsMode.isNullOrEmpty() && obfsMode != SnellBean.OBFS_NONE) { + builder.addQueryParameter("obfsMode", obfsMode) } if (!obfsHost.isNullOrEmpty()) { builder.addQueryParameter("obfs-host", obfsHost) } - if ((version ?: 4) >= 6 && !mode.isNullOrEmpty() && mode != SnellBean.MODE_DEFAULT) { + if (v >= 6 && !mode.isNullOrEmpty() && mode != SnellBean.MODE_DEFAULT) { builder.addQueryParameter("mode", mode) } + if (!userPSK.isNullOrEmpty()) { + builder.addQueryParameter("user-psk", userPSK) + } if (reuse == true) { builder.addQueryParameter("reuse", "1") } diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayConfig.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayConfig.java index 48a322e628e..f618744168f 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayConfig.java +++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayConfig.java @@ -988,12 +988,13 @@ public static class JuicityOutboundConfigurationObject implements OutboundConfig } - public static class SnellOutboundConfigurationObject implements OutboundConfigurationObject { + public static class SnellOutboundConfigurationObject implements OutboundConfigurationObject { public String address; public Integer port; public String psk; - public String obfs; + public String userPSK; + public String obfsMode; public String obfsHost; public Integer version; public Boolean reuse; diff --git a/app/src/main/java/io/nekohasekai/sagernet/group/ClashYAMLParser.kt b/app/src/main/java/io/nekohasekai/sagernet/group/ClashYAMLParser.kt index 13ebd35d361..871c83fe755 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/group/ClashYAMLParser.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/group/ClashYAMLParser.kt @@ -777,11 +777,19 @@ fun parseClashProxy(proxy: Map): List { return listOf(SnellBean().apply { serverAddress = proxy.getString("server") ?: return listOf() serverPort = proxy.getInt("port")?.takeIf { it > 0 } ?: return listOf() - psk = proxy.getString("psk") ?: return listOf() - proxy.getInt("version")?.also { version = it } - proxy.getString("obfs")?.also { obfs = it } + psk = proxy.getString("psk") ?: "" + proxy.getString("user-psk")?.also { userPSK = it } + proxy.getInt("version")?.also { + if (it != 4 && it != 6) return listOf() + version = it + } + proxy.getString("obfs")?.also { + obfsMode = if (it == "off") SnellBean.OBFS_NONE else it + } proxy.getObject("obfs-opts")?.also { opts -> - opts.getString("mode")?.also { obfs = it } + opts.getString("mode")?.also { + obfsMode = if (it == "off") SnellBean.OBFS_NONE else it + } opts.getString("host")?.also { obfsHost = it } } proxy.getString("mode")?.also { mode = it } diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/SnellSettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/SnellSettingsActivity.kt index 71e7f501fbc..ce8b997b75d 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/ui/profile/SnellSettingsActivity.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/ui/profile/SnellSettingsActivity.kt @@ -20,7 +20,8 @@ class SnellSettingsActivity : ProfileSettingsActivity() { DataStore.serverAddress = serverAddress DataStore.serverPort = serverPort DataStore.serverPassword = psk - DataStore.serverObfs = obfs + DataStore.serverPassword1 = userPSK + DataStore.serverObfs = obfsMode DataStore.serverHost = obfsHost DataStore.serverProtocolVersion = version ?: SnellBean.VERSION_4 DataStore.serverMux = reuse == true @@ -32,9 +33,10 @@ class SnellSettingsActivity : ProfileSettingsActivity() { serverAddress = DataStore.serverAddress.unwrapIDN() serverPort = DataStore.serverPort psk = DataStore.serverPassword - obfs = DataStore.serverObfs?.ifEmpty { SnellBean.OBFS_OFF } ?: SnellBean.OBFS_OFF + userPSK = DataStore.serverPassword1 + obfsMode = DataStore.serverObfs?.ifEmpty { SnellBean.OBFS_NONE } ?: SnellBean.OBFS_NONE obfsHost = DataStore.serverHost - version = DataStore.serverProtocolVersion.takeIf { it in 3..6 } ?: SnellBean.VERSION_4 + version = DataStore.serverProtocolVersion.takeIf { it == 4 || it == 6 } ?: SnellBean.VERSION_4 reuse = DataStore.serverMux mode = DataStore.serverProtocolParam?.ifEmpty { SnellBean.MODE_DEFAULT } ?: SnellBean.MODE_DEFAULT } @@ -50,15 +52,24 @@ class SnellSettingsActivity : ProfileSettingsActivity() { findPreference(Key.SERVER_PASSWORD)!!.apply { summaryProvider = PasswordSummaryProvider } + findPreference(Key.SERVER_PASSWORD1)!!.apply { + summaryProvider = PasswordSummaryProvider + } val versionPref = findPreference(Key.SERVER_PROTOCOL)!! val modePref = findPreference(Key.SERVER_PROTOCOL_PARAM)!! - fun updateModeVisibility() { - val v = versionPref.value?.toIntOrNull() ?: DataStore.serverProtocolVersion - modePref.isVisible = v >= 6 + val obfsPref = findPreference(Key.SERVER_OBFS)!! + val obfsHostPref = findPreference(Key.SERVER_HOST)!! + fun updateVisibility(v: Int) { + val isV6 = v >= 6 + modePref.isVisible = isV6 + // v4-only: obfs mode/host + obfsPref.isVisible = !isV6 + obfsHostPref.isVisible = !isV6 } - updateModeVisibility() + val cur = versionPref.value?.toIntOrNull() ?: DataStore.serverProtocolVersion + updateVisibility(cur) versionPref.setOnPreferenceChangeListener { _, newValue -> - modePref.isVisible = (newValue as? String)?.toIntOrNull()?.let { it >= 6 } == true + updateVisibility((newValue as? String)?.toIntOrNull() ?: 4) true } } diff --git a/app/src/main/res/values/snell_arrays.xml b/app/src/main/res/values/snell_arrays.xml index e28dbafd6b3..6c3818733f4 100644 --- a/app/src/main/res/values/snell_arrays.xml +++ b/app/src/main/res/values/snell_arrays.xml @@ -1,24 +1,20 @@ - 3 4 - 5 - 6 + 6 (beta) - 3 4 - 5 6 - off + none http tls - off + none http tls diff --git a/app/src/main/res/values/snell_strings.xml b/app/src/main/res/values/snell_strings.xml index 417e6cef9b6..f709422d2ca 100644 --- a/app/src/main/res/values/snell_strings.xml +++ b/app/src/main/res/values/snell_strings.xml @@ -2,8 +2,10 @@ Snell version Connection reuse - Reuse the TCP connection (ConnectV2 / v6 reuse) + Reuse the TCP connection Snell - Obfs host + Obfs mode + Obfs host (v4) Snell v6 mode + User PSK (sing-box extension) diff --git a/app/src/main/res/xml/snell_preferences.xml b/app/src/main/res/xml/snell_preferences.xml index 9a2033d2e79..648babdc047 100644 --- a/app/src/main/res/xml/snell_preferences.xml +++ b/app/src/main/res/xml/snell_preferences.xml @@ -25,6 +25,12 @@ app:key="serverPassword" app:title="@string/password" app:useSimpleSummaryProvider="true" /> +