Mobile port foundation: shared gomobile core and Android client - #6
Merged
Conversation
On mobile the tun fd is supplied by the platform VPN API (Android VpnService, iOS NEPacketTunnelProvider) and the OS owns the routing table, so sing-box must not also claim a default route. Add TunOptions.ExternalTun to suppress auto_route/strict_route on that tun; MTU and stack still apply. Desktop is unchanged (the flag defaults off, and a sing-box-owned tun still needs auto_route).
Refactor buildNodes to track each emitted node's input index alongside its tag, and add SelectorTags(nodes) returning the selectable nodes in selector order. This lets callers that need the selector's tags (the mobile connect loop ordering nodes for its fallback walk) derive them from the builder itself instead of re-implementing tag assignment and drifting from it.
Add FetchWithHeaders, which applies caller-supplied request headers on top of the default User-Agent/Accept while going through the same SSRF guard and DoH fallback as Fetch. Some subscription panels gate their response on a specific header; the mobile import path needs to pass those through without giving up the hardened fetch. Fetch is unchanged.
The config-generator bridge is no longer iOS-specific: the same package binds to an Android .aar as well as the iOS .xcframework. Move it out of ui-ios/ to the repo root and widen its build constraint from ios to ios || android, so gomobile picks it up for both GOOS=ios and GOOS=android while the desktop and CI builds (which set neither tag) still exclude it. Update the build script and the Swift call-site comment for the new path.
…alls Break the bridge into a thin gomobile surface (bridge.go, ios||android) over plain functions in build-tag-free files, so the logic and its tests compile and run on any host while the gomobile specifics stay isolated from the desktop build. Add two exports the native mobile wrapper needs, alongside the existing GenerateConfig/Version: - ImportSubscription: fetch + parse + build a profile (same stable server IDs, quota and managed-badge handling as the desktop import), returning the full profile JSON the app stores. - OrderNodes: the profile's selector tags in anti-DPI fallback order (last-good or fastest-first, then protocol preference), so the native connect loop reuses the core's ordering instead of reinventing it. GenerateConfig now carries tun.externalTun through to the builder for the platform-owned tun. Tests cover happy paths and errors (bad JSON, missing url, no nodes, and the SSRF guard via the real fetch).
The config generator is no longer iOS-only: it lives at the repo root and the same gomobile bind produces both the iOS .xcframework and an Android .aar. Update the iOS scaffold README (status table, layout, host-checkable slice) and the porting doc's architecture note to reflect the shared package and its extra exports (ImportSubscription/OrderNodes).
We were pinning sagernet/gomobile to v0.1.13 for the TenebraCore bind while `make lib_install` builds libbox with whatever the pinned sing-box tag installs (v0.1.12 for 1.13.13) — two different binders for one set of frameworks. Read the version out of the tag's Makefile instead so both use the same one, and it tracks automatically when the sing-box pin moves.
Mirror of build-libbox.sh for Android. Binds core-bridge into tenebra-core.aar and builds sing-box's libbox.aar from the pinned tag's own make targets, both with the gomobile fork that lib_install provides — no second gomobile pin here. Drops both into ui-android/app/libs. Authored on Windows and not run here; the first bind is on CI.
Standalone workflow so an Android hiccup stays off the desktop and core checks. Builds a debug APK on Android-relevant pushes and PRs; a v* tag builds a release APK, signs it with the keystore from secrets, and attaches it to the release. Caches the slow libbox.aar on the sing-box version and rebuilds the core .aar every run so core changes always land.
Counterpart to ios.md: VpnService with in-process libbox, the two-artifact split, the toolchain and CI, and the long-lived release-signing keystore. Calls out what is easier than iOS (no 50 MB cap, no store gate) and the Android-specific risks (Doze, OEM battery, per-app VPN, MTU).
Compose single-module app targeting minSdk 23. The two gomobile artifacts (libbox.aar, tenebra-core.aar) are pulled in via files() and git-ignored; CI builds and drops them into app/libs. Manifest declares the VpnService with the systemExempted foreground type plus the permissions a tunnel needs, and an optional Quick Settings tile.
ConfigGenerator wraps the Tenebracore binding (generate/import/order) and holds the frozen ABI envelopes, forcing tun.externalTun on generate. The stored profile is the raw JSON the core returns, kept verbatim so it round-trips exactly; the UI reads a small read-only projection of it. ProfileRepository shares that blob (a file) and the node selection (prefs) between the UI and the service in one process.
Mirrors the io.nekohasekai.sfa.bg plumbing (GPL-3.0, attributed in-file) onto the Tenebra core. TenebraVpnService owns the tun fd and implements libbox's PlatformInterface; BoxService drives the engine via CommandServer + StartOrReloadService. Because the core emits externalTun (no auto_route), openTun adds the default routes to the VpnService.Builder itself and protects the engine's own sockets. Targets the current libbox surface; every call the generated .aar must confirm is flagged inline.
A single-activity Compose surface: subscription import, node list, a connect toggle bound to the tunnel status, and error reporting. The activity owns the VpnService consent dialog and the notifications permission; the view model handles import and selection. Dark, monospace, brutalist theme.
Honest-status table, the connect data flow, the modern-vs-classic libbox caveat with the verify checklist, and the bring-up order (bind the two .aar, build the APK, sideload, connect). Notes the Android specifics: no iOS-style memory cap, and Doze persistence via always-on VPN plus a systemExempted foreground service.
Give the node LazyColumn a weight so it takes the remaining space and scrolls inside it instead of overflowing the column. Add the GPL-3.0 upstream note to the Quick Settings tile, which mirrors SFA's QSTileService.
…uild tag The mobile clients no longer bind this package on its own. It is imported by the new ../mobile wrapper, which binds it together with libbox in a single gomobile pass, so the exported surface has to compile on every host rather than only under the ios||android tag. Promote generateConfig/orderNodes to GenerateConfig/OrderNodes, add a thin ImportSubscription over the unexported fetch seam the tests inject into, and move Version plus the ABI constant here. Drop bridge.go (the tagged surface) and tools.go (the gomobile pin moves into mobile/).
This is the fix for the D8 checkDuplicateClasses failure: a standalone core .aar and a standalone libbox.aar each ship their own Go runtime and their own gomobile `go` support package (go/Seq, go/Universe), so they cannot coexist in one process. The new module binds both in a single gomobile pass instead. mobile.go is a tiny package named tenebracore (so gomobile emits the class Tenebracore) that delegates to ../core-bridge; tools.go pins, under the tools tag, the two modules the bind needs but no normal compile imports — gomobile/bind and experimental/libbox — so tidy keeps them and pulls sing-box's tag-gated graph into go.sum. The generator stays pure: the wrapper never imports libbox.
Replace the two-artifact builds with a single gomobile bind over the ../mobile wrapper and experimental/libbox, producing one tenebra.aar and one Tenebra.xcframework. The tags, ldflags and -androidapi are transcribed verbatim from sing-box's own cmd/internal/build_libbox at v1.13.13 (the API-23 main variant on Android, the apple variant on iOS), including the -checklinkname=0 the linkname tags require. libbox is now resolved from mobile/go.mod, so the sing-box clone survives on Android only to install the matching gomobile fork, and drops out of the iOS script entirely.
The gradle dependency drops to a single files("libs/tenebra.aar"), and the core
bridge now calls io.nekohasekai.tenebracore.Tenebracore — the class the fused bind
emits under -javapkg io.nekohasekai — instead of the old standalone
com.tenebra.core one. libbox's io.nekohasekai.libbox.* imports are untouched, so
bg/ still compiles against the same engine classes; only the stale libbox.aar
artifact name in comments is retoked to tenebra.aar.
Also fix the RectangleShape import: it pointed at androidx.compose.foundation.shape,
which has no such symbol (it lives in androidx.compose.ui.graphics), so all six
shape = RectangleShape uses were unresolved.
The old cache stored a standalone libbox.aar keyed on the sing-box version, but there is no separate engine .aar anymore. Cache ~/.cache/go-build and ~/go/pkg/mod instead, keyed on go.sum + mobile/go.sum, so a bind reuses the compiled sing-box and skips the ~11-minute engine compile whenever the dependency graph is unchanged. The fused tenebra.aar itself is never cached — it must reflect every core change. Add mobile/** to the path triggers, since the bind now lives there.
The iOS client would hit the same two-runtime problem as Android, so the core and libbox are now bound into a single Tenebra.xcframework. Swift imports one module, Tenebra, which carries both the generator (Tenebracore* symbols) and the engine (Libbox* symbols, unchanged); the two canImport(TenebraCore)/canImport(Libbox) guards collapse to canImport(Tenebra). project.yml links the single framework in both the app and the extension. Update the scaffold notes and the deleted-bridge.go reference accordingly.
The porting docs still claimed the core and libbox ship as two separate gomobile artifacts that the client links side by side. That was wrong — two artifacts each carry their own Go runtime and gomobile `go` support package and cannot share a process (D8 duplicate go/Seq + go/Universe on Android, the same underneath on Apple). Rewrite the android and ios architecture sections, bind commands, tag lists and CI notes to the one-bind model: the mobile/ wrapper and experimental/ libbox bound together into one tenebra.aar / Tenebra.xcframework, with the exact sing-box tag set and the -checklinkname=0 the linkname hacks need. Also correct the gomobile fork pin (v0.1.12) and the stale claim that with_naive_outbound is absent from sing-box's Apple build.
sing-box's v1.13.13 tag was force-moved upstream after the Go module proxy and checksum database had already frozen the original tagged commit (55260b54). That frozen commit's libbox implements MyInterface() / RegisterMyInterface but not the MyInterfaces() its own pinned sing-tun already lists on the DefaultInterfaceMonitor interface, so `go` resolving sing-box@v1.13.13 can never build experimental/libbox: monitor.go:12: *platformDefaultInterfaceMonitor does not implement sing-tun.DefaultInterfaceMonitor (missing method MyInterfaces) The fused bind compiles libbox from the module graph, not from a git checkout, so the poisoned v1.13.13 module is all `go` will ever fetch - downgrading sing-tun cannot help, since v1.13.13's own go.mod floor already requires the interface with MyInterfaces(). v1.13.14 is the first release that resolves clean (libbox + sing-tun v0.8.11, both on MyInterfaces()), so pin the mobile bind there. The android/apple build tags and the gomobile fork (v0.1.12) are identical between the two releases; only the version string moves. The desktop sidecar keeps its prebuilt v1.13.13 release binary - that is built from source and never touches the broken module - so it is left as is; both engines speak the v1.13 config schema. Bump the build scripts, the iOS bind, the CI cache key, and the porting docs to match, and spell out the deliberate mobile/desktop split so it is not "resynced" back into the break.
Two paths could crash the process instead of surfacing an error in the UI: - startForeground ran outside the coroutine's try/catch, so a ForegroundServiceStartNotAllowedException / SecurityException from the SYSTEM_EXEMPTED foreground-service type (seen on some OEMs and Android 14+) was uncaught and killed the app the instant Connect was tapped. Move it into enterForeground(), which catches the failure, retries the untyped start, and on a hard failure records the reason in TunnelState and stops the service cleanly. sing-box-for-android uses the same systemExempted type for its VpnService; match its tools:ignore on the manifest entry. - The libbox PlatformInterface / CommandServerHandler callbacks run on a Go thread, and an exception thrown back across the gomobile boundary can abort the process (SIGABRT) rather than propagate as a catchable error. openTun now returns -1 (libbox treats fd < 0 as a failed tun) and records the cause instead of throwing; protect, sendNotification, getInterfaces and the command-server handlers are wrapped so nothing escapes into the engine.
Testers hit crashes we can't see (no logcat). Install a default uncaught exception handler in the Application that writes the stack trace plus device and app info to filesDir/last_crash.txt, chaining the platform handler so the process still terminates as usual. On the next launch the UI reads the file and shows a dialog with Copy and Clear. This only catches JVM crashes; a pure native abort from the Go runtime does not unwind through the JVM. With the connect path now exception-safe, the app dying without leaving a report is itself the signal that the fault is native rather than in the Kotlin layer.
A tester on an alpha build has no adb, so whatever the engine says about a failed connect is currently lost: writeDebugMessage only reached Log.d, and in fact libbox never called it at all, because the platform debug hook is gated on SetupOptions.debug and we never set it. Turn that flag on and route the engine log, the service lifecycle events and every surfaced tunnel error into a process-wide ring buffer (LogStore, ~1000 lines). The engine colours its lines with ANSI and stamps them only with seconds since start, so strip the escapes and add a wall-clock time on capture. Everything is still mirrored to logcat for anyone who does have a cable. The buffer is local and is never sent anywhere on its own.
New screen behind a Diagnostics button on the main screen. It shows the captured log, the last crash and a short device/version header, with Copy and Share so a tester can hand the whole thing over through Telegram or mail in one tap, no cable needed. Copy and Share run the text through a light scrubber first: it masks the subscription token, node UUIDs and passwords and the server addresses, while leaving the protocol, the error and the stack trace intact, so the report stays useful without leaking the subscription into a chat. In keeping with Tenebra's no-telemetry stance, nothing here leaves the device unless the user sends it.
The crash file already captures the JVM stack, but the in-memory log dies with the process, so the engine and service lines leading up to a crash were lost. Append the recent log to the crash report as it is written. A JVM crash forces a restart, so the live buffer is always empty when the report is read back and nothing gets duplicated there. Wrapped so it can never get in the way of writing the stack itself.
Gradle generated a throwaway ~/.android/debug.keystore on each runner, so every debug APK was signed by a different key and a tester had to uninstall the previous build before a new one would install (INSTALL_FAILED_UPDATE_INCOMPATIBLE). Decode a shared keystore from the ANDROID_DEBUG_KEYSTORE_B64 secret into ~/.android/debug.keystore before assembleDebug — where AGP's default debug signingConfig looks — so the signing certificate is identical from run to run and iterative testing needs no reinstall. Falls back to Gradle's generated key when the secret is absent (fork PRs). Ignore *.keystore so a keystore never lands in the repo.
The previous commit dropped the shared keystore at ~/.android/debug.keystore, but AGP resolves the default debug keystore through ANDROID_USER_HOME on the runner, not $HOME, so it auto-generated its own key and the debug signature was still unstable — verified: the CI APK carried a different certificate than the shared keystore. Point the debug signingConfig at the keystore explicitly through a DEBUG_KEYSTORE env var and have the workflow export it, so every debug APK is signed by the shared key. Falls back to Gradle's generated key when DEBUG_KEYSTORE is unset (local development).
Inet6Address.getHostAddress() returns link-local addresses with a zone suffix (fe80::1%wlan0). Go's netip.ParsePrefix rejects zones in prefixes, so libbox panicked with SIGABRT right after the TUN came up, killing the process before the Java crash handler could run.
registerDefaultNetworkCallback reports the app's default network, which becomes our own tunnel once it is up. The monitor then pushed tun0 to the engine as the default interface, so every upstream dial bound to the tunnel itself and failed with "no available network interface" — the app connected but carried no traffic. Skip networks that carry TRANSPORT_VPN (lack NET_CAPABILITY_NOT_VPN) so the last real underlying interface stays in effect.
…op tokens W1a of the mobile redesign. Replaces the system-monospace/default-scale theme with the shared desktop canon: exact tokens (ground #0e0e0e, single rationed signal, good only for connected, warn), bundled JetBrains Mono (Regular/SemiBold/ExtraBold, Cyrillic native) driving one type family, and the canon type scale with per-role tracking. Adds TenebraPalette for the semantic colors Material's scheme has no slot for (good/warn/word tints/ping buckets), used by the screens in the next wave.
The resource merger only accepts .ttf/.otf/.ttc/.xml under res/font; the license text belongs beside the module, not in the resource tree.
W2a of the redesign. Rebuilds MainScreen on the desktop brutalist canon: status panel with the eclipse-crescent signature mark and the big status word (word-on/off/pending tints), the node list filling the middle with a signal-rubric on the selected row, and the connect/disconnect control docked at the bottom thumb zone. Empty state (no subscription) centers import as the single action. Strings switch to Russian as the primary locale. Ping badges, live traffic stats, hot node-switch and the AUTO row land in the next wave (data layer); this wave is the visual shell on existing view-model state.
Adds a concurrent TCP connect-time pinger and shows a per-node latency chip coloured on the desktop ping scale (good/warn/signal, dim for no answer). The sweep runs when the node set becomes known and is safe to re-trigger. This is the visible half of the data layer; AUTO selection and live hot-switch (which share the clash-api path) follow.
W1b-2 foundation. Exposes the same node->tag mapping GenerateConfig/OrderNodes use, so the client can switch the live tunnel to a specific node by its stable server ID without a config rebuild (feed the tag to the running selector). Reuses singbox.SelectorTags — dropped nodes are absent here exactly as they are absent from the running config — and re-exports through the mobile bind. Tested on the desktop host.
Tapping a node while connected now steers the running tunnel to it with no reconnect, fixing the finding that selection silently did nothing live. The service captures the run's clash-api endpoint + secret from the generated config (loopback, secret-gated); ClashApiClient PUTs the selector to the tapped node's outbound tag, resolved from the new NodeTags core authority. Best-effort: while disconnected, or if the call fails, the saved selection still applies on the next connect.
…aches the tunnel targetSdk 35 blocks cleartext HTTP by default, so the PUT to the tunnel's http://127.0.0.1:9090 clash-api threw and the live node-switch silently failed (swallowed as best-effort). Scope a cleartext exception to loopback only — the clash-api is on-device and secret-gated; no external host is permitted cleartext.
…1b-2b-ii) Adds an AUTO row at the top of the node list. Choosing it keeps the selection pinned to the fastest node by ping and re-picks as pings refresh, hot-switching the live tunnel to the new best without a reconnect. Picking a specific node turns AUTO off. AUTO's subtitle names the node it currently resolves to. Selection still rides selectedServerId so the connect path is unchanged; autoMode only steers the pick and the UI.
The import field showed the core's raw engineer-facing Go error. Map the common cases (bad URL, network failure, empty subscription) to messages in the app's own voice; the raw text still lands in the diagnostics log for a bug report.
…bout (W2b) Adds a Settings screen reached from the top bar: an auto-connect-on-boot toggle (backed by a BootReceiver that starts the tunnel after a reboot when consent already exists), subscription upkeep (refresh from the stored URL, remove), and an About block with the app/core/engine versions and the motto. Navigation moves from a two-state boolean to a small Screen enum.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Starts the mobile port on a shared Go core bound with gomobile, feeding both a new Android client and the existing iOS scaffold from a single binding.
Shared core
core-bridge/to the repo root and builds it for both platforms (//go:build ios || android), so one gomobile bind produces both the Android.aarand the iOS.xcframework.ExternalTun) that dropsauto_route/strict_routefrom the tun inbound. On mobile the OS owns routing —VpnService.Builderon Android, the packet-tunnel provider on iOS — not sing-box. Desktop behaviour is unchanged (the flag defaults off).ImportSubscriptionandOrderNodesnext toGenerateConfig/Version(string in, string out) so the native clients don't re-implement subscription parsing or the fallback ordering. The bridge stays a pure config generator with no sing-box import; the logic lives in build-tag-free files that stay testable on any host.Android client (
ui-android/)VpnService+libboxin-process, mirroring the official sing-box-for-android app (GPLv3, attributed). Compose UI for subscription import, node list and the connect toggle.import -> prepare -> generateConfig(externalTun) -> StartOrReloadService -> establish() fd. Routes are set onVpnService.Builder; the engine's own sockets are protected so there is no loop.Toolchain and CI
scripts/build-libbox-android.shbinds both.aar— our core, and sing-boxlibboxfrom the pinned 1.13.13 tag, used unmodified — mirroring the iOSbuild-libbox.sh..github/workflows/android.yml: a debug APK on Android-relevant pushes and PRs, and a signed release APK attached to the GitHub release on av*tag. It is a separate workflow fromci.ymlon purpose, so an Android build hiccup never reddens the desktop and core checks.Status
The Go side builds for android/ios/desktop and its tests pass. The Kotlin, the gomobile bind and the APK have not been built yet — there is no Android SDK/NDK on the authoring host — so this PR's CI run is the first real bind. Expect a round or two of fixes around the exact libbox API surface (modern vs classic on 1.13), the NDK pin, and the sing-box linkname workarounds under recent Go. iOS stays a scaffold pending a Mac and a paid Apple Developer account.