Add Pixel10 AI Server — expose on-device AI chip as REST API

Android app that turns a Pixel 10 into a free AI API server by leveraging
the Tensor G5's on-device AI capabilities through an embedded HTTP server.

Key components:
- Dual AI backend: Gemini Nano (ML Kit Prompt API) + MediaPipe LLM fallback
- OpenAI-compatible REST API (chat/completions, completions, models, health)
- NanoHTTPD embedded server with CORS support and SSE streaming
- Foreground service with wake lock for persistent background operation
- Material Design 3 dashboard with live request logging

All inference runs entirely on-device — zero cloud costs, full offline capability.

https://claude.ai/code/session_01GvqMLSMmfMR8uz66BFVXX2
This commit is contained in:
Claude
2026-02-28 14:09:41 +00:00
commit 12de200450
24 changed files with 1683 additions and 0 deletions

65
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,65 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.pixel10.ai"
compileSdk = 35
defaultConfig {
applicationId = "com.pixel10.ai"
minSdk = 31
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
viewBinding = true
}
}
dependencies {
// AndroidX
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.activity:activity-ktx:1.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.constraintlayout:constraintlayout:2.2.0")
// ML Kit GenAI — Gemini Nano via AICore (recommended for Pixel 10)
implementation("com.google.mlkit:genai-prompt:1.0.0-beta1")
// MediaPipe LLM Inference — for custom models (Gemma, etc.)
implementation("com.google.mediapipe:tasks-genai:0.10.24")
// Embedded HTTP server
implementation("org.nanohttpd:nanohttpd:2.3.1")
// JSON
implementation("com.google.code.gson:gson:2.11.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
}

10
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,10 @@
# Pixel10 AI API Server - ProGuard Rules
# Keep NanoHTTPD server
-keep class fi.iki.elonen.** { *; }
# Keep MediaPipe classes
-keep class com.google.mediapipe.** { *; }
# Keep Gson serialization models
-keep class com.pixel10.ai.server.** { *; }

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Network permissions for serving API -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- Foreground service to keep server alive -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Wake lock to prevent CPU sleep while serving -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Require device AI features -->
<uses-feature android:name="android.hardware.npu" android:required="false" />
<application
android:name=".Pixel10AIApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.Pixel10AI">
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".server.ApiServerService"
android:foregroundServiceType="specialUse"
android:exported="false">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="on_device_ai_inference_server" />
</service>
<!-- Opt-in to on-device Gemini Nano model download -->
<meta-data
android:name="com.google.android.aicore.BUNDLED_MODEL"
android:value="gemini_nano" />
</application>
</manifest>

View File

@@ -0,0 +1,29 @@
package com.pixel10.ai
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
class Pixel10AIApp : Application() {
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.notification_channel_desc)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
companion object {
const val CHANNEL_ID = "pixel10_ai_server"
}
}

View File

@@ -0,0 +1,125 @@
package com.pixel10.ai.inference
import android.content.Context
import android.util.Log
import com.google.mlkit.genai.prompt.GenerativeModel
import com.google.mlkit.genai.prompt.type.Content
import com.google.mlkit.genai.prompt.type.TextPart
import com.google.mlkit.genai.prompt.type.content
import com.google.mlkit.genai.prompt.type.generationConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.fold
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* Gemini Nano backend via ML Kit Prompt API.
*
* This runs the system-provided Gemini Nano model on the Pixel 10's Tensor G5
* TPU through Android's AICore service. The model is managed by the OS —
* no manual download or file management needed.
*
* Key advantages:
* - Hardware-accelerated on Tensor G5 TPU (2.6x faster than G4)
* - 32,000 token context window on Pixel 10
* - ~3 GB model always resident in RAM for instant inference
* - Fully offline, private — data never leaves the device
*/
class GeminiNanoModel private constructor(
private val generativeModel: GenerativeModel
) : OnDeviceModel {
override val backendName = "Gemini Nano (ML Kit)"
@Volatile
override var isReady: Boolean = true
private set
override suspend fun generate(
prompt: String,
maxTokens: Int,
temperature: Float
): String = withContext(Dispatchers.Default) {
try {
val request = content { text(prompt) }
val response = generativeModel.generateContent(request)
response.text ?: ""
} catch (e: Exception) {
Log.e(TAG, "Gemini Nano generation error", e)
throw OnDeviceModel.InferenceException("Gemini Nano generation failed: ${e.message}", e)
}
}
override suspend fun generateStreaming(
prompt: String,
onToken: (String) -> Unit
): String = withContext(Dispatchers.Default) {
try {
val request = content { text(prompt) }
generativeModel.generateContentStream(request)
.fold("") { acc, response ->
val chunk = response.text ?: ""
if (chunk.isNotEmpty()) onToken(chunk)
acc + chunk
}
} catch (e: Exception) {
Log.e(TAG, "Gemini Nano streaming error", e)
throw OnDeviceModel.InferenceException("Streaming failed: ${e.message}", e)
}
}
override fun close() {
isReady = false
generativeModel.close()
}
companion object {
private const val TAG = "GeminiNanoModel"
suspend fun create(context: Context): GeminiNanoModel = withContext(Dispatchers.IO) {
// Check if Gemini Nano is available on this device
val model = GenerativeModel.newBuilder()
.setContext(context)
.build()
// Verify feature is available — will throw if not supported
suspendCancellableCoroutine { continuation ->
model.isAvailable()
.addOnSuccessListener { available ->
if (available) {
continuation.resume(Unit)
} else {
continuation.resumeWithException(
OnDeviceModel.InferenceException(
"Gemini Nano is not available on this device"
)
)
}
}
.addOnFailureListener { e ->
continuation.resumeWithException(
OnDeviceModel.InferenceException(
"Failed to check Gemini Nano availability: ${e.message}", e
)
)
}
}
// Trigger model download if needed
suspendCancellableCoroutine { continuation ->
model.downloadModel()
.addOnSuccessListener { continuation.resume(Unit) }
.addOnFailureListener { e ->
Log.w(TAG, "Model download issue (may already be available): ${e.message}")
// Don't fail — model might already be cached
continuation.resume(Unit)
}
}
Log.i(TAG, "Gemini Nano model ready via ML Kit Prompt API")
GeminiNanoModel(model)
}
}
}

View File

@@ -0,0 +1,149 @@
package com.pixel10.ai.inference
import android.content.Context
import android.util.Log
import com.google.mediapipe.tasks.genai.llminference.LlmInference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* MediaPipe LLM Inference backend for custom open-weight models.
*
* Use this to run models like Gemma 3n E2B, Gemma 2B, or other compatible
* LLMs that you download and place on the device yourself.
*
* On Pixel 10, MediaPipe automatically leverages the Tensor G5 GPU/NPU
* for accelerated inference.
*
* To use:
* 1. Download a compatible model (e.g. gemma-3n-E2B.task)
* 2. Push to device: adb push model.task /data/local/tmp/llm/
* or copy to app files dir via the app
*/
class MediaPipeModel private constructor(
private val llmInference: LlmInference,
private val modelName: String
) : OnDeviceModel {
override val backendName = "MediaPipe ($modelName)"
@Volatile
override var isReady: Boolean = true
private set
override suspend fun generate(
prompt: String,
maxTokens: Int,
temperature: Float
): String = withContext(Dispatchers.Default) {
try {
llmInference.generateResponse(prompt)
} catch (e: Exception) {
Log.e(TAG, "MediaPipe inference error", e)
throw OnDeviceModel.InferenceException("Generation failed: ${e.message}", e)
}
}
override suspend fun generateStreaming(
prompt: String,
onToken: (String) -> Unit
): String = withContext(Dispatchers.Default) {
suspendCancellableCoroutine { continuation ->
val fullResponse = StringBuilder()
try {
llmInference.generateResponseAsync(prompt).addResultListener { partialResult, done ->
val chunk = partialResult ?: ""
fullResponse.append(chunk)
onToken(chunk)
if (done) {
continuation.resume(fullResponse.toString())
}
}
} catch (e: Exception) {
Log.e(TAG, "Streaming error", e)
continuation.resumeWithException(
OnDeviceModel.InferenceException("Streaming failed: ${e.message}", e)
)
}
}
}
override fun close() {
isReady = false
llmInference.close()
}
companion object {
private const val TAG = "MediaPipeModel"
private val MODEL_FILENAMES = listOf(
"gemma-3n-E2B.task",
"gemma-3n-E4B.task",
"gemma-2b-it-gpu-int4.bin",
"gemini-nano.bin",
"model.bin"
)
suspend fun create(context: Context): MediaPipeModel = withContext(Dispatchers.IO) {
val modelPath = findModelPath(context)
?: throw OnDeviceModel.InferenceException(
"No MediaPipe model file found.\n" +
"Place a compatible .bin or .task file in:\n" +
" ${context.filesDir.absolutePath}/\n" +
"Supported: ${MODEL_FILENAMES.joinToString()}"
)
val modelName = File(modelPath).name
Log.i(TAG, "Loading MediaPipe model: $modelPath")
try {
val options = LlmInference.LlmInferenceOptions.builder()
.setModelPath(modelPath)
.setMaxTokens(2048)
.setTopK(40)
.setTemperature(0.7f)
.setRandomSeed(42)
.build()
val inference = LlmInference.createFromOptions(context, options)
Log.i(TAG, "MediaPipe model loaded: $modelName")
MediaPipeModel(inference, modelName)
} catch (e: Exception) {
throw OnDeviceModel.InferenceException(
"Failed to load MediaPipe model from $modelPath: ${e.message}", e
)
}
}
private fun findModelPath(context: Context): String? {
// Search standard locations
val searchDirs = listOfNotNull(
context.filesDir,
File(context.filesDir, "models"),
context.getExternalFilesDir(null),
File("/data/local/tmp/llm")
)
for (dir in searchDirs) {
if (!dir.exists()) continue
for (name in MODEL_FILENAMES) {
val file = File(dir, name)
if (file.exists()) {
Log.i(TAG, "Found model: ${file.absolutePath}")
return file.absolutePath
}
}
// Also check for any .task or .bin file
dir.listFiles()?.firstOrNull {
it.extension in listOf("task", "bin", "tflite")
}?.let { return it.absolutePath }
}
return null
}
}
}

