Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ The app includes built-in presets for several highly-capable, lightweight models
| --- | --- |
| **DeepSeek-R1 Distill Qwen 1.5B**<br/>• Parameters: 1.5B \| Size: ~1.7 GB<br/>• Min. RAM: 6 GB+ (Offline Reasoning) | **Qwen 2.5 Coder 3B Instruct**<br/>• Parameters: 3B \| Size: ~2.9 GB<br/>• Min. RAM: 8 GB+ (Coding Expert) |
| **Qwen 2.5 1.5B Instruct**<br/>• Parameters: 1.5B \| Size: ~1.5 GB<br/>• Min. RAM: 6 GB+ (General Knowledge) | **Google Gemma 4 E2B Instruct**<br/>• Parameters: 2B \| Size: ~2.4 GB<br/>• Min. RAM: 6 GB+ (Multimodal Vision) |
| **Qwen 3 4B**<br/>• Parameters: 4B \| Size: ~2.5 GB<br/>• Min. RAM: 8 GB+ (High Performance) | **Google Gemma 4 E4B Instruct**<br/>• Parameters: 4B \| Size: ~3.4 GB<br/>• Min. RAM: 8 GB+ (High-Res Multimodal) |
| **Qwen 3 4B**<br/>• Parameters: 4B \| Size: ~2.5 GB<br/>• Min. RAM: 8 GB+ (High Performance) | **Google Gemma 4 E4B Instruct**<br/>• Parameters: 4B \| Size: ~3.4 GB<br/>• Min. RAM: 12 GB+ (High-Res Multimodal) |
| **Qwen 2.5 0.5B Instruct**<br/>• Parameters: 0.5B \| Size: ~0.5 GB<br/>• Min. RAM: 4 GB+ (Ultra-Fast) | |

---
Expand Down
5 changes: 5 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ android {
shaders = false
}

lint {
abortOnError = false
checkReleaseBuilds = false
}



packaging {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -186,14 +186,25 @@ 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
activeBackend = "GPU"
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 {
Expand All @@ -203,7 +214,7 @@ class LlmInferenceEngine(private val context: Context) {
}
return@withContext Result.failure(Exception(msg, e))
}
}
}
}
}
}
Expand Down
114 changes: 65 additions & 49 deletions app/src/main/java/com/example/auralocalai/data/ModelDownloadService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,19 @@ 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
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(
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -130,15 +133,14 @@ class ModelDownloadService : Service() {
}
}

return START_STICKY
return START_NOT_STICKY
}

override fun onBind(intent: Intent?): IBinder? = null

override fun onDestroy() {
activeDownloadJob?.cancel()
serviceJob.cancel()
downloadState.value = ServiceDownloadState.Idle
super.onDestroy()
}

Expand All @@ -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
}
Expand All @@ -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)
}
}
21 changes: 18 additions & 3 deletions app/src/main/java/com/example/auralocalai/data/ModelPreset.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading