A production-ready Android template that runs Gemma 4 fully on-device — no internet required after first setup. Drop it into Claude Code and describe what you want to build on top.
This template handles everything below the UI layer: model download, on-device LLM inference via LiteRT-LM, offline enforcement, and device capability detection. The demo screen is a minimal chat prompt box. Replace it with whatever you're building.
Package: com.gemma.template
Min SDK: 26 (Android 8.0)
LLM library: com.google.ai.edge.litertlm:litertlm-android:0.10.2
Default model: Gemma 4 E2B (~2 GB) — falls back to Gemma 3 1B (~555 MB) on lower-RAM devices
Model format: .litertlm (LiteRT-LM — not the older MediaPipe .task format)
app/src/main/kotlin/com/gemma/template/
├── GemmaApp.kt # @HiltAndroidApp — wires WorkManager + Hilt
├── MainActivity.kt # Single-activity host, sets AppTheme + AppNavGraph
│
├── llm/
│ ├── LlmEngine.kt # Interface: initialize(file) / generate(prompt) / close()
│ ├── LiteRtLmEngine.kt # Real inference — LiteRT-LM Engine, one Conversation per call
│ └── MockLlmEngine.kt # Debug fake — streams canned text at 30ms/word
│
├── data/model/
│ └── ModelRepository.kt # Download, file resolution, SHA-256 verify, tier prefs
│
├── download/
│ └── DownloadModelWorker.kt # WorkManager HiltWorker — background download + notification
│
├── di/
│ └── AppModule.kt # Hilt: binds LlmEngine → Mock (debug) or LiteRtLm (share/release)
│
├── util/
│ ├── DeviceCapabilityDetector.kt # Reads RAM/storage/ABI → recommends ModelTier
│ ├── ModelTier.kt # Enum: GEMMA4_E2B / GEMMA3_1B / UNSUPPORTED
│ └── NetworkGate.kt # Permanently revokes internet after model is verified
│
└── ui/
├── AppNavGraph.kt # Routes: DOWNLOAD → CHAT (replace CHAT with your screen)
├── theme/AppTheme.kt # Material 3, dynamic colour on Android 12+
├── download/
│ ├── DownloadScreen.kt # Device info card, model picker, progress, celebration
│ └── DownloadViewModel.kt # States: Loading → ReadyToDownload → Downloading → Ready
└── chat/ ← REPLACE THIS WITH YOUR OWN UI
├── ChatScreen.kt # Demo: message list + streaming input bar
└── ChatViewModel.kt # Loads model on init, exposes send(prompt), streaming state
interface LlmEngine {
suspend fun initialize(modelFile: File) // call once before generate
suspend fun generate(prompt: String): Flow<String> // streams tokens
fun close()
}Both LiteRtLmEngine and MockLlmEngine implement this. Hilt injects the right one at build time based on BuildConfig.USE_MOCK_LLM. Your ViewModel only ever sees LlmEngine.
Owns model file resolution and download. Lookup order:
context.filesDir/models/<filename>— internal storage (primary)/sdcard/Android/data/com.gemma.template/files/models/— push viaadbwithout root/sdcard/Download/<filename>— dev/emulator fast-path
Download uses 8 parallel HTTP range requests for speed, verifies SHA-256 on completion, and can resume interrupted downloads. Model URLs and filenames live in a modelConfigs map — swap them to point at a different model.
Reads total RAM and free storage at runtime. Recommends:
| RAM | Free storage | Tier |
|---|---|---|
| ≥ 4 GB | ≥ 2.5 GB | GEMMA4_E2B |
| ≥ 2 GB | ≥ 1 GB | GEMMA3_1B |
| below either | — | UNSUPPORTED |
After the model is downloaded and verified, revoke() is called once. It sets a SharedPreferences flag and the app never opens a network connection again. This is how the offline guarantee is enforced. The gate can be reset in debug builds for re-testing the download flow.
Loading → ReadyToDownload → Downloading → Celebrating → Ready (navigates away)
↘ Error → (retry) → Loading
Loading → Unsupported (device below minimum specs)
Ready state triggers navigation to the next screen. In debug builds (USE_MOCK_LLM=true) the ViewModel short-circuits straight to Ready — no download needed.
| Variant | USE_MOCK_LLM |
Model | Purpose |
|---|---|---|---|
debug |
true |
None (mock) | Fast iteration — no download, no model file |
share |
false |
Real Gemma | Sideloadable — debug-signed, anyone can install |
release |
false |
Real Gemma | Play Store / production |
# Requires Java 17
export JAVA_HOME=/opt/homebrew/Cellar/openjdk@17/17.0.18/libexec/openjdk.jdk/Contents/Home
# Debug build (mock LLM, instant, for emulator)
./gradlew assembleDebug
# Share build (real Gemma, for sideloading on real device or capable emulator)
./gradlew assembleShare
# Install on connected device/emulator
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb install -r app/build/outputs/apk/share/app-share.apkIf your model requires a HuggingFace token (gated repo), add it to local.properties:
hf_token=hf_yourTokenHere
The debug build works on any emulator — mock LLM, instant response, no model file needed.
For real inference (share build), the emulator must be:
- ARM64 (
arm64-v8aABI) — LiteRT-LM is not x86 compatible - ≥ 4 GB RAM configured in the AVD
On Apple Silicon Macs, ARM64 emulators run natively. To create a capable AVD:
Android Studio → Device Manager → Create → Pixel 8 Pro → ARM64 system image → RAM: 6144 MB
Alternatively, push a model file directly to the emulator instead of downloading:
# Push model to the adb-accessible fast-path
adb push gemma-4-E2B-it.litertlm /sdcard/Download/
# The app will detect and copy it to internal storage on next launchDelete ui/chat/ChatScreen.kt and ChatViewModel.kt. Create your own screen. The only contract is:
@HiltViewModel
class YourViewModel @Inject constructor(
private val llmEngine: LlmEngine,
private val modelRepository: ModelRepository
) : ViewModel() {
init {
viewModelScope.launch {
if (!BuildConfig.USE_MOCK_LLM) {
val file = modelRepository.getModelFile(modelRepository.getChosenTier())!!
llmEngine.initialize(file)
}
}
}
fun generate(prompt: String): Flow<String> = flow {
llmEngine.generate(prompt).collect { emit(it) }
}
}In AppNavGraph.kt:
object Routes {
const val DOWNLOAD = "download"
const val CHAT = "chat"
const val YOUR_SCREEN = "your_screen" // add this
}
// In NavHost:
composable(Routes.YOUR_SCREEN) {
YourScreen()
}Change the onModelReady navigation target in the DOWNLOAD composable to point at YOUR_SCREEN.
In ModelRepository.kt, find modelConfigs and add or replace an entry:
ModelTier.GEMMA4_E2B to ModelConfig(
filename = "your-model-filename.litertlm",
downloadUrl = "https://huggingface.co/your-org/your-repo/resolve/main/your-model.litertlm",
isGated = false, // set true if repo requires HuggingFace login
knownSha256 = "TODO_POPULATE_AFTER_FIRST_DOWNLOAD"
)After the first successful download the app logs the real SHA-256 — copy it into knownSha256 for integrity checking on subsequent installs.
The template has no database. To add Room:
// app/build.gradle.kts — add:
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)Define your @Entity, @Dao, and @Database classes. Wire them in AppModule.kt the same way as the rest of the Hilt bindings.
The template has no STT or TTS. To add Android's built-in speech recogniser:
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
}
startActivityForResult(intent, REQUEST_CODE_SPEECH)For TTS, android.speech.tts.TextToSpeech works offline with the device's built-in engine. Add android.permission.RECORD_AUDIO to the manifest for STT.
- LiteRT-LM API:
Engine(EngineConfig(modelPath))→engine.initialize()→engine.createConversation()→conversation.sendMessageAsync(prompt)collectsFlow<Message>— each message containsContent.Textparts - One Conversation per call: a new
Conversationis created for eachgenerate()call so context does not bleed between turns. To support multi-turn memory, hold theConversationopen across calls in your ViewModel - Thread safety:
LiteRtLmEngine.initialize()andgenerate()both run onDispatchers.IO. Do not call them from the main thread - Cold-load time: ~10–30 seconds for the first
initialize()call on device (555 MB–2 GB model read into memory) - Model file integrity: files starting with
<htmlorHTTP/are rejected as CDN error pages before they can crash the LLM loader - Offline after setup:
NetworkGate.revoke()is called once the model passes the SHA-256 check. After that, the app makes zero network calls
Paste this at the start of a new session:
I'm building [describe your app] on top of the Gemma Android template.
Architecture:
- LlmEngine interface: initialize(File), generate(String): Flow<String>, close()
- Hilt injects MockLlmEngine (debug) or LiteRtLmEngine (share/release) automatically
- ModelRepository handles model download and file resolution
- DownloadScreen/DownloadViewModel handles first-run setup — do not modify
- AppNavGraph: DOWNLOAD → CHAT (replace CHAT with my screen)
- Package: com.gemma.template
- Build: debug = mock LLM, share = real Gemma 4 E2B on-device
What I want to build: [describe your use case]
Start by replacing ui/chat/ with the screens I need.