diff --git a/app/src/main/java/com/pixel10/ai/inference/ModelDownloader.kt b/app/src/main/java/com/pixel10/ai/inference/ModelDownloader.kt
new file mode 100644
index 0000000..59cf178
--- /dev/null
+++ b/app/src/main/java/com/pixel10/ai/inference/ModelDownloader.kt
@@ -0,0 +1,96 @@
+package com.pixel10.ai.inference
+
+import android.content.Context
+import android.util.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+import java.net.HttpURLConnection
+import java.net.URL
+
+/**
+ * Downloads a MediaPipe-compatible Gemma model for background-safe inference.
+ *
+ * Gemini Nano (ML Kit) blocks inference when the app is backgrounded (ErrorCode 30).
+ * MediaPipe with a local model file has no such restriction — it runs entirely in
+ * the app process using the Tensor G5 GPU via OpenCL/Vulkan.
+ *
+ * The downloaded model is stored in the app's private files directory and
+ * survives app restarts. Only needs to be downloaded once (~1.3 GB).
+ */
+object ModelDownloader {
+
+ private const val TAG = "ModelDownloader"
+
+ const val MODEL_FILENAME = "gemma-2b-it-gpu-int4.bin"
+ private const val MODEL_URL =
+ "https://storage.googleapis.com/mediapipe-models/llm_inference/" +
+ "gemma-2b-it-gpu-int4/float16/1/gemma-2b-it-gpu-int4.bin"
+
+ data class Progress(
+ val downloadedBytes: Long,
+ val totalBytes: Long,
+ val percent: Int = if (totalBytes > 0) (downloadedBytes * 100 / totalBytes).toInt() else 0
+ )
+
+ fun isModelPresent(context: Context): Boolean =
+ modelFile(context).let { it.exists() && it.length() > 1_000_000L }
+
+ fun modelFile(context: Context): File = File(context.filesDir, MODEL_FILENAME)
+
+ /**
+ * Download the model, reporting progress via [onProgress].
+ * Supports resume — if a partial file exists, continues from where it left off.
+ */
+ suspend fun download(
+ context: Context,
+ onProgress: (Progress) -> Unit
+ ) = withContext(Dispatchers.IO) {
+ val dest = modelFile(context)
+ val alreadyDownloaded = if (dest.exists()) dest.length() else 0L
+
+ Log.i(TAG, "Download starting (already have $alreadyDownloaded bytes)")
+
+ val conn = URL(MODEL_URL).openConnection() as HttpURLConnection
+ try {
+ conn.connectTimeout = 30_000
+ conn.readTimeout = 60_000
+ if (alreadyDownloaded > 0) {
+ conn.setRequestProperty("Range", "bytes=$alreadyDownloaded-")
+ }
+ conn.connect()
+
+ val code = conn.responseCode
+ val resuming = code == HttpURLConnection.HTTP_PARTIAL // 206
+ if (code != HttpURLConnection.HTTP_OK && !resuming) {
+ throw OnDeviceModel.InferenceException("Download failed: HTTP $code")
+ }
+
+ val serverBytes = conn.contentLengthLong.coerceAtLeast(0L)
+ val totalBytes = if (resuming) alreadyDownloaded + serverBytes else serverBytes
+
+ conn.inputStream.use { input ->
+ FileOutputStream(dest, /* append= */ resuming).use { out ->
+ val buf = ByteArray(128 * 1024)
+ var written = alreadyDownloaded
+ var read: Int
+ while (input.read(buf).also { read = it } != -1) {
+ out.write(buf, 0, read)
+ written += read
+ onProgress(Progress(written, totalBytes))
+ }
+ }
+ }
+
+ Log.i(TAG, "Download complete — ${dest.length()} bytes")
+ } finally {
+ conn.disconnect()
+ }
+ }
+
+ fun deleteModel(context: Context) {
+ modelFile(context).delete()
+ Log.i(TAG, "Model deleted")
+ }
+}
diff --git a/app/src/main/java/com/pixel10/ai/inference/OnDeviceModel.kt b/app/src/main/java/com/pixel10/ai/inference/OnDeviceModel.kt
index 2a34aaa..8c94e02 100644
--- a/app/src/main/java/com/pixel10/ai/inference/OnDeviceModel.kt
+++ b/app/src/main/java/com/pixel10/ai/inference/OnDeviceModel.kt
@@ -102,38 +102,43 @@ interface OnDeviceModel {
/**
* Create the best available on-device model.
- * Tries Gemini Nano (Tensor G5 TPU) first, falls back to MediaPipe.
+ *
+ * Priority order:
+ * 1. MediaPipe (local model file) — runs in background, uses Tensor G5 GPU.
+ * This is the preferred backend: no foreground restriction, no AICore dep.
+ * 2. Gemini Nano (ML Kit) — foreground only (ErrorCode 30 in background).
+ * Used as fallback when no MediaPipe model file is present.
+ *
+ * Tap "Download Model" in the app UI to get the MediaPipe model automatically.
*/
suspend fun create(context: Context): OnDeviceModel = withContext(Dispatchers.IO) {
- // Try Gemini Nano via ML Kit Prompt API
+ // MediaPipe first — background-safe, GPU-accelerated via Tensor G5
try {
- Log.i(TAG, "Attempting Gemini Nano via ML Kit Prompt API...")
+ Log.i(TAG, "Attempting MediaPipe LLM with local model...")
+ val mediapipe = MediaPipeModel.create(context)
+ Log.i(TAG, "MediaPipe model ready: ${mediapipe.backendName}")
+ return@withContext mediapipe
+ } catch (e: Exception) {
+ Log.w(TAG, "MediaPipe not available: ${e.message}")
+ }
+
+ // Gemini Nano fallback — only works when app is in foreground
+ try {
+ Log.i(TAG, "Attempting Gemini Nano via ML Kit (foreground only)...")
val nano = GeminiNanoModel.create(context)
- Log.i(TAG, "Gemini Nano ready!")
+ Log.i(TAG, "Gemini Nano ready (foreground only)")
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 on-device 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" +
+ "No model loaded yet.\n\n" +
+ "Tap 'Download Model' in the app to download Gemma 2B (~1.3 GB).\n" +
+ "Once downloaded the server works fully in the background.\n\n" +
+ "Or place a compatible model file 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"
+ " Supported: gemma-2b-it-gpu-int4.bin, gemma-3n-E2B.task, etc."
)
}
}
diff --git a/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt b/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt
index b6b0305..ab5e782 100644
--- a/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt
+++ b/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt
@@ -15,9 +15,12 @@ import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
+import androidx.lifecycle.lifecycleScope
import com.pixel10.ai.R
import com.pixel10.ai.databinding.ActivityMainBinding
+import com.pixel10.ai.inference.ModelDownloader
import com.pixel10.ai.server.ApiServerService
+import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -27,6 +30,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private var service: ApiServerService? = null
private var bound = false
+ private var downloading = false
private val logBuffer = StringBuilder()
@@ -66,20 +70,27 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermission()
binding.btnToggle.setOnClickListener {
- if (service?.isRunning == true) {
- stopServer()
- } else {
- startServer()
- }
+ if (service?.isRunning == true) stopServer() else startServer()
}
+ binding.btnDownloadModel.setOnClickListener {
+ startModelDownload()
+ }
+
+ updateModelCard()
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")
+ if (ModelDownloader.isModelPresent(this)) {
+ appendLog("Model ready — server works in background")
+ } else {
+ appendLog("No local model found")
+ appendLog("Tap 'Download Model' to enable background inference")
+ appendLog("(Without it, Gemini Nano only works in foreground)")
+ }
}
override fun onStart() {
@@ -110,16 +121,64 @@ class MainActivity : AppCompatActivity() {
}
}
+ private fun startModelDownload() {
+ if (downloading) return
+ downloading = true
+ binding.btnDownloadModel.isEnabled = false
+ binding.btnDownloadModel.text = "Downloading…"
+ binding.progressDownload.visibility = View.VISIBLE
+ binding.tvModelDownloadStatus.text = "Starting download…"
+
+ lifecycleScope.launch {
+ try {
+ ModelDownloader.download(this@MainActivity) { progress ->
+ runOnUiThread {
+ binding.progressDownload.progress = progress.percent
+ val mb = progress.downloadedBytes / 1_048_576
+ val total = progress.totalBytes / 1_048_576
+ binding.tvModelDownloadStatus.text = "Downloading… ${mb}MB / ${total}MB (${progress.percent}%)"
+ }
+ }
+ runOnUiThread {
+ downloading = false
+ updateModelCard()
+ appendLog("Model downloaded — background inference enabled")
+ }
+ } catch (e: Exception) {
+ runOnUiThread {
+ downloading = false
+ binding.btnDownloadModel.isEnabled = true
+ binding.btnDownloadModel.text = getString(R.string.btn_download_model)
+ binding.progressDownload.visibility = View.GONE
+ binding.tvModelDownloadStatus.text = "Download failed: ${e.message}"
+ appendLog("Download error: ${e.message}")
+ }
+ }
+ }
+ }
+
+ private fun updateModelCard() {
+ val present = ModelDownloader.isModelPresent(this)
+ if (present) {
+ binding.tvModelDownloadStatus.text = getString(R.string.model_downloaded)
+ binding.btnDownloadModel.visibility = View.GONE
+ binding.progressDownload.visibility = View.GONE
+ } else {
+ binding.tvModelDownloadStatus.text = getString(R.string.model_not_downloaded)
+ binding.btnDownloadModel.visibility = View.VISIBLE
+ binding.btnDownloadModel.isEnabled = true
+ binding.btnDownloadModel.text = getString(R.string.btn_download_model)
+ binding.progressDownload.visibility = View.GONE
+ }
+ }
+
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),
@@ -127,7 +186,6 @@ class MainActivity : AppCompatActivity() {
Context.BIND_AUTO_CREATE
)
}
-
updateStatus(ApiServerService.ServerState.LOADING_MODEL)
}
@@ -137,6 +195,10 @@ class MainActivity : AppCompatActivity() {
}
private fun updateStatus(state: ApiServerService.ServerState) {
+ val canEdit = state == ApiServerService.ServerState.STOPPED ||
+ state == ApiServerService.ServerState.ERROR
+ binding.etPort.isEnabled = canEdit
+
when (state) {
ApiServerService.ServerState.STOPPED -> {
binding.tvServerStatus.text = getString(R.string.server_status_stopped)
@@ -145,14 +207,12 @@ class MainActivity : AppCompatActivity() {
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.background as? GradientDrawable)?.setColor(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()
@@ -163,7 +223,6 @@ class MainActivity : AppCompatActivity() {
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)
@@ -171,7 +230,6 @@ class MainActivity : AppCompatActivity() {
binding.tvModelStatus.text = getString(R.string.model_error)
binding.btnToggle.text = getString(R.string.btn_start)
binding.btnToggle.isEnabled = true
- binding.etPort.isEnabled = true
}
}
}
@@ -180,16 +238,8 @@ class MainActivity : AppCompatActivity() {
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}"
- }
+ binding.scrollLog.post { binding.scrollLog.fullScroll(View.FOCUS_DOWN) }
+ service?.let { binding.tvRequestCount.text = "Requests served: ${it.requestCount}" }
}
@Suppress("DEPRECATION")
@@ -202,7 +252,6 @@ class MainActivity : AppCompatActivity() {
}
} catch (_: Exception) {}
- // Fallback: iterate network interfaces
try {
val interfaces = java.net.NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) {
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 0163ff0..cf00e09 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -105,6 +105,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 768c684..eeb9478 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -18,6 +18,9 @@
Model: not loaded
+ Download Model (~1.3 GB)
+ ✓ Gemma 2B ready — background inference enabled
+ No local model. Download to enable background inference.
Start Server
Stop Server
Port: