Add proxy routing and sing-box support

This commit is contained in:
Fabian Freund
2026-05-22 18:16:31 +02:00
parent 51289f1266
commit a5974617aa
262 changed files with 32003 additions and 3962 deletions
@@ -0,0 +1,9 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.cxx
@@ -0,0 +1,81 @@
group = "eu.weblibre.flutter_singbox_proxy"
version = "1.0-SNAPSHOT"
buildscript {
ext.kotlin_version = "2.3.21"
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.13.2")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
def weblibreGoMobileAar = file("../../../native/go_mobile_runtime/build/weblibre-go.aar")
def requireWebLibreGoMobileAar = {
if (!weblibreGoMobileAar.exists()) {
throw new GradleException(
"Missing combined gomobile runtime AAR: ${weblibreGoMobileAar}. " +
"Run native/go_mobile_runtime/scripts/build-android.sh from the repository root."
)
}
}
apply plugin: "com.android.library"
apply plugin: "kotlin-android"
android {
namespace = "eu.weblibre.flutter_singbox_proxy"
compileSdk = 36
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17
}
sourceSets {
main.java.srcDirs += "src/main/kotlin"
test.java.srcDirs += "src/test/kotlin"
}
defaultConfig {
minSdk = 24
}
testOptions {
unitTests.all {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed", "standardOut", "standardError"
outputs.upToDateWhen { false }
showStandardStreams = true
}
}
}
}
dependencies {
requireWebLibreGoMobileAar()
compileOnly(files(weblibreGoMobileAar))
implementation("com.squareup.okhttp3:okhttp:4.12.0")
testImplementation("org.json:json:20250517")
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.mockito:mockito-core:5.23.0")
}
@@ -0,0 +1 @@
rootProject.name = 'flutter_singbox_proxy'
@@ -0,0 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="eu.weblibre.flutter_singbox_proxy">
</manifest>
@@ -0,0 +1,33 @@
package eu.weblibre.flutter_singbox_proxy
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyApi
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyEventsApi
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyLogMessage
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeState
import io.flutter.embedding.engine.plugins.FlutterPlugin
/** Flutter plugin entry point for the sing-box proxy runtime. */
class FlutterSingboxProxyPlugin : FlutterPlugin {
private var runtimeManager: SingboxRuntimeManager? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
val eventsApi = SingboxProxyEventsApi(binding.binaryMessenger)
val manager = SingboxRuntimeManager(
context = binding.applicationContext,
onStateChanged = { state: SingboxProxyRuntimeState ->
eventsApi.onStateChanged(state) { }
},
onLogMessage = { message: SingboxProxyLogMessage ->
eventsApi.onLogMessage(message) { }
}
)
runtimeManager = manager
SingboxProxyApi.setUp(binding.binaryMessenger, manager)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
SingboxProxyApi.setUp(binding.binaryMessenger, null)
runtimeManager?.close()
runtimeManager = null
}
}
@@ -0,0 +1,485 @@
package eu.weblibre.flutter_singbox_proxy
import android.content.Context
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy
open class LibboxRuntime(
private val context: Context,
private val dohResolver: PlatformDohResolver = PlatformDohResolver(),
) {
private var setupComplete = false
private var commandServer: Any? = null
private var commandServerStarted = false
private var commandClient: Any? = null
private var logSink: ((Int, String) -> Unit)? = null
private var logClientThread: Thread? = null
// Read by the LocalDNSTransport proxy on libbox worker threads, written
// from the platform thread via setBootstrapDohUrl(). Volatile so the
// bridge sees the URL configured by the most recent start() call.
@Volatile
private var bootstrapDohUrl: String? = null
/**
* Set the DoH endpoint the platform LocalDNSTransport will use for
* bootstrap lookups. Pass null to disable the bridge (sing-box's broken
* /etc/resolv.conf path will run, which is rarely what you want on
* Android).
*/
open fun setBootstrapDohUrl(url: String?) {
bootstrapDohUrl = url?.takeIf { it.isNotBlank() }
}
open fun isAvailable(): Boolean = runCatching {
Class.forName(LIBBOX_CLASS)
}.isSuccess
/**
* Register a callback that receives every log message emitted by sing-box
* (level: int, message: String). Pass null to clear. The callback is
* invoked from a background thread; the receiver must be thread-safe.
*/
@Synchronized
open fun setLogSink(sink: ((Int, String) -> Unit)?) {
logSink = sink
if (sink == null) {
disconnectLogClient()
} else if (commandServer != null) {
ensureLogClientConnected()
}
}
@Synchronized
open fun start(configJson: String) {
ensureSetup()
val server = commandServer ?: newCommandServer().also { commandServer = it }
ensureCommandServerStarted(server)
val overrideOptions = newInstance(OVERRIDE_OPTIONS_CLASS)
invoke(overrideOptions, "setAutoRedirect", false)
invoke(overrideOptions, "setIncludePackage", emptyStringIterator())
invoke(overrideOptions, "setExcludePackage", emptyStringIterator())
invoke(server, "startOrReloadService", configJson, overrideOptions)
if (logSink != null) {
ensureLogClientConnected()
}
}
@Synchronized
open fun stopService() {
commandServer?.let { server ->
runCatching { invoke(server, "closeService") }
}
disconnectLogClient()
}
@Synchronized
open fun close() {
disconnectLogClient()
commandServer?.let { server ->
runCatching { invoke(server, "close") }
}
commandServer = null
commandServerStarted = false
}
private fun ensureSetup() {
if (setupComplete) return
val baseDir = context.filesDir.resolve("singbox_proxy")
val workingDir = baseDir.resolve("working")
val tempDir = baseDir.resolve("tmp")
workingDir.mkdirs()
tempDir.mkdirs()
val options = newInstance(SETUP_OPTIONS_CLASS)
invoke(options, "setBasePath", baseDir.absolutePath)
invoke(options, "setWorkingPath", workingDir.absolutePath)
invoke(options, "setTempPath", tempDir.absolutePath)
invoke(options, "setFixAndroidStack", true)
invoke(options, "setCommandServerListenPort", 0)
invoke(options, "setCommandServerSecret", "")
invoke(options, "setLogMaxLines", 300L)
invoke(options, "setDebug", false)
invokeIfAvailable(options, "setCrashReportSource", "flutter_singbox_proxy")
// sing-box renamed the OOM killer toggle between releases; only one of
// these exists on the linked libbox AAR, so call whichever responds.
invokeFirstAvailable(
options,
methodNames = listOf("setOomKillerDisabled", "setOomKillerEnabled"),
args = arrayOf(true),
)
invokeIfAvailable(options, "setOomMemoryLimit", 0L)
val libbox = Class.forName(LIBBOX_CLASS)
libbox.getMethod("setup", Class.forName(SETUP_OPTIONS_CLASS)).invoke(null, options)
setupComplete = true
}
private fun newCommandServer(): Any {
val handlerInterface = Class.forName(COMMAND_SERVER_HANDLER_CLASS)
val platformInterface = Class.forName(PLATFORM_INTERFACE_CLASS)
val handler = Proxy.newProxyInstance(
handlerInterface.classLoader,
arrayOf(handlerInterface),
commandServerHandler()
)
val platform = Proxy.newProxyInstance(
platformInterface.classLoader,
arrayOf(platformInterface),
platformHandler()
)
return Class.forName(COMMAND_SERVER_CLASS)
.getConstructor(handlerInterface, platformInterface)
.newInstance(handler, platform)
}
private fun ensureCommandServerStarted(server: Any) {
if (commandServerStarted) return
invoke(server, "start")
commandServerStarted = true
}
private fun commandServerHandler(): InvocationHandler {
return InvocationHandler { _, method, args ->
when (method.name) {
"getSystemProxyStatus" -> newInstance(SYSTEM_PROXY_STATUS_CLASS).also { status ->
invoke(status, "setAvailable", false)
invoke(status, "setEnabled", false)
}
"serviceReload", "serviceStop", "setSystemProxyEnabled", "writeDebugMessage" -> null
"triggerNativeCrash" -> throw UnsupportedOperationException("Native crash trigger is disabled")
else -> defaultValue(method.returnType, args)
}
}
}
private fun platformHandler(): InvocationHandler {
return InvocationHandler { _, method, args ->
when (method.name) {
"autoDetectInterfaceControl",
"clearDNSCache",
"closeDefaultInterfaceMonitor",
"closeNeighborMonitor",
"registerMyInterface",
"sendNotification",
"startDefaultInterfaceMonitor",
"startNeighborMonitor" -> null
"findConnectionOwner" -> newInstance(CONNECTION_OWNER_CLASS).also { owner ->
invoke(owner, "setUserId", -1)
invoke(owner, "setUserName", "")
invoke(owner, "setProcessPath", "")
invoke(owner, "setAndroidPackageNames", emptyStringIterator())
}
"getInterfaces" -> emptyIterator(NETWORK_INTERFACE_ITERATOR_CLASS)
"includeAllNetworks",
"underNetworkExtension",
"usePlatformAutoDetectInterfaceControl",
"useProcFS" -> false
"localDNSTransport" -> createLocalDnsTransport()
"openTun" -> throw UnsupportedOperationException("TUN is not supported by WebLibre proxy routing")
"readWIFIState" -> Class.forName(WIFI_STATE_CLASS)
.getConstructor(String::class.java, String::class.java)
.newInstance("", "")
"systemCertificates" -> emptyStringIterator()
else -> defaultValue(method.returnType, args)
}
}
}
private fun ensureLogClientConnected() {
if (commandClient != null) return
val handlerInterface = runCatching {
Class.forName(COMMAND_CLIENT_HANDLER_CLASS)
}.getOrNull() ?: return
val optionsClass = runCatching {
Class.forName(COMMAND_CLIENT_OPTIONS_CLASS)
}.getOrNull() ?: return
val clientClass = runCatching {
Class.forName(COMMAND_CLIENT_CLASS)
}.getOrNull() ?: return
val handler = Proxy.newProxyInstance(
handlerInterface.classLoader,
arrayOf(handlerInterface),
commandClientHandler()
)
val options = optionsClass.getConstructor().newInstance()
// Subscribe to the log stream (CommandLog == 0 in sing-box/libbox).
invoke(options, "addCommand", 0)
// Subscribe to connection events (CommandConnections == 4). Some
// transports, including WireGuard endpoint routing, don't emit useful
// per-connection lines through the regular log stream.
invoke(options, "addCommand", 4)
val client = clientClass
.getConstructor(handlerInterface, optionsClass)
.newInstance(handler, options)
commandClient = client
// Connect dials the local command socket with retries; do it off the
// platform thread so we don't block start().
val thread = Thread({
runCatching { invoke(client, "connect") }
.onFailure { error ->
logSink?.invoke(3, "sing-box log stream connection failed: ${error.message}")
}
}, "singbox-log-client")
thread.isDaemon = true
thread.start()
logClientThread = thread
}
private fun disconnectLogClient() {
val client = commandClient ?: return
commandClient = null
runCatching { invoke(client, "disconnect") }
logClientThread = null
}
private fun commandClientHandler(): InvocationHandler {
return InvocationHandler { _, method, args ->
when (method.name) {
"writeLogs" -> {
val iterator = args?.firstOrNull()
if (iterator != null) {
forwardLogIterator(iterator)
}
null
}
"clearLogs",
"connected",
"disconnected",
"setDefaultLogLevel",
"writeStatus",
"writeGroups",
"writeOutbounds",
"initializeClashMode",
"updateClashMode" -> null
"writeConnectionEvents" -> {
val events = args?.firstOrNull()
if (events != null) {
forwardConnectionEvents(events)
}
null
}
else -> defaultValue(method.returnType, args)
}
}
}
private fun forwardLogIterator(iterator: Any) {
val sink = logSink ?: return
runCatching {
while (invoke(iterator, "hasNext") as? Boolean == true) {
val entry = invoke(iterator, "next") ?: continue
val level = (runCatching { invoke(entry, "getLevel") }.getOrNull() as? Number)
?.toInt() ?: 0
val message = runCatching { invoke(entry, "getMessage") }
.getOrNull() as? String ?: continue
sink(level, message)
}
}
}
private fun forwardConnectionEvents(events: Any) {
val sink = logSink ?: return
runCatching {
val iterator = invokeFirstAvailableResult(events, listOf("iterator", "Iterator")) ?: return
while (invoke(iterator, "hasNext") as? Boolean == true) {
val event = invoke(iterator, "next") ?: continue
val type = (invokeFirstAvailableResult(event, listOf("getType", "type")) as? Number)
?.toInt() ?: continue
if (type != CONNECTION_EVENT_NEW && type != CONNECTION_EVENT_CLOSED) continue
val connection = invokeFirstAvailableResult(
event,
listOf("getConnection", "connection"),
) ?: continue
val eventLabel = if (type == CONNECTION_EVENT_CLOSED) "closed" else "opened"
sink(4, "connection $eventLabel ${describeConnection(connection)}")
}
}
}
private fun describeConnection(connection: Any): String {
val network = stringValue(connection, "getNetwork", "network")
val source = stringValue(connection, "getSource", "source")
val destination = stringValue(
connection,
"displayDestination",
"DisplayDestination",
"getDestination",
"destination",
)
val outbound = stringValue(connection, "getOutbound", "outbound")
val inbound = stringValue(connection, "getInbound", "inbound")
return buildString {
if (network.isNotBlank()) append(network).append(' ')
if (source.isNotBlank()) append(source).append(" -> ")
append(destination.ifBlank { "unknown destination" })
if (outbound.isNotBlank()) append(" via ").append(outbound)
if (inbound.isNotBlank()) append(" (").append(inbound).append(')')
}
}
private fun stringValue(target: Any, vararg methodNames: String): String {
return invokeFirstAvailableResult(target, methodNames.toList()) as? String ?: ""
}
private fun createLocalDnsTransport(): Any? {
val iface = runCatching {
Class.forName(LOCAL_DNS_TRANSPORT_CLASS)
}.getOrNull() ?: return null
return Proxy.newProxyInstance(
iface.classLoader,
arrayOf(iface),
) { _, method, args ->
when (method.name) {
"raw" -> true
"exchange" -> {
val ctx = args?.getOrNull(0)
val request = args?.getOrNull(1) as? ByteArray
if (ctx != null && request != null) {
runDohExchange(ctx, request)
}
null
}
// Lookup is only reachable when raw() returns false. We
// always return true above, so this path is dead.
"lookup" -> null
else -> defaultValue(method.returnType, args)
}
}
}
private fun runDohExchange(ctx: Any, request: ByteArray) {
val url = bootstrapDohUrl
if (url == null) {
// No bootstrap URL configured — return SERVFAIL so sing-box gets a
// clean failure instead of hanging on a half-initialized bridge.
invokeIfAvailable(ctx, "errorCode", DNS_RCODE_SERVFAIL)
return
}
try {
val response = dohResolver.exchange(url, request)
invokeIfAvailable(ctx, "rawSuccess", response)
} catch (error: Throwable) {
logSink?.invoke(3, "DoH bootstrap exchange failed: ${error.message}")
invokeIfAvailable(ctx, "errorCode", DNS_RCODE_SERVFAIL)
}
}
private fun emptyStringIterator(): Any = emptyIterator(STRING_ITERATOR_CLASS)
private fun emptyIterator(interfaceName: String): Any {
val iteratorInterface = Class.forName(interfaceName)
return Proxy.newProxyInstance(
iteratorInterface.classLoader,
arrayOf(iteratorInterface),
) { _, method, _ ->
when (method.name) {
"hasNext" -> false
"len" -> 0
"next" -> null
else -> defaultValue(method.returnType)
}
}
}
private fun newInstance(className: String): Any {
return Class.forName(className).getConstructor().newInstance()
}
private fun invoke(target: Any, methodName: String, vararg args: Any?): Any? {
val method = findMethod(target.javaClass, methodName, args.size)
return method.invoke(target, *args)
}
private fun invokeIfAvailable(target: Any, methodName: String, vararg args: Any?) {
val method = target.javaClass.methods.firstOrNull { candidate ->
candidate.name == methodName && candidate.parameterTypes.size == args.size
}
method?.invoke(target, *args)
}
private fun invokeFirstAvailable(
target: Any,
methodNames: List<String>,
args: Array<Any?>,
) {
for (name in methodNames) {
val method = target.javaClass.methods.firstOrNull { candidate ->
candidate.name == name && candidate.parameterTypes.size == args.size
}
if (method != null) {
method.invoke(target, *args)
return
}
}
}
private fun invokeFirstAvailableResult(
target: Any,
methodNames: List<String>,
vararg args: Any?,
): Any? {
for (name in methodNames) {
val method = target.javaClass.methods.firstOrNull { candidate ->
candidate.name == name && candidate.parameterTypes.size == args.size
}
if (method != null) {
return method.invoke(target, *args)
}
}
return null
}
private fun findMethod(clazz: Class<*>, methodName: String, argCount: Int): Method {
return clazz.methods.firstOrNull { method ->
method.name == methodName && method.parameterTypes.size == argCount
} ?: throw NoSuchMethodError(
"${clazz.name}.$methodName($argCount args) is missing — linked libbox AAR " +
"may be incompatible. Available '${methodName}' overloads: " +
clazz.methods
.filter { it.name == methodName }
.joinToString { "${it.name}(${it.parameterTypes.joinToString { p -> p.simpleName }})" }
.ifEmpty { "<none>" }
)
}
private fun defaultValue(returnType: Class<*>, args: Array<Any?>? = null): Any? {
return when (returnType) {
java.lang.Boolean.TYPE -> false
java.lang.Integer.TYPE -> 0
java.lang.Long.TYPE -> 0L
java.lang.Float.TYPE -> 0f
java.lang.Double.TYPE -> 0.0
java.lang.Void.TYPE -> null
else -> args?.firstOrNull()
}
}
private companion object {
const val LIBBOX_CLASS = "io.nekohasekai.libbox.Libbox"
const val SETUP_OPTIONS_CLASS = "io.nekohasekai.libbox.SetupOptions"
const val COMMAND_SERVER_CLASS = "io.nekohasekai.libbox.CommandServer"
const val COMMAND_SERVER_HANDLER_CLASS = "io.nekohasekai.libbox.CommandServerHandler"
const val PLATFORM_INTERFACE_CLASS = "io.nekohasekai.libbox.PlatformInterface"
const val LOCAL_DNS_TRANSPORT_CLASS = "io.nekohasekai.libbox.LocalDNSTransport"
const val DNS_RCODE_SERVFAIL = 2
const val OVERRIDE_OPTIONS_CLASS = "io.nekohasekai.libbox.OverrideOptions"
const val STRING_ITERATOR_CLASS = "io.nekohasekai.libbox.StringIterator"
const val NETWORK_INTERFACE_ITERATOR_CLASS = "io.nekohasekai.libbox.NetworkInterfaceIterator"
const val CONNECTION_OWNER_CLASS = "io.nekohasekai.libbox.ConnectionOwner"
const val SYSTEM_PROXY_STATUS_CLASS = "io.nekohasekai.libbox.SystemProxyStatus"
const val WIFI_STATE_CLASS = "io.nekohasekai.libbox.WIFIState"
const val COMMAND_CLIENT_CLASS = "io.nekohasekai.libbox.CommandClient"
const val COMMAND_CLIENT_HANDLER_CLASS = "io.nekohasekai.libbox.CommandClientHandler"
const val COMMAND_CLIENT_OPTIONS_CLASS = "io.nekohasekai.libbox.CommandClientOptions"
const val CONNECTION_EVENT_NEW = 0
const val CONNECTION_EVENT_CLOSED = 2
}
}
@@ -0,0 +1,75 @@
package eu.weblibre.flutter_singbox_proxy
import java.io.IOException
import java.util.concurrent.TimeUnit
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* RFC 8484 DoH client used as the bootstrap resolver for sing-box's
* `type: "local"` DNS transport.
*
* Sing-box calls the platform LocalDNSTransport whenever a DNS server's own
* hostname (or any other hostname referenced by `domain_resolver` /
* `default_domain_resolver`) needs resolving. We bounce the wire-format query
* straight to a configured DoH endpoint so no query ever hits Android's
* system resolver.
*
* The DoH endpoint's own hostname is resolved exactly once by the JVM HTTP
* stack. Configure the URL with an IP literal (e.g. `https://1.1.1.1/dns-query`)
* if even that one lookup must not leak.
*/
class PlatformDohResolver(
private val connectTimeoutMillis: Int = 5_000,
private val readTimeoutMillis: Int = 5_000,
) {
private val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(connectTimeoutMillis.toLong(), TimeUnit.MILLISECONDS)
.readTimeout(readTimeoutMillis.toLong(), TimeUnit.MILLISECONDS)
.build()
/** Send [request] as a DoH POST and return the raw DNS wire-format reply. */
@Throws(IOException::class)
fun exchange(url: String, request: ByteArray): ByteArray {
val httpRequest = Request.Builder()
.url(url)
.header("Accept", DNS_MESSAGE_MIME)
.post(request.toRequestBody(DNS_MESSAGE_MEDIA_TYPE))
.build()
client.newCall(httpRequest).execute().use { response ->
if (!response.isSuccessful) {
throw IOException(
"DoH endpoint returned HTTP ${response.code} via ${response.protocol}"
)
}
val contentType = response.header("Content-Type").orEmpty()
if (!contentType.startsWith(DNS_MESSAGE_MIME, ignoreCase = true)) {
throw IOException(
"DoH endpoint returned unexpected Content-Type: $contentType"
)
}
val body = response.body ?: throw IOException("DoH endpoint returned no body")
val contentLength = body.contentLength()
if (contentLength > MAX_DNS_RESPONSE) {
throw IOException("DoH response exceeds maximum DNS message size")
}
return body.bytes().also { bytes ->
if (bytes.size > MAX_DNS_RESPONSE) {
throw IOException("DoH response exceeds maximum DNS message size")
}
}
}
}
private companion object {
const val DNS_MESSAGE_MIME = "application/dns-message"
val DNS_MESSAGE_MEDIA_TYPE = DNS_MESSAGE_MIME.toMediaType()
// EDNS0 typically caps responses at 4 KiB; we allow a little slack.
const val MAX_DNS_RESPONSE = 8 * 1024
}
}
@@ -0,0 +1,398 @@
package eu.weblibre.flutter_singbox_proxy
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyConfigResult
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyDnsConfig
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyDnsServerConfig
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfile
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfileType
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeEndpoint
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeOptions
import java.net.URI
import java.security.SecureRandom
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
private const val LOCALHOST = "127.0.0.1"
private const val DEFAULT_BASE_PORT = 12000L
private const val BASE64_URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
/**
* Tag of the always-emitted `type: "local"` DNS server. Hooked at runtime by
* our LocalDNSTransport bridge (PlatformDohResolver), so every hostname in
* the config — including DoH endpoint hostnames and WireGuard peer
* hostnames — resolves through DoH instead of `/etc/resolv.conf`.
*/
private const val DNS_BOOTSTRAP_TAG = "local"
class SingboxConfigBuilder(
private val random: SecureRandom = SecureRandom()
) {
fun validateProfile(profile: SingboxProxyProfile): String? {
if (profile.id.isBlank()) return "Profile id is required."
if (profile.name.isBlank()) return "Profile name is required."
val outbound = try {
buildOutbound(profile)
} catch (error: JSONException) {
return "Invalid outbound JSON: ${error.message}"
} catch (error: IllegalArgumentException) {
return error.message
}
val expectedType = expectedOutboundType(profile.type)
val actualType = outbound.optString("type")
if (expectedType != null && actualType != expectedType) {
return "Profile type ${profile.type.name} requires sing-box outbound type '$expectedType'."
}
return null
}
fun build(
profiles: List<SingboxProxyProfile>,
options: SingboxProxyRuntimeOptions
): SingboxProxyConfigResult {
profiles.forEach { profile ->
validateProfile(profile)?.let { throw IllegalArgumentException(it) }
}
val inbounds = JSONArray()
val endpointsJson = JSONArray()
val outbounds = JSONArray()
val rules = JSONArray()
val endpoints = mutableListOf<SingboxProxyRuntimeEndpoint>()
val basePort = options.preferredBasePort ?: DEFAULT_BASE_PORT
profiles.forEachIndexed { index, profile ->
val inboundTag = inboundTag(profile.id)
val outboundTag = outboundTag(profile.id)
val port = basePort + index
val username = generateToken("u")
val password = generateToken("p")
inbounds.put(JSONObject().apply {
put("type", "socks")
put("tag", inboundTag)
put("listen", LOCALHOST)
put("listen_port", port)
put("users", JSONArray().put(JSONObject().apply {
put("username", username)
put("password", password)
}))
})
if (profile.type == SingboxProxyProfileType.WIREGUARD) {
endpointsJson.put(buildWireGuardEndpoint(profile, outboundTag))
} else {
outbounds.put(buildOutbound(profile).apply {
put("tag", outboundTag)
})
}
rules.put(JSONObject().apply {
put("inbound", JSONArray().put(inboundTag))
put("action", "route")
put("outbound", outboundTag)
})
endpoints += SingboxProxyRuntimeEndpoint(
profileId = profile.id,
host = LOCALHOST,
port = port,
username = username,
password = password
)
}
val finalOutbound = if (options.blockUnmatchedTraffic) "block" else "direct"
outbounds.put(JSONObject().apply {
put("type", finalOutbound)
put("tag", finalOutbound)
})
// Always expose a `direct` outbound even when blockUnmatchedTraffic =
// true so internal bootstrap/fallback paths still have a direct route.
if (finalOutbound != "direct") {
outbounds.put(JSONObject().apply {
put("type", "direct")
put("tag", "direct")
})
}
val dnsBlock = options.dnsConfig?.let(::buildDnsBlock)
if (dnsBlock != null && options.bootstrapDohUrl.isNullOrBlank()) {
throw IllegalArgumentException(
"bootstrapDohUrl is required when dnsConfig is provided."
)
}
val config = JSONObject().apply {
put("log", JSONObject().apply { put("level", "info") })
put("inbounds", inbounds)
if (endpointsJson.length() > 0) {
put("endpoints", endpointsJson)
}
put("outbounds", outbounds)
put("route", JSONObject().apply {
put("rules", rules)
put("final", finalOutbound)
// When DNS is configured, route everything's hostname
// resolution through the platform LocalDNSTransport bridge so
// WireGuard peer hostnames and any other outbound-dialer
// hostname go through DoH, not /etc/resolv.conf.
if (dnsBlock != null) {
put("default_domain_resolver", DNS_BOOTSTRAP_TAG)
}
})
dnsBlock?.let { put("dns", it) }
}
return SingboxProxyConfigResult(
configJson = config.toString(2),
endpoints = endpoints
)
}
private fun buildDnsBlock(dns: SingboxProxyDnsConfig): JSONObject? {
if (dns.servers.isEmpty()) return null
val serversJson = JSONArray()
val rulesJson = JSONArray()
// Always emit the platform `local` server first. It is the foundation
// every other server's `domain_resolver` (and the route's
// default_domain_resolver) points at, and the LocalDNSTransport
// bridge backs it with DoH at runtime.
serversJson.put(JSONObject().apply {
put("type", "local")
put("tag", DNS_BOOTSTRAP_TAG)
})
for (server in dns.servers) {
serversJson.put(buildDnsServer(server))
if (server.matchDomainSuffixes.isNotEmpty() ||
server.matchInbounds.isNotEmpty()
) {
rulesJson.put(JSONObject().apply {
if (server.matchDomainSuffixes.isNotEmpty()) {
put(
"domain_suffix",
JSONArray(server.matchDomainSuffixes)
)
}
if (server.matchInbounds.isNotEmpty()) {
put("inbound", JSONArray(server.matchInbounds))
}
put("action", "route")
put("server", server.tag)
})
}
}
return JSONObject().apply {
put("servers", serversJson)
if (rulesJson.length() > 0) {
put("rules", rulesJson)
}
if (dns.domainStrategy.isNotBlank()) {
put("strategy", dns.domainStrategy)
}
val finalTag = dns.finalServerTag ?: DNS_BOOTSTRAP_TAG
put("final", finalTag)
}
}
private fun buildDnsServer(server: SingboxProxyDnsServerConfig): JSONObject {
val parsed = parseDnsAddress(server.address)
return JSONObject().apply {
put("type", parsed.type)
put("tag", server.tag)
parsed.server?.let { put("server", it) }
parsed.serverPort?.let { put("server_port", it) }
parsed.path?.let { put("path", it) }
server.detourTag?.takeUnless { it == "direct" }?.let { put("detour", it) }
// Hostname targets always bootstrap through `local`; sing-box
// ignores `domain_resolver` for IP-literal servers, so emitting
// it unconditionally is fine and keeps the JSON uniform.
if (parsed.server != null && !parsed.serverIsIpLiteral) {
put("domain_resolver", DNS_BOOTSTRAP_TAG)
}
}
}
private fun parseDnsAddress(address: String): ParsedDnsAddress {
val trimmed = address.trim()
if (trimmed == "local") {
return ParsedDnsAddress(type = "local")
}
val uri = if (trimmed.contains("://")) URI(trimmed) else null
val scheme = uri?.scheme?.lowercase()
return when (scheme) {
null -> parseHostPort(trimmed, "udp", 53)
"udp" -> parseUriHostPort(uri!!, "udp", 53)
"tcp" -> parseUriHostPort(uri!!, "tcp", 53)
"tls" -> parseUriHostPort(uri!!, "tls", 853)
"quic" -> parseUriHostPort(uri!!, "quic", 853)
"https", "h3" -> parseUriHostPort(uri, scheme, 443).copy(
path = uri.path.takeUnless { it.isNullOrBlank() || it == "/dns-query" }
)
else -> throw IllegalArgumentException("Unsupported DNS server scheme: $scheme")
}
}
private fun parseUriHostPort(uri: URI, type: String, defaultPort: Int): ParsedDnsAddress {
val host = uri.host ?: throw IllegalArgumentException("Invalid DNS server address")
val bare = host.removePrefix("[").removeSuffix("]")
return ParsedDnsAddress(
type = type,
server = bare,
serverPort = uri.port.takeIf { it >= 0 && it != defaultPort },
serverIsIpLiteral = isIpLiteral(bare),
)
}
private fun parseHostPort(value: String, type: String, defaultPort: Int): ParsedDnsAddress {
val trimmed = value.trim()
if (trimmed.isBlank()) throw IllegalArgumentException("DNS server address is required")
val splitPort = trimmed.lastIndexOf(':')
val hasSingleColon = splitPort > 0 && trimmed.indexOf(':') == splitPort
val host = if (hasSingleColon) trimmed.substring(0, splitPort) else trimmed
val port = if (hasSingleColon) trimmed.substring(splitPort + 1).toIntOrNull() else null
val bare = host.removePrefix("[").removeSuffix("]")
return ParsedDnsAddress(
type = type,
server = bare,
serverPort = port?.takeUnless { it == defaultPort },
serverIsIpLiteral = isIpLiteral(bare),
)
}
private fun isIpLiteral(host: String): Boolean {
if (host.matches(Regex("^\\d{1,3}(\\.\\d{1,3}){3}$"))) return true
if (host.contains(":") && host.matches(Regex("^[0-9A-Fa-f:.]+$"))) return true
return false
}
private data class ParsedDnsAddress(
val type: String,
val server: String? = null,
val serverPort: Int? = null,
val path: String? = null,
val serverIsIpLiteral: Boolean = false,
)
private fun buildOutbound(profile: SingboxProxyProfile): JSONObject {
val outbound = JSONObject(profile.configJson)
profile.secretJson?.takeIf { it.isNotBlank() }?.let { secretJson ->
deepMerge(outbound, JSONObject(secretJson))
}
expectedOutboundType(profile.type)?.let { expectedType ->
val actualType = outbound.optString("type")
if (actualType.isBlank()) {
outbound.put("type", expectedType)
}
}
return outbound
}
private fun buildWireGuardEndpoint(profile: SingboxProxyProfile, tag: String): JSONObject {
val endpoint = buildOutbound(profile)
endpoint.put("tag", tag)
endpoint.remove("server")?.let { server ->
val peer = JSONObject().apply {
put("address", server)
endpoint.remove("server_port")?.let { put("port", it) }
endpoint.remove("peer_public_key")?.let { put("public_key", it) }
endpoint.remove("pre_shared_key")?.let { put("pre_shared_key", it) }
endpoint.remove("reserved")?.let { put("reserved", it) }
endpoint.remove("persistent_keepalive_interval")?.let {
put("persistent_keepalive_interval", it)
}
put("allowed_ips", endpoint.remove("allowed_ips") ?: JSONArray().apply {
put("0.0.0.0/0")
put("::/0")
})
}
endpoint.put("peers", JSONArray().put(peer))
}
endpoint.remove("local_address")?.let { endpoint.put("address", it) }
endpoint.remove("system_interface")?.let { endpoint.put("system", it) }
endpoint.remove("interface_name")?.let { endpoint.put("name", it) }
endpoint.remove("gso")
return endpoint
}
private fun deepMerge(target: JSONObject, source: JSONObject) {
val keys = source.keys()
while (keys.hasNext()) {
val key = keys.next()
val value = source.get(key)
if (value is JSONObject && target.opt(key) is JSONObject) {
deepMerge(target.getJSONObject(key), value)
} else {
target.put(key, value)
}
}
}
private fun generateToken(prefix: String): String {
val bytes = ByteArray(18)
random.nextBytes(bytes)
return prefix + base64UrlNoPadding(bytes)
}
private fun base64UrlNoPadding(bytes: ByteArray): String {
val output = StringBuilder((bytes.size * 4 + 2) / 3)
var index = 0
while (index < bytes.size) {
val b0 = bytes[index++].toInt() and 0xff
val b1 = if (index < bytes.size) bytes[index++].toInt() and 0xff else -1
val b2 = if (index < bytes.size) bytes[index++].toInt() and 0xff else -1
output.append(BASE64_URL_ALPHABET[b0 ushr 2])
if (b1 < 0) {
output.append(BASE64_URL_ALPHABET[(b0 and 0x03) shl 4])
} else {
output.append(BASE64_URL_ALPHABET[((b0 and 0x03) shl 4) or (b1 ushr 4)])
if (b2 < 0) {
output.append(BASE64_URL_ALPHABET[(b1 and 0x0f) shl 2])
} else {
output.append(BASE64_URL_ALPHABET[((b1 and 0x0f) shl 2) or (b2 ushr 6)])
output.append(BASE64_URL_ALPHABET[b2 and 0x3f])
}
}
}
return output.toString()
}
private fun inboundTag(profileId: String) = SingboxTagFormat.inboundTag(profileId)
private fun outboundTag(profileId: String) = SingboxTagFormat.outboundTag(profileId)
private fun expectedOutboundType(type: SingboxProxyProfileType): String? = when (type) {
SingboxProxyProfileType.SOCKS -> "socks"
SingboxProxyProfileType.HTTP -> "http"
SingboxProxyProfileType.SHADOWSOCKS -> "shadowsocks"
SingboxProxyProfileType.VMESS -> "vmess"
SingboxProxyProfileType.VLESS -> "vless"
SingboxProxyProfileType.TROJAN -> "trojan"
SingboxProxyProfileType.NAIVE -> "naive"
SingboxProxyProfileType.HYSTERIA -> "hysteria"
SingboxProxyProfileType.HYSTERIA2 -> "hysteria2"
SingboxProxyProfileType.TUIC -> "tuic"
SingboxProxyProfileType.SSH -> "ssh"
SingboxProxyProfileType.WIREGUARD -> "wireguard"
SingboxProxyProfileType.SHADOW_TLS -> "shadowtls"
SingboxProxyProfileType.ANY_TLS -> "anytls"
SingboxProxyProfileType.CUSTOM_OUTBOUND -> null
}
}
@@ -0,0 +1,286 @@
package eu.weblibre.flutter_singbox_proxy
import android.content.Context
import android.os.Handler
import android.os.Looper
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyApi
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyConfigResult
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyLogMessage
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfile
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeOptions
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeEndpoint
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeState
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeStatus
import java.util.concurrent.Executors
class SingboxRuntimeManager(
context: Context,
private val configBuilder: SingboxConfigBuilder = SingboxConfigBuilder(),
private val libboxRuntime: LibboxRuntime = LibboxRuntime(context),
private val onStateChanged: (SingboxProxyRuntimeState) -> Unit = {},
private val onLogMessage: (SingboxProxyLogMessage) -> Unit = {},
private val dispatchToMain: ((() -> Unit) -> Unit) = { action ->
if (Looper.myLooper() == Looper.getMainLooper()) {
action()
} else {
Handler(Looper.getMainLooper()).post(action)
}
}
) : SingboxProxyApi {
// Pigeon dispatches Dart-side calls on the platform thread, but libbox
// callbacks fire from native threads. Guard all state transitions with
// a single lock so concurrent stop / start / event paths cannot tear
// activeProfiles, activeOptions, or `state` against each other.
private val stateLock = Any()
private val runtimeExecutor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "singbox-runtime").apply { isDaemon = true }
}
private var state = SingboxProxyRuntimeState(
status = SingboxProxyRuntimeStatus.STOPPED,
endpoints = emptyList(),
message = null
)
private var activeProfiles = emptyList<SingboxProxyProfile>()
private var activeOptions = SingboxProxyRuntimeOptions(
preferredBasePort = null,
blockUnmatchedTraffic = true
)
init {
// Forward every sing-box log entry to Dart. libbox levels follow
// sing/common/logger: 0 = panic, 1 = fatal, 2 = error, 3 = warn,
// 4 = info, 5 = debug, 6 = trace.
libboxRuntime.setLogSink { level, message ->
emitLogMessage(level, message)
}
}
private fun libboxLogLevelName(level: Int): String = when (level) {
0 -> "panic"
1 -> "fatal"
2 -> "error"
3 -> "warn"
4 -> "info"
5 -> "debug"
6 -> "trace"
else -> "info"
}
private fun emitLogMessage(level: Int, message: String) {
emitLogMessage(
SingboxProxyLogMessage(
level = libboxLogLevelName(level),
message = message,
timestamp = System.currentTimeMillis(),
profileId = null
)
)
}
private fun emitLogMessage(logMessage: SingboxProxyLogMessage) {
dispatchToMain {
onLogMessage(logMessage)
}
}
private fun emitStateChanged(nextState: SingboxProxyRuntimeState) {
dispatchToMain {
onStateChanged(nextState)
}
}
private fun statusLogMessage(
status: SingboxProxyRuntimeStatus,
message: String
) = SingboxProxyLogMessage(
level = if (status == SingboxProxyRuntimeStatus.ERROR) "warn" else "info",
message = message,
timestamp = System.currentTimeMillis(),
profileId = null
)
override fun validateProfile(
profile: SingboxProxyProfile,
callback: (Result<String?>) -> Unit
) {
callback(Result.success(configBuilder.validateProfile(profile)))
}
override fun buildConfig(
profiles: List<SingboxProxyProfile>,
options: SingboxProxyRuntimeOptions,
callback: (Result<SingboxProxyConfigResult>) -> Unit
) {
runCatching { configBuilder.build(profiles, options) }
.onSuccess { callback(Result.success(it)) }
.onFailure { callback(Result.failure(it)) }
}
override fun start(
profiles: List<SingboxProxyProfile>,
options: SingboxProxyRuntimeOptions,
callback: (Result<SingboxProxyRuntimeState>) -> Unit
) {
runtimeExecutor.execute {
val result = synchronized(stateLock) {
val previousState = state
runCatching {
updateStateLocked(
SingboxProxyRuntimeStatus.STARTING,
emptyList(),
"Building sing-box config"
)
val config = configBuilder.build(profiles, options)
if (!libboxRuntime.isAvailable()) {
val message = "sing-box libbox runtime is not linked"
updateStateLocked(
SingboxProxyRuntimeStatus.ERROR,
emptyList(),
message
)
throw IllegalStateException(message)
}
val previousBootstrapDohUrl = activeOptions.bootstrapDohUrl
libboxRuntime.setBootstrapDohUrl(options.bootstrapDohUrl)
try {
libboxRuntime.start(config.configJson)
} catch (error: Throwable) {
libboxRuntime.setBootstrapDohUrl(previousBootstrapDohUrl)
throw error
}
// Only commit profiles/options after start() returns without
// throwing, so a failed start leaves the previous active set
// intact rather than half-replaced.
activeProfiles = profiles
activeOptions = options
updateStateLocked(
SingboxProxyRuntimeStatus.RUNNING,
config.endpoints,
null
)
state
}.onFailure { error ->
updateStateLocked(
SingboxProxyRuntimeStatus.ERROR,
previousState.endpoints,
error.message ?: error::class.java.simpleName
)
}
}
dispatchToMain { callback(result) }
}
}
override fun stop(profileIds: List<String>, callback: (Result<Unit>) -> Unit) {
runtimeExecutor.execute {
val result = synchronized(stateLock) {
runCatching {
val remaining = activeProfiles.filterNot { profile ->
profile.id in profileIds
}
if (remaining.isEmpty()) {
libboxRuntime.stopService()
activeProfiles = emptyList()
activeOptions = defaultRuntimeOptions()
libboxRuntime.setBootstrapDohUrl(null)
updateStateLocked(
SingboxProxyRuntimeStatus.STOPPED,
emptyList(),
null
)
} else {
val config = configBuilder.build(remaining, activeOptions)
// Partial stop keeps activeOptions.bootstrapDohUrl, so
// no setBootstrapDohUrl call is needed here — the
// libbox bridge already holds the right URL from the
// most recent start().
libboxRuntime.start(config.configJson)
activeProfiles = remaining
updateStateLocked(
SingboxProxyRuntimeStatus.RUNNING,
config.endpoints,
null
)
}
}.onFailure { error ->
updateStateLocked(
SingboxProxyRuntimeStatus.ERROR,
state.endpoints,
error.message ?: error::class.java.simpleName
)
}
}
dispatchToMain { callback(result) }
}
}
override fun stopAll(callback: (Result<Unit>) -> Unit) {
runtimeExecutor.execute {
val result = synchronized(stateLock) {
runCatching {
libboxRuntime.stopService()
activeProfiles = emptyList()
activeOptions = defaultRuntimeOptions()
libboxRuntime.setBootstrapDohUrl(null)
updateStateLocked(
SingboxProxyRuntimeStatus.STOPPED,
emptyList(),
null
)
}.onFailure { error ->
updateStateLocked(
SingboxProxyRuntimeStatus.ERROR,
state.endpoints,
error.message ?: error::class.java.simpleName
)
}
}
dispatchToMain { callback(result) }
}
}
override fun getState(): SingboxProxyRuntimeState = synchronized(stateLock) { state }
fun close() {
runtimeExecutor.execute {
synchronized(stateLock) {
runCatching { libboxRuntime.stopService() }
activeProfiles = emptyList()
activeOptions = defaultRuntimeOptions()
libboxRuntime.setBootstrapDohUrl(null)
libboxRuntime.close()
state = SingboxProxyRuntimeState(
status = SingboxProxyRuntimeStatus.STOPPED,
endpoints = emptyList(),
message = null
)
}
}
runtimeExecutor.shutdown()
}
private fun updateStateLocked(
status: SingboxProxyRuntimeStatus,
endpoints: List<SingboxProxyRuntimeEndpoint>,
message: String?
) {
state = SingboxProxyRuntimeState(
status = status,
endpoints = endpoints,
message = message
)
val snapshot = state
emitStateChanged(snapshot)
message?.let { emitLogMessage(statusLogMessage(status, it)) }
}
private fun defaultRuntimeOptions() = SingboxProxyRuntimeOptions(
preferredBasePort = null,
blockUnmatchedTraffic = true,
dnsConfig = null,
bootstrapDohUrl = null
)
}
@@ -0,0 +1,17 @@
package eu.weblibre.flutter_singbox_proxy
/**
* Inbound/outbound tag format shared between the Kotlin config builder and
* the Dart DNS resolver. Changing this contract requires updating
* `dns_config_resolver.dart` in lockstep — the Dart side emits matching tags
* so DNS detours bind to the outbound this builder actually creates.
*/
internal object SingboxTagFormat {
fun inboundTag(profileId: String): String = "in-${sanitizeTag(profileId)}"
fun outboundTag(profileId: String): String = "out-${sanitizeTag(profileId)}"
fun sanitizeTag(value: String): String {
return value.replace(Regex("[^A-Za-z0-9_.-]"), "_")
}
}
@@ -0,0 +1,947 @@
// Autogenerated from Pigeon (v26.3.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package eu.weblibre.flutter_singbox_proxy.generated
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object SingboxProxyApiPigeonUtils {
fun createConnectionError(channelName: String): FlutterError {
return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "") }
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
fun doubleEquals(a: Double, b: Double): Boolean {
// Normalize -0.0 to 0.0 and handle NaN equality.
return (if (a == 0.0) 0.0 else a) == (if (b == 0.0) 0.0 else b) || (a.isNaN() && b.isNaN())
}
fun floatEquals(a: Float, b: Float): Boolean {
// Normalize -0.0 to 0.0 and handle NaN equality.
return (if (a == 0.0f) 0.0f else a) == (if (b == 0.0f) 0.0f else b) || (a.isNaN() && b.isNaN())
}
fun doubleHash(d: Double): Int {
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
val normalized = if (d == 0.0) 0.0 else d
val bits = java.lang.Double.doubleToLongBits(normalized)
return (bits xor (bits ushr 32)).toInt()
}
fun floatHash(f: Float): Int {
// Normalize -0.0 to 0.0 and handle NaN to ensure consistent hash codes.
val normalized = if (f == 0.0f) 0.0f else f
return java.lang.Float.floatToIntBits(normalized)
}
fun deepEquals(a: Any?, b: Any?): Boolean {
if (a === b) {
return true
}
if (a == null || b == null) {
return false
}
if (a is ByteArray && b is ByteArray) {
return a.contentEquals(b)
}
if (a is IntArray && b is IntArray) {
return a.contentEquals(b)
}
if (a is LongArray && b is LongArray) {
return a.contentEquals(b)
}
if (a is DoubleArray && b is DoubleArray) {
if (a.size != b.size) return false
for (i in a.indices) {
if (!doubleEquals(a[i], b[i])) return false
}
return true
}
if (a is FloatArray && b is FloatArray) {
if (a.size != b.size) return false
for (i in a.indices) {
if (!floatEquals(a[i], b[i])) return false
}
return true
}
if (a is Array<*> && b is Array<*>) {
if (a.size != b.size) return false
for (i in a.indices) {
if (!deepEquals(a[i], b[i])) return false
}
return true
}
if (a is List<*> && b is List<*>) {
if (a.size != b.size) return false
val iterA = a.iterator()
val iterB = b.iterator()
while (iterA.hasNext() && iterB.hasNext()) {
if (!deepEquals(iterA.next(), iterB.next())) return false
}
return true
}
if (a is Map<*, *> && b is Map<*, *>) {
if (a.size != b.size) return false
for (entry in a) {
val key = entry.key
var found = false
for (bEntry in b) {
if (deepEquals(key, bEntry.key)) {
if (deepEquals(entry.value, bEntry.value)) {
found = true
break
} else {
return false
}
}
}
if (!found) return false
}
return true
}
if (a is Double && b is Double) {
return doubleEquals(a, b)
}
if (a is Float && b is Float) {
return floatEquals(a, b)
}
return a == b
}
fun deepHash(value: Any?): Int {
return when (value) {
null -> 0
is ByteArray -> value.contentHashCode()
is IntArray -> value.contentHashCode()
is LongArray -> value.contentHashCode()
is DoubleArray -> {
var result = 1
for (item in value) {
result = 31 * result + doubleHash(item)
}
result
}
is FloatArray -> {
var result = 1
for (item in value) {
result = 31 * result + floatHash(item)
}
result
}
is Array<*> -> {
var result = 1
for (item in value) {
result = 31 * result + deepHash(item)
}
result
}
is List<*> -> {
var result = 1
for (item in value) {
result = 31 * result + deepHash(item)
}
result
}
is Map<*, *> -> {
var result = 0
for (entry in value) {
result += ((deepHash(entry.key) * 31) xor deepHash(entry.value))
}
result
}
is Double -> doubleHash(value)
is Float -> floatHash(value)
else -> value.hashCode()
}
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : Throwable()
enum class SingboxProxyProfileType(val raw: Int) {
SOCKS(0),
HTTP(1),
SHADOWSOCKS(2),
VMESS(3),
VLESS(4),
TROJAN(5),
NAIVE(6),
HYSTERIA(7),
HYSTERIA2(8),
TUIC(9),
SSH(10),
WIREGUARD(11),
SHADOW_TLS(12),
ANY_TLS(13),
CUSTOM_OUTBOUND(14);
companion object {
fun ofRaw(raw: Int): SingboxProxyProfileType? {
return values().firstOrNull { it.raw == raw }
}
}
}
enum class SingboxProxyRuntimeStatus(val raw: Int) {
STOPPED(0),
STARTING(1),
RUNNING(2),
STOPPING(3),
ERROR(4);
companion object {
fun ofRaw(raw: Int): SingboxProxyRuntimeStatus? {
return values().firstOrNull { it.raw == raw }
}
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class SingboxProxyProfile (
val id: String,
val name: String,
val type: SingboxProxyProfileType,
/**
* Public profile configuration as JSON. The schema is intentionally owned by
* the profile type so the Pigeon API stays stable while sing-box evolves.
*/
val configJson: String,
/**
* Resolved secret values as JSON. Flutter stores secrets independently and
* only passes them to native code when building or starting a runtime config.
*/
val secretJson: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyProfile {
val id = pigeonVar_list[0] as String
val name = pigeonVar_list[1] as String
val type = pigeonVar_list[2] as SingboxProxyProfileType
val configJson = pigeonVar_list[3] as String
val secretJson = pigeonVar_list[4] as String?
return SingboxProxyProfile(id, name, type, configJson, secretJson)
}
}
fun toList(): List<Any?> {
return listOf(
id,
name,
type,
configJson,
secretJson,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as SingboxProxyProfile
return SingboxProxyApiPigeonUtils.deepEquals(this.id, other.id) && SingboxProxyApiPigeonUtils.deepEquals(this.name, other.name) && SingboxProxyApiPigeonUtils.deepEquals(this.type, other.type) && SingboxProxyApiPigeonUtils.deepEquals(this.configJson, other.configJson) && SingboxProxyApiPigeonUtils.deepEquals(this.secretJson, other.secretJson)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.id)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.name)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.type)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.configJson)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.secretJson)
return result
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class SingboxProxyRuntimeOptions (
/** Optional preferred start port for generated local SOCKS inbounds. */
val preferredBasePort: Long? = null,
/**
* If true, traffic entering sing-box without a matching inbound rule is
* rejected instead of falling through to direct.
*/
val blockUnmatchedTraffic: Boolean,
/**
* Optional DNS block emitted into sing-box config. When null, sing-box
* uses its built-in default (system resolver), which can leak DNS outside
* the proxy — callers should always provide an explicit configuration.
*/
val dnsConfig: SingboxProxyDnsConfig? = null,
/**
* DoH endpoint used by the native LocalDNSTransport bridge to bootstrap
* hostname-only DNS server addresses (and any other hostname appearing in
* the sing-box config). When null, the bridge refuses to resolve and
* sing-box's stock `/etc/resolv.conf`/127.0.0.1:53 path runs — which is
* broken on Android. Callers should always pass the browser DoH URL.
*/
val bootstrapDohUrl: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyRuntimeOptions {
val preferredBasePort = pigeonVar_list[0] as Long?
val blockUnmatchedTraffic = pigeonVar_list[1] as Boolean
val dnsConfig = pigeonVar_list[2] as SingboxProxyDnsConfig?
val bootstrapDohUrl = pigeonVar_list[3] as String?
return SingboxProxyRuntimeOptions(preferredBasePort, blockUnmatchedTraffic, dnsConfig, bootstrapDohUrl)
}
}
fun toList(): List<Any?> {
return listOf(
preferredBasePort,
blockUnmatchedTraffic,
dnsConfig,
bootstrapDohUrl,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as SingboxProxyRuntimeOptions
return SingboxProxyApiPigeonUtils.deepEquals(this.preferredBasePort, other.preferredBasePort) && SingboxProxyApiPigeonUtils.deepEquals(this.blockUnmatchedTraffic, other.blockUnmatchedTraffic) && SingboxProxyApiPigeonUtils.deepEquals(this.dnsConfig, other.dnsConfig) && SingboxProxyApiPigeonUtils.deepEquals(this.bootstrapDohUrl, other.bootstrapDohUrl)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.preferredBasePort)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.blockUnmatchedTraffic)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.dnsConfig)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.bootstrapDohUrl)
return result
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class SingboxProxyDnsServerConfig (
/** sing-box server tag, used to reference the server from `dns.rules`. */
val tag: String,
/**
* Server address. `https://...`, `tls://...`, `quic://...`, or plain IP.
* Hostnames are resolved on demand via the platform LocalDNSTransport
* bridge (sing-box `type: "local"` with our DoH-backed implementation).
*/
val address: String,
/**
* Outbound tag to dial the resolver through, or `direct` for direct, or
* null when sing-box should pick automatically.
*/
val detourTag: String? = null,
/**
* If non-empty, attaches a `dns.rules` entry routing matching domains to
* this server.
*/
val matchDomainSuffixes: List<String>,
/** Advanced sing-box geosite selectors (e.g. `geosite:cn`). */
val matchGeosites: List<String>,
/**
* If non-empty, attaches a `dns.rules` entry routing queries that *the
* listed outbounds* originate to this server. Note: sing-box treats an
* outbound's own bootstrap lookups (e.g. WireGuard peer hostname
* resolution) as queries from that outbound, so using this for per-profile
* scoping creates a chicken-and-egg loop at startup. Prefer
* [matchInbounds] for "queries from tabs routed through this profile".
*/
val matchOutbounds: List<String>,
/**
* If non-empty, attaches a `dns.rules` entry matching the listed inbound
* tags. Queries entering via that inbound (e.g. a tab whose container is
* bound to this profile's local SOCKS inbound) resolve through this
* server. Endpoint-bootstrap lookups don't come from any inbound, so this
* scope safely excludes them.
*/
val matchInbounds: List<String>
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyDnsServerConfig {
val tag = pigeonVar_list[0] as String
val address = pigeonVar_list[1] as String
val detourTag = pigeonVar_list[2] as String?
val matchDomainSuffixes = pigeonVar_list[3] as List<String>
val matchGeosites = pigeonVar_list[4] as List<String>
val matchOutbounds = pigeonVar_list[5] as List<String>
val matchInbounds = pigeonVar_list[6] as List<String>
return SingboxProxyDnsServerConfig(tag, address, detourTag, matchDomainSuffixes, matchGeosites, matchOutbounds, matchInbounds)
}
}
fun toList(): List<Any?> {
return listOf(
tag,
address,
detourTag,
matchDomainSuffixes,
matchGeosites,
matchOutbounds,
matchInbounds,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as SingboxProxyDnsServerConfig
return SingboxProxyApiPigeonUtils.deepEquals(this.tag, other.tag) && SingboxProxyApiPigeonUtils.deepEquals(this.address, other.address) && SingboxProxyApiPigeonUtils.deepEquals(this.detourTag, other.detourTag) && SingboxProxyApiPigeonUtils.deepEquals(this.matchDomainSuffixes, other.matchDomainSuffixes) && SingboxProxyApiPigeonUtils.deepEquals(this.matchGeosites, other.matchGeosites) && SingboxProxyApiPigeonUtils.deepEquals(this.matchOutbounds, other.matchOutbounds) && SingboxProxyApiPigeonUtils.deepEquals(this.matchInbounds, other.matchInbounds)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.tag)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.address)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.detourTag)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.matchDomainSuffixes)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.matchGeosites)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.matchOutbounds)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.matchInbounds)
return result
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class SingboxProxyDnsConfig (
val servers: List<SingboxProxyDnsServerConfig>,
/**
* Server tag used as `dns.final`. When null, sing-box uses the first
* server in the list as the fallback.
*/
val finalServerTag: String? = null,
/** sing-box `dns.strategy` string. e.g. `prefer_ipv4`, `ipv4_only`. */
val domainStrategy: String
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyDnsConfig {
val servers = pigeonVar_list[0] as List<SingboxProxyDnsServerConfig>
val finalServerTag = pigeonVar_list[1] as String?
val domainStrategy = pigeonVar_list[2] as String
return SingboxProxyDnsConfig(servers, finalServerTag, domainStrategy)
}
}
fun toList(): List<Any?> {
return listOf(
servers,
finalServerTag,
domainStrategy,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as SingboxProxyDnsConfig
return SingboxProxyApiPigeonUtils.deepEquals(this.servers, other.servers) && SingboxProxyApiPigeonUtils.deepEquals(this.finalServerTag, other.finalServerTag) && SingboxProxyApiPigeonUtils.deepEquals(this.domainStrategy, other.domainStrategy)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.servers)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.finalServerTag)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.domainStrategy)
return result
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class SingboxProxyRuntimeEndpoint (
val profileId: String,
val host: String,
val port: Long,
val username: String,
val password: String
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyRuntimeEndpoint {
val profileId = pigeonVar_list[0] as String
val host = pigeonVar_list[1] as String
val port = pigeonVar_list[2] as Long
val username = pigeonVar_list[3] as String
val password = pigeonVar_list[4] as String
return SingboxProxyRuntimeEndpoint(profileId, host, port, username, password)
}
}
fun toList(): List<Any?> {
return listOf(
profileId,
host,
port,
username,
password,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as SingboxProxyRuntimeEndpoint
return SingboxProxyApiPigeonUtils.deepEquals(this.profileId, other.profileId) && SingboxProxyApiPigeonUtils.deepEquals(this.host, other.host) && SingboxProxyApiPigeonUtils.deepEquals(this.port, other.port) && SingboxProxyApiPigeonUtils.deepEquals(this.username, other.username) && SingboxProxyApiPigeonUtils.deepEquals(this.password, other.password)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.profileId)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.host)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.port)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.username)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.password)
return result
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class SingboxProxyRuntimeState (
val status: SingboxProxyRuntimeStatus,
val endpoints: List<SingboxProxyRuntimeEndpoint>,
val message: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyRuntimeState {
val status = pigeonVar_list[0] as SingboxProxyRuntimeStatus
val endpoints = pigeonVar_list[1] as List<SingboxProxyRuntimeEndpoint>
val message = pigeonVar_list[2] as String?
return SingboxProxyRuntimeState(status, endpoints, message)
}
}
fun toList(): List<Any?> {
return listOf(
status,
endpoints,
message,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as SingboxProxyRuntimeState
return SingboxProxyApiPigeonUtils.deepEquals(this.status, other.status) && SingboxProxyApiPigeonUtils.deepEquals(this.endpoints, other.endpoints) && SingboxProxyApiPigeonUtils.deepEquals(this.message, other.message)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.status)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.endpoints)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.message)
return result
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class SingboxProxyConfigResult (
val configJson: String,
val endpoints: List<SingboxProxyRuntimeEndpoint>
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyConfigResult {
val configJson = pigeonVar_list[0] as String
val endpoints = pigeonVar_list[1] as List<SingboxProxyRuntimeEndpoint>
return SingboxProxyConfigResult(configJson, endpoints)
}
}
fun toList(): List<Any?> {
return listOf(
configJson,
endpoints,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as SingboxProxyConfigResult
return SingboxProxyApiPigeonUtils.deepEquals(this.configJson, other.configJson) && SingboxProxyApiPigeonUtils.deepEquals(this.endpoints, other.endpoints)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.configJson)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.endpoints)
return result
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class SingboxProxyLogMessage (
val level: String,
val message: String,
val timestamp: Long,
val profileId: String? = null
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): SingboxProxyLogMessage {
val level = pigeonVar_list[0] as String
val message = pigeonVar_list[1] as String
val timestamp = pigeonVar_list[2] as Long
val profileId = pigeonVar_list[3] as String?
return SingboxProxyLogMessage(level, message, timestamp, profileId)
}
}
fun toList(): List<Any?> {
return listOf(
level,
message,
timestamp,
profileId,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as SingboxProxyLogMessage
return SingboxProxyApiPigeonUtils.deepEquals(this.level, other.level) && SingboxProxyApiPigeonUtils.deepEquals(this.message, other.message) && SingboxProxyApiPigeonUtils.deepEquals(this.timestamp, other.timestamp) && SingboxProxyApiPigeonUtils.deepEquals(this.profileId, other.profileId)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.level)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.message)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.timestamp)
result = 31 * result + SingboxProxyApiPigeonUtils.deepHash(this.profileId)
return result
}
}
private open class SingboxProxyApiPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as Long?)?.let {
SingboxProxyProfileType.ofRaw(it.toInt())
}
}
130.toByte() -> {
return (readValue(buffer) as Long?)?.let {
SingboxProxyRuntimeStatus.ofRaw(it.toInt())
}
}
131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SingboxProxyProfile.fromList(it)
}
}
132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SingboxProxyRuntimeOptions.fromList(it)
}
}
133.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SingboxProxyDnsServerConfig.fromList(it)
}
}
134.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SingboxProxyDnsConfig.fromList(it)
}
}
135.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SingboxProxyRuntimeEndpoint.fromList(it)
}
}
136.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SingboxProxyRuntimeState.fromList(it)
}
}
137.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SingboxProxyConfigResult.fromList(it)
}
}
138.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SingboxProxyLogMessage.fromList(it)
}
}
else -> super.readValueOfType(type, buffer)
}
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is SingboxProxyProfileType -> {
stream.write(129)
writeValue(stream, value.raw.toLong())
}
is SingboxProxyRuntimeStatus -> {
stream.write(130)
writeValue(stream, value.raw.toLong())
}
is SingboxProxyProfile -> {
stream.write(131)
writeValue(stream, value.toList())
}
is SingboxProxyRuntimeOptions -> {
stream.write(132)
writeValue(stream, value.toList())
}
is SingboxProxyDnsServerConfig -> {
stream.write(133)
writeValue(stream, value.toList())
}
is SingboxProxyDnsConfig -> {
stream.write(134)
writeValue(stream, value.toList())
}
is SingboxProxyRuntimeEndpoint -> {
stream.write(135)
writeValue(stream, value.toList())
}
is SingboxProxyRuntimeState -> {
stream.write(136)
writeValue(stream, value.toList())
}
is SingboxProxyConfigResult -> {
stream.write(137)
writeValue(stream, value.toList())
}
is SingboxProxyLogMessage -> {
stream.write(138)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface SingboxProxyApi {
fun validateProfile(profile: SingboxProxyProfile, callback: (Result<String?>) -> Unit)
fun buildConfig(profiles: List<SingboxProxyProfile>, options: SingboxProxyRuntimeOptions, callback: (Result<SingboxProxyConfigResult>) -> Unit)
fun start(profiles: List<SingboxProxyProfile>, options: SingboxProxyRuntimeOptions, callback: (Result<SingboxProxyRuntimeState>) -> Unit)
fun stop(profileIds: List<String>, callback: (Result<Unit>) -> Unit)
fun stopAll(callback: (Result<Unit>) -> Unit)
fun getState(): SingboxProxyRuntimeState
companion object {
/** The codec used by SingboxProxyApi. */
val codec: MessageCodec<Any?> by lazy {
SingboxProxyApiPigeonCodec()
}
/** Sets up an instance of `SingboxProxyApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: SingboxProxyApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.validateProfile$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val profileArg = args[0] as SingboxProxyProfile
api.validateProfile(profileArg) { result: Result<String?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(SingboxProxyApiPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(SingboxProxyApiPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.buildConfig$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val profilesArg = args[0] as List<SingboxProxyProfile>
val optionsArg = args[1] as SingboxProxyRuntimeOptions
api.buildConfig(profilesArg, optionsArg) { result: Result<SingboxProxyConfigResult> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(SingboxProxyApiPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(SingboxProxyApiPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.start$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val profilesArg = args[0] as List<SingboxProxyProfile>
val optionsArg = args[1] as SingboxProxyRuntimeOptions
api.start(profilesArg, optionsArg) { result: Result<SingboxProxyRuntimeState> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(SingboxProxyApiPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(SingboxProxyApiPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.stop$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val profileIdsArg = args[0] as List<String>
api.stop(profileIdsArg) { result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(SingboxProxyApiPigeonUtils.wrapError(error))
} else {
reply.reply(SingboxProxyApiPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.stopAll$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.stopAll{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(SingboxProxyApiPigeonUtils.wrapError(error))
} else {
reply.reply(SingboxProxyApiPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.getState$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getState())
} catch (exception: Throwable) {
SingboxProxyApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */
class SingboxProxyEventsApi(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") {
companion object {
/** The codec used by SingboxProxyEventsApi. */
val codec: MessageCodec<Any?> by lazy {
SingboxProxyApiPigeonCodec()
}
}
fun onStateChanged(stateArg: SingboxProxyRuntimeState, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyEventsApi.onStateChanged$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(stateArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
} else {
callback(Result.success(Unit))
}
} else {
callback(Result.failure(SingboxProxyApiPigeonUtils.createConnectionError(channelName)))
}
}
}
fun onLogMessage(messageArg: SingboxProxyLogMessage, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyEventsApi.onLogMessage$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(messageArg)) {
if (it is List<*>) {
if (it.size > 1) {
callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?)))
} else {
callback(Result.success(Unit))
}
} else {
callback(Result.failure(SingboxProxyApiPigeonUtils.createConnectionError(channelName)))
}
}
}
}
@@ -0,0 +1,247 @@
package eu.weblibre.flutter_singbox_proxy
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfile
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfileType
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyDnsConfig
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyDnsServerConfig
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeOptions
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNull
import org.json.JSONObject
internal class FlutterSingboxProxyPluginTest {
@Test
fun buildConfig_wrapsProfileOutboundWithAuthenticatedSocksInbound() {
val builder = SingboxConfigBuilder()
val profile = SingboxProxyProfile(
id = "wg-home",
name = "WireGuard Home",
type = SingboxProxyProfileType.WIREGUARD,
configJson = "{\"server\":\"example.test\"}",
secretJson = "{\"private_key\":\"secret\"}"
)
val result = builder.build(
listOf(profile),
SingboxProxyRuntimeOptions(preferredBasePort = 12500, blockUnmatchedTraffic = true)
)
assertEquals(1, result.endpoints.size)
assertEquals(12500, result.endpoints.single().port)
assertContains(result.configJson, "\"type\": \"socks\"")
assertContains(result.configJson, "\"type\": \"wireguard\"")
assertContains(result.configJson, "\"endpoints\"")
assertContains(result.configJson, "\"private_key\": \"secret\"")
}
@Test
fun buildConfig_migratesWireGuardOutboundProfileToEndpoint() {
val builder = SingboxConfigBuilder()
val profile = SingboxProxyProfile(
id = "wg-home",
name = "WireGuard Home",
type = SingboxProxyProfileType.WIREGUARD,
configJson = """
{
"type": "wireguard",
"server": "example.test",
"server_port": 51820,
"local_address": ["10.7.0.2/32"],
"peer_public_key": "peer",
"mtu": 1408
}
""".trimIndent(),
secretJson = """
{
"private_key": "secret",
"pre_shared_key": "psk"
}
""".trimIndent()
)
val result = builder.build(
listOf(profile),
SingboxProxyRuntimeOptions(preferredBasePort = 12500, blockUnmatchedTraffic = true)
)
assertContains(result.configJson, "\"endpoints\"")
assertContains(result.configJson, "\"address\": \"example.test\"")
assertContains(result.configJson, "\"port\": 51820")
assertContains(result.configJson, "\"address\": [")
assertContains(result.configJson, "\"public_key\": \"peer\"")
assertContains(result.configJson, "\"allowed_ips\": [")
}
@Test
fun validateProfile_acceptsTypeSpecificConfigWithoutExplicitType() {
val builder = SingboxConfigBuilder()
val profile = SingboxProxyProfile(
id = "ss-main",
name = "Shadowsocks",
type = SingboxProxyProfileType.SHADOWSOCKS,
configJson = "{\"server\":\"example.test\"}",
secretJson = null
)
assertNull(builder.validateProfile(profile))
}
@Test
fun buildConfig_emitsLocalBootstrapAndDomainResolverForHostnames() {
val builder = SingboxConfigBuilder()
val profile = SingboxProxyProfile(
id = "profile-1",
name = "SOCKS",
type = SingboxProxyProfileType.SOCKS,
configJson = "{\"server\":\"127.0.0.1\",\"server_port\":1080}",
secretJson = null
)
val result = builder.build(
listOf(profile),
SingboxProxyRuntimeOptions(
preferredBasePort = 12500,
blockUnmatchedTraffic = true,
bootstrapDohUrl = "https://dns.example/dns-query",
dnsConfig = SingboxProxyDnsConfig(
servers = listOf(
dnsServer(
tag = "corp",
address = "tls://dns.example",
detourTag = "out-profile-1",
matchInbounds = listOf("in-profile-1")
)
),
finalServerTag = null,
domainStrategy = ""
)
)
)
val config = JSONObject(result.configJson)
val dns = config.getJSONObject("dns")
val servers = dns.getJSONArray("servers")
val local = servers.getJSONObject(0)
val corp = servers.getJSONObject(1)
val rule = dns.getJSONArray("rules").getJSONObject(0)
// `local` is always emitted; LocalDNSTransport bridge backs it at runtime.
assertEquals("local", local.getString("type"))
assertEquals("local", local.getString("tag"))
assertFalse(local.has("detour"))
assertEquals("tls", corp.getString("type"))
assertEquals("dns.example", corp.getString("server"))
// Hostname target → bootstrap through `local`.
assertEquals("local", corp.getString("domain_resolver"))
assertEquals("out-profile-1", corp.getString("detour"))
// The original `tls.server_name` plumbing is gone; SNI is derived
// from the preserved hostname in `server`.
assertFalse(corp.has("tls"))
assertEquals("route", rule.getString("action"))
assertEquals("corp", rule.getString("server"))
assertEquals("local", dns.getString("final"))
// route.default_domain_resolver ties WG peers and other outbound
// hostnames into the same `local` bridge.
val route = config.getJSONObject("route")
assertEquals("local", route.getString("default_domain_resolver"))
}
@Test
fun buildConfig_omitsDomainResolverForIpLiteralServers() {
val builder = SingboxConfigBuilder()
val profile = SingboxProxyProfile(
id = "profile-1",
name = "SOCKS",
type = SingboxProxyProfileType.SOCKS,
configJson = "{\"server\":\"127.0.0.1\",\"server_port\":1080}",
secretJson = null
)
val result = builder.build(
listOf(profile),
SingboxProxyRuntimeOptions(
preferredBasePort = 12500,
blockUnmatchedTraffic = true,
bootstrapDohUrl = "https://dns.example/dns-query",
dnsConfig = SingboxProxyDnsConfig(
servers = listOf(
dnsServer(
tag = "plain",
address = "udp://1.2.3.4",
matchInbounds = emptyList()
)
),
finalServerTag = "plain",
domainStrategy = ""
)
)
)
val dns = JSONObject(result.configJson).getJSONObject("dns")
// `local` is still emitted unconditionally as the bootstrap anchor.
assertEquals("local", dns.getJSONArray("servers").getJSONObject(0).getString("type"))
val plain = dns.getJSONArray("servers").getJSONObject(1)
assertEquals("udp", plain.getString("type"))
assertEquals("1.2.3.4", plain.getString("server"))
// IP literal: no domain_resolver needed.
assertFalse(plain.has("domain_resolver"))
assertFalse(plain.has("tls"))
}
@Test
fun buildConfig_rejectsDnsConfigWithoutBootstrapDohUrl() {
val builder = SingboxConfigBuilder()
val profile = SingboxProxyProfile(
id = "profile-1",
name = "SOCKS",
type = SingboxProxyProfileType.SOCKS,
configJson = "{\"server\":\"127.0.0.1\",\"server_port\":1080}",
secretJson = null
)
val error = assertFailsWith<IllegalArgumentException> {
builder.build(
listOf(profile),
SingboxProxyRuntimeOptions(
preferredBasePort = 12500,
blockUnmatchedTraffic = true,
dnsConfig = SingboxProxyDnsConfig(
servers = listOf(
dnsServer(tag = "plain", address = "udp://1.2.3.4")
),
finalServerTag = "plain",
domainStrategy = ""
)
)
)
}
assertEquals(
"bootstrapDohUrl is required when dnsConfig is provided.",
error.message
)
}
}
private fun dnsServer(
tag: String,
address: String,
detourTag: String? = null,
matchDomainSuffixes: List<String> = emptyList(),
matchInbounds: List<String> = emptyList()
) = SingboxProxyDnsServerConfig(
tag = tag,
address = address,
detourTag = detourTag,
matchDomainSuffixes = matchDomainSuffixes,
matchGeosites = emptyList(),
matchOutbounds = emptyList(),
matchInbounds = matchInbounds
)
@@ -0,0 +1,84 @@
package eu.weblibre.flutter_singbox_proxy
import android.content.Context
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfile
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyProfileType
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeOptions
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeState
import eu.weblibre.flutter_singbox_proxy.generated.SingboxProxyRuntimeStatus
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
import org.mockito.Mockito.mock
internal class SingboxRuntimeManagerTest {
@Test
fun startFailure_preservesPreviouslyRunningEndpoints() {
val runtime = FakeLibboxRuntime(failOnStartAttempt = 2)
val manager = SingboxRuntimeManager(
context = mock(Context::class.java),
libboxRuntime = runtime,
dispatchToMain = { action -> action() }
)
val firstState = manager.awaitStart(listOf(profile(id = "profile-a"))).getOrThrow()
val failedResult = manager.awaitStart(listOf(profile(id = "profile-b")))
val stateAfterFailure = manager.getState()
manager.close()
assertTrue(failedResult.isFailure)
assertIs<IllegalStateException>(failedResult.exceptionOrNull())
assertEquals(SingboxProxyRuntimeStatus.ERROR, stateAfterFailure.status)
assertEquals(firstState.endpoints, stateAfterFailure.endpoints)
assertEquals("start failed", stateAfterFailure.message)
}
}
private fun profile(id: String) = SingboxProxyProfile(
id = id,
name = id,
type = SingboxProxyProfileType.SOCKS,
configJson = """{"server":"127.0.0.1","server_port":1080}""",
secretJson = null
)
private fun SingboxRuntimeManager.awaitStart(
profiles: List<SingboxProxyProfile>
): Result<SingboxProxyRuntimeState> {
val latch = CountDownLatch(1)
var result: Result<SingboxProxyRuntimeState>? = null
start(profiles, SingboxProxyRuntimeOptions(preferredBasePort = 12080, blockUnmatchedTraffic = true)) {
result = it
latch.countDown()
}
assertTrue(latch.await(5, TimeUnit.SECONDS), "Timed out waiting for start callback")
return result!!
}
private class FakeLibboxRuntime(
private val failOnStartAttempt: Int,
) : LibboxRuntime(mock(Context::class.java)) {
private var startAttempts = 0
override fun isAvailable(): Boolean = true
override fun start(configJson: String) {
startAttempts += 1
if (startAttempts == failOnStartAttempt) {
throw IllegalStateException("start failed")
}
}
override fun stopService() {}
override fun close() {}
override fun setLogSink(sink: ((Int, String) -> Unit)?) {}
override fun setBootstrapDohUrl(url: String?) {}
}
@@ -0,0 +1,39 @@
package eu.weblibre.flutter_singbox_proxy
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Tag format is part of the Dart↔Kotlin contract: the Dart DNS resolver in
* `dns_config_resolver.dart` emits `out-`/`in-` tags that must match what the
* config builder writes. The mirrored Dart test lives in
* `apps/weblibre/test/features/proxy/domain/services/singbox_tag_format_test.dart`
* — both must update together if this format ever changes.
*/
internal class SingboxTagFormatTest {
@Test
fun outboundTag_isPrefixedAndSanitized() {
assertEquals("out-singbox_foo-bar", SingboxTagFormat.outboundTag("singbox:foo-bar"))
}
@Test
fun inboundTag_isPrefixedAndSanitized() {
assertEquals("in-singbox_foo-bar", SingboxTagFormat.inboundTag("singbox:foo-bar"))
}
@Test
fun sanitizeTag_preservesAlphanumericDotsDashesUnderscores() {
assertEquals(
"Abc_123.x-Y",
SingboxTagFormat.sanitizeTag("Abc_123.x-Y")
)
}
@Test
fun sanitizeTag_replacesSpacesSlashesAndColons() {
assertEquals(
"a_b_c_d_e",
SingboxTagFormat.sanitizeTag("a:b c/d\\e")
)
}
}