With the release of Gemma 4, on-device AI development has entered a new era. The E2B model runs on roughly 2GB of memory, and the E4B delivers function-calling capabilities with under 4B effective parameters. This isn't just demo material — it means production-quality AI features can now be embedded directly into edge devices.
This article takes a deep dive into Gemma 4's on-device AI integration, walking you through advanced workflows for building, optimizing, and deploying custom models within the Antigravity development environment, complete with implementation code.
Architecture Design for On-Device AI
Building production-quality on-device AI requires more than just picking a model — you need to design the entire inference pipeline. Let's explore practical architecture patterns built around Gemma 4's four model sizes.
Hybrid Architecture: Optimal Edge + Cloud Distribution
Not every AI task needs to run on-device. The most efficient approach distributes work between edge and cloud based on task complexity.
# Hybrid inference router implementation
class HybridInferenceRouter:
"""Automatically routes tasks between edge and cloud based on complexity"""
def __init__(self, edge_model, cloud_client):
self.edge_model = edge_model # Gemma 4 E4B (local)
self.cloud_client = cloud_client # Gemma 4 31B (server)
self.complexity_threshold = 0.7
async def route(self, task: str, context: dict) -> str:
complexity = self._estimate_complexity(task, context)
if complexity < self.complexity_threshold:
# Simple tasks -> process instantly on edge
return await self.edge_model.generate(task)
else:
# Complex tasks -> delegate to cloud
if self._is_network_available():
return await self.cloud_client.generate(task)
else:
# Offline fallback: best-effort on edge
return await self.edge_model.generate(
task,
max_tokens=2048,
temperature=0.3 # Prioritize accuracy
)
def _estimate_complexity(self, task: str, context: dict) -> float:
"""Estimate task complexity on a 0.0-1.0 scale"""
score = 0.0
if len(task.split()) > 200:
score += 0.3
if context.get("code_lines", 0) > 500:
score += 0.3
if any(kw in task.lower() for kw in ["analyze", "compare", "design", "refactor"]):
score += 0.2
return min(score, 1.0)
def _is_network_available(self) -> bool:
"""Check network connectivity"""
import socket
try:
socket.create_connection(("8.8.8.8", 53), timeout=1)
return True
except OSError:
return False
# Usage:
# router = HybridInferenceRouter(edge_model, cloud_client)
# result = await router.route("Review this code", {"code_lines": 50})Memory Management and Model Lifecycle
Memory is the primary constraint on edge devices. Efficient load/unload lifecycle management for Gemma 4 models is essential.
// iOS: Core ML and Gemma 4 memory management
import CoreML
class GemmaModelManager {
private var loadedModel: MLModel?
private let modelURL: URL
private let memoryThresholdMB: Int = 150
init(modelPath: String) {
self.modelURL = Bundle.main.url(
forResource: modelPath,
withExtension: "mlmodelc"
)!
}
/// Load model based on available memory
func loadIfNeeded() throws -> MLModel {
if let model = loadedModel {
return model
}
let availableMB = getAvailableMemoryMB()
guard availableMB > memoryThresholdMB else {
throw GemmaError.insufficientMemory(
available: availableMB,
required: memoryThresholdMB
)
}
let config = MLModelConfiguration()
config.computeUnits = .cpuAndNeuralEngine // Leverage ANE
let model = try MLModel(contentsOf: modelURL, configuration: config)
self.loadedModel = model
// Auto-unload on memory warning
NotificationCenter.default.addObserver(
self,
selector: #selector(handleMemoryWarning),
name: UIApplication.didReceiveMemoryWarningNotification,
object: nil
)
return model
}
@objc private func handleMemoryWarning() {
loadedModel = nil // Release the model
print("[Gemma] Memory warning — model unloaded")
}
private func getAvailableMemoryMB() -> Int {
var info = mach_task_basic_info()
var count = mach_msg_type_number_t(
MemoryLayout<mach_task_basic_info>.size
) / 4
let result = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(
to: integer_t.self, capacity: 1
) {
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
}
}
guard result == KERN_SUCCESS else { return 0 }
let usedMB = Int(info.resident_size) / 1_048_576
let totalMB = Int(ProcessInfo.processInfo.physicalMemory) / 1_048_576
return totalMB - usedMB
}
}
enum GemmaError: Error {
case insufficientMemory(available: Int, required: Int)
}Building Custom Models with Fine-Tuning
Gemma 4's Apache 2.0 license means you can fine-tune freely. Here's how to build domain-specific custom models.
Efficient Fine-Tuning with LoRA
Full-parameter fine-tuning is computationally expensive. LoRA (Low-Rank Adaptation) makes it possible to fine-tune the 26B MoE model on just 16GB of GPU memory.
# Gemma 4 LoRA fine-tuning implementation
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
import torch
# 4-bit quantization to reduce memory usage
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True
)
# Load model and tokenizer
model_id = "google/gemma-4-27b-it"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=bnb_config,
device_map="auto",
torch_dtype=torch.bfloat16
)
# LoRA configuration
lora_config = LoraConfig(
r=16, # LoRA rank
lora_alpha=32, # Scaling factor
target_modules=[
"q_proj", "k_proj", "v_proj", # Attention layers
"o_proj", "gate_proj",
"up_proj", "down_proj"
],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM"
)
# Apply LoRA
model = prepare_model_for_kbit_training(model)
model = get_peft_model(model, lora_config)
# Verify trainable parameters
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
total_params = sum(p.numel() for p in model.parameters())
print(f"Trainable: {trainable_params:,} / Total: {total_params:,}")
print(f"Trainable ratio: {100 * trainable_params / total_params:.2f}%")
# Output: Trainable: 41,943,040 / Total: 27,227,128,832
# Output: Trainable ratio: 0.15%Training Data Preparation and Quality Control
The success of fine-tuning depends on data quality. Here's an example of building a domain-specific Q&A dataset.
# Structuring and validating training data
from datasets import Dataset
import json
def prepare_training_data(raw_data_path: str) -> Dataset:
"""Load raw data and convert to Gemma 4 format"""
with open(raw_data_path, "r", encoding="utf-8") as f:
raw_data = json.load(f)
formatted = []
skipped = 0
for item in raw_data:
# Quality checks
if len(item["question"]) < 10:
skipped += 1
continue
if len(item["answer"]) < 50:
skipped += 1
continue
# Convert to Gemma 4 chat template format
text = (
f"<start_of_turn>user\n{item['question']}<end_of_turn>\n"
f"<start_of_turn>model\n{item['answer']}<end_of_turn>"
)
formatted.append({"text": text})
print(f"Valid samples: {len(formatted)} / Skipped: {skipped}")
return Dataset.from_list(formatted)
# Usage:
# dataset = prepare_training_data("training_data.json")
# Output: Valid samples: 4,523 / Skipped: 47Running Training and Evaluating the Model
# Training execution with Transformers Trainer
from transformers import TrainingArguments, Trainer
training_args = TrainingArguments(
output_dir="./gemma4-custom",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
learning_rate=2e-4,
weight_decay=0.01,
warmup_ratio=0.03,
lr_scheduler_type="cosine",
logging_steps=10,
save_strategy="epoch",
bf16=True,
optim="paged_adamw_8bit",
gradient_checkpointing=True,
max_grad_norm=0.3,
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=dataset,
tokenizer=tokenizer,
)
# Start training
trainer.train()
# Save the LoRA adapter
model.save_pretrained("./gemma4-custom-lora")
print("Custom model saved successfully")
# Output: Custom model saved successfullyIntegrating Custom Models with Antigravity Agents
Here's how to wire your fine-tuned Gemma 4 model into Antigravity's agent system.
Building a Custom Model Server
First, convert your custom model into a format Ollama can host.
# Converting custom model to Ollama format
modelfile_content = """
FROM ./gemma4-custom-merged.gguf
PARAMETER temperature 0.7
PARAMETER top_p 0.9
PARAMETER num_ctx 8192
SYSTEM You are an AI assistant with domain-specific expertise.
Provide accurate and practical answers.
"""
with open("Modelfile", "w") as f:
f.write(modelfile_content)
# Run in shell:
# ollama create gemma4-custom -f Modelfile
# ollama run gemma4-custom "Test question"Defining Custom Models in Antigravity agents.md
<!-- .antigravity/agents.md -->
# Domain Expert Agent
## Overview
Uses a custom fine-tuned Gemma 4 model to provide
domain-specific assistance for the project.
## Model Configuration
- Base: Gemma 4 26B MoE
- Adapter: LoRA (domain-specific)
- Endpoint: http://localhost:11434/api/generate
- Model name: gemma4-custom
## Tasks
1. Code suggestions based on project-specific design patterns
2. Documentation generation with domain terminology understanding
3. Bug detection based on historical implementation patterns
## Context
- Can reference /docs/** documentation
- Can analyze /src/** source codeIntegration as an MCP (Model Context Protocol) Server
Exposing your custom Gemma 4 model as an MCP server makes it directly accessible from Antigravity.
// MCP Server: Custom Gemma 4 Model
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new Server(
{ name: "gemma4-custom", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// Domain-specific inference tool
server.setRequestHandler("tools/list", async () => ({
tools: [{
name: "domain_query",
description: "Answer domain-specific questions using custom Gemma 4",
inputSchema: {
type: "object",
properties: {
question: { type: "string", description: "The question to answer" },
context: { type: "string", description: "Additional context" }
},
required: ["question"]
}
}]
}));
server.setRequestHandler("tools/call", async (request) => {
if (request.params.name === "domain_query") {
const { question, context } = request.params.arguments as {
question: string;
context?: string;
};
// Query the custom model via Ollama
const response = await fetch("http://localhost:11434/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "gemma4-custom",
prompt: context
? `Context:\n${context}\n\nQuestion: ${question}`
: question,
stream: false
})
});
const data = await response.json();
return { content: [{ type: "text", text: data.response }] };
}
throw new Error(`Unknown tool: ${request.params.name}`);
});
// Start the server
const transport = new StdioServerTransport();
await server.connect(transport);
// Run: npx tsx mcp-gemma4-server.tsOptimizing Edge Deployment
Key optimization techniques for deploying custom models to edge devices.
Quantization and Model Compression
# GGUF conversion and quantization pipeline
# Using llama.cpp for quantization
# Step 1: HuggingFace format -> GGUF conversion
# python convert_hf_to_gguf.py ./gemma4-custom-merged \
# --outfile gemma4-custom-f16.gguf --outtype f16
# Step 2: Quantize (Q4_K_M recommended — best quality/size balance)
# ./quantize gemma4-custom-f16.gguf gemma4-custom-q4km.gguf Q4_K_M
# Quantization comparison:
# Q8_0: ~27GB -> High quality but too large for edge
# Q4_K_M: ~15GB -> Best balance of quality and size (recommended)
# Q4_0: ~14GB -> Smallest size but some quality loss
# Python quality verification for quantized models
def evaluate_quantized_model(
original_model_path: str,
quantized_model_path: str,
test_prompts: list[str]
) -> dict:
"""Compare output quality between original and quantized models"""
from rouge_score import rouge_scorer
scorer = rouge_scorer.RougeScorer(
["rouge1", "rouge2", "rougeL"],
use_stemmer=True
)
results = {"rouge1": [], "rouge2": [], "rougeL": []}
for prompt in test_prompts:
original_output = generate(original_model_path, prompt)
quantized_output = generate(quantized_model_path, prompt)
scores = scorer.score(original_output, quantized_output)
for key in results:
results[key].append(scores[key].fmeasure)
avg_results = {
k: sum(v) / len(v) for k, v in results.items()
}
print(f"Quantization quality scores: {avg_results}")
return avg_results
# Output: Quantization quality scores: {'rouge1': 0.92, 'rouge2': 0.87, 'rougeL': 0.90}Android LiteRT-LM Deployment Pipeline
// Android: Production integration for custom Gemma 4 model
class GemmaInferenceManager(
private val context: Context
) {
private var session: LlmInferenceSession? = null
companion object {
private const val MODEL_FILE = "gemma4-e4b-custom.task"
private const val MAX_TOKENS = 1024
private const val TEMPERATURE = 0.7f
}
/**
* Initialize model (run on background thread)
*/
suspend fun initialize(): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val options = LlmInference.LlmInferenceOptions.builder()
.setModelPath(getModelPath())
.setMaxTokens(MAX_TOKENS)
.setTemperature(TEMPERATURE)
.setTopK(40)
.setRandomSeed(42)
.build()
val inference = LlmInference.createFromOptions(context, options)
session = inference.createSession()
}
}
/**
* Streaming inference
*/
fun generateStream(
prompt: String,
onToken: (String) -> Unit,
onComplete: () -> Unit,
onError: (Exception) -> Unit
) {
val activeSession = session ?: run {
onError(IllegalStateException("Model not initialized"))
return
}
try {
activeSession.addQueryChunk(prompt)
activeSession.generateResponseAsync(
object : LlmInferenceSession.ResponseCallback {
override fun onPartialResponse(text: String) {
onToken(text)
}
override fun onResponse(text: String) {
onComplete()
}
override fun onError(e: Exception) {
onError(e)
}
}
)
} catch (e: Exception) {
onError(e)
}
}
private fun getModelPath(): String {
return File(context.filesDir, MODEL_FILE).absolutePath
}
fun release() {
session?.close()
session = null
}
}
// Jetpack Compose usage:
// val manager = remember { GemmaInferenceManager(context) }
// LaunchedEffect(Unit) { manager.initialize() }Production Workflow
Building a stable production workflow for custom model operations.
A/B Testing Framework
# A/B testing between model versions
import json
from datetime import datetime
from typing import Optional
class ModelABTest:
"""A/B testing framework for custom Gemma 4 models"""
def __init__(
self,
model_a: str, # Control (current model)
model_b: str, # Variant (new model)
traffic_ratio: float = 0.5,
log_path: str = "ab_test_logs.jsonl"
):
self.model_a = model_a
self.model_b = model_b
self.traffic_ratio = traffic_ratio
self.log_path = log_path
def select_model(self, user_id: str) -> str:
"""Consistent model assignment based on user ID"""
hash_val = hash(user_id) % 100
selected = self.model_b if hash_val < (self.traffic_ratio * 100) else self.model_a
return selected
async def generate_and_log(
self,
user_id: str,
prompt: str,
feedback: Optional[str] = None
) -> dict:
"""Run inference + log results"""
model = self.select_model(user_id)
start = datetime.now()
response = await self._call_model(model, prompt)
latency_ms = (datetime.now() - start).total_seconds() * 1000
log_entry = {
"timestamp": datetime.now().isoformat(),
"user_id": user_id,
"model": model,
"prompt_length": len(prompt),
"response_length": len(response),
"latency_ms": round(latency_ms, 2),
"feedback": feedback
}
with open(self.log_path, "a") as f:
f.write(json.dumps(log_entry) + "\n")
return {"response": response, "model": model, "latency_ms": latency_ms}
async def _call_model(self, model: str, prompt: str) -> str:
"""Call model via Ollama"""
import httpx
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
"http://localhost:11434/api/generate",
json={"model": model, "prompt": prompt, "stream": False}
)
return resp.json()["response"]
# Usage:
# ab_test = ModelABTest("gemma4-custom-v1", "gemma4-custom-v2", traffic_ratio=0.2)
# result = await ab_test.generate_and_log("user_123", "Query text")Monitoring and Alerts
# Production monitoring for custom models
from dataclasses import dataclass, field
from collections import deque
import statistics
@dataclass
class ModelMetrics:
"""Inference metrics collection and anomaly detection"""
window_size: int = 100
latencies: deque = field(default_factory=lambda: deque(maxlen=100))
error_count: int = 0
total_requests: int = 0
# Alert thresholds
latency_p95_threshold_ms: float = 3000.0
error_rate_threshold: float = 0.05
def record_request(self, latency_ms: float, success: bool):
self.total_requests += 1
self.latencies.append(latency_ms)
if not success:
self.error_count += 1
def check_health(self) -> dict:
if len(self.latencies) < 10:
return {"status": "warming_up", "alerts": []}
alerts = []
sorted_latencies = sorted(self.latencies)
p95_idx = int(len(sorted_latencies) * 0.95)
p95 = sorted_latencies[p95_idx]
if p95 > self.latency_p95_threshold_ms:
alerts.append(
f"P95 latency exceeded: {p95:.0f}ms "
f"(threshold: {self.latency_p95_threshold_ms:.0f}ms)"
)
error_rate = self.error_count / self.total_requests
if error_rate > self.error_rate_threshold:
alerts.append(
f"Error rate exceeded: {error_rate:.2%} "
f"(threshold: {self.error_rate_threshold:.2%})"
)
return {
"status": "healthy" if not alerts else "degraded",
"p50_ms": statistics.median(self.latencies),
"p95_ms": p95,
"error_rate": error_rate,
"total_requests": self.total_requests,
"alerts": alerts
}
# Usage:
# metrics = ModelMetrics()
# metrics.record_request(latency_ms=250.0, success=True)
# health = metrics.check_health()
# Output: {"status": "healthy", "p50_ms": 245.0, "p95_ms": 890.0, ...}Advanced Workflow: CI/CD Pipeline Integration
Automating the entire pipeline from model training to deployment.
# .github/workflows/gemma4-model-pipeline.yml
name: Gemma 4 Custom Model Pipeline
on:
push:
paths:
- 'training_data/**'
- 'model_config/**'
jobs:
train-and-evaluate:
runs-on: [self-hosted, gpu]
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install -r requirements-training.txt
- name: Train LoRA adapter
run: python scripts/train_lora.py --config model_config/config.yaml
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
- name: Evaluate model quality
run: |
python scripts/evaluate.py \
--model ./output/gemma4-custom-lora \
--benchmark ./eval_data/benchmark.json \
--threshold 0.85
- name: Convert to GGUF
if: success()
run: |
python scripts/merge_and_convert.py \
--base google/gemma-4-27b-it \
--lora ./output/gemma4-custom-lora \
--output ./output/gemma4-custom.gguf \
--quantize Q4_K_M
- name: Upload model artifact
if: success()
uses: actions/upload-artifact@v4
with:
name: gemma4-custom-model
path: ./output/gemma4-custom-q4km.gguf
deploy:
needs: train-and-evaluate
runs-on: ubuntu-latest
steps:
- name: Download model artifact
uses: actions/download-artifact@v4
with:
name: gemma4-custom-model
- name: Deploy to model server
run: |
scp gemma4-custom-q4km.gguf ${{ secrets.MODEL_SERVER }}:/models/
ssh ${{ secrets.MODEL_SERVER }} "ollama create gemma4-custom -f /models/Modelfile"
echo "Deployment complete"Production Operations Patterns — Caching, Batching, and Parameter Tuning
When running fine-tuned custom models in production, balancing cost-efficiency against response time requires inference-layer engineering: caching identical queries, batching multiple queries, and tuning inference parameters. These are the practical patterns.
Inference Caching
In production, identical prompts arrive repeatedly. Caching reduces second-call latency from 2+ seconds to under 100ms.
import hashlib
import time
from typing import Optional
class GemmaInferenceCache:
def __init__(self, max_cache_size: int = 1000):
self.cache = {}
self.access_times = {}
self.max_size = max_cache_size
def _hash_prompt(self, prompt: str, model: str) -> str:
key_str = f"{model}:{prompt}"
return hashlib.sha256(key_str.encode()).hexdigest()
def get(self, prompt: str, model: str = "gemma4-instruct") -> Optional[str]:
key = self._hash_prompt(prompt, model)
if key in self.cache:
self.access_times[key] = time.time()
return self.cache[key]
return None
def set(self, prompt: str, result: str, model: str = "gemma4-instruct"):
key = self._hash_prompt(prompt, model)
if len(self.cache) >= self.max_size:
oldest_key = min(self.access_times, key=self.access_times.get)
del self.cache[oldest_key]
del self.access_times[oldest_key]
self.cache[key] = result
self.access_times[key] = time.time()Batch Processing
Process multiple prompts in parallel using ThreadPoolExecutor.
from concurrent.futures import ThreadPoolExecutor
from typing import List, Tuple
class GemmaBatchProcessor:
def __init__(self, max_workers: int = 4):
self.executor = ThreadPoolExecutor(max_workers=max_workers)
self.model = "gemma4-instruct"
def _process_single(self, prompt: str) -> Tuple[str, str]:
try:
import ollama
response = ollama.generate(
model=self.model,
prompt=prompt,
stream=False,
options={"num_predict": 256}
)
return (prompt, response["response"])
except Exception as e:
return (prompt, f"ERROR: {str(e)}")
def process_batch(self, prompts: List[str]) -> List[Tuple[str, str]]:
import time
start = time.time()
futures = [self.executor.submit(self._process_single, p) for p in prompts]
results = [f.result() for f in futures]
elapsed = time.time() - start
print(f"Batch complete: {len(prompts)} queries in {elapsed:.2f}s")
return resultsInference Parameter Tuning
Gemma 4 output quality depends heavily on sampling parameters.
from dataclasses import dataclass
@dataclass
class InferenceConfig:
"""Recommended inference parameter sets by use case"""
CONFIGS = {
"precise": {
"top_p": 0.1,
"top_k": 5,
"temperature": 0.3,
"description": "Fact-based answers (FAQ, data extraction)"
},
"balanced": {
"top_p": 0.9,
"top_k": 40,
"temperature": 0.7,
"description": "General question-answering"
},
"creative": {
"top_p": 0.95,
"top_k": 50,
"temperature": 1.0,
"description": "Creative writing, brainstorming"
}
}
@classmethod
def get_config(cls, mode: str = "balanced") -> dict:
if mode not in cls.CONFIGS:
raise ValueError(f"Invalid mode: {mode}")
config = cls.CONFIGS[mode]
description = config.pop("description")
print(f"Mode: {mode} ({description})")
return configPre-Production Checklist
Before deploying Gemma 4 + Antigravity to production:
- [ ] Memory capacity confirmed at 13GB+
- [ ] Caching implemented with 30%+ hit rate
- [ ] Batch processing reducing multi-query time by 40%+
- [ ] API keys loaded from environment
- [ ] Rate limiting implemented with backoff
- [ ] Sampling parameters tuned per use case
- [ ] Exception handling with fallback paths
- [ ] Inference latency < 2s (uncached)
- [ ] Memory usage stays under 13GB
- [ ] Logging captures inference results
On-Device Implementation with Android AICore
For on-device Gemma 4 execution on Android, Google's AICore runtime is a viable path. This section covers the concrete implementation for invoking fine-tuned models from agents, plus the hybrid strategy with cloud APIs.
Core AICore Implementation
Step 1: Check Model Availability
// AICoreManager.kt
import com.google.android.aicore.AICore
import com.google.android.aicore.GenerativeModel
import com.google.android.aicore.AvailabilityStatus
class AICoreManager(private val context: Context) {
/**
* Check if Gemma 4 is available on this device
* @return Calls onAvailable with model, or onUnavailable with reason
*/
fun checkAvailability(
onAvailable: (GenerativeModel) -> Unit,
onUnavailable: (String) -> Unit
) {
AICore.checkAvailability(
context = context,
modelName = "gemma-4-e2b-it" // Edge 2B Instruction Tuned
).addOnSuccessListener { status ->
when (status) {
AvailabilityStatus.AVAILABLE -> {
// Model already downloaded
initModel(onAvailable, onUnavailable)
}
AvailabilityStatus.DOWNLOADABLE -> {
// Download needed
downloadAndInit(onAvailable, onUnavailable)
}
AvailabilityStatus.NOT_SUPPORTED -> {
onUnavailable("This device doesn't support AICore")
}
}
}.addOnFailureListener { e ->
onUnavailable("Availability check failed: ${e.message}")
}
}
private fun initModel(
onAvailable: (GenerativeModel) -> Unit,
onUnavailable: (String) -> Unit
) {
AICore.getGenerativeModel(
modelName = "gemma-4-e2b-it",
context = context
).addOnSuccessListener { model ->
onAvailable(model)
}.addOnFailureListener { e ->
onUnavailable("Model initialization failed: ${e.message}")
}
}
}Step 2: On-Device AI Agent Implementation
// OnDeviceAIAgent.kt
import com.google.android.aicore.GenerativeModel
import com.google.android.aicore.Content
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
class OnDeviceAIAgent(private val model: GenerativeModel) {
private val conversationHistory = mutableListOf<Content>()
// System prompt defining agent behavior
private val systemPrompt = """
You are a helpful assistant.
Answer user questions concisely.
Do not handle sensitive personal information.
""".trimIndent()
/**
* Generate text (suspend function, coroutine-compatible)
*/
suspend fun generate(userMessage: String): String =
suspendCancellableCoroutine { continuation ->
// Add user message to history
conversationHistory.add(
Content.Builder()
.setRole("user")
.addText(userMessage)
.build()
)
// Execute on-device inference with AICore
model.generateContent(conversationHistory)
.addOnSuccessListener { response ->
val text = response.text ?: ""
// Add AI response to history
conversationHistory.add(
Content.Builder()
.setRole("model")
.addText(text)
.build()
)
continuation.resume(text)
}
.addOnFailureListener { e ->
continuation.resumeWithException(e)
}
}
/**
* Streaming generation (real-time response)
*/
fun generateStream(userMessage: String): Flow<String> = flow {
model.generateContentStream(
listOf(
Content.Builder()
.setRole("user")
.addText(userMessage)
.build()
)
).forEach { chunk ->
chunk.text?.let { emit(it) }
}
}
fun clearHistory() {
conversationHistory.clear()
}
}Step 3: ViewModel Integration
// AIAgentViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class AIAgentViewModel : ViewModel() {
private var agent: OnDeviceAIAgent? = null
private val _uiState = MutableStateFlow<AgentUiState>(AgentUiState.Initializing)
val uiState: StateFlow<AgentUiState> = _uiState
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages
fun initialize(context: Context) {
AICoreManager(context).checkAvailability(
onAvailable = { model ->
agent = OnDeviceAIAgent(model)
_uiState.value = AgentUiState.Ready
},
onUnavailable = { reason ->
// On-device unavailable → fall back to cloud API
_uiState.value = AgentUiState.FallingBackToCloud(reason)
}
)
}
fun sendMessage(text: String) {
viewModelScope.launch {
_messages.value += ChatMessage(role = "user", text = text)
_uiState.value = AgentUiState.Generating
try {
// Execute on-device inference
val response = agent?.generate(text) ?: "Error: Agent not initialized"
_messages.value += ChatMessage(role = "assistant", text = response)
_uiState.value = AgentUiState.Ready
} catch (e: Exception) {
_uiState.value = AgentUiState.Error(e.message ?: "Unknown error")
}
}
}
}
sealed class AgentUiState {
object Initializing : AgentUiState()
object Ready : AgentUiState()
object Generating : AgentUiState()
data class FallingBackToCloud(val reason: String) : AgentUiState()
data class Error(val message: String) : AgentUiState()
}
data class ChatMessage(val role: String, val text: String)Hybrid Cloud + On-Device Strategy
Not all devices support AICore, so a fallback strategy is essential:
// HybridAIStrategy.kt
class HybridAIStrategy(
private val onDeviceAgent: OnDeviceAIAgent?,
private val cloudApi: GeminiApiClient
) {
suspend fun generate(prompt: String): String {
return if (onDeviceAgent != null) {
// On-device first (fast, free, private)
try {
onDeviceAgent.generate(prompt)
} catch (e: Exception) {
// Fall back to cloud on failure
cloudApi.generate(prompt)
}
} else {
// Device unsupported: use cloud API
cloudApi.generate(prompt)
}
}
}Also see Android Studio + Antigravity Development Guide for setting up the full Android development environment.
Looking back
Gemma 4's on-device AI integration has reached a stage where a consistent workflow from prototype to production is fully achievable. By combining the hybrid architecture design, LoRA fine-tuning for custom models, Antigravity agent integration, and CI/CD pipeline automation covered in this article, you can build competitive products centered on edge AI.
Start with the area that will have the most impact on your project. For deeper exploration of edge device AI experiences, see Antigravity × Core ML: Complete Guide to iOS On-Device AI Development. If you want to combine this with AgentKit 2.0's multi-agent capabilities, Antigravity AgentKit 2.0 Multi-Agent Development Practical Guide is an excellent companion resource.