Add Pixel10 AI Server — expose on-device AI chip as REST API
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
This commit is contained in:
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -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
|
||||
142
README.md
Normal file
142
README.md
Normal file
@@ -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://<phone-ip>: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://<phone-ip>:8080/v1/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"prompt": "Explain quantum computing in simple terms"}'
|
||||
|
||||
# Health check
|
||||
curl http://<phone-ip>:8080/health
|
||||
|
||||
# List models
|
||||
curl http://<phone-ip>:8080/v1/models
|
||||
```
|
||||
|
||||
### Use with Python OpenAI Library
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="http://<phone-ip>: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 <repo-url>
|
||||
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
|
||||
65
app/build.gradle.kts
Normal file
65
app/build.gradle.kts
Normal file
@@ -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")
|
||||
}
|
||||
10
app/proguard-rules.pro
vendored
Normal file
10
app/proguard-rules.pro
vendored
Normal file
@@ -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.** { *; }
|
||||
54
app/src/main/AndroidManifest.xml
Normal file
54
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Network permissions for serving API -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<!-- Foreground service to keep server alive -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Wake lock to prevent CPU sleep while serving -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- Require device AI features -->
|
||||
<uses-feature android:name="android.hardware.npu" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".Pixel10AIApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:theme="@style/Theme.Pixel10AI">
|
||||
|
||||
<activity
|
||||
android:name=".ui.MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".server.ApiServerService"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:exported="false">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="on_device_ai_inference_server" />
|
||||
</service>
|
||||
|
||||
<!-- Opt-in to on-device Gemini Nano model download -->
|
||||
<meta-data
|
||||
android:name="com.google.android.aicore.BUNDLED_MODEL"
|
||||
android:value="gemini_nano" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
29
app/src/main/java/com/pixel10/ai/Pixel10AIApp.kt
Normal file
29
app/src/main/java/com/pixel10/ai/Pixel10AIApp.kt
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
125
app/src/main/java/com/pixel10/ai/inference/GeminiNanoModel.kt
Normal file
125
app/src/main/java/com/pixel10/ai/inference/GeminiNanoModel.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
149
app/src/main/java/com/pixel10/ai/inference/MediaPipeModel.kt
Normal file
149
app/src/main/java/com/pixel10/ai/inference/MediaPipeModel.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
82
app/src/main/java/com/pixel10/ai/inference/OnDeviceModel.kt
Normal file
82
app/src/main/java/com/pixel10/ai/inference/OnDeviceModel.kt
Normal file
@@ -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)
|
||||
}
|
||||
254
app/src/main/java/com/pixel10/ai/server/AIApiServer.kt
Normal file
254
app/src/main/java/com/pixel10/ai/server/AIApiServer.kt
Normal file
@@ -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://<phone-ip>: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<Message>): 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"
|
||||
}
|
||||
}
|
||||
91
app/src/main/java/com/pixel10/ai/server/ApiModels.kt
Normal file
91
app/src/main/java/com/pixel10/ai/server/ApiModels.kt
Normal file
@@ -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<Message> = 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<Choice>,
|
||||
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<StreamChoice>
|
||||
)
|
||||
|
||||
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<ModelInfo> = 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<String> = listOf(
|
||||
"POST /v1/chat/completions",
|
||||
"POST /v1/completions",
|
||||
"GET /v1/models",
|
||||
"GET /health",
|
||||
"GET /"
|
||||
)
|
||||
)
|
||||
165
app/src/main/java/com/pixel10/ai/server/ApiServerService.kt
Normal file
165
app/src/main/java/com/pixel10/ai/server/ApiServerService.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
221
app/src/main/java/com/pixel10/ai/ui/MainActivity.kt
Normal file
221
app/src/main/java/com/pixel10/ai/ui/MainActivity.kt
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
6
app/src/main/res/drawable/status_dot.xml
Normal file
6
app/src/main/res/drawable/status_dot.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/status_stopped" />
|
||||
<size android:width="12dp" android:height="12dp" />
|
||||
</shape>
|
||||
189
app/src/main/res/layout/activity_main.xml
Normal file
189
app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,189 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/surface"
|
||||
android:padding="24dp">
|
||||
|
||||
<!-- Title -->
|
||||
<TextView
|
||||
android:id="@+id/tvTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Pixel10 AI Server"
|
||||
android:textColor="@color/on_surface"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginTop="16dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSubtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="On-device AI inference API"
|
||||
android:textColor="@color/log_text"
|
||||
android:textSize="14sp"
|
||||
android:layout_marginTop="4dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/tvTitle"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<!-- Status Card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardStatus"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
app:cardBackgroundColor="@color/surface_variant"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="0dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/tvSubtitle"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<View
|
||||
android:id="@+id/viewStatusDot"
|
||||
android:layout_width="12dp"
|
||||
android:layout_height="12dp"
|
||||
android:background="@drawable/status_dot" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvServerStatus"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_status_stopped"
|
||||
android:textColor="@color/on_surface"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginStart="12dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvServerUrl"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="http://—"
|
||||
android:textColor="@color/log_text"
|
||||
android:textSize="14sp"
|
||||
android:fontFamily="monospace"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textIsSelectable="true" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvModelStatus"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Model: not loaded"
|
||||
android:textColor="@color/log_text"
|
||||
android:textSize="13sp"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvRequestCount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Requests served: 0"
|
||||
android:textColor="@color/log_text"
|
||||
android:textSize="13sp"
|
||||
android:layout_marginTop="2dp" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Port Config -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutPort"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginTop="16dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/cardStatus"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Port:"
|
||||
android:textColor="@color/on_surface"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etPort"
|
||||
android:layout_width="100dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="12dp"
|
||||
android:text="8080"
|
||||
android:inputType="number"
|
||||
android:textColor="@color/on_surface"
|
||||
android:backgroundTint="@color/primary"
|
||||
android:fontFamily="monospace"
|
||||
android:textSize="16sp"
|
||||
android:gravity="center" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Start/Stop Button -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnToggle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="56dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="@string/btn_start"
|
||||
android:textSize="16sp"
|
||||
app:cornerRadius="12dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/layoutPort"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<!-- Log Output -->
|
||||
<TextView
|
||||
android:id="@+id/tvLogLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Request Log"
|
||||
android:textColor="@color/on_surface"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginTop="20dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/btnToggle"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/scrollLog"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:background="@color/log_bg"
|
||||
android:padding="12dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/tvLogLabel"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvLog"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/log_text"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:textIsSelectable="true" />
|
||||
</ScrollView>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
5
app/src/main/res/mipmap-hdpi/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-hdpi/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/primary" />
|
||||
<foreground android:drawable="@color/on_primary" />
|
||||
</adaptive-icon>
|
||||
15
app/src/main/res/values/colors.xml
Normal file
15
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="primary">#1A73E8</color>
|
||||
<color name="primary_container">#D2E3FC</color>
|
||||
<color name="on_primary">#FFFFFF</color>
|
||||
<color name="secondary">#34A853</color>
|
||||
<color name="surface">#0F0F0F</color>
|
||||
<color name="on_surface">#E8EAED</color>
|
||||
<color name="surface_variant">#1A1A2E</color>
|
||||
<color name="error">#EA4335</color>
|
||||
<color name="status_running">#34A853</color>
|
||||
<color name="status_stopped">#9AA0A6</color>
|
||||
<color name="log_bg">#0D1117</color>
|
||||
<color name="log_text">#8B949E</color>
|
||||
</resources>
|
||||
17
app/src/main/res/values/strings.xml
Normal file
17
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Pixel10 AI Server</string>
|
||||
<string name="server_status_stopped">Server Stopped</string>
|
||||
<string name="server_status_starting">Starting Server…</string>
|
||||
<string name="server_status_running">Server Running</string>
|
||||
<string name="server_status_error">Server Error</string>
|
||||
<string name="btn_start">Start Server</string>
|
||||
<string name="btn_stop">Stop Server</string>
|
||||
<string name="model_loading">Loading AI model…</string>
|
||||
<string name="model_ready">AI model ready</string>
|
||||
<string name="model_error">AI model failed to load</string>
|
||||
<string name="notification_channel_name">AI Server</string>
|
||||
<string name="notification_channel_desc">Pixel10 AI API Server status</string>
|
||||
<string name="notification_title">Pixel10 AI Server</string>
|
||||
<string name="notification_text">Serving AI inference on port %d</string>
|
||||
</resources>
|
||||
11
app/src/main/res/values/styles.xml
Normal file
11
app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Pixel10AI" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="colorPrimary">@color/primary</item>
|
||||
<item name="colorPrimaryContainer">@color/primary_container</item>
|
||||
<item name="colorOnPrimary">@color/on_primary</item>
|
||||
<item name="colorSecondary">@color/secondary</item>
|
||||
<item name="android:statusBarColor">@color/surface</item>
|
||||
<item name="android:navigationBarColor">@color/surface</item>
|
||||
</style>
|
||||
</resources>
|
||||
4
app/src/main/res/xml/network_security_config.xml
Normal file
4
app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
</network-security-config>
|
||||
4
build.gradle.kts
Normal file
4
build.gradle.kts
Normal file
@@ -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
|
||||
}
|
||||
4
gradle.properties
Normal file
4
gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -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
|
||||
17
settings.gradle.kts
Normal file
17
settings.gradle.kts
Normal file
@@ -0,0 +1,17 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolution {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "Pixel10AI"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user