Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9bfd76e00 | ||
|
|
af84d8ebc4 | ||
|
|
0861ed38fc | ||
|
|
371bd55e18 |
@@ -51,9 +51,12 @@ dependencies {
|
||||
// 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.)
|
||||
// MediaPipe LLM Inference — legacy fallback for .task/.bin models
|
||||
implementation("com.google.mediapipe:tasks-genai:0.10.24")
|
||||
|
||||
// LiteRT-LM — primary backend for Gemma 3n .litertlm models
|
||||
implementation("com.google.ai.edge.litertlm:litertlm-android:0.9.0-alpha05")
|
||||
|
||||
// Embedded HTTP server
|
||||
implementation("org.nanohttpd:nanohttpd:2.3.1")
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.pixel10.ai.inference
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.google.ai.edge.litertlm.Backend
|
||||
import com.google.ai.edge.litertlm.Engine
|
||||
import com.google.ai.edge.litertlm.EngineConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* LiteRT-LM backend for Gemma 3n models (.litertlm format).
|
||||
*
|
||||
* This replaces MediaPipe for the newer Gemma 3n E4B/E2B models which use
|
||||
* the LiteRT-LM runtime. Runs fully on-device using the Tensor G5 GPU.
|
||||
*
|
||||
* Model files must be placed in the app's files directory (see [ModelDownloader]).
|
||||
*/
|
||||
class LiteRTModel private constructor(
|
||||
private val engine: Engine,
|
||||
private val modelName: String
|
||||
) : OnDeviceModel {
|
||||
|
||||
override val backendName = "LiteRT-LM ($modelName)"
|
||||
|
||||
@Volatile
|
||||
override var isReady: Boolean = true
|
||||
private set
|
||||
|
||||
override suspend fun generate(
|
||||
prompt: String,
|
||||
maxTokens: Int,
|
||||
temperature: Float
|
||||
): String = withContext(Dispatchers.Default) {
|
||||
val conversation = engine.createConversation()
|
||||
try {
|
||||
conversation.sendMessage(prompt).toString()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "LiteRT inference error", e)
|
||||
throw OnDeviceModel.InferenceException("Generation failed: ${e.message}", e)
|
||||
} finally {
|
||||
conversation.close()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun generateStreaming(
|
||||
prompt: String,
|
||||
onToken: (String) -> Unit
|
||||
): String = withContext(Dispatchers.Default) {
|
||||
val conversation = engine.createConversation()
|
||||
val sb = StringBuilder()
|
||||
try {
|
||||
conversation.sendMessageAsync(prompt)
|
||||
.catch { e ->
|
||||
throw OnDeviceModel.InferenceException("Streaming failed: ${e.message}", e)
|
||||
}
|
||||
.collect { message ->
|
||||
val token = message.toString()
|
||||
sb.append(token)
|
||||
onToken(token)
|
||||
}
|
||||
} finally {
|
||||
conversation.close()
|
||||
}
|
||||
sb.toString()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
isReady = false
|
||||
engine.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LiteRTModel"
|
||||
|
||||
private val MODEL_EXTENSIONS = listOf("litertlm")
|
||||
|
||||
suspend fun create(context: Context): LiteRTModel = withContext(Dispatchers.IO) {
|
||||
val modelPath = findModelPath(context)
|
||||
?: throw OnDeviceModel.InferenceException(
|
||||
"No LiteRT-LM model file found.\n" +
|
||||
"Download a .litertlm model via the app or place one in:\n" +
|
||||
" ${context.filesDir.absolutePath}/"
|
||||
)
|
||||
|
||||
val modelName = File(modelPath).name
|
||||
Log.i(TAG, "Loading LiteRT-LM model: $modelPath")
|
||||
|
||||
try {
|
||||
val config = EngineConfig(
|
||||
modelPath = modelPath,
|
||||
backend = Backend.GPU
|
||||
)
|
||||
val engine = Engine(config)
|
||||
withContext(Dispatchers.Default) {
|
||||
engine.initialize()
|
||||
}
|
||||
Log.i(TAG, "LiteRT-LM model loaded: $modelName")
|
||||
LiteRTModel(engine, modelName)
|
||||
} catch (gpuError: Exception) {
|
||||
Log.w(TAG, "GPU backend failed, trying CPU: ${gpuError.message}")
|
||||
try {
|
||||
val config = EngineConfig(
|
||||
modelPath = modelPath,
|
||||
backend = Backend.CPU
|
||||
)
|
||||
val engine = Engine(config)
|
||||
withContext(Dispatchers.Default) {
|
||||
engine.initialize()
|
||||
}
|
||||
Log.i(TAG, "LiteRT-LM model loaded on CPU: $modelName")
|
||||
LiteRTModel(engine, modelName)
|
||||
} catch (e: Exception) {
|
||||
throw OnDeviceModel.InferenceException(
|
||||
"Failed to load LiteRT-LM model from $modelPath: ${e.message}", e
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findModelPath(context: Context): String? {
|
||||
val searchDirs = listOfNotNull(
|
||||
context.filesDir,
|
||||
File(context.filesDir, "models"),
|
||||
context.getExternalFilesDir(null)
|
||||
)
|
||||
for (dir in searchDirs) {
|
||||
if (!dir.exists()) continue
|
||||
dir.listFiles()?.firstOrNull { it.extension in MODEL_EXTENSIONS }
|
||||
?.let { return it.absolutePath }
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.pixel10.ai.inference
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Downloads a MediaPipe-compatible model for background-safe inference.
|
||||
*
|
||||
* All models are hosted on HuggingFace and require a free API token.
|
||||
* Get one at: https://huggingface.co/settings/tokens
|
||||
*
|
||||
* Models use the MediaPipe `.task` format, compatible with [MediaPipeModel].
|
||||
* Gemma 3n E4B/E2B (`.litertlm` format) requires a runtime upgrade — coming later.
|
||||
*/
|
||||
object ModelDownloader {
|
||||
|
||||
private const val TAG = "ModelDownloader"
|
||||
private const val HF_BASE = "https://huggingface.co"
|
||||
|
||||
/** Available model specs downloadable from HuggingFace (requires token + license acceptance). */
|
||||
enum class ModelSpec(
|
||||
val displayName: String,
|
||||
val filename: String,
|
||||
val repo: String,
|
||||
val sizeMb: Int,
|
||||
val description: String
|
||||
) {
|
||||
/**
|
||||
* Gemma 3n E4B INT4 — best quality, Tensor G5 optimised, background-safe.
|
||||
* Accept license at: https://huggingface.co/google/gemma-3n-E4B-it-litert-lm
|
||||
*/
|
||||
GEMMA_3N_E4B(
|
||||
displayName = "Gemma 3n E4B",
|
||||
filename = "gemma-3n-E4B-it-int4.litertlm",
|
||||
repo = "google/gemma-3n-E4B-it-litert-lm",
|
||||
sizeMb = 4920,
|
||||
description = "Best quality — Tensor G5 optimised (~4.9 GB)"
|
||||
),
|
||||
/**
|
||||
* Gemma 3n E4B Web INT4 — smaller variant, slightly lower quality.
|
||||
* Same license as above.
|
||||
*/
|
||||
GEMMA_3N_E4B_WEB(
|
||||
displayName = "Gemma 3n E4B (Web)",
|
||||
filename = "gemma-3n-E4B-it-int4-Web.litertlm",
|
||||
repo = "google/gemma-3n-E4B-it-litert-lm",
|
||||
sizeMb = 4280,
|
||||
description = "Slightly smaller variant (~4.3 GB)"
|
||||
)
|
||||
}
|
||||
|
||||
data class Progress(
|
||||
val downloadedBytes: Long,
|
||||
val totalBytes: Long,
|
||||
val percent: Int = if (totalBytes > 0) (downloadedBytes * 100 / totalBytes).toInt() else 0
|
||||
)
|
||||
|
||||
/** Returns true if any supported model is present in the app's files directory. */
|
||||
fun isModelPresent(context: Context): Boolean =
|
||||
ModelSpec.values().any { modelFile(context, it).let { f -> f.exists() && f.length() > 1_000_000L } }
|
||||
|
||||
/** Returns the installed [ModelSpec], or null if no model is present. */
|
||||
fun installedSpec(context: Context): ModelSpec? =
|
||||
ModelSpec.values().firstOrNull { modelFile(context, it).let { f -> f.exists() && f.length() > 1_000_000L } }
|
||||
|
||||
fun modelFile(context: Context, spec: ModelSpec): File =
|
||||
File(context.filesDir, spec.filename)
|
||||
|
||||
/** Returns the file of the installed model, or E4B path as default. */
|
||||
fun modelFile(context: Context): File =
|
||||
installedSpec(context)?.let { modelFile(context, it) }
|
||||
?: modelFile(context, ModelSpec.GEMMA_3N_E4B)
|
||||
|
||||
/**
|
||||
* Download [spec] from HuggingFace, using [hfToken] for authentication.
|
||||
* Supports resume — if a partial file exists, continues from where it left off.
|
||||
*
|
||||
* Get a free token at https://huggingface.co/settings/tokens
|
||||
*/
|
||||
suspend fun download(
|
||||
context: Context,
|
||||
spec: ModelSpec = ModelSpec.GEMMA_3N_E4B,
|
||||
hfToken: String,
|
||||
onProgress: (Progress) -> Unit
|
||||
) = withContext(Dispatchers.IO) {
|
||||
if (hfToken.isBlank()) throw OnDeviceModel.InferenceException(
|
||||
"HuggingFace token required.\nGet a free token at huggingface.co/settings/tokens"
|
||||
)
|
||||
|
||||
val dest = modelFile(context, spec)
|
||||
val alreadyDownloaded = if (dest.exists()) dest.length() else 0L
|
||||
val downloadUrl = "$HF_BASE/${spec.repo}/resolve/main/${spec.filename}"
|
||||
|
||||
Log.i(TAG, "Download starting ${spec.displayName} from $downloadUrl (already have $alreadyDownloaded bytes)")
|
||||
|
||||
val conn = URL(downloadUrl).openConnection() as HttpURLConnection
|
||||
try {
|
||||
conn.connectTimeout = 30_000
|
||||
conn.readTimeout = 60_000
|
||||
conn.setRequestProperty("Authorization", "Bearer $hfToken")
|
||||
if (alreadyDownloaded > 0) {
|
||||
conn.setRequestProperty("Range", "bytes=$alreadyDownloaded-")
|
||||
}
|
||||
conn.connect()
|
||||
|
||||
val code = conn.responseCode
|
||||
if (code == 401 || code == 403) throw OnDeviceModel.InferenceException(
|
||||
"Authentication failed (HTTP $code).\nCheck your HuggingFace token."
|
||||
)
|
||||
val resuming = code == HttpURLConnection.HTTP_PARTIAL // 206
|
||||
if (code != HttpURLConnection.HTTP_OK && !resuming) {
|
||||
throw OnDeviceModel.InferenceException("Download failed: HTTP $code")
|
||||
}
|
||||
|
||||
val serverBytes = conn.contentLengthLong.coerceAtLeast(0L)
|
||||
val totalBytes = if (resuming) alreadyDownloaded + serverBytes else serverBytes
|
||||
|
||||
conn.inputStream.use { input ->
|
||||
FileOutputStream(dest, /* append= */ resuming).use { out ->
|
||||
val buf = ByteArray(128 * 1024)
|
||||
var written = alreadyDownloaded
|
||||
var read: Int
|
||||
while (input.read(buf).also { read = it } != -1) {
|
||||
out.write(buf, 0, read)
|
||||
written += read
|
||||
onProgress(Progress(written, totalBytes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "Download complete — ${dest.length()} bytes")
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteModel(context: Context) {
|
||||
ModelSpec.values().forEach { modelFile(context, it).delete() }
|
||||
Log.i(TAG, "All models deleted")
|
||||
}
|
||||
}
|
||||
@@ -102,38 +102,52 @@ interface OnDeviceModel {
|
||||
|
||||
/**
|
||||
* Create the best available on-device model.
|
||||
* Tries Gemini Nano (Tensor G5 TPU) first, falls back to MediaPipe.
|
||||
*
|
||||
* Priority order:
|
||||
* 1. MediaPipe (local model file) — runs in background, uses Tensor G5 GPU.
|
||||
* This is the preferred backend: no foreground restriction, no AICore dep.
|
||||
* 2. Gemini Nano (ML Kit) — foreground only (ErrorCode 30 in background).
|
||||
* Used as fallback when no MediaPipe model file is present.
|
||||
*
|
||||
* Tap "Download Model" in the app UI to get the MediaPipe model automatically.
|
||||
*/
|
||||
suspend fun create(context: Context): OnDeviceModel = withContext(Dispatchers.IO) {
|
||||
// Try Gemini Nano via ML Kit Prompt API
|
||||
// LiteRT-LM first — Gemma 3n .litertlm format, GPU-accelerated, background-safe
|
||||
try {
|
||||
Log.i(TAG, "Attempting Gemini Nano via ML Kit Prompt API...")
|
||||
Log.i(TAG, "Attempting LiteRT-LM with local .litertlm model...")
|
||||
val litert = LiteRTModel.create(context)
|
||||
Log.i(TAG, "LiteRT-LM model ready: ${litert.backendName}")
|
||||
return@withContext litert
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "LiteRT-LM not available: ${e.message}")
|
||||
}
|
||||
|
||||
// MediaPipe fallback — .task/.bin format, background-safe
|
||||
try {
|
||||
Log.i(TAG, "Attempting MediaPipe LLM with local .task model...")
|
||||
val mediapipe = MediaPipeModel.create(context)
|
||||
Log.i(TAG, "MediaPipe model ready: ${mediapipe.backendName}")
|
||||
return@withContext mediapipe
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "MediaPipe not available: ${e.message}")
|
||||
}
|
||||
|
||||
// Gemini Nano last resort — foreground only
|
||||
try {
|
||||
Log.i(TAG, "Attempting Gemini Nano via ML Kit (foreground only)...")
|
||||
val nano = GeminiNanoModel.create(context)
|
||||
Log.i(TAG, "Gemini Nano ready!")
|
||||
Log.i(TAG, "Gemini Nano ready (foreground only)")
|
||||
return@withContext nano
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Gemini Nano not available: ${e.message}")
|
||||
}
|
||||
|
||||
// Fall back to MediaPipe with a local model file
|
||||
try {
|
||||
Log.i(TAG, "Attempting MediaPipe LLM with local model...")
|
||||
val mediapipe = MediaPipeModel.create(context)
|
||||
Log.i(TAG, "MediaPipe model ready!")
|
||||
return@withContext mediapipe
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "MediaPipe model not available: ${e.message}")
|
||||
}
|
||||
|
||||
throw InferenceException(
|
||||
"No on-device AI model available.\n\n" +
|
||||
"Option 1: Use a Pixel device with Gemini Nano support " +
|
||||
"(Pixel 10/9/8 series)\n\n" +
|
||||
"Option 2: Place a MediaPipe-compatible model (.bin or .task) in:\n" +
|
||||
" ${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"
|
||||
"No model loaded yet.\n\n" +
|
||||
"Tap 'Download Model' in the app to download Gemma 3n E4B.\n" +
|
||||
"Once downloaded the server works fully in the background.\n\n" +
|
||||
"Or place a .litertlm file in:\n" +
|
||||
" ${context.filesDir.absolutePath}/"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.net.wifi.WifiManager
|
||||
@@ -15,9 +16,13 @@ import android.view.View
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.pixel10.ai.R
|
||||
import com.pixel10.ai.databinding.ActivityMainBinding
|
||||
import com.pixel10.ai.inference.ModelDownloader
|
||||
import com.pixel10.ai.inference.ModelDownloader.ModelSpec
|
||||
import com.pixel10.ai.server.ApiServerService
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
@@ -25,8 +30,10 @@ import java.util.Locale
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
private lateinit var prefs: SharedPreferences
|
||||
private var service: ApiServerService? = null
|
||||
private var bound = false
|
||||
private var downloading = false
|
||||
|
||||
private val logBuffer = StringBuilder()
|
||||
|
||||
@@ -63,23 +70,39 @@ class MainActivity : AppCompatActivity() {
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
prefs = getSharedPreferences("pixel10_prefs", MODE_PRIVATE)
|
||||
requestNotificationPermission()
|
||||
|
||||
// Restore saved HF token
|
||||
binding.etHfToken.setText(prefs.getString("hf_token", ""))
|
||||
|
||||
binding.btnToggle.setOnClickListener {
|
||||
if (service?.isRunning == true) {
|
||||
stopServer()
|
||||
} else {
|
||||
startServer()
|
||||
}
|
||||
if (service?.isRunning == true) stopServer() else startServer()
|
||||
}
|
||||
|
||||
binding.btnDownloadModel.setOnClickListener {
|
||||
saveHfToken()
|
||||
startModelDownload(ModelSpec.GEMMA_3N_E4B)
|
||||
}
|
||||
binding.btnDownloadGemma3Q8.setOnClickListener {
|
||||
saveHfToken()
|
||||
startModelDownload(ModelSpec.GEMMA_3N_E4B_WEB)
|
||||
}
|
||||
|
||||
updateModelCard()
|
||||
updateStatus(ApiServerService.ServerState.STOPPED)
|
||||
appendLog("Pixel10 AI Server ready")
|
||||
appendLog("Device: ${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
appendLog("SoC: ${Build.SOC_MODEL}")
|
||||
appendLog("Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
|
||||
appendLog("")
|
||||
appendLog("Tap 'Start Server' to begin serving AI inference")
|
||||
if (ModelDownloader.isModelPresent(this)) {
|
||||
appendLog("Model ready — server works in background")
|
||||
} else {
|
||||
appendLog("No local model found")
|
||||
appendLog("Tap 'Download Model' to enable background inference")
|
||||
appendLog("(Without it, Gemini Nano only works in foreground)")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
@@ -110,16 +133,81 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveHfToken() {
|
||||
val token = binding.etHfToken.text.toString().trim()
|
||||
prefs.edit().putString("hf_token", token).apply()
|
||||
}
|
||||
|
||||
private fun startModelDownload(spec: ModelSpec) {
|
||||
if (downloading) return
|
||||
val token = binding.etHfToken.text.toString().trim()
|
||||
if (token.isBlank()) {
|
||||
binding.tvModelDownloadStatus.text = "Enter your HuggingFace token first"
|
||||
return
|
||||
}
|
||||
downloading = true
|
||||
setDownloadButtonsEnabled(false)
|
||||
binding.progressDownload.visibility = View.VISIBLE
|
||||
binding.tvModelDownloadStatus.text = "Starting download: ${spec.displayName}…"
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
ModelDownloader.download(this@MainActivity, spec, token) { progress ->
|
||||
runOnUiThread {
|
||||
binding.progressDownload.progress = progress.percent
|
||||
val mb = progress.downloadedBytes / 1_048_576
|
||||
val total = progress.totalBytes / 1_048_576
|
||||
binding.tvModelDownloadStatus.text =
|
||||
"${spec.displayName}: ${mb}MB / ${total}MB (${progress.percent}%)"
|
||||
}
|
||||
}
|
||||
runOnUiThread {
|
||||
downloading = false
|
||||
updateModelCard()
|
||||
appendLog("${spec.displayName} downloaded — background inference enabled")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
runOnUiThread {
|
||||
downloading = false
|
||||
setDownloadButtonsEnabled(true)
|
||||
binding.progressDownload.visibility = View.GONE
|
||||
binding.tvModelDownloadStatus.text = "Download failed: ${e.message}"
|
||||
appendLog("Download error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setDownloadButtonsEnabled(enabled: Boolean) {
|
||||
binding.btnDownloadModel.isEnabled = enabled
|
||||
binding.btnDownloadGemma3Q8.isEnabled = enabled
|
||||
}
|
||||
|
||||
private fun updateModelCard() {
|
||||
val spec = ModelDownloader.installedSpec(this)
|
||||
if (spec != null) {
|
||||
binding.tvModelDownloadStatus.text = getString(R.string.model_downloaded, spec.displayName)
|
||||
binding.etHfToken.visibility = View.GONE
|
||||
binding.btnDownloadModel.visibility = View.GONE
|
||||
binding.btnDownloadGemma3Q8.visibility = View.GONE
|
||||
binding.progressDownload.visibility = View.GONE
|
||||
} else {
|
||||
binding.tvModelDownloadStatus.text = getString(R.string.model_not_downloaded)
|
||||
binding.etHfToken.visibility = View.VISIBLE
|
||||
binding.btnDownloadModel.visibility = View.VISIBLE
|
||||
binding.btnDownloadGemma3Q8.visibility = View.VISIBLE
|
||||
setDownloadButtonsEnabled(true)
|
||||
binding.progressDownload.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun startServer() {
|
||||
val port = binding.etPort.text.toString().toIntOrNull() ?: 8080
|
||||
|
||||
val intent = Intent(this, ApiServerService::class.java).apply {
|
||||
action = ApiServerService.ACTION_START
|
||||
putExtra(ApiServerService.EXTRA_PORT, port)
|
||||
}
|
||||
startForegroundService(intent)
|
||||
|
||||
// Bind if not already bound
|
||||
if (!bound) {
|
||||
bindService(
|
||||
Intent(this, ApiServerService::class.java),
|
||||
@@ -127,7 +215,6 @@ class MainActivity : AppCompatActivity() {
|
||||
Context.BIND_AUTO_CREATE
|
||||
)
|
||||
}
|
||||
|
||||
updateStatus(ApiServerService.ServerState.LOADING_MODEL)
|
||||
}
|
||||
|
||||
@@ -137,6 +224,10 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun updateStatus(state: ApiServerService.ServerState) {
|
||||
val canEdit = state == ApiServerService.ServerState.STOPPED ||
|
||||
state == ApiServerService.ServerState.ERROR
|
||||
binding.etPort.isEnabled = canEdit
|
||||
|
||||
when (state) {
|
||||
ApiServerService.ServerState.STOPPED -> {
|
||||
binding.tvServerStatus.text = getString(R.string.server_status_stopped)
|
||||
@@ -145,14 +236,12 @@ class MainActivity : AppCompatActivity() {
|
||||
binding.tvModelStatus.text = "Model: not loaded"
|
||||
binding.btnToggle.text = getString(R.string.btn_start)
|
||||
binding.btnToggle.isEnabled = true
|
||||
binding.etPort.isEnabled = true
|
||||
}
|
||||
ApiServerService.ServerState.LOADING_MODEL -> {
|
||||
binding.tvServerStatus.text = getString(R.string.server_status_starting)
|
||||
(binding.viewStatusDot.background as? GradientDrawable)?.setColor(getColor(R.color.primary))
|
||||
binding.tvModelStatus.text = getString(R.string.model_loading)
|
||||
binding.btnToggle.isEnabled = false
|
||||
binding.etPort.isEnabled = false
|
||||
}
|
||||
ApiServerService.ServerState.RUNNING -> {
|
||||
val port = binding.etPort.text.toString()
|
||||
@@ -163,7 +252,6 @@ class MainActivity : AppCompatActivity() {
|
||||
binding.tvModelStatus.text = getString(R.string.model_ready)
|
||||
binding.btnToggle.text = getString(R.string.btn_stop)
|
||||
binding.btnToggle.isEnabled = true
|
||||
binding.etPort.isEnabled = false
|
||||
}
|
||||
ApiServerService.ServerState.ERROR -> {
|
||||
binding.tvServerStatus.text = getString(R.string.server_status_error)
|
||||
@@ -171,7 +259,6 @@ class MainActivity : AppCompatActivity() {
|
||||
binding.tvModelStatus.text = getString(R.string.model_error)
|
||||
binding.btnToggle.text = getString(R.string.btn_start)
|
||||
binding.btnToggle.isEnabled = true
|
||||
binding.etPort.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,16 +267,8 @@ class MainActivity : AppCompatActivity() {
|
||||
val timestamp = SimpleDateFormat("HH:mm:ss", Locale.US).format(Date())
|
||||
logBuffer.append("[$timestamp] $message\n")
|
||||
binding.tvLog.text = logBuffer.toString()
|
||||
|
||||
// Auto-scroll to bottom
|
||||
binding.scrollLog.post {
|
||||
binding.scrollLog.fullScroll(View.FOCUS_DOWN)
|
||||
}
|
||||
|
||||
// Update request count
|
||||
service?.let {
|
||||
binding.tvRequestCount.text = "Requests served: ${it.requestCount}"
|
||||
}
|
||||
binding.scrollLog.post { binding.scrollLog.fullScroll(View.FOCUS_DOWN) }
|
||||
service?.let { binding.tvRequestCount.text = "Requests served: ${it.requestCount}" }
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -202,7 +281,6 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
// Fallback: iterate network interfaces
|
||||
try {
|
||||
val interfaces = java.net.NetworkInterface.getNetworkInterfaces()
|
||||
while (interfaces.hasMoreElements()) {
|
||||
|
||||
@@ -105,6 +105,77 @@
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Model Download Card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardModel"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
app:cardBackgroundColor="@color/surface_variant"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="0dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/cardStatus"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvModelDownloadStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/log_text"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etHfToken"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="@string/hf_token_hint"
|
||||
android:inputType="textPassword"
|
||||
android:textColor="@color/on_surface"
|
||||
android:textColorHint="@color/log_text"
|
||||
android:textSize="13sp"
|
||||
android:fontFamily="monospace"
|
||||
android:backgroundTint="@color/primary" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressDownload"
|
||||
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:max="100"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnDownloadModel"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/btn_download_gemma3_q4"
|
||||
android:textSize="13sp"
|
||||
app:cornerRadius="8dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnDownloadGemma3Q8"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/btn_download_gemma3_q8"
|
||||
android:textSize="13sp"
|
||||
app:cornerRadius="8dp" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Port Config -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutPort"
|
||||
@@ -113,7 +184,7 @@
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginTop="16dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/cardStatus"
|
||||
app:layout_constraintTop_toBottomOf="@id/cardModel"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
<string name="model_not_loaded">Model: not loaded</string>
|
||||
|
||||
<!-- Controls -->
|
||||
<string name="hf_token_hint">HuggingFace token (huggingface.co/settings/tokens)</string>
|
||||
<string name="btn_download_gemma3_q4">⭐ Gemma 3n E4B — Best quality (~4.9 GB)</string>
|
||||
<string name="btn_download_gemma3_q8">Gemma 3n E4B Web — Smaller (~4.3 GB)</string>
|
||||
<string name="model_downloaded">✓ %s ready — background inference enabled</string>
|
||||
<string name="model_not_downloaded">No local model. Enter HuggingFace token and download.</string>
|
||||
<string name="btn_start">Start Server</string>
|
||||
<string name="btn_stop">Stop Server</string>
|
||||
<string name="port_label">Port:</string>
|
||||
|
||||
Reference in New Issue
Block a user