diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index f04d89e..0c3f4db 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -14,15 +14,33 @@ jobs: detekt: name: Static Analysis (Detekt) runs-on: ubuntu-latest + env: + DETEKT_VERSION: 1.23.8 steps: - uses: actions/checkout@v6 - - - name: Run Detekt - uses: detekt/detekt-action@v2 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 with: - config: detekt.yml - report-sarif: true - + java-version: '21' + distribution: 'temurin' + + - name: Download Detekt + run: | + BASE="https://github.com/detekt/detekt/releases/download/v${DETEKT_VERSION}" + curl -sSfL -o detekt-cli.jar "$BASE/detekt-cli-${DETEKT_VERSION}-all.jar" + curl -sSfL -o detekt-formatting.jar "$BASE/detekt-formatting-${DETEKT_VERSION}.jar" + + - name: Run Detekt + run: | + java -jar detekt-cli.jar \ + --config detekt.yml \ + --build-upon-default-config \ + --plugins detekt-formatting.jar \ + --baseline detekt-baseline.xml \ + --input app/src/main/java \ + --report sarif:results.sarif + - name: Upload SARIF to GitHub Security uses: github/codeql-action/upload-sarif@v3 if: always() diff --git a/app/src/androidTest/java/com/skeler/scanely/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/skeler/scanely/ExampleInstrumentedTest.kt index e9aaedb..6eaf3cf 100644 --- a/app/src/androidTest/java/com/skeler/scanely/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/skeler/scanely/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package com.skeler.scanely -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.* import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - @RunWith(AndroidJUnit4::class) class ExampleInstrumentedTest { @Test diff --git a/app/src/androidTest/java/com/skeler/scanely/core/ocr/PaddleFixesTest.kt b/app/src/androidTest/java/com/skeler/scanely/core/ocr/PaddleFixesTest.kt index d330bb4..39bfafd 100644 --- a/app/src/androidTest/java/com/skeler/scanely/core/ocr/PaddleFixesTest.kt +++ b/app/src/androidTest/java/com/skeler/scanely/core/ocr/PaddleFixesTest.kt @@ -46,7 +46,9 @@ class PaddleFixesTest { repeat(lineCount) { i -> canvas.drawText( "Line ${i + 1}: the quick brown fox jumps over the lazy dog", - 100f, 160f + i * 64f, paint + 100f, + 160f + i * 64f, + paint ) } return bmp @@ -98,11 +100,13 @@ class PaddleFixesTest { ) assertEquals( "deferred close must run once inference drains", - 0, engine.openSessionCount() + 0, + engine.openSessionCount() ) assertEquals( "engine must recover by recreating sessions", - quads.size, engine.detect(page, 960).size + quads.size, + engine.detect(page, 960).size ) crops.forEach { it.recycle() } page.recycle() @@ -125,7 +129,8 @@ class PaddleFixesTest { assertEquals( "correction did not restore upright: $results", - 0, residual + 0, + residual ) val quads = engine.detect(corrected, 960) assertTrue( @@ -145,8 +150,14 @@ class PaddleFixesTest { val charset = listOf("", "a", "b", " ") val soft = floatArrayOf( - 0.05f, 0.80f, 0.10f, 0.05f, - 0.90f, 0.04f, 0.03f, 0.03f + 0.05f, + 0.80f, + 0.10f, + 0.05f, + 0.90f, + 0.04f, + 0.03f, + 0.03f ) assertTrue(CtcDecoder.isSoftmaxed(soft, 4)) val softLine = CtcDecoder.decodeBatch(soft, 1, 2, 4, charset).single() @@ -156,8 +167,14 @@ class PaddleFixesTest { // Raw logits whose max lands in [0,1] — the old range heuristic // would have reported 0.9 as a probability. val raw = floatArrayOf( - 0.5f, 0.9f, -3.0f, -2.0f, - 5.0f, 1.0f, 0.0f, 0.0f + 0.5f, + 0.9f, + -3.0f, + -2.0f, + 5.0f, + 1.0f, + 0.0f, + 0.0f ) assertFalse(CtcDecoder.isSoftmaxed(raw, 4)) val rawLine = CtcDecoder.decodeBatch(raw, 1, 2, 4, charset).single() diff --git a/app/src/androidTest/java/com/skeler/scanely/core/ocr/PaddleOcrBenchmarkTest.kt b/app/src/androidTest/java/com/skeler/scanely/core/ocr/PaddleOcrBenchmarkTest.kt index 676f274..ba9511b 100644 --- a/app/src/androidTest/java/com/skeler/scanely/core/ocr/PaddleOcrBenchmarkTest.kt +++ b/app/src/androidTest/java/com/skeler/scanely/core/ocr/PaddleOcrBenchmarkTest.kt @@ -34,7 +34,9 @@ class PaddleOcrBenchmarkTest { repeat(35) { i -> canvas.drawText( "Line ${i + 1}: the quick brown fox jumps over the lazy dog 0123456789", - 120f, 200f + i * 58f, paint + 120f, + 200f + i * 58f, + paint ) } return bmp diff --git a/app/src/androidTest/java/com/skeler/scanely/core/ocr/PdfOcrResolutionTest.kt b/app/src/androidTest/java/com/skeler/scanely/core/ocr/PdfOcrResolutionTest.kt index d14e664..010c412 100644 --- a/app/src/androidTest/java/com/skeler/scanely/core/ocr/PdfOcrResolutionTest.kt +++ b/app/src/androidTest/java/com/skeler/scanely/core/ocr/PdfOcrResolutionTest.kt @@ -149,7 +149,8 @@ class PdfOcrResolutionTest { val doc = PdfDocument() val page = doc.startPage(PdfDocument.PageInfo.Builder(595, 842, 1).create()) page.canvas.drawBitmap( - photo, null, + photo, + null, android.graphics.Rect(0, 0, 595, 842), Paint(Paint.FILTER_BITMAP_FLAG) ) diff --git a/app/src/androidTest/java/com/skeler/scanely/core/ocr/paddle/PaddleMarksTableTest.kt b/app/src/androidTest/java/com/skeler/scanely/core/ocr/paddle/PaddleMarksTableTest.kt index c41266d..1537fd1 100644 --- a/app/src/androidTest/java/com/skeler/scanely/core/ocr/paddle/PaddleMarksTableTest.kt +++ b/app/src/androidTest/java/com/skeler/scanely/core/ocr/paddle/PaddleMarksTableTest.kt @@ -11,10 +11,10 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient +import org.json.JSONObject import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue -import org.json.JSONObject import org.junit.Before import org.junit.Test diff --git a/app/src/androidTest/java/com/skeler/scanely/core/ocr/paddle/PaddleStructureTest.kt b/app/src/androidTest/java/com/skeler/scanely/core/ocr/paddle/PaddleStructureTest.kt index 9bfc6b9..4391800 100644 --- a/app/src/androidTest/java/com/skeler/scanely/core/ocr/paddle/PaddleStructureTest.kt +++ b/app/src/androidTest/java/com/skeler/scanely/core/ocr/paddle/PaddleStructureTest.kt @@ -62,7 +62,9 @@ class PaddleStructureTest { repeat(8) { i -> canvas.drawText( "Revenue grew steadily across all regions during the quarter, with strong", - 80f, 220f + i * 30f, text + 80f, + 220f + i * 30f, + text ) } @@ -87,7 +89,9 @@ class PaddleStructureTest { repeat(6) { i -> canvas.drawText( "Costs remained flat while margins improved year over year.", - 80f, 900f + i * 30f, text + 80f, + 900f + i * 30f, + text ) } return bitmap @@ -137,7 +141,11 @@ class PaddleStructureTest { val page = document() val crop = Bitmap.createBitmap( - page, tableLeft, tableTop, columns * columnWidth, rows * rowHeight + page, + tableLeft, + tableTop, + columns * columnWidth, + rows * rowHeight ) val structure = engine.recognizeTable(crop) diff --git a/app/src/androidTest/java/com/skeler/scanely/core/pdf/MarkdownPdfTest.kt b/app/src/androidTest/java/com/skeler/scanely/core/pdf/MarkdownPdfTest.kt index c73c9ce..90e3503 100644 --- a/app/src/androidTest/java/com/skeler/scanely/core/pdf/MarkdownPdfTest.kt +++ b/app/src/androidTest/java/com/skeler/scanely/core/pdf/MarkdownPdfTest.kt @@ -6,11 +6,11 @@ import android.graphics.pdf.PdfRenderer import android.os.ParcelFileDescriptor import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import java.io.File import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith +import java.io.File @RunWith(AndroidJUnit4::class) class MarkdownPdfTest { diff --git a/app/src/main/java/com/skeler/scanely/MainActivity.kt b/app/src/main/java/com/skeler/scanely/MainActivity.kt index a4c9c61..dce062e 100644 --- a/app/src/main/java/com/skeler/scanely/MainActivity.kt +++ b/app/src/main/java/com/skeler/scanely/MainActivity.kt @@ -5,11 +5,11 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels -import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.skeler.scanely.core.common.CompositionLocals import com.skeler.scanely.navigation.ScanelyNavigation import com.skeler.scanely.settings.presentation.viewmodel.SettingsViewModel 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 089af81..61c5602 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 @@ -4,7 +4,6 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent -import androidx.core.net.toUri import android.net.wifi.WifiManager import android.net.wifi.WifiNetworkSuggestion import android.os.Build @@ -12,6 +11,7 @@ import android.provider.CalendarContract import android.provider.ContactsContract import android.provider.Settings import android.widget.Toast +import androidx.core.net.toUri import java.text.SimpleDateFormat import java.util.Locale import java.util.TimeZone @@ -108,7 +108,7 @@ object ActionExecutor { val wifiManager = context.getSystemService(Context.WIFI_SERVICE) as WifiManager wifiManager.addNetworkSuggestions(listOf(suggestion)) - + val intent = Intent(Settings.ACTION_WIFI_SETTINGS) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) diff --git a/app/src/main/java/com/skeler/scanely/core/actions/ScanAction.kt b/app/src/main/java/com/skeler/scanely/core/actions/ScanAction.kt index e6d9b44..3c0694a 100644 --- a/app/src/main/java/com/skeler/scanely/core/actions/ScanAction.kt +++ b/app/src/main/java/com/skeler/scanely/core/actions/ScanAction.kt @@ -1,6 +1,7 @@ package com.skeler.scanely.core.actions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.TextSnippet import androidx.compose.material.icons.rounded.Call import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.Email @@ -9,7 +10,6 @@ import androidx.compose.material.icons.rounded.Link import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.RestaurantMenu import androidx.compose.material.icons.rounded.Sms -import androidx.compose.material.icons.automirrored.rounded.TextSnippet import androidx.compose.material.icons.rounded.Wifi import androidx.compose.ui.graphics.vector.ImageVector @@ -20,25 +20,25 @@ enum class WifiType { } sealed class ScanAction { - + abstract val label: String abstract val icon: ImageVector - + data class OpenUrl(val url: String) : ScanAction() { override val label: String = "Open Link" override val icon: ImageVector = Icons.Rounded.Link } - + data class CopyText(val text: String, val displayLabel: String = "Copy") : ScanAction() { override val label: String = displayLabel override val icon: ImageVector = Icons.Rounded.ContentCopy } - + data class CallPhone(val number: String) : ScanAction() { override val label: String = "Call" override val icon: ImageVector = Icons.Rounded.Call } - + data class SendEmail( val email: String, val subject: String? = null, @@ -47,7 +47,7 @@ sealed class ScanAction { override val label: String = "Send Email" override val icon: ImageVector = Icons.Rounded.Email } - + data class ConnectWifi( val ssid: String, val password: String?, @@ -56,7 +56,7 @@ sealed class ScanAction { override val label: String = "Connect to WiFi" override val icon: ImageVector = Icons.Rounded.Wifi } - + data class SendSms( val number: String, val message: String? = null @@ -64,7 +64,7 @@ sealed class ScanAction { override val label: String = "Send SMS" override val icon: ImageVector = Icons.Rounded.Sms } - + data class AddContact( val name: String?, val phone: String?, @@ -74,12 +74,12 @@ sealed class ScanAction { override val label: String = "Add Contact" override val icon: ImageVector = Icons.Rounded.Person } - + data class ShowRaw(val text: String) : ScanAction() { override val label: String = "View Text" override val icon: ImageVector = Icons.AutoMirrored.Rounded.TextSnippet } - + /** startRaw/endRaw: ML Kit CalendarDateTime, e.g. "20240604T090000Z". */ data class AddEvent( val title: String?, diff --git a/app/src/main/java/com/skeler/scanely/core/actions/ScanActionDetector.kt b/app/src/main/java/com/skeler/scanely/core/actions/ScanActionDetector.kt index dc7b1d4..b9cfb96 100644 --- a/app/src/main/java/com/skeler/scanely/core/actions/ScanActionDetector.kt +++ b/app/src/main/java/com/skeler/scanely/core/actions/ScanActionDetector.kt @@ -5,63 +5,63 @@ import android.util.Log private const val TAG = "ScanActionDetector" object ScanActionDetector { - + private const val TAG = "ScanActionDetector" private val URL_PATTERN = Regex( """(?i)\b(https?://[^\s<>"{}|\\^`\[\]]+|www\.[^\s<>"{}|\\^`\[\]]+\.[a-z]{2,})""", RegexOption.IGNORE_CASE ) - + private val EMAIL_PATTERN = Regex( """[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}""" ) - + private val PHONE_PATTERN = Regex( """(?:\+|00)?[1-9]\d{0,2}[-.\s]?\(?\d{1,4}\)?[-.\s]?\d{1,4}[-.\s]?\d{1,9}""" ) - + // WIFI:T:WPA;S:NetworkName;P:password;; private val WIFI_PATTERN = Regex( """WIFI:(?:T:([^;]*);)?S:([^;]+);(?:P:([^;]*);)?(?:H:([^;]*);)?;?""", RegexOption.IGNORE_CASE ) - + fun detectActions(text: String): List { val actions = mutableListOf() - + Log.d(TAG, "Detecting actions for text: ${text.take(50)}...") - + actions.addAll(detectFromPatterns(text)) - + if (text.isNotBlank() && actions.none { it is ScanAction.CopyText }) { actions.add(ScanAction.CopyText(text, "Copy Text")) } - + Log.d(TAG, "Detected ${actions.size} actions") return actions.distinctBy { it.javaClass.simpleName + getActionKey(it) } } - + private fun detectFromPatterns(text: String): List { val actions = mutableListOf() - + val wifiMatch = WIFI_PATTERN.find(text) if (wifiMatch != null) { val type = wifiMatch.groupValues[1].uppercase() val ssid = wifiMatch.groupValues[2] val password = wifiMatch.groupValues[3].takeIf { it.isNotEmpty() } - + val wifiType = when (type) { "WPA", "WPA2", "WPA3" -> WifiType.WPA "WEP" -> WifiType.WEP else -> WifiType.OPEN } - + actions.add(ScanAction.ConnectWifi(ssid, password, wifiType)) actions.add(ScanAction.CopyText(ssid, "Copy SSID")) return actions // WiFi format is exclusive } - + val urls = URL_PATTERN.findAll(text).map { it.value }.toList() urls.take(5).forEach { url -> // Limit to avoid UI overflow val normalized = normalizeUrl(url) @@ -69,44 +69,44 @@ object ScanActionDetector { actions.add(ScanAction.OpenUrl(normalized)) } } - + val emails = EMAIL_PATTERN.findAll(text).map { it.value }.toList() emails.take(3).forEach { email -> actions.add(ScanAction.SendEmail(email)) } - - val phones = PHONE_PATTERN.findAll(text).map { it.value }.toList() + + val phones = PHONE_PATTERN.findAll(text).map { it.value }.toList() phones.take(2).forEach { phone -> if (isValidPhoneNumber(phone)) { actions.add(ScanAction.CallPhone(phone.trim())) } } - + return actions } - + // Phones: +/00 + 10–15 digits (avoid barcode FPs). private fun isValidPhoneNumber(phone: String): Boolean { val cleaned = phone.trim() - + if (!cleaned.startsWith("+") && !cleaned.startsWith("00")) { return false } - + val digitsOnly = cleaned.filter { it.isDigit() } - + if (digitsOnly.length < 10 || digitsOnly.length > 15) { return false } - - val hasProperFormat = cleaned.contains(" ") || - cleaned.contains("-") || - cleaned.contains("(") || - cleaned.startsWith("+") - + + val hasProperFormat = cleaned.contains(" ") || + cleaned.contains("-") || + cleaned.contains("(") || + cleaned.startsWith("+") + return hasProperFormat } - + private fun normalizeUrl(url: String): String { return when { url.startsWith("http://", ignoreCase = true) -> url @@ -115,23 +115,24 @@ object ScanActionDetector { else -> url } } - + private fun isValidUrl(url: String): Boolean { - if (!url.startsWith("http://", ignoreCase = true) && - !url.startsWith("https://", ignoreCase = true)) { + if (!url.startsWith("http://", ignoreCase = true) && + !url.startsWith("https://", ignoreCase = true) + ) { return false } - + val withoutProtocol = url.substringAfter("://") val host = withoutProtocol.substringBefore("/").substringBefore("?") val parts = host.split(".") - + if (parts.size < 2) return false - + val tld = parts.last().lowercase() return tld.length >= 2 } - + private fun getActionKey(action: ScanAction): String { return when (action) { is ScanAction.OpenUrl -> action.url @@ -146,9 +147,9 @@ object ScanActionDetector { is ScanAction.LookupProduct -> action.barcode } } - + fun getSummary(actions: List): Map { - return actions.groupBy { + return actions.groupBy { when (it) { is ScanAction.OpenUrl -> "links" is ScanAction.SendEmail -> "emails" diff --git a/app/src/main/java/com/skeler/scanely/core/ai/AiProvider.kt b/app/src/main/java/com/skeler/scanely/core/ai/AiProvider.kt index a33377e..57cebd4 100644 --- a/app/src/main/java/com/skeler/scanely/core/ai/AiProvider.kt +++ b/app/src/main/java/com/skeler/scanely/core/ai/AiProvider.kt @@ -4,7 +4,7 @@ enum class ProviderKind { OPENAI_COMPAT, // OpenRouter, OpenAI, most self-hosted / custom ANTHROPIC, GEMINI, - MISTRAL_OCR // non-chat, non-streaming + MISTRAL_OCR // non-chat, non-streaming } enum class AiProvider(val displayName: String, val kind: ProviderKind) { diff --git a/app/src/main/java/com/skeler/scanely/core/ai/AiRequestFactory.kt b/app/src/main/java/com/skeler/scanely/core/ai/AiRequestFactory.kt index b273537..ad7518c 100644 --- a/app/src/main/java/com/skeler/scanely/core/ai/AiRequestFactory.kt +++ b/app/src/main/java/com/skeler/scanely/core/ai/AiRequestFactory.kt @@ -37,7 +37,11 @@ internal object AiRequestFactory { val reasoningEffort = if ( config.url?.contains("api.groq.com") == true && config.model.contains("qwen", ignoreCase = true) - ) "none" else null + ) { + "none" + } else { + null + } return ChatRequest( model = config.model, messages = messages, diff --git a/app/src/main/java/com/skeler/scanely/core/ai/GenerativeAiService.kt b/app/src/main/java/com/skeler/scanely/core/ai/GenerativeAiService.kt index b8c6fd3..c6e7a4f 100644 --- a/app/src/main/java/com/skeler/scanely/core/ai/GenerativeAiService.kt +++ b/app/src/main/java/com/skeler/scanely/core/ai/GenerativeAiService.kt @@ -119,7 +119,9 @@ class GenerativeAiService @Inject internal constructor( // OCR endpoint can't translate; use Mistral chat. val config = if (resolved.kind == ProviderKind.MISTRAL_OCR) { ProviderConfig.mistralChat(resolved.apiKey) - } else resolved + } else { + resolved + } val outcome = executor.run( name = provider.displayName, config = config, 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 c3571a9..4f46755 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 @@ -133,7 +133,9 @@ internal class ProviderClient @Inject constructor( } if (event.type == "content_block_delta" && event.delta?.type == "text_delta") { event.delta.text - } else null + } else { + null + } } ProviderKind.GEMINI -> { val chunk = decodeChunk(payload) @@ -173,7 +175,8 @@ internal class ProviderClient @Inject constructor( ProviderKind.OPENAI_COMPAT -> { val url = config.url ?: throw FatalAiException(AiResult.Error("Missing endpoint URL")) val response = openAiApi.chatCompletion( - url, "Bearer ${config.apiKey}", + url, + "Bearer ${config.apiKey}", AiRequestFactory.openAi(config, systemInstruction, prompt, images, stream = false) ) response.error?.let { err -> @@ -198,7 +201,8 @@ internal class ProviderClient @Inject constructor( } ProviderKind.GEMINI -> { val response = geminiApi.generateContent( - config.model, config.apiKey, + config.model, + config.apiKey, AiRequestFactory.gemini(systemInstruction, prompt, images) ) response.error?.let { err -> @@ -241,7 +245,9 @@ internal class ProviderClient @Inject constructor( 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 + } else { + throw e + } } response.pages .sortedBy { it.index } diff --git a/app/src/main/java/com/skeler/scanely/core/ai/ProviderConfig.kt b/app/src/main/java/com/skeler/scanely/core/ai/ProviderConfig.kt index 4a865f4..36a7c67 100644 --- a/app/src/main/java/com/skeler/scanely/core/ai/ProviderConfig.kt +++ b/app/src/main/java/com/skeler/scanely/core/ai/ProviderConfig.kt @@ -43,32 +43,62 @@ internal class ProviderConfigResolver @Inject constructor( val userKey = settingValue(SettingsKeys.OPENROUTER_API_KEY) val key = userKey ?: Secrets.openRouter.trim().ifBlank { null } val model = settingValue(SettingsKeys.OPENROUTER_MODEL) ?: OPENROUTER_MODEL - key?.let { ProviderConfig(ProviderKind.OPENAI_COMPAT, model, it, OPENROUTER_URL, usesBundledKey = userKey == null) } + key?.let { ProviderConfig( + ProviderKind.OPENAI_COMPAT, + model, + it, + OPENROUTER_URL, + usesBundledKey = userKey == null + ) } } AiProvider.HUGGINGFACE -> { val userKey = settingValue(SettingsKeys.HUGGINGFACE_API_KEY) val key = userKey ?: Secrets.huggingFace.trim().ifBlank { null } val engineModel = OcrEngine.fromId(settingValue(SettingsKeys.OCR_ENGINE)).model val model = settingValue(SettingsKeys.HUGGINGFACE_MODEL) ?: engineModel - key?.let { ProviderConfig(ProviderKind.OPENAI_COMPAT, model, it, HUGGINGFACE_URL, usesBundledKey = userKey == null) } + key?.let { ProviderConfig( + ProviderKind.OPENAI_COMPAT, + model, + it, + HUGGINGFACE_URL, + usesBundledKey = userKey == null + ) } } AiProvider.NVIDIA -> { val userKey = settingValue(SettingsKeys.NVIDIA_API_KEY) val key = userKey ?: Secrets.nvidia.trim().ifBlank { null } val model = settingValue(SettingsKeys.NVIDIA_MODEL) ?: NVIDIA_MODEL - key?.let { ProviderConfig(ProviderKind.OPENAI_COMPAT, model, it, NVIDIA_URL, usesBundledKey = userKey == null) } + key?.let { ProviderConfig( + ProviderKind.OPENAI_COMPAT, + model, + it, + NVIDIA_URL, + usesBundledKey = userKey == null + ) } } AiProvider.GROQ -> { val userKey = settingValue(SettingsKeys.GROQ_API_KEY) val key = userKey ?: Secrets.groq.trim().ifBlank { null } val model = settingValue(SettingsKeys.GROQ_MODEL) ?: GROQ_MODEL - key?.let { ProviderConfig(ProviderKind.OPENAI_COMPAT, model, it, GROQ_URL, usesBundledKey = userKey == null) } + key?.let { ProviderConfig( + ProviderKind.OPENAI_COMPAT, + model, + it, + GROQ_URL, + usesBundledKey = userKey == null + ) } } AiProvider.CEREBRAS -> { val userKey = settingValue(SettingsKeys.CEREBRAS_API_KEY) val key = userKey ?: Secrets.cerebras.trim().ifBlank { null } val model = settingValue(SettingsKeys.CEREBRAS_MODEL) ?: CEREBRAS_MODEL - key?.let { ProviderConfig(ProviderKind.OPENAI_COMPAT, model, it, CEREBRAS_URL, usesBundledKey = userKey == null) } + key?.let { ProviderConfig( + ProviderKind.OPENAI_COMPAT, + model, + it, + CEREBRAS_URL, + usesBundledKey = userKey == null + ) } } AiProvider.CLOUDFLARE -> { val userKey = settingValue(SettingsKeys.CLOUDFLARE_API_KEY) @@ -82,7 +112,13 @@ internal class ProviderConfigResolver @Inject constructor( } pair?.let { (key, accountId, bundled) -> val model = settingValue(SettingsKeys.CLOUDFLARE_MODEL) ?: CLOUDFLARE_MODEL - ProviderConfig(ProviderKind.OPENAI_COMPAT, model, key, cloudflareUrl(accountId), usesBundledKey = bundled) + ProviderConfig( + ProviderKind.OPENAI_COMPAT, + model, + key, + cloudflareUrl(accountId), + usesBundledKey = bundled + ) } } AiProvider.OPENAI -> settingValue(SettingsKeys.OPENAI_API_KEY)?.let { @@ -105,7 +141,9 @@ internal class ProviderConfigResolver @Inject constructor( val model = settingValue(SettingsKeys.CUSTOM_MODEL) if (key != null && url != null && model != null) { ProviderConfig(ProviderKind.OPENAI_COMPAT, model, key, url) - } else null + } else { + null + } } } @@ -172,6 +210,7 @@ internal class ProviderConfigResolver @Inject constructor( 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" + // Account id is part of the Workers AI path. fun cloudflareUrl(accountId: String) = "https://api.cloudflare.com/client/v4/accounts/$accountId/ai/v1/chat/completions" 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 8f4ca0f..f9f6164 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 @@ -46,8 +46,11 @@ internal class ProviderExecutor @Inject constructor( emit( AiEvent.Stage( AiStage.PROCESSING, - if (lastFailureWasNetwork) "Connection trouble — $suffix" - else "$name is busy — $suffix" + if (lastFailureWasNetwork) { + "Connection trouble — $suffix" + } else { + "$name is busy — $suffix" + } ) ) delay(wait) @@ -110,15 +113,19 @@ internal class ProviderExecutor @Inject constructor( BACKOFF_BASE_MS * (1L shl (attempt - 1)) + Random.nextLong(BACKOFF_JITTER_MS) private fun attemptTimeout(imageCount: Int): Long = - (ATTEMPT_TIMEOUT_BASE_MS + - ATTEMPT_TIMEOUT_PER_EXTRA_IMAGE_MS * (imageCount - 1).coerceAtLeast(0)) + ( + ATTEMPT_TIMEOUT_BASE_MS + + ATTEMPT_TIMEOUT_PER_EXTRA_IMAGE_MS * (imageCount - 1).coerceAtLeast(0) + ) .coerceAtMost(ATTEMPT_TIMEOUT_MAX_MS) private fun httpErrorMessage(code: Int): String = when (code) { - 429 -> "Provider is rate-limited right now. Try again shortly, or add " + - "your own API key in Settings → AI Providers." - 402 -> "Your API key has hit its usage or billing limit. Check your plan " + - "with the provider, then try again." + 429 -> + "Provider is rate-limited right now. Try again shortly, or add " + + "your own API key in Settings → AI Providers." + 402 -> + "Your API key has hit its usage or billing limit. Check your plan " + + "with the provider, then try again." 401, 403 -> "Invalid or unauthorized API key. Check it in Settings → AI Providers." else -> "Request failed (HTTP $code)" } diff --git a/app/src/main/java/com/skeler/scanely/core/animation/AnimationSpecs.kt b/app/src/main/java/com/skeler/scanely/core/animation/AnimationSpecs.kt index ba9e542..77775f8 100644 --- a/app/src/main/java/com/skeler/scanely/core/animation/AnimationSpecs.kt +++ b/app/src/main/java/com/skeler/scanely/core/animation/AnimationSpecs.kt @@ -21,7 +21,6 @@ import androidx.compose.runtime.setValue import kotlinx.coroutines.delay import kotlin.math.pow - val EaseOutExpo = Easing { fraction -> if (fraction == 1f) 1f else 1f - 2f.pow(-10f * fraction) } diff --git a/app/src/main/java/com/skeler/scanely/core/barcode/BarcodeAnalyzer.kt b/app/src/main/java/com/skeler/scanely/core/barcode/BarcodeAnalyzer.kt index aa24752..2b3ce33 100644 --- a/app/src/main/java/com/skeler/scanely/core/barcode/BarcodeAnalyzer.kt +++ b/app/src/main/java/com/skeler/scanely/core/barcode/BarcodeAnalyzer.kt @@ -49,7 +49,9 @@ class BarcodeAnalyzer( ) .build() ) - } else null + } else { + null + } private val zxingDecoder by lazy { ZxingBarcodeDecoder() } @@ -162,11 +164,13 @@ class BarcodeAnalyzer( Barcode.WiFi.TYPE_WEP -> WifiType.WEP else -> WifiType.OPEN } - actions.add(ScanAction.ConnectWifi( - ssid = wifi.ssid ?: "", - password = wifi.password, - type = wifiType - )) + actions.add( + ScanAction.ConnectWifi( + ssid = wifi.ssid ?: "", + password = wifi.password, + type = wifiType + ) + ) wifi.ssid?.let { ssid -> actions.add(ScanAction.CopyText(ssid, "Copy SSID")) } @@ -175,11 +179,13 @@ class BarcodeAnalyzer( Barcode.TYPE_EMAIL -> { barcode.email?.let { email -> - actions.add(ScanAction.SendEmail( - email = email.address ?: rawValue, - subject = email.subject, - body = email.body - )) + actions.add( + ScanAction.SendEmail( + email = email.address ?: rawValue, + subject = email.subject, + body = email.body + ) + ) } } @@ -191,10 +197,12 @@ class BarcodeAnalyzer( Barcode.TYPE_SMS -> { barcode.sms?.let { sms -> - actions.add(ScanAction.SendSms( - number = sms.phoneNumber ?: "", - message = sms.message - )) + actions.add( + ScanAction.SendSms( + number = sms.phoneNumber ?: "", + message = sms.message + ) + ) } } @@ -205,12 +213,14 @@ class BarcodeAnalyzer( val email = contact.emails.firstOrNull()?.address val org = contact.organization - actions.add(ScanAction.AddContact( - name = name, - phone = phone, - email = email, - organization = org - )) + actions.add( + ScanAction.AddContact( + name = name, + phone = phone, + email = email, + organization = org + ) + ) } } @@ -220,13 +230,15 @@ class BarcodeAnalyzer( Barcode.TYPE_CALENDAR_EVENT -> { barcode.calendarEvent?.let { event -> - actions.add(ScanAction.AddEvent( - title = event.summary, - location = event.location, - description = event.description, - startRaw = event.start?.rawValue, - endRaw = event.end?.rawValue - )) + actions.add( + ScanAction.AddEvent( + title = event.summary, + location = event.location, + description = event.description, + startRaw = event.start?.rawValue, + endRaw = event.end?.rawValue + ) + ) } } diff --git a/app/src/main/java/com/skeler/scanely/core/di/LookupModule.kt b/app/src/main/java/com/skeler/scanely/core/di/LookupModule.kt index 0132009..6e587a7 100644 --- a/app/src/main/java/com/skeler/scanely/core/di/LookupModule.kt +++ b/app/src/main/java/com/skeler/scanely/core/di/LookupModule.kt @@ -25,12 +25,12 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object LookupModule { - + private val json = Json { ignoreUnknownKeys = true coerceInputValues = true } - + @Provides @Singleton fun provideOkHttpClient(): OkHttpClient { @@ -45,7 +45,7 @@ object LookupModule { } .build() } - + @Provides @Singleton fun provideOpenFoodFactsApi(okHttpClient: OkHttpClient): OpenFoodFactsApi { @@ -56,44 +56,43 @@ object LookupModule { .build() .create(OpenFoodFactsApi::class.java) } - - + @Provides @IntoSet fun provideOpenFoodFactsEngine(api: OpenFoodFactsApi): LookupEngine { return OpenFoodFactsEngine(api) } - + @Provides @IntoSet fun provideGoogleBooksEngine(okHttpClient: OkHttpClient): LookupEngine { return GoogleBooksEngine(okHttpClient) } - + @Provides @IntoSet fun provideOpenFDAEngine(okHttpClient: OkHttpClient): LookupEngine { return OpenFDAEngine(okHttpClient) } - + @Provides @IntoSet fun provideOpenBeautyFactsEngine(okHttpClient: OkHttpClient): LookupEngine { return OpenBeautyFactsEngine(okHttpClient) } - + @Provides @IntoSet fun provideOpenPetFoodFactsEngine(okHttpClient: OkHttpClient): LookupEngine { return OpenPetFoodFactsEngine(okHttpClient) } - + @Provides @IntoSet fun provideOpenLibraryEngine(okHttpClient: OkHttpClient): LookupEngine { return OpenLibraryEngine(okHttpClient) } - + @Provides @Singleton fun provideLookupOrchestrator(engines: Set<@JvmSuppressWildcards LookupEngine>): LookupOrchestrator { diff --git a/app/src/main/java/com/skeler/scanely/core/di/SettingsModule.kt b/app/src/main/java/com/skeler/scanely/core/di/SettingsModule.kt index c494640..936c813 100644 --- a/app/src/main/java/com/skeler/scanely/core/di/SettingsModule.kt +++ b/app/src/main/java/com/skeler/scanely/core/di/SettingsModule.kt @@ -29,7 +29,6 @@ object SettingsModule { fun provideSettingsRepository(dataStore: SettingsDataStore): SettingsRepository = SettingsRepositoryImpl(dataStore) - @Provides @Singleton fun provideJson(): Json = Json { @@ -37,4 +36,4 @@ object SettingsModule { isLenient = true ignoreUnknownKeys = true } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/skeler/scanely/core/food/FoodProduct.kt b/app/src/main/java/com/skeler/scanely/core/food/FoodProduct.kt index 8324720..6c71884 100644 --- a/app/src/main/java/com/skeler/scanely/core/food/FoodProduct.kt +++ b/app/src/main/java/com/skeler/scanely/core/food/FoodProduct.kt @@ -53,7 +53,7 @@ data class NutrimentsDto( fun ProductDto.toDomain(): FoodProduct? { val productName = name?.takeIf { it.isNotBlank() } ?: return null - + return FoodProduct( barcode = barcode ?: "", name = productName, diff --git a/app/src/main/java/com/skeler/scanely/core/image/DocumentFilters.kt b/app/src/main/java/com/skeler/scanely/core/image/DocumentFilters.kt index 76a172c..9198183 100644 --- a/app/src/main/java/com/skeler/scanely/core/image/DocumentFilters.kt +++ b/app/src/main/java/com/skeler/scanely/core/image/DocumentFilters.kt @@ -85,9 +85,11 @@ object DocumentFilters { val pixelRow = y * width for (x in 0 until width) { val p = pixels[pixelRow + x] - val luminance = ((p shr 16 and 0xFF) * 30 + - (p shr 8 and 0xFF) * 59 + - (p and 0xFF) * 11) / 100 + val luminance = ( + (p shr 16 and 0xFF) * 30 + + (p shr 8 and 0xFF) * 59 + + (p and 0xFF) * 11 + ) / 100 rowSum += luminance integral[integralRow + x + 1] = integral[previousRow + x + 1] + rowSum } @@ -108,9 +110,11 @@ object DocumentFilters { integral[top * stride + left] ) / area val p = pixels[y * width + x] - val luminance = ((p shr 16 and 0xFF) * 30 + - (p shr 8 and 0xFF) * 59 + - (p and 0xFF) * 11) / 100 + val luminance = ( + (p shr 16 and 0xFF) * 30 + + (p shr 8 and 0xFF) * 59 + + (p and 0xFF) * 11 + ) / 100 val value = if (luminance >= localMean - BLACK_WHITE_BIAS) 0xFF else 0x00 pixels[y * width + x] = (0xFF shl 24) or (value shl 16) or (value shl 8) or value } diff --git a/app/src/main/java/com/skeler/scanely/core/image/ImagePreprocessor.kt b/app/src/main/java/com/skeler/scanely/core/image/ImagePreprocessor.kt index 5c39323..4c79ae1 100644 --- a/app/src/main/java/com/skeler/scanely/core/image/ImagePreprocessor.kt +++ b/app/src/main/java/com/skeler/scanely/core/image/ImagePreprocessor.kt @@ -16,20 +16,22 @@ object ImagePreprocessor { val result = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888) val canvas = Canvas(result) val paint = Paint() - + val scale = contrast val translate = (1.0f - scale) / 2.0f * 255f - - val colorMatrix = ColorMatrix(floatArrayOf( - scale, 0f, 0f, 0f, translate, - 0f, scale, 0f, 0f, translate, - 0f, 0f, scale, 0f, translate, - 0f, 0f, 0f, 1f, 0f - )) - + + val colorMatrix = ColorMatrix( + floatArrayOf( + scale, 0f, 0f, 0f, translate, + 0f, scale, 0f, 0f, translate, + 0f, 0f, scale, 0f, translate, + 0f, 0f, 0f, 1f, 0f + ) + ) + paint.colorFilter = ColorMatrixColorFilter(colorMatrix) canvas.drawBitmap(bitmap, 0f, 0f, paint) - + Log.d(TAG, "Applied contrast enhancement: $contrast") return result } @@ -38,45 +40,49 @@ object ImagePreprocessor { val result = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888) val canvas = Canvas(result) val paint = Paint() - + val colorMatrix = ColorMatrix().apply { setSaturation(0f) } - + paint.colorFilter = ColorMatrixColorFilter(colorMatrix) canvas.drawBitmap(bitmap, 0f, 0f, paint) - + return result } fun rotate(bitmap: Bitmap, degrees: Float): Bitmap { if (degrees == 0f) return bitmap - + val matrix = Matrix().apply { postRotate(degrees, bitmap.width / 2f, bitmap.height / 2f) } - + return Bitmap.createBitmap( - bitmap, 0, 0, - bitmap.width, bitmap.height, - matrix, true + bitmap, + 0, + 0, + bitmap.width, + bitmap.height, + matrix, + true ) } fun scaleToMinDimension(bitmap: Bitmap, minDimension: Int = 1024): Bitmap { val width = bitmap.width val height = bitmap.height - + if (width >= minDimension && height >= minDimension) { return bitmap } - + val scale = minDimension.toFloat() / minOf(width, height) val newWidth = (width * scale).toInt() val newHeight = (height * scale).toInt() - - Log.d(TAG, "Scaling image from ${width}x${height} to ${newWidth}x${newHeight}") - + + Log.d(TAG, "Scaling image from ${width}x$height to ${newWidth}x$newHeight") + return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true) } @@ -84,17 +90,19 @@ object ImagePreprocessor { val result = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888) val canvas = Canvas(result) val paint = Paint() - - val colorMatrix = ColorMatrix(floatArrayOf( - 1f, 0f, 0f, 0f, brightness, - 0f, 1f, 0f, 0f, brightness, - 0f, 0f, 1f, 0f, brightness, - 0f, 0f, 0f, 1f, 0f - )) - + + val colorMatrix = ColorMatrix( + floatArrayOf( + 1f, 0f, 0f, 0f, brightness, + 0f, 1f, 0f, 0f, brightness, + 0f, 0f, 1f, 0f, brightness, + 0f, 0f, 0f, 1f, 0f + ) + ) + paint.colorFilter = ColorMatrixColorFilter(colorMatrix) canvas.drawBitmap(bitmap, 0f, 0f, paint) - + return result } diff --git a/app/src/main/java/com/skeler/scanely/core/lookup/LookupEngine.kt b/app/src/main/java/com/skeler/scanely/core/lookup/LookupEngine.kt index be52f6f..a5adcd8 100644 --- a/app/src/main/java/com/skeler/scanely/core/lookup/LookupEngine.kt +++ b/app/src/main/java/com/skeler/scanely/core/lookup/LookupEngine.kt @@ -5,9 +5,9 @@ sealed interface LookupResult { val product: ProductInfo, val source: String ) : LookupResult - + data class NotFound(val source: String) : LookupResult - + data class Error( val source: String, val exception: Exception @@ -16,11 +16,12 @@ sealed interface LookupResult { interface LookupEngine { val name: String + /** Lower = higher priority in fallback order. */ val priority: Int val category: ProductCategory - + fun supports(barcode: String): Boolean - + suspend fun lookup(barcode: String): LookupResult } diff --git a/app/src/main/java/com/skeler/scanely/core/lookup/LookupOrchestrator.kt b/app/src/main/java/com/skeler/scanely/core/lookup/LookupOrchestrator.kt index 85f4774..c881b60 100644 --- a/app/src/main/java/com/skeler/scanely/core/lookup/LookupOrchestrator.kt +++ b/app/src/main/java/com/skeler/scanely/core/lookup/LookupOrchestrator.kt @@ -20,7 +20,7 @@ class LookupOrchestrator @Inject constructor( initialDelayMs = 500, maxDelayMs = 2000 ) - + suspend fun lookup(barcode: String): LookupResult = coroutineScope { val supportingEngines = engines .filter { it.supports(barcode) } @@ -31,8 +31,11 @@ class LookupOrchestrator @Inject constructor( return@coroutineScope LookupResult.NotFound("No compatible engines") } - Log.d(TAG, "Looking up $barcode with ${supportingEngines.size} engines: " + - supportingEngines.joinToString { it.name }) + Log.d( + TAG, + "Looking up $barcode with ${supportingEngines.size} engines: " + + supportingEngines.joinToString { it.name } + ) val jobs = supportingEngines.map { engine -> async { lookupWithRetry(engine, barcode) } @@ -59,7 +62,7 @@ class LookupOrchestrator @Inject constructor( LookupResult.NotFound("Searched ${supportingEngines.size} sources") } } - + private suspend fun lookupWithRetry(engine: LookupEngine, barcode: String): LookupResult { return try { withRetry(retryConfig) { diff --git a/app/src/main/java/com/skeler/scanely/core/lookup/ProductInfo.kt b/app/src/main/java/com/skeler/scanely/core/lookup/ProductInfo.kt index 504f82e..6deb558 100644 --- a/app/src/main/java/com/skeler/scanely/core/lookup/ProductInfo.kt +++ b/app/src/main/java/com/skeler/scanely/core/lookup/ProductInfo.kt @@ -13,20 +13,20 @@ data class ProductInfo( val barcode: String, val source: String, val category: ProductCategory, - + val name: String?, val brand: String?, val description: String?, val imageUrl: String?, - + val foodData: FoodData? = null, - + val bookData: BookData? = null, - + val medicineData: MedicineData? = null, - + val cosmeticsData: CosmeticsData? = null, - + val rawMetadata: Map = emptyMap() ) 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 c2ae266..44d427a 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 @@ -2,7 +2,6 @@ package com.skeler.scanely.core.lookup import kotlinx.coroutines.delay import kotlin.math.min -import kotlin.math.pow data class RetryConfig( val maxAttempts: Int = 3, @@ -18,17 +17,17 @@ suspend fun withRetry( ): 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 } - + delay(currentDelay) currentDelay = min( (currentDelay * config.backoffMultiplier).toLong(), @@ -36,7 +35,7 @@ suspend fun withRetry( ) } } - + throw lastException ?: IllegalStateException("Retry failed") } diff --git a/app/src/main/java/com/skeler/scanely/core/lookup/engines/GoogleBooksEngine.kt b/app/src/main/java/com/skeler/scanely/core/lookup/engines/GoogleBooksEngine.kt index b63cdf2..3cc89a1 100644 --- a/app/src/main/java/com/skeler/scanely/core/lookup/engines/GoogleBooksEngine.kt +++ b/app/src/main/java/com/skeler/scanely/core/lookup/engines/GoogleBooksEngine.kt @@ -8,14 +8,13 @@ import com.skeler.scanely.core.lookup.LookupResult import com.skeler.scanely.core.lookup.ProductCategory import com.skeler.scanely.core.lookup.ProductInfo import com.skeler.scanely.core.lookup.isIsbn -import kotlinx.serialization.SerialName +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import okhttp3.OkHttpClient import okhttp3.Request import javax.inject.Inject import javax.inject.Singleton -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext private const val TAG = "GoogleBooksEngine" @@ -24,7 +23,7 @@ private const val TAG = "GoogleBooksEngine" class GoogleBooksEngine @Inject constructor( private val okHttpClient: OkHttpClient ) : LookupEngine { - + override val name = "Google Books" override val priority = 1 override val category = ProductCategory.BOOK @@ -45,7 +44,7 @@ class GoogleBooksEngine @Inject constructor( } ?: return@withContext LookupResult.NotFound(name) val booksResponse = LookupJson.decodeFromString(body) - + if (booksResponse.totalItems > 0 && booksResponse.items?.isNotEmpty() == true) { val volume = booksResponse.items.first().volumeInfo val product = mapToProductInfo(barcode, volume) @@ -60,7 +59,7 @@ class GoogleBooksEngine @Inject constructor( LookupResult.Error(name, e) } } - + private fun mapToProductInfo(barcode: String, volume: VolumeInfo): ProductInfo { val isbn10 = volume.industryIdentifiers ?.find { it.type == "ISBN_10" }?.identifier diff --git a/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenBeautyFactsEngine.kt b/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenBeautyFactsEngine.kt index 259d4e1..33732bd 100644 --- a/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenBeautyFactsEngine.kt +++ b/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenBeautyFactsEngine.kt @@ -8,14 +8,14 @@ import com.skeler.scanely.core.lookup.LookupResult import com.skeler.scanely.core.lookup.ProductCategory import com.skeler.scanely.core.lookup.ProductInfo import com.skeler.scanely.core.lookup.isEanUpc +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import okhttp3.OkHttpClient import okhttp3.Request import javax.inject.Inject import javax.inject.Singleton -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext private const val TAG = "OpenBeautyFactsEngine" private const val FIELDS = diff --git a/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenFDAEngine.kt b/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenFDAEngine.kt index 6766ec1..4446fae 100644 --- a/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenFDAEngine.kt +++ b/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenFDAEngine.kt @@ -7,14 +7,14 @@ import com.skeler.scanely.core.lookup.LookupResult import com.skeler.scanely.core.lookup.MedicineData import com.skeler.scanely.core.lookup.ProductCategory import com.skeler.scanely.core.lookup.ProductInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import okhttp3.OkHttpClient import okhttp3.Request import javax.inject.Inject import javax.inject.Singleton -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext private const val TAG = "OpenFDAEngine" @@ -41,7 +41,7 @@ class OpenFDAEngine @Inject constructor( "https://api.fda.gov/drug/label.json?search=openfda.upc:\"$cleaned\"&limit=1", "https://api.fda.gov/drug/label.json?search=openfda.package_ndc:\"$cleaned\"&limit=1" ) - + for (url in urls) { Log.d(TAG, "Trying: $url") @@ -64,7 +64,7 @@ class OpenFDAEngine @Inject constructor( continue } } - + Log.d(TAG, "Not found: $barcode") LookupResult.NotFound(name) } catch (e: Exception) { @@ -72,10 +72,10 @@ class OpenFDAEngine @Inject constructor( LookupResult.Error(name, e) } } - + private fun mapToProductInfo(barcode: String, result: DrugLabel): ProductInfo { val openFda = result.openfda - + return ProductInfo( barcode = barcode, source = name, diff --git a/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenFoodFactsEngine.kt b/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenFoodFactsEngine.kt index a921cba..e933078 100644 --- a/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenFoodFactsEngine.kt +++ b/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenFoodFactsEngine.kt @@ -23,14 +23,14 @@ class OpenFoodFactsEngine @Inject constructor( override val name = "Open Food Facts" override val priority = 0 override val category = ProductCategory.FOOD - + override fun supports(barcode: String): Boolean = isEanUpc(barcode) - + override suspend fun lookup(barcode: String): LookupResult { return try { Log.d(TAG, "Looking up: $barcode") val response = api.getProduct(barcode) - + if (response.status == 1 && response.product != null) { val food = response.product.toDomain() if (food != null) { @@ -49,7 +49,7 @@ class OpenFoodFactsEngine @Inject constructor( LookupResult.Error(name, e) } } - + private fun mapToProductInfo(barcode: String, food: FoodProduct): ProductInfo { return ProductInfo( barcode = barcode, 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 3661379..a53f5c9 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 @@ -8,19 +8,18 @@ import com.skeler.scanely.core.lookup.LookupResult import com.skeler.scanely.core.lookup.ProductCategory import com.skeler.scanely.core.lookup.ProductInfo import com.skeler.scanely.core.lookup.isIsbn +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import okhttp3.OkHttpClient import okhttp3.Request import javax.inject.Inject import javax.inject.Singleton -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext private const val TAG = "OpenLibraryEngine" @@ -29,7 +28,7 @@ private const val TAG = "OpenLibraryEngine" class OpenLibraryEngine @Inject constructor( private val okHttpClient: OkHttpClient ) : LookupEngine { - + override val name = "Open Library" override val priority = 2 override val category = ProductCategory.BOOK @@ -51,7 +50,7 @@ class OpenLibraryEngine @Inject constructor( val jsonObject = LookupJson.parseToJsonElement(body).jsonObject val key = "ISBN:$cleaned" - + if (jsonObject.containsKey(key)) { val bookJson = jsonObject[key]?.jsonObject if (bookJson != null) { @@ -60,7 +59,7 @@ class OpenLibraryEngine @Inject constructor( return@withContext LookupResult.Found(product, name) } } - + Log.d(TAG, "Not found: $barcode") LookupResult.NotFound(name) } catch (e: Exception) { @@ -68,7 +67,7 @@ class OpenLibraryEngine @Inject constructor( LookupResult.Error(name, e) } } - + private fun mapToProductInfo(barcode: String, bookJson: JsonObject): ProductInfo { val title = bookJson["title"]?.jsonPrimitive?.contentOrNull val publishers = extractNames(bookJson["publishers"]) @@ -101,7 +100,7 @@ class OpenLibraryEngine @Inject constructor( ) ) } - + private fun extractNames(element: kotlinx.serialization.json.JsonElement?): List { return try { when (element) { diff --git a/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenPetFoodFactsEngine.kt b/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenPetFoodFactsEngine.kt index fd47bf4..466a906 100644 --- a/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenPetFoodFactsEngine.kt +++ b/app/src/main/java/com/skeler/scanely/core/lookup/engines/OpenPetFoodFactsEngine.kt @@ -8,14 +8,14 @@ import com.skeler.scanely.core.lookup.LookupResult import com.skeler.scanely.core.lookup.ProductCategory import com.skeler.scanely.core.lookup.ProductInfo import com.skeler.scanely.core.lookup.isEanUpc +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import okhttp3.OkHttpClient import okhttp3.Request import javax.inject.Inject import javax.inject.Singleton -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext private const val TAG = "OpenPetFoodFactsEngine" private const val FIELDS = diff --git a/app/src/main/java/com/skeler/scanely/core/network/NetworkObserver.kt b/app/src/main/java/com/skeler/scanely/core/network/NetworkObserver.kt index b1e9f43..525e8dd 100644 --- a/app/src/main/java/com/skeler/scanely/core/network/NetworkObserver.kt +++ b/app/src/main/java/com/skeler/scanely/core/network/NetworkObserver.kt @@ -17,7 +17,7 @@ import javax.inject.Singleton class NetworkObserver @Inject constructor( @ApplicationContext private val context: Context ) { - private val connectivityManager = + private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager fun isCurrentlyOnline(): Boolean { @@ -57,10 +57,10 @@ class NetworkObserver @Inject constructor( val initialState = connectivityManager.activeNetwork?.let { network -> connectivityManager.getNetworkCapabilities(network)?.let { capabilities -> capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && - capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) } } ?: false - + trySend(initialState) connectivityManager.registerNetworkCallback(request, callback) diff --git a/app/src/main/java/com/skeler/scanely/core/ocr/TextOcrService.kt b/app/src/main/java/com/skeler/scanely/core/ocr/TextOcrService.kt index bb834d0..a3b9dfc 100644 --- a/app/src/main/java/com/skeler/scanely/core/ocr/TextOcrService.kt +++ b/app/src/main/java/com/skeler/scanely/core/ocr/TextOcrService.kt @@ -37,9 +37,12 @@ class TextOcrService @Inject constructor( suspend fun recognizeFromUri(uri: Uri): OcrResult = when (engine()) { TextOcrEngine.PADDLE -> { val bitmap = loadBitmap(uri) - if (bitmap == null) OcrResult.Error("Failed to load image") - else paddle.recognize(bitmap).orFallback { mlKit.recognizeFromBitmap(bitmap) } - .also { bitmap.recycle() } + if (bitmap == null) { + OcrResult.Error("Failed to load image") + } else { + paddle.recognize(bitmap).orFallback { mlKit.recognizeFromBitmap(bitmap) } + .also { bitmap.recycle() } + } } TextOcrEngine.ML_KIT -> mlKit.recognizeFromUri(uri) } diff --git a/app/src/main/java/com/skeler/scanely/core/ocr/paddle/Geometry.kt b/app/src/main/java/com/skeler/scanely/core/ocr/paddle/Geometry.kt index 0d828af..663ebd8 100644 --- a/app/src/main/java/com/skeler/scanely/core/ocr/paddle/Geometry.kt +++ b/app/src/main/java/com/skeler/scanely/core/ocr/paddle/Geometry.kt @@ -15,9 +15,11 @@ data class Quad(val pts: FloatArray) { val minY get() = (0..3).minOf { y(it) } val maxY get() = (0..3).maxOf { y(it) } - fun scaled(sx: Float, sy: Float) = Quad(FloatArray(8) { i -> - if (i % 2 == 0) pts[i] * sx else pts[i] * sy - }) + fun scaled(sx: Float, sy: Float) = Quad( + FloatArray(8) { i -> + if (i % 2 == 0) pts[i] * sx else pts[i] * sy + } + ) override fun equals(other: Any?) = other is Quad && pts.contentEquals(other.pts) override fun hashCode() = pts.contentHashCode() @@ -40,8 +42,10 @@ object Geometry { var k = 0 fun cross(i: Int): Float { - val ox = hull[(k - 2) * 2]; val oy = hull[(k - 2) * 2 + 1] - val ax = hull[(k - 1) * 2]; val ay = hull[(k - 1) * 2 + 1] + val ox = hull[(k - 2) * 2] + val oy = hull[(k - 2) * 2 + 1] + val ax = hull[(k - 1) * 2] + val ay = hull[(k - 1) * 2 + 1] return (ax - ox) * (pts[i * 2 + 1] - oy) - (ay - oy) * (pts[i * 2] - ox) } fun push(i: Int, floor: Int) { @@ -64,21 +68,30 @@ object Geometry { val hull = convexHull(pts) val n = hull.size / 2 if (n < 3) { - var x0 = Float.MAX_VALUE; var y0 = Float.MAX_VALUE - var x1 = -Float.MAX_VALUE; var y1 = -Float.MAX_VALUE + var x0 = Float.MAX_VALUE + var y0 = Float.MAX_VALUE + var x1 = -Float.MAX_VALUE + var y1 = -Float.MAX_VALUE for (i in 0 until n) { - x0 = minOf(x0, hull[i * 2]); x1 = maxOf(x1, hull[i * 2]) - y0 = minOf(y0, hull[i * 2 + 1]); y1 = maxOf(y1, hull[i * 2 + 1]) + x0 = minOf(x0, hull[i * 2]) + x1 = maxOf(x1, hull[i * 2]) + y0 = minOf(y0, hull[i * 2 + 1]) + y1 = maxOf(y1, hull[i * 2 + 1]) } return RotatedRect( Quad(floatArrayOf(x0, y0, x1, y0, x1, y1, x0, y1)), - x1 - x0, y1 - y0 + x1 - x0, + y1 - y0 ) } var bestArea = Float.MAX_VALUE - var bux = 1f; var buy = 0f - var bp0 = 0f; var bp1 = 0f; var bq0 = 0f; var bq1 = 0f + var bux = 1f + var buy = 0f + var bp0 = 0f + var bp1 = 0f + var bq0 = 0f + var bq1 = 0f for (i in 0 until n) { val j = (i + 1) % n @@ -86,31 +99,47 @@ object Geometry { val ey = hull[j * 2 + 1] - hull[i * 2 + 1] val len = hypot(ex, ey) if (len < 1e-6f) continue - val ux = ex / len; val uy = ey / len - var p0 = Float.MAX_VALUE; var p1 = -Float.MAX_VALUE - var q0 = Float.MAX_VALUE; var q1 = -Float.MAX_VALUE + val ux = ex / len + val uy = ey / len + var p0 = Float.MAX_VALUE + var p1 = -Float.MAX_VALUE + var q0 = Float.MAX_VALUE + var q1 = -Float.MAX_VALUE for (h in 0 until n) { - val hx = hull[h * 2]; val hy = hull[h * 2 + 1] + val hx = hull[h * 2] + val hy = hull[h * 2 + 1] val p = hx * ux + hy * uy val q = -hx * uy + hy * ux - if (p < p0) p0 = p; if (p > p1) p1 = p - if (q < q0) q0 = q; if (q > q1) q1 = q + if (p < p0) p0 = p + if (p > p1) p1 = p + if (q < q0) q0 = q + if (q > q1) q1 = q } val area = (p1 - p0) * (q1 - q0) if (area < bestArea) { - bestArea = area; bux = ux; buy = uy - bp0 = p0; bp1 = p1; bq0 = q0; bq1 = q1 + bestArea = area + bux = ux + buy = uy + bp0 = p0 + bp1 = p1 + bq0 = q0 + bq1 = q1 } } - val axx = bux; val axy = buy - val ayx = -buy; val ayy = bux + val axx = bux + val axy = buy + val ayx = -buy + val ayy = bux fun corner(p: Float, q: Float) = floatArrayOf(axx * p + ayx * q, axy * p + ayy * q) - val c0 = corner(bp0, bq0); val c1 = corner(bp1, bq0) - val c2 = corner(bp1, bq1); val c3 = corner(bp0, bq1) + val c0 = corner(bp0, bq0) + val c1 = corner(bp1, bq0) + val c2 = corner(bp1, bq1) + val c3 = corner(bp0, bq1) return RotatedRect( Quad(floatArrayOf(c0[0], c0[1], c1[0], c1[1], c2[0], c2[1], c3[0], c3[1])), - bp1 - bp0, bq1 - bq0 + bp1 - bp0, + bq1 - bq0 ) } @@ -127,8 +156,10 @@ object Geometry { if (perim < 1e-6f) return q.pts val d = area * ratio / perim - val ax = FloatArray(4); val ay = FloatArray(4) - val bx = FloatArray(4); val by = FloatArray(4) + val ax = FloatArray(4) + val ay = FloatArray(4) + val bx = FloatArray(4) + val by = FloatArray(4) for (i in 0 until 4) { val j = (i + 1) % 4 val ex = q.x(j) - q.x(i) @@ -136,18 +167,23 @@ object Geometry { val len = maxOf(hypot(ex, ey), 1e-6f) val nx = ey / len * d val ny = -ex / len * d - ax[i] = q.x(i) + nx; ay[i] = q.y(i) + ny - bx[i] = q.x(j) + nx; by[i] = q.y(j) + ny + ax[i] = q.x(i) + nx + ay[i] = q.y(i) + ny + bx[i] = q.x(j) + nx + by[i] = q.y(j) + ny } val out = FloatArray(8) for (i in 0 until 4) { val p = (i + 3) % 4 - val d1x = bx[p] - ax[p]; val d1y = by[p] - ay[p] - val d2x = bx[i] - ax[i]; val d2y = by[i] - ay[i] + val d1x = bx[p] - ax[p] + val d1y = by[p] - ay[p] + val d2x = bx[i] - ax[i] + val d2y = by[i] - ay[i] val den = d1x * d2y - d1y * d2x if (abs(den) < 1e-9f) { - out[i * 2] = ax[i]; out[i * 2 + 1] = ay[i] + out[i * 2] = ax[i] + out[i * 2 + 1] = ay[i] } else { val t = ((ax[i] - ax[p]) * d2y - (ay[i] - ay[p]) * d2x) / den out[i * 2] = ax[p] + d1x * t @@ -159,21 +195,44 @@ object Geometry { /** Corners → tl, tr, br, bl. */ fun orderQuad(q: Quad): Quad { - var tl = 0; var br = 0; var tr = 0; var bl = 0 - var minSum = Float.MAX_VALUE; var maxSum = -Float.MAX_VALUE - var minDiff = Float.MAX_VALUE; var maxDiff = -Float.MAX_VALUE + var tl = 0 + var br = 0 + var tr = 0 + var bl = 0 + var minSum = Float.MAX_VALUE + var maxSum = -Float.MAX_VALUE + var minDiff = Float.MAX_VALUE + var maxDiff = -Float.MAX_VALUE for (i in 0 until 4) { val s = q.x(i) + q.y(i) val d = q.y(i) - q.x(i) - if (s < minSum) { minSum = s; tl = i } - if (s > maxSum) { maxSum = s; br = i } - if (d < minDiff) { minDiff = d; tr = i } - if (d > maxDiff) { maxDiff = d; bl = i } + if (s < minSum) { + minSum = s + tl = i + } + if (s > maxSum) { + maxSum = s + br = i + } + if (d < minDiff) { + minDiff = d + tr = i + } + if (d > maxDiff) { + maxDiff = d + bl = i + } } return Quad( floatArrayOf( - q.x(tl), q.y(tl), q.x(tr), q.y(tr), - q.x(br), q.y(br), q.x(bl), q.y(bl) + q.x(tl), + q.y(tl), + q.x(tr), + q.y(tr), + q.x(br), + q.y(br), + q.x(bl), + q.y(bl) ) ) } diff --git a/app/src/main/java/com/skeler/scanely/core/ocr/paddle/ImageOps.kt b/app/src/main/java/com/skeler/scanely/core/ocr/paddle/ImageOps.kt index 25b44e2..69cc473 100644 --- a/app/src/main/java/com/skeler/scanely/core/ocr/paddle/ImageOps.kt +++ b/app/src/main/java/com/skeler/scanely/core/ocr/paddle/ImageOps.kt @@ -182,8 +182,10 @@ object ImageOps { /** Most document lines are square-on: a native subrect copy beats per-pixel warping. */ private fun axisAlignedCrop(src: Bitmap, q: Quad, w: Int, h: Int): Bitmap? { val skew = maxOf( - abs(q.y(0) - q.y(1)), abs(q.y(3) - q.y(2)), - abs(q.x(0) - q.x(3)), abs(q.x(1) - q.x(2)) + abs(q.y(0) - q.y(1)), + abs(q.y(3) - q.y(2)), + abs(q.x(0) - q.x(3)), + abs(q.x(1) - q.x(2)) ) if (skew > maxOf(1f, h * 0.04f)) return null @@ -269,12 +271,17 @@ object ImageOps { private fun sampleBilinear(px: IntArray, w: Int, h: Int, x: Float, y: Float): Int { val cx = x.coerceIn(0f, (w - 1).toFloat()) val cy = y.coerceIn(0f, (h - 1).toFloat()) - val x0 = cx.toInt(); val y0 = cy.toInt() - val x1 = minOf(x0 + 1, w - 1); val y1 = minOf(y0 + 1, h - 1) - val fx = cx - x0; val fy = cy - y0 - - val p00 = px[y0 * w + x0]; val p01 = px[y0 * w + x1] - val p10 = px[y1 * w + x0]; val p11 = px[y1 * w + x1] + val x0 = cx.toInt() + val y0 = cy.toInt() + val x1 = minOf(x0 + 1, w - 1) + val y1 = minOf(y0 + 1, h - 1) + val fx = cx - x0 + val fy = cy - y0 + + val p00 = px[y0 * w + x0] + val p01 = px[y0 * w + x1] + val p10 = px[y1 * w + x0] + val p11 = px[y1 * w + x1] fun ch(shift: Int): Int { val a = (p00 shr shift and 0xFF) * (1 - fx) + (p01 shr shift and 0xFF) * fx @@ -289,8 +296,10 @@ object ImageOps { val dst = floatArrayOf(0f, 0f, w, 0f, w, h, 0f, h) val a = Array(8) { DoubleArray(9) } for (i in 0 until 4) { - val dx = dst[i * 2].toDouble(); val dy = dst[i * 2 + 1].toDouble() - val sx = q.x(i).toDouble(); val sy = q.y(i).toDouble() + val dx = dst[i * 2].toDouble() + val dy = dst[i * 2 + 1].toDouble() + val sx = q.x(i).toDouble() + val sy = q.y(i).toDouble() a[i * 2] = doubleArrayOf(dx, dy, 1.0, 0.0, 0.0, 0.0, -dx * sx, -dy * sx, sx) a[i * 2 + 1] = doubleArrayOf(0.0, 0.0, 0.0, dx, dy, 1.0, -dx * sy, -dy * sy, sy) } @@ -309,7 +318,9 @@ object ImageOps { for (r in col + 1 until 8) { if (kotlin.math.abs(a[r][col]) > kotlin.math.abs(a[pivot][col])) pivot = r } - val tmp = a[col]; a[col] = a[pivot]; a[pivot] = tmp + val tmp = a[col] + a[col] = a[pivot] + a[pivot] = tmp val p = a[col][col] if (kotlin.math.abs(p) < 1e-12) continue for (c in col until 9) a[col][c] /= p diff --git a/app/src/main/java/com/skeler/scanely/core/ocr/paddle/PaddleOcrEngine.kt b/app/src/main/java/com/skeler/scanely/core/ocr/paddle/PaddleOcrEngine.kt index e2f692e..3df8ccc 100644 --- a/app/src/main/java/com/skeler/scanely/core/ocr/paddle/PaddleOcrEngine.kt +++ b/app/src/main/java/com/skeler/scanely/core/ocr/paddle/PaddleOcrEngine.kt @@ -15,6 +15,7 @@ import kotlin.math.abs import kotlin.math.exp private const val REC_HEIGHT = 48 + // Wide enough that a dense full-width line at height 48 keeps its aspect ratio; // clamping below that squashes long lines and misreads them. private const val REC_MAX_WIDTH = 3200 @@ -180,7 +181,8 @@ class PaddleOcrEngine @Inject constructor( scaled.forEachIndexed { i, s -> if (s !== chunk[i]) s.recycle() } val probs = run( - textLineCls, tensor, + textLineCls, + tensor, longArrayOf(chunk.size.toLong(), 3, ORI_HEIGHT.toLong(), ORI_WIDTH.toLong()) ) { buf, _ -> FloatArray(chunk.size * 2).also { buf.get(it) } } @@ -229,8 +231,10 @@ class PaddleOcrEngine @Inject constructor( } val out = run( - unwarper, FloatBuffer.wrap(data), - longArrayOf(1, 3, h.toLong(), w.toLong()), "image" + unwarper, + FloatBuffer.wrap(data), + longArrayOf(1, 3, h.toLong(), w.toLong()), + "image" ) { buf, shape -> val oh = shape[2].toInt() val ow = shape[3].toInt() @@ -261,7 +265,9 @@ class PaddleOcrEngine @Inject constructor( ) OnnxTensor.createTensor( - env, image, longArrayOf(1, 3, LAYOUT_SIDE.toLong(), LAYOUT_SIDE.toLong()) + env, + image, + longArrayOf(1, 3, LAYOUT_SIDE.toLong(), LAYOUT_SIDE.toLong()) ).use { imageTensor -> OnnxTensor.createTensor(env, scaleFactor, longArrayOf(1, 2)).use { scaleTensor -> parseLayout(imageTensor, scaleTensor) @@ -303,7 +309,9 @@ class PaddleOcrEngine @Inject constructor( if (resized !== crop) resized.recycle() OnnxTensor.createTensor( - env, tensor, longArrayOf(1, 3, TABLE_SIDE.toLong(), TABLE_SIDE.toLong()) + env, + tensor, + longArrayOf(1, 3, TABLE_SIDE.toLong(), TABLE_SIDE.toLong()) ).use { input -> tableRec.run(mapOf(tableRec.inputNames.first() to input)).use { result -> val boxOut = result[0] as OnnxTensor @@ -402,7 +410,10 @@ object CtcDecoder { var bestScore = logits[base] for (c in 1 until classes) { val v = logits[base + c] - if (v > bestScore) { bestScore = v; best = c } + if (v > bestScore) { + bestScore = v; + best = c + } } if (best != 0 && best != prev) { sb.append(charset.getOrElse(best) { "" }) diff --git a/app/src/main/java/com/skeler/scanely/core/ocr/paddle/PaddleOcrService.kt b/app/src/main/java/com/skeler/scanely/core/ocr/paddle/PaddleOcrService.kt index 186e4ef..2ca0b66 100644 --- a/app/src/main/java/com/skeler/scanely/core/ocr/paddle/PaddleOcrService.kt +++ b/app/src/main/java/com/skeler/scanely/core/ocr/paddle/PaddleOcrService.kt @@ -151,14 +151,17 @@ class PaddleOcrService @Inject constructor( if (!opened) return@withContext OcrResult.Error("Failed to render PDF") } - if (text.isEmpty()) OcrResult.Empty - else OcrResult.Success( - text = text.toString(), - blocks = blocks, - confidence = if (pages > 0) confidence / pages else 0f, - source = OcrSource.PADDLE, - markdown = markdown.toString().ifBlank { null } - ) + if (text.isEmpty()) { + OcrResult.Empty + } else { + OcrResult.Success( + text = text.toString(), + blocks = blocks, + confidence = if (pages > 0) confidence / pages else 0f, + source = OcrSource.PADDLE, + markdown = markdown.toString().ifBlank { null } + ) + } } /** @@ -186,14 +189,16 @@ class PaddleOcrService @Inject constructor( // Cell boxes come back in crop space; matching runs in page space, // offset by the clamped crop origin, not the raw region box. val structure = engine.recognizeTable(crop.bitmap) - structure.copy(cells = structure.cells.map { cell -> - cell.copy( - left = cell.left + crop.left, - top = cell.top + crop.top, - right = cell.right + crop.left, - bottom = cell.bottom + crop.top - ) - }) + structure.copy( + cells = structure.cells.map { cell -> + cell.copy( + left = cell.left + crop.left, + top = cell.top + crop.top, + right = cell.right + crop.left, + bottom = cell.bottom + crop.top + ) + } + ) } finally { // createBitmap returns the page itself for a full-page region. if (crop.bitmap !== page) crop.bitmap.recycle() diff --git a/app/src/main/java/com/skeler/scanely/core/ocr/paddle/RtlText.kt b/app/src/main/java/com/skeler/scanely/core/ocr/paddle/RtlText.kt index b810b21..e4ad695 100644 --- a/app/src/main/java/com/skeler/scanely/core/ocr/paddle/RtlText.kt +++ b/app/src/main/java/com/skeler/scanely/core/ocr/paddle/RtlText.kt @@ -31,8 +31,9 @@ object RtlText { var rtl = 0 var ltr = 0 for (c in s) { - if (isRtl(c)) rtl++ - else if (Character.getDirectionality(c) == Character.DIRECTIONALITY_LEFT_TO_RIGHT) ltr++ + if (isRtl(c)) { + rtl++ + } else if (Character.getDirectionality(c) == Character.DIRECTIONALITY_LEFT_TO_RIGHT) ltr++ } return rtl > ltr } @@ -66,8 +67,11 @@ object RtlText { var lo = i var hi = end while (lo < hi) { - val t = out[lo]; out[lo] = out[hi]; out[hi] = t - lo++; hi-- + val t = out[lo]; + out[lo] = out[hi]; + out[hi] = t + lo++; + hi-- } i = end + 1 } diff --git a/app/src/main/java/com/skeler/scanely/core/ocr/paddle/ScriptPack.kt b/app/src/main/java/com/skeler/scanely/core/ocr/paddle/ScriptPack.kt index a51b948..d2e8f77 100644 --- a/app/src/main/java/com/skeler/scanely/core/ocr/paddle/ScriptPack.kt +++ b/app/src/main/java/com/skeler/scanely/core/ocr/paddle/ScriptPack.kt @@ -31,8 +31,20 @@ enum class ScriptPack( bundledDict = "ocr/dict_arabic.txt" ), KOREAN("korean", "Korean", "Korean", "korean_PP-OCRv5_mobile_rec_onnx", sizeMb = 14), - CYRILLIC("cyrillic", "Cyrillic", "Russian, Ukrainian, Bulgarian, Serbian", "cyrillic_PP-OCRv5_mobile_rec_onnx", sizeMb = 8), - DEVANAGARI("devanagari", "Devanagari", "Hindi, Marathi, Nepali, Sanskrit", "devanagari_PP-OCRv5_mobile_rec_onnx", sizeMb = 9), + CYRILLIC( + "cyrillic", + "Cyrillic", + "Russian, Ukrainian, Bulgarian, Serbian", + "cyrillic_PP-OCRv5_mobile_rec_onnx", + sizeMb = 8 + ), + DEVANAGARI( + "devanagari", + "Devanagari", + "Hindi, Marathi, Nepali, Sanskrit", + "devanagari_PP-OCRv5_mobile_rec_onnx", + sizeMb = 9 + ), THAI("thai", "Thai", "Thai", "th_PP-OCRv5_mobile_rec_onnx", sizeMb = 8), GREEK("greek", "Greek", "Greek", "el_PP-OCRv5_mobile_rec_onnx", sizeMb = 8), TAMIL("tamil", "Tamil", "Tamil", "ta_PP-OCRv5_mobile_rec_onnx", sizeMb = 9), diff --git a/app/src/main/java/com/skeler/scanely/core/pdf/MarkdownPdf.kt b/app/src/main/java/com/skeler/scanely/core/pdf/MarkdownPdf.kt index 634d633..d132f8f 100644 --- a/app/src/main/java/com/skeler/scanely/core/pdf/MarkdownPdf.kt +++ b/app/src/main/java/com/skeler/scanely/core/pdf/MarkdownPdf.kt @@ -245,7 +245,7 @@ internal object MarkdownPdf { var end = line while (end < content.lineCount && content.getLineBottom(end) - top <= pager.remaining - ) end++ + ) end++ // An oversized line still has to go somewhere. if (end == line) end = line + 1 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 7663f2e..3fbe11b 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 @@ -4,7 +4,6 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.widget.Toast -import androidx.core.content.FileProvider object PdfHelper { @@ -36,6 +35,6 @@ object PdfHelper { fun isPdfUri(context: Context, uri: Uri): Boolean { val mimeType = context.contentResolver.getType(uri) return mimeType == MIME_TYPE_PDF || - uri.toString().lowercase().endsWith(".pdf") + uri.toString().lowercase().endsWith(".pdf") } } 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 df991dd..a1d6ee6 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 @@ -192,9 +192,14 @@ object ScanExporter { pages.put( JSONObject() .put("page", number) - .put("lines", JSONArray(lines.filter { it.isNotBlank() }.map { - JSONObject().put("text", it) - })) + .put( + "lines", + JSONArray( + lines.filter { it.isNotBlank() }.map { + JSONObject().put("text", it) + } + ) + ) ) } } else { @@ -249,7 +254,6 @@ object ScanExporter { .map { (number, lines) -> number to lines.dropWhile(String::isBlank).dropLastWhile(String::isBlank) } } - /** Caller owns and must close the returned document. */ private fun buildTextPdfDocument(text: String): PdfDocument { val contentWidth = PAGE_WIDTH - 2 * PAGE_MARGIN @@ -285,7 +289,7 @@ object ScanExporter { var end = line while (end < lineCount && layout.getLineBottom(end) - top <= contentHeight - ) end++ + ) end++ // Oversized single line must still advance. if (end == line) end = line + 1 @@ -527,21 +531,29 @@ object ScanExporter { append("") append("") repeat(pageCount) { index -> - append("") + append( + "" + ) } append("") } private fun wordDocument(bitmaps: List): String = buildString { append("") - append("") + append( + "" + ) bitmaps.forEachIndexed { index, bitmap -> val (cx, cy) = wordImageExtent(bitmap) val id = index + 1 - append("") + append( + "" + ) if (index != bitmaps.lastIndex) append("") } - append("") + append( + "" + ) } private fun wordTextDocument(text: String): String = buildString { diff --git a/app/src/main/java/com/skeler/scanely/core/presentation/components/svg/_DrawableVectors.kt b/app/src/main/java/com/skeler/scanely/core/presentation/components/svg/_DrawableVectors.kt index 1940597..23a54f0 100644 --- a/app/src/main/java/com/skeler/scanely/core/presentation/components/svg/_DrawableVectors.kt +++ b/app/src/main/java/com/skeler/scanely/core/presentation/components/svg/_DrawableVectors.kt @@ -1,3 +1,3 @@ package com.skeler.scanely.core.presentation.components.svg -object DynamicColorImageVectors \ No newline at end of file +object DynamicColorImageVectors diff --git a/app/src/main/java/com/skeler/scanely/core/presentation/components/svg/vectors/NoSearchResult.kt b/app/src/main/java/com/skeler/scanely/core/presentation/components/svg/vectors/NoSearchResult.kt index e07a9a6..b342c71 100644 --- a/app/src/main/java/com/skeler/scanely/core/presentation/components/svg/vectors/NoSearchResult.kt +++ b/app/src/main/java/com/skeler/scanely/core/presentation/components/svg/vectors/NoSearchResult.kt @@ -82,4 +82,4 @@ fun DynamicColorImageVectors.noSearchResult(): ImageVector { fillAlpha = 0.3f ) }.build() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/skeler/scanely/core/scan/UnifiedScanService.kt b/app/src/main/java/com/skeler/scanely/core/scan/UnifiedScanService.kt index 7baccd5..1eb0bcb 100644 --- a/app/src/main/java/com/skeler/scanely/core/scan/UnifiedScanService.kt +++ b/app/src/main/java/com/skeler/scanely/core/scan/UnifiedScanService.kt @@ -41,9 +41,9 @@ class UnifiedScanService @Inject constructor( private val settingsRepository: SettingsRepository, private val textOcrService: TextOcrService ) { - private val textRecognizer: TextRecognizer = + private val textRecognizer: TextRecognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) - + private val barcodeScanner: BarcodeScanner = BarcodeScanning.getClient( BarcodeScannerOptions.Builder() .setBarcodeFormats( @@ -77,7 +77,7 @@ class UnifiedScanService @Inject constructor( try { val result = scanBitmap(bitmap) - + if (result.isEmpty) { Log.d(TAG, "Initial scan empty, retrying with preprocessing") val preprocessed = com.skeler.scanely.core.image.ImagePreprocessor.preprocessForOcr(bitmap) @@ -91,13 +91,13 @@ class UnifiedScanService @Inject constructor( if (preprocessed != bitmap) preprocessed.recycle() } } - + result } finally { bitmap.recycle() } } - + private suspend fun scanBitmap(bitmap: Bitmap): UnifiedScanResult { val barcodes = detectBarcodes(bitmap) @@ -138,7 +138,7 @@ class UnifiedScanService @Inject constructor( } } - private suspend fun recognizeText(inputImage: InputImage): OcrResult = + private suspend fun recognizeText(inputImage: InputImage): OcrResult = suspendCancellableCoroutine { continuation -> textRecognizer.process(inputImage) .addOnSuccessListener { visionText -> @@ -202,11 +202,11 @@ class UnifiedScanService @Inject constructor( val uniqueBarcodes = barcodes .distinctBy { it.rawValue ?: "" } .filter { it.rawValue?.isNotBlank() == true } - + val actions = uniqueBarcodes.flatMap { barcode -> mapBarcodeToActions(barcode) } - + continuation.resume(actions) } .addOnFailureListener { e -> @@ -303,14 +303,14 @@ class UnifiedScanService @Inject constructor( inJustDecodeBounds = true } BitmapFactory.decodeStream(inputStream, null, options) - + options.inSampleSize = calculateInSampleSize( - options.outWidth, - options.outHeight, + options.outWidth, + options.outHeight, MAX_BITMAP_DIMENSION ) options.inJustDecodeBounds = false - + // Re-open stream (first pass consumed it). context.contentResolver.openInputStream(uri)?.use { newStream -> BitmapFactory.decodeStream(newStream, null, options) @@ -323,22 +323,23 @@ class UnifiedScanService @Inject constructor( } private fun calculateInSampleSize( - width: Int, - height: Int, + width: Int, + height: Int, maxDimension: Int ): Int { var inSampleSize = 1 - + if (width > maxDimension || height > maxDimension) { val halfWidth = width / 2 val halfHeight = height / 2 - - while ((halfWidth / inSampleSize) >= maxDimension || - (halfHeight / inSampleSize) >= maxDimension) { + + while ((halfWidth / inSampleSize) >= maxDimension || + (halfHeight / inSampleSize) >= maxDimension + ) { inSampleSize *= 2 } } - + return inSampleSize } diff --git a/app/src/main/java/com/skeler/scanely/core/security/Secrets.kt b/app/src/main/java/com/skeler/scanely/core/security/Secrets.kt index 6261632..00f83fe 100644 --- a/app/src/main/java/com/skeler/scanely/core/security/Secrets.kt +++ b/app/src/main/java/com/skeler/scanely/core/security/Secrets.kt @@ -83,8 +83,10 @@ object Secrets { md.update(seed) md.update( byteArrayOf( - (counter ushr 24).toByte(), (counter ushr 16).toByte(), - (counter ushr 8).toByte(), counter.toByte() + (counter ushr 24).toByte(), + (counter ushr 16).toByte(), + (counter ushr 8).toByte(), + counter.toByte() ) ) val block = md.digest() 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 3e03190..ef8f5e3 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 @@ -1,7 +1,6 @@ package com.skeler.scanely.history.data import android.content.Context -import android.net.Uri import android.util.Log import androidx.core.net.toUri import dagger.hilt.android.qualifiers.ApplicationContext @@ -57,13 +56,13 @@ class HistoryManager @Inject constructor( val uri = sourceUri.toUri() val fileName = "${UUID.randomUUID()}.jpg" val destFile = File(imagesDir, fileName) - + context.contentResolver.openInputStream(uri)?.use { input -> destFile.outputStream().use { output -> input.copyTo(output) } } - + destFile.toUri().toString() } catch (e: Exception) { Log.w(TAG, "Image copy failed, using original URI", e) @@ -89,7 +88,7 @@ class HistoryManager @Inject constructor( 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( @@ -107,15 +106,15 @@ class HistoryManager @Inject constructor( emptyList() } } - + fun clearHistory() { imagesDir.listFiles()?.forEach { it.delete() } - + if (historyFile.exists()) { historyFile.delete() } } - + fun deleteItem(id: String) { val currentList = getHistory().toMutableList() val item = currentList.find { it.id == id } @@ -125,7 +124,7 @@ class HistoryManager @Inject constructor( saveHistory(currentList) } } - + private fun saveHistory(items: List) { val jsonArray = JSONArray() items.forEach { item -> diff --git a/app/src/main/java/com/skeler/scanely/history/presentation/screen/HistoryScreen.kt b/app/src/main/java/com/skeler/scanely/history/presentation/screen/HistoryScreen.kt index 53a1df5..75af3d1 100644 --- a/app/src/main/java/com/skeler/scanely/history/presentation/screen/HistoryScreen.kt +++ b/app/src/main/java/com/skeler/scanely/history/presentation/screen/HistoryScreen.kt @@ -4,9 +4,9 @@ package com.skeler.scanely.history.presentation.screen import androidx.activity.ComponentActivity import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -23,24 +23,24 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FloatingActionButtonDefaults -import androidx.compose.material3.SwipeToDismissBox -import androidx.compose.material3.SwipeToDismissBoxValue -import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LargeTopAppBar -import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SwipeToDismissBox +import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -54,6 +54,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLocale import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -71,7 +72,6 @@ import com.skeler.scanely.navigation.Routes import com.skeler.scanely.ui.ScanViewModel import java.text.SimpleDateFormat import java.util.Date -import java.util.Locale @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -81,9 +81,9 @@ fun HistoryScreen() { val scanViewModel: ScanViewModel = hiltViewModel(activity) val historyViewModel: HistoryViewModel = hiltViewModel() val navController = LocalNavController.current - + val historyItems by historyViewModel.historyItems.collectAsState() - + val topBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) val listState = rememberLazyListState() @@ -95,7 +95,7 @@ fun HistoryScreen() { } } } - + var showClearConfirmDialog by remember { mutableStateOf(false) } Scaffold( @@ -191,7 +191,7 @@ fun HistoryScreen() { }, positionalThreshold = { totalDistance -> totalDistance * 0.5f } ) - + SwipeToDismissBox( state = dismissState, backgroundContent = { @@ -229,7 +229,7 @@ fun HistoryScreen() { } } } - + if (showClearConfirmDialog) { AlertDialog( onDismissRequest = { showClearConfirmDialog = false }, @@ -261,6 +261,10 @@ private fun HistoryItemCard( item: HistoryItem, onClick: () -> Unit ) { + val locale = LocalLocale.current.platformLocale + val formatter = remember(locale) { SimpleDateFormat("MMM dd, yyyy • HH:mm", locale) } + val timestamp = remember(formatter, item.timestamp) { formatter.format(Date(item.timestamp)) } + Card( onClick = onClick, modifier = modifier, @@ -283,11 +287,7 @@ private fun HistoryItemCard( ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = SimpleDateFormat("MMM dd, yyyy • HH:mm", Locale.getDefault()).format( - Date( - item.timestamp - ) - ), + text = timestamp, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -343,4 +343,4 @@ private fun LazyListState.shouldFabExpand( isScrollingDown -> false else -> false } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/skeler/scanely/navigation/LocalNavController.kt b/app/src/main/java/com/skeler/scanely/navigation/LocalNavController.kt index a4266b5..d1ea3d0 100644 --- a/app/src/main/java/com/skeler/scanely/navigation/LocalNavController.kt +++ b/app/src/main/java/com/skeler/scanely/navigation/LocalNavController.kt @@ -4,4 +4,4 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.navigation.NavHostController val LocalNavController = - staticCompositionLocalOf { error("No LocalNavController provided") } \ No newline at end of file + staticCompositionLocalOf { error("No LocalNavController provided") } diff --git a/app/src/main/java/com/skeler/scanely/navigation/Navigation.kt b/app/src/main/java/com/skeler/scanely/navigation/Navigation.kt index aa4c449..49db8f1 100644 --- a/app/src/main/java/com/skeler/scanely/navigation/Navigation.kt +++ b/app/src/main/java/com/skeler/scanely/navigation/Navigation.kt @@ -18,10 +18,10 @@ import com.skeler.scanely.settings.presentation.screen.AiProvidersScreen import com.skeler.scanely.settings.presentation.screen.LookAndFeelScreen import com.skeler.scanely.settings.presentation.screen.SettingsScreen import com.skeler.scanely.settings.presentation.screen.TextRecognitionScreen +import com.skeler.scanely.ui.screens.BarcodeScannerScreen import com.skeler.scanely.ui.screens.DocumentCaptureScreen import com.skeler.scanely.ui.screens.HomeScreen import com.skeler.scanely.ui.screens.ResultsScreen -import com.skeler.scanely.ui.screens.BarcodeScannerScreen import com.skeler.scanely.ui.screens.ScanReviewScreen import com.skeler.scanely.ui.screens.TextComposeScreen import com.skeler.scanely.ui.screens.UnifiedResultsScreen @@ -119,4 +119,4 @@ fun ScanelyNavigation( @dagger.hilt.InstallIn(dagger.hilt.components.SingletonComponent::class) interface LookupOrchestratorEntryPoint { fun lookupOrchestrator(): LookupOrchestrator -} \ No newline at end of file +} diff --git a/app/src/main/java/com/skeler/scanely/settings/data/SettingsKeys.kt b/app/src/main/java/com/skeler/scanely/settings/data/SettingsKeys.kt index 9e51d0b..508b84a 100644 --- a/app/src/main/java/com/skeler/scanely/settings/data/SettingsKeys.kt +++ b/app/src/main/java/com/skeler/scanely/settings/data/SettingsKeys.kt @@ -11,6 +11,7 @@ enum class SettingsKeys(val default: Any?) { SEED_COLOR_INDEX(SeedPalettes.DEFAULT_INDEX), // Index into SeedPalettes.ALL LAST_AI_REQUEST_TIMESTAMP(0L), // epoch millis AI_REQUEST_COUNT(0), + // Opt-in cross-provider fallback; default = selected only. AI_PROVIDER_FALLBACK(false), SELECTED_AI_PROVIDER(""), // AiProvider enum name @@ -46,4 +47,4 @@ enum class SettingsKeys(val default: Any?) { CUSTOM_API_KEY(""), CUSTOM_BASE_URL(""), // full chat/completions URL, not a base CUSTOM_MODEL("") -} \ No newline at end of file +} diff --git a/app/src/main/java/com/skeler/scanely/settings/data/datastore/SettingsDataStore.kt b/app/src/main/java/com/skeler/scanely/settings/data/datastore/SettingsDataStore.kt index c0396c2..55e716d 100644 --- a/app/src/main/java/com/skeler/scanely/settings/data/datastore/SettingsDataStore.kt +++ b/app/src/main/java/com/skeler/scanely/settings/data/datastore/SettingsDataStore.kt @@ -167,4 +167,4 @@ class SettingsDataStore @Inject constructor( prefs[preferencesKey] = value } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/skeler/scanely/settings/data/repository/SettingsRepositoryImpl.kt b/app/src/main/java/com/skeler/scanely/settings/data/repository/SettingsRepositoryImpl.kt index ed2ccee..94cd931 100644 --- a/app/src/main/java/com/skeler/scanely/settings/data/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/java/com/skeler/scanely/settings/data/repository/SettingsRepositoryImpl.kt @@ -28,4 +28,4 @@ class SettingsRepositoryImpl( override fun getStringSet(key: SettingsKeys): Flow> = dataStore.stringSetFlow(key) override suspend fun setStringSet(key: SettingsKeys, value: Set) = dataStore.setStringSet(key, value) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/skeler/scanely/settings/domain/model/SettingsState.kt b/app/src/main/java/com/skeler/scanely/settings/domain/model/SettingsState.kt index 15c1037..dc40160 100644 --- a/app/src/main/java/com/skeler/scanely/settings/domain/model/SettingsState.kt +++ b/app/src/main/java/com/skeler/scanely/settings/domain/model/SettingsState.kt @@ -6,4 +6,4 @@ data class SettingsState( val ocrLanguages: Set, val useDynamicColors: Boolean, val seedColorIndex: Int -) \ No newline at end of file +) diff --git a/app/src/main/java/com/skeler/scanely/settings/domain/repository/SettingsRepository.kt b/app/src/main/java/com/skeler/scanely/settings/domain/repository/SettingsRepository.kt index 5e66e34..7c6f62c 100644 --- a/app/src/main/java/com/skeler/scanely/settings/domain/repository/SettingsRepository.kt +++ b/app/src/main/java/com/skeler/scanely/settings/domain/repository/SettingsRepository.kt @@ -20,4 +20,4 @@ interface SettingsRepository { fun getStringSet(key: SettingsKeys): Flow> suspend fun setStringSet(key: SettingsKeys, value: Set) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/skeler/scanely/settings/presentation/screen/AiProvidersScreen.kt b/app/src/main/java/com/skeler/scanely/settings/presentation/screen/AiProvidersScreen.kt index b9767e2..ced06c5 100644 --- a/app/src/main/java/com/skeler/scanely/settings/presentation/screen/AiProvidersScreen.kt +++ b/app/src/main/java/com/skeler/scanely/settings/presentation/screen/AiProvidersScreen.kt @@ -35,8 +35,8 @@ import androidx.compose.material.icons.automirrored.rounded.OpenInNew import androidx.compose.material.icons.rounded.AltRoute import androidx.compose.material.icons.rounded.Category import androidx.compose.material.icons.rounded.CheckCircle -import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.ErrorOutline +import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Key import androidx.compose.material.icons.rounded.Link import androidx.compose.material.icons.rounded.Refresh diff --git a/app/src/main/java/com/skeler/scanely/settings/presentation/screen/LookAndFeelScreen.kt b/app/src/main/java/com/skeler/scanely/settings/presentation/screen/LookAndFeelScreen.kt index 912d18e..3eaca8d 100644 --- a/app/src/main/java/com/skeler/scanely/settings/presentation/screen/LookAndFeelScreen.kt +++ b/app/src/main/java/com/skeler/scanely/settings/presentation/screen/LookAndFeelScreen.kt @@ -29,10 +29,10 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.TopAppBar import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -96,13 +96,11 @@ fun LookAndFeelScreen( .padding(innerPadding), contentPadding = PaddingValues(bottom = 48.dp) ) { - item { Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { - PaletteShowcase( modifier = Modifier.padding(top = 8.dp, start = 24.dp, end = 24.dp) ) @@ -140,7 +138,6 @@ fun LookAndFeelScreen( } } - item { SettingsSectionHeader( text = "Color palette", @@ -153,7 +150,6 @@ fun LookAndFeelScreen( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(horizontal = 24.dp, vertical = 4.dp) ) { - item(key = "dynamic") { PaletteChip( label = "Dynamic", @@ -182,7 +178,6 @@ fun LookAndFeelScreen( item { Spacer(modifier = Modifier.height(36.dp)) } - item { SettingsSectionHeader( text = "Personalize", diff --git a/app/src/main/java/com/skeler/scanely/settings/presentation/screen/SettingsScreen.kt b/app/src/main/java/com/skeler/scanely/settings/presentation/screen/SettingsScreen.kt index d0c7102..8411d2a 100644 --- a/app/src/main/java/com/skeler/scanely/settings/presentation/screen/SettingsScreen.kt +++ b/app/src/main/java/com/skeler/scanely/settings/presentation/screen/SettingsScreen.kt @@ -30,12 +30,12 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.RadioButton -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState diff --git a/app/src/main/java/com/skeler/scanely/settings/presentation/screen/TextRecognitionScreen.kt b/app/src/main/java/com/skeler/scanely/settings/presentation/screen/TextRecognitionScreen.kt index ca50cb8..169aeac 100644 --- a/app/src/main/java/com/skeler/scanely/settings/presentation/screen/TextRecognitionScreen.kt +++ b/app/src/main/java/com/skeler/scanely/settings/presentation/screen/TextRecognitionScreen.kt @@ -20,13 +20,13 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.Download -import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Article import androidx.compose.material.icons.rounded.Crop -import androidx.compose.material.icons.rounded.TableChart +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.Rotate90DegreesCcw import androidx.compose.material.icons.rounded.ScreenRotation +import androidx.compose.material.icons.rounded.TableChart import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon diff --git a/app/src/main/java/com/skeler/scanely/ui/components/AiModeBottomSheet.kt b/app/src/main/java/com/skeler/scanely/ui/components/AiModeBottomSheet.kt index 036fd8b..557877b 100644 --- a/app/src/main/java/com/skeler/scanely/ui/components/AiModeBottomSheet.kt +++ b/app/src/main/java/com/skeler/scanely/ui/components/AiModeBottomSheet.kt @@ -1,5 +1,6 @@ package com.skeler.scanely.ui.components +import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -8,7 +9,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items -import androidx.annotation.DrawableRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.Card @@ -90,7 +90,9 @@ fun AiModeBottomSheet( modifier = Modifier.size(FilterChipDefaults.IconSize) ) } - } else null + } else { + null + } ) } } diff --git a/app/src/main/java/com/skeler/scanely/ui/components/BarcodeScannerComponents.kt b/app/src/main/java/com/skeler/scanely/ui/components/BarcodeScannerComponents.kt index 0cd6135..87de4d4 100644 --- a/app/src/main/java/com/skeler/scanely/ui/components/BarcodeScannerComponents.kt +++ b/app/src/main/java/com/skeler/scanely/ui/components/BarcodeScannerComponents.kt @@ -154,12 +154,42 @@ fun ScanningOverlay(modifier: Modifier = Modifier) { drawLine(accentColor, Offset(left, top + 24.dp.toPx()), Offset(left, top + cornerLength), cornerStroke) drawLine(accentColor, Offset(left + 24.dp.toPx(), top), Offset(left + cornerLength, top), cornerStroke) - drawLine(accentColor, Offset(left + scanBoxSize, top + 24.dp.toPx()), Offset(left + scanBoxSize, top + cornerLength), cornerStroke) - drawLine(accentColor, Offset(left + scanBoxSize - 24.dp.toPx(), top), Offset(left + scanBoxSize - cornerLength, top), cornerStroke) - drawLine(accentColor, Offset(left, top + scanBoxSize - 24.dp.toPx()), Offset(left, top + scanBoxSize - cornerLength), cornerStroke) - drawLine(accentColor, Offset(left + 24.dp.toPx(), top + scanBoxSize), Offset(left + cornerLength, top + scanBoxSize), cornerStroke) - drawLine(accentColor, Offset(left + scanBoxSize, top + scanBoxSize - 24.dp.toPx()), Offset(left + scanBoxSize, top + scanBoxSize - cornerLength), cornerStroke) - drawLine(accentColor, Offset(left + scanBoxSize - 24.dp.toPx(), top + scanBoxSize), Offset(left + scanBoxSize - cornerLength, top + scanBoxSize), cornerStroke) + drawLine( + accentColor, + Offset(left + scanBoxSize, top + 24.dp.toPx()), + Offset(left + scanBoxSize, top + cornerLength), + cornerStroke + ) + drawLine( + accentColor, + Offset(left + scanBoxSize - 24.dp.toPx(), top), + Offset(left + scanBoxSize - cornerLength, top), + cornerStroke + ) + drawLine( + accentColor, + Offset(left, top + scanBoxSize - 24.dp.toPx()), + Offset(left, top + scanBoxSize - cornerLength), + cornerStroke + ) + drawLine( + accentColor, + Offset(left + 24.dp.toPx(), top + scanBoxSize), + Offset(left + cornerLength, top + scanBoxSize), + cornerStroke + ) + drawLine( + accentColor, + Offset(left + scanBoxSize, top + scanBoxSize - 24.dp.toPx()), + Offset(left + scanBoxSize, top + scanBoxSize - cornerLength), + cornerStroke + ) + drawLine( + accentColor, + Offset(left + scanBoxSize - 24.dp.toPx(), top + scanBoxSize), + Offset(left + scanBoxSize - cornerLength, top + scanBoxSize), + cornerStroke + ) } } diff --git a/app/src/main/java/com/skeler/scanely/ui/components/GalleryPicker.kt b/app/src/main/java/com/skeler/scanely/ui/components/GalleryPicker.kt index cf24169..4f88bd7 100644 --- a/app/src/main/java/com/skeler/scanely/ui/components/GalleryPicker.kt +++ b/app/src/main/java/com/skeler/scanely/ui/components/GalleryPicker.kt @@ -5,10 +5,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue /** Max images/files accepted per AI scan (matches the per-file 20 MB cap in PayloadFactory). */ @@ -24,7 +21,7 @@ fun rememberGalleryPicker( ) { uri -> onImageSelected(uri) } - + return { launcher.launch( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) @@ -43,7 +40,7 @@ fun rememberMultiGalleryPicker( ) { uris -> onImagesSelected(uris) } - + return { launcher.launch( PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) @@ -62,7 +59,6 @@ fun rememberMultiDocumentPicker( ) { uris -> onDocumentsSelected(uris) } - + return { launcher.launch(mimeTypes) } } - diff --git a/app/src/main/java/com/skeler/scanely/ui/components/LanguageChipRow.kt b/app/src/main/java/com/skeler/scanely/ui/components/LanguageChipRow.kt index e914d89..f1cc702 100644 --- a/app/src/main/java/com/skeler/scanely/ui/components/LanguageChipRow.kt +++ b/app/src/main/java/com/skeler/scanely/ui/components/LanguageChipRow.kt @@ -1,10 +1,10 @@ package com.skeler.scanely.ui.components +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons @@ -63,14 +63,14 @@ fun LanguageChipRow( label = { Text(language) } ) } - + Box { FilterChip( selected = false, onClick = onShowLanguageMenu, label = { Text("Translate") } ) - + DropdownMenu( expanded = showLanguageMenu, onDismissRequest = onDismissLanguageMenu diff --git a/app/src/main/java/com/skeler/scanely/ui/components/MarkdownContent.kt b/app/src/main/java/com/skeler/scanely/ui/components/MarkdownContent.kt index fd27185..94e3957 100644 --- a/app/src/main/java/com/skeler/scanely/ui/components/MarkdownContent.kt +++ b/app/src/main/java/com/skeler/scanely/ui/components/MarkdownContent.kt @@ -32,16 +32,16 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.skeler.scanely.core.text.MdBlock import com.skeler.scanely.core.text.MarkdownParser +import com.skeler.scanely.core.text.MdBlock /** Renders the AI's Markdown the way it read the page: headings, emphasis, lists, table grids. */ @Composable diff --git a/app/src/main/java/com/skeler/scanely/ui/components/MarkdownPrint.kt b/app/src/main/java/com/skeler/scanely/ui/components/MarkdownPrint.kt index 91ff151..c0fd073 100644 --- a/app/src/main/java/com/skeler/scanely/ui/components/MarkdownPrint.kt +++ b/app/src/main/java/com/skeler/scanely/ui/components/MarkdownPrint.kt @@ -30,7 +30,10 @@ fun rememberMarkdownPrinter(): (text: String, asMarkdown: Boolean) -> Unit { val context = LocalContext.current val scope = rememberCoroutineScope() return remember(context, scope) { - { text, asMarkdown -> scope.launch { print(context, text, asMarkdown) } } + { + text, asMarkdown -> + scope.launch { print(context, text, asMarkdown) } + } } } diff --git a/app/src/main/java/com/skeler/scanely/ui/components/ProductDetailSheet.kt b/app/src/main/java/com/skeler/scanely/ui/components/ProductDetailSheet.kt index f38d0cd..417dc1d 100644 --- a/app/src/main/java/com/skeler/scanely/ui/components/ProductDetailSheet.kt +++ b/app/src/main/java/com/skeler/scanely/ui/components/ProductDetailSheet.kt @@ -23,9 +23,9 @@ import androidx.compose.material.icons.rounded.Warning import androidx.compose.material3.AssistChip import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text @@ -56,7 +56,7 @@ fun ProductDetailSheet( onDismiss: () -> Unit ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - + ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState @@ -154,7 +154,7 @@ private fun ProductContent(product: ProductInfo) { Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider() Spacer(modifier = Modifier.height(16.dp)) - + when (product.category) { ProductCategory.FOOD, ProductCategory.PET_FOOD -> { product.foodData?.let { FoodContentSection(it) } @@ -169,7 +169,7 @@ private fun ProductContent(product: ProductInfo) { product.cosmeticsData?.let { CosmeticsContentSection(it) } } ProductCategory.GENERIC -> { - product.description?.let { + product.description?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } } @@ -193,7 +193,7 @@ private fun ProductHeader(product: ProductInfo) { ) Spacer(modifier = Modifier.width(16.dp)) } - + Column(modifier = Modifier.weight(1f)) { Text( text = product.name ?: "Unknown Product", @@ -202,7 +202,7 @@ private fun ProductHeader(product: ProductInfo) { maxLines = 2, overflow = TextOverflow.Ellipsis ) - + product.brand?.let { brand -> Text( text = brand, @@ -210,9 +210,9 @@ private fun ProductHeader(product: ProductInfo) { color = MaterialTheme.colorScheme.primary ) } - + Spacer(modifier = Modifier.height(4.dp)) - + Text( text = product.barcode, style = MaterialTheme.typography.bodySmall, @@ -232,7 +232,7 @@ private fun SourceBadge(source: String, category: ProductCategory) { ProductCategory.PET_FOOD -> Icons.Rounded.Pets ProductCategory.GENERIC -> Icons.Rounded.Restaurant } - + AssistChip( onClick = { }, label = { Text("via $source") }, diff --git a/app/src/main/java/com/skeler/scanely/ui/components/RateLimitSheet.kt b/app/src/main/java/com/skeler/scanely/ui/components/RateLimitSheet.kt index 6939efc..d3c1460 100644 --- a/app/src/main/java/com/skeler/scanely/ui/components/RateLimitSheet.kt +++ b/app/src/main/java/com/skeler/scanely/ui/components/RateLimitSheet.kt @@ -46,11 +46,11 @@ fun RateLimitSheet( animationSpec = tween(durationMillis = 1000), label = "progress" ) - + ModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - modifier = modifier.semantics { + modifier = modifier.semantics { contentDescription = "Rate limit explanation. $remainingSeconds seconds remaining." } ) { @@ -103,7 +103,7 @@ fun RateLimitSheet( trackColor = MaterialTheme.colorScheme.surfaceContainerHighest, color = MaterialTheme.colorScheme.primary ) - + Spacer(modifier = Modifier.height(8.dp)) Text( @@ -112,7 +112,7 @@ fun RateLimitSheet( fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.primary ) - + Spacer(modifier = Modifier.height(24.dp)) if (onWatchAd != null) { diff --git a/app/src/main/java/com/skeler/scanely/ui/components/ResultsComponents.kt b/app/src/main/java/com/skeler/scanely/ui/components/ResultsComponents.kt index 9e2da15..6cc44e8 100644 --- a/app/src/main/java/com/skeler/scanely/ui/components/ResultsComponents.kt +++ b/app/src/main/java/com/skeler/scanely/ui/components/ResultsComponents.kt @@ -190,7 +190,9 @@ fun ExportMenuItem( DropdownMenuItem( text = { Text(format.label) }, enabled = enabled, - trailingIcon = if (enabled) null else { + trailingIcon = if (enabled) { + null + } else { { Text( text = "No table", diff --git a/app/src/main/java/com/skeler/scanely/ui/components/product/FoodContentSection.kt b/app/src/main/java/com/skeler/scanely/ui/components/product/FoodContentSection.kt index 601edcd..34b3be5 100644 --- a/app/src/main/java/com/skeler/scanely/ui/components/product/FoodContentSection.kt +++ b/app/src/main/java/com/skeler/scanely/ui/components/product/FoodContentSection.kt @@ -43,8 +43,13 @@ fun FoodContentSection(data: FoodData) { } val hasNutrition = listOfNotNull( - data.calories, data.fat, data.carbs, - data.protein, data.sugar, data.salt, data.fiber + data.calories, + data.fat, + data.carbs, + data.protein, + data.sugar, + data.salt, + data.fiber ).isNotEmpty() if (hasNutrition) { diff --git a/app/src/main/java/com/skeler/scanely/ui/screens/BarcodeScannerScreen.kt b/app/src/main/java/com/skeler/scanely/ui/screens/BarcodeScannerScreen.kt index db9bca4..5d2ad13 100644 --- a/app/src/main/java/com/skeler/scanely/ui/screens/BarcodeScannerScreen.kt +++ b/app/src/main/java/com/skeler/scanely/ui/screens/BarcodeScannerScreen.kt @@ -59,10 +59,10 @@ import com.skeler.scanely.core.actions.ScanAction import com.skeler.scanely.core.barcode.BarcodeAnalyzer import com.skeler.scanely.core.barcode.BarcodeEngine import com.skeler.scanely.core.lookup.LookupOrchestrator -import com.skeler.scanely.settings.data.SettingsKeys -import com.skeler.scanely.settings.presentation.viewmodel.SettingsViewModel import com.skeler.scanely.core.lookup.LookupResult import com.skeler.scanely.navigation.LocalNavController +import com.skeler.scanely.settings.data.SettingsKeys +import com.skeler.scanely.settings.presentation.viewmodel.SettingsViewModel import com.skeler.scanely.ui.components.BarcodeActionsSheet import com.skeler.scanely.ui.components.BarcodeCameraPreview import com.skeler.scanely.ui.components.ProductDetailSheet @@ -118,7 +118,7 @@ fun BarcodeScannerScreen( showProductSheet = true isLookupLoading = true lookupResult = null - + scope.launch { lookupResult = lookupOrchestrator.lookup(barcode) isLookupLoading = false @@ -182,7 +182,7 @@ fun BarcodeScannerScreen( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) ) } - + Card( onClick = { galleryPicker() }, colors = CardDefaults.cardColors(containerColor = Color.Transparent), diff --git a/app/src/main/java/com/skeler/scanely/ui/screens/CameraScreen.kt b/app/src/main/java/com/skeler/scanely/ui/screens/CameraScreen.kt index b18f646..055b56f 100644 --- a/app/src/main/java/com/skeler/scanely/ui/screens/CameraScreen.kt +++ b/app/src/main/java/com/skeler/scanely/ui/screens/CameraScreen.kt @@ -21,7 +21,6 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row diff --git a/app/src/main/java/com/skeler/scanely/ui/screens/HomeScreen.kt b/app/src/main/java/com/skeler/scanely/ui/screens/HomeScreen.kt index 1e5c594..9791f7e 100644 --- a/app/src/main/java/com/skeler/scanely/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/skeler/scanely/ui/screens/HomeScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Settings - import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -48,12 +47,12 @@ import com.skeler.scanely.ui.ScanViewModel import com.skeler.scanely.ui.components.AiModeBottomSheet import com.skeler.scanely.ui.components.GamifiedAiFab import com.skeler.scanely.ui.components.HistoryPillButton +import com.skeler.scanely.ui.components.MAX_AI_FILES import com.skeler.scanely.ui.components.MainActionButton import com.skeler.scanely.ui.components.RateLimitDisplayState import com.skeler.scanely.ui.components.RateLimitSheet import com.skeler.scanely.ui.components.rememberGalleryPicker import com.skeler.scanely.ui.components.rememberMultiDocumentPicker -import com.skeler.scanely.ui.components.MAX_AI_FILES import com.skeler.scanely.ui.components.rememberMultiGalleryPicker import com.skeler.scanely.ui.viewmodel.AiScanViewModel import com.skeler.scanely.ui.viewmodel.OcrViewModel @@ -90,9 +89,10 @@ fun HomeScreen() { val provider = pendingAiProvider pendingAiMode = null if (scanViewModel.triggerAiWithRateLimit(provider) { - scanViewModel.onNewScanSelected() - aiViewModel.processMultipleFiles(uris, mode, provider) - }) { + scanViewModel.onNewScanSelected() + aiViewModel.processMultipleFiles(uris, mode, provider) + } + ) { navController.navigate(Routes.RESULTS) } } @@ -117,14 +117,16 @@ fun HomeScreen() { uris.forEach { uri -> try { context.contentResolver.takePersistableUriPermission( - uri, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION + uri, + android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION ) } catch (_: Exception) { } } if (scanViewModel.triggerAiWithRateLimit(provider) { - scanViewModel.onNewScanSelected() - aiViewModel.processMultipleFiles(uris, mode, provider) - }) { + scanViewModel.onNewScanSelected() + aiViewModel.processMultipleFiles(uris, mode, provider) + } + ) { navController.navigate(Routes.RESULTS) } } @@ -146,7 +148,8 @@ fun HomeScreen() { if (uri != null) { try { context.contentResolver.takePersistableUriPermission( - uri, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION + uri, + android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION ) } catch (_: Exception) { } aiViewModel.clearResult() @@ -298,16 +301,16 @@ private fun rememberActionAccents(): ActionAccents { return if (LocalDarkMode.current) { ActionAccents( document = Color(0xFFB6A9DC), // muted lavender - gallery = Color(0xFFD8B878), // muted ochre - pdf = Color(0xFFE0A79B), // muted clay - qr = Color(0xFF9CC7AE) // muted sage + gallery = Color(0xFFD8B878), // muted ochre + pdf = Color(0xFFE0A79B), // muted clay + qr = Color(0xFF9CC7AE) // muted sage ) } else { ActionAccents( - document = Color(0xFF5E4E80), // muted lavender - gallery = Color(0xFF9A7B3F), // muted ochre - pdf = Color(0xFFA05A4E), // muted clay - qr = Color(0xFF4F7A63) // muted sage + document = Color(0xFF5E4E80), // muted lavender + gallery = Color(0xFF9A7B3F), // muted ochre + pdf = Color(0xFFA05A4E), // muted clay + qr = Color(0xFF4F7A63) // muted sage ) } } 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 52375d7..fab6655 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 @@ -21,8 +21,8 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack @@ -59,22 +59,22 @@ import com.skeler.scanely.navigation.LocalNavController import com.skeler.scanely.navigation.Routes import com.skeler.scanely.ui.ScanViewModel import com.skeler.scanely.ui.components.CredentialBadge +import com.skeler.scanely.ui.components.EmptyResultContent import com.skeler.scanely.ui.components.ExportContent -import com.skeler.scanely.ui.components.PADDLE_EXPORT_FORMATS -import com.skeler.scanely.ui.components.TEXT_EXPORT_FORMATS -import com.skeler.scanely.ui.components.unavailableFormats import com.skeler.scanely.ui.components.ExtractedTextActions -import com.skeler.scanely.ui.components.rememberExtractedTextState -import com.skeler.scanely.ui.components.EmptyResultContent import com.skeler.scanely.ui.components.ExtractedTextSection import com.skeler.scanely.ui.components.LanguageChipRow +import com.skeler.scanely.ui.components.PADDLE_EXPORT_FORMATS import com.skeler.scanely.ui.components.ProcessingContent import com.skeler.scanely.ui.components.RateLimitSheet import com.skeler.scanely.ui.components.ScanResultSkeleton +import com.skeler.scanely.ui.components.TEXT_EXPORT_FORMATS import com.skeler.scanely.ui.components.TranslatingContent import com.skeler.scanely.ui.components.TranslationLanguages +import com.skeler.scanely.ui.components.rememberExtractedTextState import com.skeler.scanely.ui.components.rememberMarkdownPrinter import com.skeler.scanely.ui.components.rememberTextExporter +import com.skeler.scanely.ui.components.unavailableFormats import com.skeler.scanely.ui.viewmodel.AiScanViewModel import com.skeler.scanely.ui.viewmodel.OcrViewModel import kotlinx.coroutines.delay @@ -203,172 +203,174 @@ fun ResultsScreen() { .statusBarsPadding() .padding(horizontal = 20.dp) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .verticalScroll(rememberScrollState()) - ) { - Box( + Column( modifier = Modifier .fillMaxWidth() - .padding(vertical = 14.dp), - contentAlignment = Alignment.Center + .weight(1f) + .verticalScroll(rememberScrollState()) ) { - Text( - text = "Results", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Medium - ) - } - when { - aiState.isProcessing -> { - ProcessingContent( - currentFile = aiState.currentFileIndex, - totalFiles = aiState.totalFiles, - stage = aiState.stage, - stageMessage = aiState.stageMessage, - streamingText = aiState.streamingText, - onCancel = { - aiViewModel.cancelProcessing() - onBack() - } - ) - } - isProcessing -> { - ScanResultSkeleton( - modifier = Modifier.padding(top = 8.dp), - showChips = false + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 14.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Results", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Medium ) } - isTranslating -> { - TranslatingContent() - } - displayText != null -> { - val runInfo = aiState.runInfo - val credential: (@Composable () -> Unit)? = - if (isAiResult && historyText == null && runInfo != null && - aiState.result is AiResult.Success - ) { - { - CredentialBadge( - providerName = runInfo.provider.displayName, - model = runInfo.model, - usesBundledKey = runInfo.usesBundledKey - ) + when { + aiState.isProcessing -> { + ProcessingContent( + currentFile = aiState.currentFileIndex, + totalFiles = aiState.totalFiles, + stage = aiState.stage, + stageMessage = aiState.stageMessage, + streamingText = aiState.streamingText, + onCancel = { + aiViewModel.cancelProcessing() + onBack() } - } else null - - if (isAiResult && isOnline) { - LanguageChipRow( - cachedLanguages = cachedLanguages, - currentLanguage = currentLanguage, - showLanguageMenu = showLanguageMenu, - onShowLanguageMenu = { showLanguageMenu = true }, - onDismissLanguageMenu = { showLanguageMenu = false }, - onSelectOriginal = { - markdownView = false - aiViewModel.showOriginal() - }, - onSelectCached = { - markdownView = false - aiViewModel.selectCachedLanguage(it) - }, - allLanguages = languages, - onNewLanguageSelected = { language -> - showLanguageMenu = false - scanViewModel.triggerAiWithRateLimit(aiState.provider) { - markdownView = false - aiViewModel.translateResult(language) - } - }, - onComposeText = { navController.navigate(Routes.TEXT_COMPOSE) }, - showMarkdown = hasMarkdown, - markdownSelected = markdownMode, - onSelectMarkdown = { markdownView = true } ) - Spacer(modifier = Modifier.height(10.dp)) - } else if (hasMarkdown) { - // Offline scans have no language row; still offer the structured view. - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - FilterChip( - selected = !markdownMode, - onClick = { markdownView = false }, - label = { Text("Original") } - ) - FilterChip( - selected = markdownMode, - onClick = { markdownView = true }, - label = { Text("Markdown") } - ) - } - Spacer(modifier = Modifier.height(10.dp)) } - - // Copy, export, print, edit and the body all follow the visible view. - val visibleText = (if (markdownMode) markdownSource else shownText) - ?: displayText - val preview = remember(visibleText, markdownMode, paddleResult) { - ExportContent( - text = visibleText, - isMarkdown = markdownMode, - blocks = paddleResult?.blocks.orEmpty() + isProcessing -> { + ScanResultSkeleton( + modifier = Modifier.padding(top = 8.dp), + showChips = false ) } - val disabledFormats = remember(preview) { unavailableFormats(preview) } + isTranslating -> { + TranslatingContent() + } + displayText != null -> { + val runInfo = aiState.runInfo + val credential: (@Composable () -> Unit)? = + if (isAiResult && historyText == null && runInfo != null && + aiState.result is AiResult.Success + ) { + { + CredentialBadge( + providerName = runInfo.provider.displayName, + model = runInfo.model, + usesBundledKey = runInfo.usesBundledKey + ) + } + } else { + null + } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.Notes, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(22.dp) - ) - Spacer(modifier = Modifier.size(10.dp)) - Text( - text = "Extracted Text", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold + if (isAiResult && isOnline) { + LanguageChipRow( + cachedLanguages = cachedLanguages, + currentLanguage = currentLanguage, + showLanguageMenu = showLanguageMenu, + onShowLanguageMenu = { showLanguageMenu = true }, + onDismissLanguageMenu = { showLanguageMenu = false }, + onSelectOriginal = { + markdownView = false + aiViewModel.showOriginal() + }, + onSelectCached = { + markdownView = false + aiViewModel.selectCachedLanguage(it) + }, + allLanguages = languages, + onNewLanguageSelected = { language -> + showLanguageMenu = false + scanViewModel.triggerAiWithRateLimit(aiState.provider) { + markdownView = false + aiViewModel.translateResult(language) + } + }, + onComposeText = { navController.navigate(Routes.TEXT_COMPOSE) }, + showMarkdown = hasMarkdown, + markdownSelected = markdownMode, + onSelectMarkdown = { markdownView = true } ) + Spacer(modifier = Modifier.height(10.dp)) + } else if (hasMarkdown) { + // Offline scans have no language row; still offer the structured view. + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = !markdownMode, + onClick = { markdownView = false }, + label = { Text("Original") } + ) + FilterChip( + selected = markdownMode, + onClick = { markdownView = true }, + label = { Text("Markdown") } + ) + } + Spacer(modifier = Modifier.height(10.dp)) } - Row(verticalAlignment = Alignment.CenterVertically) { - ExtractedTextActions( - state = textState, + + // Copy, export, print, edit and the body all follow the visible view. + val visibleText = (if (markdownMode) markdownSource else shownText) + ?: displayText + val preview = remember(visibleText, markdownMode, paddleResult) { + ExportContent( text = visibleText, - onCopy = { copyToClipboard(context, visibleText) }, - onExport = { format -> exportText(preview, format) }, - exportFormats = exportFormats, - disabledFormats = disabledFormats, - onSaveEdit = onSaveExtracted, - onPrint = { printText(visibleText, markdownMode) }, - compact = true + isMarkdown = markdownMode, + blocks = paddleResult?.blocks.orEmpty() ) } - } + val disabledFormats = remember(preview) { unavailableFormats(preview) } - Spacer(modifier = Modifier.height(10.dp)) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - Spacer(modifier = Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.Notes, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(22.dp) + ) + Spacer(modifier = Modifier.size(10.dp)) + Text( + text = "Extracted Text", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + ExtractedTextActions( + state = textState, + text = visibleText, + onCopy = { copyToClipboard(context, visibleText) }, + onExport = { format -> exportText(preview, format) }, + exportFormats = exportFormats, + disabledFormats = disabledFormats, + onSaveEdit = onSaveExtracted, + onPrint = { printText(visibleText, markdownMode) }, + compact = true + ) + } + } - ExtractedTextSection( - state = textState, - text = visibleText, - credential = credential, - markdown = markdownMode - ) - } - else -> { - EmptyResultContent() + Spacer(modifier = Modifier.height(10.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(16.dp)) + + ExtractedTextSection( + state = textState, + text = visibleText, + credential = credential, + markdown = markdownMode + ) + } + else -> { + EmptyResultContent() + } } - } - Spacer(modifier = Modifier.height(100.dp)) - } + Spacer(modifier = Modifier.height(100.dp)) + } } if (showRescan) { diff --git a/app/src/main/java/com/skeler/scanely/ui/screens/ScanReviewScreen.kt b/app/src/main/java/com/skeler/scanely/ui/screens/ScanReviewScreen.kt index 0f108f9..38f2f22 100644 --- a/app/src/main/java/com/skeler/scanely/ui/screens/ScanReviewScreen.kt +++ b/app/src/main/java/com/skeler/scanely/ui/screens/ScanReviewScreen.kt @@ -203,9 +203,18 @@ fun ScanReviewScreen() { if (showExportSheet) { ExportSheet( pageCount = pages.size, - onPdf = { showExportSheet = false; vm.exportPdf() }, - onWord = { showExportSheet = false; vm.exportWord() }, - onImages = { showExportSheet = false; vm.saveImages() }, + onPdf = { + showExportSheet = false; + vm.exportPdf() + }, + onWord = { + showExportSheet = false; + vm.exportWord() + }, + onImages = { + showExportSheet = false; + vm.saveImages() + }, onDismiss = { showExportSheet = false } ) } diff --git a/app/src/main/java/com/skeler/scanely/ui/screens/UnifiedResultsScreen.kt b/app/src/main/java/com/skeler/scanely/ui/screens/UnifiedResultsScreen.kt index fd3fc1a..75455a3 100644 --- a/app/src/main/java/com/skeler/scanely/ui/screens/UnifiedResultsScreen.kt +++ b/app/src/main/java/com/skeler/scanely/ui/screens/UnifiedResultsScreen.kt @@ -56,8 +56,8 @@ import com.skeler.scanely.navigation.LocalNavController import com.skeler.scanely.ui.components.ExportContent import com.skeler.scanely.ui.components.ExtractedTextActions import com.skeler.scanely.ui.components.ExtractedTextSection -import com.skeler.scanely.ui.components.ScanResultSkeleton import com.skeler.scanely.ui.components.PADDLE_EXPORT_FORMATS +import com.skeler.scanely.ui.components.ScanResultSkeleton import com.skeler.scanely.ui.components.TEXT_EXPORT_FORMATS import com.skeler.scanely.ui.components.rememberExtractedTextState import com.skeler.scanely.ui.components.rememberTextExporter @@ -225,7 +225,9 @@ fun UnifiedResultsScreen() { compact = true ) } - } else null + } else { + null + } ) } else if (hasActions) { ActionsButton( diff --git a/app/src/main/java/com/skeler/scanely/ui/theme/ThemePalettes.kt b/app/src/main/java/com/skeler/scanely/ui/theme/ThemePalettes.kt index 3dbf124..1a3c0d4 100644 --- a/app/src/main/java/com/skeler/scanely/ui/theme/ThemePalettes.kt +++ b/app/src/main/java/com/skeler/scanely/ui/theme/ThemePalettes.kt @@ -49,7 +49,12 @@ object SeedPalettes { ) val ALL: List = listOf( - RoseBlush, CeruleanWave, LavenderVolt, AmethystHaze, EmeraldGrove, SunsetCoral + RoseBlush, + CeruleanWave, + LavenderVolt, + AmethystHaze, + EmeraldGrove, + SunsetCoral ) val DEFAULT: SeedColor = LavenderVolt 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 d22b730..02c5fc6 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 @@ -38,7 +38,7 @@ data class AiScanState( val runInfo: AiRunInfo? = null, val originalText: String? = null, val translationCache: Map = emptyMap(), - val currentLanguage: String? = null, + val currentLanguage: String? = null, val isTranslating: Boolean = false, val lastImageUri: Uri? = null, val totalFiles: Int = 0, @@ -236,7 +236,9 @@ class AiScanViewModel @Inject constructor( _aiState.value = _aiState.value.copy(isTranslating = true) val translationResult = aiService.translateText( - currentText, targetLanguage, _aiState.value.provider + currentText, + targetLanguage, + _aiState.value.provider ) when (translationResult) { diff --git a/app/src/main/java/com/skeler/scanely/ui/viewmodel/DocumentScanViewModel.kt b/app/src/main/java/com/skeler/scanely/ui/viewmodel/DocumentScanViewModel.kt index c4888e9..20822cc 100644 --- a/app/src/main/java/com/skeler/scanely/ui/viewmodel/DocumentScanViewModel.kt +++ b/app/src/main/java/com/skeler/scanely/ui/viewmodel/DocumentScanViewModel.kt @@ -274,9 +274,11 @@ class DocumentScanViewModel @Inject constructor( private fun applyExifRotation(uri: Uri, bitmap: Bitmap): Bitmap { val degrees = runCatching { appContext.contentResolver.openInputStream(uri)?.use { stream -> - when (ExifInterface(stream).getAttributeInt( - ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL - )) { + when ( + ExifInterface(stream).getAttributeInt( + ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL + ) + ) { ExifInterface.ORIENTATION_ROTATE_90 -> 90f ExifInterface.ORIENTATION_ROTATE_180 -> 180f ExifInterface.ORIENTATION_ROTATE_270 -> 270f diff --git a/app/src/main/java/com/skeler/scanely/ui/viewmodel/UnifiedScanViewModel.kt b/app/src/main/java/com/skeler/scanely/ui/viewmodel/UnifiedScanViewModel.kt index 70cf00f..aee7c3a 100644 --- a/app/src/main/java/com/skeler/scanely/ui/viewmodel/UnifiedScanViewModel.kt +++ b/app/src/main/java/com/skeler/scanely/ui/viewmodel/UnifiedScanViewModel.kt @@ -7,7 +7,6 @@ import com.skeler.scanely.core.actions.ScanAction import com.skeler.scanely.core.ocr.OcrResult import com.skeler.scanely.core.ocr.OcrSource import com.skeler.scanely.core.ocr.TextBlockData -import com.skeler.scanely.core.scan.UnifiedScanResult import com.skeler.scanely.core.scan.UnifiedScanService import com.skeler.scanely.history.data.HistoryManager import dagger.hilt.android.lifecycle.HiltViewModel @@ -35,7 +34,7 @@ class UnifiedScanViewModel @Inject constructor( currentScanJob = viewModelScope.launch { val result = unifiedScanService.scanImage(uri) - + val success = result.textResult as? OcrResult.Success val extractedText = success?.text @@ -64,7 +63,7 @@ class UnifiedScanViewModel @Inject constructor( currentScanJob = viewModelScope.launch { val barcodeActions = unifiedScanService.scanBarcodeOnly(uri) - + _uiState.value = UnifiedScanUiState( isLoading = false, barcodeActions = barcodeActions, diff --git a/app/src/test/java/com/skeler/scanely/ExampleUnitTest.kt b/app/src/test/java/com/skeler/scanely/ExampleUnitTest.kt index 1066814..b96c55e 100644 --- a/app/src/test/java/com/skeler/scanely/ExampleUnitTest.kt +++ b/app/src/test/java/com/skeler/scanely/ExampleUnitTest.kt @@ -1,12 +1,11 @@ package com.skeler.scanely -import org.junit.Test - import org.junit.Assert.* +import org.junit.Test class ExampleUnitTest { @Test fun addition_isCorrect() { assertEquals(4, 2 + 2) } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/skeler/scanely/core/ocr/paddle/StructureReviewFindingsTest.kt b/app/src/test/java/com/skeler/scanely/core/ocr/paddle/StructureReviewFindingsTest.kt index 4defd5e..cb214a2 100644 --- a/app/src/test/java/com/skeler/scanely/core/ocr/paddle/StructureReviewFindingsTest.kt +++ b/app/src/test/java/com/skeler/scanely/core/ocr/paddle/StructureReviewFindingsTest.kt @@ -110,7 +110,9 @@ class StructureReviewFindingsTest { tableOf = { table } ) - assertTrue("text inside the table region must not vanish, got:\n$markdown", - markdown.contains("stray")) + assertTrue( + "text inside the table region must not vanish, got:\n$markdown", + markdown.contains("stray") + ) } } diff --git a/app/src/test/java/com/skeler/scanely/core/ocr/paddle/TableDecoderTest.kt b/app/src/test/java/com/skeler/scanely/core/ocr/paddle/TableDecoderTest.kt index a5284f1..9c50a48 100644 --- a/app/src/test/java/com/skeler/scanely/core/ocr/paddle/TableDecoderTest.kt +++ b/app/src/test/java/com/skeler/scanely/core/ocr/paddle/TableDecoderTest.kt @@ -44,7 +44,14 @@ class TableDecoderTest { fun `a column span shifts the following cell`() { val table = decode( listOf( - "", "", "", "", "", "eos" + "", + "", + "", + "", + "", + "eos" ) ) diff --git a/app/src/test/java/com/skeler/scanely/core/pdf/ScanExporterTest.kt b/app/src/test/java/com/skeler/scanely/core/pdf/ScanExporterTest.kt index 525c431..3b41bc3 100644 --- a/app/src/test/java/com/skeler/scanely/core/pdf/ScanExporterTest.kt +++ b/app/src/test/java/com/skeler/scanely/core/pdf/ScanExporterTest.kt @@ -46,9 +46,12 @@ class ScanExporterTest { assertEquals(1, pages.getJSONObject(0).getInt("page")) assertEquals("Invoice", firstLine.getString("text")) assertEquals(0.98, firstLine.getDouble("confidence"), 0.001) - assertEquals(listOf(10, 20, 110, 45), firstLine.getJSONArray("box").let { box -> - (0 until box.length()).map { box.getInt(it) } - }) + assertEquals( + listOf(10, 20, 110, 45), + firstLine.getJSONArray("box").let { box -> + (0 until box.length()).map { box.getInt(it) } + } + ) assertEquals(2, pages.getJSONObject(1).getInt("page")) } diff --git a/app/src/test/java/com/skeler/scanely/settings/LookAndFeelSettingsTest.kt b/app/src/test/java/com/skeler/scanely/settings/LookAndFeelSettingsTest.kt index 5d340cf..961c164 100644 --- a/app/src/test/java/com/skeler/scanely/settings/LookAndFeelSettingsTest.kt +++ b/app/src/test/java/com/skeler/scanely/settings/LookAndFeelSettingsTest.kt @@ -5,8 +5,8 @@ import com.skeler.scanely.core.common.resolvedDarkTheme import com.skeler.scanely.settings.data.SettingsKeys import com.skeler.scanely.settings.domain.model.SettingsState import com.skeler.scanely.ui.theme.SeedPalettes -import org.junit.Assert.assertFalse import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test diff --git a/detekt-baseline.xml b/detekt-baseline.xml new file mode 100644 index 0000000..bf11ad5 --- /dev/null +++ b/detekt-baseline.xml @@ -0,0 +1,213 @@ + + + + + ComplexCondition:BarcodeScannerScreen.kt$actions.isNotEmpty() && !showActionsSheet && textToShow == null && !showProductSheet + ComplexCondition:DbPostProcessor.kt$DbPostProcessor$nx < 0 || ny < 0 || nx >= w || ny >= h + ComplexCondition:MarkdownParser.kt$MarkdownParser$bold || italic || (closes("**", i + 3) && closes("*", i + 3)) + ComplexCondition:ResultsScreen.kt$isAiResult && historyText == null && runInfo != null && aiState.result is AiResult.Success + ConstructorParameterNaming:MistralApi.kt$MistralDocument$val document_url: String? = null + ConstructorParameterNaming:MistralApi.kt$MistralDocument$val image_url: String? = null + CyclomaticComplexMethod:ActionExecutor.kt$ActionExecutor$fun execute(context: Context, action: ScanAction) + CyclomaticComplexMethod:BarcodeAnalyzer.kt$BarcodeAnalyzer$private fun getActionKey(action: ScanAction): String + CyclomaticComplexMethod:BarcodeAnalyzer.kt$BarcodeAnalyzer$private fun mapBarcodeToActions(barcode: Barcode): List<ScanAction> + CyclomaticComplexMethod:BarcodeContentParser.kt$BarcodeContentParser$fun parse(barcode: DecodedBarcode): List<ScanAction> + CyclomaticComplexMethod:BarcodeContentParser.kt$BarcodeContentParser$private fun parseVCard(text: String, actions: MutableList<ScanAction>) + CyclomaticComplexMethod:BarcodeContentParser.kt$BarcodeContentParser$private fun splitQrFields(body: String): Map<String, String> + CyclomaticComplexMethod:DbPostProcessor.kt$DbPostProcessor$fun extract(prob: FloatArray, w: Int, h: Int, scaleX: Float, scaleY: Float): List<Quad> + CyclomaticComplexMethod:DocumentScanViewModel.kt$DocumentScanViewModel$private fun applyExifRotation(uri: Uri, bitmap: Bitmap): Bitmap + CyclomaticComplexMethod:DocumentStructure.kt$DocumentStructure$private fun render( region: LayoutRegion, lines: List<PositionedLine>, tableOf: (LayoutRegion) -> TableStructure? ): String? + CyclomaticComplexMethod:DocumentStructure.kt$DocumentStructure$private fun tableMarkdown( table: TableStructure, lines: List<PositionedLine> ): String? + CyclomaticComplexMethod:FoodProduct.kt$fun ProductDto.toDomain(): FoodProduct? + CyclomaticComplexMethod:GenerativeAiService.kt$GenerativeAiService$fun processImageEvents(uri: Uri, mode: AiMode, provider: AiProvider): Flow<AiEvent> + CyclomaticComplexMethod:Geometry.kt$Geometry$fun convexHull(pts: FloatArray): FloatArray + CyclomaticComplexMethod:Geometry.kt$Geometry$fun minAreaRect(pts: FloatArray): RotatedRect + CyclomaticComplexMethod:HistoryScreen.kt$private fun LazyListState.shouldFabExpand( lastScrollOffset: Int, onScrollOffsetChanged: (Int) -> Unit ): Boolean + CyclomaticComplexMethod:ImageOps.kt$ImageOps$private fun solve8(a: Array<DoubleArray>): DoubleArray + CyclomaticComplexMethod:KeyVerifier.kt$KeyVerifier$private fun classify(provider: AiProvider, code: Int): VerificationResult + CyclomaticComplexMethod:KeyVerifier.kt$KeyVerifier$suspend fun verify( provider: AiProvider, key: String, customUrl: String? = null ): VerificationResult + CyclomaticComplexMethod:MarkdownHtml.kt$MarkdownHtml$fun render(source: String): String + CyclomaticComplexMethod:MarkdownParser.kt$MarkdownParser$fun parse(source: String): List<MdBlock> + CyclomaticComplexMethod:MarkdownParser.kt$MarkdownParser$fun parseInline(source: String): List<MdSpan> + CyclomaticComplexMethod:MarkdownParser.kt$MarkdownParser$fun toPlainText(source: String): String + CyclomaticComplexMethod:MarkdownPdf.kt$MarkdownPdf$private fun draw(pager: Pager, block: MdBlock) + CyclomaticComplexMethod:MarkdownPdf.kt$MarkdownPdf$private fun spans(text: String): CharSequence + CyclomaticComplexMethod:MlKitOcrService.kt$MlKitOcrService$suspend fun recognizeFromPdf(pdfUri: Uri, pageIndex: Int? = null): OcrResult + CyclomaticComplexMethod:OpenFDAEngine.kt$OpenFDAEngine$override suspend fun lookup(barcode: String): LookupResult + CyclomaticComplexMethod:PaddleModelStore.kt$PaddleModelStore.Companion$fun parseYamlDict(yml: String): List<String> + CyclomaticComplexMethod:PaddleOcrService.kt$PaddleOcrService$suspend fun recognize(bitmap: Bitmap): OcrResult + CyclomaticComplexMethod:PaddleOcrService.kt$PaddleOcrService$suspend fun recognizeFromPdf(pdfUri: Uri, pageIndex: Int? = null): OcrResult + CyclomaticComplexMethod:PayloadFactory.kt$PayloadFactory$fun create(uri: Uri, mode: AiMode): Payload + CyclomaticComplexMethod:ProviderClient.kt$ProviderClient$private fun parseStreamPiece(kind: ProviderKind, payload: String): String? + CyclomaticComplexMethod:ProviderClient.kt$ProviderClient$private suspend fun consumeSse(body: ResponseBody, onData: suspend (String) -> Unit) + CyclomaticComplexMethod:ProviderClient.kt$ProviderClient$suspend fun once( config: ProviderConfig, systemInstruction: String?, prompt: String, images: List<String> ): String + CyclomaticComplexMethod:ProviderClient.kt$ProviderClient$suspend fun stream( config: ProviderConfig, systemInstruction: String?, prompt: String, images: List<String>, streamedAnything: BooleanArray, emit: suspend (AiEvent) -> Unit ): String + CyclomaticComplexMethod:ProviderConfig.kt$ProviderConfigResolver$fun bundledKeyProviders(): Flow<Set<AiProvider>> + CyclomaticComplexMethod:ProviderConfig.kt$ProviderConfigResolver$suspend fun resolve(provider: AiProvider): ProviderConfig? + CyclomaticComplexMethod:ProviderExecutor.kt$ProviderExecutor$suspend fun run( name: String, config: ProviderConfig, systemInstruction: String?, prompt: String, images: List<String>, allowStreaming: Boolean, emit: suspend (AiEvent) -> Unit ): ProviderOutcome + CyclomaticComplexMethod:ProviderVerificationViewModel.kt$ProviderVerificationViewModel$fun verify(provider: AiProvider, key: String, customUrl: String? = null) + CyclomaticComplexMethod:RetryUtils.kt$fun Exception.isRetryable(): Boolean + CyclomaticComplexMethod:RtlText.kt$RtlText$fun visualToLogical(s: String): String + CyclomaticComplexMethod:ScanActionDetector.kt$ScanActionDetector$private fun detectFromPatterns(text: String): List<ScanAction> + CyclomaticComplexMethod:ScanActionDetector.kt$ScanActionDetector$private fun getActionKey(action: ScanAction): String + CyclomaticComplexMethod:ScanExporter.kt$ScanExporter$internal fun wordMarkdownDocument(blocks: List<MdBlock>): String + CyclomaticComplexMethod:ScanExporter.kt$ScanExporter$private fun escapeXml(value: String): String + 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> + LongMethod:ColorSchemes.kt$private fun darkSeedScheme(p: TonalPalettes): ColorScheme + LongMethod:ColorSchemes.kt$private fun lightSeedScheme(p: TonalPalettes): ColorScheme + LongMethod:DbPostProcessor.kt$DbPostProcessor$fun extract(prob: FloatArray, w: Int, h: Int, scaleX: Float, scaleY: Float): List<Quad> + LongMethod:DocumentFilters.kt$DocumentFilters$private fun binarize(source: Bitmap): Bitmap + LongMethod:GenerativeAiService.kt$GenerativeAiService$fun processImageEvents(uri: Uri, mode: AiMode, provider: AiProvider): Flow<AiEvent> + LongMethod:Geometry.kt$Geometry$fun minAreaRect(pts: FloatArray): RotatedRect + LongMethod:Geometry.kt$Geometry$fun orderQuad(q: Quad): Quad + LongMethod:Geometry.kt$Geometry$fun unclip(q: Quad, ratio: Float): FloatArray + LongMethod:KeyVerifier.kt$KeyVerifier$suspend fun verify( provider: AiProvider, key: String, customUrl: String? = null ): VerificationResult + LongMethod:MarkdownParser.kt$MarkdownParser$fun parse(source: String): List<MdBlock> + LongMethod:MarkdownParser.kt$MarkdownParser$fun parseInline(source: String): List<MdSpan> + LongMethod:MlKitOcrService.kt$MlKitOcrService$suspend fun recognizeFromBitmap(bitmap: Bitmap): OcrResult + LongMethod:MlKitOcrService.kt$MlKitOcrService$suspend fun recognizeFromPdf(pdfUri: Uri, pageIndex: Int? = null): OcrResult + LongMethod:PaddleOcrService.kt$PaddleOcrService$suspend fun recognize(bitmap: Bitmap): OcrResult + LongMethod:PayloadFactory.kt$PayloadFactory$fun create(uri: Uri, mode: AiMode): Payload + LongMethod:ProviderClient.kt$ProviderClient$suspend fun once( config: ProviderConfig, systemInstruction: String?, prompt: String, images: List<String> ): String + LongMethod:ProviderClient.kt$ProviderClient$suspend fun stream( config: ProviderConfig, systemInstruction: String?, prompt: String, images: List<String>, streamedAnything: BooleanArray, emit: suspend (AiEvent) -> Unit ): String + LongMethod:ProviderConfig.kt$ProviderConfigResolver$suspend fun resolve(provider: AiProvider): ProviderConfig? + LongMethod:ProviderExecutor.kt$ProviderExecutor$suspend fun run( name: String, config: ProviderConfig, systemInstruction: String?, prompt: String, images: List<String>, allowStreaming: Boolean, emit: suspend (AiEvent) -> Unit ): ProviderOutcome + LongMethod:ScanExporter.kt$ScanExporter$internal fun buildJson(text: String, blocks: List<TextBlockData>): String + LongMethod:ScanExporter.kt$ScanExporter$internal fun wordMarkdownDocument(blocks: List<MdBlock>): String + LongMethod:ScanExporter.kt$ScanExporter$private fun buildTextPdfDocument(text: String): PdfDocument + LongMethod:ScanExporter.kt$ScanExporter$private fun exportToDownloads( context: Context, displayName: String, mimeType: String, write: (OutputStream) -> Unit ): Uri + LongMethod:TableDecoder.kt$TableDecoder$fun decode( structure: FloatArray, boxes: FloatArray, steps: Int, classes: Int, vocab: List<String>, scale: Float ): TableStructure + LongMethod:UnifiedScanService.kt$UnifiedScanService$private fun mapBarcodeToActions(barcode: Barcode): List<ScanAction> + LongParameterList:LanguageChipRow.kt$( cachedLanguages: List<String>, currentLanguage: String?, showLanguageMenu: Boolean, onShowLanguageMenu: () -> Unit, onDismissLanguageMenu: () -> Unit, onSelectOriginal: () -> Unit, onSelectCached: (String) -> Unit, allLanguages: List<String>, onNewLanguageSelected: (String) -> Unit, onComposeText: () -> Unit, modifier: Modifier = Modifier, showMarkdown: Boolean = false, markdownSelected: Boolean = false, onSelectMarkdown: () -> Unit = {} ) + LoopWithTooManyJumpStatements:DbPostProcessor.kt$DbPostProcessor$for + LoopWithTooManyJumpStatements:ImageOps.kt$ImageOps$for + LoopWithTooManyJumpStatements:OpenFDAEngine.kt$OpenFDAEngine$for + LoopWithTooManyJumpStatements:PaddleModelStore.kt$PaddleModelStore.Companion$for + LoopWithTooManyJumpStatements:ProviderClient.kt$ProviderClient$while + LoopWithTooManyJumpStatements:TableDecoder.kt$TableDecoder$while + MagicNumber:App.kt$App$0.25 + MagicNumber:App.kt$App$1024 + MagicNumber:BarcodeAnalyzer.kt$BarcodeAnalyzer$10 + MagicNumber:BarcodeAnalyzer.kt$BarcodeAnalyzer$50 + MagicNumber:BarcodeContentParser.kt$BarcodeContentParser$10 + MagicNumber:BarcodeContentParser.kt$BarcodeContentParser$4 + MagicNumber:BarcodeMatch.kt$10 + MagicNumber:BarcodeMatch.kt$13 + MagicNumber:BarcodeMatch.kt$9 + MagicNumber:DocumentScanViewModel.kt$DocumentScanViewModel$90f + MagicNumber:GenerativeAiModule.kt$GenerativeAiModule$20 + MagicNumber:GenerativeAiModule.kt$GenerativeAiModule$30 + MagicNumber:GenerativeAiModule.kt$GenerativeAiModule$90 + MagicNumber:HistoryManager.kt$HistoryManager$50 + MagicNumber:KeyCipher.kt$KeyCipher$256 + MagicNumber:KeyVerifier.kt$KeyVerifier$400 + MagicNumber:KeyVerifier.kt$KeyVerifier$401 + MagicNumber:KeyVerifier.kt$KeyVerifier$403 + MagicNumber:KeyVerifier.kt$KeyVerifier$429 + MagicNumber:LookupModule.kt$LookupModule$15 + MagicNumber:MarkdownParser.kt$MarkdownParser$3 + MagicNumber:MarkdownParser.kt$MarkdownParser$4 + MagicNumber:MarkdownPdf.kt$MarkdownPdf$6f + MagicNumber:OpenFDAEngine.kt$OpenFDAEngine$500 + MagicNumber:PayloadFactory.kt$PayloadFactory$1024 + MagicNumber:ProviderClient.kt$ProviderClient$429 + MagicNumber:ProviderConfig.kt$ProviderConfigResolver$3 + MagicNumber:ProviderConfig.kt$ProviderConfigResolver$4 + MagicNumber:ProviderConfig.kt$ProviderConfigResolver$5 + MagicNumber:ProviderConfig.kt$ProviderConfigResolver$6 + MagicNumber:ProviderConfig.kt$ProviderConfigResolver$7 + MagicNumber:ProviderConfig.kt$ProviderConfigResolver$8 + MagicNumber:ProviderExecutor.kt$ProviderExecutor$401 + MagicNumber:ProviderExecutor.kt$ProviderExecutor$402 + MagicNumber:ProviderExecutor.kt$ProviderExecutor$403 + MagicNumber:ProviderExecutor.kt$ProviderExecutor$429 + MagicNumber:ScanActionDetector.kt$ScanActionDetector$10 + MagicNumber:ScanActionDetector.kt$ScanActionDetector$15 + MagicNumber:ScanActionDetector.kt$ScanActionDetector$3 + MagicNumber:ScanActionDetector.kt$ScanActionDetector$5 + MagicNumber:ScanActionDetector.kt$ScanActionDetector$50 + MagicNumber:ScanExporter.kt$ScanExporter$360 + MagicNumber:Secrets.kt$Secrets$16 + MagicNumber:Secrets.kt$Secrets$24 + MagicNumber:Secrets.kt$Secrets$8 + MagicNumber:SharedComponents.kt$0xFF038141 + MagicNumber:SharedComponents.kt$0xFF85BB2F + MagicNumber:SharedComponents.kt$0xFFE63E11 + MagicNumber:SharedComponents.kt$0xFFEE8100 + MagicNumber:SharedComponents.kt$0xFFFECB02 + MagicNumber:SharedComponents.kt$3 + MagicNumber:SharedComponents.kt$4 + MatchingDeclarationName:GamifiedAiFab.kt$RateLimitDisplayState + MatchingDeclarationName:ResultsComponents.kt$ExtractedTextState + MatchingDeclarationName:RetryUtils.kt$RetryConfig + MatchingDeclarationName:_DrawableVectors.kt$DynamicColorImageVectors + MaxLineLength:NoSearchResult.kt$"M164 100H336A14 14 0 0 1 350 114V206A14 14 0 0 1 336 220H164A14 14 0 0 1 150 206V114A14 14 0 0 1 164 100Z" + MaxLineLength:ScanExporter.kt$ScanExporter$"<Relationship Id=\"rId${index + 1}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\" Target=\"media/image${index + 1}.jpg\"/>" + MaxLineLength:ScanExporter.kt$ScanExporter$"<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" xmlns:pic=\"http://schemas.openxmlformats.org/drawingml/2006/picture\"><w:body>" + MaxLineLength:ScanExporter.kt$ScanExporter$"<w:p><w:r><w:drawing><wp:inline distT=\"0\" distB=\"0\" distL=\"0\" distR=\"0\"><wp:extent cx=\"$cx\" cy=\"$cy\"/><wp:docPr id=\"$id\" name=\"Scan $id\"/><a:graphic><a:graphicData uri=\"http://schemas.openxmlformats.org/drawingml/2006/picture\"><pic:pic><pic:nvPicPr><pic:cNvPr id=\"0\" name=\"Scan $id\"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed=\"rId$id\"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"$cx\" cy=\"$cy\"/></a:xfrm><a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>" + MaxLineLength:ScanExporter.kt$ScanExporter$"<w:sectPr><w:pgSz w:w=\"11906\" w:h=\"16838\"/><w:pgMar w:top=\"720\" w:right=\"720\" w:bottom=\"720\" w:left=\"720\"/></w:sectPr></w:body></w:document>" + MaxLineLength:ScanExporter.kt$ScanExporter$properties = "<w:pBdr><w:bottom w:val=\"single\" w:sz=\"6\" w:space=\"1\" w:color=\"999999\"/></w:pBdr>" + 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) + NestedBlockDepth:PayloadFactory.kt$PayloadFactory$private fun renderPdfToBase64(uri: Uri): List<String> + NestedBlockDepth:TableDecoder.kt$TableDecoder$fun decode( structure: FloatArray, boxes: FloatArray, steps: Int, classes: Int, vocab: List<String>, scale: Float ): TableStructure + 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 + TooGenericExceptionCaught:MarkdownPdf.kt$MarkdownPdf$t: Throwable + TooGenericExceptionCaught:PaddleOcrService.kt$PaddleOcrService$e: Throwable + TooGenericExceptionCaught:ScanExporter.kt$ScanExporter$t: Throwable + TooManyFunctions:ActionExecutor.kt$ActionExecutor + TooManyFunctions:BarcodeContentParser.kt$BarcodeContentParser + TooManyFunctions:DocumentScanViewModel.kt$DocumentScanViewModel : ViewModel + TooManyFunctions:DocumentStructure.kt$DocumentStructure + TooManyFunctions:ImageOps.kt$ImageOps + TooManyFunctions:MarkdownPdf.kt$MarkdownPdf + TooManyFunctions:PaddleModelStore.kt$PaddleModelStore + TooManyFunctions:PaddleOcrEngine.kt$PaddleOcrEngine + TooManyFunctions:ScanExporter.kt$ScanExporter + TooManyFunctions:SettingsDataStore.kt$SettingsDataStore + 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 ) + Wrapping:ProviderConfig.kt$ProviderConfigResolver$ProviderConfig( ProviderKind.OPENAI_COMPAT, model, it, NVIDIA_URL, usesBundledKey = userKey == null ) + Wrapping:ProviderConfig.kt$ProviderConfigResolver$ProviderConfig( ProviderKind.OPENAI_COMPAT, model, it, OPENROUTER_URL, usesBundledKey = userKey == null ) + + diff --git a/detekt.yml b/detekt.yml index f17add9..c6db444 100644 --- a/detekt.yml +++ b/detekt.yml @@ -11,6 +11,7 @@ complexity: LongMethod: active: true threshold: 40 + ignoreAnnotated: ["Composable"] LongParameterList: active: true functionThreshold: 8 @@ -18,9 +19,10 @@ complexity: ignoreDefaultParameters: true ignoreDataClasses: true ignoreAnnotatedParameter: ["Composable"] - ComplexMethod: + CyclomaticComplexMethod: active: true threshold: 10 + ignoreAnnotated: ["Composable"] TooManyFunctions: active: true thresholdInFiles: 15 @@ -31,6 +33,7 @@ complexity: ignoreDeprecated: true ignorePrivate: false ignoreOverridden: true + ignoreAnnotatedFunctions: ["Composable"] LargeClass: active: true threshold: 300 @@ -48,15 +51,28 @@ style: ReturnCount: active: true max: 3 - excludedFunctions: "equals" + excludedFunctions: ["equals"] excludeGuardClauses: true ForbiddenComment: active: true - values: ["TODO:", "FIXME:", "STOPSHIP:"] - customMessage: "Use issue tracker for tracking todos" + comments: + - reason: "Use issue tracker for tracking todos" + value: "TODO:" + - reason: "Use issue tracker for tracking todos" + value: "FIXME:" + - reason: "Use issue tracker for tracking todos" + value: "STOPSHIP:" MagicNumber: active: true - excludes: ["**/test/**", "**/androidTest/**", "**/*.kts"] + # Numeric literals are the domain in these: HCT tone tables, tensor + # normalization means/stds, geometry. Naming each one obscures the math. + excludes: + - "**/test/**" + - "**/androidTest/**" + - "**/*.kts" + - "**/ui/theme/**" + - "**/core/ocr/paddle/**" + - "**/core/image/**" ignoreNumbers: ["-1", "0", "1", "2", "60", "100", "1000"] ignoreHashCodeFunction: true ignorePropertyDeclaration: true @@ -66,6 +82,8 @@ style: ignoreAnnotation: true ignoreEnums: true ignoreRanges: true + # Compose layout literals (dp/sp/weights) are self-describing at the call site. + ignoreAnnotated: ["Composable"] empty-blocks: active: true @@ -82,7 +100,7 @@ naming: active: true functionPattern: "[a-z][a-zA-Z0-9]*" excludes: ["**/test/**", "**/androidTest/**"] - excludeClassPattern: ".*Composable.*" + ignoreAnnotated: ["Composable"] TopLevelPropertyNaming: active: true constantPattern: "[A-Z][_A-Z0-9]*"