From 95b7c02fd00f47dfa4ebe0f895b087402b36f440 Mon Sep 17 00:00:00 2001 From: alexpolo1 Date: Sat, 28 Feb 2026 22:19:14 +0100 Subject: [PATCH] Fix background inference: MediaPipe-first, in-app model downloader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: ML Kit Gemini Nano enforces ErrorCode 30 (foreground restriction) at the AICore binder level. No foreground service or wake lock can bypass it — the app's Activity must be the visible top window. Fix: MediaPipe LLM Inference runs entirely in the app process via the Tensor G5 GPU (OpenCL/Vulkan), with no AICore dependency and no foreground restriction. Changes: - OnDeviceModel.create() now tries MediaPipe FIRST, Nano second - ModelDownloader.kt: downloads Gemma 2B IT GPU INT4 (~1.3GB) from Google's MediaPipe model CDN with resume support (Range header) - MainActivity: "Download Model" card shows download status and progress bar; auto-hides once model is present; uses lifecycleScope for coroutine - Layout: model card inserted between status and port field - Strings: btn_download_model, model_downloaded, model_not_downloaded Once the model is downloaded the server accepts requests in the background indefinitely with no foreground Activity required. Co-Authored-By: Claude Sonnet 4.6 --- .../pixel10/ai/inference/ModelDownloader.kt | 96 ++++++++++++++++++ .../com/pixel10/ai/inference/OnDeviceModel.kt | 47 +++++---- .../java/com/pixel10/ai/ui/MainActivity.kt | 99 ++++++++++++++----- app/src/main/res/layout/activity_main.xml | 50 +++++++++- app/src/main/res/values/strings.xml | 3 + 5 files changed, 248 insertions(+), 47 deletions(-) create mode 100644 app/src/main/java/com/pixel10/ai/inference/ModelDownloader.kt 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: