The phone half of hongshi: open a room from a phone, and the people joining type an address in the game.
There is no launcher here and no mod. The app does one thing — it exposes a port on
the phone through a relay — so whoever is joining needs nothing at all: they type
nj.hongshi.site:35374 into the game's direct-connect box and they are in. That is the
same promise the desktop client makes, and it is the reason this exists rather than a
mobile launcher with a tunnel bolted on.
┌─ the phone ─────────────────────────────────────────────┐
│ │
│ MainActivity WebView ──────────┐ │
│ │ │ http://127.0.0.1 │
│ │ startForegroundService │ :<port>/ │
│ ▼ │ │
│ ShellService ──► libhongshi_android.so (the shell) │
│ │ │ loopback HTTP server │
│ │ │ + the whole interface │
│ │ ▼ │
│ │ spawns ──► libhongshic.so (the kernel) │
│ │ │ │
│ └── notification │ control + data │
└──────────────────────────────────┼───────────────────────┘
▼
hongshis (relay)
▲
│ players connect here
nj.hongshi.site:35374
The interesting decision is what isn't here. There is no Android UI. hongshi-shell
— the crate that already serves the interface from a loopback port on the desktop — is
compiled for aarch64-linux-android and runs inside the app's process, and a WebView
is pointed at http://127.0.0.1:<port>/.
That is not a shortcut around writing an interface. It is what the desktop shell already
does: it serves its own page from loopback precisely so that the interface is a web page
rather than a bundled browser, which is why the desktop binary has no Chromium in it and
why the interface can be edited with F5. On the phone the same arrangement gets a browser
the platform already ships. The page cannot tell the difference, and neither can the
shell — pages.js, app.css, the node picker, the news feed, the latency probe and the
log drawer are byte-for-byte the desktop ones.
What the shell did have to grow is three seams, and the bar for each was that the
desktop build must not change at all. It didn't: cargo test (104 unit + 20 end-to-end)
and scripts/verify.ps1 (149 acceptance checks) pass unchanged after the work, and the
shell crate is still at zero dependencies.
| Seam | Why the desktop default is wrong on Android |
|---|---|
config::install_app_dir |
Settings are written next to the executable. On Android the executable is /system/bin/app_process64, whose directory is read-only. |
kernel::install_search_roots |
The kernel is looked for beside the executable and in the working directory. Neither means anything here. |
net::install_http_backend |
The fallback HTTP client speaks plain http:// and refuses https:// — and api_base is https://hongshi.site. |
The fourth thing the shell did not need is sites.rs's ICMP probe. It spawns the
platform's ping and falls back to a TCP connect when that fails or prints nothing, so
it degrades on its own: Android opens net.ipv4.ping_group_range to apps, so ICMP often
works, and where it doesn't the probe is simply a port measurement. Disabling it would
have removed a working capability to solve a problem that did not exist.
hongshic is a separate program. It is not linked into the shell — on the desktop it
is a separate download that ships on the same page — and on Android it ships inside the
APK and is still run as a child process, with its stdout parsed for endpoint= exactly
as on the desktop.
Getting a program into a position where an app may run it is the whole problem.
Android 10 enforces W^X on the app's writable data directory, so filesDir is out.
What is left is applicationInfo.nativeLibraryDir — the read-only directory the
installer extracts libraries into — and the only way to put a file there is to ship it in
the APK as lib/<abi>/lib*.so. So the kernel is built as libhongshic.so and:
packaging { jniLibs { useLegacyPackaging = true } }forces the installer to actually extract it. Modern APKs leavelib/compressed and page-aligned inside the APK and the loader maps it from there, which is smaller and faster — and leavesnativeLibraryDirempty, at which point there is nothing to execute.kernel::ANDROID_LIBRARY_NAMEis the name the shell looks for, and it is whyplatform()answersandroidrather thanlinux: the download endpoint must not hand a phone the musl build.
The first three rows of the table below are the load-bearing assumption, and they are the
reason scripts/e2e-emulator.ps1 exists rather than a note saying "should work":
启动内核:/data/app/~~1qqydFSTX5SeNNLKvD0gyQ==/site.hongshi.android-…/lib/x86_64/libhongshic.so -t nj.hongshi.site -p 25565
隧道地址已分配:nj.hongshi.site:35374
endpoint=nj.hongshi.site:35374 uuid=727900d8-… port=35374
Worth writing down because it started perfectly and then died, and because the fix is not obvious from the symptom.
The shell's connection handlers are Rust threads. They are not Java threads, so each
call into Kotlin attaches them first — and FindClass from a thread with no Java frame on
its stack resolves against the system class loader, which has never heard of
site.hongshi.android.*. The first build bound a port, served the interface, and then:
FATAL EXCEPTION: Thread-3
java.lang.ClassNotFoundException: site.hongshi.android.AndroidHttp
R8 had not touched the class — mapping.txt and the dex both show it intact — and the
proguard rules were fine. The loader was the problem. ShellBridge.nativePrepare now
captures the app's ClassLoader while a Java frame is still on the stack and holds it as
a global reference, and AndroidHttp resolves itself through ClassLoader.loadClass.
The Kotlin reply is framed, not parsed. AndroidHttp.get returns a byte[] of four
big-endian status bytes followed by the body, with a status of zero meaning "transport
failure" and the reason in the body. Bytes because a body is bytes — the kernel download
is a megabyte of ELF — and framed rather than JSON because the shell has no JSON
dependency and this is not a reason to add one.
| Does | Host. Pick a relay, type the local game port, get an address, hand it out. |
| Does | Keep hosting while you switch to the game. The shell lives in a foreground service, not the Activity, with a partial wake lock. |
| Does not | Launch or manage the game. It is not a launcher, and it has no idea what game you are running. |
| Does not | Do anything for the people joining. They need no app; that is the product. |
| Does not | Speak UDP. Java Edition only for now — Bedrock is UDP 19132 and the kernel's supported_modes is ["frp:tcp"]. The interface keeps 协议 on 中转 · TCP with the other option dashed out, exactly as the desktop does. |
Three permissions and nothing else: INTERNET, FOREGROUND_SERVICE (+_DATA_SYNC),
WAKE_LOCK. No storage, no location, no phone state. Cleartext is permitted for
127.0.0.1 and localhost only, via a network security config rather than
usesCleartextTraffic, so everything else stays on verified https.
targetSdk is 34, not 35, and that is deliberate: Android 15 caps the dataSync
foreground service type at six hours a day for apps targeting 35, and a hosting session
is exactly the thing expected to outlive six hours. Revisit when there is a foreground
service type that fits better.
Two dependencies, treated two different ways, and the difference is deliberate.
git clone --recursive https://github.com/hongshionline/HongshiAndroid.git| How | Why | |
|---|---|---|
shell/ — the WebUI client this app embeds |
submodule at hongshionline/hongshi-webui, pinned to a commit |
It is a real project with its own repository and its own release cycle. A submodule pins the exact revision the APK was built and tested against, which a path to "wherever your checkout happens to be" cannot. |
hongshi/ — the kernel this app carries |
a sibling checkout, ../hongshi |
Its public repository does not currently match the revision this was built against. Pinning it would lock the build to a kernel nobody has tested, which is worse than not pinning it at all. |
So a full checkout is git clone --recursive plus one directory beside it:
someparent/
├── hongshi/ hongshic — the kernel source (not a submodule; see above)
└── HongshiAndroid/ this repository, with shell/ already inside it
scripts/build-rust.ps1 says all of this again if the kernel is not there, and takes
-KernelDir to point somewhere else. Kotlin-only work needs none of it:
scripts/build-apk.ps1 -SkipRust builds app/ on its own.
Everything below also assumes ANDROID_SDK_ROOT (or D:\Android\Sdk), an NDK, JDK 17, and
Gradle 8.9 — the first two arrive via sdkmanager, and the last is the wrapper committed
here, which the build script uses if it is present and installs otherwise.
powershell -File scripts\build-apk.ps1 # release APK, arm64-v8a (what ships)
powershell -File scripts\build-apk.ps1 -Emulator # + x86_64, for an x86_64 emulator
powershell -File scripts\build-apk.ps1 -SkipRust # Kotlin-only iteration
powershell -File scripts\verify-apk.ps1 # 32 checks, reads the APK as a file
powershell -File scripts\e2e-emulator.ps1 # 24 checks, drives a real deviceThe release APK carries arm64-v8a only, because each ABI carries its own copy of the
kernel and arm64 is every phone this targets. The debug/-Emulator variant adds x86_64
so the app can be exercised on an x86_64 host — an arm64 system image under TCG is too
slow to be a test. verify-apk.ps1 asserts the shipped APK has exactly one ABI, so a
leaked emulator build is caught before it is published rather than after.
scripts/build-rust.ps1 is the half that cargo owns, and it exists rather than a plain
cargo build for two reasons: this toolchain's manifest is v1, so rustup target add
answers "does not support components" and the missing std is unpacked into the sysroot
by hand; and a cross build needs the NDK's linker, which is looked up and passed through
the environment so no machine-specific path is committed. Every artifact is checked for
its ELF machine type and ET_DYN before it is staged — a build that quietly produced an
x86_64 kernel for an arm64 APK would install fine and fail with an error nobody can read.
Being precise about this, because the difference matters and is easy to blur.
Executed end to end. An Android 15 x86_64 emulator installs the APK, starts the shell,
finds the kernel in nativeLibraryDir, fetches the node list over HTTPS through the Kotlin
client, spawns libhongshic.so, and gets a real endpoint from a real relay. Then a player
connects to that endpoint and plays:
a player joins
PASS a player connected to nj.hongshi.site:43362 and sent bytes
PASS the bytes that came back are the game's own
note the player got: HONGSHI-E2E-BANNER
PASS the kernel dialled the local game port
That last group is the whole product and the reason it is in the script rather than in a
note: everything before it proves a tunnel exists, and only this proves it carries
traffic. Arranging it took two pieces of knowledge worth writing down. The kernel dials the
local game port on the device, so the stand-in game has to be reachable from the
device's own loopback — adb reverse tcp:25565 tcp:25565 is what puts it there. And the
kernel dials it lazily, on the first real bytes, and drops any pipe whose opening bytes
look like an HTTP scanner, so the probe must be sent before anything is read and must not
start with GET .
The interface renders (the screenshot is from that run) and the tunnel stops cleanly.
scripts/e2e-emulator.ps1 is that run: 24/24.
Verified structurally, not executed. The APK that actually ships is arm64-v8a only,
and this machine cannot run it: the emulator refuses an arm64 system image on an x86_64 host
(Avd's CPU Architecture 'arm64' is not supported by the QEMU2 emulator on x86_64 host), and
there was no physical device attached. So the arm64 payload is checked by reading it rather
than by running it — verify-apk.ps1 asserts the machine type, ET_DYN, that
libhongshic.so carries PT_INTERP pointing at /system/bin/linker64, that the shell
library carries no PT_INTERP (it is mapped, not executed), and that both need only
bionic libraries.
PT_INTERP is the one that would have been worth catching: a dynamically linked PIE with no
interpreter is not executable, and it would pass every other check here while failing on the
device. But "the arm64 binary runs on an arm64 phone" has not been observed. The x86_64
path proves the mechanism, the arm64 checks prove the file's shape, and the remaining step is
a real phone: adb install the APK and run scripts/e2e-emulator.ps1 (it does not care which
ABI the device is).
| Path | |
|---|---|
app/ |
the Kotlin: MainActivity (WebView), ShellService (foreground service, wake lock), ShellBridge (JNI declarations), AndroidHttp (the client Rust borrows) |
rust/ |
the JNI shim, the class loader capture, and the framed HTTP bridge |
scripts/build-rust.ps1 |
the shell and the kernel, for Android, staged into jniLibs/ |
scripts/build-apk.ps1 |
Rust then Gradle, the signing key, end to end |
scripts/verify-apk.ps1 |
what is actually inside the APK, and whether it can run |
scripts/e2e-emulator.ps1 |
installs, launches, hosts a real tunnel through a real relay, and connects to it as a player |
app/src/main/jniLibs/ is a build output and is not committed; keystore.properties and
hongshi-release.jks are generated once by build-apk.ps1 and must be kept — Android
refuses to install an update over an app signed by a different key, so losing them means
every user has to uninstall and reinstall.