serene-pub/android/README.md

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 of release.keystore.base64
  • ANDROID_KEYSTORE_PASSWORD: Your store password from step 1
  • ANDROID_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 compile src/main/cpp/node-bridge.cpp, the JNI bridge that embeds Node in-process (see Architecture below). Install both via Android Studio's SDK Manager, or sdkmanager --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:

  1. MainActivity.kt: WebView wrapper, manages server lifecycle
  2. NodeService.kt: Foreground service that runs Node in-process (on a background thread) via NodeBridge
  3. NodeBridge.kt / node-bridge.cpp: JNI bridge — NodeBridge.kt is a thin Kotlin wrapper around a native startNodeWithArguments() call implemented in src/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.
  4. Assets: Contains the app code and dependencies (SvelteKit build, node_modules, static files, drizzle migrations) — extracted to app-private storage on first run
  5. src/main/cpp/libnode: A prebuilt, Bionic-targeted libnode.so + Node's C headers, pulled from the nodejs-mobile-react-native npm package by scripts/build-android.js (not committed — regenerated by android:prepare). Linked into node-bridge.so at 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:

  • MainActivity starts NodeService as a foreground service immediately
  • NodeService extracts the app bundle (not Node itself) to the app data directory, then calls into NodeBridge.startNodeWithArguments() on a background thread, which blocks until Node's event loop exits
  • MainActivity polls localhost:3000 and 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=dev runs as part of android:full before 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 access
  • FOREGROUND_SERVICE / FOREGROUND_SERVICE_SPECIAL_USE: Keep the bundled Node.js server running for the whole session (specialUse is the correct API 34+ category for a long-lived local app server — it isn't subject to the execution-time limits Android increasingly enforces on dataSync-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.so is not 16 KB page-size aligned (p_align=0x1000, confirmed via readelf -lW) — nodejs-mobile's prebuilt binary predates Google's 16 KB mandate and hasn't been updated (last release Oct 2024, verified against both the nodejs-mobile-react-native npm package and the upstream nodejs-mobile/nodejs-mobile GitHub releases). node-bridge.so (our own code, see src/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/koboldcpp only publishes linux-x64/mac-arm64/Windows release binaries, no linux-arm64 exists 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.
  • Local vectorization (the in-process ONNX embedding model) is disabled on Android — confirmed via readelf that onnxruntime-node's prebuilt Linux ARM64 binaries depend on ld-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 /embeddings HTTP call (see src/lib/server/embedding/index.ts's activateApiEmbedding), 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 a SyntaxError while 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 by LMStudioAdapter.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 SyntaxError at 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 dynamic import(), 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 (including LMStudioAdapter) 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, since TokenCounters is 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-js and mistral-tokenizer-js don'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 Intl global 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 — Intl is 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.cjsrequire("intl"); the intl npm package (added as a regular dependency) self-detects a missing global.Intl and polyfills it, including patching Date.prototype/Number.prototype's locale-sensitive methods.
    • scripts/build-android.js copies it into the assets bundle.
    • NodeService.kt passes it via node --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 internal global.IntlPolyfill assignment doesn't reliably propagate when eval'd another way (e.g. node -e).
    • No-op on every other platform, where a real Intl already exists.
  • The WebView's own HTTP cache is disabled entirely (cacheMode = WebSettings.LOAD_NO_CACHE, plus an explicit webView.clearCache(true) on launch, in MainActivity.kt) — a broken/still-starting server response (a 404 or 500 hit while Node is still coming up) could otherwise get cached against http://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 foundANDROID_STL=c++_shared is missing from app/build.gradle's externalNativeBuild.cmake.arguments (libnode.so was built against the shared C++ runtime by nodejs-mobile; this setting is what makes AGP auto-package the NDK's libc++_shared.so into the APK alongside it)
  • UnsatisfiedLinkError loading node or node-bridge for any other reason — means libnode.so wasn't extracted from the nodejs-mobile-react-native npm package correctly (re-run npm run android:prepare and confirm android/app/src/main/cpp/libnode/bin/arm64-v8a/libnode.so exists), or the CMake build failed silently (check the Gradle build log for externalNativeBuild/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 NodeJS for Node's own stdout/stderr, redirected to logcat by node-bridge.cpp
  • adb logcat | grep NodeService for an "App entrypoint not found" error, which means asset extraction didn't produce build/index.js
  • Available storage space

Adding to GitHub Release

The workflow automatically:

  1. Builds the APK when you push a version tag
  2. Signs it with your keystore (if configured)
  3. 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