View File

@@ -0,0 +1,82 @@
package com.pixel10.ai.inference
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Unified interface for on-device AI inference on the Pixel 10.
*
* Supports two backends:
* 1. **Gemini Nano** via ML Kit Prompt API — uses the system-managed model
* through AICore, accelerated by the Tensor G5 TPU. Zero setup needed
* on supported Pixel devices.
* 2. **MediaPipe LLM** — for custom open-weight models (Gemma 2B/3n, etc.)
* that you supply yourself. Place the .bin/.task file in the app's
* files directory.
*
* The factory method tries Gemini Nano first (preferred), then falls back
* to MediaPipe if a local model file is found.
*/
interface OnDeviceModel {
val backendName: String
val isReady: Boolean
suspend fun generate(
prompt: String,
maxTokens: Int = 1024,
temperature: Float = 0.7f
): String
suspend fun generateStreaming(
prompt: String,
onToken: (String) -> Unit
): String
fun close()
companion object {
private const val TAG = "OnDeviceModel"
/**
* Create the best available on-device model.
* Tries Gemini Nano (AICore) first, falls back to MediaPipe.
*/
suspend fun create(context: Context): OnDeviceModel = withContext(Dispatchers.IO) {
// Try Gemini Nano via ML Kit Prompt API first
try {
Log.i(TAG, "Attempting Gemini Nano via ML Kit Prompt API...")
val nano = GeminiNanoModel.create(context)
Log.i(TAG, "Gemini Nano ready!")
return@withContext nano
} catch (e: Exception) {
Log.w(TAG, "Gemini Nano not available: ${e.message}")
}
// Fall back to MediaPipe with a local model file
try {
Log.i(TAG, "Attempting MediaPipe LLM with local model...")
val mediapipe = MediaPipeModel.create(context)
Log.i(TAG, "MediaPipe model ready!")
return@withContext mediapipe
} catch (e: Exception) {
Log.w(TAG, "MediaPipe model not available: ${e.message}")
}
throw InferenceException(
"No AI model available.\n\n" +
"Option 1: Use a Pixel device with Gemini Nano support " +
"(Pixel 10/9/8 series)\n\n" +
"Option 2: Place a MediaPipe-compatible model (.bin or .task) in:\n" +
" ${context.filesDir.absolutePath}/\n" +
" Supported: gemma-3n-E2B.task, gemma-2b-it-gpu-int4.bin, etc.\n\n" +
"Download models from:\n" +
" https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android"
)
}
}
class InferenceException(message: String, cause: Throwable? = null) :
Exception(message, cause)
}

View File

