Skip to content

Navigation Menu

Sign in
Sign up

Repository files navigation

Gemma Android Template

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.

What this is

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)


Architecture

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

Layer responsibilities

LlmEngine (interface)

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.

ModelRepository

Owns model file resolution and download. Lookup order:

  1. context.filesDir/models/<filename> — internal storage (primary)
  2. /sdcard/Android/data/com.gemma.template/files/models/ — push via adb without root
  3. /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.

DeviceCapabilityDetector + ModelTier

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

NetworkGate

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.

DownloadViewModel states

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.


Build variants

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.apk

If your model requires a HuggingFace token (gated repo), add it to local.properties:

hf_token=hf_yourTokenHere

Testing on the emulator

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-v8a ABI) — 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 launch

How to extend this template

1. Replace the chat screen

Delete 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) }
 }
}

2. Add a route

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.

3. Swap or add a model

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.

4. Add persistent storage

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.

5. Add speech input / output

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.


Key technical facts

  • LiteRT-LM API: Engine(EngineConfig(modelPath))engine.initialize()engine.createConversation()conversation.sendMessageAsync(prompt) collects Flow<Message> — each message contains Content.Text parts
  • One Conversation per call: a new Conversation is created for each generate() call so context does not bleed between turns. To support multi-turn memory, hold the Conversation open across calls in your ViewModel
  • Thread safety: LiteRtLmEngine.initialize() and generate() both run on Dispatchers.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 <html or HTTP/ 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

Prompt to give Claude Code when building on this template

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.

About

Production-ready Android template for running Gemma 4 fully on-device via LiteRT-LM. No internet required after first setup.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

AltStyle によって変換されたページ (->オリジナル) /