Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@
android:name="io.nekohasekai.sagernet.ui.profile.ShadowQUICSettingsActivity" />
<activity
android:name="io.nekohasekai.sagernet.ui.profile.TrustTunnelSettingsActivity" />
<activity
android:name="io.nekohasekai.sagernet.ui.profile.SnellSettingsActivity" />
<activity
android:name="io.nekohasekai.sagernet.ui.profile.ConfigSettingsActivity" />
<activity
Expand Down
23 changes: 22 additions & 1 deletion app/src/main/java/io/nekohasekai/sagernet/database/Migrations.kt
Original file line number Diff line number Diff line change
Expand Up @@ -169,4 +169,25 @@ class SagerDatabase_Migration_33_34 : AutoMigrationSpec
columnName = "redirect"
),
)
class SagerDatabase_Migration_35_36 : AutoMigrationSpec
class SagerDatabase_Migration_35_36 : AutoMigrationSpec
/**
* Reintroduce Snell support. The snellBean column may already exist from
* SagerNet-era migration 3_4 and was never dropped.
*/
object SagerDatabase_Migration_36_37 : Migration(36, 37) {
override fun migrate(database: SupportSQLiteDatabase) {
val cursor = database.query("PRAGMA table_info(`proxy_entities`)")
var hasSnell = false
while (cursor.moveToNext()) {
val name = cursor.getString(cursor.getColumnIndex("name"))
if (name == "snellBean") {
hasSnell = true
break
}
}
cursor.close()
if (!hasSnell) {
database.execSQL("""ALTER TABLE `proxy_entities` ADD `snellBean` BLOB DEFAULT NULL""")
}
}
}
15 changes: 15 additions & 0 deletions app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ import io.nekohasekai.sagernet.fmt.ssh.SSHBean
import io.nekohasekai.sagernet.fmt.trojan.TrojanBean
import io.nekohasekai.sagernet.fmt.trojan.toUri
import io.nekohasekai.sagernet.fmt.trusttunnel.TrustTunnelBean
import io.nekohasekai.sagernet.fmt.snell.SnellBean
import io.nekohasekai.sagernet.fmt.snell.toUri
import io.nekohasekai.sagernet.ui.profile.SnellSettingsActivity
import io.nekohasekai.sagernet.fmt.trusttunnel.toUri
import io.nekohasekai.sagernet.fmt.tuic5.Tuic5Bean
import io.nekohasekai.sagernet.fmt.tuic5.toUri
Expand Down Expand Up @@ -104,6 +107,7 @@ data class ProxyEntity(
var anytlsBean: AnyTLSBean? = null,
var shadowquicBean: ShadowQUICBean? = null,
var trustTunnelBean: TrustTunnelBean? = null,
var snellBean: SnellBean? = null,
var configBean: ConfigBean? = null,
var chainBean: ChainBean? = null,
var balancerBean: BalancerBean? = null
Expand All @@ -128,6 +132,7 @@ data class ProxyEntity(
const val TYPE_ANYTLS = 27
const val TYPE_SHADOWQUIC = 28
const val TYPE_TRUSTTUNNEL = 29
const val TYPE_SNELL = 30
const val TYPE_CHAIN = 8
const val TYPE_BALANCER = 14
const val TYPE_CONFIG = 13
Expand Down Expand Up @@ -220,6 +225,7 @@ data class ProxyEntity(
TYPE_ANYTLS -> 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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -317,6 +325,7 @@ data class ProxyEntity(
is Http3Bean -> toUri()
is AnyTLSBean -> toUri()
is TrustTunnelBean -> toUri()
is SnellBean -> toUri()
is ShadowQUICBean -> toUri()
else -> null
}
Expand Down Expand Up @@ -382,6 +391,7 @@ data class ProxyEntity(
anytlsBean = null
shadowquicBean = null
trustTunnelBean = null
snellBean = null

configBean = null
chainBean = null
Expand Down Expand Up @@ -460,6 +470,10 @@ data class ProxyEntity(
type = TYPE_TRUSTTUNNEL
trustTunnelBean = bean
}
is SnellBean -> {
type = TYPE_SNELL
snellBean = bean
}

is ConfigBean -> {
type = TYPE_CONFIG
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
14 changes: 14 additions & 0 deletions app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1740,6 +1741,19 @@ fun buildV2RayConfig(
}
}
}
} else if (bean is SnellBean) {
protocol = "snell"
settings = LazyOutboundConfigurationObject(this, V2RayConfig.SnellOutboundConfigurationObject().apply {
address = bean.serverAddress
port = bean.serverPort
psk = bean.psk
userPSK = bean.userPSK
obfsMode = bean.obfsMode
obfsHost = bean.obfsHost
version = bean.version
reuse = bean.reuse
mode = bean.mode
})
} else if (bean is MieruBean) {
protocol = "mieru"
settings = LazyOutboundConfigurationObject(this,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/io/nekohasekai/sagernet/fmt/TypeMap.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ object TypeMap : HashMap<String, Int>() {
this["anytls"] = ProxyEntity.TYPE_ANYTLS
this["shadowquic"] = ProxyEntity.TYPE_SHADOWQUIC
this["trusttunnel"] = ProxyEntity.TYPE_TRUSTTUNNEL
this["snell"] = ProxyEntity.TYPE_SNELL
}

val reversed = HashMap<Int, String>()
Expand Down
124 changes: 124 additions & 0 deletions app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellBean.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/******************************************************************************
* *
* 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_4 = 4;
public static final int VERSION_6 = 6;

public static final String OBFS_NONE = "none";
public static final String OBFS_HTTP = "http";
public static final String OBFS_TLS = "tls";

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;
/** 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 (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;
if (mode == null || mode.isEmpty()) mode = MODE_DEFAULT;
}

@Override
public void serialize(ByteBufferOutput output) {
// v3: rename obfs->obfsMode, add userPSK; version 4/6 only
output.writeInt(3);
super.serialize(output);
output.writeString(psk);
output.writeString(obfsMode);
output.writeInt(version);
output.writeBoolean(reuse);
output.writeString(obfsHost);
output.writeString(mode);
output.writeString(userPSK);
}

@Override
public void deserialize(ByteBufferInput input) {
int ver = input.readInt();
super.deserialize(input);
psk = 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();
}
if (ver >= 2) {
obfsHost = input.readString();
mode = input.readString();
}
if (ver >= 3) {
userPSK = 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<SnellBean> CREATOR = new CREATOR<>() {
@NonNull
@Override
public SnellBean newInstance() {
return new SnellBean();
}

@Override
public SnellBean[] newArray(int size) {
return new SnellBean[size];
}
};
}
64 changes: 64 additions & 0 deletions app/src/main/java/io/nekohasekai/sagernet/fmt/snell/SnellFmt.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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") ?: ""
}
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("obfs-host")?.also { 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)
}
initializeDefaultValues()
}
}

fun SnellBean.toUri(): String? {
val builder = Libexclavecore.newURL("snell").apply {
setHostPort(serverAddress.ifEmpty { error("empty server address") }, serverPort)
if (!psk.isNullOrEmpty()) {
username = psk
}
if (name.isNotEmpty()) {
fragment = name
}
}
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 (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")
}
return builder.string
}
Loading