diff --git a/app/src/main/java/com/skeler/scanely/core/actions/ActionExecutor.kt b/app/src/main/java/com/skeler/scanely/core/actions/ActionExecutor.kt index 61c5602..8f7fb79 100644 --- a/app/src/main/java/com/skeler/scanely/core/actions/ActionExecutor.kt +++ b/app/src/main/java/com/skeler/scanely/core/actions/ActionExecutor.kt @@ -1,5 +1,6 @@ package com.skeler.scanely.core.actions +import android.content.ActivityNotFoundException import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -10,12 +11,16 @@ import android.os.Build import android.provider.CalendarContract import android.provider.ContactsContract import android.provider.Settings +import android.util.Log import android.widget.Toast import androidx.core.net.toUri +import java.text.ParseException import java.text.SimpleDateFormat import java.util.Locale import java.util.TimeZone +private const val TAG = "ActionExecutor" + object ActionExecutor { fun execute(context: Context, action: ScanAction) { @@ -33,8 +38,26 @@ object ActionExecutor { } } + /** Runs an action, toasting [errorToast] on the failures launching intents can produce. */ + private inline fun tryAction(context: Context, errorToast: String, block: () -> Unit) { + val failure = try { + block() + null + } catch (e: ActivityNotFoundException) { + e + } catch (e: SecurityException) { + e + } catch (e: IllegalArgumentException) { + e + } + if (failure != null) { + Log.w(TAG, errorToast, failure) + Toast.makeText(context, errorToast, Toast.LENGTH_SHORT).show() + } + } + private fun openUrl(context: Context, url: String) { - try { + tryAction(context, "Cannot open URL") { var finalUrl = url if (!url.startsWith("http://") && !url.startsWith("https://")) { finalUrl = "https://$url" @@ -42,34 +65,28 @@ object ActionExecutor { val intent = Intent(Intent.ACTION_VIEW, finalUrl.toUri()) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) - } catch (e: Exception) { - Toast.makeText(context, "Cannot open URL", Toast.LENGTH_SHORT).show() } } private fun copyText(context: Context, text: String) { - try { + tryAction(context, "Failed to copy") { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clip = ClipData.newPlainText("Scanned Text", text) clipboard.setPrimaryClip(clip) Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show() - } catch (e: Exception) { - Toast.makeText(context, "Failed to copy", Toast.LENGTH_SHORT).show() } } private fun callPhone(context: Context, number: String) { - try { + tryAction(context, "Cannot open dialer") { val intent = Intent(Intent.ACTION_DIAL, "tel:$number".toUri()) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) - } catch (e: Exception) { - Toast.makeText(context, "Cannot open dialer", Toast.LENGTH_SHORT).show() } } private fun sendEmail(context: Context, email: String, subject: String?, body: String?) { - try { + tryAction(context, "Cannot open email app") { val intent = Intent(Intent.ACTION_SENDTO).apply { data = "mailto:".toUri() putExtra(Intent.EXTRA_EMAIL, arrayOf(email)) @@ -78,24 +95,20 @@ object ActionExecutor { } intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) - } catch (e: Exception) { - Toast.makeText(context, "Cannot open email app", Toast.LENGTH_SHORT).show() } } private fun sendSms(context: Context, number: String, message: String?) { - try { + tryAction(context, "Cannot open SMS app") { val intent = Intent(Intent.ACTION_SENDTO, "smsto:$number".toUri()) message?.let { intent.putExtra("sms_body", it) } intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) - } catch (e: Exception) { - Toast.makeText(context, "Cannot open SMS app", Toast.LENGTH_SHORT).show() } } private fun connectWifi(context: Context, ssid: String, password: String?, type: WifiType) { - try { + tryAction(context, "Cannot connect to WiFi") { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val suggestion = WifiNetworkSuggestion.Builder() .setSsid(ssid) @@ -119,13 +132,11 @@ object ActionExecutor { context.startActivity(intent) Toast.makeText(context, "Open WiFi settings to connect to: $ssid", Toast.LENGTH_SHORT).show() } - } catch (e: Exception) { - Toast.makeText(context, "Cannot connect to WiFi", Toast.LENGTH_SHORT).show() } } private fun addContact(context: Context, action: ScanAction.AddContact) { - try { + tryAction(context, "Cannot open contacts") { val intent = Intent(Intent.ACTION_INSERT).apply { type = ContactsContract.Contacts.CONTENT_TYPE action.name?.let { putExtra(ContactsContract.Intents.Insert.NAME, it) } @@ -135,13 +146,11 @@ object ActionExecutor { } intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) - } catch (e: Exception) { - Toast.makeText(context, "Cannot open contacts", Toast.LENGTH_SHORT).show() } } private fun addEvent(context: Context, action: ScanAction.AddEvent) { - try { + tryAction(context, "Cannot open calendar") { val intent = Intent(Intent.ACTION_INSERT).apply { data = CalendarContract.Events.CONTENT_URI action.title?.let { putExtra(CalendarContract.Events.TITLE, it) } @@ -156,8 +165,6 @@ object ActionExecutor { } intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) - } catch (e: Exception) { - Toast.makeText(context, "Cannot open calendar", Toast.LENGTH_SHORT).show() } } @@ -176,7 +183,7 @@ object ActionExecutor { if (utc) timeZone = TimeZone.getTimeZone("UTC") } format.parse(value)?.time - } catch (e: Exception) { + } catch (e: ParseException) { null } } diff --git a/app/src/main/java/com/skeler/scanely/core/ai/KeyVerifier.kt b/app/src/main/java/com/skeler/scanely/core/ai/KeyVerifier.kt index f96b22b..6c42a44 100644 --- a/app/src/main/java/com/skeler/scanely/core/ai/KeyVerifier.kt +++ b/app/src/main/java/com/skeler/scanely/core/ai/KeyVerifier.kt @@ -102,7 +102,7 @@ class KeyVerifier @Inject constructor( classify(provider, code) } catch (e: CancellationException) { throw e - } catch (e: IOException) { + } catch (_: IOException) { if (!networkObserver.isCurrentlyOnline()) { VerificationResult.Failed("You're offline") } else { 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 index 4f46755..9b77171 100644 --- a/app/src/main/java/com/skeler/scanely/core/ai/ProviderClient.kt +++ b/app/src/main/java/com/skeler/scanely/core/ai/ProviderClient.kt @@ -20,7 +20,7 @@ import retrofit2.HttpException import javax.inject.Inject import javax.inject.Singleton -internal class TransientAiException(message: String) : Exception(message) +internal class TransientAiException(message: String, cause: Throwable? = null) : Exception(message, cause) internal class FatalAiException(val result: AiResult.Error) : Exception(result.message) @@ -87,8 +87,9 @@ internal class ProviderClient @Inject constructor( } } catch (e: FatalAiException) { throw e + } catch (e: kotlinx.coroutines.CancellationException) { + throw e } catch (e: Exception) { - if (e is kotlinx.coroutines.CancellationException) throw e // Keep partial text after break (e.g. Cerebras closes post-token); don't re-bill. if (accumulated.isEmpty()) throw e aiDebug { "stream broke after ${accumulated.length} chars, keeping partial: ${e.message}" } @@ -111,7 +112,7 @@ internal class ProviderClient @Inject constructor( private inline fun decodeChunk(payload: String): T = try { streamJson.decodeFromString(payload) } catch (e: SerializationException) { - throw TransientAiException("Malformed response chunk") + throw TransientAiException("Malformed response chunk", e) } private fun parseStreamPiece(kind: ProviderKind, payload: String): String? = when (kind) { diff --git a/app/src/main/java/com/skeler/scanely/core/ai/ProviderExecutor.kt b/app/src/main/java/com/skeler/scanely/core/ai/ProviderExecutor.kt index f9f6164..70fd846 100644 --- a/app/src/main/java/com/skeler/scanely/core/ai/ProviderExecutor.kt +++ b/app/src/main/java/com/skeler/scanely/core/ai/ProviderExecutor.kt @@ -77,7 +77,7 @@ internal class ProviderExecutor @Inject constructor( } else { return ProviderOutcome.Fatal(AiResult.Error("No response generated")) } - } catch (e: TimeoutCancellationException) { + } catch (_: TimeoutCancellationException) { aiDebug { "$name attempt ${attempt + 1} timed out after $timeoutMs ms" } lastFailureWasNetwork = false if (streamedAnything[0]) return ProviderOutcome.Exhausted() diff --git a/app/src/main/java/com/skeler/scanely/core/lookup/RetryUtils.kt b/app/src/main/java/com/skeler/scanely/core/lookup/RetryUtils.kt index 44d427a..64071cf 100644 --- a/app/src/main/java/com/skeler/scanely/core/lookup/RetryUtils.kt +++ b/app/src/main/java/com/skeler/scanely/core/lookup/RetryUtils.kt @@ -15,15 +15,12 @@ suspend fun withRetry( shouldRetry: (Exception) -> Boolean = { it.isRetryable() }, block: suspend () -> T ): T { - var lastException: Exception? = null var currentDelay = config.initialDelayMs repeat(config.maxAttempts) { attempt -> try { return block() } catch (e: Exception) { - lastException = e - if (!shouldRetry(e) || attempt == config.maxAttempts - 1) { throw e } @@ -36,7 +33,8 @@ suspend fun withRetry( } } - throw lastException ?: IllegalStateException("Retry failed") + // Reachable only with maxAttempts <= 0; the last attempt always rethrows. + error("Retry failed") } fun Exception.isRetryable(): Boolean { diff --git a/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenLibraryEngine.kt b/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenLibraryEngine.kt index a53f5c9..1923b22 100644 --- a/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenLibraryEngine.kt +++ b/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenLibraryEngine.kt @@ -109,7 +109,7 @@ class OpenLibraryEngine @Inject constructor( } else -> emptyList() } - } catch (e: Exception) { + } catch (_: IllegalArgumentException) { emptyList() } } diff --git a/app/src/main/java/com/skeler/scanely/core/pdf/PdfHelper.kt b/app/src/main/java/com/skeler/scanely/core/pdf/PdfHelper.kt index 3fbe11b..6269695 100644 --- a/app/src/main/java/com/skeler/scanely/core/pdf/PdfHelper.kt +++ b/app/src/main/java/com/skeler/scanely/core/pdf/PdfHelper.kt @@ -1,13 +1,16 @@ package com.skeler.scanely.core.pdf +import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.net.Uri +import android.util.Log import android.widget.Toast object PdfHelper { private const val MIME_TYPE_PDF = "application/pdf" + private const val TAG = "PdfHelper" fun openPdfExternally(context: Context, uri: Uri): Boolean { return try { @@ -22,7 +25,8 @@ object PdfHelper { context.startActivity(chooser) true - } catch (e: Exception) { + } catch (e: ActivityNotFoundException) { + Log.w(TAG, "No activity can view PDFs", e) Toast.makeText( context, "No PDF viewer found. Install a PDF reader app.", diff --git a/app/src/main/java/com/skeler/scanely/core/pdf/ScanExporter.kt b/app/src/main/java/com/skeler/scanely/core/pdf/ScanExporter.kt index a1d6ee6..fdf9084 100644 --- a/app/src/main/java/com/skeler/scanely/core/pdf/ScanExporter.kt +++ b/app/src/main/java/com/skeler/scanely/core/pdf/ScanExporter.kt @@ -360,6 +360,7 @@ object ScanExporter { } true } catch (e: Exception) { + Log.e(TAG, "Gallery save failed for $displayName, rolling back MediaStore row", e) resolver.delete(uri, null, null) false } diff --git a/app/src/main/java/com/skeler/scanely/core/security/KeyCipher.kt b/app/src/main/java/com/skeler/scanely/core/security/KeyCipher.kt index 73980f8..d184f2f 100644 --- a/app/src/main/java/com/skeler/scanely/core/security/KeyCipher.kt +++ b/app/src/main/java/com/skeler/scanely/core/security/KeyCipher.kt @@ -1,8 +1,11 @@ package com.skeler.scanely.core.security import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException import android.security.keystore.KeyProperties import android.util.Base64 +import android.util.Log +import java.security.GeneralSecurityException import java.security.KeyStore import javax.crypto.Cipher import javax.crypto.KeyGenerator @@ -11,6 +14,8 @@ import javax.crypto.spec.GCMParameterSpec import javax.inject.Inject import javax.inject.Singleton +private const val TAG = "KeyCipher" + @Singleton class KeyCipher @Inject constructor() { @@ -30,15 +35,25 @@ class KeyCipher @Inject constructor() { return Base64.encodeToString(payload, Base64.NO_WRAP) } + // Log only the exception class — never the payload or plaintext. fun decrypt(encoded: String): String? = try { val payload = Base64.decode(encoded, Base64.NO_WRAP) + require(payload.size > IV_SIZE_BYTES) { "payload too short" } val iv = payload.copyOfRange(0, IV_SIZE_BYTES) val ciphertext = payload.copyOfRange(IV_SIZE_BYTES, payload.size) val cipher = Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(TAG_LENGTH_BITS, iv)) } String(cipher.doFinal(ciphertext), Charsets.UTF_8) - } catch (e: Exception) { + } catch (_: KeyPermanentlyInvalidatedException) { + // Lock-screen change etc. — ciphertext is undecryptable forever; user must re-enter keys. + Log.e(TAG, "Keystore key permanently invalidated, stored API keys are lost") + null + } catch (e: GeneralSecurityException) { + Log.w(TAG, "Decrypt failed: ${e.javaClass.simpleName}") + null + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Decrypt failed on malformed payload: ${e.javaClass.simpleName}") null } diff --git a/app/src/main/java/com/skeler/scanely/history/data/HistoryManager.kt b/app/src/main/java/com/skeler/scanely/history/data/HistoryManager.kt index ef8f5e3..6576ca3 100644 --- a/app/src/main/java/com/skeler/scanely/history/data/HistoryManager.kt +++ b/app/src/main/java/com/skeler/scanely/history/data/HistoryManager.kt @@ -5,6 +5,7 @@ import android.util.Log import androidx.core.net.toUri import dagger.hilt.android.qualifiers.ApplicationContext import org.json.JSONArray +import org.json.JSONException import org.json.JSONObject import java.io.File import java.util.UUID @@ -20,6 +21,42 @@ data class HistoryItem( val timestamp: Long = System.currentTimeMillis() ) +/** Null on malformed JSON — distinct from an empty-but-valid `[]`. */ +internal fun parseHistoryJson(json: String): List? { + return try { + val jsonArray = JSONArray(json) + val items = mutableListOf() + for (i in 0 until jsonArray.length()) { + val obj = jsonArray.getJSONObject(i) + items.add( + HistoryItem( + id = obj.optString("id", UUID.randomUUID().toString()), + text = obj.optString("text"), + imageUri = obj.optString("imageUri"), + timestamp = obj.optLong("timestamp", System.currentTimeMillis()) + ) + ) + } + items + } catch (_: JSONException) { + null + } +} + +internal fun historyToJson(items: List): String { + val jsonArray = JSONArray() + items.forEach { item -> + val obj = JSONObject().apply { + put("id", item.id) + put("text", item.text) + put("imageUri", item.imageUri) + put("timestamp", item.timestamp) + } + jsonArray.put(obj) + } + return jsonArray.toString() +} + @Singleton class HistoryManager @Inject constructor( @ApplicationContext private val context: Context @@ -79,32 +116,27 @@ class HistoryManager @Inject constructor( } } } catch (e: Exception) { + Log.w(TAG, "Best-effort image cleanup failed for $imageUri", e) } } fun getHistory(): List { if (!historyFile.exists()) return emptyList() - return try { - val jsonString = historyFile.readText() - val jsonArray = JSONArray(jsonString) - val items = mutableListOf() - - for (i in 0 until jsonArray.length()) { - val obj = jsonArray.getJSONObject(i) - items.add( - HistoryItem( - id = obj.optString("id", UUID.randomUUID().toString()), - text = obj.optString("text"), - imageUri = obj.optString("imageUri"), - timestamp = obj.optLong("timestamp", System.currentTimeMillis()) - ) - ) - } - items + val jsonString = try { + historyFile.readText() } catch (e: Exception) { - e.printStackTrace() - emptyList() + Log.e(TAG, "Could not read history file", e) + return emptyList() + } + val items = parseHistoryJson(jsonString) + if (items == null) { + // Corrupt (e.g. process killed mid-write). Preserve the bytes so the + // next saveItem() doesn't overwrite them with an empty list. + Log.e(TAG, "History unreadable, preserving as scan_history.corrupt.json") + historyFile.renameTo(File(context.filesDir, "scan_history.corrupt.json")) + return emptyList() } + return items } fun clearHistory() { @@ -126,16 +158,12 @@ class HistoryManager @Inject constructor( } private fun saveHistory(items: List) { - val jsonArray = JSONArray() - items.forEach { item -> - val obj = JSONObject().apply { - put("id", item.id) - put("text", item.text) - put("imageUri", item.imageUri) - put("timestamp", item.timestamp) - } - jsonArray.put(obj) + // Write-then-rename keeps the old file intact if we're killed mid-write. + val tmpFile = File(context.filesDir, "scan_history.json.tmp") + tmpFile.writeText(historyToJson(items)) + if (!tmpFile.renameTo(historyFile)) { + Log.e(TAG, "Could not move new history into place") + tmpFile.delete() } - historyFile.writeText(jsonArray.toString()) } } 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 02c5fc6..b6f1434 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 @@ -1,6 +1,7 @@ package com.skeler.scanely.ui.viewmodel import android.net.Uri +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.skeler.scanely.core.ai.AiEvent @@ -14,6 +15,7 @@ import com.skeler.scanely.history.data.HistoryManager import com.skeler.scanely.settings.data.SettingsKeys import com.skeler.scanely.settings.domain.repository.SettingsRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -27,6 +29,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject +private const val TAG = "AiScanViewModel" + data class AiScanState( val isProcessing: Boolean = false, val stage: AiStage? = null, @@ -187,7 +191,10 @@ class AiScanViewModel @Inject constructor( savedHistoryId = viewModelScope.async(Dispatchers.IO) { try { historyManager.saveItem(text, uri.toString()).id + } catch (e: CancellationException) { + throw e } catch (e: Exception) { + Log.e(TAG, "History save failed", e) null } } @@ -212,8 +219,11 @@ class AiScanViewModel @Inject constructor( try { val id = pendingId.await() ?: return@launch historyManager.updateItemText(id, newText) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { // Keep in-memory correction if persist fails. + Log.w(TAG, "History text update failed", e) } } } diff --git a/app/src/main/java/com/skeler/scanely/ui/viewmodel/OcrViewModel.kt b/app/src/main/java/com/skeler/scanely/ui/viewmodel/OcrViewModel.kt index 6fbe151..fdbfefb 100644 --- a/app/src/main/java/com/skeler/scanely/ui/viewmodel/OcrViewModel.kt +++ b/app/src/main/java/com/skeler/scanely/ui/viewmodel/OcrViewModel.kt @@ -1,12 +1,14 @@ package com.skeler.scanely.ui.viewmodel import android.net.Uri +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.skeler.scanely.core.ocr.OcrResult import com.skeler.scanely.core.ocr.TextOcrService import com.skeler.scanely.history.data.HistoryManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -16,6 +18,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject +private const val TAG = "OcrViewModel" + data class OcrUiState( val isProcessing: Boolean = false, val result: OcrResult? = null @@ -50,7 +54,10 @@ class OcrViewModel @Inject constructor( savedHistoryId = viewModelScope.async(Dispatchers.IO) { try { historyManager.saveItem(text, uri.toString()).id + } catch (e: CancellationException) { + throw e } catch (e: Exception) { + Log.e(TAG, "History save failed", e) null } } @@ -68,7 +75,11 @@ class OcrViewModel @Inject constructor( try { val id = pendingId.await() ?: return@launch historyManager.updateItemText(id, newText) - } catch (_: Exception) { + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Keep in-memory correction if persist fails. + Log.w(TAG, "History text update failed", e) } } } diff --git a/app/src/test/java/com/skeler/scanely/history/HistoryManagerJsonTest.kt b/app/src/test/java/com/skeler/scanely/history/HistoryManagerJsonTest.kt new file mode 100644 index 0000000..a2e1b44 --- /dev/null +++ b/app/src/test/java/com/skeler/scanely/history/HistoryManagerJsonTest.kt @@ -0,0 +1,49 @@ +package com.skeler.scanely.history + +import com.skeler.scanely.history.data.HistoryItem +import com.skeler.scanely.history.data.historyToJson +import com.skeler.scanely.history.data.parseHistoryJson +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class HistoryManagerJsonTest { + + @Test + fun `truncated json is corrupt, not an empty history`() { + val full = historyToJson( + listOf(HistoryItem(text = "receipt", imageUri = "file:///img.jpg")) + ) + val truncated = full.dropLast(10) + + assertNull(parseHistoryJson(truncated)) + } + + @Test + fun `empty array is a valid empty history, not corrupt`() { + val parsed = parseHistoryJson("[]") + + assertNotNull(parsed) + assertEquals(emptyList(), parsed) + } + + @Test + fun `garbage is corrupt`() { + assertNull(parseHistoryJson("not json at all")) + } + + @Test + fun `fifty items round-trip losslessly`() { + val items = (1..50).map { i -> + HistoryItem( + id = "id-$i", + text = "scan number $i\nwith a second line", + imageUri = "file:///data/history_images/$i.jpg", + timestamp = 1_700_000_000_000 + i + ) + } + + assertEquals(items, parseHistoryJson(historyToJson(items))) + } +} diff --git a/detekt-baseline.xml b/detekt-baseline.xml index bf11ad5..516dce0 100644 --- a/detekt-baseline.xml +++ b/detekt-baseline.xml @@ -55,11 +55,9 @@ CyclomaticComplexMethod:ScanExporter.kt$ScanExporter$private fun exportToDownloads( context: Context, displayName: String, mimeType: String, write: (OutputStream) -> Unit ): Uri CyclomaticComplexMethod:TableDecoder.kt$TableDecoder$fun decode( structure: FloatArray, boxes: FloatArray, steps: Int, classes: Int, vocab: List<String>, scale: Float ): TableStructure CyclomaticComplexMethod:UnifiedScanService.kt$UnifiedScanService$private fun mapBarcodeToActions(barcode: Barcode): List<ScanAction> - EmptyCatchBlock:HistoryManager.kt$HistoryManager${ } Filename:_DrawableVectors.kt$com.skeler.scanely.core.presentation.components.svg._DrawableVectors.kt FunctionOnlyReturningConstant:ScanExporter.kt$ScanExporter$private fun packageRelationships(): String FunctionOnlyReturningConstant:ScanExporter.kt$ScanExporter$private fun wordContentTypes(): String - InstanceOfCheckForException:ProviderClient.kt$ProviderClient$e is kotlinx.coroutines.CancellationException LargeClass:MarkdownParser.kt$MarkdownParser LargeClass:ScanExporter.kt$ScanExporter LongMethod:BarcodeAnalyzer.kt$BarcodeAnalyzer$private fun mapBarcodeToActions(barcode: Barcode): List<ScanAction> @@ -160,7 +158,6 @@ MaxLineLength:ScanExporter.kt$ScanExporter$properties = "<w:pBdr><w:left w:val=\"single\" w:sz=\"12\" w:space=\"6\" w:color=\"888888\"/></w:pBdr>" MaximumLineLength:NoSearchResult.kt$ MaximumLineLength:ScanExporter.kt$ScanExporter$ - NestedBlockDepth:ActionExecutor.kt$ActionExecutor$private fun connectWifi(context: Context, ssid: String, password: String?, type: WifiType) NestedBlockDepth:DbPostProcessor.kt$DbPostProcessor$fun extract(prob: FloatArray, w: Int, h: Int, scaleX: Float, scaleY: Float): List<Quad> NestedBlockDepth:MarkdownParser.kt$MarkdownParser$fun parse(source: String): List<MdBlock> NestedBlockDepth:PaddleModelStore.kt$PaddleModelStore$private fun downloadVerified(repo: String, dest: File, onProgress: (Float) -> Unit) @@ -169,21 +166,9 @@ NoSemicolons:PaddleOcrEngine.kt$CtcDecoder$; NoSemicolons:RtlText.kt$RtlText$; NoSemicolons:ScanReviewScreen.kt$; - PrintStackTrace:HistoryManager.kt$HistoryManager$e ReturnCount:ImageOps.kt$ImageOps$private fun axisAlignedCrop(src: Bitmap, q: Quad, w: Int, h: Int): Bitmap? ReturnCount:KeyVerifier.kt$KeyVerifier$suspend fun verify( provider: AiProvider, key: String, customUrl: String? = null ): VerificationResult ReturnCount:ProviderExecutor.kt$ProviderExecutor$suspend fun run( name: String, config: ProviderConfig, systemInstruction: String?, prompt: String, images: List<String>, allowStreaming: Boolean, emit: suspend (AiEvent) -> Unit ): ProviderOutcome - SwallowedException:ActionExecutor.kt$ActionExecutor$e: Exception - SwallowedException:AiScanViewModel.kt$AiScanViewModel$e: Exception - SwallowedException:HistoryManager.kt$HistoryManager$e: Exception - SwallowedException:KeyCipher.kt$KeyCipher$e: Exception - SwallowedException:KeyVerifier.kt$KeyVerifier$e: IOException - SwallowedException:OcrViewModel.kt$OcrViewModel$e: Exception - SwallowedException:OpenLibraryEngine.kt$OpenLibraryEngine$e: Exception - SwallowedException:PdfHelper.kt$PdfHelper$e: Exception - SwallowedException:ProviderClient.kt$ProviderClient$e: SerializationException - SwallowedException:ProviderExecutor.kt$ProviderExecutor$e: TimeoutCancellationException - SwallowedException:ScanExporter.kt$ScanExporter$e: Exception ThrowsCount:PayloadFactory.kt$PayloadFactory$fun create(uri: Uri, mode: AiMode): Payload ThrowsCount:ProviderClient.kt$ProviderClient$suspend fun stream( config: ProviderConfig, systemInstruction: String?, prompt: String, images: List<String>, streamedAnything: BooleanArray, emit: suspend (AiEvent) -> Unit ): String ThrowsCount:ScanExporter.kt$ScanExporter$private fun exportToDownloads( context: Context, displayName: String, mimeType: String, write: (OutputStream) -> Unit ): Uri @@ -203,7 +188,6 @@ TooManyFunctions:SettingsRepository.kt$SettingsRepository TopLevelPropertyNaming:ColorSchemes.kt$private const val HarmonizeFraction = 0.1f TopLevelPropertyNaming:ThemeMotion.kt$const val ThemeSwitchDurationMillis = 220 - UseCheckOrError:RetryUtils.kt$throw lastException ?: IllegalStateException("Retry failed") Wrapping:ProviderConfig.kt$ProviderConfigResolver$ProviderConfig( ProviderKind.OPENAI_COMPAT, model, it, CEREBRAS_URL, usesBundledKey = userKey == null ) Wrapping:ProviderConfig.kt$ProviderConfigResolver$ProviderConfig( ProviderKind.OPENAI_COMPAT, model, it, GROQ_URL, usesBundledKey = userKey == null ) Wrapping:ProviderConfig.kt$ProviderConfigResolver$ProviderConfig( ProviderKind.OPENAI_COMPAT, model, it, HUGGINGFACE_URL, usesBundledKey = userKey == null )