Add GeminiCloudModel, background inference fix, and CI pipeline

- Add GeminiCloudModel: proxies inference to Gemini 2.0 Flash via HTTPS
  using HttpURLConnection (no new deps). Supports both blocking and SSE
  streaming. Works from any context — background, emulator, CI.

- Fix background inference: OnDeviceModel.create() now accepts an apiKey;
  when set, GeminiCloudModel is selected immediately, bypassing Gemini
  Nano's foreground-only restriction. ApiServerService reads the key from
  SharedPreferences at startup.

- Add API key UI: password field in MainActivity saved to SharedPreferences
  before the service starts; disabled while server is running.

- Add GitHub Actions CI (.github/workflows/ci.yml): build job produces a
  debug APK artifact; test job spins up a KVM-accelerated Android 31
  emulator, installs the APK, writes the GEMINI_API_KEY secret into
  SharedPreferences via adb run-as, starts the service, and runs curl
  assertions against /health, /v1/models, /v1/chat/completions, and the
  SSE streaming endpoint.

- Add release workflow (.github/workflows/release.yml): triggered on v*
  tags, builds the APK and creates a GitHub Release with it attached.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alexpolo1
2026-02-28 21:29:47 +01:00
parent d0059442f8
commit db48abb0a2
8 changed files with 507 additions and 40 deletions

