4 Commits
Author SHA1 Message Date
alexpolo1andClaude Sonnet 4.6 0c26c836d6 Bump versionCode to 7 / versionName to 1.7.0 for proper update installs
Release / Build and Release APK (push) Failing after 10m54s
Android requires versionCode to increase for update-over-top installs.
Without this, reinstalling would say "App not installed" on some devices.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-28 23:23:40 +01:00
alexpolo1andClaude Sonnet 4.6 592550e71c Add in-app chat with streaming, settings UI, and request indicator
Release / Build and Release APK (push) Failing after 9m3s
Chat:
- ChatActivity with RecyclerView message list (user/AI bubbles)
- Streams tokens directly from LiteRT model via generateStreaming()
- Maintains full conversation history (multi-turn context)
- Respects auto_system_prompt setting from SharedPreferences
- "Chat" button in MainActivity, enabled only when server is running
- " Generating…" indicator while model is thinking
- Log on main screen unchanged — still shows all requests

Server:
- Settings card: temperature slider, max tokens, system prompt toggle
- " Processing request…" indicator in status card during inference
- onActiveRequest callback wired through service → activity

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-28 23:21:49 +01:00
alexpolo1andClaude Sonnet 4.6 baacaabf24 Fix streaming, add thread safety, settings UI, and request indicator
Release / Build and Release APK (push) Failing after 8m49s
Fixes:
- Streaming was bypassed for all requests (system prompt auto-inject made
  messages.size always > 1). Stream=true now routes directly to generateStreaming
  before multi-turn check
- LiteRTModel: add Mutex to serialize Engine calls (not thread-safe)

New features:
- Settings card: temperature slider, max tokens, agent system prompt toggle
  (all saved to SharedPreferences, applied on server start)
- Active request indicator: " Processing request…" shown in status card
  while inference is running
- onActiveRequest callback from AIApiServer → ApiServerService → MainActivity

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-28 23:17:41 +01:00
alexpolo1andClaude Sonnet 4.6 f9bfd76e00 Upgrade to LiteRT-LM runtime for Gemma 3n E4B support
Release / Build and Release APK (push) Failing after 9m46s
- Add com.google.ai.edge.litertlm:litertlm-android:0.9.0-alpha05 dependency
- New LiteRTModel backend: loads .litertlm files via Engine API with GPU backend
  (CPU fallback if GPU init fails)
- OnDeviceModel.create() priority: LiteRT-LM → MediaPipe → Gemini Nano
- ModelDownloader: updated to Gemma 3n E4B (4.9 GB) and E4B Web (4.3 GB)
  from google/gemma-3n-E4B-it-litert-lm (HuggingFace, license required)
- Download uses Bearer token auth; token saved to SharedPreferences

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-28 22:49:22 +01:00
19 changed files with 816 additions and 76 deletions
+6 -3
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.pixel10.ai"
minSdk = 31
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
versionCode = 7
versionName = "1.7.0"
}
buildTypes {
@@ -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")
+5
View File
@@ -27,6 +27,11 @@
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.Pixel10AI">
<activity
android:name=".ui.ChatActivity"
android:exported="false"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.MainActivity"
android:exported="true"
@@ -0,0 +1,146 @@
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.sync.Mutex
import kotlinx.coroutines.sync.withLock
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
// LiteRT Engine is not thread-safe — serialize all inference calls
private val mutex = Mutex()
override suspend fun generate(
prompt: String,
maxTokens: Int,
temperature: Float
): String = mutex.withLock {
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 = mutex.withLock {
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
}
}
}
@@ -23,7 +23,7 @@ object ModelDownloader {
private const val TAG = "ModelDownloader"
private const val HF_BASE = "https://huggingface.co"
/** Available model specs downloadable from HuggingFace. */
/** Available model specs downloadable from HuggingFace (requires token + license acceptance). */
enum class ModelSpec(
val displayName: String,
val filename: String,
@@ -31,21 +31,27 @@ object ModelDownloader {
val sizeMb: Int,
val description: String
) {
/** Recommended: best size/quality trade-off, runs fast on Tensor G5. */
GEMMA_3_1B_Q4(
displayName = "Gemma 3 1B IT (Q4)",
filename = "gemma3-1b-it-int4.task",
repo = "litert-community/Gemma3-1B-IT",
sizeMb = 555,
description = "Best balance — fast & capable (~555 MB)"
/**
* 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)"
),
/** Higher quality, slower. Good for complex reasoning. */
GEMMA_3_1B_Q8(
displayName = "Gemma 3 1B IT (Q8)",
filename = "gemma3-1b-it-int8-web.task",
repo = "litert-community/Gemma3-1B-IT",
sizeMb = 1010,
description = "Higher quality, slower (~1 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)"
)
}
@@ -66,10 +72,10 @@ object ModelDownloader {
fun modelFile(context: Context, spec: ModelSpec): File =
File(context.filesDir, spec.filename)
/** Legacy compat — returns the file of the installed model, or Q4 path as default. */
/** 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_3_1B_Q4)
?: modelFile(context, ModelSpec.GEMMA_3N_E4B)
/**
* Download [spec] from HuggingFace, using [hfToken] for authentication.
@@ -79,7 +85,7 @@ object ModelDownloader {
*/
suspend fun download(
context: Context,
spec: ModelSpec = ModelSpec.GEMMA_3_1B_Q4,
spec: ModelSpec = ModelSpec.GEMMA_3N_E4B,
hfToken: String,
onProgress: (Progress) -> Unit
) = withContext(Dispatchers.IO) {
@@ -112,9 +112,19 @@ interface OnDeviceModel {
* Tap "Download Model" in the app UI to get the MediaPipe model automatically.
*/
suspend fun create(context: Context): OnDeviceModel = withContext(Dispatchers.IO) {
// MediaPipe first — background-safe, GPU-accelerated via Tensor G5
// LiteRT-LM first — Gemma 3n .litertlm format, GPU-accelerated, background-safe
try {
Log.i(TAG, "Attempting MediaPipe LLM with local model...")
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
@@ -122,7 +132,7 @@ interface OnDeviceModel {
Log.w(TAG, "MediaPipe not available: ${e.message}")
}
// Gemini Nano fallback — only works when app is in foreground
// Gemini Nano last resort — foreground only
try {
Log.i(TAG, "Attempting Gemini Nano via ML Kit (foreground only)...")
val nano = GeminiNanoModel.create(context)
@@ -134,11 +144,10 @@ interface OnDeviceModel {
throw InferenceException(
"No model loaded yet.\n\n" +
"Tap 'Download Model' in the app to download Gemma 2B (~1.3 GB).\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 compatible model file in:\n" +
" ${context.filesDir.absolutePath}/\n" +
" Supported: gemma-2b-it-gpu-int4.bin, gemma-3n-E2B.task, etc."
"Or place a .litertlm file in:\n" +
" ${context.filesDir.absolutePath}/"
)
}
}
@@ -27,9 +27,16 @@ import java.util.concurrent.atomic.AtomicLong
* -H "Content-Type: application/json" \
* -d '{"messages":[{"role":"user","content":"Hello!"}]}'
*/
data class ServerConfig(
val defaultTemperature: Float = 0.7f,
val defaultMaxTokens: Int = 1024,
val autoSystemPrompt: Boolean = true
)
class AIApiServer(
port: Int,
private val model: OnDeviceModel
private val model: OnDeviceModel,
private val config: ServerConfig = ServerConfig()
) : NanoHTTPD(port) {
private val gson = Gson()
@@ -37,6 +44,7 @@ class AIApiServer(
val requestCount = AtomicLong(0)
var onRequestLogged: ((String) -> Unit)? = null
var onActiveRequest: ((Boolean) -> Unit)? = null
override fun serve(session: IHTTPSession): Response {
val method = session.method
@@ -113,31 +121,48 @@ class AIApiServer(
return errorResponse(400, "messages array is required and must not be empty")
}
// Auto-inject agent system prompt if the conversation has no system message.
// Auto-inject default tools if the request provides none.
// This makes the server zero-config as a coding agent for any OpenAI-compatible client.
val messages = if (raw.messages.none { it.role == "system" }) {
// Auto-inject agent system prompt if enabled and no system message present
val messages = if (config.autoSystemPrompt && raw.messages.none { it.role == "system" }) {
listOf(Message(role = "system", content = AgentConfig.SYSTEM_PROMPT)) + raw.messages
} else {
raw.messages
}
val request = raw.copy(
messages = messages,
tools = raw.tools.takeUnless { it.isNullOrEmpty() } ?: AgentConfig.DEFAULT_TOOLS
tools = raw.tools.takeUnless { it.isNullOrEmpty() }
?: if (config.autoSystemPrompt) AgentConfig.DEFAULT_TOOLS else null,
temperature = if (raw.temperature == 0.7f) config.defaultTemperature else raw.temperature,
max_tokens = if (raw.max_tokens == 8192) config.defaultMaxTokens else raw.max_tokens
)
val id = "chatcmpl-${UUID.randomUUID().toString().take(8)}"
val hasTools = !request.tools.isNullOrEmpty()
log("Chat: ${request.messages.size} messages, tools=${request.tools?.size ?: 0}, stream=${request.stream}")
log("Chat: ${request.messages.size} messages, tools=${request.tools?.size ?: 0}, stream=${request.stream}, temp=${request.temperature}")
// ── Streaming — always uses flat prompt + generateStreaming ────────────
if (request.stream) {
val prompt = buildFlatPrompt(request.messages)
onActiveRequest?.invoke(true)
return try {
handleStreamingResponse(id, prompt, request)
} finally {
onActiveRequest?.invoke(false)
}
}
// ── Tool calling / multi-turn chat ─────────────────────────────────────
if (hasTools || request.messages.size > 1 || request.messages.any { it.role == "system" }) {
val convMessages = request.messages.map { it.toConvMessage() }
val toolDefs = request.tools?.map { it.toToolDef() } ?: emptyList()
val result = runBlocking {
model.chat(convMessages, toolDefs, request.max_tokens, request.temperature)
onActiveRequest?.invoke(true)
val result = try {
runBlocking {
model.chat(convMessages, toolDefs, request.max_tokens, request.temperature)
}
} finally {
onActiveRequest?.invoke(false)
}
if (result.toolCalls != null) {
@@ -34,9 +34,11 @@ class ApiServerService : Service() {
var onStatusChanged: ((ServerState) -> Unit)? = null
var onLog: ((String) -> Unit)? = null
var onActiveRequest: ((Boolean) -> Unit)? = null
val isRunning: Boolean get() = server != null
val requestCount: Long get() = server?.requestCount?.get() ?: 0
val currentModel: OnDeviceModel? get() = model
inner class LocalBinder : Binder() {
val service: ApiServerService get() = this@ApiServerService
@@ -70,8 +72,15 @@ class ApiServerService : Service() {
// Start the HTTP server
notifyLog("Starting API server on port $port...")
val apiServer = AIApiServer(port, model!!)
val prefs = getSharedPreferences("pixel10_prefs", MODE_PRIVATE)
val serverConfig = ServerConfig(
defaultTemperature = prefs.getFloat("temperature", 0.7f),
defaultMaxTokens = prefs.getInt("max_tokens", 1024),
autoSystemPrompt = prefs.getBoolean("auto_system_prompt", true)
)
val apiServer = AIApiServer(port, model!!, serverConfig)
apiServer.onRequestLogged = { msg -> notifyLog(msg) }
apiServer.onActiveRequest = { active -> onActiveRequest?.invoke(active) }
apiServer.start()
server = apiServer
@@ -0,0 +1,158 @@
package com.pixel10.ai.ui
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.view.View
import android.view.inputmethod.EditorInfo
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.pixel10.ai.databinding.ActivityChatBinding
import com.pixel10.ai.inference.OnDeviceModel
import com.pixel10.ai.server.AgentConfig
import com.pixel10.ai.server.ApiServerService
import kotlinx.coroutines.launch
class ChatActivity : AppCompatActivity() {
private lateinit var binding: ActivityChatBinding
private val messages = mutableListOf<ChatMessage>()
private lateinit var adapter: MessageAdapter
private var service: ApiServerService? = null
private var bound = false
private var generating = false
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
service = (binder as ApiServerService.LocalBinder).service
bound = true
}
override fun onServiceDisconnected(name: ComponentName?) {
service = null
bound = false
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityChatBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
binding.toolbar.setNavigationOnClickListener { finish() }
adapter = MessageAdapter(messages)
binding.rvMessages.layoutManager = LinearLayoutManager(this).also {
it.stackFromEnd = true
}
binding.rvMessages.adapter = adapter
binding.btnSend.setOnClickListener { sendMessage() }
binding.etMessage.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEND) { sendMessage(); true } else false
}
}
override fun onStart() {
super.onStart()
bindService(
Intent(this, ApiServerService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
)
}
override fun onStop() {
super.onStop()
if (bound) { unbindService(serviceConnection); bound = false }
}
private fun sendMessage() {
if (generating) return
val text = binding.etMessage.text.toString().trim()
if (text.isEmpty()) return
binding.etMessage.text?.clear()
// Add user message
messages.add(ChatMessage("user", text))
adapter.notifyItemInserted(messages.size - 1)
scrollToBottom()
// Add empty AI placeholder
messages.add(ChatMessage("assistant", ""))
val aiIndex = messages.size - 1
adapter.notifyItemInserted(aiIndex)
scrollToBottom()
binding.tvTyping.visibility = View.VISIBLE
binding.btnSend.isEnabled = false
generating = true
val model = service?.currentModel
if (model == null || !model.isReady) {
messages[aiIndex].content = "⚠️ Server not running — start the server first."
adapter.notifyItemChanged(aiIndex)
finishGeneration()
return
}
val prompt = buildPrompt()
lifecycleScope.launch {
try {
model.generateStreaming(prompt) { token ->
runOnUiThread {
messages[aiIndex].content += token
adapter.notifyItemChanged(aiIndex)
scrollToBottom()
}
}
} catch (e: Exception) {
runOnUiThread {
messages[aiIndex].content = "⚠️ Error: ${e.message}"
adapter.notifyItemChanged(aiIndex)
}
} finally {
runOnUiThread { finishGeneration() }
}
}
}
private fun finishGeneration() {
generating = false
binding.tvTyping.visibility = View.GONE
binding.btnSend.isEnabled = true
scrollToBottom()
}
private fun buildPrompt(): String {
val prefs = getSharedPreferences("pixel10_prefs", MODE_PRIVATE)
val useSystemPrompt = prefs.getBoolean("auto_system_prompt", true)
val sb = StringBuilder()
if (useSystemPrompt) {
sb.append("System: ${AgentConfig.SYSTEM_PROMPT}\n\n")
}
// Include all messages except the last empty AI placeholder
for (i in 0 until messages.size - 1) {
val msg = messages[i]
when (msg.role) {
"user" -> sb.append("User: ${msg.content}\n\n")
"assistant" -> sb.append("Assistant: ${msg.content}\n\n")
}
}
sb.append("Assistant:")
return sb.toString()
}
private fun scrollToBottom() {
if (messages.isNotEmpty()) {
binding.rvMessages.smoothScrollToPosition(messages.size - 1)
}
}
}
@@ -0,0 +1,3 @@
package com.pixel10.ai.ui
data class ChatMessage(val role: String, var content: String)
@@ -13,6 +13,7 @@ import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.view.View
import android.widget.SeekBar
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
@@ -53,6 +54,16 @@ class MainActivity : AppCompatActivity() {
service?.onLog = { message ->
runOnUiThread { appendLog(message) }
}
service?.onActiveRequest = { active ->
runOnUiThread {
if (active) {
binding.tvActiveRequest.text = "⚡ Processing request…"
binding.tvActiveRequest.visibility = View.VISIBLE
} else {
binding.tvActiveRequest.visibility = View.GONE
}
}
}
if (service?.isRunning == true) {
updateStatus(ApiServerService.ServerState.RUNNING)
@@ -73,20 +84,37 @@ class MainActivity : AppCompatActivity() {
prefs = getSharedPreferences("pixel10_prefs", MODE_PRIVATE)
requestNotificationPermission()
// Restore saved HF token
// Restore saved settings
binding.etHfToken.setText(prefs.getString("hf_token", ""))
val savedTemp = (prefs.getFloat("temperature", 0.7f) * 100).toInt()
binding.seekTemperature.progress = savedTemp
binding.tvTemperatureValue.text = "%.1f".format(savedTemp / 100f)
binding.etMaxTokens.setText(prefs.getInt("max_tokens", 1024).toString())
binding.switchSystemPrompt.isChecked = prefs.getBoolean("auto_system_prompt", true)
binding.seekTemperature.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
binding.tvTemperatureValue.text = "%.1f".format(progress / 100f)
}
override fun onStartTrackingTouch(seekBar: SeekBar) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {}
})
binding.btnToggle.setOnClickListener {
if (service?.isRunning == true) stopServer() else startServer()
}
binding.btnChat.setOnClickListener {
startActivity(Intent(this, ChatActivity::class.java))
}
binding.btnDownloadModel.setOnClickListener {
saveHfToken()
startModelDownload(ModelSpec.GEMMA_3_1B_Q4)
startModelDownload(ModelSpec.GEMMA_3N_E4B)
}
binding.btnDownloadGemma3Q8.setOnClickListener {
saveHfToken()
startModelDownload(ModelSpec.GEMMA_3_1B_Q8)
startModelDownload(ModelSpec.GEMMA_3N_E4B_WEB)
}
updateModelCard()
@@ -201,7 +229,16 @@ class MainActivity : AppCompatActivity() {
}
}
private fun saveSettings() {
prefs.edit()
.putFloat("temperature", binding.seekTemperature.progress / 100f)
.putInt("max_tokens", binding.etMaxTokens.text.toString().toIntOrNull() ?: 1024)
.putBoolean("auto_system_prompt", binding.switchSystemPrompt.isChecked)
.apply()
}
private fun startServer() {
saveSettings()
val port = binding.etPort.text.toString().toIntOrNull() ?: 8080
val intent = Intent(this, ApiServerService::class.java).apply {
action = ApiServerService.ACTION_START
@@ -227,6 +264,9 @@ class MainActivity : AppCompatActivity() {
val canEdit = state == ApiServerService.ServerState.STOPPED ||
state == ApiServerService.ServerState.ERROR
binding.etPort.isEnabled = canEdit
binding.seekTemperature.isEnabled = canEdit
binding.etMaxTokens.isEnabled = canEdit
binding.switchSystemPrompt.isEnabled = canEdit
when (state) {
ApiServerService.ServerState.STOPPED -> {
@@ -236,6 +276,7 @@ class MainActivity : AppCompatActivity() {
binding.tvModelStatus.text = "Model: not loaded"
binding.btnToggle.text = getString(R.string.btn_start)
binding.btnToggle.isEnabled = true
binding.btnChat.isEnabled = false
}
ApiServerService.ServerState.LOADING_MODEL -> {
binding.tvServerStatus.text = getString(R.string.server_status_starting)
@@ -252,6 +293,7 @@ class MainActivity : AppCompatActivity() {
binding.tvModelStatus.text = getString(R.string.model_ready)
binding.btnToggle.text = getString(R.string.btn_stop)
binding.btnToggle.isEnabled = true
binding.btnChat.isEnabled = true
}
ApiServerService.ServerState.ERROR -> {
binding.tvServerStatus.text = getString(R.string.server_status_error)
@@ -0,0 +1,43 @@
package com.pixel10.ai.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.pixel10.ai.R
class MessageAdapter(private val messages: List<ChatMessage>) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
companion object {
private const val TYPE_USER = 0
private const val TYPE_AI = 1
}
override fun getItemViewType(position: Int) =
if (messages[position].role == "user") TYPE_USER else TYPE_AI
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return if (viewType == TYPE_USER) {
val view = inflater.inflate(R.layout.item_message_user, parent, false)
UserViewHolder(view.findViewById(R.id.tvContent))
} else {
val view = inflater.inflate(R.layout.item_message_ai, parent, false)
AiViewHolder(view.findViewById(R.id.tvContent))
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val msg = messages[position]
when (holder) {
is UserViewHolder -> holder.tv.text = msg.content
is AiViewHolder -> holder.tv.text = msg.content.ifEmpty { "" }
}
}
override fun getItemCount() = messages.size
class UserViewHolder(val tv: TextView) : RecyclerView.ViewHolder(tv.parent as android.view.View)
class AiViewHolder(val tv: TextView) : RecyclerView.ViewHolder(tv.parent as android.view.View)
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/surface_variant" />
<corners
android:topLeftRadius="4dp"
android:topRightRadius="16dp"
android:bottomLeftRadius="16dp"
android:bottomRightRadius="16dp" />
</shape>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/primary" />
<corners
android:topLeftRadius="16dp"
android:topRightRadius="16dp"
android:bottomLeftRadius="16dp"
android:bottomRightRadius="4dp" />
</shape>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/on_surface">
<path
android:fillColor="@color/on_surface"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</vector>
+76
View File
@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
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:orientation="vertical"
android:background="@color/surface">
<!-- Toolbar -->
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/surface_variant"
android:paddingStart="4dp"
app:title="Chat"
app:titleTextColor="@color/on_surface"
app:navigationIcon="@drawable/ic_back" />
<!-- Message list -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvMessages"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:padding="12dp"
android:clipToPadding="false" />
<!-- Typing indicator -->
<TextView
android:id="@+id/tvTyping"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingBottom="4dp"
android:text="⚡ Generating…"
android:textColor="@color/primary"
android:textSize="12sp"
android:visibility="gone" />
<!-- Input row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="8dp"
android:background="@color/surface_variant">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etMessage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="Message…"
android:textColor="@color/on_surface"
android:textColorHint="@color/log_text"
android:textSize="15sp"
android:maxLines="4"
android:inputType="textMultiLine|textCapSentences"
android:backgroundTint="@color/primary" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSend"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="Send"
android:textSize="14sp"
app:cornerRadius="8dp" />
</LinearLayout>
</LinearLayout>
+168 -32
View File
@@ -102,6 +102,16 @@
android:textColor="@color/log_text"
android:textSize="13sp"
android:layout_marginTop="2dp" />
<TextView
android:id="@+id/tvActiveRequest"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/primary"
android:textSize="13sp"
android:textStyle="bold"
android:layout_marginTop="2dp"
android:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
@@ -176,51 +186,176 @@
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Port Config -->
<LinearLayout
<!-- Settings Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/layoutPort"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="16dp"
android:layout_marginTop="12dp"
app:cardBackgroundColor="@color/surface_variant"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="0dp"
app:layout_constraintTop_toBottomOf="@id/cardModel"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<TextView
android:layout_width="wrap_content"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/port_label"
android:textColor="@color/on_surface"
android:textSize="16sp" />
android:orientation="vertical"
android:padding="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPort"
android:layout_width="100dp"
android:layout_height="48dp"
android:layout_marginStart="12dp"
android:text="@string/port_default"
android:inputType="number"
android:textColor="@color/on_surface"
android:backgroundTint="@color/primary"
android:fontFamily="monospace"
android:textSize="16sp"
android:gravity="center" />
</LinearLayout>
<!-- Port row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<!-- Start/Stop Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnToggle"
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/port_label"
android:textColor="@color/on_surface"
android:textSize="14sp" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPort"
android:layout_width="80dp"
android:layout_height="40dp"
android:text="@string/port_default"
android:inputType="number"
android:textColor="@color/on_surface"
android:backgroundTint="@color/primary"
android:fontFamily="monospace"
android:textSize="14sp"
android:gravity="center" />
</LinearLayout>
<!-- Temperature row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="12dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/setting_temperature"
android:textColor="@color/on_surface"
android:textSize="14sp" />
<TextView
android:id="@+id/tvTemperatureValue"
android:layout_width="36dp"
android:layout_height="wrap_content"
android:text="0.7"
android:textColor="@color/log_text"
android:textSize="13sp"
android:fontFamily="monospace"
android:gravity="end" />
<SeekBar
android:id="@+id/seekTemperature"
android:layout_width="120dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:max="100"
android:progress="70" />
</LinearLayout>
<!-- Max tokens row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/setting_max_tokens"
android:textColor="@color/on_surface"
android:textSize="14sp" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etMaxTokens"
android:layout_width="80dp"
android:layout_height="40dp"
android:text="1024"
android:inputType="number"
android:textColor="@color/on_surface"
android:backgroundTint="@color/primary"
android:fontFamily="monospace"
android:textSize="14sp"
android:gravity="center" />
</LinearLayout>
<!-- System prompt toggle -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/setting_auto_system_prompt"
android:textColor="@color/on_surface"
android:textSize="14sp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchSystemPrompt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Start/Stop + Chat Buttons -->
<LinearLayout
android:id="@+id/layoutButtons"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
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" />
app:layout_constraintEnd_toEndOf="parent">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnToggle"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_weight="1"
android:text="@string/btn_start"
android:textSize="16sp"
app:cornerRadius="12dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnChat"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="56dp"
android:layout_marginStart="8dp"
android:text="Chat"
android:textSize="16sp"
android:enabled="false"
app:cornerRadius="12dp" />
</LinearLayout>
<!-- Log Output -->
<TextView
@@ -232,7 +367,7 @@
android:textSize="14sp"
android:textStyle="bold"
android:layout_marginTop="20dp"
app:layout_constraintTop_toBottomOf="@id/btnToggle"
app:layout_constraintTop_toBottomOf="@id/layoutButtons"
app:layout_constraintStart_toStartOf="parent" />
<ScrollView
@@ -243,6 +378,7 @@
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">
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="start"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<TextView
android:id="@+id/tvContent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="280dp"
android:background="@drawable/bubble_ai"
android:padding="10dp"
android:textColor="@color/on_surface"
android:textSize="14sp"
android:lineSpacingMultiplier="1.2"
android:fontFamily="monospace" />
</LinearLayout>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="end"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<TextView
android:id="@+id/tvContent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="280dp"
android:background="@drawable/bubble_user"
android:padding="10dp"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:lineSpacingMultiplier="1.2" />
</LinearLayout>
+6 -3
View File
@@ -19,13 +19,16 @@
<!-- Controls -->
<string name="hf_token_hint">HuggingFace token (huggingface.co/settings/tokens)</string>
<string name="btn_download_gemma3_q4">⭐ Gemma 3 1B IT Q4 — Fast (~555 MB)</string>
<string name="btn_download_gemma3_q8">Gemma 3 1B IT Q8 — Higher quality (~1 GB)</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>
<string name="port_label">Port</string>
<string name="setting_temperature">Temperature</string>
<string name="setting_max_tokens">Max tokens</string>
<string name="setting_auto_system_prompt">Agent system prompt</string>
<string name="port_default">8080</string>
<string name="requests_served">Requests served: %d</string>
<string name="request_log_label">Request Log</string>