First off — thank you for considering a contribution! This project turns old Android flagships into private, OpenAI-compatible AI servers, and every issue, doc fix, test, and pull request makes it better for the next person.
This document is the single source of truth for how to contribute. It covers the dev environment, how to build and run the app, the coding conventions enforced by CI, the test strategy, and the PR workflow.
- Code of Conduct
- Ways to Contribute
- Project Layout
- Prerequisites
- Initial Setup
- Building the App
- Running on a Device
- Development Workflow
- Coding Style
- Testing
- Continuous Integration
- Submitting a Pull Request
- Reporting Bugs
- Requesting Features
- Releases
- Getting Help
This project adheres to the Contributor Covenant. By participating you are expected to uphold this code. Please report unacceptable behavior to the maintainers via a private issue or email.
You don't have to write code to help:
- Report bugs — open an issue with logs and reproduction steps.
- Improve documentation — fix typos, clarify wording, add examples to
README.md,ARCHITECTURE.md, orapi/README.md. - Triage issues — reproduce reports, ask for missing info, label them.
- Test on real hardware — every device/Android-version combination we validate makes the app more reliable.
- Add tests — coverage for
app/src/test/andapp/src/androidTest/is always welcome. - Submit code — bug fixes, new endpoints, performance improvements, UI polish.
If you plan a non-trivial change, open an issue first to discuss the approach. It saves everyone time and avoids wasted PRs.
android-llm-server/
├── app/ # Android application module
│ ├── build.gradle.kts # App build config (SDK, deps, R8, signing)
│ ├── proguard-rules.pro # R8/ProGuard rules
│ └── src/
│ ├── main/java/de/cyclenerd/android/llm/server/
│ │ ├── LlmServerApplication.kt
│ │ ├── data/ # Model catalog, downloads, validation
│ │ ├── inference/ # LiteRT engine, conversation pool, streaming
│ │ ├── network/ # IP detection, network monitoring
│ │ ├── perf/ # CPU/GPU/NPU tuning, baseline profiles
│ │ ├── security/ # Bind-address allow-list, request guards
│ │ ├── server/ # Ktor HTTP server, routes, models
│ │ ├── service/ # Foreground service lifecycle
│ │ ├── ui/ # Jetpack Compose screens & ViewModels
│ │ └── utils/ # Logger, helpers
│ ├── test/ # JVM unit tests
│ └── androidTest/ # On-device instrumented tests
├── api/ # OpenAI-compatible API docs & examples
├── gradle/ # Wrapper + JVM toolchain config
├── .github/ # CI workflows, issue & PR templates
├── ARCHITECTURE.md # In-depth design documentation
├── CONTRIBUTING.md # ← you are here
├── README.md # User-facing intro
└── settings.gradle.kts # Single module project
Read ARCHITECTURE.md before making structural changes — it explains the threading model, the inference pipeline, and the performance trade-offs that drive most of the non-obvious code.
You need:
| Tool | Version | Notes |
|---|---|---|
| JDK | 21 (Temurin) | Required by the Gradle 9.5 daemon. |
| Android Studio | Ladybug or newer | Optional but strongly recommended. |
| Android SDK | API 36 + 37 | compileSdk = 37, minSdk = 36, targetSdk = 37. |
| Build Tools | 36.0.0 | Installed by Android Studio's SDK Manager. |
| Git | 2.x | — |
| Physical device | Android 16 (API 36)+, arm64-v8a, modern GPU | Emulators cannot run LiteRT inference. |
| macOS / Linux / Windows | — | All three work; macOS/Linux are the primary dev hosts. |
The Gradle wrapper (./gradlew) pins Gradle 9.5.0 — do not install
Gradle globally, always use the wrapper.
# Fork the repo on GitHub, then:
git clone https://github.com/<your-username>/android-llm-server.git
cd android-llm-server
# Add the upstream remote so you can sync later
git remote add upstream https://github.com/Cyclenerd/android-llm-server.gitCreate local.properties in the repo root (it is git-ignored):
sdk.dir=/absolute/path/to/Android/sdkAndroid Studio will write this file automatically when you open the project for the first time.
File → Open → select the android-llm-server/ directory. Wait for the
initial Gradle sync to finish (5–10 minutes on a cold cache).
All commands run from the repo root. Use the wrapper (./gradlew),
not a system Gradle install.
# Compile and assemble everything (debug + release)
./gradlew build
# Debug APK only — installs faster, includes debuggable=true
./gradlew :app:assembleDebug
# Release APK with R8 full mode, resource shrinking, baseline profile
./gradlew :app:assembleRelease
# Install the debug APK on a connected device
./gradlew :app:installDebug
# Wipe build outputs
./gradlew cleanOutput APKs land in:
app/build/outputs/apk/debug/app-debug.apkapp/build/outputs/apk/release/app-release.apk
The release APK is signed with the auto-generated debug keystore so it installs directly on any device. It is not Play-Store-eligible — see
app/build.gradle.ktsfor how to override thesigningConfigif you need a real release key.
gradle.properties already enables the Gradle daemon, configuration
cache, build cache, and parallel execution. The first build is slow
because it downloads ~1 GB of dependencies; subsequent builds are
typically incremental and complete in seconds.
If you hit out-of-memory errors, increase the heap:
# in gradle.properties
org.gradle.jvmargs=-Xmx8192m -Xms2048m -XX:MaxMetaspaceSize=1024m -XX:+UseParallelGCLiteRT inference requires a real Android device with a modern GPU and the arm64-v8a ABI. Emulators will install the app but cannot run inference.
- Enable Developer Options on your device (tap Build number 7×).
- Enable USB debugging.
- Connect via USB, accept the RSA prompt.
- Verify the device is visible:
adb devices
- Install and launch:
./gradlew :app:installDebug adb shell am start -n de.cyclenerd.android.llm.server/.ui.MainActivity
- In the app, open Model Management → download Gemma 4 E2B (~2.4 GB; needs WiFi).
- Return to the Dashboard → Start Server.
- Hit the API:
curl http://<device-ip>:8080/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model":"gemma-4","messages":[{"role":"user","content":"Hi"}]}'
See api/README.md for the full API surface and more
example clients.
# Filter by package
adb logcat | grep "de.cyclenerd.android.llm.server"
# Filter by a single tag (e.g. the inference engine)
adb logcat -s LlmEngine
# Crash dumps
adb logcat AndroidRuntime:E *:STo bump verbosity, set Logger.logLevel = LogLevel.DEBUG in
utils/Logger.kt (debug builds only — Logger.d calls are stripped from
release by R8).
-
Sync with upstream before starting work:
git fetch upstream git checkout main git merge --ff-only upstream/master
-
Create a feature branch off
master:git checkout -b feat/short-descriptive-name
Branch naming convention:
Prefix Use for feat/New feature fix/Bug fix docs/Documentation only refactor/Code restructuring test/Test-only changes perf/Performance work chore/Build / tooling / CI -
Make focused, atomic commits. One logical change per commit.
-
Run the full pre-commit gauntlet before pushing:
./gradlew ktlintFormat # auto-fix formatting ./gradlew ktlintCheck # verify no remaining issues ./gradlew :app:lintDebug # Android Lint ./gradlew :app:testDebugUnitTest # JVM unit tests
-
Push and open a PR (see Submitting a Pull Request).
We follow Conventional Commits:
<type>(<scope>): <short imperative summary>
<optional body — wrap at 72 chars; explain *why*, not *what*>
<optional footer — Closes #123, Refs #456, BREAKING CHANGE: ...>
Types: feat, fix, docs, style, refactor, perf, test,
chore, ci, build.
Examples:
feat(inference): add NPU acceleration for Pixel devices
Auto-detects the Edge TPU and falls back to GPU/CPU if unavailable.
Cuts decode latency by ~40% on Pixel 8 Pro.
Closes #123
fix(network): rebind server when WiFi changes networks
NetworkMonitor now reacts to ConnectivityManager callbacks instead of
polling, eliminating the 30 s stale-IP window after a network switch.
Formatting is enforced by ktlint in CI. Always run before committing:
./gradlew ktlintFormat # auto-fix what can be fixed
./gradlew ktlintCheck # verify (CI runs this)The .editorconfig file pins line endings (LF), encoding (UTF-8), and
indentation. Don't fight your editor — let it pick those up automatically.
Clear, descriptive names:
// Good
class LlmEngine(private val modelPath: String)
// Bad
class Engine(private val mp: String)Comments explain why, not what:
// Good — captures non-obvious reasoning
// Pool of 3 conversations: avoids the ~50 MB allocation and 2-3 s
// warm-up cost incurred by creating a fresh LiteRT session per request.
private val conversationPool = ConversationPool(maxSize = 3)
// Bad — restates the code
// Creates a pool of conversations
private val conversationPool = ConversationPool(maxSize = 3)KDoc for every public API:
/**
* Loads a LiteRT model from disk into the inference engine.
*
* Cold load takes 10–30 s depending on model size and storage speed.
* Safe to call from any dispatcher; internally hops to [Dispatchers.IO].
*
* @param path Absolute path to the `.task` model file.
* @return [Result.success] with a ready-to-use engine, or [Result.failure]
* wrapping a [ModelNotFoundException] / [InsufficientMemoryException].
*/
suspend fun loadModel(path: String): Result<LlmEngine>Pick the right dispatcher:
// I/O — file, network, DataStore
suspend fun download() = withContext(Dispatchers.IO) { /* ... */ }
// CPU-bound work — JSON parsing, prompt formatting
suspend fun format() = withContext(Dispatchers.Default) { /* ... */ }
// Inference — uses the dedicated single-thread dispatcher in PerformanceManagerUse the right scope:
class DashboardViewModel : ViewModel() {
fun refresh() {
// Cancelled automatically when the ViewModel clears
viewModelScope.launch { /* ... */ }
}
}Never use GlobalScope — it leaks coroutines past the lifecycle of the
component that started them.
Prefer Result<T> for recoverable errors at module boundaries; let
unrecoverable invariants throw:
suspend fun loadModel(path: String): Result<LlmEngine> = runCatching {
LlmEngine(path).also { it.initialize() }
}.recoverCatching { e ->
throw ModelLoadException("Failed to load model at $path", e)
}| Code area | Minimum coverage |
|---|---|
Business logic (data/, inference/, server/) |
80% |
Security-critical (security/, bind-address logic) |
100% |
UI (ui/) |
Smoke tests + screenshot tests welcome |
app/src/test/— unit tests, run on the JVM, no device required. Fast (< 1 s per test). Use these for everything that doesn't touch the Android framework.app/src/androidTest/— instrumented tests, run on a real device or emulator. Use only when you actually need anApplication,Context, real Ktor server, or LiteRT engine.
// app/src/test/java/de/cyclenerd/android/llm/server/network/NetworkUtilsTest.kt
class NetworkUtilsTest {
@Test
fun `mobile interfaces are filtered out`() {
val ips = NetworkUtils.getLocalIpAddresses()
assertTrue(ips.none { it.startsWith("rmnet") })
}
}// app/src/androidTest/java/de/cyclenerd/android/llm/server/E2ETest.kt
@RunWith(AndroidJUnit4::class)
class E2ETest {
@Test
fun serverBindsOnlyToLocalAddresses() = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
// ...
}
}# All JVM unit tests
./gradlew :app:testDebugUnitTest
# A single test class
./gradlew :app:testDebugUnitTest --tests "*.NetworkUtilsTest"
# A single test method
./gradlew :app:testDebugUnitTest --tests "*.NetworkUtilsTest.mobile interfaces are filtered out"
# Instrumented tests (device/emulator must be connected)
./gradlew :app:connectedDebugAndroidTestHTML reports:
- Unit:
app/build/reports/tests/testDebugUnitTest/index.html - Instrumented:
app/build/reports/androidTests/connected/index.html
Every push and every pull request runs against
.github/workflows/ci.yml:
| Job | What it runs |
|---|---|
| Lint & format | ./gradlew ktlintCheck + ./gradlew :app:lintDebug |
| Unit tests | ./gradlew :app:testDebugUnitTest |
| Build APK | ./gradlew :app:assembleRelease (R8 full mode, shrunk, debug-signed) |
A separate instrumented-tests.yml workflow runs nightly (and
on-demand) against an emulator with KVM acceleration.
Releases (.github/workflows/release.yml) trigger on v* tags:
re-run lint + unit tests, build a signed release APK, generate a
changelog from git log, and publish a GitHub Release with the APK
attached.
If CI fails on your PR, click the failing job to see the full log and the uploaded artefacts (lint HTML, test reports). Run the same Gradle command locally to reproduce.
-
Push your branch to your fork:
git push -u origin feat/your-feature
-
Open the PR against
masteron the upstream repo. The PR template in.github/PULL_REQUEST_TEMPLATE.mdwill be pre-filled. -
Fill in the description. A good PR description answers:
- What does this change?
- Why is the change needed?
- How was it tested? (Devices, Android versions, models.)
- Screenshots / logs for UI or behavioural changes.
- Linked issue —
Closes #123.
-
Self-review checklist before requesting review:
-
./gradlew ktlintFormat ktlintCheck :app:lintDebugpasses. -
./gradlew :app:testDebugUnitTestpasses. - New / changed code is covered by tests.
- Public APIs have KDoc.
-
README.md/ARCHITECTURE.md/api/README.mdupdated if user-facing or architectural behaviour changed. - Tested on at least one physical device (note the model + Android version in the PR).
- No secrets, keystores, or
local.propertiescommitted. - Commit messages follow Conventional Commits.
-
-
Respond to review comments. Push additional commits — do not force-push during review unless asked. The maintainer will squash on merge, so commit hygiene during review is less important than a readable diff.
-
Keep your branch current. If
mastermoves, rebase or merge:git fetch upstream git rebase upstream/master # preferred # or: git merge upstream/master
Use the Bug report issue template
(.github/ISSUE_TEMPLATE/bug_report.md). A great bug report includes:
- Device + Android version (e.g. Pixel 8 Pro, Android 16).
- App version (Settings → About, or commit SHA if you built it).
- Model in use (e.g. Gemma 4 E2B).
- Exact reproduction steps — numbered list, no skipped steps.
- Expected vs actual behaviour.
- Logs:
Attach
adb logcat -d | grep "de.cyclenerd.android.llm.server" > bug.log
bug.log(redact your IP if it appears). - Screenshots / screen recording for UI bugs.
Security vulnerabilities should not be filed as public issues — contact the maintainers privately first.
Use the Feature request template. Strong feature requests answer:
- Problem: what user-visible pain point does this solve?
- Proposed solution: how would it work from the user's perspective?
- Alternatives: other approaches you considered and why they're worse.
- Scope: is this a small UI tweak, a new endpoint, a new backend?
- Willing to implement? Maintainers prioritise issues with a contributor lined up.
Maintainers cut releases by tagging:
git tag -a v1.2.0 -m "Release 1.2.0"
git push upstream v1.2.0The release.yml workflow takes over from there. Contributors should
not push tags.
Versioning follows SemVer. Pre-release suffixes
(-beta.1, -rc.2) are auto-detected and marked as pre-releases on
GitHub.
- Architecture questions → read
ARCHITECTURE.mdfirst; it covers the threading model, performance tuning, and the Ktor/LiteRT integration. - API questions → see
api/README.mdand the example scripts inapi/examples.shandapi/examples.py. - Build / setup issues → open an issue with the
questionlabel and paste the full Gradle output. - General discussion → GitHub Discussions (if enabled) or the
questionissue label.
Thanks again for contributing — happy hacking!