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
@@ -10,6 +10,7 @@ import eu.weblibre.flutter_mozilla_components.feature.BrowserExtensionFeature
import eu.weblibre.flutter_mozilla_components.feature.ContainerProxyFeature
import eu.weblibre.flutter_mozilla_components.feature.ResultConsumer
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoContainerProxyApi
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoProxySettings
import org.json.JSONObject
class GeckoContainerProxyApiImpl : GeckoContainerProxyApi {
@@ -25,6 +26,38 @@ class GeckoContainerProxyApiImpl : GeckoContainerProxyApi {
ContainerProxyFeature.scheduleRequest("removeContainerProxy", contextId)
}
override fun upsertProxy(proxy: GeckoProxySettings) {
ContainerProxyFeature.scheduleRequest("upsertProxy", proxy.toJson())
}
override fun removeProxy(proxyId: String) {
ContainerProxyFeature.scheduleRequest("removeProxy", proxyId)
}
override fun setContainerProxy(contextId: String, proxyId: String) {
ContainerProxyFeature.scheduleRequest(
"setContainerProxy",
JSONObject().apply {
put("contextId", contextId)
put("proxyId", proxyId)
}
)
}
override fun clearContainerProxy(contextId: String) {
ContainerProxyFeature.scheduleRequest("clearContainerProxy", contextId)
}
override fun removeContainerProxyRelation(contextId: String, proxyId: String) {
ContainerProxyFeature.scheduleRequest(
"removeContainerProxyRelation",
JSONObject().apply {
put("contextId", contextId)
put("proxyId", proxyId)
}
)
}
override fun setSiteAssignments(assignments: Map<String, String>) {
ContainerProxyFeature.scheduleRequest("setSiteAssignments", JSONObject(assignments))
}
@@ -42,4 +75,18 @@ class GeckoContainerProxyApiImpl : GeckoContainerProxyApi {
}
})
}
}
private fun GeckoProxySettings.toJson(): JSONObject {
return JSONObject().apply {
put("id", id)
put("title", title)
put("type", type)
put("host", host)
put("port", port)
username?.let { put("username", it) }
password?.let { put("password", it) }
put("proxyDNS", proxyDNS)
put("doNotProxyLocal", doNotProxyLocal)
}
}
}
@@ -12,9 +12,11 @@ import android.content.Intent
import android.net.Uri
import android.util.Log
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
import eu.weblibre.flutter_mozilla_components.feature.InertExternalSchemes
import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureBridge
import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureRegistry
import eu.weblibre.flutter_mozilla_components.pigeons.ProxyLoadError
import mozilla.components.browser.errorpages.ErrorPages
import mozilla.components.browser.errorpages.ErrorType
import mozilla.components.browser.state.selector.findTabOrCustomTab
@@ -145,6 +147,21 @@ class AppRequestInterceptor(private val context: Context) : RequestInterceptor {
errorType: ErrorType,
uri: String?,
): RequestInterceptor.ErrorResponse {
if (errorType == ErrorType.ERROR_PROXY_CONNECTION_REFUSED ||
errorType == ErrorType.ERROR_UNKNOWN_PROXY_HOST
) {
val tab = components.core.store.state.findTabOrCustomTab(session)
components.flutterEvents.onProxyLoadError(
EventSequence.next(),
ProxyLoadError(
tabId = tab?.id,
contextId = tab?.contextId,
url = uri,
errorType = errorType.name,
)
) { _ -> }
}
val errorPage = ErrorPages.createUrlEncodedErrorPage(context, errorType, uri)
return RequestInterceptor.ErrorResponse(errorPage)
}
@@ -4573,6 +4573,72 @@ data class MlProgressData (
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class GeckoProxySettings (
val id: String,
val title: String,
val type: String,
val host: String,
val port: Long,
val username: String? = null,
val password: String? = null,
val proxyDNS: Boolean,
val doNotProxyLocal: Boolean
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): GeckoProxySettings {
val id = pigeonVar_list[0] as String
val title = pigeonVar_list[1] as String
val type = pigeonVar_list[2] as String
val host = pigeonVar_list[3] as String
val port = pigeonVar_list[4] as Long
val username = pigeonVar_list[5] as String?
val password = pigeonVar_list[6] as String?
val proxyDNS = pigeonVar_list[7] as Boolean
val doNotProxyLocal = pigeonVar_list[8] as Boolean
return GeckoProxySettings(id, title, type, host, port, username, password, proxyDNS, doNotProxyLocal)
}
}
fun toList(): List<Any?> {
return listOf(
id,
title,
type,
host,
port,
username,
password,
proxyDNS,
doNotProxyLocal,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as GeckoProxySettings
return GeckoPigeonUtils.deepEquals(this.id, other.id) && GeckoPigeonUtils.deepEquals(this.title, other.title) && GeckoPigeonUtils.deepEquals(this.type, other.type) && GeckoPigeonUtils.deepEquals(this.host, other.host) && GeckoPigeonUtils.deepEquals(this.port, other.port) && GeckoPigeonUtils.deepEquals(this.username, other.username) && GeckoPigeonUtils.deepEquals(this.password, other.password) && GeckoPigeonUtils.deepEquals(this.proxyDNS, other.proxyDNS) && GeckoPigeonUtils.deepEquals(this.doNotProxyLocal, other.doNotProxyLocal)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + GeckoPigeonUtils.deepHash(this.id)
result = 31 * result + GeckoPigeonUtils.deepHash(this.title)
result = 31 * result + GeckoPigeonUtils.deepHash(this.type)
result = 31 * result + GeckoPigeonUtils.deepHash(this.host)
result = 31 * result + GeckoPigeonUtils.deepHash(this.port)
result = 31 * result + GeckoPigeonUtils.deepHash(this.username)
result = 31 * result + GeckoPigeonUtils.deepHash(this.password)
result = 31 * result + GeckoPigeonUtils.deepHash(this.proxyDNS)
result = 31 * result + GeckoPigeonUtils.deepHash(this.doNotProxyLocal)
return result
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class ContainerSiteAssignment (
val requestId: String,
@@ -4623,6 +4689,52 @@ data class ContainerSiteAssignment (
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class ProxyLoadError (
val tabId: String? = null,
val contextId: String? = null,
val url: String? = null,
val errorType: String
)
{
companion object {
fun fromList(pigeonVar_list: List<Any?>): ProxyLoadError {
val tabId = pigeonVar_list[0] as String?
val contextId = pigeonVar_list[1] as String?
val url = pigeonVar_list[2] as String?
val errorType = pigeonVar_list[3] as String
return ProxyLoadError(tabId, contextId, url, errorType)
}
}
fun toList(): List<Any?> {
return listOf(
tabId,
contextId,
url,
errorType,
)
}
override fun equals(other: Any?): Boolean {
if (other == null || other.javaClass != javaClass) {
return false
}
if (this === other) {
return true
}
val other = other as ProxyLoadError
return GeckoPigeonUtils.deepEquals(this.tabId, other.tabId) && GeckoPigeonUtils.deepEquals(this.contextId, other.contextId) && GeckoPigeonUtils.deepEquals(this.url, other.url) && GeckoPigeonUtils.deepEquals(this.errorType, other.errorType)
}
override fun hashCode(): Int {
var result = javaClass.hashCode()
result = 31 * result + GeckoPigeonUtils.deepHash(this.tabId)
result = 31 * result + GeckoPigeonUtils.deepHash(this.contextId)
result = 31 * result + GeckoPigeonUtils.deepHash(this.url)
result = 31 * result + GeckoPigeonUtils.deepHash(this.errorType)
return result
}
}
/** Generated class from Pigeon that represents data sent in messages. */
data class GeckoHeader (
val key: String,
@@ -5995,75 +6107,85 @@ private open class GeckoPigeonCodec : StandardMessageCodec() {
}
235.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ContainerSiteAssignment.fromList(it)
GeckoProxySettings.fromList(it)
}
}
236.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
GeckoHeader.fromList(it)
ContainerSiteAssignment.fromList(it)
}
}
237.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
GeckoFetchRequest.fromList(it)
ProxyLoadError.fromList(it)
}
}
238.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
GeckoFetchResponse.fromList(it)
GeckoHeader.fromList(it)
}
}
239.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
BookmarkNode.fromList(it)
GeckoFetchRequest.fromList(it)
}
}
240.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
BookmarkInfo.fromList(it)
GeckoFetchResponse.fromList(it)
}
}
241.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SitePermissions.fromList(it)
BookmarkNode.fromList(it)
}
}
242.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
TrackingProtectionException.fromList(it)
BookmarkInfo.fromList(it)
}
}
243.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PwaIcon.fromList(it)
SitePermissions.fromList(it)
}
}
244.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ShareTargetFiles.fromList(it)
TrackingProtectionException.fromList(it)
}
}
245.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ShareTargetParams.fromList(it)
PwaIcon.fromList(it)
}
}
246.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ShareTarget.fromList(it)
ShareTargetFiles.fromList(it)
}
}
247.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ExternalApplicationResource.fromList(it)
ShareTargetParams.fromList(it)
}
}
248.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PwaManifest.fromList(it)
ShareTarget.fromList(it)
}
}
249.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
ExternalApplicationResource.fromList(it)
}
}
250.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PwaManifest.fromList(it)
}
}
251.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SandboxCaptureEntry.fromList(it)
}
@@ -6497,66 +6619,74 @@ private open class GeckoPigeonCodec : StandardMessageCodec() {
stream.write(234)
writeValue(stream, value.toList())
}
is ContainerSiteAssignment -> {
is GeckoProxySettings -> {
stream.write(235)
writeValue(stream, value.toList())
}
is GeckoHeader -> {
is ContainerSiteAssignment -> {
stream.write(236)
writeValue(stream, value.toList())
}
is GeckoFetchRequest -> {
is ProxyLoadError -> {
stream.write(237)
writeValue(stream, value.toList())
}
is GeckoFetchResponse -> {
is GeckoHeader -> {
stream.write(238)
writeValue(stream, value.toList())
}
is BookmarkNode -> {
is GeckoFetchRequest -> {
stream.write(239)
writeValue(stream, value.toList())
}
is BookmarkInfo -> {
is GeckoFetchResponse -> {
stream.write(240)
writeValue(stream, value.toList())
}
is SitePermissions -> {
is BookmarkNode -> {
stream.write(241)
writeValue(stream, value.toList())
}
is TrackingProtectionException -> {
is BookmarkInfo -> {
stream.write(242)
writeValue(stream, value.toList())
}
is PwaIcon -> {
is SitePermissions -> {
stream.write(243)
writeValue(stream, value.toList())
}
is ShareTargetFiles -> {
is TrackingProtectionException -> {
stream.write(244)
writeValue(stream, value.toList())
}
is ShareTargetParams -> {
is PwaIcon -> {
stream.write(245)
writeValue(stream, value.toList())
}
is ShareTarget -> {
is ShareTargetFiles -> {
stream.write(246)
writeValue(stream, value.toList())
}
is ExternalApplicationResource -> {
is ShareTargetParams -> {
stream.write(247)
writeValue(stream, value.toList())
}
is PwaManifest -> {
is ShareTarget -> {
stream.write(248)
writeValue(stream, value.toList())
}
is SandboxCaptureEntry -> {
is ExternalApplicationResource -> {
stream.write(249)
writeValue(stream, value.toList())
}
is PwaManifest -> {
stream.write(250)
writeValue(stream, value.toList())
}
is SandboxCaptureEntry -> {
stream.write(251)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)
}
}
@@ -8279,6 +8409,11 @@ interface GeckoContainerProxyApi {
fun setProxyPort(port: Long)
fun addContainerProxy(contextId: String)
fun removeContainerProxy(contextId: String)
fun upsertProxy(proxy: GeckoProxySettings)
fun removeProxy(proxyId: String)
fun setContainerProxy(contextId: String, proxyId: String)
fun clearContainerProxy(contextId: String)
fun removeContainerProxyRelation(contextId: String, proxyId: String)
fun setSiteAssignments(assignments: Map<String, String>)
fun healthcheck(callback: (Result<Boolean>) -> Unit)
@@ -8345,6 +8480,98 @@ interface GeckoContainerProxyApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.upsertProxy$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val proxyArg = args[0] as GeckoProxySettings
val wrapped: List<Any?> = try {
api.upsertProxy(proxyArg)
listOf(null)
} catch (exception: Throwable) {
GeckoPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.removeProxy$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val proxyIdArg = args[0] as String
val wrapped: List<Any?> = try {
api.removeProxy(proxyIdArg)
listOf(null)
} catch (exception: Throwable) {
GeckoPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.setContainerProxy$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val contextIdArg = args[0] as String
val proxyIdArg = args[1] as String
val wrapped: List<Any?> = try {
api.setContainerProxy(contextIdArg, proxyIdArg)
listOf(null)
} catch (exception: Throwable) {
GeckoPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.clearContainerProxy$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val contextIdArg = args[0] as String
val wrapped: List<Any?> = try {
api.clearContainerProxy(contextIdArg)
listOf(null)
} catch (exception: Throwable) {
GeckoPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.removeContainerProxyRelation$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val contextIdArg = args[0] as String
val proxyIdArg = args[1] as String
val wrapped: List<Any?> = try {
api.removeContainerProxyRelation(contextIdArg, proxyIdArg)
listOf(null)
} catch (exception: Throwable) {
GeckoPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoContainerProxyApi.setSiteAssignments$separatedMessageChannelSuffix", codec)
if (api != null) {
@@ -8785,6 +9012,23 @@ class GeckoStateEvents(private val binaryMessenger: BinaryMessenger, private val
}
}
}
fun onProxyLoadError(sequenceArg: Long, detailsArg: ProxyLoadError, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.flutter_mozilla_components.GeckoStateEvents.onProxyLoadError$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(binaryMessenger, channelName, codec)
channel.send(listOf(sequenceArg, detailsArg)) {
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(GeckoPigeonUtils.createConnectionError(channelName)))
}
}
}
fun onMlProgress(sequenceArg: Long, progressArg: MlProgressData, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
@@ -15,7 +15,7 @@ const privateIdentifier = 'firefox-private'
type DoNotProxy = never[]
export const doNotProxy: DoNotProxy = []
const emergencyBreak: Socks5ProxyInfo = {
export const emergencyBreak: Socks5ProxyInfo = {
type: ProxyType.Socks5,
host: 'emergency-break-proxy.localhost',
port: 1,
@@ -1,4 +1,4 @@
import { Socks5ProxySettings } from 'src/domain/ProxySettings';
import { ProxySettings, Socks5ProxySettings } from 'src/domain/ProxySettings';
import { Store } from '../store/Store'
import BackgroundMain from './BackgroundMain'
@@ -8,7 +8,16 @@ const store = new Store()
interface Message {
id: String | undefined;
action: 'setProxyPort' | 'addContainerProxy' | 'removeContainerProxy' | 'healthcheck' | 'setSiteAssignments';
action: 'setProxyPort' |
'addContainerProxy' |
'removeContainerProxy' |
'upsertProxy' |
'removeProxy' |
'setContainerProxy' |
'clearContainerProxy' |
'removeContainerProxyRelation' |
'healthcheck' |
'setSiteAssignments';
args: any;
}
@@ -42,6 +51,32 @@ port.onMessage.addListener((raw: unknown): void => {
store.removeContainerProxyRelation(message.args, "tor")
console.log('removed container relation ' + message.args)
break
case "upsertProxy": {
const proxy = ProxySettings.tryFromDao(message.args)
if (proxy === undefined) {
console.error('invalid proxy settings ' + JSON.stringify(message.args))
break
}
store.putProxy(proxy)
console.log('upsert proxy ' + message.args.id)
break
}
case "removeProxy":
store.deleteProxyById(message.args)
console.log('removed proxy ' + message.args)
break
case "setContainerProxy":
store.setContainerProxyRelation(message.args.contextId, message.args.proxyId)
console.log('set container relation ' + message.args.contextId + ' -> ' + message.args.proxyId)
break
case "clearContainerProxy":
store.clearContainerProxyRelation(message.args)
console.log('cleared container relation ' + message.args)
break
case "removeContainerProxyRelation":
store.removeContainerProxyRelation(message.args.contextId, message.args.proxyId)
console.log('removed container relation ' + message.args.contextId + ' -> ' + message.args.proxyId)
break
case "setSiteAssignments":
const entries = new Map(Object.entries(message.args))
console.log('set site assignments ' + JSON.stringify(message.args))
@@ -224,6 +224,10 @@ export class Store {
this.relations[cookieStoreId] = [proxyId]
}
clearContainerProxyRelation(cookieStoreId: string): void {
delete this.relations[cookieStoreId]
}
removeContainerProxyRelation(cookieStoreId: string, proxyId: string): void {
const currentRelations = this.relations[cookieStoreId] ?? []
this.relations[cookieStoreId] = currentRelations.filter(id => id !== proxyId)
@@ -1,4 +1,4 @@
import BackgroundMain, { doNotProxy } from '../../src/background/BackgroundMain'
import BackgroundMain, { doNotProxy, emergencyBreak } from '../../src/background/BackgroundMain'
import { Store } from '../../src/store/Store'
import { expect } from 'chai'
@@ -41,6 +41,16 @@ describe('BackgroundMain', function () {
expect(result).to.be.not.empty
})
it('should block if an assigned proxy no longer exists', async () => {
const isolatedStore = new Store()
const isolatedBackgroundMain = new BackgroundMain({ store: isolatedStore })
isolatedStore.setContainerProxyRelation('general', 'missing-proxy')
const result = await isolatedBackgroundMain.onRequest({ cookieStoreId: 'firefox-default', url: 'https://google.com', tabId: 0 })
expect(result).to.be.deep.equal([emergencyBreak])
})
it('should remove doNotProxyLocal flag from proxy settings if proxy is set up', async () => {
await givenSomeProxyIsSetUpForContainer({ containerId: 'firefox-default', host: undefined, doNotProxyLocal: undefined })
@@ -163,6 +163,15 @@ describe('Store', () => {
expect(relations.container1).to.be.deep.equal(['proxy1'])
expect(relations.container2).to.be.deep.equal(['proxy2'])
})
it('should keep a missing proxy relation distinguishable from no relation', () => {
const isolatedStore = new Store()
isolatedStore.setContainerProxyRelation('container1', 'deleted-proxy')
const result = isolatedStore.getProxiesForContainer('container1')
expect(result).to.be.deep.equal([])
})
})
describe('wildcard site assignments', function () {
@@ -69,6 +69,7 @@ export 'src/pigeons/gecko.g.dart'
GeckoDeleteBrowsingDataController,
GeckoEngineSettings,
GeckoFetchResponse,
GeckoProxySettings,
GeckoPref,
GeckoPublicSuffixListApi,
GeckoPwaApi,
@@ -93,6 +94,7 @@ export 'src/pigeons/gecko.g.dart'
MlProgressStatus,
MlProgressType,
PhoneHitResult,
ProxyLoadError,
PwaIcon,
PwaManifest,
QueryParameterStripping,
@@ -24,6 +24,26 @@ class GeckoContainerProxyService {
return _apiInstance.removeContainerProxy(contextId);
}
Future<void> upsertProxy(GeckoProxySettings proxy) {
return _apiInstance.upsertProxy(proxy);
}
Future<void> removeProxy(String proxyId) {
return _apiInstance.removeProxy(proxyId);
}
Future<void> setContainerProxy(String contextId, String proxyId) {
return _apiInstance.setContainerProxy(contextId, proxyId);
}
Future<void> clearContainerProxy(String contextId) {
return _apiInstance.clearContainerProxy(contextId);
}
Future<void> removeContainerProxyRelation(String contextId, String proxyId) {
return _apiInstance.removeContainerProxyRelation(contextId, proxyId);
}
Future<void> setSiteAssignments(Map<String, String> assignments) {
return _apiInstance.setSiteAssignments(assignments);
}
@@ -44,6 +44,7 @@ class GeckoEventService extends GeckoStateEvents {
// final _scrollEventSubject = PublishSubject<ScrollEvent>();
final _prefUpdateSubject = PublishSubject<GeckoPref>();
final _siteAssignementSubject = PublishSubject<ContainerSiteAssignment>();
final _proxyLoadErrorSubject = PublishSubject<ProxyLoadError>();
final _tabAddedSubject = PublishSubject<String>();
final _mlProgressSubject = PublishSubject<MlProgressData>();
@@ -71,6 +72,8 @@ class GeckoEventService extends GeckoStateEvents {
Stream<GeckoPref> get prefUpdateEvent => _prefUpdateSubject.stream;
Stream<ContainerSiteAssignment> get siteAssignementEvent =>
_siteAssignementSubject.stream;
Stream<ProxyLoadError> get proxyLoadErrorEvents =>
_proxyLoadErrorSubject.stream;
Stream<String> get tabAddedStream => _tabAddedSubject.stream;
Stream<MlProgressData> get mlProgressEvents => _mlProgressSubject.stream;
@@ -205,6 +208,11 @@ class GeckoEventService extends GeckoStateEvents {
);
}
@override
void onProxyLoadError(int sequence, ProxyLoadError details) {
_proxyLoadErrorSubject.addWhenMoreRecent(sequence, details.tabId, details);
}
@override
void onMlProgress(int sequence, MlProgressData progress) {
_mlProgressSubject.addWhenMoreRecent(sequence, null, progress);
@@ -266,6 +274,7 @@ class GeckoEventService extends GeckoStateEvents {
await _tabAddedSubject.close();
await _prefUpdateSubject.close();
await _siteAssignementSubject.close();
await _proxyLoadErrorSubject.close();
await _mlProgressSubject.close();
await _manifestUpdateSubject.close();
await _translationEngineSubject.close();
@@ -91,7 +91,10 @@ class GeckoHistoryService {
return _api.getVisited(urls);
}
Future<List<HistorySuggestion>> getSuggestions(String query, {int limit = 10}) {
Future<List<HistorySuggestion>> getSuggestions(
String query, {
int limit = 10,
}) {
return _api.getSuggestions(query, limit);
}
@@ -140,6 +143,8 @@ class GeckoHistoryService {
}
Future<void> deleteHistoryMetadataOlderThan(DateTime olderThan) {
return _api.deleteHistoryMetadataOlderThan(olderThan.millisecondsSinceEpoch);
return _api.deleteHistoryMetadataOlderThan(
olderThan.millisecondsSinceEpoch,
);
}
}
File diff suppressed because it is too large Load Diff
@@ -1856,11 +1856,40 @@ abstract class GeckoBrowserExtensionApi {
List<Object> getMarkdown(List<String> htmlList);
}
class GeckoProxySettings {
final String id;
final String title;
final String type;
final String host;
final int port;
final String? username;
final String? password;
final bool proxyDNS;
final bool doNotProxyLocal;
const GeckoProxySettings({
required this.id,
required this.title,
required this.type,
required this.host,
required this.port,
this.username,
this.password,
this.proxyDNS = true,
this.doNotProxyLocal = true,
});
}
@HostApi()
abstract class GeckoContainerProxyApi {
void setProxyPort(int port);
void addContainerProxy(String contextId);
void removeContainerProxy(String contextId);
void upsertProxy(GeckoProxySettings proxy);
void removeProxy(String proxyId);
void setContainerProxy(String contextId, String proxyId);
void clearContainerProxy(String contextId);
void removeContainerProxyRelation(String contextId, String proxyId);
void setSiteAssignments(Map<String, String> assignments);
@async
@@ -1930,6 +1959,20 @@ class ContainerSiteAssignment {
});
}
class ProxyLoadError {
final String? tabId;
final String? contextId;
final String? url;
final String errorType;
ProxyLoadError({
required this.tabId,
required this.contextId,
required this.url,
required this.errorType,
});
}
@FlutterApi()
abstract class GeckoStateEvents {
void onViewReadyStateChange(int sequence, bool state);
@@ -1960,6 +2003,8 @@ abstract class GeckoStateEvents {
void onContainerSiteAssignment(int sequence, ContainerSiteAssignment details);
void onProxyLoadError(int sequence, ProxyLoadError details);
void onMlProgress(int sequence, MlProgressData progress);
void onManifestUpdate(int sequence, String tabId, PwaManifest? manifest);
+37
View File
@@ -0,0 +1,37 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.flutter-plugins-dependencies
/build/
/coverage/
# Built from official sing-box source by scripts/build-libbox-android.sh.
/android/libs/*.aar
!/android/libs/.gitkeep
+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694"
channel: "stable"
project_type: plugin
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
- platform: android
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
@@ -0,0 +1,3 @@
## 0.0.1
* TODO: Describe initial release.
+1
View File
@@ -0,0 +1 @@
TODO: Add your license here.
+59
View File
@@ -0,0 +1,59 @@
# flutter_singbox_proxy
Android sing-box proxy runtime plugin for WebLibre.
The public API is Pigeon-based and intentionally uses a generic JSON profile
boundary. Flutter can add strongly typed editors over time while the native API
stays stable across sing-box protocol/schema updates.
Current state:
- Builds sing-box JSON for multiple simultaneous profile outbounds.
- Creates one authenticated local SOCKS inbound per active profile.
- Returns runtime endpoint metadata for Gecko container proxy integration.
- Exposes status/log callbacks.
- Starts libbox via reflection against the `io.nekohasekai.libbox.*` classes
shipped in the AAR — if the AAR is missing, the plugin still compiles but
`start` reports an `IllegalStateException` at runtime.
## Building official sing-box libbox
The plugin does not commit binary AARs. Build them from the official sing-box
checkout before release/F-Droid builds:
```sh
packages/flutter_singbox_proxy/scripts/build-libbox-android.sh \
--source /path/to/sing-box
```
By default the script looks for `../../../sing-box` relative to this package,
which matches the local workspace layout used during development. It copies
`libbox.aar` into `android/libs/`; Gradle consumes that AAR automatically when
present.
For F-Droid metadata, call the package-local prebuild script:
```sh
packages/flutter_singbox_proxy/scripts/fdroid-prebuild.sh \
--source /path/to/sing-box
```
The build requires OpenJDK 17, Go, Android SDK/NDK, and gomobile/gobind. If
`JAVA_HOME` is unset and `/usr/lib/jvm/java-17-openjdk` exists, the script uses
that automatically. If gomobile/gobind are missing, the script installs the
official SagerNet gomobile tools with `go install`.
### Version compatibility
The script pins `github.com/sagernet/gomobile/cmd/gomobile@v0.1.12` and
`gobind@v0.1.12`. The sing-box source itself is whatever you point `--source`
at; the runtime accesses `io.nekohasekai.libbox.*` via reflection, so API drift
across sing-box releases surfaces as a `NoSuchMethodException` at start time
rather than a build failure. When bumping sing-box:
1. Rebuild the AAR with the new tag.
2. Smoke-test `start`/`stop` on a profile to catch missing/renamed setup
methods (notably `SetupOptions.setOomKillerDisabled` /
`setOomKillerEnabled``LibboxRuntime` falls back between the two).
3. Update this README with the verified sing-box tag if relying on it for a
release.
@@ -0,0 +1,4 @@
# Analysis options for flutter_singbox_proxy package
# Includes root configuration from monorepo
include: ../../analysis_options.yaml
@@ -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")
)
}
}
@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
@@ -0,0 +1,17 @@
# flutter_singbox_proxy_example
Demonstrates how to use the flutter_singbox_proxy plugin.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
@@ -0,0 +1,4 @@
# Analysis options for flutter_singbox_proxy example
# Includes root configuration from monorepo
include: ../../../analysis_options.yaml
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
@@ -0,0 +1,38 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
android {
namespace = "eu.weblibre.flutter_singbox_proxy_example"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17
}
defaultConfig {
applicationId = "eu.weblibre.flutter_singbox_proxy_example"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
signingConfig = signingConfigs.debug
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="flutter_singbox_proxy_example"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package eu.weblibre.flutter_singbox_proxy_example
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,18 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = "../../build"
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
@@ -0,0 +1,25 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}()
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.13.2" apply false
id "org.jetbrains.kotlin.android" version "2.3.21" apply false
}
include ":app"
@@ -0,0 +1,23 @@
// This is a basic Flutter integration test.
//
// Since integration tests run in a full Flutter application, they can interact
// with the host side of a plugin implementation, unlike Dart unit tests.
//
// For more information about Flutter integration tests, please see
// https://flutter.dev/to/integration-testing
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('getState test', (WidgetTester tester) async {
final FlutterSingboxProxy plugin = FlutterSingboxProxy();
final state = await plugin.getState();
expect(state.status, SingboxProxyRuntimeStatus.stopped);
});
}
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
final proxy = FlutterSingboxProxy();
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Plugin example app')),
body: FutureBuilder<SingboxProxyRuntimeState>(
future: proxy.getState(),
builder: (context, snapshot) {
final status = snapshot.data?.status.name ?? 'unknown';
return Center(child: Text('sing-box proxy status: $status'));
},
),
),
);
}
}
@@ -0,0 +1,86 @@
name: flutter_singbox_proxy_example
description: "Demonstrates how to use the flutter_singbox_proxy plugin."
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
resolution: workspace
environment:
sdk: ^3.10.4
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
flutter_singbox_proxy:
# When depending on this package from a real application you should use:
# flutter_singbox_proxy: ^x.y.z
# See https://dart.dev/tools/pub/dependencies#version-constraints
# The example app is bundled with the plugin so we use a path dependency on
# the parent directory to use the current plugin's version.
path: ../
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
dev_dependencies:
integration_test:
sdk: flutter
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
@@ -0,0 +1,28 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_singbox_proxy_example/main.dart';
void main() {
testWidgets('shows proxy runtime status label', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Without a native messenger in widget tests the future remains pending,
// but the static label should still be present.
expect(
find.byWidgetPredicate(
(Widget widget) =>
widget is Text && widget.data!.startsWith('sing-box proxy status:'),
),
findsOneWidget,
);
});
}
@@ -0,0 +1,71 @@
import 'dart:async';
import 'src/singbox_proxy_api.g.dart';
export 'src/singbox_proxy_api.g.dart'
show
SingboxProxyConfigResult,
SingboxProxyDnsConfig,
SingboxProxyDnsServerConfig,
SingboxProxyLogMessage,
SingboxProxyProfile,
SingboxProxyProfileType,
SingboxProxyRuntimeEndpoint,
SingboxProxyRuntimeOptions,
SingboxProxyRuntimeState,
SingboxProxyRuntimeStatus;
class FlutterSingboxProxy implements SingboxProxyEventsApi {
FlutterSingboxProxy({SingboxProxyApi? api})
: _api = api ?? SingboxProxyApi() {
SingboxProxyEventsApi.setUp(this);
}
final SingboxProxyApi _api;
final _stateController =
StreamController<SingboxProxyRuntimeState>.broadcast();
final _logController = StreamController<SingboxProxyLogMessage>.broadcast();
Stream<SingboxProxyRuntimeState> get stateStream => _stateController.stream;
Stream<SingboxProxyLogMessage> get logStream => _logController.stream;
Future<String?> validateProfile(SingboxProxyProfile profile) {
return _api.validateProfile(profile);
}
Future<SingboxProxyConfigResult> buildConfig(
List<SingboxProxyProfile> profiles, {
SingboxProxyRuntimeOptions? options,
}) {
return _api.buildConfig(profiles, options ?? SingboxProxyRuntimeOptions());
}
Future<SingboxProxyRuntimeState> start(
List<SingboxProxyProfile> profiles, {
SingboxProxyRuntimeOptions? options,
}) {
return _api.start(profiles, options ?? SingboxProxyRuntimeOptions());
}
Future<void> stop(List<String> profileIds) => _api.stop(profileIds);
Future<void> stopAll() => _api.stopAll();
Future<SingboxProxyRuntimeState> getState() => _api.getState();
@override
void onStateChanged(SingboxProxyRuntimeState state) {
_stateController.add(state);
}
@override
void onLogMessage(SingboxProxyLogMessage message) {
_logController.add(message);
}
Future<void> dispose() async {
SingboxProxyEventsApi.setUp(null);
await _stateController.close();
await _logController.close();
}
}
@@ -0,0 +1,912 @@
// Autogenerated from Pigeon (v26.3.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: unused_import, unused_shown_name
// ignore_for_file: type=lint
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List;
import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
}) {
if (replyList == null) {
throw PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".',
);
} else if (replyList.length > 1) {
throw PlatformException(
code: replyList[0]! as String,
message: replyList[1] as String?,
details: replyList[2],
);
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
}
return replyList.firstOrNull;
}
List<Object?> wrapResponse({
Object? result,
PlatformException? error,
bool empty = false,
}) {
if (empty) {
return <Object?>[];
}
if (error == null) {
return <Object?>[result];
}
return <Object?>[error.code, error.message, error.details];
}
bool _deepEquals(Object? a, Object? b) {
if (identical(a, b)) {
return true;
}
if (a is double && b is double) {
if (a.isNaN && b.isNaN) {
return true;
}
return a == b;
}
if (a is List && b is List) {
return a.length == b.length &&
a.indexed.every(
((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
);
}
if (a is Map && b is Map) {
if (a.length != b.length) {
return false;
}
for (final MapEntry<Object?, Object?> entryA in a.entries) {
bool found = false;
for (final MapEntry<Object?, Object?> entryB in b.entries) {
if (_deepEquals(entryA.key, entryB.key)) {
if (_deepEquals(entryA.value, entryB.value)) {
found = true;
break;
} else {
return false;
}
}
}
if (!found) {
return false;
}
}
return true;
}
return a == b;
}
int _deepHash(Object? value) {
if (value is List) {
return Object.hashAll(value.map(_deepHash));
}
if (value is Map) {
int result = 0;
for (final MapEntry<Object?, Object?> entry in value.entries) {
result += (_deepHash(entry.key) * 31) ^ _deepHash(entry.value);
}
return result;
}
if (value is double && value.isNaN) {
// Normalize NaN to a consistent hash.
return 0x7FF8000000000000.hashCode;
}
if (value is double && value == 0.0) {
// Normalize -0.0 to 0.0 so they have the same hash code.
return 0.0.hashCode;
}
return value.hashCode;
}
enum SingboxProxyProfileType {
socks,
http,
shadowsocks,
vmess,
vless,
trojan,
naive,
hysteria,
hysteria2,
tuic,
ssh,
wireguard,
shadowTls,
anyTls,
customOutbound,
}
enum SingboxProxyRuntimeStatus { stopped, starting, running, stopping, error }
class SingboxProxyProfile {
SingboxProxyProfile({
required this.id,
required this.name,
required this.type,
required this.configJson,
this.secretJson,
});
String id;
String name;
SingboxProxyProfileType type;
/// Public profile configuration as JSON. The schema is intentionally owned by
/// the profile type so the Pigeon API stays stable while sing-box evolves.
String configJson;
/// Resolved secret values as JSON. Flutter stores secrets independently and
/// only passes them to native code when building or starting a runtime config.
String? secretJson;
List<Object?> _toList() {
return <Object?>[id, name, type, configJson, secretJson];
}
Object encode() {
return _toList();
}
static SingboxProxyProfile decode(Object result) {
result as List<Object?>;
return SingboxProxyProfile(
id: result[0]! as String,
name: result[1]! as String,
type: result[2]! as SingboxProxyProfileType,
configJson: result[3]! as String,
secretJson: result[4] as String?,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! SingboxProxyProfile || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(id, other.id) &&
_deepEquals(name, other.name) &&
_deepEquals(type, other.type) &&
_deepEquals(configJson, other.configJson) &&
_deepEquals(secretJson, other.secretJson);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class SingboxProxyRuntimeOptions {
SingboxProxyRuntimeOptions({
this.preferredBasePort,
this.blockUnmatchedTraffic = true,
this.dnsConfig,
this.bootstrapDohUrl,
});
/// Optional preferred start port for generated local SOCKS inbounds.
int? preferredBasePort;
/// If true, traffic entering sing-box without a matching inbound rule is
/// rejected instead of falling through to direct.
bool blockUnmatchedTraffic;
/// 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.
SingboxProxyDnsConfig? dnsConfig;
/// 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.
String? bootstrapDohUrl;
List<Object?> _toList() {
return <Object?>[
preferredBasePort,
blockUnmatchedTraffic,
dnsConfig,
bootstrapDohUrl,
];
}
Object encode() {
return _toList();
}
static SingboxProxyRuntimeOptions decode(Object result) {
result as List<Object?>;
return SingboxProxyRuntimeOptions(
preferredBasePort: result[0] as int?,
blockUnmatchedTraffic: result[1]! as bool,
dnsConfig: result[2] as SingboxProxyDnsConfig?,
bootstrapDohUrl: result[3] as String?,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! SingboxProxyRuntimeOptions ||
other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(preferredBasePort, other.preferredBasePort) &&
_deepEquals(blockUnmatchedTraffic, other.blockUnmatchedTraffic) &&
_deepEquals(dnsConfig, other.dnsConfig) &&
_deepEquals(bootstrapDohUrl, other.bootstrapDohUrl);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class SingboxProxyDnsServerConfig {
SingboxProxyDnsServerConfig({
required this.tag,
required this.address,
this.detourTag,
this.matchDomainSuffixes = const <String>[],
this.matchGeosites = const <String>[],
this.matchOutbounds = const <String>[],
this.matchInbounds = const <String>[],
});
/// sing-box server tag, used to reference the server from `dns.rules`.
String tag;
/// 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).
String address;
/// Outbound tag to dial the resolver through, or `direct` for direct, or
/// null when sing-box should pick automatically.
String? detourTag;
/// If non-empty, attaches a `dns.rules` entry routing matching domains to
/// this server.
List<String> matchDomainSuffixes;
/// Advanced sing-box geosite selectors (e.g. `geosite:cn`).
List<String> matchGeosites;
/// 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".
List<String> matchOutbounds;
/// 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.
List<String> matchInbounds;
List<Object?> _toList() {
return <Object?>[
tag,
address,
detourTag,
matchDomainSuffixes,
matchGeosites,
matchOutbounds,
matchInbounds,
];
}
Object encode() {
return _toList();
}
static SingboxProxyDnsServerConfig decode(Object result) {
result as List<Object?>;
return SingboxProxyDnsServerConfig(
tag: result[0]! as String,
address: result[1]! as String,
detourTag: result[2] as String?,
matchDomainSuffixes: (result[3]! as List<Object?>).cast<String>(),
matchGeosites: (result[4]! as List<Object?>).cast<String>(),
matchOutbounds: (result[5]! as List<Object?>).cast<String>(),
matchInbounds: (result[6]! as List<Object?>).cast<String>(),
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! SingboxProxyDnsServerConfig ||
other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(tag, other.tag) &&
_deepEquals(address, other.address) &&
_deepEquals(detourTag, other.detourTag) &&
_deepEquals(matchDomainSuffixes, other.matchDomainSuffixes) &&
_deepEquals(matchGeosites, other.matchGeosites) &&
_deepEquals(matchOutbounds, other.matchOutbounds) &&
_deepEquals(matchInbounds, other.matchInbounds);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class SingboxProxyDnsConfig {
SingboxProxyDnsConfig({
required this.servers,
this.finalServerTag,
required this.domainStrategy,
});
List<SingboxProxyDnsServerConfig> servers;
/// Server tag used as `dns.final`. When null, sing-box uses the first
/// server in the list as the fallback.
String? finalServerTag;
/// sing-box `dns.strategy` string. e.g. `prefer_ipv4`, `ipv4_only`.
String domainStrategy;
List<Object?> _toList() {
return <Object?>[servers, finalServerTag, domainStrategy];
}
Object encode() {
return _toList();
}
static SingboxProxyDnsConfig decode(Object result) {
result as List<Object?>;
return SingboxProxyDnsConfig(
servers: (result[0]! as List<Object?>)
.cast<SingboxProxyDnsServerConfig>(),
finalServerTag: result[1] as String?,
domainStrategy: result[2]! as String,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! SingboxProxyDnsConfig || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(servers, other.servers) &&
_deepEquals(finalServerTag, other.finalServerTag) &&
_deepEquals(domainStrategy, other.domainStrategy);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class SingboxProxyRuntimeEndpoint {
SingboxProxyRuntimeEndpoint({
required this.profileId,
required this.host,
required this.port,
required this.username,
required this.password,
});
String profileId;
String host;
int port;
String username;
String password;
List<Object?> _toList() {
return <Object?>[profileId, host, port, username, password];
}
Object encode() {
return _toList();
}
static SingboxProxyRuntimeEndpoint decode(Object result) {
result as List<Object?>;
return SingboxProxyRuntimeEndpoint(
profileId: result[0]! as String,
host: result[1]! as String,
port: result[2]! as int,
username: result[3]! as String,
password: result[4]! as String,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! SingboxProxyRuntimeEndpoint ||
other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(profileId, other.profileId) &&
_deepEquals(host, other.host) &&
_deepEquals(port, other.port) &&
_deepEquals(username, other.username) &&
_deepEquals(password, other.password);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class SingboxProxyRuntimeState {
SingboxProxyRuntimeState({
required this.status,
required this.endpoints,
this.message,
});
SingboxProxyRuntimeStatus status;
List<SingboxProxyRuntimeEndpoint> endpoints;
String? message;
List<Object?> _toList() {
return <Object?>[status, endpoints, message];
}
Object encode() {
return _toList();
}
static SingboxProxyRuntimeState decode(Object result) {
result as List<Object?>;
return SingboxProxyRuntimeState(
status: result[0]! as SingboxProxyRuntimeStatus,
endpoints: (result[1]! as List<Object?>)
.cast<SingboxProxyRuntimeEndpoint>(),
message: result[2] as String?,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! SingboxProxyRuntimeState ||
other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(status, other.status) &&
_deepEquals(endpoints, other.endpoints) &&
_deepEquals(message, other.message);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class SingboxProxyConfigResult {
SingboxProxyConfigResult({required this.configJson, required this.endpoints});
String configJson;
List<SingboxProxyRuntimeEndpoint> endpoints;
List<Object?> _toList() {
return <Object?>[configJson, endpoints];
}
Object encode() {
return _toList();
}
static SingboxProxyConfigResult decode(Object result) {
result as List<Object?>;
return SingboxProxyConfigResult(
configJson: result[0]! as String,
endpoints: (result[1]! as List<Object?>)
.cast<SingboxProxyRuntimeEndpoint>(),
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! SingboxProxyConfigResult ||
other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(configJson, other.configJson) &&
_deepEquals(endpoints, other.endpoints);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class SingboxProxyLogMessage {
SingboxProxyLogMessage({
required this.level,
required this.message,
required this.timestamp,
this.profileId,
});
String level;
String message;
int timestamp;
String? profileId;
List<Object?> _toList() {
return <Object?>[level, message, timestamp, profileId];
}
Object encode() {
return _toList();
}
static SingboxProxyLogMessage decode(Object result) {
result as List<Object?>;
return SingboxProxyLogMessage(
level: result[0]! as String,
message: result[1]! as String,
timestamp: result[2]! as int,
profileId: result[3] as String?,
);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
bool operator ==(Object other) {
if (other is! SingboxProxyLogMessage || other.runtimeType != runtimeType) {
return false;
}
if (identical(this, other)) {
return true;
}
return _deepEquals(level, other.level) &&
_deepEquals(message, other.message) &&
_deepEquals(timestamp, other.timestamp) &&
_deepEquals(profileId, other.profileId);
}
@override
// ignore: avoid_equals_and_hash_code_on_mutable_classes
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is SingboxProxyProfileType) {
buffer.putUint8(129);
writeValue(buffer, value.index);
} else if (value is SingboxProxyRuntimeStatus) {
buffer.putUint8(130);
writeValue(buffer, value.index);
} else if (value is SingboxProxyProfile) {
buffer.putUint8(131);
writeValue(buffer, value.encode());
} else if (value is SingboxProxyRuntimeOptions) {
buffer.putUint8(132);
writeValue(buffer, value.encode());
} else if (value is SingboxProxyDnsServerConfig) {
buffer.putUint8(133);
writeValue(buffer, value.encode());
} else if (value is SingboxProxyDnsConfig) {
buffer.putUint8(134);
writeValue(buffer, value.encode());
} else if (value is SingboxProxyRuntimeEndpoint) {
buffer.putUint8(135);
writeValue(buffer, value.encode());
} else if (value is SingboxProxyRuntimeState) {
buffer.putUint8(136);
writeValue(buffer, value.encode());
} else if (value is SingboxProxyConfigResult) {
buffer.putUint8(137);
writeValue(buffer, value.encode());
} else if (value is SingboxProxyLogMessage) {
buffer.putUint8(138);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 129:
final value = readValue(buffer) as int?;
return value == null ? null : SingboxProxyProfileType.values[value];
case 130:
final value = readValue(buffer) as int?;
return value == null ? null : SingboxProxyRuntimeStatus.values[value];
case 131:
return SingboxProxyProfile.decode(readValue(buffer)!);
case 132:
return SingboxProxyRuntimeOptions.decode(readValue(buffer)!);
case 133:
return SingboxProxyDnsServerConfig.decode(readValue(buffer)!);
case 134:
return SingboxProxyDnsConfig.decode(readValue(buffer)!);
case 135:
return SingboxProxyRuntimeEndpoint.decode(readValue(buffer)!);
case 136:
return SingboxProxyRuntimeState.decode(readValue(buffer)!);
case 137:
return SingboxProxyConfigResult.decode(readValue(buffer)!);
case 138:
return SingboxProxyLogMessage.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
}
}
}
class SingboxProxyApi {
/// Constructor for [SingboxProxyApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
SingboxProxyApi({
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<String?> validateProfile(SingboxProxyProfile profile) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.validateProfile$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[profile],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
return pigeonVar_replyValue as String?;
}
Future<SingboxProxyConfigResult> buildConfig(
List<SingboxProxyProfile> profiles,
SingboxProxyRuntimeOptions options,
) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.buildConfig$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[profiles, options],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as SingboxProxyConfigResult;
}
Future<SingboxProxyRuntimeState> start(
List<SingboxProxyProfile> profiles,
SingboxProxyRuntimeOptions options,
) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.start$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[profiles, options],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as SingboxProxyRuntimeState;
}
Future<void> stop(List<String> profileIds) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.stop$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[profileIds],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
}
Future<void> stopAll() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.stopAll$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
}
Future<SingboxProxyRuntimeState> getState() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyApi.getState$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as SingboxProxyRuntimeState;
}
}
abstract class SingboxProxyEventsApi {
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
void onStateChanged(SingboxProxyRuntimeState state);
void onLogMessage(SingboxProxyLogMessage message);
static void setUp(
SingboxProxyEventsApi? api, {
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyEventsApi.onStateChanged$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
pigeonVar_channel.setMessageHandler((Object? message) async {
final List<Object?> args = message! as List<Object?>;
final SingboxProxyRuntimeState arg_state =
args[0]! as SingboxProxyRuntimeState;
try {
api.onStateChanged(arg_state);
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
}
});
}
}
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.flutter_singbox_proxy.SingboxProxyEventsApi.onLogMessage$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
pigeonVar_channel.setMessageHandler((Object? message) async {
final List<Object?> args = message! as List<Object?>;
final SingboxProxyLogMessage arg_message =
args[0]! as SingboxProxyLogMessage;
try {
api.onLogMessage(arg_message);
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
}
});
}
}
}
}
@@ -0,0 +1,227 @@
import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(
PigeonOptions(
dartPackageName: 'flutter_singbox_proxy',
dartOut: 'lib/src/singbox_proxy_api.g.dart',
dartOptions: DartOptions(),
kotlinOut:
'android/src/main/kotlin/eu/weblibre/flutter_singbox_proxy/generated/SingboxProxyApi.g.kt',
kotlinOptions: KotlinOptions(
package: 'eu.weblibre.flutter_singbox_proxy.generated',
),
),
)
enum SingboxProxyProfileType {
socks,
http,
shadowsocks,
vmess,
vless,
trojan,
naive,
hysteria,
hysteria2,
tuic,
ssh,
wireguard,
shadowTls,
anyTls,
customOutbound,
}
enum SingboxProxyRuntimeStatus { stopped, starting, running, stopping, error }
class SingboxProxyProfile {
SingboxProxyProfile({
required this.id,
required this.name,
required this.type,
required this.configJson,
this.secretJson,
});
final String id;
final String name;
final SingboxProxyProfileType type;
/// Public profile configuration as JSON. The schema is intentionally owned by
/// the profile type so the Pigeon API stays stable while sing-box evolves.
final String configJson;
/// Resolved secret values as JSON. Flutter stores secrets independently and
/// only passes them to native code when building or starting a runtime config.
final String? secretJson;
}
class SingboxProxyRuntimeOptions {
SingboxProxyRuntimeOptions({
this.preferredBasePort,
this.blockUnmatchedTraffic = true,
this.dnsConfig,
this.bootstrapDohUrl,
});
/// Optional preferred start port for generated local SOCKS inbounds.
final int? preferredBasePort;
/// If true, traffic entering sing-box without a matching inbound rule is
/// rejected instead of falling through to direct.
final bool blockUnmatchedTraffic;
/// 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.
final SingboxProxyDnsConfig? dnsConfig;
/// 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.
final String? bootstrapDohUrl;
}
class SingboxProxyDnsServerConfig {
SingboxProxyDnsServerConfig({
required this.tag,
required this.address,
this.detourTag,
this.matchDomainSuffixes = const <String>[],
this.matchGeosites = const <String>[],
this.matchOutbounds = const <String>[],
this.matchInbounds = const <String>[],
});
/// sing-box server tag, used to reference the server from `dns.rules`.
final String tag;
/// 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).
final String address;
/// Outbound tag to dial the resolver through, or `direct` for direct, or
/// null when sing-box should pick automatically.
final String? detourTag;
/// If non-empty, attaches a `dns.rules` entry routing matching domains to
/// this server.
final List<String> matchDomainSuffixes;
/// Advanced sing-box geosite selectors (e.g. `geosite:cn`).
final List<String> matchGeosites;
/// 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".
final List<String> matchOutbounds;
/// 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.
final List<String> matchInbounds;
}
class SingboxProxyDnsConfig {
SingboxProxyDnsConfig({
required this.servers,
this.finalServerTag,
required this.domainStrategy,
});
final List<SingboxProxyDnsServerConfig> servers;
/// Server tag used as `dns.final`. When null, sing-box uses the first
/// server in the list as the fallback.
final String? finalServerTag;
/// sing-box `dns.strategy` string. e.g. `prefer_ipv4`, `ipv4_only`.
final String domainStrategy;
}
class SingboxProxyRuntimeEndpoint {
SingboxProxyRuntimeEndpoint({
required this.profileId,
required this.host,
required this.port,
required this.username,
required this.password,
});
final String profileId;
final String host;
final int port;
final String username;
final String password;
}
class SingboxProxyRuntimeState {
SingboxProxyRuntimeState({
required this.status,
required this.endpoints,
this.message,
});
final SingboxProxyRuntimeStatus status;
final List<SingboxProxyRuntimeEndpoint> endpoints;
final String? message;
}
class SingboxProxyConfigResult {
SingboxProxyConfigResult({required this.configJson, required this.endpoints});
final String configJson;
final List<SingboxProxyRuntimeEndpoint> endpoints;
}
class SingboxProxyLogMessage {
SingboxProxyLogMessage({
required this.level,
required this.message,
required this.timestamp,
this.profileId,
});
final String level;
final String message;
final int timestamp;
final String? profileId;
}
@HostApi()
abstract class SingboxProxyApi {
@async
String? validateProfile(SingboxProxyProfile profile);
@async
SingboxProxyConfigResult buildConfig(
List<SingboxProxyProfile> profiles,
SingboxProxyRuntimeOptions options,
);
@async
SingboxProxyRuntimeState start(
List<SingboxProxyProfile> profiles,
SingboxProxyRuntimeOptions options,
);
@async
void stop(List<String> profileIds);
@async
void stopAll();
SingboxProxyRuntimeState getState();
}
@FlutterApi()
abstract class SingboxProxyEventsApi {
void onStateChanged(SingboxProxyRuntimeState state);
void onLogMessage(SingboxProxyLogMessage message);
}
@@ -0,0 +1,71 @@
name: flutter_singbox_proxy
description: "Android sing-box proxy runtime plugin for WebLibre."
version: 0.0.1
homepage:
resolution: workspace
environment:
sdk: ^3.10.4
flutter: '>=3.3.0'
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
lint: ^2.8.0
pigeon: ^26.3.2
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# This section identifies this Flutter project as a plugin project.
# The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.)
# which should be registered in the plugin registry. This is required for
# using method channels.
# The Android 'package' specifies package in which the registered class is.
# This is required for using method channels on Android.
# The 'ffiPlugin' specifies that native code should be built and bundled.
# This is required for using `dart:ffi`.
# All these are used by the tooling to maintain consistency when
# adding or updating assets for this project.
plugin:
platforms:
android:
package: eu.weblibre.flutter_singbox_proxy
pluginClass: FlutterSingboxProxyPlugin
# To add assets to your plugin package, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
#
# For details regarding assets in packages, see
# https://flutter.dev/to/asset-from-package
#
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# To add custom fonts to your plugin package, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts in packages, see
# https://flutter.dev/to/font-from-package
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
PACKAGE_DIR="$(cd "$SCRIPT_DIR/.." && pwd -P)"
DEFAULT_SING_BOX_SOURCE="$(cd "$PACKAGE_DIR/../../.." && pwd -P)/sing-box"
SING_BOX_SOURCE="${SING_BOX_SOURCE:-$DEFAULT_SING_BOX_SOURCE}"
OUTPUT_DIR="$PACKAGE_DIR/android/libs"
BUILD_DEBUG=0
PLATFORM=""
INSTALL_GOMOBILE=1
usage() {
cat <<'EOF'
Usage: build-libbox-android.sh [options]
Build official sing-box libbox Android AARs and copy them into this plugin.
Options:
--source PATH sing-box source checkout (default: ../../../sing-box)
--output-dir PATH destination for libbox.aar (default: android/libs)
--platform TARGET gomobile target, e.g. android/arm64 for faster local builds
--debug build sing-box debug variant
--skip-install-gomobile require gomobile/gobind to already exist in GOPATH/bin
-h, --help show this help
Environment:
SING_BOX_SOURCE same as --source
JAVA_HOME should point to OpenJDK 17; auto-detected locally when unset
ANDROID_HOME Android SDK path, required by sing-box build tooling
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--source)
SING_BOX_SOURCE="$2"
shift 2
;;
--output-dir)
OUTPUT_DIR="$2"
shift 2
;;
--platform)
PLATFORM="$2"
shift 2
;;
--debug)
BUILD_DEBUG=1
shift
;;
--skip-install-gomobile)
INSTALL_GOMOBILE=0
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ ! -f "$SING_BOX_SOURCE/Makefile" || ! -d "$SING_BOX_SOURCE/experimental/libbox" ]]; then
echo "sing-box source not found at: $SING_BOX_SOURCE" >&2
echo "Pass --source PATH or set SING_BOX_SOURCE." >&2
exit 1
fi
if [[ -z "${JAVA_HOME:-}" && -x /usr/lib/jvm/java-17-openjdk/bin/java ]]; then
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk
fi
JAVA_BIN="${JAVA_HOME:+$JAVA_HOME/bin/}java"
if ! "$JAVA_BIN" --version 2>/dev/null | grep -q 'openjdk 17'; then
echo "sing-box Android libbox build requires OpenJDK 17." >&2
echo "Current java:" >&2
"$JAVA_BIN" --version >&2 || true
exit 1
fi
if ! command -v go >/dev/null 2>&1; then
echo "go is required to build sing-box libbox." >&2
exit 1
fi
GOPATH="${GOPATH:-$(go env GOPATH)}"
if [[ $INSTALL_GOMOBILE -eq 1 ]]; then
needs_gomobile_install=0
if [[ ! -x "$GOPATH/bin/gomobile" || ! -x "$GOPATH/bin/gobind" ]]; then
needs_gomobile_install=1
elif ! "$GOPATH/bin/gomobile" bind -h 2>&1 | grep -q -- '-libname'; then
needs_gomobile_install=1
fi
if [[ $needs_gomobile_install -eq 1 ]]; then
go install -v github.com/sagernet/gomobile/cmd/gomobile@v0.1.12
go install -v github.com/sagernet/gomobile/cmd/gobind@v0.1.12
fi
fi
mkdir -p "$OUTPUT_DIR"
pushd "$SING_BOX_SOURCE" >/dev/null
args=(go run ./cmd/internal/build_libbox -target android)
if [[ $BUILD_DEBUG -eq 1 ]]; then
args+=(-debug)
fi
if [[ -n "$PLATFORM" ]]; then
args+=(-platform "$PLATFORM")
fi
"${args[@]}"
cp -f libbox.aar "$OUTPUT_DIR/libbox.aar"
if [[ -f libbox-legacy.aar ]]; then
cp -f libbox-legacy.aar "$OUTPUT_DIR/libbox-legacy.aar"
fi
popd >/dev/null
echo "Installed $OUTPUT_DIR/libbox.aar"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$OUTPUT_DIR"/libbox*.aar
fi
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd -P)"
"$REPO_ROOT/native/go_mobile_runtime/scripts/fdroid-prebuild.sh" "$@"
@@ -0,0 +1,25 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
void main() {
test('profile model keeps generic sing-box config boundary', () {
final profile = SingboxProxyProfile(
id: 'wg-home',
name: 'WireGuard Home',
type: SingboxProxyProfileType.wireguard,
configJson: '{"server":"example.test"}',
secretJson: '{"private_key":"secret"}',
);
expect(profile.id, 'wg-home');
expect(profile.type, SingboxProxyProfileType.wireguard);
expect(profile.secretJson, contains('private_key'));
});
test('runtime options default to blocking unmatched traffic', () {
final options = SingboxProxyRuntimeOptions();
expect(options.preferredBasePort, isNull);
expect(options.blockUnmatchedTraffic, isTrue);
});
}
+13 -2
View File
@@ -22,6 +22,16 @@ allprojects {
}
}
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"
@@ -53,8 +63,9 @@ android {
implementation("info.guardianproject:tor-android:0.4.9.6")
implementation("info.guardianproject:jtorctl:0.4.5.7")
// Pluggable transports
implementation("com.netzarchitekten:IPtProxy:5.4.1")
// Pluggable transports are provided by the app-level combined gomobile AAR.
requireWebLibreGoMobileAar()
compileOnly(files(weblibreGoMobileAar))
// Coroutines for async operations
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
@@ -1,6 +1,5 @@
package eu.weblibre.flutter_tor
import IPtProxy.Controller
import android.content.ComponentName
import android.content.Context
import android.content.Intent
@@ -2,9 +2,9 @@ package eu.weblibre.flutter_tor
import android.content.Context
import android.util.Log
import IPtProxy.Controller
import IPtProxy.IPtProxy
import IPtProxy.OnTransportEvents
import io.nekohasekai.IPtProxy.Controller
import io.nekohasekai.IPtProxy.IPtProxy
import io.nekohasekai.IPtProxy.OnTransportEvents
import java.io.File
/**
@@ -19,11 +19,12 @@
*/
package eu.weblibre.flutter_tor
import IPtProxy.IPtProxy
import eu.weblibre.flutter_tor.generated.IPtProxyController
import eu.weblibre.flutter_tor.generated.TransportType
import io.nekohasekai.IPtProxy.Controller
import io.nekohasekai.IPtProxy.IPtProxy
class ProxyImpl(val controller: IPtProxy.Controller) : IPtProxyController {
class ProxyImpl(val controller: Controller) : IPtProxyController {
override fun start(proxyType: TransportType, proxy: String): Long {
val type = when (proxyType) {
TransportType.SNOWFLAKE -> IPtProxy.Snowflake
@@ -55,4 +56,4 @@ class ProxyImpl(val controller: IPtProxy.Controller) : IPtProxyController {
controller.stop(type)
}
}
}
@@ -1,7 +1,7 @@
package eu.weblibre.flutter_tor
import eu.weblibre.flutter_tor.generated.TorConfiguration
import IPtProxy.IPtProxy
import io.nekohasekai.IPtProxy.IPtProxy
import java.io.File
/**
+123 -78
View File
@@ -10,9 +10,9 @@ import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
}) {
if (replyList == null) {
throw PlatformException(
@@ -34,8 +34,11 @@ Object? _extractReplyValueOrThrow(
return replyList.firstOrNull;
}
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
List<Object?> wrapResponse({
Object? result,
PlatformException? error,
bool empty = false,
}) {
if (empty) {
return <Object?>[];
}
@@ -44,6 +47,7 @@ List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty
}
return <Object?>[error.code, error.message, error.details];
}
bool _deepEquals(Object? a, Object? b) {
if (identical(a, b)) {
return true;
@@ -56,8 +60,9 @@ bool _deepEquals(Object? a, Object? b) {
}
if (a is List && b is List) {
return a.length == b.length &&
a.indexed
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
a.indexed.every(
((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
);
}
if (a is Map && b is Map) {
if (a.length != b.length) {
@@ -106,23 +111,29 @@ int _deepHash(Object? value) {
return value.hashCode;
}
/// Transport types for Tor connections
enum TransportType {
/// Direct Tor connection (no bridges)
none,
/// obfs4 pluggable transport
obfs4,
/// Snowflake pluggable transport (default broker)
snowflake,
/// Snowflake via AMP cache
snowflakeAmp,
/// Meek pluggable transport
meek,
/// Meek via Azure CDN
meekAzure,
/// WebTunnel pluggable transport
webtunnel,
/// Custom bridge lines (passthrough)
custom,
}
@@ -163,7 +174,8 @@ class TorConfiguration {
}
Object encode() {
return _toList(); }
return _toList();
}
static TorConfiguration decode(Object result) {
result as List<Object?>;
@@ -185,7 +197,11 @@ class TorConfiguration {
if (identical(this, other)) {
return true;
}
return _deepEquals(transport, other.transport) && _deepEquals(bridgeLines, other.bridgeLines) && _deepEquals(entryNodeCountries, other.entryNodeCountries) && _deepEquals(exitNodeCountries, other.exitNodeCountries) && _deepEquals(strictNodes, other.strictNodes);
return _deepEquals(transport, other.transport) &&
_deepEquals(bridgeLines, other.bridgeLines) &&
_deepEquals(entryNodeCountries, other.entryNodeCountries) &&
_deepEquals(exitNodeCountries, other.exitNodeCountries) &&
_deepEquals(strictNodes, other.strictNodes);
}
@override
@@ -229,7 +245,8 @@ class TorStatus {
}
Object encode() {
return _toList(); }
return _toList();
}
static TorStatus decode(Object result) {
result as List<Object?>;
@@ -251,7 +268,11 @@ class TorStatus {
if (identical(this, other)) {
return true;
}
return _deepEquals(isRunning, other.isRunning) && _deepEquals(socksPort, other.socksPort) && _deepEquals(bootstrapProgress, other.bootstrapProgress) && _deepEquals(currentCircuit, other.currentCircuit) && _deepEquals(exitNodeCountry, other.exitNodeCountry);
return _deepEquals(isRunning, other.isRunning) &&
_deepEquals(socksPort, other.socksPort) &&
_deepEquals(bootstrapProgress, other.bootstrapProgress) &&
_deepEquals(currentCircuit, other.currentCircuit) &&
_deepEquals(exitNodeCountry, other.exitNodeCountry);
}
@override
@@ -277,15 +298,12 @@ class TorLogMessage {
int timestamp;
List<Object?> _toList() {
return <Object?>[
severity,
message,
timestamp,
];
return <Object?>[severity, message, timestamp];
}
Object encode() {
return _toList(); }
return _toList();
}
static TorLogMessage decode(Object result) {
result as List<Object?>;
@@ -305,7 +323,9 @@ class TorLogMessage {
if (identical(this, other)) {
return true;
}
return _deepEquals(severity, other.severity) && _deepEquals(message, other.message) && _deepEquals(timestamp, other.timestamp);
return _deepEquals(severity, other.severity) &&
_deepEquals(message, other.message) &&
_deepEquals(timestamp, other.timestamp);
}
@override
@@ -313,7 +333,6 @@ class TorLogMessage {
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -321,16 +340,16 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is TransportType) {
} else if (value is TransportType) {
buffer.putUint8(129);
writeValue(buffer, value.index);
} else if (value is TorConfiguration) {
} else if (value is TorConfiguration) {
buffer.putUint8(130);
writeValue(buffer, value.encode());
} else if (value is TorStatus) {
} else if (value is TorStatus) {
buffer.putUint8(131);
writeValue(buffer, value.encode());
} else if (value is TorLogMessage) {
} else if (value is TorLogMessage) {
buffer.putUint8(132);
writeValue(buffer, value.encode());
} else {
@@ -362,8 +381,10 @@ class TorApi {
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
TorApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -373,27 +394,30 @@ class TorApi {
/// Start Tor with the given configuration
/// Returns a Future to avoid blocking the main thread
Future<int> startTor(TorConfiguration config) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.startTor$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_tor.TorApi.startTor$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[config]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[config],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as int;
}
/// Stop Tor
Future<void> stopTor() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.stopTor$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_tor.TorApi.stopTor$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -403,16 +427,16 @@ class TorApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
}
/// Get current status
Future<TorStatus> getStatus() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.getStatus$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_tor.TorApi.getStatus$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -422,17 +446,17 @@ class TorApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as TorStatus;
}
/// Request a new Tor identity (new circuit)
Future<void> requestNewIdentity() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.TorApi.requestNewIdentity$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_tor.TorApi.requestNewIdentity$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -442,11 +466,10 @@ class TorApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
}
}
@@ -460,12 +483,20 @@ abstract class TorLogApi {
/// Called when status changes
void onStatusChanged(TorStatus status);
static void setUp(TorLogApi? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
static void setUp(
TorLogApi? api, {
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage$messageChannelSuffix', pigeonChannelCodec,
binaryMessenger: binaryMessenger);
'dev.flutter.pigeon.flutter_tor.TorLogApi.onLogMessage$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
@@ -477,16 +508,20 @@ abstract class TorLogApi {
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
} catch (e) {
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
}
});
}
}
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged$messageChannelSuffix', pigeonChannelCodec,
binaryMessenger: binaryMessenger);
'dev.flutter.pigeon.flutter_tor.TorLogApi.onStatusChanged$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
@@ -498,8 +533,10 @@ abstract class TorLogApi {
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
} catch (e) {
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
}
});
}
@@ -511,9 +548,13 @@ class IPtProxyController {
/// Constructor for [IPtProxyController]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
IPtProxyController({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
IPtProxyController({
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -521,39 +562,43 @@ class IPtProxyController {
final String pigeonVar_messageChannelSuffix;
Future<int> start(TransportType proxyType, String proxy) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.IPtProxyController.start$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_tor.IPtProxyController.start$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[proxyType, proxy]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[proxyType, proxy],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as int;
}
Future<void> stop(TransportType proxyType) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_tor.IPtProxyController.stop$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.flutter_tor.IPtProxyController.stop$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[proxyType]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[proxyType],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
}
}
@@ -10,9 +10,9 @@ import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
}) {
if (replyList == null) {
throw PlatformException(
@@ -46,8 +46,9 @@ bool _deepEquals(Object? a, Object? b) {
}
if (a is List && b is List) {
return a.length == b.length &&
a.indexed
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
a.indexed.every(
((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
);
}
if (a is Map && b is Map) {
if (a.length != b.length) {
@@ -96,26 +97,20 @@ int _deepHash(Object? value) {
return value.hashCode;
}
class LocalizedResult {
LocalizedResult({
required this.languageName,
this.countryName,
});
LocalizedResult({required this.languageName, this.countryName});
String languageName;
String? countryName;
List<Object?> _toList() {
return <Object?>[
languageName,
countryName,
];
return <Object?>[languageName, countryName];
}
Object encode() {
return _toList(); }
return _toList();
}
static LocalizedResult decode(Object result) {
result as List<Object?>;
@@ -134,7 +129,8 @@ class LocalizedResult {
if (identical(this, other)) {
return true;
}
return _deepEquals(languageName, other.languageName) && _deepEquals(countryName, other.countryName);
return _deepEquals(languageName, other.languageName) &&
_deepEquals(countryName, other.countryName);
}
@override
@@ -142,7 +138,6 @@ class LocalizedResult {
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -150,7 +145,7 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is LocalizedResult) {
} else if (value is LocalizedResult) {
buffer.putUint8(129);
writeValue(buffer, value.encode());
} else {
@@ -173,31 +168,40 @@ class LocaleResolver {
/// Constructor for [LocaleResolver]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
LocaleResolver({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
LocaleResolver({
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<LocalizedResult> resolve(String languageTag, String targetLangouageTag) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.locale_resolver.LocaleResolver.resolve$pigeonVar_messageChannelSuffix';
Future<LocalizedResult> resolve(
String languageTag,
String targetLangouageTag,
) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.locale_resolver.LocaleResolver.resolve$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[languageTag, targetLangouageTag]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[languageTag, targetLangouageTag],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as LocalizedResult;
}
}
@@ -10,9 +10,9 @@ import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
}) {
if (replyList == null) {
throw PlatformException(
@@ -34,8 +34,11 @@ Object? _extractReplyValueOrThrow(
return replyList.firstOrNull;
}
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
List<Object?> wrapResponse({
Object? result,
PlatformException? error,
bool empty = false,
}) {
if (empty) {
return <Object?>[];
}
@@ -44,6 +47,7 @@ List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty
}
return <Object?>[error.code, error.message, error.details];
}
bool _deepEquals(Object? a, Object? b) {
if (identical(a, b)) {
return true;
@@ -56,8 +60,9 @@ bool _deepEquals(Object? a, Object? b) {
}
if (a is List && b is List) {
return a.length == b.length &&
a.indexed
.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]));
a.indexed.every(
((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]),
);
}
if (a is Map && b is Map) {
if (a.length != b.length) {
@@ -106,7 +111,6 @@ int _deepHash(Object? value) {
return value.hashCode;
}
class Intent {
Intent({
this.fromPackageName,
@@ -141,7 +145,8 @@ class Intent {
}
Object encode() {
return _toList(); }
return _toList();
}
static Intent decode(Object result) {
result as List<Object?>;
@@ -164,7 +169,12 @@ class Intent {
if (identical(this, other)) {
return true;
}
return _deepEquals(fromPackageName, other.fromPackageName) && _deepEquals(action, other.action) && _deepEquals(data, other.data) && _deepEquals(categories, other.categories) && _deepEquals(mimeType, other.mimeType) && _deepEquals(extra, other.extra);
return _deepEquals(fromPackageName, other.fromPackageName) &&
_deepEquals(action, other.action) &&
_deepEquals(data, other.data) &&
_deepEquals(categories, other.categories) &&
_deepEquals(mimeType, other.mimeType) &&
_deepEquals(extra, other.extra);
}
@override
@@ -172,7 +182,6 @@ class Intent {
int get hashCode => _deepHash(<Object?>[runtimeType, ..._toList()]);
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -180,7 +189,7 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is Intent) {
} else if (value is Intent) {
buffer.putUint8(129);
writeValue(buffer, value.encode());
} else {
@@ -203,9 +212,13 @@ class IntentHost {
/// Constructor for [IntentHost]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
IntentHost({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
IntentHost({
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -217,7 +230,8 @@ class IntentHost {
/// IntentEvents.setUp() was called (cold-start deep links).
/// Returns null if no launch intent is pending.
Future<Intent?> getInitialIntent() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentHost.getInitialIntent$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.simple_intent_receiver.IntentHost.getInitialIntent$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -227,11 +241,10 @@ class IntentHost {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
return pigeonVar_replyValue as Intent?;
}
}
@@ -241,12 +254,20 @@ abstract class IntentEvents {
void onIntentReceived(int sequence, Intent intent);
static void setUp(IntentEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
static void setUp(
IntentEvents? api, {
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived$messageChannelSuffix', pigeonChannelCodec,
binaryMessenger: binaryMessenger);
'dev.flutter.pigeon.simple_intent_receiver.IntentEvents.onIntentReceived$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
@@ -259,8 +280,10 @@ abstract class IntentEvents {
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
} catch (e) {
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
}
});
}
@@ -272,9 +295,13 @@ class IntentGatekeeperHostApi {
/// Constructor for [IntentGatekeeperHostApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
IntentGatekeeperHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
IntentGatekeeperHostApi({
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -284,42 +311,46 @@ class IntentGatekeeperHostApi {
/// Replicates the blocked-packages policy to the native side so the
/// [IntentReceiverActivity] can reject intents without launching Flutter.
Future<void> setConfig(bool enabled, List<String> blockedPackages) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.setConfig$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.setConfig$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[enabled, blockedPackages]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[enabled, blockedPackages],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
}
/// Resolves a package name to its user-visible application label via
/// [PackageManager]. Returns `null` if the package is not installed or the
/// label cannot be resolved.
Future<String?> resolvePackageLabel(String packageName) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.resolvePackageLabel$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.resolvePackageLabel$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[packageName]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[packageName],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
return pigeonVar_replyValue as String?;
}
@@ -329,7 +360,8 @@ class IntentGatekeeperHostApi {
/// [ackPendingAlwaysAllows] after Flutter settings were updated
/// successfully.
Future<List<String>> getPendingAlwaysAllows() async {
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.getPendingAlwaysAllows$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.getPendingAlwaysAllows$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
@@ -339,31 +371,32 @@ class IntentGatekeeperHostApi {
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return (pigeonVar_replyValue! as List<Object?>).cast<String>();
}
/// Removes the given packages from the pending "Always allow" set after
/// Flutter has successfully persisted them into its own policy store.
Future<void> ackPendingAlwaysAllows(List<String> packageNames) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.ackPendingAlwaysAllows$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.simple_intent_receiver.IntentGatekeeperHostApi.ackPendingAlwaysAllows$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[packageNames]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[packageNames],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
);
}
}
@@ -10,9 +10,9 @@ import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
List<Object?>? replyList,
String channelName, {
required bool isNullValid,
}) {
if (replyList == null) {
throw PlatformException(
@@ -34,8 +34,11 @@ Object? _extractReplyValueOrThrow(
return replyList.firstOrNull;
}
List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty = false}) {
List<Object?> wrapResponse({
Object? result,
PlatformException? error,
bool empty = false,
}) {
if (empty) {
return <Object?>[];
}
@@ -45,7 +48,6 @@ List<Object?> wrapResponse({Object? result, PlatformException? error, bool empty
return <Object?>[error.code, error.message, error.details];
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
@@ -72,9 +74,13 @@ class SpeechToTextApi {
/// Constructor for [SpeechToTextApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
SpeechToTextApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
SpeechToTextApi({
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) : pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
@@ -89,21 +95,23 @@ class SpeechToTextApi {
/// The [locale] parameter specifies the language locale for recognition
/// (e.g., 'en-US', 'de-DE'). If null, uses the device default.
Future<bool> showDialog({String? locale}) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextApi.showDialog$pigeonVar_messageChannelSuffix';
final pigeonVar_channelName =
'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextApi.showDialog$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[locale]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(
<Object?>[locale],
);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
)
;
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as bool;
}
}
@@ -118,12 +126,20 @@ abstract class SpeechToTextEvents {
/// recognition failed or was cancelled.
void onTextReceived(String text);
static void setUp(SpeechToTextEvents? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
static void setUp(
SpeechToTextEvents? api, {
BinaryMessenger? binaryMessenger,
String messageChannelSuffix = '',
}) {
messageChannelSuffix = messageChannelSuffix.isNotEmpty
? '.$messageChannelSuffix'
: '';
{
final pigeonVar_channel = BasicMessageChannel<Object?>(
'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived$messageChannelSuffix', pigeonChannelCodec,
binaryMessenger: binaryMessenger);
'dev.flutter.pigeon.speech_to_text_dialog.SpeechToTextEvents.onTextReceived$messageChannelSuffix',
pigeonChannelCodec,
binaryMessenger: binaryMessenger,
);
if (api == null) {
pigeonVar_channel.setMessageHandler(null);
} else {
@@ -135,8 +151,10 @@ abstract class SpeechToTextEvents {
return wrapResponse(empty: true);
} on PlatformException catch (e) {
return wrapResponse(error: e);
} catch (e) {
return wrapResponse(error: PlatformException(code: 'error', message: e.toString()));
} catch (e) {
return wrapResponse(
error: PlatformException(code: 'error', message: e.toString()),
);
}
});
}