From 12de2004503e1db751cfe32a513225f9e8db9d1f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 14:09:41 +0000 Subject: [PATCH] =?UTF-8?q?Add=20Pixel10=20AI=20Server=20=E2=80=94=20expos?= =?UTF-8?q?e=20on-device=20AI=20chip=20as=20REST=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android app that turns a Pixel 10 into a free AI API server by leveraging the Tensor G5's on-device AI capabilities through an embedded HTTP server. Key components: - Dual AI backend: Gemini Nano (ML Kit Prompt API) + MediaPipe LLM fallback - OpenAI-compatible REST API (chat/completions, completions, models, health) - NanoHTTPD embedded server with CORS support and SSE streaming - Foreground service with wake lock for persistent background operation - Material Design 3 dashboard with live request logging All inference runs entirely on-device — zero cloud costs, full offline capability. https://claude.ai/code/session_01GvqMLSMmfMR8uz66BFVXX2 --- .gitignore | 17 ++ README.md | 142 ++++++++++ app/build.gradle.kts | 65 +++++ app/proguard-rules.pro | 10 + app/src/main/AndroidManifest.xml | 54 ++++ .../main/java/com/pixel10/ai/Pixel10AIApp.kt | 29 ++ .../pixel10/ai/inference/GeminiNanoModel.kt | 125 +++++++++ .../pixel10/ai/inference/MediaPipeModel.kt | 149 ++++++++++ .../com/pixel10/ai/inference/OnDeviceModel.kt | 82 ++++++ .../java/com/pixel10/ai/server/AIApiServer.kt | 254 ++++++++++++++++++ .../java/com/pixel10/ai/server/ApiModels.kt | 91 +++++++ .../com/pixel10/ai/server/ApiServerService.kt | 165 ++++++++++++ .../java/com/pixel10/ai/ui/MainActivity.kt | 221 +++++++++++++++ app/src/main/res/drawable/status_dot.xml | 6 + app/src/main/res/layout/activity_main.xml | 189 +++++++++++++ app/src/main/res/mipmap-hdpi/ic_launcher.xml | 5 + app/src/main/res/values/colors.xml | 15 ++ app/src/main/res/values/strings.xml | 17 ++ app/src/main/res/values/styles.xml | 11 + .../main/res/xml/network_security_config.xml | 4 + build.gradle.kts | 4 + gradle.properties | 4 + gradle/wrapper/gradle-wrapper.properties | 7 + settings.gradle.kts | 17 ++ 24 files changed, 1683 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/pixel10/ai/Pixel10AIApp.kt create mode 100644 app/src/main/java/com/pixel10/ai/inference/GeminiNanoModel.kt create mode 100644 app/src/main/java/com/pixel10/ai/inference/MediaPipeModel.kt create mode 100644 app/src/main/java/com/pixel10/ai/inference/OnDeviceModel.kt create mode 100644 app/src/main/java/com/pixel10/ai/server/AIApiServer.kt create mode 100644 app/src/main/java/com/pixel10/ai/server/ApiModels.kt create mode 100644 app/src/main/java/com/pixel10/ai/server/ApiServerService.kt create mode 100644 app/src/main/java/com/pixel10/ai/ui/MainActivity.kt create mode 100644 app/src/main/res/drawable/status_dot.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/styles.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc81b83 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +/app/build +/app/release +*.apk +*.aab +*.bin +*.task +*.tflite diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd42d40 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# Pixel10 AI Server + +Turn your Pixel 10 into a free AI API server. This Android app exposes the Tensor G5's on-device AI chip via a REST API, letting any device on your network make AI inference requests — no cloud, no API keys, no costs. + +## How It Works + +The app runs an HTTP server directly on your phone that accepts OpenAI-compatible API requests. Under the hood, it uses Google's on-device AI stack: + +1. **Gemini Nano** (preferred) — The system-provided model via ML Kit Prompt API, hardware-accelerated on the Tensor G5 TPU with a 32K token context window +2. **MediaPipe LLM** (fallback) — For custom open-weight models like Gemma 3n or Gemma 2B that you supply yourself + +All inference runs entirely on-device. Your data never leaves the phone. + +## API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/v1/chat/completions` | Chat completion (OpenAI-compatible) | +| `POST` | `/v1/completions` | Text completion | +| `GET` | `/v1/models` | List available models | +| `GET` | `/health` | Server status and device info | + +## Quick Start + +### 1. Install and Launch + +Build the APK in Android Studio and install on your Pixel 10 (or Pixel 9/8 series). + +### 2. Start the Server + +Open the app and tap **Start Server**. The app will: +- Load the AI model (Gemini Nano or your custom model) +- Start the HTTP server on the configured port (default: 8080) +- Display the local IP address to connect to + +### 3. Make Requests + +From any device on the same WiFi network: + +```bash +# Chat completion +curl http://:8080/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{ + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "What is the Tensor G5 chip?"} + ] + }' + +# Simple completion +curl http://:8080/v1/completions \ + -H "Content-Type: application/json" \ + -d '{"prompt": "Explain quantum computing in simple terms"}' + +# Health check +curl http://:8080/health + +# List models +curl http://:8080/v1/models +``` + +### Use with Python OpenAI Library + +```python +from openai import OpenAI + +client = OpenAI( + base_url="http://:8080/v1", + api_key="not-needed" # no auth required +) + +response = client.chat.completions.create( + model="pixel10-on-device", + messages=[ + {"role": "user", "content": "Hello from my laptop!"} + ] +) +print(response.choices[0].message.content) +``` + +## Using Custom Models (MediaPipe) + +If Gemini Nano isn't available on your device, you can use custom models: + +1. Download a compatible model (e.g., [Gemma 3n E2B](https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android)) +2. Push to the device: + ```bash + adb push gemma-3n-E2B.task /data/data/com.pixel10.ai/files/ + ``` +3. Restart the app — it will auto-detect the model file + +Supported model formats: `.task`, `.bin`, `.tflite` + +## Supported Devices + +- **Pixel 10 / 10 Pro / 10 Pro XL** — Full Tensor G5 TPU acceleration +- **Pixel 9 series** — Tensor G4 TPU +- **Pixel 8 series** — Tensor G3 TPU +- Other Android 12+ devices — MediaPipe backend with custom models + +## Requirements + +- Android 12 (API 31) or higher +- WiFi connection (for network access to the API) +- For Gemini Nano: Pixel device with AICore support +- For custom models: Compatible model file placed in app directory + +## Building + +```bash +# Clone the repo +git clone +cd Pixel10-ai + +# Open in Android Studio and build, or: +./gradlew assembleDebug + +# Install on connected device +adb install app/build/outputs/apk/debug/app-debug.apk +``` + +## Architecture + +``` +com.pixel10.ai/ +├── inference/ +│ ├── OnDeviceModel.kt # Unified model interface +│ ├── GeminiNanoModel.kt # Gemini Nano via ML Kit Prompt API +│ └── MediaPipeModel.kt # Custom models via MediaPipe LLM +├── server/ +│ ├── AIApiServer.kt # NanoHTTPD-based REST API server +│ ├── ApiModels.kt # Request/response data classes +│ └── ApiServerService.kt # Foreground service for background operation +├── ui/ +│ └── MainActivity.kt # Server controls and status dashboard +└── Pixel10AIApp.kt # Application class +``` + +## License + +MIT diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..8f28ce0 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.pixel10.ai" + compileSdk = 35 + + defaultConfig { + applicationId = "com.pixel10.ai" + minSdk = 31 + targetSdk = 35 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + viewBinding = true + } +} + +dependencies { + // AndroidX + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.activity:activity-ktx:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.constraintlayout:constraintlayout:2.2.0") + + // ML Kit GenAI — Gemini Nano via AICore (recommended for Pixel 10) + implementation("com.google.mlkit:genai-prompt:1.0.0-beta1") + + // MediaPipe LLM Inference — for custom models (Gemma, etc.) + implementation("com.google.mediapipe:tasks-genai:0.10.24") + + // Embedded HTTP server + implementation("org.nanohttpd:nanohttpd:2.3.1") + + // JSON + implementation("com.google.code.gson:gson:2.11.0") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..78e19eb --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,10 @@ +# Pixel10 AI API Server - ProGuard Rules + +# Keep NanoHTTPD server +-keep class fi.iki.elonen.** { *; } + +# Keep MediaPipe classes +-keep class com.google.mediapipe.** { *; } + +# Keep Gson serialization models +-keep class com.pixel10.ai.server.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0729f5c --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/pixel10/ai/Pixel10AIApp.kt b/app/src/main/java/com/pixel10/ai/Pixel10AIApp.kt new file mode 100644 index 0000000..8b07e6e --- /dev/null +++ b/app/src/main/java/com/pixel10/ai/Pixel10AIApp.kt @@ -0,0 +1,29 @@ +package com.pixel10.ai + +import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager + +class Pixel10AIApp : Application() { + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + private fun createNotificationChannel() { + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = getString(R.string.notification_channel_desc) + } + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + + companion object { + const val CHANNEL_ID = "pixel10_ai_server" + } +} diff --git a/app/src/main/java/com/pixel10/ai/inference/GeminiNanoModel.kt b/app/src/main/java/com/pixel10/ai/inference/GeminiNanoModel.kt new file mode 100644 index 0000000..22e4b4d --- /dev/null +++ b/app/src/main/java/com/pixel10/ai/inference/GeminiNanoModel.kt @@ -0,0 +1,125 @@ +package com.pixel10.ai.inference + +import android.content.Context +import android.util.Log +import com.google.mlkit.genai.prompt.GenerativeModel +import com.google.mlkit.genai.prompt.type.Content +import com.google.mlkit.genai.prompt.type.TextPart +import com.google.mlkit.genai.prompt.type.content +import com.google.mlkit.genai.prompt.type.generationConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.fold +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Gemini Nano backend via ML Kit Prompt API. + * + * This runs the system-provided Gemini Nano model on the Pixel 10's Tensor G5 + * TPU through Android's AICore service. The model is managed by the OS — + * no manual download or file management needed. + * + * Key advantages: + * - Hardware-accelerated on Tensor G5 TPU (2.6x faster than G4) + * - 32,000 token context window on Pixel 10 + * - ~3 GB model always resident in RAM for instant inference + * - Fully offline, private — data never leaves the device + */ +class GeminiNanoModel private constructor( + private val generativeModel: GenerativeModel +) : OnDeviceModel { + + override val backendName = "Gemini Nano (ML Kit)" + + @Volatile + override var isReady: Boolean = true + private set + + override suspend fun generate( + prompt: String, + maxTokens: Int, + temperature: Float + ): String = withContext(Dispatchers.Default) { + try { + val request = content { text(prompt) } + val response = generativeModel.generateContent(request) + response.text ?: "" + } catch (e: Exception) { + Log.e(TAG, "Gemini Nano generation error", e) + throw OnDeviceModel.InferenceException("Gemini Nano generation failed: ${e.message}", e) + } + } + + override suspend fun generateStreaming( + prompt: String, + onToken: (String) -> Unit + ): String = withContext(Dispatchers.Default) { + try { + val request = content { text(prompt) } + generativeModel.generateContentStream(request) + .fold("") { acc, response -> + val chunk = response.text ?: "" + if (chunk.isNotEmpty()) onToken(chunk) + acc + chunk + } + } catch (e: Exception) { + Log.e(TAG, "Gemini Nano streaming error", e) + throw OnDeviceModel.InferenceException("Streaming failed: ${e.message}", e) + } + } + + override fun close() { + isReady = false + generativeModel.close() + } + + companion object { + private const val TAG = "GeminiNanoModel" + + suspend fun create(context: Context): GeminiNanoModel = withContext(Dispatchers.IO) { + // Check if Gemini Nano is available on this device + val model = GenerativeModel.newBuilder() + .setContext(context) + .build() + + // Verify feature is available — will throw if not supported + suspendCancellableCoroutine { continuation -> + model.isAvailable() + .addOnSuccessListener { available -> + if (available) { + continuation.resume(Unit) + } else { + continuation.resumeWithException( + OnDeviceModel.InferenceException( + "Gemini Nano is not available on this device" + ) + ) + } + } + .addOnFailureListener { e -> + continuation.resumeWithException( + OnDeviceModel.InferenceException( + "Failed to check Gemini Nano availability: ${e.message}", e + ) + ) + } + } + + // Trigger model download if needed + suspendCancellableCoroutine { continuation -> + model.downloadModel() + .addOnSuccessListener { continuation.resume(Unit) } + .addOnFailureListener { e -> + Log.w(TAG, "Model download issue (may already be available): ${e.message}") + // Don't fail — model might already be cached + continuation.resume(Unit) + } + } + + Log.i(TAG, "Gemini Nano model ready via ML Kit Prompt API") + GeminiNanoModel(model) + } + } +} diff --git a/app/src/main/java/com/pixel10/ai/inference/MediaPipeModel.kt b/app/src/main/java/com/pixel10/ai/inference/MediaPipeModel.kt new file mode 100644 index 0000000..6637e97 --- /dev/null +++ b/app/src/main/java/com/pixel10/ai/inference/MediaPipeModel.kt @@ -0,0 +1,149 @@ +package com.pixel10.ai.inference + +import android.content.Context +import android.util.Log +import com.google.mediapipe.tasks.genai.llminference.LlmInference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.io.File +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * MediaPipe LLM Inference backend for custom open-weight models. + * + * Use this to run models like Gemma 3n E2B, Gemma 2B, or other compatible + * LLMs that you download and place on the device yourself. + * + * On Pixel 10, MediaPipe automatically leverages the Tensor G5 GPU/NPU + * for accelerated inference. + * + * To use: + * 1. Download a compatible model (e.g. gemma-3n-E2B.task) + * 2. Push to device: adb push model.task /data/local/tmp/llm/ + * or copy to app files dir via the app + */ +class MediaPipeModel private constructor( + private val llmInference: LlmInference, + private val modelName: String +) : OnDeviceModel { + + override val backendName = "MediaPipe ($modelName)" + + @Volatile + override var isReady: Boolean = true + private set + + override suspend fun generate( + prompt: String, + maxTokens: Int, + temperature: Float + ): String = withContext(Dispatchers.Default) { + try { + llmInference.generateResponse(prompt) + } catch (e: Exception) { + Log.e(TAG, "MediaPipe inference error", e) + throw OnDeviceModel.InferenceException("Generation failed: ${e.message}", e) + } + } + + override suspend fun generateStreaming( + prompt: String, + onToken: (String) -> Unit + ): String = withContext(Dispatchers.Default) { + suspendCancellableCoroutine { continuation -> + val fullResponse = StringBuilder() + try { + llmInference.generateResponseAsync(prompt).addResultListener { partialResult, done -> + val chunk = partialResult ?: "" + fullResponse.append(chunk) + onToken(chunk) + if (done) { + continuation.resume(fullResponse.toString()) + } + } + } catch (e: Exception) { + Log.e(TAG, "Streaming error", e) + continuation.resumeWithException( + OnDeviceModel.InferenceException("Streaming failed: ${e.message}", e) + ) + } + } + } + + override fun close() { + isReady = false + llmInference.close() + } + + companion object { + private const val TAG = "MediaPipeModel" + + private val MODEL_FILENAMES = listOf( + "gemma-3n-E2B.task", + "gemma-3n-E4B.task", + "gemma-2b-it-gpu-int4.bin", + "gemini-nano.bin", + "model.bin" + ) + + suspend fun create(context: Context): MediaPipeModel = withContext(Dispatchers.IO) { + val modelPath = findModelPath(context) + ?: throw OnDeviceModel.InferenceException( + "No MediaPipe model file found.\n" + + "Place a compatible .bin or .task file in:\n" + + " ${context.filesDir.absolutePath}/\n" + + "Supported: ${MODEL_FILENAMES.joinToString()}" + ) + + val modelName = File(modelPath).name + Log.i(TAG, "Loading MediaPipe model: $modelPath") + + try { + val options = LlmInference.LlmInferenceOptions.builder() + .setModelPath(modelPath) + .setMaxTokens(2048) + .setTopK(40) + .setTemperature(0.7f) + .setRandomSeed(42) + .build() + + val inference = LlmInference.createFromOptions(context, options) + Log.i(TAG, "MediaPipe model loaded: $modelName") + MediaPipeModel(inference, modelName) + } catch (e: Exception) { + throw OnDeviceModel.InferenceException( + "Failed to load MediaPipe model from $modelPath: ${e.message}", e + ) + } + } + + private fun findModelPath(context: Context): String? { + // Search standard locations + val searchDirs = listOfNotNull( + context.filesDir, + File(context.filesDir, "models"), + context.getExternalFilesDir(null), + File("/data/local/tmp/llm") + ) + + for (dir in searchDirs) { + if (!dir.exists()) continue + for (name in MODEL_FILENAMES) { + val file = File(dir, name) + if (file.exists()) { + Log.i(TAG, "Found model: ${file.absolutePath}") + return file.absolutePath + } + } + // Also check for any .task or .bin file + dir.listFiles()?.firstOrNull { + it.extension in listOf("task", "bin", "tflite") + }?.let { return it.absolutePath } + } + + return null + } + } +} diff --git a/app/src/main/java/com/pixel10/ai/inference/OnDeviceModel.kt b/app/src/main/java/com/pixel10/ai/inference/OnDeviceModel.kt new file mode 100644 index 0000000..0489430 --- /dev/null +++ b/app/src/main/java/com/pixel10/ai/inference/OnDeviceModel.kt @@ -0,0 +1,82 @@ +package com.pixel10.ai.inference + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Unified interface for on-device AI inference on the Pixel 10. + * + * Supports two backends: + * 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.) + * 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. + */ +interface OnDeviceModel { + val backendName: String + val isReady: Boolean + + suspend fun generate( + prompt: String, + maxTokens: Int = 1024, + temperature: Float = 0.7f + ): String + + suspend fun generateStreaming( + prompt: String, + onToken: (String) -> Unit + ): String + + fun close() + + companion object { + private const val TAG = "OnDeviceModel" + + /** + * Create the best available on-device model. + * Tries Gemini Nano (AICore) first, falls back to MediaPipe. + */ + 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}") + } + + // 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: 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" + ) + } + } + + class InferenceException(message: String, cause: Throwable? = null) : + Exception(message, cause) +} diff --git a/app/src/main/java/com/pixel10/ai/server/AIApiServer.kt b/app/src/main/java/com/pixel10/ai/server/AIApiServer.kt new file mode 100644 index 0000000..ecc8d6c --- /dev/null +++ b/app/src/main/java/com/pixel10/ai/server/AIApiServer.kt @@ -0,0 +1,254 @@ +package com.pixel10.ai.server + +import android.os.Build +import android.util.Log +import com.google.gson.Gson +import com.pixel10.ai.inference.OnDeviceModel +import fi.iki.elonen.NanoHTTPD +import kotlinx.coroutines.runBlocking +import java.util.UUID +import java.util.concurrent.atomic.AtomicLong + +/** + * Embedded HTTP server that exposes the on-device AI model as a REST API. + * + * Provides OpenAI-compatible endpoints so existing tools (curl, Python openai + * library, etc.) can talk to this phone as if it were a cloud AI endpoint. + * + * Usage from any device on the same network: + * curl http://:8080/v1/chat/completions \ + * -H "Content-Type: application/json" \ + * -d '{"messages":[{"role":"user","content":"Hello!"}]}' + */ +class AIApiServer( + port: Int, + private val model: OnDeviceModel +) : NanoHTTPD(port) { + + private val gson = Gson() + private val startTime = System.currentTimeMillis() + val requestCount = AtomicLong(0) + + var onRequestLogged: ((String) -> Unit)? = null + + override fun serve(session: IHTTPSession): Response { + val method = session.method + val uri = session.uri + val count = requestCount.incrementAndGet() + + log("[$count] ${method.name} $uri") + + return try { + // Add CORS headers to all responses + when { + method == Method.OPTIONS -> corsPreflightResponse() + uri == "/" || uri == "/health" -> handleHealth() + uri == "/v1/models" && method == Method.GET -> handleModels() + uri == "/v1/chat/completions" && method == Method.POST -> handleChatCompletions(session) + uri == "/v1/completions" && method == Method.POST -> handleCompletions(session) + else -> errorResponse(404, "Not found: $uri") + }.also { addCorsHeaders(it) } + } catch (e: Exception) { + Log.e(TAG, "Request error", e) + log("ERROR: ${e.message}") + errorResponse(500, "Internal server error: ${e.message}") + .also { addCorsHeaders(it) } + } + } + + // ── Endpoint Handlers ────────────────────────────────────────────── + + private fun handleHealth(): Response { + val status = ServerStatus( + status = if (model.isReady) "ready" else "model_not_loaded", + model = model.backendName, + device = "${Build.MANUFACTURER} ${Build.MODEL} (${Build.SOC_MODEL})", + uptime_seconds = (System.currentTimeMillis() - startTime) / 1000, + requests_served = requestCount.get() + ) + return jsonResponse(200, gson.toJson(status)) + } + + private fun handleModels(): Response { + return jsonResponse(200, gson.toJson(ModelList())) + } + + private fun handleChatCompletions(session: IHTTPSession): Response { + val body = readBody(session) + val request = gson.fromJson(body, ChatRequest::class.java) + + if (request.messages.isEmpty()) { + return errorResponse(400, "messages array is required and must not be empty") + } + + // Build a prompt from the chat messages + val prompt = buildChatPrompt(request.messages) + + log("Chat prompt (${request.messages.size} messages, ${prompt.length} chars)") + + if (request.stream) { + return handleStreamingResponse(prompt, request) + } + + // Synchronous generation + val responseText = runBlocking { + model.generate(prompt, request.max_tokens, request.temperature) + } + + log("Response: ${responseText.take(80)}...") + + val chatResponse = ChatResponse( + id = "chatcmpl-${UUID.randomUUID().toString().take(8)}", + choices = listOf( + Choice( + message = Message(role = "assistant", content = responseText) + ) + ), + usage = Usage( + prompt_tokens = estimateTokens(prompt), + completion_tokens = estimateTokens(responseText), + total_tokens = estimateTokens(prompt) + estimateTokens(responseText) + ) + ) + + return jsonResponse(200, gson.toJson(chatResponse)) + } + + private fun handleCompletions(session: IHTTPSession): Response { + val body = readBody(session) + val request = gson.fromJson(body, ChatRequest::class.java) + + val prompt = request.prompt + ?: request.messages.lastOrNull()?.content + ?: return errorResponse(400, "prompt or messages is required") + + log("Completion prompt (${prompt.length} chars)") + + val responseText = runBlocking { + model.generate(prompt, request.max_tokens, request.temperature) + } + + log("Response: ${responseText.take(80)}...") + + val chatResponse = ChatResponse( + id = "cmpl-${UUID.randomUUID().toString().take(8)}", + choices = listOf( + Choice( + message = Message(role = "assistant", content = responseText) + ) + ), + usage = Usage( + prompt_tokens = estimateTokens(prompt), + completion_tokens = estimateTokens(responseText), + total_tokens = estimateTokens(prompt) + estimateTokens(responseText) + ) + ) + + return jsonResponse(200, gson.toJson(chatResponse)) + } + + private fun handleStreamingResponse(prompt: String, request: ChatRequest): Response { + val id = "chatcmpl-${UUID.randomUUID().toString().take(8)}" + + // For streaming, collect all tokens then return as SSE-formatted response. + // NanoHTTPD doesn't natively support chunked streaming in a clean way, + // so we buffer and return the full SSE payload. + val sseBuilder = StringBuilder() + + // Initial role chunk + val roleChunk = StreamChunk( + id = id, + choices = listOf(StreamChoice(delta = Delta(role = "assistant"))) + ) + sseBuilder.append("data: ${gson.toJson(roleChunk)}\n\n") + + val fullResponse = runBlocking { + model.generateStreaming(prompt) { token -> + val chunk = StreamChunk( + id = id, + choices = listOf(StreamChoice(delta = Delta(content = token))) + ) + sseBuilder.append("data: ${gson.toJson(chunk)}\n\n") + } + } + + // Final done chunk + val doneChunk = StreamChunk( + id = id, + choices = listOf(StreamChoice(delta = Delta(), finish_reason = "stop")) + ) + sseBuilder.append("data: ${gson.toJson(doneChunk)}\n\n") + sseBuilder.append("data: [DONE]\n\n") + + log("Streamed response: ${fullResponse.take(80)}...") + + return newFixedLengthResponse( + Response.Status.OK, + "text/event-stream", + sseBuilder.toString() + ) + } + + // ── Helpers ───────────────────────────────────────────────────────── + + private fun buildChatPrompt(messages: List): String { + val sb = StringBuilder() + for (msg in messages) { + when (msg.role) { + "system" -> sb.append("System: ${msg.content}\n\n") + "user" -> sb.append("User: ${msg.content}\n\n") + "assistant" -> sb.append("Assistant: ${msg.content}\n\n") + } + } + sb.append("Assistant: ") + return sb.toString() + } + + private fun readBody(session: IHTTPSession): String { + val contentLength = session.headers["content-length"]?.toIntOrNull() ?: 0 + val buffer = ByteArray(contentLength) + session.inputStream.read(buffer, 0, contentLength) + return String(buffer) + } + + private fun estimateTokens(text: String): Int { + // Rough estimate: ~4 characters per token + return (text.length / 4).coerceAtLeast(1) + } + + private fun jsonResponse(statusCode: Int, json: String): Response { + val status = when (statusCode) { + 200 -> Response.Status.OK + 400 -> Response.Status.BAD_REQUEST + 404 -> Response.Status.NOT_FOUND + else -> Response.Status.INTERNAL_ERROR + } + return newFixedLengthResponse(status, "application/json", json) + } + + private fun errorResponse(statusCode: Int, message: String): Response { + val error = ErrorResponse( + ErrorDetail(message = message, code = statusCode) + ) + return jsonResponse(statusCode, gson.toJson(error)) + } + + private fun corsPreflightResponse(): Response { + return newFixedLengthResponse(Response.Status.OK, MIME_PLAINTEXT, "") + } + + private fun addCorsHeaders(response: Response) { + response.addHeader("Access-Control-Allow-Origin", "*") + response.addHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + response.addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization") + } + + private fun log(message: String) { + Log.d(TAG, message) + onRequestLogged?.invoke(message) + } + + companion object { + private const val TAG = "AIApiServer" + } +} diff --git a/app/src/main/java/com/pixel10/ai/server/ApiModels.kt b/app/src/main/java/com/pixel10/ai/server/ApiModels.kt new file mode 100644 index 0000000..1f542d0 --- /dev/null +++ b/app/src/main/java/com/pixel10/ai/server/ApiModels.kt @@ -0,0 +1,91 @@ +package com.pixel10.ai.server + +/** + * Request/response models for the AI API. + * Follows an OpenAI-compatible schema for easy integration. + */ + +data class ChatRequest( + val messages: List = emptyList(), + val prompt: String? = null, + val max_tokens: Int = 1024, + val temperature: Float = 0.7f, + val stream: Boolean = false +) + +data class Message( + val role: String = "user", + val content: String = "" +) + +data class ChatResponse( + val id: String, + val model: String = "pixel10-on-device", + val choices: List, + val usage: Usage +) + +data class Choice( + val index: Int = 0, + val message: Message, + val finish_reason: String = "stop" +) + +data class Usage( + val prompt_tokens: Int, + val completion_tokens: Int, + val total_tokens: Int +) + +data class StreamChunk( + val id: String, + val model: String = "pixel10-on-device", + val choices: List +) + +data class StreamChoice( + val index: Int = 0, + val delta: Delta, + val finish_reason: String? = null +) + +data class Delta( + val role: String? = null, + val content: String? = null +) + +data class ModelInfo( + val id: String = "pixel10-on-device", + val object_type: String = "model", + val owned_by: String = "local-device", + val description: String = "On-device AI model running on Pixel 10 Tensor G5 chip" +) + +data class ModelList( + val data: List = listOf(ModelInfo()) +) + +data class ErrorResponse( + val error: ErrorDetail +) + +data class ErrorDetail( + val message: String, + val type: String = "server_error", + val code: Int = 500 +) + +data class ServerStatus( + val status: String, + val model: String, + val device: String, + val uptime_seconds: Long, + val requests_served: Long, + val endpoints: List = listOf( + "POST /v1/chat/completions", + "POST /v1/completions", + "GET /v1/models", + "GET /health", + "GET /" + ) +) diff --git a/app/src/main/java/com/pixel10/ai/server/ApiServerService.kt b/app/src/main/java/com/pixel10/ai/server/ApiServerService.kt new file mode 100644 index 0000000..59bb199 --- /dev/null +++ b/app/src/main/java/com/pixel10/ai/server/ApiServerService.kt @@ -0,0 +1,165 @@ +package com.pixel10.ai.server + +import android.app.Notification +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.os.Binder +import android.os.IBinder +import android.os.PowerManager +import android.util.Log +import androidx.core.app.NotificationCompat +import com.pixel10.ai.Pixel10AIApp +import com.pixel10.ai.R +import com.pixel10.ai.inference.OnDeviceModel +import com.pixel10.ai.ui.MainActivity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * Foreground service that keeps the AI API server running even when the + * app is in the background. Shows a persistent notification with server status. + */ +class ApiServerService : Service() { + + private val binder = LocalBinder() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + + private var server: AIApiServer? = null + private var model: OnDeviceModel? = null + private var wakeLock: PowerManager.WakeLock? = null + + var onStatusChanged: ((ServerState) -> Unit)? = null + var onLog: ((String) -> Unit)? = null + + val isRunning: Boolean get() = server != null + val requestCount: Long get() = server?.requestCount?.get() ?: 0 + + inner class LocalBinder : Binder() { + val service: ApiServerService get() = this@ApiServerService + } + + override fun onBind(intent: Intent?): IBinder = binder + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_START -> { + val port = intent.getIntExtra(EXTRA_PORT, DEFAULT_PORT) + startServer(port) + } + ACTION_STOP -> stopServer() + } + return START_STICKY + } + + private fun startServer(port: Int) { + if (server != null) return + + startForeground(NOTIFICATION_ID, buildNotification(port)) + acquireWakeLock() + notifyStatus(ServerState.LOADING_MODEL) + + scope.launch { + try { + // Load the on-device AI model + notifyLog("Loading AI model...") + model = OnDeviceModel.create(applicationContext) + notifyLog("Model ready: ${model!!.backendName}") + + // Start the HTTP server + notifyLog("Starting API server on port $port...") + val apiServer = AIApiServer(port, model!!) + apiServer.onRequestLogged = { msg -> notifyLog(msg) } + apiServer.start() + server = apiServer + + notifyStatus(ServerState.RUNNING) + notifyLog("Server running on port $port") + notifyLog("Endpoints:") + notifyLog(" POST /v1/chat/completions") + notifyLog(" POST /v1/completions") + notifyLog(" GET /v1/models") + notifyLog(" GET /health") + } catch (e: Exception) { + Log.e(TAG, "Failed to start server", e) + notifyLog("ERROR: ${e.message}") + notifyStatus(ServerState.ERROR) + stopServer() + } + } + } + + fun stopServer() { + server?.stop() + server = null + model?.close() + model = null + releaseWakeLock() + notifyStatus(ServerState.STOPPED) + notifyLog("Server stopped") + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + private fun acquireWakeLock() { + val pm = getSystemService(POWER_SERVICE) as PowerManager + wakeLock = pm.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "Pixel10AI::ServerWakeLock" + ).apply { acquire(4 * 60 * 60 * 1000L) } // 4 hours max + } + + private fun releaseWakeLock() { + wakeLock?.let { + if (it.isHeld) it.release() + } + wakeLock = null + } + + private fun buildNotification(port: Int): Notification { + val pendingIntent = PendingIntent.getActivity( + this, 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Builder(this, Pixel10AIApp.CHANNEL_ID) + .setContentTitle(getString(R.string.notification_title)) + .setContentText(getString(R.string.notification_text, port)) + .setSmallIcon(android.R.drawable.ic_menu_share) + .setContentIntent(pendingIntent) + .setOngoing(true) + .build() + } + + private fun notifyStatus(state: ServerState) { + onStatusChanged?.invoke(state) + } + + private fun notifyLog(message: String) { + Log.d(TAG, message) + onLog?.invoke(message) + } + + override fun onDestroy() { + stopServer() + scope.cancel() + super.onDestroy() + } + + enum class ServerState { + STOPPED, LOADING_MODEL, RUNNING, ERROR + } + + companion object { + private const val TAG = "ApiServerService" + const val ACTION_START = "com.pixel10.ai.START_SERVER" + const val ACTION_STOP = "com.pixel10.ai.STOP_SERVER" + const val EXTRA_PORT = "port" + const val DEFAULT_PORT = 8080 + private const val NOTIFICATION_ID = 1 + } +} diff --git a/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt b/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt new file mode 100644 index 0000000..29449db --- /dev/null +++ b/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt @@ -0,0 +1,221 @@ +package com.pixel10.ai.ui + +import android.Manifest +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.content.pm.PackageManager +import android.net.wifi.WifiManager +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import com.pixel10.ai.R +import com.pixel10.ai.databinding.ActivityMainBinding +import com.pixel10.ai.server.ApiServerService +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + private var service: ApiServerService? = null + private var bound = false + + private val logBuffer = StringBuilder() + + private val notificationPermission = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { /* proceed regardless */ } + + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + val localBinder = binder as ApiServerService.LocalBinder + service = localBinder.service + bound = true + + service?.onStatusChanged = { state -> + runOnUiThread { updateStatus(state) } + } + service?.onLog = { message -> + runOnUiThread { appendLog(message) } + } + + if (service?.isRunning == true) { + updateStatus(ApiServerService.ServerState.RUNNING) + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + service = null + bound = false + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + requestNotificationPermission() + + binding.btnToggle.setOnClickListener { + if (service?.isRunning == true) { + stopServer() + } else { + startServer() + } + } + + 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") + } + + override fun onStart() { + super.onStart() + Intent(this, ApiServerService::class.java).also { intent -> + bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) + } + } + + override fun onStop() { + super.onStop() + if (bound) { + service?.onStatusChanged = null + service?.onLog = null + unbindService(serviceConnection) + bound = false + } + } + + private fun requestNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission( + this, Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + } + + 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), + serviceConnection, + Context.BIND_AUTO_CREATE + ) + } + + updateStatus(ApiServerService.ServerState.LOADING_MODEL) + } + + private fun stopServer() { + service?.stopServer() + updateStatus(ApiServerService.ServerState.STOPPED) + } + + private fun updateStatus(state: ApiServerService.ServerState) { + when (state) { + ApiServerService.ServerState.STOPPED -> { + binding.tvServerStatus.text = getString(R.string.server_status_stopped) + binding.viewStatusDot.setBackgroundColor(getColor(R.color.status_stopped)) + binding.tvServerUrl.text = "http://—" + 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.setBackgroundColor(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() + val ip = getLocalIpAddress() + binding.tvServerStatus.text = getString(R.string.server_status_running) + binding.viewStatusDot.setBackgroundColor(getColor(R.color.status_running)) + binding.tvServerUrl.text = "http://$ip:$port" + 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) + binding.viewStatusDot.setBackgroundColor(getColor(R.color.error)) + binding.tvModelStatus.text = getString(R.string.model_error) + binding.btnToggle.text = getString(R.string.btn_start) + binding.btnToggle.isEnabled = true + binding.etPort.isEnabled = true + } + } + } + + private fun appendLog(message: String) { + 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}" + } + } + + @Suppress("DEPRECATION") + private fun getLocalIpAddress(): String { + try { + val wifiManager = applicationContext.getSystemService(WIFI_SERVICE) as WifiManager + val ip = wifiManager.connectionInfo.ipAddress + if (ip != 0) { + return "${ip and 0xFF}.${ip shr 8 and 0xFF}.${ip shr 16 and 0xFF}.${ip shr 24 and 0xFF}" + } + } catch (_: Exception) {} + + // Fallback: iterate network interfaces + try { + val interfaces = java.net.NetworkInterface.getNetworkInterfaces() + while (interfaces.hasMoreElements()) { + val iface = interfaces.nextElement() + val addresses = iface.inetAddresses + while (addresses.hasMoreElements()) { + val addr = addresses.nextElement() + if (!addr.isLoopbackAddress && addr is java.net.Inet4Address) { + return addr.hostAddress ?: "0.0.0.0" + } + } + } + } catch (_: Exception) {} + + return "0.0.0.0" + } +} diff --git a/app/src/main/res/drawable/status_dot.xml b/app/src/main/res/drawable/status_dot.xml new file mode 100644 index 0000000..2e3266f --- /dev/null +++ b/app/src/main/res/drawable/status_dot.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b530c65 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.xml b/app/src/main/res/mipmap-hdpi/ic_launcher.xml new file mode 100644 index 0000000..3252005 --- /dev/null +++ b/app/src/main/res/mipmap-hdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..e614f4c --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,15 @@ + + + #1A73E8 + #D2E3FC + #FFFFFF + #34A853 + #0F0F0F + #E8EAED + #1A1A2E + #EA4335 + #34A853 + #9AA0A6 + #0D1117 + #8B949E + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..ab2e69f --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,17 @@ + + + Pixel10 AI Server + Server Stopped + Starting Server… + Server Running + Server Error + Start Server + Stop Server + Loading AI model… + AI model ready + AI model failed to load + AI Server + Pixel10 AI API Server status + Pixel10 AI Server + Serving AI inference on port %d + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..8e0a384 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..2439f15 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,4 @@ + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..c7ad754 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..8f2e28c --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..09523c0 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..99b2eb6 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolution { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Pixel10AI" +include(":app")