@@ -0,0 +1,254 @@
package com.pixel10.ai.server
import android.os.Build
import android.util.Log
import com.google.gson.Gson
import com.pixel10.ai.inference.OnDeviceModel
import fi.iki.elonen.NanoHTTPD
import kotlinx.coroutines.runBlocking
import java.util.UUID
import java.util.concurrent.atomic.AtomicLong
/**
* Embedded HTTP server that exposes the on-device AI model as a REST API.
*
* Provides OpenAI-compatible endpoints so existing tools (curl, Python openai
* library, etc.) can talk to this phone as if it were a cloud AI endpoint.
*
* Usage from any device on the same network:
* curl http://<phone-ip>:8080/v1/chat/completions \
* -H "Content-Type: application/json" \
* -d '{"messages":[{"role":"user","content":"Hello!"}]}'
*/
class AIApiServer(
port: Int,
private val model: OnDeviceModel
) : NanoHTTPD(port) {
private val gson = Gson()
private val startTime = System.currentTimeMillis()
val requestCount = AtomicLong(0)
var onRequestLogged: ((String) -> Unit)? = null
override fun serve(session: IHTTPSession): Response {
val method = session.method
val uri = session.uri
val count = requestCount.incrementAndGet()
log("[$count] ${method.name} $uri")
return try {
// Add CORS headers to all responses
when {
method == Method.OPTIONS -> corsPreflightResponse()
uri == "/" || uri == "/health" -> handleHealth()
uri == "/v1/models" && method == Method.GET -> handleModels()
uri == "/v1/chat/completions" && method == Method.POST -> handleChatCompletions(session)
uri == "/v1/completions" && method == Method.POST -> handleCompletions(session)
else -> errorResponse(404, "Not found: $uri")
}.also { addCorsHeaders(it) }
} catch (e: Exception) {
Log.e(TAG, "Request error", e)
log("ERROR: ${e.message}")
errorResponse(500, "Internal server error: ${e.message}")
.also { addCorsHeaders(it) }
}
}
// ── Endpoint Handlers ──────────────────────────────────────────────
private fun handleHealth(): Response {
val status = ServerStatus(
status = if (model.isReady) "ready" else "model_not_loaded",
model = model.backendName,
device = "${Build.MANUFACTURER} ${Build.MODEL} (${Build.SOC_MODEL})",
uptime_seconds = (System.currentTimeMillis() - startTime) / 1000,
requests_served = requestCount.get()
)
return jsonResponse(200, gson.toJson(status))
}
private fun handleModels(): Response {
return jsonResponse(200, gson.toJson(ModelList()))
}
private fun handleChatCompletions(session: IHTTPSession): Response {
val body = readBody(session)
val request = gson.fromJson(body, ChatRequest::class.java)
if (request.messages.isEmpty()) {
return errorResponse(400, "messages array is required and must not be empty")
}
// Build a prompt from the chat messages
val prompt = buildChatPrompt(request.messages)
log("Chat prompt (${request.messages.size} messages, ${prompt.length} chars)")
if (request.stream) {
return handleStreamingResponse(prompt, request)
}
// Synchronous generation
val responseText = runBlocking {
model.generate(prompt, request.max_tokens, request.temperature)
}
log("Response: ${responseText.take(80)}...")
val chatResponse = ChatResponse(
id = "chatcmpl-${UUID.randomUUID().toString().take(8)}",
choices = listOf(
Choice(
message = Message(role = "assistant", content = responseText)
)
),
usage = Usage(
prompt_tokens = estimateTokens(prompt),
completion_tokens = estimateTokens(responseText),
total_tokens = estimateTokens(prompt) + estimateTokens(responseText)
)
)
return jsonResponse(200, gson.toJson(chatResponse))
}
private fun handleCompletions(session: IHTTPSession): Response {
val body = readBody(session)
val request = gson.fromJson(body, ChatRequest::class.java)
val prompt = request.prompt
?: request.messages.lastOrNull()?.content
?: return errorResponse(400, "prompt or messages is required")
log("Completion prompt (${prompt.length} chars)")
val responseText = runBlocking {
model.generate(prompt, request.max_tokens, request.temperature)
}
log("Response: ${responseText.take(80)}...")
val chatResponse = ChatResponse(
id = "cmpl-${UUID.randomUUID().toString().take(8)}",
choices = listOf(
Choice(
message = Message(role = "assistant", content = responseText)
)
),
usage = Usage(
prompt_tokens = estimateTokens(prompt),
completion_tokens = estimateTokens(responseText),
total_tokens = estimateTokens(prompt) + estimateTokens(responseText)
)
)
return jsonResponse(200, gson.toJson(chatResponse))
}
private fun handleStreamingResponse(prompt: String, request: ChatRequest): Response {
val id = "chatcmpl-${UUID.randomUUID().toString().take(8)}"
// For streaming, collect all tokens then return as SSE-formatted response.
// NanoHTTPD doesn't natively support chunked streaming in a clean way,
// so we buffer and return the full SSE payload.
val sseBuilder = StringBuilder()
// Initial role chunk
val roleChunk = StreamChunk(
id = id,
choices = listOf(StreamChoice(delta = Delta(role = "assistant")))
)
sseBuilder.append("data: ${gson.toJson(roleChunk)}\n\n")
val fullResponse = runBlocking {
model.generateStreaming(prompt) { token ->
val chunk = StreamChunk(
id = id,
choices = listOf(StreamChoice(delta = Delta(content = token)))
)
sseBuilder.append("data: ${gson.toJson(chunk)}\n\n")
}
}
// Final done chunk
val doneChunk = StreamChunk(
id = id,
choices = listOf(StreamChoice(delta = Delta(), finish_reason = "stop"))
)
sseBuilder.append("data: ${gson.toJson(doneChunk)}\n\n")
sseBuilder.append("data: [DONE]\n\n")
log("Streamed response: ${fullResponse.take(80)}...")
return newFixedLengthResponse(
Response.Status.OK,
"text/event-stream",
sseBuilder.toString()
)
}
// ── Helpers ─────────────────────────────────────────────────────────
private fun buildChatPrompt(messages: List<Message>): String {
val sb = StringBuilder()
for (msg in messages) {
when (msg.role) {
"system" -> sb.append("System: ${msg.content}\n\n")
"user" -> sb.append("User: ${msg.content}\n\n")
"assistant" -> sb.append("Assistant: ${msg.content}\n\n")
}
}
sb.append("Assistant: ")
return sb.toString()
}
private fun readBody(session: IHTTPSession): String {
val contentLength = session.headers["content-length"]?.toIntOrNull() ?: 0
val buffer = ByteArray(contentLength)
session.inputStream.read(buffer, 0, contentLength)
return String(buffer)
}
private fun estimateTokens(text: String): Int {
// Rough estimate: ~4 characters per token
return (text.length / 4).coerceAtLeast(1)
}
private fun jsonResponse(statusCode: Int, json: String): Response {
val status = when (statusCode) {
200 -> Response.Status.OK
400 -> Response.Status.BAD_REQUEST
404 -> Response.Status.NOT_FOUND
else -> Response.Status.INTERNAL_ERROR
}
return newFixedLengthResponse(status, "application/json", json)
}
private fun errorResponse(statusCode: Int, message: String): Response {
val error = ErrorResponse(
ErrorDetail(message = message, code = statusCode)
)
return jsonResponse(statusCode, gson.toJson(error))
}
private fun corsPreflightResponse(): Response {
return newFixedLengthResponse(Response.Status.OK, MIME_PLAINTEXT, "")
}
private fun addCorsHeaders(response: Response) {
response.addHeader("Access-Control-Allow-Origin", "*")
response.addHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
response.addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization")
}
private fun log(message: String) {
Log.d(TAG, message)
onRequestLogged?.invoke(message)
}
companion object {
private const val TAG = "AIApiServer"
}
}

View File

@@ -0,0 +1,91 @@
package com.pixel10.ai.server
/**
* Request/response models for the AI API.
* Follows an OpenAI-compatible schema for easy integration.
*/
data class ChatRequest(
val messages: List<Message> = emptyList(),
val prompt: String? = null,
val max_tokens: Int = 1024,
val temperature: Float = 0.7f,
val stream: Boolean = false
)
data class Message(
val role: String = "user",
val content: String = ""
)
data class ChatResponse(
val id: String,
val model: String = "pixel10-on-device",
val choices: List<Choice>,
val usage: Usage
)
data class Choice(
val index: Int = 0,
val message: Message,
val finish_reason: String = "stop"
)
data class Usage(
val prompt_tokens: Int,
val completion_tokens: Int,
val total_tokens: Int
)
data class StreamChunk(
val id: String,
val model: String = "pixel10-on-device",
val choices: List<StreamChoice>
)
data class StreamChoice(
val index: Int = 0,
val delta: Delta,
val finish_reason: String? = null
)
data class Delta(
val role: String? = null,
val content: String? = null
)
data class ModelInfo(
val id: String = "pixel10-on-device",
val object_type: String = "model",
val owned_by: String = "local-device",
val description: String = "On-device AI model running on Pixel 10 Tensor G5 chip"
)
data class ModelList(
val data: List<ModelInfo> = listOf(ModelInfo())
)
data class ErrorResponse(
val error: ErrorDetail
)
data class ErrorDetail(
val message: String,
val type: String = "server_error",
val code: Int = 500
)
data class ServerStatus(
val status: String,
val model: String,
val device: String,
val uptime_seconds: Long,
val requests_served: Long,
val endpoints: List<String> = listOf(
"POST /v1/chat/completions",
"POST /v1/completions",
"GET /v1/models",
"GET /health",
"GET /"
)
)

View File

