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