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
17 changes: 17 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,23 @@ android {
"HUGGINGFACE_API_KEY",
"\"${localProperties.getProperty("HUGGINGFACE_API_KEY") ?: ""}\""
)
// Bundled free-tier NVIDIA NIM developer key.
buildConfigField(
"String",
"NVIDIA_API_KEY",
"\"${localProperties.getProperty("NVIDIA_API_KEY") ?: ""}\""
)
// Bundled free-tier Cloudflare Workers AI token + account id.
buildConfigField(
"String",
"CLOUDFLARE_API_KEY",
"\"${localProperties.getProperty("CLOUDFLARE_API_KEY") ?: ""}\""
)
buildConfigField(
"String",
"CLOUDFLARE_ACCOUNT_ID",
"\"${localProperties.getProperty("CLOUDFLARE_ACCOUNT_ID") ?: ""}\""
)
}

lint {
Expand Down
Binary file modified app/release/baselineProfiles/0/app-arm64-v8a-release.dm
Binary file not shown.
Binary file modified app/release/baselineProfiles/0/app-armeabi-v7a-release.dm
Binary file not shown.
Binary file modified app/release/baselineProfiles/0/app-universal-release.dm
Binary file not shown.
Binary file modified app/release/baselineProfiles/0/app-x86_64-release.dm
Binary file not shown.
Binary file modified app/release/baselineProfiles/1/app-arm64-v8a-release.dm
Binary file not shown.
Binary file modified app/release/baselineProfiles/1/app-armeabi-v7a-release.dm
Binary file not shown.
Binary file modified app/release/baselineProfiles/1/app-universal-release.dm
Binary file not shown.
Binary file modified app/release/baselineProfiles/1/app-x86_64-release.dm
Binary file not shown.
6 changes: 5 additions & 1 deletion app/src/main/java/com/skeler/scanely/core/ai/AiLog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ private val SECRET_PATTERNS: List<Pair<Regex, String>> = listOf(
Regex("sk-ant-[A-Za-z0-9._\\-]+") to "***",
Regex("sk-or-[A-Za-z0-9._\\-]+") to "***",
Regex("sk-[A-Za-z0-9._\\-]+") to "***",
Regex("AIza[A-Za-z0-9._\\-]+") to "***"
Regex("AIza[A-Za-z0-9._\\-]+") to "***",
Regex("nvapi-[A-Za-z0-9._\\-]+") to "***",
Regex("gsk_[A-Za-z0-9._\\-]+") to "***",
Regex("hf_[A-Za-z0-9._\\-]+") to "***",
Regex("cfut_[A-Za-z0-9._\\-]+") to "***"
)

/**
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/skeler/scanely/core/ai/AiProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ enum class AiProvider(val displayName: String, val kind: ProviderKind) {
HUGGINGFACE("Hugging Face", ProviderKind.OPENAI_COMPAT),
NVIDIA("NVIDIA", ProviderKind.OPENAI_COMPAT),
GROQ("Groq", ProviderKind.OPENAI_COMPAT),
CEREBRAS("Cerebras", ProviderKind.OPENAI_COMPAT),
CLOUDFLARE("Cloudflare", ProviderKind.OPENAI_COMPAT),
OPENAI("OpenAI", ProviderKind.OPENAI_COMPAT),
CLAUDE("Claude", ProviderKind.ANTHROPIC),
CUSTOM("Custom", ProviderKind.OPENAI_COMPAT);
Expand Down
43 changes: 36 additions & 7 deletions app/src/main/java/com/skeler/scanely/core/ai/KeyVerifier.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import com.skeler.scanely.core.network.ClaudeApi
import com.skeler.scanely.core.network.KeyValidationApi
import com.skeler.scanely.core.network.NetworkObserver
import kotlinx.coroutines.CancellationException
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
Expand All @@ -21,8 +23,9 @@ sealed interface VerificationResult {

/**
* Verifies an API key by calling the provider's cheapest authenticated endpoint
* (typically `GET /models`) and inspecting the HTTP status. No tokens are spent
* and no user content is sent. Runs entirely off the main thread; cancellation
* (typically `GET /models`) and inspecting the HTTP status. No user content is
* sent, and no tokens are spent except NVIDIA's unavoidable 1-token probe.
* Runs entirely off the main thread; cancellation
* propagates so an in-flight check can be abandoned when the key changes.
*/
@Singleton
Expand Down Expand Up @@ -60,16 +63,26 @@ class KeyVerifier @Inject constructor(
bearer(trimmed)
).code()

AiProvider.NVIDIA -> api.get(
"https://integrate.api.nvidia.com/v1/models",
bearer(trimmed)
// NVIDIA's GET /v1/models is public and returns 200 for any (or no)
// key, so it can't validate. The only authenticated endpoint is
// chat/completions itself; probe it with max_tokens=1 (401/403 on a
// bad key costs nothing; a valid key spends one token).
AiProvider.NVIDIA -> api.post(
"https://integrate.api.nvidia.com/v1/chat/completions",
bearer(trimmed),
NVIDIA_PROBE_BODY.toRequestBody("application/json".toMediaType())
).code()

AiProvider.GROQ -> api.get(
"https://api.groq.com/openai/v1/models",
bearer(trimmed)
).code()

AiProvider.CEREBRAS -> api.get(
"https://api.cerebras.ai/v1/models",
bearer(trimmed)
).code()

AiProvider.OPENAI -> api.get(
"https://api.openai.com/v1/models",
bearer(trimmed)
Expand All @@ -83,6 +96,16 @@ class KeyVerifier @Inject constructor(
)
).code()

AiProvider.CLOUDFLARE -> {
// customUrl carries the account id here (no full URL to enter).
val accountId = customUrl?.trim()?.ifBlank { null }
?: return VerificationResult.Invalid("Enter your Account ID first")
api.get(
"https://api.cloudflare.com/client/v4/accounts/$accountId/ai/models/search",
bearer(trimmed)
).code()
}

AiProvider.CUSTOM -> {
val url = customUrl?.trim()?.ifBlank { null }
?: return VerificationResult.Invalid("Set the endpoint URL first")
Expand Down Expand Up @@ -113,8 +136,9 @@ class KeyVerifier @Inject constructor(
// Authenticated but throttled: the key itself is accepted.
code == 429 -> VerificationResult.Valid
code == 401 || code == 403 -> VerificationResult.Invalid("Key was rejected")
// Google returns 400 with API_KEY_INVALID rather than 401 for a bad key.
code == 400 && provider == AiProvider.GEMINI -> VerificationResult.Invalid("Key was rejected")
// Google (API_KEY_INVALID) and Cloudflare (auth code 9106) return 400, not 401, for a bad key.
code == 400 && (provider == AiProvider.GEMINI || provider == AiProvider.CLOUDFLARE) ->
VerificationResult.Invalid("Key was rejected")
code in 500..599 -> VerificationResult.Failed("Provider error — try again")
else -> VerificationResult.Failed("Unexpected response (HTTP $code)")
}
Expand All @@ -124,4 +148,9 @@ class KeyVerifier @Inject constructor(
val idx = chatUrl.indexOf("/chat/completions")
return if (idx >= 0) chatUrl.substring(0, idx) + "/models" else null
}

private companion object {
const val NVIDIA_PROBE_BODY =
"""{"model":"google/gemma-4-31b-it","messages":[{"role":"user","content":"hi"}],"max_tokens":1}"""
}
}
34 changes: 28 additions & 6 deletions app/src/main/java/com/skeler/scanely/core/ai/PayloadFactory.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import javax.inject.Singleton
*
* Supported inputs:
* - Images: image/png, image/jpeg, image/webp (sent directly)
* - PDF: application/pdf (first [MAX_PDF_PAGES] pages rendered to images)
* - PDF: application/pdf (first [MAX_INPUT_IMAGES] pages rendered to images)
* - Text: text/plain (content inlined into the prompt)
*/
@Singleton
Expand All @@ -35,6 +35,14 @@ internal class PayloadFactory @Inject constructor(
data class Payload(val prompt: String, val images: List<String>)

fun create(uri: Uri, mode: AiMode): Payload {
val size = fileSizeBytes(uri)
if (size != null && size > MAX_FILE_SIZE_BYTES) {
throw PayloadException(
"File too large (${size / (1024 * 1024)} MB). " +
"The maximum size for an AI scan is $MAX_FILE_SIZE_MB MB."
)
}

val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream"
val prompt = AiPrompts.forMode(mode)

Expand All @@ -51,7 +59,7 @@ internal class PayloadFactory @Inject constructor(
Payload(prompt, images)
}

mimeType.startsWith("image/") || mode != AiMode.EXTRACT_PDF_TEXT -> {
mimeType.startsWith("image/") -> {
val bitmap = loadBitmapFromUri(uri)
?: throw PayloadException("Failed to load image")
try {
Expand Down Expand Up @@ -94,6 +102,16 @@ internal class PayloadFactory @Inject constructor(
return Bitmap.createScaledBitmap(bitmap, width, height, true)
}

/** Source byte size via file descriptor, or null when the provider won't report it. */
private fun fileSizeBytes(uri: Uri): Long? = try {
context.contentResolver.openFileDescriptor(uri, "r")?.use {
it.statSize.takeIf { size -> size >= 0 }
}
} catch (e: Exception) {
aiDebug { "size check failed: ${e.message}" }
null
}

private fun loadFileBytes(uri: Uri): ByteArray? = try {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
} catch (e: Exception) {
Expand All @@ -109,7 +127,7 @@ internal class PayloadFactory @Inject constructor(
}

/**
* Render up to [MAX_PDF_PAGES] pages to downscaled JPEG base64 strings,
* Render up to [MAX_INPUT_IMAGES] pages to downscaled JPEG base64 strings,
* recycling each page bitmap before rendering the next so peak memory is
* one page rather than the whole document.
*/
Expand All @@ -124,7 +142,7 @@ internal class PayloadFactory @Inject constructor(

ParcelFileDescriptor.open(tempFile, ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
PdfRenderer(fd).use { renderer ->
val pageCount = minOf(renderer.pageCount, MAX_PDF_PAGES)
val pageCount = minOf(renderer.pageCount, MAX_INPUT_IMAGES)
for (i in 0 until pageCount) {
val bitmap = renderer.openPage(i).use { page ->
val width = (page.width * PDF_RENDER_SCALE).toInt()
Expand Down Expand Up @@ -155,8 +173,12 @@ internal class PayloadFactory @Inject constructor(
private const val PDF_MIME_TYPE = "application/pdf"
private const val TEXT_MIME_TYPE = "text/plain"

/** Cap pages/dimensions to keep request payloads (and token usage) sane. */
private const val MAX_PDF_PAGES = 5
/** AI-scan input limits. */
private const val MAX_FILE_SIZE_MB = 20
private const val MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB.toLong() * 1024 * 1024
private const val MAX_INPUT_IMAGES = 3

/** Cap dimensions to keep request payloads (and token usage) sane. */
private const val MAX_IMAGE_DIMENSION = 1536
private const val JPEG_QUALITY = 85
private const val PDF_RENDER_SCALE = 2.0f
Expand Down
16 changes: 10 additions & 6 deletions app/src/main/java/com/skeler/scanely/core/ai/ProviderClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -254,21 +254,24 @@ internal class ProviderClient @Inject constructor(
}

val auth = "Bearer ${config.apiKey}"
// Remembered across images so a rejected default model isn't re-tried per page.
var model = config.model
val results = images.map { base64 ->
val document = MistralDocument(
type = "image_url",
image_url = "data:image/jpeg;base64,$base64"
)
val response = try {
mistralApi.ocr(auth, MistralOcrRequest(config.model, document))
mistralApi.ocr(auth, MistralOcrRequest(model, document))
} catch (e: HttpException) {
// Only the app default is eligible for the older-model retry; a
// user-chosen model is never silently swapped.
if (config.model == ProviderConfig.MISTRAL_OCR_DEFAULT &&
if (model == ProviderConfig.MISTRAL_OCR_DEFAULT &&
e.code() in 400..499 && e.code() != 429
) {
aiDebug { "Mistral ${config.model} rejected (HTTP ${e.code()}), trying $MISTRAL_OCR_FALLBACK_MODEL" }
mistralApi.ocr(auth, MistralOcrRequest(MISTRAL_OCR_FALLBACK_MODEL, document))
aiDebug { "Mistral $model rejected (HTTP ${e.code()}), trying $MISTRAL_OCR_FALLBACK_MODEL" }
model = MISTRAL_OCR_FALLBACK_MODEL
mistralApi.ocr(auth, MistralOcrRequest(model, document))
} else throw e
}
response.pages
Expand All @@ -286,7 +289,8 @@ internal class ProviderClient @Inject constructor(
/** Minimum interval between streamed [AiEvent.Delta] emissions. */
private const val DELTA_THROTTLE_MS = 100L

/** Inline reasoning block emitted by some models; stripped from output. */
private val THINK_BLOCK = Regex("(?s)<think>.*?</think>\\s*")
/** Inline reasoning block emitted by some models; stripped from output.
* Also matches an unterminated block (response truncated mid-thought). */
private val THINK_BLOCK = Regex("(?s)<think>.*?(?:</think>\\s*|$)")
}
}
61 changes: 52 additions & 9 deletions app/src/main/java/com/skeler/scanely/core/ai/ProviderConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,37 @@ internal class ProviderConfigResolver @Inject constructor(
val model = settingValue(SettingsKeys.HUGGINGFACE_MODEL) ?: engineModel
key?.let { ProviderConfig(ProviderKind.OPENAI_COMPAT, model, it, HUGGINGFACE_URL, usesBundledKey = userKey == null) }
}
AiProvider.NVIDIA -> settingValue(SettingsKeys.NVIDIA_API_KEY)?.let {
AiProvider.NVIDIA -> {
val userKey = settingValue(SettingsKeys.NVIDIA_API_KEY)
val key = userKey ?: BuildConfig.NVIDIA_API_KEY.trim().ifBlank { null }
val model = settingValue(SettingsKeys.NVIDIA_MODEL) ?: NVIDIA_MODEL
ProviderConfig(ProviderKind.OPENAI_COMPAT, model, it, NVIDIA_URL)
key?.let { ProviderConfig(ProviderKind.OPENAI_COMPAT, model, it, NVIDIA_URL, usesBundledKey = userKey == null) }
}
AiProvider.GROQ -> settingValue(SettingsKeys.GROQ_API_KEY)?.let {
val model = settingValue(SettingsKeys.GROQ_MODEL) ?: GROQ_MODEL
ProviderConfig(ProviderKind.OPENAI_COMPAT, model, it, GROQ_URL)
}
AiProvider.CEREBRAS -> settingValue(SettingsKeys.CEREBRAS_API_KEY)?.let {
val model = settingValue(SettingsKeys.CEREBRAS_MODEL) ?: CEREBRAS_MODEL
ProviderConfig(ProviderKind.OPENAI_COMPAT, model, it, CEREBRAS_URL)
}
AiProvider.CLOUDFLARE -> {
// A token is bound to its account, so key + account id travel as a pair:
// use the user's pair when both are set, else fall back to the bundled pair.
val userKey = settingValue(SettingsKeys.CLOUDFLARE_API_KEY)
val userAccount = settingValue(SettingsKeys.CLOUDFLARE_ACCOUNT_ID)
val bundledKey = BuildConfig.CLOUDFLARE_API_KEY.trim().ifBlank { null }
val bundledAccount = BuildConfig.CLOUDFLARE_ACCOUNT_ID.trim().ifBlank { null }
val pair = when {
userKey != null && userAccount != null -> Triple(userKey, userAccount, false)
bundledKey != null && bundledAccount != null -> Triple(bundledKey, bundledAccount, true)
else -> null
}
pair?.let { (key, accountId, bundled) ->
val model = settingValue(SettingsKeys.CLOUDFLARE_MODEL) ?: CLOUDFLARE_MODEL
ProviderConfig(ProviderKind.OPENAI_COMPAT, model, key, cloudflareUrl(accountId), usesBundledKey = bundled)
}
}
AiProvider.OPENAI -> settingValue(SettingsKeys.OPENAI_API_KEY)?.let {
val model = settingValue(SettingsKeys.OPENAI_MODEL) ?: OPENAI_MODEL
ProviderConfig(ProviderKind.OPENAI_COMPAT, model, it, OPENAI_URL)
Expand Down Expand Up @@ -117,13 +140,20 @@ internal class ProviderConfigResolver @Inject constructor(
settingsRepository.getString(SettingsKeys.GEMINI_API_KEY),
settingsRepository.getString(SettingsKeys.MISTRAL_API_KEY),
settingsRepository.getString(SettingsKeys.OPENROUTER_API_KEY),
settingsRepository.getString(SettingsKeys.HUGGINGFACE_API_KEY)
) { gemini, mistral, openRouter, huggingFace ->
settingsRepository.getString(SettingsKeys.HUGGINGFACE_API_KEY),
settingsRepository.getString(SettingsKeys.NVIDIA_API_KEY),
settingsRepository.getString(SettingsKeys.CLOUDFLARE_API_KEY),
settingsRepository.getString(SettingsKeys.CLOUDFLARE_ACCOUNT_ID)
) { keys ->
buildSet {
if (gemini.isBlank() && BUNDLED_GEMINI) add(AiProvider.GEMINI)
if (mistral.isBlank() && BUNDLED_MISTRAL) add(AiProvider.MISTRAL)
if (openRouter.isBlank() && BUNDLED_OPENROUTER) add(AiProvider.OPENROUTER)
if (huggingFace.isBlank() && BUNDLED_HUGGINGFACE) add(AiProvider.HUGGINGFACE)
if (keys[0].isBlank() && BUNDLED_GEMINI) add(AiProvider.GEMINI)
if (keys[1].isBlank() && BUNDLED_MISTRAL) add(AiProvider.MISTRAL)
if (keys[2].isBlank() && BUNDLED_OPENROUTER) add(AiProvider.OPENROUTER)
if (keys[3].isBlank() && BUNDLED_HUGGINGFACE) add(AiProvider.HUGGINGFACE)
if (keys[4].isBlank() && BUNDLED_NVIDIA) add(AiProvider.NVIDIA)
// Cloudflare falls back to the bundled pair unless BOTH user fields are set
// (resolve() uses key + account id as an inseparable pair).
if ((keys[5].isBlank() || keys[6].isBlank()) && BUNDLED_CLOUDFLARE) add(AiProvider.CLOUDFLARE)
}
}

Expand All @@ -133,6 +163,8 @@ internal class ProviderConfigResolver @Inject constructor(
if (BUNDLED_MISTRAL) add(AiProvider.MISTRAL)
if (BUNDLED_OPENROUTER) add(AiProvider.OPENROUTER)
if (BUNDLED_HUGGINGFACE) add(AiProvider.HUGGINGFACE)
if (BUNDLED_NVIDIA) add(AiProvider.NVIDIA)
if (BUNDLED_CLOUDFLARE) add(AiProvider.CLOUDFLARE)
}

// Trimmed, or null if unset/blank.
Expand All @@ -145,9 +177,15 @@ internal class ProviderConfigResolver @Inject constructor(
private const val OPENROUTER_MODEL = "google/gemma-4-26b-a4b-it:free"
private const val HUGGINGFACE_URL = "https://router.huggingface.co/v1/chat/completions"
private const val NVIDIA_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
private const val NVIDIA_MODEL = "meta/llama-3.2-11b-vision-instruct"
private const val NVIDIA_MODEL = "google/gemma-4-31b-it"
private const val GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
private const val GROQ_MODEL = "qwen/qwen3.6-27b"
private const val CEREBRAS_URL = "https://api.cerebras.ai/v1/chat/completions"
private const val CEREBRAS_MODEL = "gemma-4-31b"
private const val CLOUDFLARE_MODEL = "@cf/mistralai/mistral-small-3.1-24b-instruct"
// Workers AI OpenAI-compatible endpoint; the account id is part of the path.
fun cloudflareUrl(accountId: String) =
"https://api.cloudflare.com/client/v4/accounts/$accountId/ai/v1/chat/completions"
private const val OPENAI_URL = "https://api.openai.com/v1/chat/completions"
private const val OPENAI_MODEL = "gpt-4o-mini"
private const val CLAUDE_MODEL = "claude-haiku-4-5-20251001"
Expand All @@ -157,6 +195,9 @@ internal class ProviderConfigResolver @Inject constructor(
private val BUNDLED_MISTRAL = BuildConfig.MISTRAL_API_KEY.isNotBlank()
private val BUNDLED_OPENROUTER = BuildConfig.OPENROUTER_API_KEY.isNotBlank()
private val BUNDLED_HUGGINGFACE = BuildConfig.HUGGINGFACE_API_KEY.isNotBlank()
private val BUNDLED_NVIDIA = BuildConfig.NVIDIA_API_KEY.isNotBlank()
private val BUNDLED_CLOUDFLARE =
BuildConfig.CLOUDFLARE_API_KEY.isNotBlank() && BuildConfig.CLOUDFLARE_ACCOUNT_ID.isNotBlank()

private val FALLBACK_ORDER = listOf(
AiProvider.GEMINI,
Expand All @@ -165,6 +206,8 @@ internal class ProviderConfigResolver @Inject constructor(
AiProvider.HUGGINGFACE,
AiProvider.NVIDIA,
AiProvider.GROQ,
AiProvider.CEREBRAS,
AiProvider.CLOUDFLARE,
AiProvider.OPENAI,
AiProvider.CLAUDE,
AiProvider.CUSTOM
Expand Down
Loading
Loading