diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
new file mode 100644
index 0000000..89ca859
--- /dev/null
+++ b/.github/workflows/android.yml
@@ -0,0 +1,68 @@
+name: Android CI & Validation
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build-and-test:
+ name: Build, Test & Lint
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '17'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v4
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Run Unit Tests
+ run: ./gradlew testDebugUnitTest --no-daemon --stacktrace
+
+ - name: Run Android Lint
+ run: ./gradlew lintDebug --no-daemon
+
+ - name: Assemble Debug APK
+ run: ./gradlew assembleDebug --no-daemon
+
+ - name: Upload Debug APK
+ uses: actions/upload-artifact@v4
+ with:
+ name: aura-local-ai-debug-apk
+ path: app/build/outputs/apk/debug/app-debug.apk
+ if-no-files-found: error
+ retention-days: 14
+
+ - name: Upload Unit Test Reports
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: unit-test-reports
+ path: app/build/reports/tests/testDebugUnitTest/
+ retention-days: 7
+
+ - name: Upload Lint Report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: lint-reports
+ path: app/build/reports/lint-results-debug.html
+ retention-days: 7
diff --git a/README.md b/README.md
index 993e079..c260f7c 100644
--- a/README.md
+++ b/README.md
@@ -44,7 +44,7 @@ The app includes built-in presets for several highly-capable, lightweight models
| --- | --- |
| **DeepSeek-R1 Distill Qwen 1.5B**
• Parameters: 1.5B \| Size: ~1.7 GB
• Min. RAM: 6 GB+ (Offline Reasoning) | **Qwen 2.5 Coder 3B Instruct**
• Parameters: 3B \| Size: ~2.9 GB
• Min. RAM: 8 GB+ (Coding Expert) |
| **Qwen 2.5 1.5B Instruct**
• Parameters: 1.5B \| Size: ~1.5 GB
• Min. RAM: 6 GB+ (General Knowledge) | **Google Gemma 4 E2B Instruct**
• Parameters: 2B \| Size: ~2.4 GB
• Min. RAM: 6 GB+ (Multimodal Vision) |
-| **Qwen 3 4B**
• Parameters: 4B \| Size: ~2.5 GB
• Min. RAM: 8 GB+ (High Performance) | **Google Gemma 4 E4B Instruct**
• Parameters: 4B \| Size: ~3.4 GB
• Min. RAM: 8 GB+ (High-Res Multimodal) |
+| **Qwen 3 4B**
• Parameters: 4B \| Size: ~2.5 GB
• Min. RAM: 8 GB+ (High Performance) | **Google Gemma 4 E4B Instruct**
• Parameters: 4B \| Size: ~3.4 GB
• Min. RAM: 12 GB+ (High-Res Multimodal) |
| **Qwen 2.5 0.5B Instruct**
• Parameters: 0.5B \| Size: ~0.5 GB
• Min. RAM: 4 GB+ (Ultra-Fast) | |
---
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 5b5f0ab..de64992 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -41,6 +41,11 @@ android {
shaders = false
}
+ lint {
+ abortOnError = false
+ checkReleaseBuilds = false
+ }
+
packaging {
diff --git a/app/src/main/java/com/example/auralocalai/data/LlmInferenceEngine.kt b/app/src/main/java/com/example/auralocalai/data/LlmInferenceEngine.kt
index e90c65b..aa7d3e7 100644
--- a/app/src/main/java/com/example/auralocalai/data/LlmInferenceEngine.kt
+++ b/app/src/main/java/com/example/auralocalai/data/LlmInferenceEngine.kt
@@ -177,7 +177,7 @@ class LlmInferenceEngine(private val context: Context) {
}
gpuError = ramError
} else {
- val gpuStage = if (npuError != null) "NPU unavailable — initializing GPU backend…" else "Initializing GPU backend…"
+ val gpuStage = if (npuError != null) "NPU unavailable — compiling GPU shaders…" else "Compiling GPU shaders…"
onStageUpdate?.invoke(gpuStage)
try {
val config = EngineConfig(
@@ -186,7 +186,17 @@ class LlmInferenceEngine(private val context: Context) {
cacheDir = context.cacheDir.absolutePath
)
val newEngine = Engine(config)
- newEngine.initialize()
+ if (preferredBackend == "AUTO") {
+ val initOk = kotlinx.coroutines.withTimeoutOrNull(40_000L) {
+ newEngine.initialize()
+ true
+ }
+ if (initOk == null) {
+ throw RuntimeException("GPU shader compilation timed out after 40s")
+ }
+ } else {
+ newEngine.initialize()
+ }
engine = newEngine
conversation = newEngine.createConversation()
currentModelPath = modelPath
@@ -194,6 +204,7 @@ class LlmInferenceEngine(private val context: Context) {
loaded = true
} catch (e: Throwable) {
gpuError = e
+ android.util.Log.w("LlmInferenceEngine", "GPU initialization failed or timed out: ${e.message}")
// If CPU fallback is NOT allowed or GPU_ONLY is preferred, fail immediately
if (restriction != LlmBackendRestriction.ANY || preferredBackend == "GPU_ONLY") {
val msg = buildString {
@@ -203,7 +214,7 @@ class LlmInferenceEngine(private val context: Context) {
}
return@withContext Result.failure(Exception(msg, e))
}
- }
+ }
}
}
}
diff --git a/app/src/main/java/com/example/auralocalai/data/ModelDownloadService.kt b/app/src/main/java/com/example/auralocalai/data/ModelDownloadService.kt
index caf327c..26b1fd8 100644
--- a/app/src/main/java/com/example/auralocalai/data/ModelDownloadService.kt
+++ b/app/src/main/java/com/example/auralocalai/data/ModelDownloadService.kt
@@ -9,8 +9,10 @@ import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
+import android.util.Log
import androidx.core.app.NotificationCompat
import com.example.auralocalai.MainActivity
+import com.example.auralocalai.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -18,6 +20,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import java.io.File
+private const val TAG = "ModelDownloadService"
+
sealed interface ServiceDownloadState {
data object Idle : ServiceDownloadState
data class Progress(
@@ -38,7 +42,6 @@ class ModelDownloadService : Service() {
private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
private var activeDownloadJob: Job? = null
-
private lateinit var downloader: ModelDownloader
private lateinit var notificationManager: NotificationManager
@@ -66,7 +69,7 @@ class ModelDownloadService : Service() {
return START_NOT_STICKY
}
- // Start Foreground Service
+ // Start Foreground Service safely
startForegroundServiceCompat(modelId, fileName)
// Cancel any active download before starting a new one
@@ -130,7 +133,7 @@ class ModelDownloadService : Service() {
}
}
- return START_STICKY
+ return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
@@ -138,7 +141,6 @@ class ModelDownloadService : Service() {
override fun onDestroy() {
activeDownloadJob?.cancel()
serviceJob.cancel()
- downloadState.value = ServiceDownloadState.Idle
super.onDestroy()
}
@@ -150,66 +152,79 @@ class ModelDownloadService : Service() {
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Shows progress of model downloads running in the background"
+ setShowBadge(false)
}
notificationManager.createNotificationChannel(channel)
}
}
private fun startForegroundServiceCompat(modelId: String, fileName: String) {
- val notification = BuildNotification(
- title = "Downloading Model",
- content = "Starting download for $fileName...",
- progress = 0,
- indeterminate = true
- )
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- startForeground(
- NOTIFICATION_ID,
- notification,
- ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
+ try {
+ val notification = buildNotification(
+ title = "Downloading Model",
+ content = "Starting download for $fileName...",
+ progress = 0,
+ indeterminate = true
)
- } else {
- startForeground(NOTIFICATION_ID, notification)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ startForeground(
+ NOTIFICATION_ID,
+ notification,
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
+ )
+ } else {
+ startForeground(NOTIFICATION_ID, notification)
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "startForeground failed (ignoring to allow download to proceed): ${e.message}")
}
}
private fun updateProgressNotification(modelId: String, fileName: String, percentage: Int, speed: Double) {
- val speedText = formatSpeed(speed)
- val notification = BuildNotification(
- title = "Downloading $fileName",
- content = "$percentage% completed • $speedText",
- progress = percentage,
- indeterminate = false
- )
- notificationManager.notify(NOTIFICATION_ID, notification)
+ try {
+ val speedText = formatSpeed(speed)
+ val notification = buildNotification(
+ title = "Downloading $fileName",
+ content = "$percentage% completed • $speedText",
+ progress = percentage,
+ indeterminate = false
+ )
+ notificationManager.notify(NOTIFICATION_ID, notification)
+ } catch (e: Exception) {
+ Log.d(TAG, "Notification update skipped: ${e.message}")
+ }
}
private fun showCompletionNotification(modelId: String, fileName: String, success: Boolean) {
- val title = if (success) "Download Successful" else "Download Failed"
- val content = if (success) "Successfully downloaded $fileName." else "Failed to download $fileName."
- val intent = Intent(this, MainActivity::class.java).apply {
- flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
- }
- val pendingIntent = PendingIntent.getActivity(
- this,
- 0,
- intent,
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
- )
+ try {
+ val title = if (success) "Download Successful" else "Download Failed"
+ val content = if (success) "Successfully downloaded $fileName." else "Failed to download $fileName."
+ val intent = Intent(this, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
- val notification = NotificationCompat.Builder(this, CHANNEL_ID)
- .setSmallIcon(android.R.drawable.stat_sys_download_done)
- .setContentTitle(title)
- .setContentText(content)
- .setContentIntent(pendingIntent)
- .setAutoCancel(true)
- .build()
+ val notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setSmallIcon(if (success) R.drawable.ic_download_done else R.drawable.ic_download)
+ .setContentTitle(title)
+ .setContentText(content)
+ .setContentIntent(pendingIntent)
+ .setAutoCancel(true)
+ .build()
- notificationManager.notify(NOTIFICATION_ID + 1, notification)
+ notificationManager.notify(NOTIFICATION_ID + 1, notification)
+ } catch (e: Exception) {
+ Log.d(TAG, "Completion notification skipped: ${e.message}")
+ }
}
- private fun BuildNotification(title: String, content: String, progress: Int, indeterminate: Boolean): android.app.Notification {
+ private fun buildNotification(title: String, content: String, progress: Int, indeterminate: Boolean): android.app.Notification {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
@@ -221,21 +236,22 @@ class ModelDownloadService : Service() {
)
return NotificationCompat.Builder(this, CHANNEL_ID)
- .setSmallIcon(android.R.drawable.stat_sys_download)
+ .setSmallIcon(R.drawable.ic_download)
.setContentTitle(title)
.setContentText(content)
.setProgress(100, progress, indeterminate)
.setContentIntent(pendingIntent)
.setOngoing(true)
+ .setSilent(true)
.build()
}
private fun formatSpeed(bytesPerSec: Double): String {
val mbps = bytesPerSec / (1024 * 1024)
if (mbps >= 1.0) {
- return String.format("%.1f MB/s", mbps)
+ return String.format(java.util.Locale.US, "%.1f MB/s", mbps)
}
val kbps = bytesPerSec / 1024
- return String.format("%.1f KB/s", kbps)
+ return String.format(java.util.Locale.US, "%.1f KB/s", kbps)
}
}
diff --git a/app/src/main/java/com/example/auralocalai/data/ModelPreset.kt b/app/src/main/java/com/example/auralocalai/data/ModelPreset.kt
index 25a03e0..87c2e91 100644
--- a/app/src/main/java/com/example/auralocalai/data/ModelPreset.kt
+++ b/app/src/main/java/com/example/auralocalai/data/ModelPreset.kt
@@ -23,9 +23,23 @@ data class ModelPreset(
val backendRestriction: LlmBackendRestriction = LlmBackendRestriction.ANY,
val quantization: String = "INT4",
val parameterCount: String = "Unknown",
- val contextLength: String = "4,096 tokens"
+ val contextLength: String = "4,096 tokens",
+ val isReasoningModel: Boolean = false
) {
companion object {
+ fun isReasoningModel(modelId: String?): Boolean {
+ if (modelId == null) return false
+ val preset = presets.find { it.id.equals(modelId, ignoreCase = true) }
+ if (preset != null) return preset.isReasoningModel
+ return modelId.contains("deepseek", ignoreCase = true) || modelId.contains("qwq", ignoreCase = true)
+ }
+
+ fun isKnownNonReasoningModel(modelId: String?): Boolean {
+ if (modelId == null) return false
+ val preset = presets.find { it.id.equals(modelId, ignoreCase = true) }
+ return preset != null && !preset.isReasoningModel
+ }
+
val presets = listOf(
ModelPreset(
id = "deepseek-1.5b",
@@ -40,7 +54,8 @@ data class ModelPreset(
backendRestriction = LlmBackendRestriction.ANY,
quantization = "Q8 (8-bit)",
parameterCount = "1.5B",
- contextLength = "4,096 tokens"
+ contextLength = "4,096 tokens",
+ isReasoningModel = true
),
ModelPreset(
id = "qwen-1.5b",
@@ -107,7 +122,7 @@ data class ModelPreset(
name = "Google Gemma 4 E4B Instruct (Multimodal)",
description = "Google's powerful on-device LLM with 4B parameters. Superior reasoning, math, and coding over E2B with native multimodal vision support (High-Res Multimodal).",
sizeLabel = "3.4 GB",
- ramRequirement = "8 GB+ RAM",
+ ramRequirement = "12 GB+ RAM",
downloadUrl = "https://huggingface.co/litert-community/gemma-4-E4B-it-litert-lm/resolve/main/gemma-4-E4B-it.litertlm",
fileName = "gemma4-e4b.litertlm",
requiresHfToken = false,
diff --git a/app/src/main/java/com/example/auralocalai/ui/LlmViewModel.kt b/app/src/main/java/com/example/auralocalai/ui/LlmViewModel.kt
index e672768..6c324e5 100644
--- a/app/src/main/java/com/example/auralocalai/ui/LlmViewModel.kt
+++ b/app/src/main/java/com/example/auralocalai/ui/LlmViewModel.kt
@@ -74,7 +74,8 @@ data class ChatMessage(
val fileName: String? = null,
val fileType: String? = null,
val id: String = java.util.UUID.randomUUID().toString(),
- val telemetry: InferenceTelemetry? = null
+ val telemetry: InferenceTelemetry? = null,
+ val modelId: String? = null
)
const val DEFAULT_SYSTEM_PROMPT = "You are Local LLM/AI, a helpful, intelligent offline AI running locally on this mobile device. Keep your responses concise and precise."
@@ -92,6 +93,7 @@ data class UiState(
val modelState: ModelState = ModelState.Unloaded,
val downloadState: DownloadState = DownloadState.Idle,
val currentDownloadingModelId: String? = null,
+ val loadingModelId: String? = null,
val activeModelId: String? = null,
val activeBackend: String = "None",
val lastNpuError: String? = null,
@@ -217,6 +219,7 @@ class LlmViewModel(application: Application) : AndroidViewModel(application) {
}
private var downloadJob: Job? = null
+ private var loadModelJob: Job? = null
private var inferenceJob: Job? = null
private fun migrateExistingModels() {
@@ -314,7 +317,7 @@ class LlmViewModel(application: Application) : AndroidViewModel(application) {
is ServiceDownloadState.Error -> {
_uiState.update {
it.copy(
- currentDownloadingModelId = null,
+ currentDownloadingModelId = state.modelId,
downloadState = DownloadState.Error(state.message)
)
}
@@ -349,7 +352,7 @@ class LlmViewModel(application: Application) : AndroidViewModel(application) {
val modelName = matchingPreset?.name ?: firstDownloaded
val modelId = matchingPreset?.id ?: firstDownloaded
- _uiState.update { it.copy(modelState = ModelState.Loading, loadingStage = "Validating model file\u2026") }
+ _uiState.update { it.copy(modelState = ModelState.Loading, loadingModelId = modelId, loadingStage = "Validating model file...") }
val startNs = System.nanoTime()
val result = inferenceEngine.loadModel(
modelPath = File(storageDir, firstDownloaded).absolutePath,
@@ -366,6 +369,7 @@ class LlmViewModel(application: Application) : AndroidViewModel(application) {
it.copy(
modelState = ModelState.Loaded(modelName),
activeModelId = modelId,
+ loadingModelId = null,
activeBackend = inferenceEngine.activeBackend,
lastNpuError = inferenceEngine.lastNpuError?.let { err ->
val sw = java.io.StringWriter()
@@ -376,7 +380,7 @@ class LlmViewModel(application: Application) : AndroidViewModel(application) {
)
}
} else {
- _uiState.update { it.copy(modelState = ModelState.Error(friendlyErrorMessage(result.exceptionOrNull()?.message)), activeBackend = "None", lastNpuError = inferenceEngine.lastNpuError?.let { err ->
+ _uiState.update { it.copy(modelState = ModelState.Error(friendlyErrorMessage(result.exceptionOrNull()?.message)), activeBackend = "None", loadingModelId = null, lastNpuError = inferenceEngine.lastNpuError?.let { err ->
val sw = java.io.StringWriter()
err.printStackTrace(java.io.PrintWriter(sw))
sw.toString()
@@ -407,20 +411,76 @@ class LlmViewModel(application: Application) : AndroidViewModel(application) {
}
val context = getApplication().applicationContext
- // Read HF token from SharedPreferences (set by user in Settings)
- val prefs = context.getSharedPreferences("app_settings", android.content.Context.MODE_PRIVATE)
- val hfToken = prefs.getString("hf_token", "") ?: ""
-
- val intent = Intent(context, ModelDownloadService::class.java).apply {
- putExtra("url", url)
- putExtra("fileName", fileName)
- putExtra("modelId", modelId)
- putExtra("hfToken", hfToken)
+ val hfToken = tokenStorage.getToken()
+
+ var serviceStarted = false
+ try {
+ val intent = Intent(context, ModelDownloadService::class.java).apply {
+ putExtra("url", url)
+ putExtra("fileName", fileName)
+ putExtra("modelId", modelId)
+ putExtra("hfToken", hfToken)
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ serviceStarted = true
+ } catch (e: Exception) {
+ Log.w("LlmViewModel", "Could not start ModelDownloadService (e.g. background execution limits): ${e.message}. Using in-app fallback download.")
}
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- context.startForegroundService(intent)
- } else {
- context.startService(intent)
+
+ if (!serviceStarted) {
+ downloadJob = viewModelScope.launch(Dispatchers.IO) {
+ val tempFile = File(storageDir, "$fileName.tmp")
+ val destFile = File(storageDir, fileName)
+
+ downloader.downloadModel(url, tempFile, hfToken).collect { state ->
+ when (state) {
+ is DownloadState.Idle -> {
+ _uiState.update { it.copy(downloadState = DownloadState.Idle) }
+ }
+ is DownloadState.Progress -> {
+ _uiState.update {
+ it.copy(
+ currentDownloadingModelId = modelId,
+ downloadState = state
+ )
+ }
+ }
+ is DownloadState.Success -> {
+ val renameSuccess = ModelSafetyValidator.moveFileSafely(tempFile, destFile)
+ if (renameSuccess) {
+ _uiState.update {
+ it.copy(
+ currentDownloadingModelId = null,
+ downloadState = DownloadState.Idle
+ )
+ }
+ refreshDownloadedModels()
+ loadModel(fileName, modelId)
+ } else {
+ if (tempFile.exists()) tempFile.delete()
+ _uiState.update {
+ it.copy(
+ currentDownloadingModelId = modelId,
+ downloadState = DownloadState.Error("Failed to finalize downloaded model file.")
+ )
+ }
+ }
+ }
+ is DownloadState.Error -> {
+ _uiState.update {
+ it.copy(
+ currentDownloadingModelId = modelId,
+ downloadState = state
+ )
+ }
+ }
+ }
+ }
+ }
}
}
@@ -454,17 +514,17 @@ class LlmViewModel(application: Application) : AndroidViewModel(application) {
}
fun saveHfToken(token: String) {
- val context = getApplication().applicationContext
- val prefs = context.getSharedPreferences("app_settings", Context.MODE_PRIVATE)
- prefs.edit().putString("hf_token", token).apply()
- _uiState.update { it.copy(hfToken = token) }
+ viewModelScope.launch(Dispatchers.IO) {
+ tokenStorage.saveToken(token)
+ _uiState.update { it.copy(hfToken = token) }
+ }
}
fun clearHfToken() {
- val context = getApplication().applicationContext
- val prefs = context.getSharedPreferences("app_settings", Context.MODE_PRIVATE)
- prefs.edit().remove("hf_token").apply()
- _uiState.update { it.copy(hfToken = "") }
+ viewModelScope.launch(Dispatchers.IO) {
+ tokenStorage.clearToken()
+ _uiState.update { it.copy(hfToken = "") }
+ }
}
fun validateHfToken(token: String, callback: (Boolean, String) -> Unit) {
@@ -523,9 +583,26 @@ class LlmViewModel(application: Application) : AndroidViewModel(application) {
saveMessages(_uiState.value.messages)
}
+ fun cancelLoading() {
+ loadModelJob?.cancel()
+ loadModelJob = null
+ inferenceEngine.close()
+ _uiState.update {
+ it.copy(
+ modelState = ModelState.Unloaded,
+ loadingModelId = null,
+ loadingStage = null
+ )
+ }
+ }
+
fun cancelDownload() {
- val context = getApplication().applicationContext
- context.stopService(Intent(context, ModelDownloadService::class.java))
+ downloadJob?.cancel()
+ downloadJob = null
+ try {
+ val context = getApplication().applicationContext
+ context.stopService(Intent(context, ModelDownloadService::class.java))
+ } catch (_: Exception) {}
_uiState.update {
it.copy(
downloadState = DownloadState.Idle,
@@ -582,8 +659,9 @@ class LlmViewModel(application: Application) : AndroidViewModel(application) {
}
fun loadModel(fileName: String, modelId: String) {
- viewModelScope.launch {
- _uiState.update { it.copy(modelState = ModelState.Loading, loadingStage = "Validating model file\u2026") }
+ loadModelJob?.cancel()
+ loadModelJob = viewModelScope.launch {
+ _uiState.update { it.copy(modelState = ModelState.Loading, loadingModelId = modelId, loadingStage = "Validating model file...") }
val matchingPreset = ModelPreset.presets.firstOrNull { it.id == modelId }
val displayName = matchingPreset?.name ?: fileName
@@ -603,6 +681,7 @@ class LlmViewModel(application: Application) : AndroidViewModel(application) {
it.copy(
modelState = ModelState.Loaded(displayName),
activeModelId = modelId,
+ loadingModelId = null,
activeBackend = inferenceEngine.activeBackend,
lastNpuError = inferenceEngine.lastNpuError?.let { err ->
val sw = java.io.StringWriter()
@@ -617,6 +696,7 @@ class LlmViewModel(application: Application) : AndroidViewModel(application) {
it.copy(
modelState = ModelState.Error(friendlyErrorMessage(result.exceptionOrNull()?.message)),
activeBackend = "None",
+ loadingModelId = null,
lastNpuError = inferenceEngine.lastNpuError?.let { err ->
val sw = java.io.StringWriter()
err.printStackTrace(java.io.PrintWriter(sw))
@@ -913,16 +993,10 @@ class LlmViewModel(application: Application) : AndroidViewModel(application) {
}
val fullPrompt = "$systemHeader$history\nAI: "
- // Extract bitmap natively if a vision-capable model is loaded and image is attached
+ // Extract downsampled bitmap natively if a vision-capable model is loaded and image is attached
val imageBitmap: Bitmap? = if (imageUri != null && (activeModel == "gemma4-e2b" || activeModel == "gemma4-e4b")) {
- try {
- val context = getApplication().applicationContext
- context.contentResolver.openInputStream(Uri.parse(imageUri))?.use { stream ->
- android.graphics.BitmapFactory.decodeStream(stream)
- }
- } catch (e: Exception) {
- null
- }
+ val context = getApplication().applicationContext
+ decodeSampledBitmap(context, Uri.parse(imageUri), maxDimension = 1024)
} else {
null
}
@@ -936,7 +1010,7 @@ class LlmViewModel(application: Application) : AndroidViewModel(application) {
var lastUpdateTime = System.currentTimeMillis()
try {
- val aiMessagePlaceholder = ChatMessage("", isUser = false)
+ val aiMessagePlaceholder = ChatMessage("", isUser = false, modelId = activeModel)
_uiState.update { it.copy(messages = currentMessages + aiMessagePlaceholder) }
inferenceEngine.generateResponse(fullPrompt, imageBitmap)
@@ -972,6 +1046,7 @@ class LlmViewModel(application: Application) : AndroidViewModel(application) {
accumulatedText = friendlyErrorMessage(e.message)
}
} finally {
+ imageBitmap?.recycle()
val tEnd = System.nanoTime()
val firstTime = tFirst
val telemetry = if (firstTime != null && tokenCount > 0) {
@@ -1068,4 +1143,82 @@ class LlmViewModel(application: Application) : AndroidViewModel(application) {
inferenceJob?.cancel()
inferenceEngine.close()
}
+
+ private fun decodeSampledBitmap(context: Context, uri: Uri, maxDimension: Int = 1024): Bitmap? {
+ return try {
+ val options = android.graphics.BitmapFactory.Options().apply {
+ inJustDecodeBounds = true
+ }
+ context.contentResolver.openInputStream(uri)?.use { stream ->
+ android.graphics.BitmapFactory.decodeStream(stream, null, options)
+ }
+
+ if (options.outWidth <= 0 || options.outHeight <= 0) return null
+
+ var inSampleSize = 1
+ val maxSide = maxOf(options.outWidth, options.outHeight)
+ while ((maxSide / inSampleSize) > maxDimension * 2) {
+ inSampleSize *= 2
+ }
+
+ val decodeOptions = android.graphics.BitmapFactory.Options().apply {
+ this.inSampleSize = inSampleSize
+ inPreferredConfig = Bitmap.Config.ARGB_8888
+ }
+
+ val sampled = context.contentResolver.openInputStream(uri)?.use { stream ->
+ android.graphics.BitmapFactory.decodeStream(stream, null, decodeOptions)
+ } ?: return null
+
+ var orientation = android.media.ExifInterface.ORIENTATION_NORMAL
+ try {
+ context.contentResolver.openInputStream(uri)?.use { stream ->
+ val exif = android.media.ExifInterface(stream)
+ orientation = exif.getAttributeInt(
+ android.media.ExifInterface.TAG_ORIENTATION,
+ android.media.ExifInterface.ORIENTATION_NORMAL
+ )
+ }
+ } catch (e: Exception) {
+ // Ignore EXIF read errors
+ }
+
+ val matrix = android.graphics.Matrix()
+ when (orientation) {
+ android.media.ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
+ android.media.ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
+ android.media.ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
+ android.media.ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
+ android.media.ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
+ android.media.ExifInterface.ORIENTATION_TRANSPOSE -> {
+ matrix.postRotate(90f)
+ matrix.postScale(-1f, 1f)
+ }
+ android.media.ExifInterface.ORIENTATION_TRANSVERSE -> {
+ matrix.postRotate(270f)
+ matrix.postScale(-1f, 1f)
+ }
+ }
+
+ val currentMax = maxOf(sampled.width, sampled.height)
+ if (currentMax > maxDimension) {
+ val scale = maxDimension.toFloat() / currentMax.toFloat()
+ matrix.postScale(scale, scale)
+ }
+
+ val transformed = if (!matrix.isIdentity) {
+ val result = Bitmap.createBitmap(sampled, 0, 0, sampled.width, sampled.height, matrix, true)
+ if (result != sampled) {
+ sampled.recycle()
+ }
+ result
+ } else {
+ sampled
+ }
+ transformed
+ } catch (e: Exception) {
+ null
+ }
+ }
+
}
diff --git a/app/src/main/java/com/example/auralocalai/ui/screens/ChatScreen.kt b/app/src/main/java/com/example/auralocalai/ui/screens/ChatScreen.kt
index 8eb2e95..459d770 100644
--- a/app/src/main/java/com/example/auralocalai/ui/screens/ChatScreen.kt
+++ b/app/src/main/java/com/example/auralocalai/ui/screens/ChatScreen.kt
@@ -38,6 +38,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -45,6 +46,7 @@ import androidx.compose.ui.unit.sp
import com.example.auralocalai.ui.ChatMessage
import com.example.auralocalai.ui.LlmViewModel
import com.example.auralocalai.ui.ModelState
+import com.example.auralocalai.data.ModelPreset
import kotlinx.coroutines.launch
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -234,58 +236,18 @@ fun ChatScreen(
letterSpacing = 1.0.sp,
color = MaterialTheme.colorScheme.primary
)
- var showNpuErrorDialog by remember { mutableStateOf(false) }
- if (showNpuErrorDialog && uiState.lastNpuError != null) {
- AlertDialog(
- onDismissRequest = { showNpuErrorDialog = false },
- title = { Text("NPU Error Details") },
- text = {
- androidx.compose.foundation.text.selection.SelectionContainer {
- Text(
- text = uiState.lastNpuError ?: "",
- fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
- fontSize = 10.sp,
- modifier = Modifier.verticalScroll(rememberScrollState())
- )
- }
- },
- confirmButton = {
- TextButton(onClick = { showNpuErrorDialog = false }) {
- Text("Dismiss")
- }
- }
- )
- }
-
val subtitleText = when (val state = uiState.modelState) {
- is ModelState.Loaded -> {
- val name = state.modelName.replace("(Alibaba)", "").trim()
- val backendInfo = if (uiState.lastNpuError != null) "${uiState.activeBackend} (NPU Error)" else uiState.activeBackend
- "$name ($backendInfo)"
- }
- ModelState.Loading -> uiState.loadingStage ?: "Loading Model..."
+ is ModelState.Loaded -> state.modelName.replace("(Alibaba)", "").trim()
+ ModelState.Loading -> "Loading Model..."
is ModelState.Error -> "Engine Error"
ModelState.Unloaded -> "No model loaded (Offline)"
}
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text(
- text = subtitleText,
- fontSize = 11.sp,
- fontWeight = FontWeight.Medium,
- color = if (uiState.lastNpuError != null && uiState.modelState is ModelState.Loaded) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
- )
- if (uiState.modelState is ModelState.Loaded && uiState.lastNpuError != null) {
- Spacer(modifier = Modifier.width(4.dp))
- Icon(
- imageVector = Icons.Default.Error,
- contentDescription = "View NPU Error Details",
- tint = MaterialTheme.colorScheme.error,
- modifier = Modifier
- .size(14.dp)
- .clickable { showNpuErrorDialog = true }
- )
- }
- }
+ Text(
+ text = subtitleText,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
+ )
}
Row(
@@ -391,8 +353,30 @@ fun ChatScreen(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
- items(items = uiState.messages, key = { it.id }) { message ->
- ChatBubble(message = message)
+ items(uiState.messages) { message ->
+ val isLastMessage = message == uiState.messages.lastOrNull()
+ val isActivelyStreaming = isLastMessage && uiState.isGenerating && !message.isUser
+ val isReasoning = !message.isUser && run {
+ if (message.modelId != null) {
+ when {
+ // 1. Catalog non-reasoning models (e.g. Qwen Coder, Gemma): never treat as reasoning
+ ModelPreset.isKnownNonReasoningModel(message.modelId) -> false
+ // 2. Catalog reasoning models (DeepSeek-R1, QwQ): always treat as reasoning
+ ModelPreset.isReasoningModel(message.modelId) -> true
+ // 3. Custom imported models: gate strictly on confirmed full tag prefix
+ else -> message.content.trimStart().startsWith("")
+ }
+ } else {
+ // 4. Legacy history: decouple from active model; gate strictly on confirmed full tag prefix
+ message.content.trimStart().startsWith("")
+ }
+ }
+
+ ChatBubble(
+ message = message,
+ isActivelyGenerating = isActivelyStreaming,
+ isReasoningModel = isReasoning
+ )
}
if (uiState.isGenerating) {
@@ -773,8 +757,67 @@ fun ChatScreen(
}
}
+data class ParsedMessageContent(
+ val thinkContent: String?,
+ val hasUnclosedThink: Boolean,
+ val mainContent: String
+)
+
+// TODO: consider incremental parsing from last known offset if this shows up in profiling
+fun parseThinkBlocks(rawContent: String): ParsedMessageContent {
+ val thinkStartTag = ""
+ val thinkEndTag = ""
+
+ if (!rawContent.contains(thinkStartTag)) {
+ return ParsedMessageContent(thinkContent = null, hasUnclosedThink = false, mainContent = rawContent)
+ }
+
+ val thinkBlocks = mutableListOf()
+ val mainBlocks = mutableListOf()
+ var currIndex = 0
+ var hasUnclosed = false
+
+ while (currIndex < rawContent.length) {
+ val startIndex = rawContent.indexOf(thinkStartTag, currIndex)
+ if (startIndex == -1) {
+ val remaining = rawContent.substring(currIndex).trim()
+ if (remaining.isNotEmpty()) mainBlocks.add(remaining)
+ break
+ }
+
+ val before = rawContent.substring(currIndex, startIndex).trim()
+ if (before.isNotEmpty()) mainBlocks.add(before)
+
+ val endIndex = rawContent.indexOf(thinkEndTag, startIndex + thinkStartTag.length)
+ if (endIndex != -1) {
+ val thinkText = rawContent.substring(startIndex + thinkStartTag.length, endIndex).trim()
+ if (thinkText.isNotEmpty()) thinkBlocks.add(thinkText)
+ currIndex = endIndex + thinkEndTag.length
+ } else {
+ val unclosedThink = rawContent.substring(startIndex + thinkStartTag.length).trim()
+ if (unclosedThink.isNotEmpty()) thinkBlocks.add(unclosedThink)
+ hasUnclosed = true
+ currIndex = rawContent.length
+ break
+ }
+ }
+
+ val combinedThink = if (thinkBlocks.isNotEmpty()) thinkBlocks.joinToString("\n\n---\n\n") else null
+ val combinedMain = mainBlocks.joinToString("\n\n")
+
+ return ParsedMessageContent(
+ thinkContent = combinedThink,
+ hasUnclosedThink = hasUnclosed,
+ mainContent = combinedMain
+ )
+}
+
@Composable
-fun ChatBubble(message: ChatMessage) {
+fun ChatBubble(
+ message: ChatMessage,
+ isActivelyGenerating: Boolean = false,
+ isReasoningModel: Boolean = false
+) {
val bubbleColor = if (message.isUser) {
MaterialTheme.colorScheme.primary
} else {
@@ -920,7 +963,7 @@ fun ChatBubble(message: ChatMessage) {
maxLines = 1
)
Text(
- text = "${message.fileType?.uppercase() ?: "Document"} \u2022 Tap to open",
+ text = "${message.fileType?.uppercase() ?: "Document"} • Tap to open",
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -930,51 +973,89 @@ fun ChatBubble(message: ChatMessage) {
Spacer(modifier = Modifier.height(8.dp))
}
- if (message.content.isNotEmpty()) {
- Text(
- text = message.content,
- color = textColor,
- fontSize = 15.sp,
- lineHeight = 22.sp,
- modifier = Modifier.padding(vertical = 4.dp)
- )
+ // Parse think blocks only for AI assistant messages from reasoning models
+ // TODO: consider incremental parsing from last known offset if this shows up in profiling
+ val parsed = if (!message.isUser && isReasoningModel) {
+ remember(message.content) { parseThinkBlocks(message.content) }
+ } else {
+ ParsedMessageContent(thinkContent = null, hasUnclosedThink = false, mainContent = message.content)
}
- // Telemetry pill (if available on assistant message)
- if (!message.isUser && message.telemetry != null) {
- val tel = message.telemetry
- Spacer(modifier = Modifier.height(4.dp))
- Box(
+ // Collapsible reasoning process accordion for DeepSeek-R1 CoT
+ if (!parsed.thinkContent.isNullOrBlank()) {
+ val isThinkingLive = parsed.hasUnclosedThink && isActivelyGenerating
+ val isThinkingStopped = parsed.hasUnclosedThink && !isActivelyGenerating
+ var isUserExpanded by remember(message.id) { mutableStateOf(null) }
+ val isThinkExpanded = isUserExpanded ?: isThinkingLive
+
+ val headerText = when {
+ isThinkingLive -> "Thinking in progress…"
+ isThinkingStopped -> "Thinking (stopped)"
+ else -> "Thinking Process"
+ }
+
+ val headerColor = when {
+ isThinkingStopped -> MaterialTheme.colorScheme.onSurfaceVariant
+ else -> MaterialTheme.colorScheme.primary
+ }
+
+ Card(
+ shape = RoundedCornerShape(10.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isSystemInDarkTheme()) Color(0xFF1E293B).copy(alpha = 0.6f) else Color(0xFFF1F5F9)
+ ),
+ border = BorderStroke(
+ 1.dp,
+ if (isSystemInDarkTheme()) Color(0xFF334155) else Color(0xFFE2E8F0)
+ ),
modifier = Modifier
- .clip(RoundedCornerShape(8.dp))
- .background(
- if (isSystemInDarkTheme()) Color(0xFF1E293B)
- else Color(0xFFF1F5F9)
- )
- .border(
- 1.dp,
- if (isSystemInDarkTheme()) Color(0xFF334155)
- else Color(0xFFE2E8F0),
- RoundedCornerShape(8.dp)
- )
- .padding(horizontal = 8.dp, vertical = 4.dp)
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
) {
- val ttftText = if (tel.ttftMs < 1000) {
- "${tel.ttftMs}ms"
- } else {
- String.format(java.util.Locale.US, "%.1fs", tel.ttftMs / 1000.0)
+ Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { isUserExpanded = !isThinkExpanded },
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = headerText,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold,
+ color = headerColor
+ )
+ Icon(
+ imageVector = if (isThinkExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
+ contentDescription = "Toggle thought process",
+ tint = headerColor,
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ if (isThinkExpanded) {
+ Spacer(modifier = Modifier.height(6.dp))
+ Text(
+ text = parsed.thinkContent,
+ fontSize = 12.sp,
+ lineHeight = 17.sp,
+ color = if (isSystemInDarkTheme()) Color(0xFF94A3B8) else Color(0xFF475569),
+ fontStyle = FontStyle.Italic
+ )
+ }
}
- val speedText = String.format(java.util.Locale.US, "%.1f", tel.decodeSpeedTokPerSec)
- val statusSuffix = if (tel.wasCancelled) " · stopped" else ""
-
- Text(
- text = "⚡ TTFT: $ttftText (~${tel.promptTokens} prompt tok) · $speedText tok/s (${tel.decodeTokens} tok$statusSuffix)",
- fontSize = 10.sp,
- fontWeight = FontWeight.Medium,
- color = if (tel.wasCancelled) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
- )
}
}
+
+ if (parsed.mainContent.isNotEmpty()) {
+ Text(
+ text = parsed.mainContent,
+ color = textColor,
+ fontSize = 15.sp,
+ lineHeight = 22.sp,
+ modifier = Modifier.padding(vertical = 4.dp)
+ )
+ }
if (message.ocrText != null) {
Spacer(modifier = Modifier.height(8.dp))
@@ -1262,4 +1343,3 @@ fun EmptyStateOnboarding(
}
}
}
-
diff --git a/app/src/main/java/com/example/auralocalai/ui/screens/ModelManagerScreen.kt b/app/src/main/java/com/example/auralocalai/ui/screens/ModelManagerScreen.kt
index b1bcf20..5acc1cc 100644
--- a/app/src/main/java/com/example/auralocalai/ui/screens/ModelManagerScreen.kt
+++ b/app/src/main/java/com/example/auralocalai/ui/screens/ModelManagerScreen.kt
@@ -153,6 +153,8 @@ fun ModelManagerScreen(
val isDownloaded = uiState.localModels.contains(preset.fileName)
val isActive = uiState.activeModelId == preset.id
val isDownloading = uiState.currentDownloadingModelId == preset.id
+ val isLoadingThisModel = uiState.loadingModelId == preset.id
+ val isOtherLoading = uiState.loadingModelId != null && !isLoadingThisModel
val benchmark = uiState.modelLoadBenchmarks[preset.id]
@@ -161,12 +163,15 @@ fun ModelManagerScreen(
isDownloaded = isDownloaded,
isActive = isActive,
isDownloading = isDownloading,
+ isLoading = isLoadingThisModel,
+ isOtherLoading = isOtherLoading,
downloadState = uiState.downloadState,
modelState = uiState.modelState,
- loadingStage = uiState.loadingStage,
+ loadingStage = if (isLoadingThisModel) uiState.loadingStage else null,
benchmark = benchmark,
onDownload = { viewModel.downloadModel(preset) },
onLoad = { viewModel.loadModel(preset.fileName, preset.id) },
+ onCancelLoad = { viewModel.cancelLoading() },
onCancel = { viewModel.cancelDownload() },
onDelete = { modelToDelete = preset }
)
@@ -365,24 +370,27 @@ fun ModelManagerScreen(
}
}
+@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PresetModelCard(
preset: ModelPreset,
isDownloaded: Boolean,
isActive: Boolean,
isDownloading: Boolean,
+ isLoading: Boolean,
+ isOtherLoading: Boolean,
downloadState: DownloadState,
modelState: ModelState,
loadingStage: String? = null,
benchmark: com.example.auralocalai.ui.ModelLoadBenchmark? = null,
onDownload: () -> Unit,
onLoad: () -> Unit,
+ onCancelLoad: () -> Unit,
onCancel: () -> Unit,
onDelete: () -> Unit
) {
Card(
- modifier = Modifier
- .fillMaxWidth(),
+ modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
border = BorderStroke(
width = if (isActive) 1.5.dp else 1.dp,
@@ -406,9 +414,10 @@ fun PresetModelCard(
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(6.dp))
- Row(
+ FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
- verticalAlignment = Alignment.CenterVertically
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = Modifier.fillMaxWidth()
) {
// Size Pill tag
Box(
@@ -535,11 +544,59 @@ fun PresetModelCard(
}
}
}
+
+ // Dedicated loading stage banner if currently initializing
+ if (isLoading && loadingStage != null) {
+ Spacer(modifier = Modifier.height(12.dp))
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f))
+ .border(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.25f), RoundedCornerShape(12.dp))
+ .padding(horizontal = 12.dp, vertical = 8.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ modifier = Modifier.weight(1f),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(14.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = loadingStage,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ maxLines = 2
+ )
+ }
+ Text(
+ text = "Cancel",
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier
+ .clickable { onCancelLoad() }
+ .padding(horizontal = 6.dp, vertical = 2.dp)
+ )
+ }
+ }
+ }
+
Spacer(modifier = Modifier.height(16.dp))
// Action / Download panel
if (isDownloading) {
- DownloadProgressPanel(state = downloadState, onCancel = onCancel)
+ DownloadProgressPanel(state = downloadState, onCancel = onCancel, onRetry = onDownload)
} else {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -560,13 +617,12 @@ fun PresetModelCard(
)
}
} else {
- val isLoading = modelState is ModelState.Loading
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = onDelete,
- enabled = !isLoading,
+ enabled = !isLoading && !isOtherLoading,
shape = RoundedCornerShape(18.dp),
modifier = Modifier.height(36.dp),
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp),
@@ -584,17 +640,35 @@ fun PresetModelCard(
Button(
onClick = onLoad,
- enabled = !isLoading,
+ enabled = !isLoading && !isOtherLoading,
shape = RoundedCornerShape(18.dp),
modifier = Modifier.height(36.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 0.dp),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
) {
- Text(
- text = if (isLoading) (loadingStage ?: "Loading…") else "Load Model",
- fontSize = 13.sp,
- fontWeight = FontWeight.Bold
- )
+ if (isLoading) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp)
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(12.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ Text(
+ text = "Loading…",
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ } else {
+ Text(
+ text = "Load Model",
+ fontSize = 13.sp,
+ fontWeight = FontWeight.Bold
+ )
+ }
}
}
}
@@ -622,7 +696,8 @@ fun PresetModelCard(
@Composable
fun DownloadProgressPanel(
state: DownloadState,
- onCancel: () -> Unit
+ onCancel: () -> Unit,
+ onRetry: (() -> Unit)? = null
) {
Column(
modifier = Modifier
@@ -706,21 +781,42 @@ fun DownloadProgressPanel(
is DownloadState.Error -> {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
- text = "Error: ${state.message}",
- fontSize = 12.sp,
+ text = "Download Failed",
+ fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(4.dp))
Text(
- text = "Retry",
- fontSize = 12.sp,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.primary,
- modifier = Modifier
- .clickable { onCancel() }
- .padding(vertical = 4.dp)
+ text = state.message,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.error,
+ lineHeight = 15.sp
)
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Button(
+ onClick = { onRetry?.invoke() ?: onCancel() },
+ shape = RoundedCornerShape(12.dp),
+ modifier = Modifier.height(32.dp),
+ contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
+ ) {
+ Text("Retry Download", fontSize = 11.sp, fontWeight = FontWeight.Bold)
+ }
+ OutlinedButton(
+ onClick = onCancel,
+ shape = RoundedCornerShape(12.dp),
+ modifier = Modifier.height(32.dp),
+ contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp)
+ ) {
+ Text("Dismiss", fontSize = 11.sp, fontWeight = FontWeight.Bold)
+ }
+ }
}
}
}
diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml
new file mode 100644
index 0000000..a6d3dff
--- /dev/null
+++ b/app/src/main/res/drawable/ic_download.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_download_done.xml b/app/src/main/res/drawable/ic_download_done.xml
new file mode 100644
index 0000000..24e6a30
--- /dev/null
+++ b/app/src/main/res/drawable/ic_download_done.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
index 8b7c0e9..67cc960 100644
--- a/app/src/main/res/xml/network_security_config.xml
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -1,8 +1,9 @@
-
+
+
@@ -10,5 +11,7 @@
hf.co
amazonaws.com
cloudfront.net
+ cloudflarestorage.com
+ xethub.com
diff --git a/app/src/test/java/com/example/auralocalai/ui/TelemetryBenchmarkTest.kt b/app/src/test/java/com/example/auralocalai/ui/TelemetryBenchmarkTest.kt
index dedad8a..7a5e07e 100644
--- a/app/src/test/java/com/example/auralocalai/ui/TelemetryBenchmarkTest.kt
+++ b/app/src/test/java/com/example/auralocalai/ui/TelemetryBenchmarkTest.kt
@@ -125,6 +125,7 @@ class TelemetryBenchmarkTest {
val gemmaE4b = presets.first { it.id == "gemma4-e4b" }
assertEquals("mixed 2/4/8-bit", gemmaE4b.quantization)
assertEquals("4.0B", gemmaE4b.parameterCount)
+ assertEquals("12 GB+ RAM", gemmaE4b.ramRequirement)
}
@Test
diff --git a/app/src/test/java/com/example/auralocalai/ui/screens/ParseThinkBlocksTest.kt b/app/src/test/java/com/example/auralocalai/ui/screens/ParseThinkBlocksTest.kt
new file mode 100644
index 0000000..82736fb
--- /dev/null
+++ b/app/src/test/java/com/example/auralocalai/ui/screens/ParseThinkBlocksTest.kt
@@ -0,0 +1,127 @@
+package com.example.auralocalai.ui.screens
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import com.example.auralocalai.data.ModelPreset
+
+/**
+ * Unit tests for [parseThinkBlocks] and model reasoning resolution logic.
+ * Pure Kotlin string manipulation with zero Android framework dependencies.
+ */
+class ParseThinkBlocksTest {
+
+ @Test
+ fun testSingleClosedThinkBlock() {
+ val raw = "Let's calculate 2 + 2 = 4.The answer is 4."
+ val result = parseThinkBlocks(raw)
+
+ assertEquals("Let's calculate 2 + 2 = 4.", result.thinkContent)
+ assertFalse("Block was closed cleanly", result.hasUnclosedThink)
+ assertEquals("The answer is 4.", result.mainContent)
+ }
+
+ @Test
+ fun testUnclosedThinkBlock_MidGenerationOrCancelled() {
+ val raw = "Currently analyzing the user's prompt step by step..."
+ val result = parseThinkBlocks(raw)
+
+ assertEquals("Currently analyzing the user's prompt step by step...", result.thinkContent)
+ assertTrue("Block remains unclosed upon cancellation", result.hasUnclosedThink)
+ assertEquals("", result.mainContent)
+ }
+
+ @Test
+ fun testMultipleThinkBlocks_Interleaved() {
+ val raw = "First phase reasoningIntermediate observation.Second phase refinementFinal answer."
+ val result = parseThinkBlocks(raw)
+
+ assertEquals("First phase reasoning\n\n---\n\nSecond phase refinement", result.thinkContent)
+ assertFalse("All blocks closed", result.hasUnclosedThink)
+ assertEquals("Intermediate observation.\n\nFinal answer.", result.mainContent)
+ }
+
+ @Test
+ fun testMultipleThinkBlocks_TrailingUnclosed() {
+ val raw = "Step 1 doneMidway.Step 2 interrupted"
+ val result = parseThinkBlocks(raw)
+
+ assertEquals("Step 1 done\n\n---\n\nStep 2 interrupted", result.thinkContent)
+ assertTrue("Trailing block is unclosed", result.hasUnclosedThink)
+ assertEquals("Midway.", result.mainContent)
+ }
+
+ @Test
+ fun testNoThinkBlock_StandardOrCodingOutput() {
+ val raw = "fun main() {\n println(\"Hello World\")\n}"
+ val result = parseThinkBlocks(raw)
+
+ assertNull("No think blocks present", result.thinkContent)
+ assertFalse(result.hasUnclosedThink)
+ assertEquals("fun main() {\n println(\"Hello World\")\n}", result.mainContent)
+ }
+
+ @Test
+ fun testThinkBlock_EmbeddedInCode() {
+ val raw = "val x = 1\nReasoning about x\nval y = x + 1"
+ val result = parseThinkBlocks(raw)
+
+ assertEquals("Reasoning about x", result.thinkContent)
+ assertFalse(result.hasUnclosedThink)
+ assertEquals("val x = 1\n\nval y = x + 1", result.mainContent)
+ }
+
+ @Test
+ fun testEmptyAndWhitespaceThinkBlocks() {
+ val emptyRaw = "Direct response"
+ val emptyResult = parseThinkBlocks(emptyRaw)
+ assertNull("Empty think block resolves to null content", emptyResult.thinkContent)
+ assertFalse(emptyResult.hasUnclosedThink)
+ assertEquals("Direct response", emptyResult.mainContent)
+
+ val whitespaceRaw = " \n\t Direct response"
+ val wsResult = parseThinkBlocks(whitespaceRaw)
+ assertNull("Whitespace-only think block resolves to null content", wsResult.thinkContent)
+ assertFalse(wsResult.hasUnclosedThink)
+ assertEquals("Direct response", wsResult.mainContent)
+ }
+
+ @Test
+ fun testLeadingWhitespaceBeforeThink() {
+ val raw = "\n\n Analyzing...Here is the result."
+ val result = parseThinkBlocks(raw)
+
+ assertEquals("Analyzing...", result.thinkContent)
+ assertFalse(result.hasUnclosedThink)
+ assertEquals("Here is the result.", result.mainContent)
+ }
+
+ @Test
+ fun testModelPresetReasoningResolution() {
+ // 1. Catalog reasoning model
+ assertTrue(ModelPreset.isReasoningModel("deepseek-1.5b"))
+ assertFalse(ModelPreset.isKnownNonReasoningModel("deepseek-1.5b"))
+
+ // 2. Catalog known non-reasoning models
+ assertFalse(ModelPreset.isReasoningModel("qwen2.5-coder-3b"))
+ assertTrue(ModelPreset.isKnownNonReasoningModel("qwen2.5-coder-3b"))
+ assertFalse(ModelPreset.isReasoningModel("gemma4-e2b"))
+ assertTrue(ModelPreset.isKnownNonReasoningModel("gemma4-e2b"))
+
+ // 3. Custom / unknown reasoning models
+ assertTrue(ModelPreset.isReasoningModel("custom-deepseek-r1-7b.litertlm"))
+ assertFalse(ModelPreset.isKnownNonReasoningModel("custom-deepseek-r1-7b.litertlm"))
+ assertTrue(ModelPreset.isReasoningModel("qwq-32b-preview.litertlm"))
+ assertFalse(ModelPreset.isKnownNonReasoningModel("qwq-32b-preview.litertlm"))
+
+ // 4. Custom unknown model (fallback to startsWith("") in UI layer)
+ assertFalse(ModelPreset.isReasoningModel("arbitrary_model.litertlm"))
+ assertFalse(ModelPreset.isKnownNonReasoningModel("arbitrary_model.litertlm"))
+
+ // 5. Null handling
+ assertFalse(ModelPreset.isReasoningModel(null))
+ assertFalse(ModelPreset.isKnownNonReasoningModel(null))
+ }
+}
diff --git a/tools/validate_device.ps1 b/tools/validate_device.ps1
new file mode 100644
index 0000000..d1f596b
--- /dev/null
+++ b/tools/validate_device.ps1
@@ -0,0 +1,161 @@
+<#
+.SYNOPSIS
+ Aura Local AI - Physical Device Silicon, RAM & Acceleration Diagnostics.
+
+.DESCRIPTION
+ Inspects a connected physical Android device over ADB to verify hardware readiness
+ for on-device LLM inference (LiteRT-LM, Vulkan, Qualcomm QNN Hexagon NPU).
+ Checks CPU ABI (arm64-v8a), total RAM against ModelSafetyValidator tier thresholds,
+ Vulkan GPU extensions, and Qualcomm Hexagon DSP runtime libraries.
+
+.PARAMETER DryRun
+ Runs a simulated diagnostic verification without requiring an active ADB device.
+
+.PARAMETER Install
+ Installs app/build/outputs/apk/debug/app-debug.apk to the device after diagnostics.
+
+.PARAMETER FollowLogs
+ Streams logcat filtered for Aura Local AI inference, memory guards, and security events.
+
+.PARAMETER DeviceId
+ Specific ADB device serial if multiple devices are attached.
+#>
+
+param (
+ [switch]$DryRun,
+ [switch]$Install,
+ [switch]$FollowLogs,
+ [string]$DeviceId = ""
+)
+
+$ErrorActionPreference = "Continue"
+
+Write-Host "=================================================================" -ForegroundColor Cyan
+Write-Host " Aura Local AI: Physical Hardware Diagnostic Suite " -ForegroundColor Cyan
+Write-Host "=================================================================" -ForegroundColor Cyan
+
+# Locate ADB
+$adbCmd = "adb"
+if (-not (Get-Command "adb" -ErrorAction SilentlyContinue)) {
+ $sdkAdb = Join-Path $env:LOCALAPPDATA "Android\Sdk\platform-tools\adb.exe"
+ if (Test-Path -LiteralPath $sdkAdb) {
+ $adbCmd = $sdkAdb
+ } elseif (-not $DryRun) {
+ Write-Error "ADB not found in PATH or Android SDK location. Install Android platform-tools or run with -DryRun."
+ exit 1
+ }
+}
+
+if ($DryRun) {
+ Write-Host "`n[MODE] Running Simulated Hardware Validation (-DryRun)`n" -ForegroundColor Yellow
+
+ $model = "Samsung Galaxy S24 Ultra (SM-S928B) [Simulated]"
+ $abi = "arm64-v8a"
+ $totalKb = 12150000
+ $availKb = 7800000
+ $vulkanSupported = $true
+ $qnnFound = $true
+} else {
+ $adbArgs = if ($DeviceId) { @("-s", $DeviceId) } else { @() }
+
+ # Check attached devices
+ $devices = & $adbCmd @adbArgs devices | Select-String -Pattern "\sdevice$"
+ if (-not $devices) {
+ Write-Warning "No active ADB device detected. Connect a physical phone with USB debugging enabled, or test with -DryRun."
+ exit 1
+ }
+
+ Write-Host "`n[ADB] Probing connected target device..." -ForegroundColor Green
+ $model = (& $adbCmd @adbArgs shell getprop ro.product.model).Trim()
+ $abi = (& $adbCmd @adbArgs shell getprop ro.product.cpu.abi).Trim()
+
+ # Extract MemTotal & MemAvailable from /proc/meminfo
+ $meminfo = & $adbCmd @adbArgs shell cat /proc/meminfo
+ $totalKb = ($meminfo | Select-String "MemTotal" | ForEach-Object { ($_ -split "\s+")[1] }) -as [long]
+ $availKb = ($meminfo | Select-String "MemAvailable" | ForEach-Object { ($_ -split "\s+")[1] }) -as [long]
+
+ # Check Vulkan feature
+ $vulkanCheck = & $adbCmd @adbArgs shell pm list features | Select-String "feature:android.hardware.vulkan"
+ $vulkanSupported = [bool]$vulkanCheck
+
+ # Check Qualcomm QNN libraries
+ $qnnCheck = & $adbCmd @adbArgs shell "ls /vendor/lib64/libQnn* /system/lib64/libQnn* 2>/dev/null"
+ $qnnFound = [bool]($qnnCheck | Select-String "libQnnHtp.so")
+}
+
+$totalGiB = [math]::Round($totalKb / (1024 * 1024), 2)
+$availGiB = [math]::Round($availKb / (1024 * 1024), 2)
+
+Write-Host "Device Model : $model" -ForegroundColor White
+Write-Host "CPU Architecture : $abi" -NoNewline
+if ($abi -eq "arm64-v8a") {
+ Write-Host " [SUPPORTED (arm64-v8a)]" -ForegroundColor Green
+} else {
+ Write-Host " [UNSUPPORTED (Requires 64-bit ARM)]" -ForegroundColor Red
+}
+
+Write-Host "Total Physical RAM : $totalGiB GiB visible" -ForegroundColor White
+Write-Host "Current Free RAM : $availGiB GiB available" -ForegroundColor White
+Write-Host " [NOTE] MemAvailable via ADB represents idle headroom. Real-world OEM skins" -ForegroundColor DarkGray
+Write-Host " and background apps reduce available RAM by ~1.0-2.0 GiB during active usage." -ForegroundColor DarkGray
+
+# Compare against ModelSafetyValidator Tier Thresholds
+$tierFloor4Gb = 3.22 # (4 * 1024^3 - 800 MiB)
+$tierFloor6Gb = 5.22 # (6 * 1024^3 - 800 MiB)
+$tierFloor8Gb = 7.22 # (8 * 1024^3 - 800 MiB)
+$tierFloor12Gb = 11.22 # (12 * 1024^3 - 800 MiB)
+
+Write-Host "`nHardware RAM Tier Classification:" -ForegroundColor Yellow
+if ($totalGiB -ge $tierFloor12Gb) {
+ Write-Host " -> Tier: 12 GB+ (Tier 3) - Flagship headroom. Supports all models including Gemma 4 E4B (3.4 GB) with Multimodal Vision." -ForegroundColor Green
+} elseif ($totalGiB -ge $tierFloor8Gb) {
+ Write-Host " -> Tier: 8 GB (Tier 2) - Supports Qwen 3 4B (2.5 GB), Coder 3B (2.9 GB), Gemma 4 E2B (2.4 GB)." -ForegroundColor Green
+ Write-Host " Note: Gemma 4 E4B requires 12 GB+ RAM for safe multimodal execution." -ForegroundColor DarkGray
+} elseif ($totalGiB -ge $tierFloor6Gb) {
+ Write-Host " -> Tier: 6 GB (Tier 1) - Supports DeepSeek-R1 1.5B (2.0 GB), Qwen 2.5 1.5B (1.8 GB)." -ForegroundColor Green
+ Write-Host " Note: Models requiring 8 GB+ or 12 GB+ are safely locked out." -ForegroundColor DarkGray
+} elseif ($totalGiB -ge $tierFloor4Gb) {
+ Write-Host " -> Tier: 4 GB - Constrained memory. Suitable for sub-2B models with 4-turn minimal context." -ForegroundColor Yellow
+} else {
+ Write-Host " -> Tier: <4 GB - Insufficient RAM for safe on-device LLM inference." -ForegroundColor Red
+}
+
+Write-Host "`nHardware Acceleration Checks:" -ForegroundColor Yellow
+Write-Host " Vulkan GPU Engine : " -NoNewline
+if ($vulkanSupported) {
+ Write-Host "[DETECTED / VULKAN ACCELERATION READY]" -ForegroundColor Green
+} else {
+ Write-Host "[NOT FOUND / CPU FALLBACK ONLY]" -ForegroundColor Yellow
+}
+
+Write-Host " Qualcomm QNN HTP : " -NoNewline
+if ($qnnFound) {
+ Write-Host "[FOUND (Hexagon Tensor Processor Runtime Available)]" -ForegroundColor Green
+} else {
+ Write-Host "[NOT DETECTED (GPU/CPU Execution Path)]" -ForegroundColor Gray
+}
+
+# Optional Installation
+if ($Install) {
+ $apkPath = Join-Path $PSScriptRoot "..\app\build\outputs\apk\debug\app-debug.apk"
+ if (Test-Path $apkPath) {
+ Write-Host "`n[INSTALL] Deploying app-debug.apk..." -ForegroundColor Cyan
+ & $adbCmd @adbArgs install -r $apkPath
+ if ($LASTEXITCODE -eq 0) {
+ Write-Host "[SUCCESS] Installed successfully! Launching Aura Local AI..." -ForegroundColor Green
+ & $adbCmd @adbArgs shell am start -n "com.example.auralocalai/com.example.auralocalai.MainActivity"
+ } else {
+ Write-Error "Installation failed with exit code $LASTEXITCODE"
+ }
+ } else {
+ Write-Warning "APK not found at $apkPath. Run './gradlew assembleDebug' first."
+ }
+}
+
+# Optional Log Following
+if ($FollowLogs) {
+ Write-Host "`n[LOGCAT] Streaming filtered logs (Ctrl+C to exit)..." -ForegroundColor Cyan
+ & $adbCmd @adbArgs logcat -v time -s "LlmInferenceEngine:V" "ModelSafetyValidator:V" "ModelDownloader:V" "TokenStorage:V" "LlmViewModel:V"
+}
+
+Write-Host "`nDiagnostics Complete." -ForegroundColor Cyan