From db48abb0a29e92df994e94ecb07344187a2138c1 Mon Sep 17 00:00:00 2001 From: alexpolo1 Date: Sat, 28 Feb 2026 21:29:47 +0100 Subject: [PATCH] Add GeminiCloudModel, background inference fix, and CI pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .github/workflows/ci.yml | 180 ++++++++++++++++++ .github/workflows/release.yml | 45 +++++ .../pixel10/ai/inference/GeminiCloudModel.kt | 165 ++++++++++++++++ .../com/pixel10/ai/inference/OnDeviceModel.kt | 93 +++++---- .../com/pixel10/ai/server/ApiServerService.kt | 12 +- .../java/com/pixel10/ai/ui/MainActivity.kt | 17 ++ app/src/main/res/layout/activity_main.xml | 33 +++- app/src/main/res/values/strings.xml | 2 + 8 files changed, 507 insertions(+), 40 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 app/src/main/java/com/pixel10/ai/inference/GeminiCloudModel.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9ca7a80 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 + + + ${GEMINI_API_KEY} + + 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d813c99 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/app/src/main/java/com/pixel10/ai/inference/GeminiCloudModel.kt b/app/src/main/java/com/pixel10/ai/inference/GeminiCloudModel.kt new file mode 100644 index 0000000..443f2f7 --- /dev/null +++ b/app/src/main/java/com/pixel10/ai/inference/GeminiCloudModel.kt @@ -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" + } +} 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 0489430..3036d43 100644 --- a/app/src/main/java/com/pixel10/ai/inference/OnDeviceModel.kt +++ b/app/src/main/java/com/pixel10/ai/inference/OnDeviceModel.kt @@ -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) : diff --git a/app/src/main/java/com/pixel10/ai/server/ApiServerService.kt b/app/src/main/java/com/pixel10/ai/server/ApiServerService.kt index 59bb199..3110172 100644 --- a/app/src/main/java/com/pixel10/ai/server/ApiServerService.kt +++ b/app/src/main/java/com/pixel10/ai/server/ApiServerService.kt @@ -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" } } 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..12f9102 100644 --- a/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt +++ b/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt @@ -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 } } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 0163ff0..28e7bdb 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -138,6 +138,37 @@ android:gravity="center" /> + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 768c684..6843130 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -22,6 +22,8 @@ Stop Server Port: 8080 + Gemini API Key (optional, for background use) + AIza… Requests served: %d Request Log