Skip to content

About

The Android host for hongshi: open a game to other players from a phone, and they install nothing

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

hongshi for Android

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

It is the same shell

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.


The kernel is a lib*.so, and that is not a naming accident

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 leave lib/ compressed and page-aligned inside the APK and the loader maps it from there, which is smaller and faster — and leaves nativeLibraryDir empty, at which point there is nothing to execute.
  • kernel::ANDROID_LIBRARY_NAME is the name the shell looks for, and it is why platform() answers android rather than linux: 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

The JNI class loader, which cost a crash

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.


What the app does, and what it deliberately does not

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.


Build

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 device

The 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.


What was verified, and what was not

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).


What is here

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.

About

The Android host for hongshi: open a game to other players from a phone, and they install nothing

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages