2 Commits

Author SHA1 Message Date
alexpolo1
0861ed38fc Add multi-model download: Gemma 3n E4B (recommended), E2B, Gemma 2B
Some checks failed
Release / Build and Release APK (push) Failing after 14m8s
- ModelSpec enum with 3 downloadable options from MediaPipe CDN
- Gemma 3n E4B set as recommended default (best coding/reasoning via MoE)
- Gemma 3n E2B as faster/smaller alternative
- Gemma 2B kept as lightest option
- UI shows all 3 download buttons, hides all once any model is installed
- Installed model name shown in status (e.g. "✓ Gemma 3n E4B ready")
- Custom models (DeepSeek Coder, Qwen2.5-Coder) can be manually placed in files dir

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 22:25:49 +01:00
alexpolo1
371bd55e18 Fix background inference: MediaPipe-first, in-app model downloader
Some checks failed
Release / Build and Release APK (push) Failing after 9m2s
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 <noreply@anthropic.com>
2026-02-28 22:19:14 +01:00
5 changed files with 334 additions and 47 deletions

View File

@@ -0,0 +1,145 @@
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 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.
*
* Three model options (all from Google's MediaPipe CDN):
* - [ModelSpec.GEMMA_3N_E4B_CODING] — best coding/reasoning, ~2.5 GB (recommended)
* - [ModelSpec.GEMMA_3N_E2B_CODING] — good balance, ~1.5 GB
* - [ModelSpec.GEMMA_2B_GENERAL] — lightest, ~1.3 GB
*
* Custom models (DeepSeek Coder, Qwen2.5-Coder, etc.) can be placed manually in
* the app's files directory after converting with ai-edge-torch.
*/
object ModelDownloader {
private const val TAG = "ModelDownloader"
/** Available model specs that can be downloaded from Google's MediaPipe CDN. */
enum class ModelSpec(
val displayName: String,
val filename: String,
val url: String,
val sizeMb: Int,
val description: String
) {
/** Recommended: best coding & reasoning quality via MoE architecture. */
GEMMA_3N_E4B_CODING(
displayName = "Gemma 3n E4B",
filename = "gemma-3n-E4B-it-int4.task",
url = "https://storage.googleapis.com/mediapipe-models/llm_inference/" +
"gemma-3n-E4B-it-int4/float16/1/gemma-3n-E4B-it-int4.task",
sizeMb = 2500,
description = "Best coding & reasoning (~2.5 GB)"
),
/** Good balance between quality and speed. */
GEMMA_3N_E2B_CODING(
displayName = "Gemma 3n E2B",
filename = "gemma-3n-E2B-it-int4.task",
url = "https://storage.googleapis.com/mediapipe-models/llm_inference/" +
"gemma-3n-E2B-it-int4/float16/1/gemma-3n-E2B-it-int4.task",
sizeMb = 1500,
description = "Good balance, faster (~1.5 GB)"
),
/** Lightest option — general-purpose, not optimised for code. */
GEMMA_2B_GENERAL(
displayName = "Gemma 2B",
filename = "gemma-2b-it-gpu-int4.bin",
url = "https://storage.googleapis.com/mediapipe-models/llm_inference/" +
"gemma-2b-it-gpu-int4/float16/1/gemma-2b-it-gpu-int4.bin",
sizeMb = 1300,
description = "Lightest, general-purpose (~1.3 GB)"
)
}
data class Progress(
val downloadedBytes: Long,
val totalBytes: Long,
val percent: Int = if (totalBytes > 0) (downloadedBytes * 100 / totalBytes).toInt() else 0
)
/** Returns true if any supported model is present in the app's files directory. */
fun isModelPresent(context: Context): Boolean =
ModelSpec.values().any { modelFile(context, it).let { f -> f.exists() && f.length() > 1_000_000L } }
/** Returns the installed [ModelSpec], or null if no model is present. */
fun installedSpec(context: Context): ModelSpec? =
ModelSpec.values().firstOrNull { modelFile(context, it).let { f -> f.exists() && f.length() > 1_000_000L } }
fun modelFile(context: Context, spec: ModelSpec): File =
File(context.filesDir, spec.filename)
/** Legacy compat — returns the file of the installed model, or Gemma 3n E4B path as default. */
fun modelFile(context: Context): File =
installedSpec(context)?.let { modelFile(context, it) }
?: modelFile(context, ModelSpec.GEMMA_3N_E4B_CODING)
/**
* Download [spec], reporting progress via [onProgress].
* Supports resume — if a partial file exists, continues from where it left off.
*/
suspend fun download(
context: Context,
spec: ModelSpec = ModelSpec.GEMMA_3N_E4B_CODING,
onProgress: (Progress) -> Unit
) = withContext(Dispatchers.IO) {
val dest = modelFile(context, spec)
val alreadyDownloaded = if (dest.exists()) dest.length() else 0L
Log.i(TAG, "Download starting ${spec.displayName} (already have $alreadyDownloaded bytes)")
val conn = URL(spec.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) {
ModelSpec.values().forEach { modelFile(context, it).delete() }
Log.i(TAG, "All models deleted")
}
}

View File

@@ -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."
)
}
}

View File

@@ -15,9 +15,13 @@ 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.inference.ModelDownloader.ModelSpec
import com.pixel10.ai.server.ApiServerService
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -27,6 +31,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 +71,33 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermission()
binding.btnToggle.setOnClickListener {
if (service?.isRunning == true) {
stopServer()
} else {
startServer()
}
if (service?.isRunning == true) stopServer() else startServer()
}
binding.btnDownloadGemma3nE4b.setOnClickListener {
startModelDownload(ModelSpec.GEMMA_3N_E4B_CODING)
}
binding.btnDownloadGemma3nE2b.setOnClickListener {
startModelDownload(ModelSpec.GEMMA_3N_E2B_CODING)
}
binding.btnDownloadModel.setOnClickListener {
startModelDownload(ModelSpec.GEMMA_2B_GENERAL)
}
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 +128,72 @@ class MainActivity : AppCompatActivity() {
}
}
private fun startModelDownload(spec: ModelSpec) {
if (downloading) return
downloading = true
setDownloadButtonsEnabled(false)
binding.progressDownload.visibility = View.VISIBLE
binding.tvModelDownloadStatus.text = "Starting download: ${spec.displayName}"
lifecycleScope.launch {
try {
ModelDownloader.download(this@MainActivity, spec) { progress ->
runOnUiThread {
binding.progressDownload.progress = progress.percent
val mb = progress.downloadedBytes / 1_048_576
val total = progress.totalBytes / 1_048_576
binding.tvModelDownloadStatus.text =
"${spec.displayName}: ${mb}MB / ${total}MB (${progress.percent}%)"
}
}
runOnUiThread {
downloading = false
updateModelCard()
appendLog("${spec.displayName} downloaded — background inference enabled")
}
} catch (e: Exception) {
runOnUiThread {
downloading = false
setDownloadButtonsEnabled(true)
binding.progressDownload.visibility = View.GONE
binding.tvModelDownloadStatus.text = "Download failed: ${e.message}"
appendLog("Download error: ${e.message}")
}
}
}
}
private fun setDownloadButtonsEnabled(enabled: Boolean) {
binding.btnDownloadGemma3nE4b.isEnabled = enabled
binding.btnDownloadGemma3nE2b.isEnabled = enabled
binding.btnDownloadModel.isEnabled = enabled
}
private fun updateModelCard() {
val spec = ModelDownloader.installedSpec(this)
if (spec != null) {
binding.tvModelDownloadStatus.text = getString(R.string.model_downloaded, spec.displayName)
binding.btnDownloadGemma3nE4b.visibility = View.GONE
binding.btnDownloadGemma3nE2b.visibility = View.GONE
binding.btnDownloadModel.visibility = View.GONE
binding.progressDownload.visibility = View.GONE
} else {
binding.tvModelDownloadStatus.text = getString(R.string.model_not_downloaded)
binding.btnDownloadGemma3nE4b.visibility = View.VISIBLE
binding.btnDownloadGemma3nE2b.visibility = View.VISIBLE
binding.btnDownloadModel.visibility = View.VISIBLE
setDownloadButtonsEnabled(true)
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 +201,6 @@ class MainActivity : AppCompatActivity() {
Context.BIND_AUTO_CREATE
)
}
updateStatus(ApiServerService.ServerState.LOADING_MODEL)
}
@@ -137,6 +210,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 +222,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 +238,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 +245,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 +253,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 +267,6 @@ class MainActivity : AppCompatActivity() {
}
} catch (_: Exception) {}
// Fallback: iterate network interfaces
try {
val interfaces = java.net.NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) {

View File

@@ -105,6 +105,74 @@
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Model Download Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardModel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:cardBackgroundColor="@color/surface_variant"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="0dp"
app:layout_constraintTop_toBottomOf="@id/cardStatus"
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="16dp">
<TextView
android:id="@+id/tvModelDownloadStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/log_text"
android:textSize="13sp" />
<ProgressBar
android:id="@+id/progressDownload"
style="@android:style/Widget.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:max="100"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDownloadGemma3nE4b"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/btn_download_gemma3n_e4b"
android:textSize="13sp"
app:cornerRadius="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDownloadGemma3nE2b"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/btn_download_gemma3n_e2b"
android:textSize="13sp"
app:cornerRadius="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDownloadModel"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/btn_download_model"
android:textSize="13sp"
app:cornerRadius="8dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Port Config -->
<LinearLayout
android:id="@+id/layoutPort"
@@ -113,7 +181,7 @@
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/cardStatus"
app:layout_constraintTop_toBottomOf="@id/cardModel"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">

View File

@@ -18,6 +18,11 @@
<string name="model_not_loaded">Model: not loaded</string>
<!-- Controls -->
<string name="btn_download_gemma3n_e4b">⭐ Gemma 3n E4B — Best coding (~2.5 GB)</string>
<string name="btn_download_gemma3n_e2b">Gemma 3n E2B — Faster (~1.5 GB)</string>
<string name="btn_download_model">Gemma 2B — Lightest (~1.3 GB)</string>
<string name="model_downloaded">✓ %s ready — background inference enabled</string>
<string name="model_not_downloaded">No local model. Download one to enable background inference.</string>
<string name="btn_start">Start Server</string>
<string name="btn_stop">Stop Server</string>
<string name="port_label">Port:</string>