Skip to content

Mobile port foundation: shared gomobile core and Android client - #6

Merged
Divaaaan merged 50 commits into
mainfrom
feat/mobile-android
Jul 22, 2026
Merged

Mobile port foundation: shared gomobile core and Android client#6
Divaaaan merged 50 commits into
mainfrom
feat/mobile-android

Conversation

@Divaaaan

Copy link
Copy Markdown
Owner

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

  • Moves core-bridge/ to the repo root and builds it for both platforms (//go:build ios || android), so one gomobile bind produces both the Android .aar and the iOS .xcframework.
  • Adds an external-tun mode (ExternalTun) that drops auto_route/strict_route from the tun inbound. On mobile the OS owns routing — VpnService.Builder on Android, the packet-tunnel provider on iOS — not sing-box. Desktop behaviour is unchanged (the flag defaults off).
  • Exposes ImportSubscription and OrderNodes next to GenerateConfig/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/)

  • A thin client: VpnService + libbox in-process, mirroring the official sing-box-for-android app (GPLv3, attributed). Compose UI for subscription import, node list and the connect toggle.
  • Connect flow: import -> prepare -> generateConfig(externalTun) -> StartOrReloadService -> establish() fd. Routes are set on VpnService.Builder; the engine's own sockets are protected so there is no loop.

Toolchain and CI

  • scripts/build-libbox-android.sh binds both .aar — our core, and sing-box libbox from the pinned 1.13.13 tag, used unmodified — mirroring the iOS build-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 a v* tag. It is a separate workflow from ci.yml on 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.

Divaaaan added 30 commits July 17, 2026 19:28
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.
Divaaaan added 20 commits July 18, 2026 17:00
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.
@Divaaaan
Divaaaan merged commit cacb13b into main Jul 22, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant