16 KiB
Android Build
Keystore Setup for GitHub Actions
To enable signed APK builds in GitHub Actions, you need to set up the following secrets:
1. Generate a Keystore (one-time setup)
keytool -genkey -v -keystore release.keystore \
-alias serene-pub \
-keyalg RSA -keysize 2048 -validity 10000 \
-storepass YourStorePassword \
-keypass YourKeyPassword
Answer the prompts for your organization details.
2. Encode the Keystore to Base64
base64 -w 0 release.keystore > release.keystore.base64
3. Add Secrets to GitHub
Go to your repository settings → Secrets and variables → Actions → New repository secret
Add these secrets:
ANDROID_KEYSTORE_BASE64: Paste the contents ofrelease.keystore.base64ANDROID_KEYSTORE_PASSWORD: Your store password from step 1ANDROID_KEY_ALIAS:serene-pub(or whatever you used)ANDROID_KEY_PASSWORD: Your key password from step 1
4. Cleanup
rm release.keystore release.keystore.base64
IMPORTANT: Never commit the keystore files to git!
Building Locally
Prerequisites
- Node.js 20+
- Java JDK 17+ (needed to run
./gradlew) - Android SDK with the NDK (
25.2.9519653) and CMake (3.22.1) components installed — needed to compilesrc/main/cpp/node-bridge.cpp, the JNI bridge that embeds Node in-process (see Architecture below). Install both via Android Studio's SDK Manager, orsdkmanager --install "ndk;25.2.9519653" "cmake;3.22.1".
No separate wrapper setup step is needed — android/gradlew is committed directly
(a real Gradle wrapper: gradlew, gradlew.bat, gradle/wrapper/gradle-wrapper.jar,
gradle/wrapper/gradle-wrapper.properties) and bootstraps its own pinned Gradle
distribution on first invocation.
Build Debug APK
# Build app first
npm run build
# Prepare Android assets
npm run android:prepare
# Build debug APK (no signing needed)
npm run android:build:debug
Output: android/app/build/outputs/apk/debug/app-debug.apk
Build Release APK
For signed release builds, you need a keystore. Place release.keystore in android/app/ and export environment variables:
export KEYSTORE_FILE=release.keystore
export KEYSTORE_PASSWORD=YourStorePassword
export KEY_ALIAS=serene-pub
export KEY_PASSWORD=YourKeyPassword
npm run android:full
Output: android/app/build/outputs/apk/release/app-release.apk
Full Build Command
npm run android:full
This runs: build → android:prepare → android:build
Testing the APK
Install on Device
adb install android/app/build/outputs/apk/debug/app-debug.apk
View Logs
# View all logs
adb logcat
# Filter Node.js logs
adb logcat | grep NodeJS
# Filter app logs
adb logcat | grep "pub.serene"
Architecture
The Android app:
- MainActivity.kt: WebView wrapper, manages server lifecycle
- NodeService.kt: Foreground service that runs Node in-process (on a background thread) via NodeBridge
- NodeBridge.kt / node-bridge.cpp: JNI bridge —
NodeBridge.ktis a thin Kotlin wrapper around a nativestartNodeWithArguments()call implemented insrc/main/cpp/node-bridge.cpp, which invokes Node's own embedding API (node::Start()) directly. Node runs as a thread inside this app's process, not as a separate OS process. - Assets: Contains the app code and dependencies (SvelteKit build, node_modules, static files, drizzle migrations) — extracted to app-private storage on first run
src/main/cpp/libnode: A prebuilt, Bionic-targetedlibnode.so+ Node's C headers, pulled from thenodejs-mobile-react-nativenpm package byscripts/build-android.js(not committed — regenerated byandroid:prepare). Linked intonode-bridge.soat build time via CMake.
Why in-process embedding instead of spawning node as a subprocess (the
original design): the official nodejs.org Linux ARM64 build is linked against
glibc (readelf -p .interp shows /lib/ld-linux-aarch64.so.1), which doesn't
exist on Android — Bionic is not glibc-compatible, and Android ships no glibc
dynamic linker at all. execve() on that binary fails with ENOENT while
resolving the interpreter itself, not the binary — indistinguishable from a
missing-file error without inspecting the ELF header directly, and unfixable
by any file placement, permissions, or packaging trick (this was tried first:
placing the binary under jniLibs/ sidesteps Android's separate SELinux
execute_no_trans restriction on app-private storage, but the glibc/Bionic
ABI mismatch remains underneath regardless). nodejs-mobile
solves this by compiling Node specifically for Android via the NDK, producing
a genuine Bionic shared library (libnode.so, no PT_INTERP segment) meant
to be dlopen()'d and driven via node::Start(), not executed as a
standalone binary.
On first launch:
MainActivitystartsNodeServiceas a foreground service immediatelyNodeServiceextracts the app bundle (not Node itself) to the app data directory, then calls intoNodeBridge.startNodeWithArguments()on a background thread, which blocks until Node's event loop exitsMainActivitypollslocalhost:3000and opens the WebView once it responds
node::Start() cannot safely be called a second time within the same OS
process (global V8/libuv initialization state) — so there's no in-app "restart
Node" short of restarting the whole app process. NodeService guards against
a duplicate in-process call, and the recovery UI's "Restart" button
(MainActivity.restartApp()) relaunches the Activity via
Intent.makeRestartActivityTask and kills the current process
(Runtime.getRuntime().exit(0)) rather than trying to restart Node alone.
File Sizes
- APK size: ~80-100MB (compressed)
- Installed size: ~500MB
libnode.so: ~60MB (nodejs-mobile's prebuilt Bionic build, packaged as a native library, not the extracted assets)- node_modules: production dependencies only (
npm install --omit=devruns as part ofandroid:fullbefore bundling — dev tooling like vite/typescript/ vitest/drizzle-kit is excluded) - App code: ~50MB
- Data/cache: grows with use
Permissions
Required permissions (defined in AndroidManifest.xml):
INTERNET/ACCESS_NETWORK_STATE: Network accessFOREGROUND_SERVICE/FOREGROUND_SERVICE_SPECIAL_USE: Keep the bundled Node.js server running for the whole session (specialUseis the correct API 34+ category for a long-lived local app server — it isn't subject to the execution-time limits Android increasingly enforces ondataSync-type services)WAKE_LOCK: Prevent CPU sleep during operations
Storage/camera permissions were removed — nothing in MainActivity.kt/NodeService.kt
touches external storage (everything correctly targets the app's private filesDir),
and there's no confirmed code path using the camera (no WebChromeClient.onShowFileChooser
override exists yet). Re-add CAMERA if on-device testing shows the avatar-upload file
picker actually needs it.
Known Limitations
-
Large app size due to Node.js + dependencies
-
First launch is slow (asset extraction)
-
Battery drain from Node.js process
-
Minimum Android 8.0 (API 26) required
-
ARM64 only (no x86/ARM32 support yet — the standard Android Studio emulator defaults to x86_64, so testing needs either a physical ARM64 device or a specifically-created ARM64 emulator image)
-
libnode.sois not 16 KB page-size aligned (p_align=0x1000, confirmed viareadelf -lW) — nodejs-mobile's prebuilt binary predates Google's 16 KB mandate and hasn't been updated (last release Oct 2024, verified against both thenodejs-mobile-react-nativenpm package and the upstreamnodejs-mobile/nodejs-mobileGitHub releases).node-bridge.so(our own code, seesrc/main/cpp/CMakeLists.txt) is correctly 16 KB-aligned via an explicit linker flag. This surfaces as an install-time "isn't 16 KB compatible" notice on Android 15+ — not a hard failure, since Android runs 4 KB-aligned libraries via a compatibility shim on 16 KB-page devices. No drop-in fix exists today short of compiling nodejs-mobile's Node runtime from source with a 16 KB-aware toolchain (a multi-hour native build, not attempted here). Revisit if this ever causes an actual crash rather than a notice, or once upstream ships an aligned build. -
Ollama Manager and KoboldCPP Manager are hidden entirely in this build — not offered in the setup wizard, not shown in the sidebar nav, not toggleable from System Settings, and rejected server-side if triggered directly. Reasons:
- KoboldCPP Manager's managed/local-subprocess mode can't work regardless of
engineering effort here — upstream
LostRuins/koboldcpponly publisheslinux-x64/mac-arm64/Windows release binaries, nolinux-arm64exists to download and run on-device. - Ollama Manager has no local-subprocess story in this codebase at all (it's always been a pure HTTP client to an already-running Ollama server) — hidden here as a scope decision to keep the Android build simple, not a hard technical blocker. A remote Ollama instance running on another machine can still be reached via a plain connection in the Connections panel.
- Remote/external KoboldCPP and Ollama connections (as opposed to the Manager sub-systems) are unaffected — configure them manually via the Connections panel, same as any other provider.
- KoboldCPP Manager's managed/local-subprocess mode can't work regardless of
engineering effort here — upstream
-
Local vectorization (the in-process ONNX embedding model) is disabled on Android — confirmed via
readelfthatonnxruntime-node's prebuilt Linux ARM64 binaries depend onld-linux-aarch64.so.1/libc.so.6/libpthread.so.0, the same glibc-ecosystem libraries that don't exist under Bionic which made the original Node.js binary itself unusable here. Not fixable via packaging. External-API vectorization works fine, including on Android — it's a plain OpenAI-compatible/embeddingsHTTP call (seesrc/lib/server/embedding/index.ts'sactivateApiEmbedding), so it's usable with OpenAI itself, or a self-hosted Ollama/LM Studio/llama.cpp server instance elsewhere on the network. Configure it from the Vectorization sidebar's Settings tab → External API. -
nodejs-mobile's V8 build lacks full ICU/Unicode support — any regex using a Unicode property escape (
\p{L},\p{N},\p{Lu}, etc.) throws aSyntaxErrorwhile the containing module is parsed, not run. This isn't a corner case: it's the standard tiktoken-style BPE pretokenization pattern, so it shows up in multiple otherwise-unrelated dependencies:@lmstudio/sdk(used byLMStudioAdapter.ts)gpt-tokenizer(all four OpenAI GPT2/3.5/4/4o token counters)llama3-tokenizer-js(the Llama 3 token counter)@lenml/tokenizer-gemma(used, slightly confusingly, by the Cohere token counter)
Because a
SyntaxErrorat module-parse time crashes the whole in-process Node runtime — not just the code path that needed the broken module — the real risk was these being pulled in by static imports in code that's part of the server's eager startup graph. Two files had exactly that problem and were fixed by switching to dynamicimport(), deferred until the specific feature is actually used, instead of a static top-level import evaluated the moment the containing module loads:src/lib/server/utils/getConnectionAdapter.ts— each connection adapter (includingLMStudioAdapter) is now dynamically imported per connection type, so only actually selecting an LM Studio connection can trigger this.src/lib/server/utils/TokenCounterManager.ts— this one was more dangerous, sinceTokenCountersis core infrastructure used by every connection adapter (not optional like a single connection type). The affected counter classes (OpenAIGPT2/35/4/4oTokenCounter,Llama3TokenCounter,CohereTokenCounter) now lazy-import their tokenizer on first use instead of at module load.llama-tokenizer-jsandmistral-tokenizer-jsdon't use\p{...}and stay as ordinary static imports.
Still unresolved: actually using an LM Studio connection, or an affected token counter, on Android will still fail at that point (same underlying ICU gap) — this only stops it from crashing the app for everyone else at boot. If this surfaces again in practice, either add a friendlier error for that specific path, or find/build a nodejs-mobile variant with full ICU.
A more severe symptom of the same root cause: nodejs-mobile's Android build doesn't just lack Unicode regex support, it has no
Intlglobal at all (ReferenceError: Intl is not defined) — not missing locale data, the entire namespace absent. Unlike the\p{...}cases above, this isn't confined to a handful of dependencies behind lazy imports —Intlis an ambient global that SvelteKit's own generated page code (and presumably other UI code, via.toLocaleDateString()/.toLocaleString()) references directly, so every page request 500'd. Fixed with a global polyfill instead of tracking down every call site:scripts/android-intl-polyfill.cjs—require("intl"); theintlnpm package (added as a regular dependency) self-detects a missingglobal.Intland polyfills it, including patchingDate.prototype/Number.prototype's locale-sensitive methods.scripts/build-android.jscopies it into the assets bundle.NodeService.ktpasses it vianode --require <path>, ahead of the main script, so it's in place before any app code runs — this only works because it's preloaded as a real file;intl's internalglobal.IntlPolyfillassignment doesn't reliably propagate when eval'd another way (e.g.node -e).- No-op on every other platform, where a real
Intlalready exists.
-
The WebView's own HTTP cache is disabled entirely (
cacheMode = WebSettings.LOAD_NO_CACHE, plus an explicitwebView.clearCache(true)on launch, inMainActivity.kt) — a broken/still-starting server response (a 404 or 500 hit while Node is still coming up) could otherwise get cached againsthttp://localhost:3000/...and get served back on a later, successful launch instead of the real response, since the origin never changes. There's no real network between the embedded server and its own WebView, so caching had no upside here to trade off against that risk.
Troubleshooting
Gradle build fails
cd android
./gradlew clean
./gradlew assembleDebug --info
App crashes on startup
Check logs: adb logcat | grep "pub.serene"
Common issues:
UnsatisfiedLinkError: dlopen failed: library "libc++_shared.so" not found—ANDROID_STL=c++_sharedis missing fromapp/build.gradle'sexternalNativeBuild.cmake.arguments(libnode.so was built against the shared C++ runtime by nodejs-mobile; this setting is what makes AGP auto-package the NDK'slibc++_shared.sointo the APK alongside it)UnsatisfiedLinkErrorloadingnodeornode-bridgefor any other reason — meanslibnode.sowasn't extracted from thenodejs-mobile-react-nativenpm package correctly (re-runnpm run android:prepareand confirmandroid/app/src/main/cpp/libnode/bin/arm64-v8a/libnode.soexists), or the CMake build failed silently (check the Gradle build log forexternalNativeBuild/CMake errors — usually a missing NDK/CMake SDK component, see Prerequisites)- Assets not extracted properly
- Port 3000 already in use
Server won't start
The app shows "Server startup timeout" if Node.js doesn't respond on
localhost:3000 within the wait window. Check:
adb logcat | grep NodeJSfor Node's own stdout/stderr, redirected to logcat bynode-bridge.cppadb logcat | grep NodeServicefor an "App entrypoint not found" error, which means asset extraction didn't producebuild/index.js- Available storage space
Adding to GitHub Release
The workflow automatically:
- Builds the APK when you push a version tag
- Signs it with your keystore (if configured)
- Uploads to the GitHub release
Just push a tag:
git tag v0.5.0
git push origin v0.5.0
The APK will appear in the release as serene-pub-0.5.0-android.apk