@@ -0,0 +1,165 @@
package com.pixel10.ai.server
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import com.pixel10.ai.Pixel10AIApp
import com.pixel10.ai.R
import com.pixel10.ai.inference.OnDeviceModel
import com.pixel10.ai.ui.MainActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
/**
* Foreground service that keeps the AI API server running even when the
* app is in the background. Shows a persistent notification with server status.
*/
class ApiServerService : Service() {
private val binder = LocalBinder()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var server: AIApiServer? = null
private var model: OnDeviceModel? = null
private var wakeLock: PowerManager.WakeLock? = null
var onStatusChanged: ((ServerState) -> Unit)? = null
var onLog: ((String) -> Unit)? = null
val isRunning: Boolean get() = server != null
val requestCount: Long get() = server?.requestCount?.get() ?: 0
inner class LocalBinder : Binder() {
val service: ApiServerService get() = this@ApiServerService
}
override fun onBind(intent: Intent?): IBinder = binder
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
val port = intent.getIntExtra(EXTRA_PORT, DEFAULT_PORT)
startServer(port)
}
ACTION_STOP -> stopServer()
}
return START_STICKY
}
private fun startServer(port: Int) {
if (server != null) return
startForeground(NOTIFICATION_ID, buildNotification(port))
acquireWakeLock()
notifyStatus(ServerState.LOADING_MODEL)
scope.launch {
try {
// Load the on-device AI model
notifyLog("Loading AI model...")
model = OnDeviceModel.create(applicationContext)
notifyLog("Model ready: ${model!!.backendName}")
// Start the HTTP server
notifyLog("Starting API server on port $port...")
val apiServer = AIApiServer(port, model!!)
apiServer.onRequestLogged = { msg -> notifyLog(msg) }
apiServer.start()
server = apiServer
notifyStatus(ServerState.RUNNING)
notifyLog("Server running on port $port")
notifyLog("Endpoints:")
notifyLog(" POST /v1/chat/completions")
notifyLog(" POST /v1/completions")
notifyLog(" GET /v1/models")
notifyLog(" GET /health")
} catch (e: Exception) {
Log.e(TAG, "Failed to start server", e)
notifyLog("ERROR: ${e.message}")
notifyStatus(ServerState.ERROR)
stopServer()
}
}
}
fun stopServer() {
server?.stop()
server = null
model?.close()
model = null
releaseWakeLock()
notifyStatus(ServerState.STOPPED)
notifyLog("Server stopped")
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun acquireWakeLock() {
val pm = getSystemService(POWER_SERVICE) as PowerManager
wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"Pixel10AI::ServerWakeLock"
).apply { acquire(4 * 60 * 60 * 1000L) } // 4 hours max
}
private fun releaseWakeLock() {
wakeLock?.let {
if (it.isHeld) it.release()
}
wakeLock = null
}
private fun buildNotification(port: Int): Notification {
val pendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, Pixel10AIApp.CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title))
.setContentText(getString(R.string.notification_text, port))
.setSmallIcon(android.R.drawable.ic_menu_share)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
}
private fun notifyStatus(state: ServerState) {
onStatusChanged?.invoke(state)
}
private fun notifyLog(message: String) {
Log.d(TAG, message)
onLog?.invoke(message)
}
override fun onDestroy() {
stopServer()
scope.cancel()
super.onDestroy()
}
enum class ServerState {
STOPPED, LOADING_MODEL, RUNNING, ERROR
}
companion object {
private const val TAG = "ApiServerService"
const val ACTION_START = "com.pixel10.ai.START_SERVER"
const val ACTION_STOP = "com.pixel10.ai.STOP_SERVER"
const val EXTRA_PORT = "port"
const val DEFAULT_PORT = 8080
private const val NOTIFICATION_ID = 1
}
}

View File