180
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,180 @@
name: CI
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
build:
name: Build APK
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: "17"
distribution: "temurin"
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-
- name: Build debug APK
run: ./gradlew assembleDebug --no-daemon
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: app-debug
path: app/build/outputs/apk/debug/app-debug.apk
test:
name: API Integration Tests
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- name: Enable KVM (hardware-accelerated emulator)
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: "17"
distribution: "temurin"
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-
- name: Download APK artifact
uses: actions/download-artifact@v4
with:
name: app-debug
path: apk/
- name: Install Android SDK components
run: |
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses > /dev/null
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
"platform-tools" \
"emulator" \
"system-images;android-31;google_apis;x86_64"
- name: Create AVD
run: |
echo no | $ANDROID_HOME/cmdline-tools/latest/bin/avdmanager create avd \
--name pixel10_test \
--package "system-images;android-31;google_apis;x86_64" \
--device "pixel_6"
- name: Start emulator
run: |
$ANDROID_HOME/emulator/emulator \
-avd pixel10_test \
-no-window -no-audio -no-snapshot \
-gpu swiftshader_indirect &
$ANDROID_HOME/platform-tools/adb wait-for-device
- name: Wait for emulator boot
run: |
timeout 300 bash -c '
until $ANDROID_HOME/platform-tools/adb shell \
getprop sys.boot_completed 2>/dev/null | grep -q "1"; do
sleep 3
done
'
$ANDROID_HOME/platform-tools/adb shell input keyevent 82
- name: Install APK
run: $ANDROID_HOME/platform-tools/adb install apk/app-debug.apk
- name: Write Gemini API key to SharedPreferences
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
run: |
$ANDROID_HOME/platform-tools/adb shell run-as com.pixel10.ai sh -c \
'mkdir -p /data/data/com.pixel10.ai/shared_prefs && cat > /data/data/com.pixel10.ai/shared_prefs/pixel10_prefs.xml' << EOF
<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
<map>
<string name="gemini_api_key">${GEMINI_API_KEY}</string>
</map>
EOF
- name: Start API server
run: |
$ANDROID_HOME/platform-tools/adb shell am startservice \
-n com.pixel10.ai/.server.ApiServerService \
-a com.pixel10.ai.START_SERVER \
--ei port 8080
sleep 5
- name: Forward device port
run: $ANDROID_HOME/platform-tools/adb forward tcp:8080 tcp:8080
- name: Wait for server to be ready
run: |
timeout 30 bash -c '
until curl -sf http://localhost:8080/health > /dev/null 2>&1; do
sleep 2
done
'
- name: Test /health
run: |
RESPONSE=$(curl -sf http://localhost:8080/health)
echo "Health response: $RESPONSE"
echo "$RESPONSE" | grep -q '"status"' || \
(echo "FAIL: /health did not return expected JSON" && exit 1)
- name: Test /v1/models
run: |
RESPONSE=$(curl -sf http://localhost:8080/v1/models)
echo "Models response: $RESPONSE"
echo "$RESPONSE" | grep -q '"data"' || \
(echo "FAIL: /v1/models did not return expected JSON" && exit 1)
- name: Test /v1/chat/completions (non-streaming)
run: |
RESPONSE=$(curl -sf http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"gemini","messages":[{"role":"user","content":"Reply with one word: hello"}]}')
echo "Chat response: $RESPONSE"
echo "$RESPONSE" | grep -q '"choices"' || \
(echo "FAIL: /v1/chat/completions did not return choices" && exit 1)
- name: Test /v1/chat/completions (streaming)
run: |
# Collect SSE chunks with a 20-second timeout
CHUNKS=$(curl -sf --max-time 20 http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"gemini","stream":true,"messages":[{"role":"user","content":"Say hi"}]}')
echo "Streaming chunks received:"
echo "$CHUNKS"
echo "$CHUNKS" | grep -q "data:" || \
(echo "FAIL: streaming returned no SSE chunks" && exit 1)
- name: Print logcat on failure
if: failure()
run: $ANDROID_HOME/platform-tools/adb logcat -d -s Pixel10AI ApiServerService GeminiCloudModel

45
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: Release
on:
push:
tags:
- "v*"
jobs:
release:
name: Build and Release APK
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: "17"
distribution: "temurin"
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-
- name: Build debug APK
run: ./gradlew assembleDebug --no-daemon
- name: Rename APK with version tag
run: |
TAG="${{ github.ref_name }}"
cp app/build/outputs/apk/debug/app-debug.apk "pixel10-ai-${TAG}.apk"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: "pixel10-ai-${{ github.ref_name }}.apk"
generate_release_notes: true

View File

@@ -0,0 +1,165 @@
package com.pixel10.ai.inference
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
/**
* Cloud backend that proxies inference requests to the Gemini 2.0 Flash API.
*
* Works from any context (foreground service, background) because it uses
* standard HTTPS rather than the AICore system service. This is the fallback
* when Gemini Nano is unavailable (emulator, background inference blocked, etc.).
*
* Requires a Gemini API key (free tier available at ai.google.dev).
*/
class GeminiCloudModel(private val apiKey: String) : OnDeviceModel {
override val backendName = "Gemini 2.0 Flash (Cloud)"
override val isReady: Boolean = true
override suspend fun generate(
prompt: String,
maxTokens: Int,
temperature: Float
): String = withContext(Dispatchers.IO) {
val url = URL("$BASE_URL:generateContent?key=$apiKey")
val connection = url.openConnection() as HttpURLConnection
try {
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json")
connection.doOutput = true
connection.connectTimeout = 30_000
connection.readTimeout = 60_000
val body = buildRequestBody(prompt, maxTokens, temperature)
connection.outputStream.use { it.write(body.toByteArray()) }
val responseCode = connection.responseCode
if (responseCode != HttpURLConnection.HTTP_OK) {
val error = connection.errorStream?.bufferedReader()?.readText() ?: "Unknown error"
throw OnDeviceModel.InferenceException("Gemini API error $responseCode: $error")
}
val responseText = connection.inputStream.bufferedReader().readText()
parseGenerateResponse(responseText)
} finally {
connection.disconnect()
}
}
override suspend fun generateStreaming(
prompt: String,
onToken: (String) -> Unit
): String = withContext(Dispatchers.IO) {
val url = URL("$STREAMING_URL?key=$apiKey")
val connection = url.openConnection() as HttpURLConnection
try {
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json")
connection.setRequestProperty("Accept", "text/event-stream")
connection.doOutput = true
connection.connectTimeout = 30_000
connection.readTimeout = 120_000
val body = buildRequestBody(prompt, maxTokens = 1024, temperature = 0.7f)
connection.outputStream.use { it.write(body.toByteArray()) }
val responseCode = connection.responseCode
if (responseCode != HttpURLConnection.HTTP_OK) {
val error = connection.errorStream?.bufferedReader()?.readText() ?: "Unknown error"
throw OnDeviceModel.InferenceException("Gemini streaming API error $responseCode: $error")
}
val fullText = StringBuilder()
BufferedReader(InputStreamReader(connection.inputStream)).use { reader ->
var line: String?
while (reader.readLine().also { line = it } != null) {
val l = line!!
if (!l.startsWith("data: ")) continue
val data = l.removePrefix("data: ").trim()
if (data == "[DONE]") break
try {
val token = parseChunkText(data)
if (token.isNotEmpty()) {
onToken(token)
fullText.append(token)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to parse SSE chunk: $data", e)
}
}
}
fullText.toString()
} finally {
connection.disconnect()
}
}
override fun close() {
// No resources to clean up
}
private fun buildRequestBody(prompt: String, maxTokens: Int, temperature: Float): String {
return JSONObject().apply {
put("contents", JSONArray().apply {
put(JSONObject().apply {
put("role", "user")
put("parts", JSONArray().apply {
put(JSONObject().apply {
put("text", prompt)
})
})
})
})
put("generationConfig", JSONObject().apply {
put("maxOutputTokens", maxTokens)
put("temperature", temperature.toDouble())
})
}.toString()
}
private fun parseGenerateResponse(json: String): String {
return try {
JSONObject(json)
.getJSONArray("candidates")
.getJSONObject(0)
.getJSONObject("content")
.getJSONArray("parts")
.getJSONObject(0)
.getString("text")
} catch (e: Exception) {
throw OnDeviceModel.InferenceException("Failed to parse Gemini response: ${e.message}", e)
}
}
private fun parseChunkText(json: String): String {
return try {
JSONObject(json)
.getJSONArray("candidates")
.getJSONObject(0)
.getJSONObject("content")
.getJSONArray("parts")
.getJSONObject(0)
.optString("text", "")
} catch (_: Exception) {
""
}
}
companion object {
private const val TAG = "GeminiCloudModel"
private const val BASE_URL =
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash"
private const val STREAMING_URL =
"$BASE_URL:streamGenerateContent?alt=sse"
}
}

View File

@@ -8,16 +8,18 @@ import kotlinx.coroutines.withContext
/**
* Unified interface for on-device AI inference on the Pixel 10.
*
* Supports two backends:
* Supports three backends, tried in order:
* 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.)
* on supported Pixel devices. Requires foreground context.
* 2. **Gemini Cloud** — proxies to Gemini 2.0 Flash via HTTPS. Works from
* any context (background, emulator). Requires an API key.
* 3. **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.
* Pass [apiKey] to enable the cloud backend. If an API key is provided,
* cloud is preferred over Nano to guarantee background operation.
*/
interface OnDeviceModel {
val backendName: String
@@ -40,41 +42,58 @@ interface OnDeviceModel {
private const val TAG = "OnDeviceModel"
/**
* Create the best available on-device model.
* Tries Gemini Nano (AICore) first, falls back to MediaPipe.
* Create the best available model.
*
* Fallback chain:
* 1. GeminiNano — on-device, best quality, foreground only
* 2. GeminiCloud — network, always works (background + CI); needs [apiKey]
* 3. MediaPipe — local model file, fully offline
*
* If [apiKey] is non-empty, cloud is tried *before* Nano so the server
* stays responsive after the user navigates away from the app.
*/
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}")
}
suspend fun create(context: Context, apiKey: String = ""): OnDeviceModel =
withContext(Dispatchers.IO) {
val hasKey = apiKey.isNotBlank()
// 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}")
}
// Prefer cloud when an API key is available — guarantees background operation
if (hasKey) {
Log.i(TAG, "API key set — using Gemini Cloud for background-safe inference")
return@withContext GeminiCloudModel(apiKey)
}
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"
)
}
// Try Gemini Nano via ML Kit Prompt API
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: Enter a Gemini API key in the app (works everywhere)\n\n" +
"Option 2: Use a Pixel device with Gemini Nano support " +
"(Pixel 10/9/8 series)\n\n" +
"Option 3: 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) :

View File

@@ -3,6 +3,7 @@ package com.pixel10.ai.server
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Binder
import android.os.IBinder
@@ -64,9 +65,14 @@ class ApiServerService : Service() {
scope.launch {
try {
// Load the on-device AI model
// Load the AI model — prefer cloud if an API key is configured
val apiKey = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(PREF_API_KEY, "") ?: ""
notifyLog("Loading AI model...")
model = OnDeviceModel.create(applicationContext)
if (apiKey.isNotBlank()) {
notifyLog("API key configured — using cloud backend")
}
model = OnDeviceModel.create(applicationContext, apiKey)
notifyLog("Model ready: ${model!!.backendName}")
// Start the HTTP server
@@ -161,5 +167,7 @@ class ApiServerService : Service() {
const val EXTRA_PORT = "port"
const val DEFAULT_PORT = 8080
private const val NOTIFICATION_ID = 1
const val PREFS_NAME = "pixel10_prefs"
const val PREF_API_KEY = "gemini_api_key"
}
}

View File

@@ -18,6 +18,8 @@ import androidx.core.content.ContextCompat
import com.pixel10.ai.R
import com.pixel10.ai.databinding.ActivityMainBinding
import com.pixel10.ai.server.ApiServerService
import com.pixel10.ai.server.ApiServerService.Companion.PREF_API_KEY
import com.pixel10.ai.server.ApiServerService.Companion.PREFS_NAME
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -65,6 +67,10 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermission()
// Restore saved API key
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
binding.etApiKey.setText(prefs.getString(PREF_API_KEY, ""))
binding.btnToggle.setOnClickListener {
if (service?.isRunning == true) {
stopServer()
@@ -113,6 +119,13 @@ class MainActivity : AppCompatActivity() {
private fun startServer() {
val port = binding.etPort.text.toString().toIntOrNull() ?: 8080
// Persist API key before starting the service
val apiKey = binding.etApiKey.text.toString().trim()
getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putString(PREF_API_KEY, apiKey)
.apply()
val intent = Intent(this, ApiServerService::class.java).apply {
action = ApiServerService.ACTION_START
putExtra(ApiServerService.EXTRA_PORT, port)
@@ -146,6 +159,7 @@ class MainActivity : AppCompatActivity() {
binding.btnToggle.text = getString(R.string.btn_start)
binding.btnToggle.isEnabled = true
binding.etPort.isEnabled = true
binding.etApiKey.isEnabled = true
}
ApiServerService.ServerState.LOADING_MODEL -> {
binding.tvServerStatus.text = getString(R.string.server_status_starting)
@@ -153,6 +167,7 @@ class MainActivity : AppCompatActivity() {
binding.tvModelStatus.text = getString(R.string.model_loading)
binding.btnToggle.isEnabled = false
binding.etPort.isEnabled = false
binding.etApiKey.isEnabled = false
}
ApiServerService.ServerState.RUNNING -> {
val port = binding.etPort.text.toString()
@@ -164,6 +179,7 @@ class MainActivity : AppCompatActivity() {
binding.btnToggle.text = getString(R.string.btn_stop)
binding.btnToggle.isEnabled = true
binding.etPort.isEnabled = false
binding.etApiKey.isEnabled = false
}
ApiServerService.ServerState.ERROR -> {
binding.tvServerStatus.text = getString(R.string.server_status_error)
@@ -172,6 +188,7 @@ class MainActivity : AppCompatActivity() {
binding.btnToggle.text = getString(R.string.btn_start)
binding.btnToggle.isEnabled = true
binding.etPort.isEnabled = true
binding.etApiKey.isEnabled = true
}
}
}

View File

@@ -138,6 +138,37 @@
android:gravity="center" />
</LinearLayout>
<!-- API Key Config -->
<LinearLayout
android:id="@+id/layoutApiKey"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="12dp"
app:layout_constraintTop_toBottomOf="@id/layoutPort"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/api_key_label"
android:textColor="@color/log_text"
android:textSize="13sp" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etApiKey"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="4dp"
android:hint="@string/api_key_hint"
android:inputType="textPassword"
android:textColor="@color/on_surface"
android:backgroundTint="@color/primary"
android:fontFamily="monospace"
android:textSize="14sp" />
</LinearLayout>
<!-- Start/Stop Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnToggle"
@@ -147,7 +178,7 @@
android:text="@string/btn_start"
android:textSize="16sp"
app:cornerRadius="12dp"
app:layout_constraintTop_toBottomOf="@id/layoutPort"
app:layout_constraintTop_toBottomOf="@id/layoutApiKey"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />

View File

@@ -22,6 +22,8 @@
<string name="btn_stop">Stop Server</string>
<string name="port_label">Port:</string>
<string name="port_default">8080</string>
<string name="api_key_label">Gemini API Key (optional, for background use)</string>
<string name="api_key_hint">AIza…</string>
<string name="requests_served">Requests served: %d</string>
<string name="request_log_label">Request Log</string>