# 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) ```bash 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 ```bash 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 ```bash 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 ```bash # 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: ```bash 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 ```bash npm run android:full ``` This runs: build → android:prepare → android:build ## Testing the APK ### Install on Device ```bash adb install android/app/build/outputs/apk/debug/app-debug.apk ``` ### View Logs ```bash # 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](https://github.com/nodejs-mobile/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.cjs` — `require("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 `, 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 ```bash 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++_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: ```bash 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`