@@ -0,0 +1,221 @@
package com.pixel10.ai.ui
import android.Manifest
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.net.wifi.WifiManager
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.pixel10.ai.R
import com.pixel10.ai.databinding.ActivityMainBinding
import com.pixel10.ai.server.ApiServerService
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private var service: ApiServerService? = null
private var bound = false
private val logBuffer = StringBuilder()
private val notificationPermission = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { /* proceed regardless */ }
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
val localBinder = binder as ApiServerService.LocalBinder
service = localBinder.service
bound = true
service?.onStatusChanged = { state ->
runOnUiThread { updateStatus(state) }
}
service?.onLog = { message ->
runOnUiThread { appendLog(message) }
}
if (service?.isRunning == true) {
updateStatus(ApiServerService.ServerState.RUNNING)
}
}
override fun onServiceDisconnected(name: ComponentName?) {
service = null
bound = false
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
requestNotificationPermission()
binding.btnToggle.setOnClickListener {
if (service?.isRunning == true) {
stopServer()
} else {
startServer()
}
}
updateStatus(ApiServerService.ServerState.STOPPED)
appendLog("Pixel10 AI Server ready")
appendLog("Device: ${Build.MANUFACTURER} ${Build.MODEL}")
appendLog("SoC: ${Build.SOC_MODEL}")
appendLog("Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
appendLog("")
appendLog("Tap 'Start Server' to begin serving AI inference")
}
override fun onStart() {
super.onStart()
Intent(this, ApiServerService::class.java).also { intent ->
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
}
}
override fun onStop() {
super.onStop()
if (bound) {
service?.onStatusChanged = null
service?.onLog = null
unbindService(serviceConnection)
bound = false
}
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
this, Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
private fun startServer() {
val port = binding.etPort.text.toString().toIntOrNull() ?: 8080
val intent = Intent(this, ApiServerService::class.java).apply {
action = ApiServerService.ACTION_START
putExtra(ApiServerService.EXTRA_PORT, port)
}
startForegroundService(intent)
// Bind if not already bound
if (!bound) {
bindService(
Intent(this, ApiServerService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
)
}
updateStatus(ApiServerService.ServerState.LOADING_MODEL)
}
private fun stopServer() {
service?.stopServer()
updateStatus(ApiServerService.ServerState.STOPPED)
}
private fun updateStatus(state: ApiServerService.ServerState) {
when (state) {
ApiServerService.ServerState.STOPPED -> {
binding.tvServerStatus.text = getString(R.string.server_status_stopped)
binding.viewStatusDot.setBackgroundColor(getColor(R.color.status_stopped))
binding.tvServerUrl.text = "http://—"
binding.tvModelStatus.text = "Model: not loaded"
binding.btnToggle.text = getString(R.string.btn_start)
binding.btnToggle.isEnabled = true
binding.etPort.isEnabled = true
}
ApiServerService.ServerState.LOADING_MODEL -> {
binding.tvServerStatus.text = getString(R.string.server_status_starting)
binding.viewStatusDot.setBackgroundColor(getColor(R.color.primary))
binding.tvModelStatus.text = getString(R.string.model_loading)
binding.btnToggle.isEnabled = false
binding.etPort.isEnabled = false
}
ApiServerService.ServerState.RUNNING -> {
val port = binding.etPort.text.toString()
val ip = getLocalIpAddress()
binding.tvServerStatus.text = getString(R.string.server_status_running)
binding.viewStatusDot.setBackgroundColor(getColor(R.color.status_running))
binding.tvServerUrl.text = "http://$ip:$port"
binding.tvModelStatus.text = getString(R.string.model_ready)
binding.btnToggle.text = getString(R.string.btn_stop)
binding.btnToggle.isEnabled = true
binding.etPort.isEnabled = false
}
ApiServerService.ServerState.ERROR -> {
binding.tvServerStatus.text = getString(R.string.server_status_error)
binding.viewStatusDot.setBackgroundColor(getColor(R.color.error))
binding.tvModelStatus.text = getString(R.string.model_error)
binding.btnToggle.text = getString(R.string.btn_start)
binding.btnToggle.isEnabled = true
binding.etPort.isEnabled = true
}
}
}
private fun appendLog(message: String) {
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.US).format(Date())
logBuffer.append("[$timestamp] $message\n")
binding.tvLog.text = logBuffer.toString()
// Auto-scroll to bottom
binding.scrollLog.post {
binding.scrollLog.fullScroll(View.FOCUS_DOWN)
}
// Update request count
service?.let {
binding.tvRequestCount.text = "Requests served: ${it.requestCount}"
}
}
@Suppress("DEPRECATION")
private fun getLocalIpAddress(): String {
try {
val wifiManager = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager
val ip = wifiManager.connectionInfo.ipAddress
if (ip != 0) {
return "${ip and 0xFF}.${ip shr 8 and 0xFF}.${ip shr 16 and 0xFF}.${ip shr 24 and 0xFF}"
}
} catch (_: Exception) {}
// Fallback: iterate network interfaces
try {
val interfaces = java.net.NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) {
val iface = interfaces.nextElement()
val addresses = iface.inetAddresses
while (addresses.hasMoreElements()) {
val addr = addresses.nextElement()
if (!addr.isLoopbackAddress && addr is java.net.Inet4Address) {
return addr.hostAddress ?: "0.0.0.0"
}
}
}
} catch (_: Exception) {}
return "0.0.0.0"
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/status_stopped" />
<size android:width="12dp" android:height="12dp" />
</shape>

View File

@@ -0,0 +1,189 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/surface"
android:padding="24dp">
<!-- Title -->
<TextView
android:id="@+id/tvTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Pixel10 AI Server"
android:textColor="@color/on_surface"
android:textSize="28sp"
android:textStyle="bold"
android:layout_marginTop="16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/tvSubtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="On-device AI inference API"
android:textColor="@color/log_text"
android:textSize="14sp"
android:layout_marginTop="4dp"
app:layout_constraintTop_toBottomOf="@id/tvTitle"
app:layout_constraintStart_toStartOf="parent" />
<!-- Status Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardStatus"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
app:cardBackgroundColor="@color/surface_variant"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="0dp"
app:layout_constraintTop_toBottomOf="@id/tvSubtitle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<View
android:id="@+id/viewStatusDot"
android:layout_width="12dp"
android:layout_height="12dp"
android:background="@drawable/status_dot" />
<TextView
android:id="@+id/tvServerStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_status_stopped"
android:textColor="@color/on_surface"
android:textSize="18sp"
android:textStyle="bold"
android:layout_marginStart="12dp" />
</LinearLayout>
<TextView
android:id="@+id/tvServerUrl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="http://—"
android:textColor="@color/log_text"
android:textSize="14sp"
android:fontFamily="monospace"
android:layout_marginTop="8dp"
android:textIsSelectable="true" />
<TextView
android:id="@+id/tvModelStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Model: not loaded"
android:textColor="@color/log_text"
android:textSize="13sp"
android:layout_marginTop="4dp" />
<TextView
android:id="@+id/tvRequestCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Requests served: 0"
android:textColor="@color/log_text"
android:textSize="13sp"
android:layout_marginTop="2dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Port Config -->
<LinearLayout
android:id="@+id/layoutPort"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/cardStatus"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Port:"
android:textColor="@color/on_surface"
android:textSize="16sp" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPort"
android:layout_width="100dp"
android:layout_height="48dp"
android:layout_marginStart="12dp"
android:text="8080"
android:inputType="number"
android:textColor="@color/on_surface"
android:backgroundTint="@color/primary"
android:fontFamily="monospace"
android:textSize="16sp"
android:gravity="center" />
</LinearLayout>
<!-- Start/Stop Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnToggle"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_marginTop="16dp"
android:text="@string/btn_start"
android:textSize="16sp"
app:cornerRadius="12dp"
app:layout_constraintTop_toBottomOf="@id/layoutPort"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Log Output -->
<TextView
android:id="@+id/tvLogLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Request Log"
android:textColor="@color/on_surface"
android:textSize="14sp"
android:textStyle="bold"
android:layout_marginTop="20dp"
app:layout_constraintTop_toBottomOf="@id/btnToggle"
app:layout_constraintStart_toStartOf="parent" />
<ScrollView
android:id="@+id/scrollLog"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:background="@color/log_bg"
android:padding="12dp"
app:layout_constraintTop_toBottomOf="@id/tvLogLabel"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<TextView
android:id="@+id/tvLog"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/log_text"
android:textSize="12sp"
android:fontFamily="monospace"
android:textIsSelectable="true" />
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary" />
<foreground android:drawable="@color/on_primary" />
</adaptive-icon>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#1A73E8</color>
<color name="primary_container">#D2E3FC</color>
<color name="on_primary">#FFFFFF</color>
<color name="secondary">#34A853</color>
<color name="surface">#0F0F0F</color>
<color name="on_surface">#E8EAED</color>
<color name="surface_variant">#1A1A2E</color>
<color name="error">#EA4335</color>
<color name="status_running">#34A853</color>
<color name="status_stopped">#9AA0A6</color>
<color name="log_bg">#0D1117</color>
<color name="log_text">#8B949E</color>
</resources>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Pixel10 AI Server</string>
<string name="server_status_stopped">Server Stopped</string>
<string name="server_status_starting">Starting Server…</string>
<string name="server_status_running">Server Running</string>
<string name="server_status_error">Server Error</string>
<string name="btn_start">Start Server</string>
<string name="btn_stop">Stop Server</string>
<string name="model_loading">Loading AI model…</string>
<string name="model_ready">AI model ready</string>
<string name="model_error">AI model failed to load</string>
<string name="notification_channel_name">AI Server</string>
<string name="notification_channel_desc">Pixel10 AI API Server status</string>
<string name="notification_title">Pixel10 AI Server</string>
<string name="notification_text">Serving AI inference on port %d</string>
</resources>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Pixel10AI" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryContainer">@color/primary_container</item>
<item name="colorOnPrimary">@color/on_primary</item>
<item name="colorSecondary">@color/secondary</item>
<item name="android:statusBarColor">@color/surface</item>
<item name="android:navigationBarColor">@color/surface</item>
</style>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>