Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.itsaky.androidide.plugins.aiassistant.util

import android.content.ContentResolver
import android.net.Uri
import android.util.Log
import java.io.InputStream

/**
* Best-effort GGUF validation for a SAF-selected document.
*
* Kept out of AiSettingsViewModel so the ViewModel stays focused on UI state (SRP). It is
* self-contained rather than reusing ai-core's `GgufModelInspector` because ai-assistant and
* ai-core are separate plugins with isolated classloaders — ai-assistant cannot import ai-core's
* classes, so this magic-byte check is unavoidably (and deliberately) duplicated across the
* boundary. This object is the single definition on the ai-assistant side; keep the magic in sync
* with `GgufModelInspector.GGUF_MAGIC` in ai-core if the format ever changes.
*/
internal object GgufFileInspector {

private const val TAG = "GgufFileInspector"

/** The GGUF magic: the four ASCII bytes a `.gguf` file starts with. */
private val GGUF_MAGIC = "GGUF".toByteArray(Charsets.US_ASCII)

/**
* @param header first bytes of a candidate file
* @return true if [header] starts with the GGUF magic ("GGUF")
*/
fun bytesAreGgufMagic(header: ByteArray): Boolean =
header.size >= GGUF_MAGIC.size && GGUF_MAGIC.indices.all { header[it] == GGUF_MAGIC[it] }

/**
* Best-effort check that the picked document is a GGUF model, reading only its first four
* bytes. Fails OPEN (returns true) on any read error or short file so a real model is never
* wrongly rejected; SAF can't filter the picker by a .gguf extension. Do NOT call on the main
* thread.
* @param resolver content resolver used to open [uriString]
* @param uriString content URI (or path) of the selected document
* @return false only when four bytes were read and are not the GGUF magic; true otherwise
*/
fun looksLikeGguf(resolver: ContentResolver, uriString: String): Boolean =
looksLikeGguf(
openStream = { resolver.openInputStream(Uri.parse(uriString)) },
onReadError = { e ->
Log.w(TAG, "Could not read model header for $uriString; skipping pre-check", e)
},
)

/**
* Android-free core of [looksLikeGguf], so every fail-open path (document that can't be
* opened, stream that throws mid-read, truncated file) is unit-testable without a device.
* @param openStream opens the candidate document, or returns null if it can't be opened
* @param onReadError invoked with the failure when the stream can't be opened or read
* @return false only when four bytes were read and are not the GGUF magic; true otherwise
*/
fun looksLikeGguf(openStream: () -> InputStream?, onReadError: (Exception) -> Unit): Boolean =
try {
openStream()?.use { readsGgufMagic(it) } ?: true
} catch (e: Exception) {
onReadError(e)
true
}

/**
* @param input stream positioned at the start of the candidate document
* @return true if the first four bytes are the GGUF magic, or if the stream ended early
*/
private fun readsGgufMagic(input: InputStream): Boolean {
val header = ByteArray(GGUF_MAGIC.size)
var off = 0
// Read may return fewer than 4 bytes at a time, so loop until full or EOF.
while (off < header.size) {
val n = input.read(header, off, header.size - off)
if (n < 0) break
off += n
}
return if (off < header.size) true else bytesAreGgufMagic(header)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.itsaky.androidide.plugins.aiassistant.security.SecureApiKeyStore
import com.itsaky.androidide.plugins.aiassistant.R
import com.itsaky.androidide.plugins.aiassistant.util.GgufFileInspector
import com.itsaky.androidide.plugins.services.LlmInferenceService
import com.itsaky.androidide.plugins.services.SharedServices
import kotlinx.coroutines.CoroutineDispatcher
Expand Down Expand Up @@ -379,6 +381,14 @@ class AiSettingsViewModel(
// Resolve the real file name (not the raw content-URI doc id) for display.
val fileName = resolveDisplayName(uriString)

// Reject a non-GGUF pick up front, so no bad path is persisted or shown as "Loaded".
if (!GgufFileInspector.looksLikeGguf(context.contentResolver, uriString)) {
_modelLoadingState.postValue(
ModelLoadingState.Error(context.getString(R.string.error_model_not_gguf, fileName))
)
return@launch
}

// Persist the name before the path so the savedModelPath observer can read it.
saveLocalModelName(fileName)
saveLocalModelPath(uriString)
Expand Down
3 changes: 3 additions & 0 deletions ai-assistant/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,7 @@
<string name="loading">Loading…</string>
<string name="refresh_models">Refresh Models</string>
<string name="model_changed">Model changed to %s</string>

<!-- Local model selection -->
<string name="error_model_not_gguf">\"%1$s\" isn\'t a valid .gguf model (it may be the wrong file or a corrupt or partial download). Select a .gguf chat model.</string>
</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.itsaky.androidide.plugins.aiassistant.util

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 java.io.ByteArrayInputStream
import java.io.IOException
import java.io.InputStream

class GgufFileInspectorTest {

/** Captures whatever [GgufFileInspector.looksLikeGguf] reports, standing in for Log.w. */
private var reportedError: Exception? = null

private fun looksLikeGguf(openStream: () -> InputStream?): Boolean =
GgufFileInspector.looksLikeGguf(openStream) { reportedError = it }

@Test
fun givenGgufMagic_whenChecked_thenAccepted() {
val header = byteArrayOf(0x47, 0x47, 0x55, 0x46) // "GGUF"
assertTrue(GgufFileInspector.bytesAreGgufMagic(header))
}

@Test
fun givenMagicWithTrailingBytes_whenChecked_thenAccepted() {
val header = byteArrayOf(0x47, 0x47, 0x55, 0x46, 0x03, 0x00)
assertTrue(GgufFileInspector.bytesAreGgufMagic(header))
}

@Test
fun givenZipMagic_whenChecked_thenRejected() {
val header = byteArrayOf(0x50, 0x4B, 0x03, 0x04) // "PK.." — a .zip renamed to .gguf
assertFalse(GgufFileInspector.bytesAreGgufMagic(header))
}

@Test
fun givenFewerThanFourBytes_whenChecked_thenRejected() {
assertFalse(GgufFileInspector.bytesAreGgufMagic(byteArrayOf(0x47, 0x47, 0x55)))
}

@Test
fun givenGgufStream_whenInspected_thenAccepted() {
assertTrue(looksLikeGguf { ByteArrayInputStream(byteArrayOf(0x47, 0x47, 0x55, 0x46, 0x03)) })
assertNull(reportedError)
}

@Test
fun givenNonGgufStream_whenInspected_thenRejected() {
assertFalse(looksLikeGguf { ByteArrayInputStream(byteArrayOf(0x50, 0x4B, 0x03, 0x04)) })
assertNull(reportedError)
}

@Test
fun givenStreamThatThrowsMidRead_whenInspected_thenFailsOpenAndReports() {
// SAF can drop a content:// stream mid-transfer; a real model must not be rejected for it.
val truncating = object : InputStream() {
private var served = 0
override fun read(): Int = throw IOException("stream died")
override fun read(b: ByteArray, off: Int, len: Int): Int {
if (served > 0) throw IOException("stream died")
served = 2
b[off] = 0x47
b[off + 1] = 0x47
return 2
}
}

assertTrue(looksLikeGguf { truncating })
assertEquals("stream died", reportedError?.message)
}

@Test
fun givenUnopenableDocument_whenInspected_thenFailsOpenAndReports() {
assertTrue(looksLikeGguf { throw SecurityException("permission revoked") })
assertEquals("permission revoked", reportedError?.message)
}

@Test
fun givenNullStream_whenInspected_thenFailsOpenWithoutError() {
// openInputStream() returns null for a provider that can't produce the document.
assertTrue(looksLikeGguf { null })
assertNull(reportedError)
}

@Test
fun givenTruncatedStream_whenInspected_thenFailsOpen() {
// Fewer than four bytes available: not enough to judge, so don't reject.
assertTrue(looksLikeGguf { ByteArrayInputStream(byteArrayOf(0x47, 0x47)) })
assertNull(reportedError)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.itsaky.androidide.plugins.aicore

import java.util.Locale

/**
* Formats byte counts for display.
*
* Deliberately binary (÷1024³) and US-locale rather than
* `android.text.format.Formatter.formatShortFileSize()`, which reports decimal (SI) units: these
* figures describe device RAM, which every Android surface (Settings, `ActivityManager`, spec
* sheets) reports in binary units, so a decimal "8.6 GB" for 8 GiB of RAM would read as wrong.
* Kept out of the backend so the engine has no formatting logic and this stays unit-testable.
*/
internal object ByteSize {

private const val BYTES_PER_MB = 1024.0 * 1024.0
private const val BYTES_PER_GB = BYTES_PER_MB * 1024.0

/**
* Formats [bytes] with the largest unit that keeps the figure meaningful. Sub-gigabyte values
* fall back to MB: a device with 12 MB free must not be described as having "0.0 GB", which
* reads as a broken string rather than as a memory shortage.
*
* @param bytes a byte count
* @return the size as a one-decimal "X.X GB" or "X.X MB" string
*/
fun format(bytes: Long): String =
if (bytes >= BYTES_PER_GB) String.format(Locale.US, "%.1f GB", bytes / BYTES_PER_GB)
else String.format(Locale.US, "%.1f MB", bytes / BYTES_PER_MB)
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ object GgufModelInspector {
val isEmbeddingOnly: Boolean get() = kind == ModelKind.EMBEDDING
}

/**
* Cheap magic-only check that never throws; reads just the first 4 bytes.
* @param modelPath path to the candidate file
* @return true if the file begins with the GGUF magic; false on any read error or mismatch
*/
fun isGguf(modelPath: String): Boolean = try {
DataInputStream(BufferedInputStream(FileInputStream(File(modelPath)), 16)).use { readU32(it) == GGUF_MAGIC }
} catch (_: Exception) {
false
}

/** Reads [modelPath]'s GGUF header and classifies it. Never throws. */
fun classify(modelPath: String): Result {
val arch = try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,8 @@ class LlmInferenceServiceImpl : LlmInferenceService {
return
}

// Delegate to Gemini backend with tool support
(backend as GeminiBackend).generateStreamingWithTools(prompt, history, config, tools, callback)
// Delegate to Gemini backend with tool support (smart-cast by the guard above)
backend.generateStreamingWithTools(prompt, history, config, tools, callback)
}

override fun getEmbeddings(text: String, backendId: String): CompletableFuture<FloatArray> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.itsaky.androidide.plugins.aicore

import android.app.ActivityManager
import android.content.Context
import android.llama.cpp.LLamaAndroid
import android.net.Uri
import android.provider.OpenableColumns
Expand Down Expand Up @@ -61,6 +63,9 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend, Cancella
@Volatile private var currentStreamingJob: Job? = null
@Volatile private var currentGenerateJob: Job? = null

/** Renders load diagnoses as user-facing text; keeps R.string lookups out of this engine. */
private val loadMessages by lazy { ModelLoadMessages(context.androidContext) }

@Volatile private var modelLoaded = false
@Volatile private var currentModelPath: String? = null

Expand Down Expand Up @@ -212,6 +217,13 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend, Cancella
}
}

/**
* Loads [modelPath] unless it is already resident, diagnosing any native failure into a
* [ModelLoadException]. Cancellation is rethrown first because [CancellationException] extends
* [IllegalStateException] and would otherwise be diagnosed as a corrupt model.
*
* @param modelPath the configured model path or content URI
*/
private suspend fun ensureModelLoaded(modelPath: String) {
// Resolve content URI to actual file path
val resolvedPath = resolveContentUriToPath(modelPath)
Expand Down Expand Up @@ -242,14 +254,36 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend, Cancella
currentModelPath = null
}

// Load new model with resolved path
context.logger.info("Loading model: $resolvedPath")
llama.load(resolvedPath)
try {
llama.load(resolvedPath)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
if (e is UserActionableLlmException) throw e
// Native load_model() signals failure only with a null handle, so diagnose the likely cause.
context.logger.error("Native model load failed for $resolvedPath", e)
val diagnosis = ModelLoadDiagnostics.diagnose(resolvedPath, availableMemoryBytes(), e.message)
throw ModelLoadException(loadMessages.describe(diagnosis), diagnosis)
}
modelLoaded = true
currentModelPath = resolvedPath
context.logger.info("Model loaded successfully")
}

/**
* @return free RAM the OS reports, or -1 if unreadable (diagnosis then skips the low-memory case)
*/
private fun availableMemoryBytes(): Long = try {
val am = context.androidContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val info = ActivityManager.MemoryInfo()
am.getMemoryInfo(info)
info.availMem
} catch (e: Exception) {
context.logger.warn("Could not read available memory: ${e.message}")
-1L
}

/**
* Wraps a turn in ChatML — the prompt format Qwen and most other on-device instruct GGUFs
* were fine-tuned on.
Expand Down Expand Up @@ -369,7 +403,7 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend, Cancella
throw ce
} catch (e: Exception) {
context.logger.error("Error during generation", e)
if (e is ModelNotConfiguredException || e is IncompatibleModelException) {
if (e is UserActionableLlmException) {
UserFeedback.notify(context.androidContext, e.message ?: "Local LLM is not configured.")
}
future.complete(LlmResponse.failure("Error: ${e.message}"))
Expand Down Expand Up @@ -474,7 +508,7 @@ class LocalLlmBackend(private val context: PluginContext) : LlmBackend, Cancella
throw ce
} catch (e: Exception) {
context.logger.error("Error during streaming generation", e)
if (e is ModelNotConfiguredException || e is IncompatibleModelException) {
if (e is UserActionableLlmException) {
UserFeedback.notify(context.androidContext, e.message ?: "Local LLM is not configured.")
}
callback.onError("Error: ${e.message}")
Expand Down
Loading