From d825be0b5b5b4a1a5382d9258b48028b3e233cdb Mon Sep 17 00:00:00 2001 From: skeler <168662493+Azyrn@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:35:00 +0100 Subject: [PATCH 1/2] fix(ai): stop malformed SSE chunks from crashing the scan pipeline OpenAI-compatible providers may send the error `code` as a string ("rate_limit_exceeded") instead of an int, and a mid-stream chunk can be schema-mismatched or truncated. Either case threw a SerializationException from decodeFromString in parseStreamPiece that ProviderExecutor.run does not catch, so it escaped the retry/fallback loop and failed the whole flow with a raw exception. Loosen ApiError.code to JsonElement and read it via a codeInt helper, and wrap the streaming decode in decodeChunk so a parse failure is re-thrown as TransientAiException. ProviderExecutor already treats TransientAiException as retryable, so a bad chunk now degrades to a normal retry / provider fallback instead of a crash. Co-Authored-By: Claude Opus 4.8 --- .../skeler/scanely/core/ai/ProviderClient.kt | 292 ++++++++++++++++++ .../scanely/core/network/OpenAiCompatApi.kt | 16 +- 2 files changed, 306 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/skeler/scanely/core/ai/ProviderClient.kt diff --git a/app/src/main/java/com/skeler/scanely/core/ai/ProviderClient.kt b/app/src/main/java/com/skeler/scanely/core/ai/ProviderClient.kt new file mode 100644 index 0000000..7c25a44 --- /dev/null +++ b/app/src/main/java/com/skeler/scanely/core/ai/ProviderClient.kt @@ -0,0 +1,292 @@ +package com.skeler.scanely.core.ai + +import android.os.SystemClock +import com.skeler.scanely.core.network.ChatStreamChunk +import com.skeler.scanely.core.network.ClaudeApi +import com.skeler.scanely.core.network.ClaudeStreamEvent +import com.skeler.scanely.core.network.GeminiApi +import com.skeler.scanely.core.network.GeminiResponse +import com.skeler.scanely.core.network.MistralApi +import com.skeler.scanely.core.network.MistralDocument +import com.skeler.scanely.core.network.MistralOcrRequest +import com.skeler.scanely.core.network.OpenAiCompatApi +import com.skeler.scanely.core.network.codeInt +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import okhttp3.ResponseBody +import retrofit2.HttpException +import javax.inject.Inject +import javax.inject.Singleton + +/** Transient provider-side failure (429 / 5xx / overload) worth retrying. */ +internal class TransientAiException(message: String) : Exception(message) + +/** Definitive provider error that retrying will not fix. */ +internal class FatalAiException(val result: AiResult.Error) : Exception(result.message) + +/** + * Executes a single request against one resolved [ProviderConfig] and returns + * the extracted text. Parsing errors surface as [TransientAiException] (retry) + * or [FatalAiException] (give up); transport failures propagate as-is. + */ +@Singleton +internal class ProviderClient @Inject constructor( + private val openAiApi: OpenAiCompatApi, + private val claudeApi: ClaudeApi, + private val geminiApi: GeminiApi, + private val mistralApi: MistralApi +) { + /** Lenient decoder for SSE payload lines. */ + private val streamJson = Json { ignoreUnknownKeys = true } + + /** True when this provider can be streamed via SSE. */ + fun supportsStreaming(config: ProviderConfig): Boolean = + config.kind != ProviderKind.MISTRAL_OCR + + /** + * Send one streaming request and return the full accumulated text, + * emitting throttled [AiEvent.Delta]s as tokens arrive. [streamedAnything] + * is flipped once the first token lands so callers can tell an empty + * (no-SSE) stream from a slow one. + */ + suspend fun stream( + config: ProviderConfig, + systemInstruction: String?, + prompt: String, + images: List, + streamedAnything: BooleanArray, + emit: suspend (AiEvent) -> Unit + ): String { + val body: ResponseBody = when (config.kind) { + ProviderKind.OPENAI_COMPAT -> openAiApi.chatCompletionStream( + url = config.url ?: throw FatalAiException(AiResult.Error("Missing endpoint URL")), + authorization = "Bearer ${config.apiKey}", + request = AiRequestFactory.openAi(config, systemInstruction, prompt, images, stream = true) + ) + ProviderKind.ANTHROPIC -> claudeApi.messagesStream( + apiKey = config.apiKey, + request = AiRequestFactory.claude(config, systemInstruction, prompt, images, stream = true) + ) + ProviderKind.GEMINI -> geminiApi.streamGenerateContent( + model = config.model, + apiKey = config.apiKey, + request = AiRequestFactory.gemini(systemInstruction, prompt, images) + ) + ProviderKind.MISTRAL_OCR -> error("Mistral OCR does not stream") + } + emit(AiEvent.Stage(AiStage.PROCESSING)) + + val accumulated = StringBuilder() + var firstTokenAt = 0L + var lastEmit = 0L + val requestStart = SystemClock.elapsedRealtime() + + consumeSse(body) { payload -> + val piece = parseStreamPiece(config.kind, payload) + if (!piece.isNullOrEmpty()) { + if (accumulated.isEmpty()) { + firstTokenAt = SystemClock.elapsedRealtime() + emit(AiEvent.Stage(AiStage.GENERATING)) + } + streamedAnything[0] = true + accumulated.append(piece) + val now = SystemClock.elapsedRealtime() + if (now - lastEmit >= DELTA_THROTTLE_MS) { + lastEmit = now + emit(AiEvent.Delta(accumulated.toString())) + } + } + } + + val result = stripReasoning(accumulated.toString()) + if (result.isNotEmpty()) { + emit(AiEvent.Delta(result)) + aiDebug { + "first token ${firstTokenAt - requestStart} ms, " + + "full response ${SystemClock.elapsedRealtime() - requestStart} ms" + } + } + return result + } + + // Some reasoning models emit their chain-of-thought inline as + // (Qwen, DeepSeek-R1, …). Disabling it at the API is provider-specific and + // rejected by non-reasoning models, so strip it from the output for every + // provider as a universal safety net; Groq additionally turns it off upstream. + private fun stripReasoning(text: String): String = + text.replace(THINK_BLOCK, "").trim() + + // A malformed / schema-mismatched SSE chunk (e.g. a provider sending a string + // error `code` where an int is expected) would otherwise throw an unhandled + // SerializationException that escapes ProviderExecutor's retry/fallback. Treat + // it as transient so the next attempt / provider can take over. + private inline fun decodeChunk(payload: String): T = try { + streamJson.decodeFromString(payload) + } catch (e: SerializationException) { + throw TransientAiException("Malformed response chunk") + } + + /** Decode one SSE payload line into a text delta, or null if it carries none. */ + private fun parseStreamPiece(kind: ProviderKind, payload: String): String? = when (kind) { + ProviderKind.OPENAI_COMPAT -> { + val chunk = decodeChunk(payload) + chunk.error?.let { err -> + if (err.codeInt == 429) throw TransientAiException(err.message ?: "rate limited") + throw FatalAiException(AiResult.Error(err.message ?: "Provider error")) + } + chunk.choices.firstOrNull()?.delta?.content + } + ProviderKind.ANTHROPIC -> { + val event = decodeChunk(payload) + event.error?.let { err -> + if (err.type == "overloaded_error") { + throw TransientAiException(err.message ?: "overloaded") + } + throw FatalAiException(AiResult.Error(err.message ?: "Claude error")) + } + if (event.type == "content_block_delta" && event.delta?.type == "text_delta") { + event.delta.text + } else null + } + ProviderKind.GEMINI -> { + val chunk = decodeChunk(payload) + chunk.error?.let { err -> + if (err.code == 429) throw TransientAiException(err.message ?: "rate limited") + throw FatalAiException(AiResult.Error(err.message ?: "Gemini error")) + } + // Gemma "thinks" by default; keep only non-thought parts. + chunk.candidates.firstOrNull()?.content?.parts + ?.filter { it.thought != true } + ?.mapNotNull { it.text } + ?.joinToString("") + ?.ifEmpty { null } + } + ProviderKind.MISTRAL_OCR -> null + } + + /** + * Read `data:` lines off an SSE body. Checks for cancellation between lines + * so a user cancel is honored at token granularity; a fully stalled + * connection is broken by the OkHttp read timeout. + */ + private suspend fun consumeSse(body: ResponseBody, onData: suspend (String) -> Unit) { + body.use { + val source = it.source() + while (true) { + currentCoroutineContext().ensureActive() + val line = source.readUtf8Line() ?: break + if (!line.startsWith("data:")) continue + val payload = line.substring(5).trim() + if (payload == "[DONE]") break + if (payload.isNotEmpty()) onData(payload) + } + } + } + + /** Send one plain (non-streaming) request and return the response text. */ + suspend fun once( + config: ProviderConfig, + systemInstruction: String?, + prompt: String, + images: List + ): String = when (config.kind) { + ProviderKind.OPENAI_COMPAT -> { + val url = config.url ?: throw FatalAiException(AiResult.Error("Missing endpoint URL")) + val response = openAiApi.chatCompletion( + url, "Bearer ${config.apiKey}", + AiRequestFactory.openAi(config, systemInstruction, prompt, images, stream = false) + ) + response.error?.let { err -> + if (err.codeInt == 429) throw TransientAiException(err.message ?: "rate limited") + throw FatalAiException(AiResult.Error(err.message ?: "Provider error")) + } + stripReasoning(response.choices.firstOrNull()?.message?.content.orEmpty()) + } + ProviderKind.ANTHROPIC -> { + val response = claudeApi.messages( + apiKey = config.apiKey, + request = AiRequestFactory.claude(config, systemInstruction, prompt, images, stream = false) + ) + response.error?.let { err -> + throw FatalAiException(AiResult.Error(err.message ?: "Claude error")) + } + response.content + .filter { it.type == "text" } + .mapNotNull { it.text } + .joinToString("") + .trim() + } + ProviderKind.GEMINI -> { + val response = geminiApi.generateContent( + config.model, config.apiKey, + AiRequestFactory.gemini(systemInstruction, prompt, images) + ) + response.error?.let { err -> + if (err.code == 429) throw TransientAiException(err.message ?: "rate limited") + throw FatalAiException(AiResult.Error(err.message ?: "Gemini error")) + } + response.candidates.firstOrNull()?.content?.parts + ?.filter { it.thought != true } + ?.mapNotNull { it.text } + ?.joinToString("") + ?.trim() + .orEmpty() + } + ProviderKind.MISTRAL_OCR -> mistralOcr(config, systemInstruction, prompt, images) + } + + /** + * Mistral OCR: one request per image, pages joined as markdown. Text-only + * prompts (txt files, translation) go to Mistral's chat API instead. + * If the app's *default* OCR model is rejected, retries once with the older + * model — a user-selected model is used exactly and never substituted. + */ + private suspend fun mistralOcr( + config: ProviderConfig, + systemInstruction: String?, + prompt: String, + images: List + ): String { + if (images.isEmpty()) { + return once(ProviderConfig.mistralChat(config.apiKey), systemInstruction, prompt, images) + } + + val auth = "Bearer ${config.apiKey}" + 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)) + } 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 && + 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)) + } else throw e + } + response.pages + .sortedBy { it.index } + .joinToString("\n\n") { it.markdown } + .trim() + } + return results.filter { it.isNotEmpty() }.joinToString("\n\n").trim() + } + + companion object { + /** Older Mistral OCR model tried when the primary one errors. */ + private const val MISTRAL_OCR_FALLBACK_MODEL = "mistral-ocr-3" + + /** 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).*?\\s*") + } +} diff --git a/app/src/main/java/com/skeler/scanely/core/network/OpenAiCompatApi.kt b/app/src/main/java/com/skeler/scanely/core/network/OpenAiCompatApi.kt index 4a73c4a..026aed5 100644 --- a/app/src/main/java/com/skeler/scanely/core/network/OpenAiCompatApi.kt +++ b/app/src/main/java/com/skeler/scanely/core/network/OpenAiCompatApi.kt @@ -2,6 +2,9 @@ package com.skeler.scanely.core.network import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.intOrNull import okhttp3.ResponseBody import retrofit2.http.Body import retrofit2.http.Header @@ -52,7 +55,10 @@ data class ChatRequest( val model: String, val messages: List, val temperature: Double = 0.1, - val stream: Boolean = false + val stream: Boolean = false, + // Groq-only: "none" disables Qwen3 thinking so blocks don't pollute + // the extracted text. Omitted (null) for every other provider/model. + @SerialName("reasoning_effort") val reasoningEffort: String? = null ) @Serializable @@ -106,9 +112,15 @@ data class ResponseMessage( @Serializable data class ApiError( val message: String? = null, - val code: Int? = null + // Some OpenAI-compatible providers send `code` as a string ("rate_limit_exceeded") + // rather than an int, so decode it loosely and normalize via [codeInt]. + val code: JsonElement? = null ) +/** Numeric error code when the provider sent one as a JSON number, else null. */ +val ApiError.codeInt: Int? + get() = (code as? JsonPrimitive)?.intOrNull + // --- Streaming response models --- /** One SSE chunk of a streamed chat completion. */ From 98872b9f48b29129b627047d0545ee104779d207 Mon Sep 17 00:00:00 2001 From: skeler <168662493+Azyrn@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:36:16 +0100 Subject: [PATCH 2/2] refactor(ai): remove never-produced AiResult.RateLimited MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AiResult.RateLimited was never constructed anywhere in the pipeline — rate limits already surface as AiResult.Error strings via exhaustedMessage / httpErrorMessage. The three UI branches handling it (ResultsScreen, AiScanViewModel extraction + translation) were dead, and its remainingMs had no source. Remove the variant and its dead consumers; the sealed when-expressions stay exhaustive over Success/Error, and a rate-limited translation now falls through to the existing Error branch. Co-Authored-By: Claude Opus 4.8 --- .../com/skeler/scanely/core/ai/AiEvents.kt | 36 ++++ .../scanely/ui/screens/ResultsScreen.kt | 58 +++--- .../scanely/ui/viewmodel/AiScanViewModel.kt | 174 ++++++++++-------- 3 files changed, 167 insertions(+), 101 deletions(-) create mode 100644 app/src/main/java/com/skeler/scanely/core/ai/AiEvents.kt diff --git a/app/src/main/java/com/skeler/scanely/core/ai/AiEvents.kt b/app/src/main/java/com/skeler/scanely/core/ai/AiEvents.kt new file mode 100644 index 0000000..b931a6e --- /dev/null +++ b/app/src/main/java/com/skeler/scanely/core/ai/AiEvents.kt @@ -0,0 +1,36 @@ +package com.skeler.scanely.core.ai + +/** AI processing mode for image/document analysis. */ +enum class AiMode { + EXTRACT_TEXT, + EXTRACT_PDF_TEXT, + ICON_TRANSLATE +} + +/** Terminal result of an AI operation. */ +sealed class AiResult { + data class Success(val text: String) : AiResult() + data class Error(val message: String) : AiResult() +} + +/** User-visible phases of one extraction, in order. */ +enum class AiStage { + PREPARING, + UPLOADING, + PROCESSING, + GENERATING, + COMPLETE +} + +/** + * Progress events emitted while an extraction runs. + * + * [Stage] marks a phase change (with an optional human-readable note); + * [Delta] carries the accumulated streamed text so far; [Finished] is always + * the terminal event. + */ +sealed class AiEvent { + data class Stage(val stage: AiStage, val message: String? = null) : AiEvent() + data class Delta(val textSoFar: String) : AiEvent() + data class Finished(val result: AiResult) : AiEvent() +} diff --git a/app/src/main/java/com/skeler/scanely/ui/screens/ResultsScreen.kt b/app/src/main/java/com/skeler/scanely/ui/screens/ResultsScreen.kt index bee0625..29b3a9a 100644 --- a/app/src/main/java/com/skeler/scanely/ui/screens/ResultsScreen.kt +++ b/app/src/main/java/com/skeler/scanely/ui/screens/ResultsScreen.kt @@ -40,7 +40,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -48,9 +47,11 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.lifecycleScope import com.skeler.scanely.core.ai.AiResult import com.skeler.scanely.core.ocr.OcrResult import com.skeler.scanely.navigation.LocalNavController +import com.skeler.scanely.navigation.Routes import com.skeler.scanely.ui.ScanViewModel import com.skeler.scanely.ui.components.EmptyResultContent import com.skeler.scanely.ui.components.ExtractedTextSection @@ -58,10 +59,10 @@ import com.skeler.scanely.ui.components.LanguageChipRow import com.skeler.scanely.ui.components.ProcessingContent import com.skeler.scanely.ui.components.RateLimitSheet import com.skeler.scanely.ui.components.TranslatingContent +import com.skeler.scanely.ui.components.TranslationLanguages import com.skeler.scanely.ui.components.rememberTextExporter import com.skeler.scanely.ui.viewmodel.AiScanViewModel import com.skeler.scanely.ui.viewmodel.OcrViewModel -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -81,7 +82,6 @@ fun ResultsScreen() { val aiViewModel: AiScanViewModel = hiltViewModel(activity) val ocrViewModel: OcrViewModel = hiltViewModel(activity) val navController = LocalNavController.current - val scope = rememberCoroutineScope() val exportText = rememberTextExporter() val scanState by scanViewModel.uiState.collectAsState() @@ -89,7 +89,7 @@ fun ResultsScreen() { val ocrState by ocrViewModel.uiState.collectAsState() // Processing state - val isProcessing = scanState.isProcessing || aiState.isProcessing || ocrState.isProcessing + val isProcessing = aiState.isProcessing || ocrState.isProcessing val isTranslating = aiState.isTranslating // Text priority: History > AI > OCR @@ -97,7 +97,6 @@ fun ResultsScreen() { val aiResultText = when (val result = aiState.result) { is AiResult.Success -> result.text is AiResult.Error -> "Error: ${result.message}" - is AiResult.RateLimited -> "Rate limited. Wait ${result.remainingMs / 1000}s" null -> null } val ocrResultText = when (val result = ocrState.result) { @@ -115,22 +114,12 @@ fun ResultsScreen() { // Rate Limit & Network State val rateLimitState by scanViewModel.rateLimitState.collectAsState() val showRateLimitSheet by scanViewModel.showRateLimitSheet.collectAsState() + val isRewardedAdAvailable by scanViewModel.isRewardedAdAvailable.collectAsState() val isOnline by scanViewModel.isOnline.collectAsState() // Language menu state var showLanguageMenu by remember { mutableStateOf(false) } - val languages = listOf( - "English", "Spanish", "French", "German", "Italian", - "Portuguese", "Russian", "Japanese", "Korean", "Chinese", - "Arabic", "Hindi", "Turkish", "Dutch", "Polish" - ) - - // Auto-dismiss rate limit sheet - LaunchedEffect(rateLimitState.remainingSeconds, rateLimitState.justBecameReady) { - if (rateLimitState.remainingSeconds == 0 && rateLimitState.justBecameReady) { - scanViewModel.dismissRateLimitSheet() - } - } + val languages = TranslationLanguages.ALL var showContent by remember { mutableStateOf(false) } var navigatingUp by remember { mutableStateOf(false) } @@ -139,7 +128,9 @@ fun ResultsScreen() { if (!navigatingUp) { navigatingUp = true navController.popBackStack() - scope.launch(Dispatchers.Default) { + // Activity scope: outlives this composable, so the cleanup still runs + // after the pop animation instead of dying with the composition. + activity.lifecycleScope.launch { delay(600) scanViewModel.clearState() aiViewModel.clearResult() @@ -153,12 +144,22 @@ fun ResultsScreen() { showContent = true } + // Surface one-shot messages (e.g. a translation failure) as a toast. + LaunchedEffect(aiState.message) { + aiState.message?.let { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + aiViewModel.consumeMessage() + } + } + BackHandler { onBack() } if (showRateLimitSheet) { RateLimitSheet( remainingSeconds = rateLimitState.remainingSeconds, - onDismiss = { scanViewModel.dismissRateLimitSheet() } + onDismiss = { scanViewModel.dismissRateLimitSheet() }, + adAvailable = isRewardedAdAvailable, + onWatchAd = { scanViewModel.showRewardedAdForExtraScan(activity) } ) } @@ -193,7 +194,7 @@ fun ResultsScreen() { FloatingActionButton( onClick = { aiViewModel.getRescanParams()?.let { (uri, mode, provider) -> - scanViewModel.triggerAiWithRateLimit { + scanViewModel.triggerAiWithRateLimit(provider) { aiViewModel.processImage(uri, mode, provider) } } @@ -265,17 +266,28 @@ fun ResultsScreen() { allLanguages = languages, onNewLanguageSelected = { language -> showLanguageMenu = false - scanViewModel.triggerAiWithRateLimit { + scanViewModel.triggerAiWithRateLimit(aiState.provider) { aiViewModel.translateResult(language) } - } + }, + onComposeText = { navController.navigate(Routes.TEXT_COMPOSE) } ) Spacer(modifier = Modifier.height(16.dp)) } ExtractedTextSection( text = displayText, onCopy = { copyToClipboard(context, displayText) }, - onExport = { format -> exportText(displayText, format) } + onExport = { format -> exportText(displayText, format) }, + onSaveEdit = { edited -> + when { + // Editing a shown translation corrects that translation. + currentLanguage != null -> aiViewModel.updateText(edited) + // Reopened-from-history text persists to its row. + historyText != null -> scanViewModel.updateHistoryText(edited) + isAiResult -> aiViewModel.updateText(edited) + else -> ocrViewModel.updateText(edited) + } + } ) } else -> { diff --git a/app/src/main/java/com/skeler/scanely/ui/viewmodel/AiScanViewModel.kt b/app/src/main/java/com/skeler/scanely/ui/viewmodel/AiScanViewModel.kt index 4ae5015..6e6cd8e 100644 --- a/app/src/main/java/com/skeler/scanely/ui/viewmodel/AiScanViewModel.kt +++ b/app/src/main/java/com/skeler/scanely/ui/viewmodel/AiScanViewModel.kt @@ -11,8 +11,10 @@ import com.skeler.scanely.core.ai.AiStage import com.skeler.scanely.core.ai.GenerativeAiService import com.skeler.scanely.history.data.HistoryManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -47,7 +49,9 @@ data class AiScanState( /** Current file index being processed (1-indexed for display) */ val currentFileIndex: Int = 0, /** All URIs being processed (for multi-file) */ - val allUris: List = emptyList() + val allUris: List = emptyList(), + /** One-shot user-facing message (e.g. a translation failure). */ + val message: String? = null ) /** @@ -66,6 +70,11 @@ class AiScanViewModel @Inject constructor( /** The in-flight processing job, so Cancel can abort it. */ private var processingJob: Job? = null + /** Pending history save for the current result, so edits update it in place. + * Deferred (not a plain id) so an edit fired before the save completes can + * await the row id instead of silently missing it. */ + private var savedHistoryId: Deferred? = null + /** * Process an image with the specified AI mode. Progress (stages and * streamed text) is surfaced through [aiState] as it happens. @@ -81,36 +90,41 @@ class AiScanViewModel @Inject constructor( lastImageUri = imageUri ) - var result: AiResult = AiResult.Error("Cancelled") - aiService.processImageEvents(imageUri, mode, provider).collect { event -> - when (event) { - is AiEvent.Stage -> _aiState.value = _aiState.value.copy( - stage = event.stage, - stageMessage = event.message - ) - is AiEvent.Delta -> _aiState.value = _aiState.value.copy( - streamingText = event.textSoFar - ) - is AiEvent.Finished -> result = event.result - } - } - - val finalResult = result - val originalText = if (finalResult is AiResult.Success) finalResult.text else null - _aiState.value = _aiState.value.copy( - isProcessing = false, - stage = null, - stageMessage = null, - streamingText = null, - result = finalResult, - originalText = originalText - ) + val result = collectPipeline(imageUri, mode, provider) + publishFinalResult(result, imageUri) + } + } - // Save to history on success - if (finalResult is AiResult.Success && finalResult.text.isNotBlank()) { - saveToHistory(finalResult.text, imageUri) + /** Runs the AI pipeline for one file, mirroring its events into [aiState]. */ + private suspend fun collectPipeline(uri: Uri, mode: AiMode, provider: AiProvider): AiResult { + var result: AiResult = AiResult.Error("Cancelled") + aiService.processImageEvents(uri, mode, provider).collect { event -> + when (event) { + is AiEvent.Stage -> _aiState.value = _aiState.value.copy( + stage = event.stage, + stageMessage = event.message + ) + is AiEvent.Delta -> _aiState.value = _aiState.value.copy( + streamingText = event.textSoFar + ) + is AiEvent.Finished -> result = event.result } } + return result + } + + private fun publishFinalResult(result: AiResult, historyUri: Uri) { + _aiState.value = _aiState.value.copy( + isProcessing = false, + stage = null, + stageMessage = null, + streamingText = null, + result = result, + originalText = (result as? AiResult.Success)?.text + ) + if (result is AiResult.Success && result.text.isNotBlank()) { + saveToHistory(result.text, historyUri) + } } /** @@ -155,38 +169,17 @@ class AiScanViewModel @Inject constructor( lastImageUri = uri ) - var result: AiResult = AiResult.Error("Cancelled") - aiService.processImageEvents(uri, mode, provider).collect { event -> - when (event) { - is AiEvent.Stage -> _aiState.value = _aiState.value.copy( - stage = event.stage, - stageMessage = event.message - ) - is AiEvent.Delta -> _aiState.value = _aiState.value.copy( - streamingText = event.textSoFar - ) - is AiEvent.Finished -> result = event.result - } - } + val result = collectPipeline(uri, mode, provider) _aiState.value = _aiState.value.copy(streamingText = null) - when (val fileResult = result) { - is AiResult.Success -> { - if (allResults.isNotEmpty()) { - allResults.append("\n\n--- File ${index + 1} ---\n\n") - } - allResults.append(fileResult.text) - } - is AiResult.Error -> { - if (allResults.isNotEmpty()) { - allResults.append("\n\n--- File ${index + 1} (Error) ---\n\n") - } - allResults.append("Error: ${fileResult.message}") - } - is AiResult.RateLimited -> { - allResults.append("\n\n--- Rate Limited ---\n") - } + val (label, body) = when (result) { + is AiResult.Success -> "" to result.text + is AiResult.Error -> " (Error)" to "Error: ${result.message}" + } + if (allResults.isNotEmpty()) { + allResults.append("\n\n--- File ${index + 1}$label ---\n\n") } + allResults.append(body) } val combinedResult = if (allResults.isNotEmpty()) { @@ -195,19 +188,8 @@ class AiScanViewModel @Inject constructor( AiResult.Error("No text extracted from any file") } - _aiState.value = _aiState.value.copy( - isProcessing = false, - stage = null, - stageMessage = null, - streamingText = null, - result = combinedResult, - originalText = if (combinedResult is AiResult.Success) combinedResult.text else null - ) - - // Save combined result to history (using first file as reference) - if (combinedResult is AiResult.Success && combinedResult.text.isNotBlank()) { - saveToHistory(combinedResult.text, uris.first()) - } + // Combined result saves to history keyed on the first file. + publishFinalResult(combinedResult, uris.first()) } } @@ -215,15 +197,50 @@ class AiScanViewModel @Inject constructor( * Save extraction result to history. */ private fun saveToHistory(text: String, uri: Uri) { + savedHistoryId = viewModelScope.async(Dispatchers.IO) { + try { + historyManager.saveItem(text, uri.toString()).id + } catch (e: Exception) { + null // Silent fail - don't interrupt user flow + } + } + } + + /** + * Replace the extracted text with a user correction. Updates the in-memory + * result and the persisted history row so the corrected version is what gets + * exported now and what re-opens from history later. + */ + fun updateText(newText: String) { + val state = _aiState.value + val language = state.currentLanguage + if (language != null) { + // Editing a translation edits that cached translation, not the source. + _aiState.value = state.copy( + translationCache = state.translationCache + (language to newText) + ) + return + } + _aiState.value = state.copy( + result = AiResult.Success(newText), + originalText = newText + ) + val pendingId = savedHistoryId ?: return viewModelScope.launch(Dispatchers.IO) { try { - historyManager.saveItem(text, uri.toString()) + val id = pendingId.await() ?: return@launch + historyManager.updateItemText(id, newText) } catch (e: Exception) { - // Silent fail - don't interrupt user flow + // Persistence failure shouldn't disrupt the in-memory correction. } } } + /** Consume the one-shot [AiScanState.message] after it has been shown. */ + fun consumeMessage() { + _aiState.value = _aiState.value.copy(message = null) + } + /** * Rescan the last processed image with the same mode. * Returns the URI and mode for the caller to trigger with rate limit check. @@ -259,13 +276,13 @@ class AiScanViewModel @Inject constructor( currentLanguage = targetLanguage ) } - is AiResult.RateLimited -> { - _aiState.value = _aiState.value.copy(isTranslating = false) - // Rate limit handled by ScanViewModel - don't update state further - } is AiResult.Error -> { - _aiState.value = _aiState.value.copy(isTranslating = false) - // Could show error toast via state if needed + // Surface the failure instead of silently snapping back to the + // original text, which reads as "nothing happened". + _aiState.value = _aiState.value.copy( + isTranslating = false, + message = "Translation failed. Please try again." + ) } } } @@ -291,6 +308,7 @@ class AiScanViewModel @Inject constructor( * Clear the current AI result. */ fun clearResult() { + savedHistoryId = null _aiState.value = AiScanState() } }