From 9c2dd690bd733d6870aa9abb33636d3d13dbc128 Mon Sep 17 00:00:00 2001 From: skeler Date: Mon, 13 Jul 2026 06:21:30 +0100 Subject: [PATCH 1/3] fix(history): read locale observably in HistoryItemCard Lint's NonObservableLocale flagged Locale.getDefault() inside a composable: timestamps would not re-format when the user changed system locale. Use LocalLocale.current.platformLocale and remember the formatter so it is not rebuilt on every recomposition. This was the sole error failing the Android Lint job. Co-Authored-By: Claude Opus 4.8 --- .../presentation/screen/HistoryScreen.kt | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) 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 +} From b24ea23e82daa1cdcb7e0d3082fb3c1c97443ce0 Mon Sep 17 00:00:00 2001 From: skeler Date: Mon, 13 Jul 2026 06:21:38 +0100 Subject: [PATCH 2/3] ci(detekt): replace deleted action with official CLI, fix config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detekt job never ran: detekt/detekt-action no longer exists on GitHub (404), so it failed during "Set up job" every time. Run the official detekt CLI jar instead, pinned to the 1.23.8 already in the version catalog. That exposed a detekt.yml that was invalid for 1.23.8 (renamed ComplexMethod, deprecated ForbiddenComment values/customMessage, excludedFunctions as a comma-string) — fatal under warningsAsErrors. Also correct two rules that were misconfigured rather than strict: FunctionNaming used excludeClassPattern, which does not exclude @Composable functions (ignoreAnnotated does), and MagicNumber fired on the HCT tone tables and tensor normalization constants, where the literals are the domain. Remaining 258 findings are recorded in detekt-baseline.xml so new code is gated while the existing debt stays tracked. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/quality.yml | 30 ++++- detekt-baseline.xml | 213 ++++++++++++++++++++++++++++++++++ detekt.yml | 30 ++++- 3 files changed, 261 insertions(+), 12 deletions(-) create mode 100644 detekt-baseline.xml 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/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]*" From 2a12d30fee7ab453701277bdf85b8adac8154bbf Mon Sep 17 00:00:00 2001 From: skeler Date: Mon, 13 Jul 2026 06:21:43 +0100 Subject: [PATCH 3/3] style: apply detekt --auto-correct (formatting only) Mechanical ktlint pass: indentation, argument-list wrapping, trailing whitespace, import ordering, unused imports, final newlines. No behavioural change; compileDebugKotlin passes. Clears ~700 of the findings the detekt job would have reported. Co-Authored-By: Claude Opus 4.8 --- .../skeler/scanely/ExampleInstrumentedTest.kt | 6 +- .../scanely/core/ocr/PaddleFixesTest.kt | 33 +- .../core/ocr/PaddleOcrBenchmarkTest.kt | 4 +- .../scanely/core/ocr/PdfOcrResolutionTest.kt | 3 +- .../core/ocr/paddle/PaddleMarksTableTest.kt | 2 +- .../core/ocr/paddle/PaddleStructureTest.kt | 14 +- .../scanely/core/pdf/MarkdownPdfTest.kt | 2 +- .../java/com/skeler/scanely/MainActivity.kt | 2 +- .../scanely/core/actions/ActionExecutor.kt | 4 +- .../skeler/scanely/core/actions/ScanAction.kt | 22 +- .../core/actions/ScanActionDetector.kt | 77 ++--- .../com/skeler/scanely/core/ai/AiProvider.kt | 2 +- .../scanely/core/ai/AiRequestFactory.kt | 6 +- .../scanely/core/ai/GenerativeAiService.kt | 4 +- .../skeler/scanely/core/ai/ProviderClient.kt | 14 +- .../skeler/scanely/core/ai/ProviderConfig.kt | 53 ++- .../scanely/core/ai/ProviderExecutor.kt | 23 +- .../scanely/core/animation/AnimationSpecs.kt | 1 - .../scanely/core/barcode/BarcodeAnalyzer.kt | 68 ++-- .../skeler/scanely/core/di/LookupModule.kt | 21 +- .../skeler/scanely/core/di/SettingsModule.kt | 3 +- .../skeler/scanely/core/food/FoodProduct.kt | 2 +- .../scanely/core/image/DocumentFilters.kt | 16 +- .../scanely/core/image/ImagePreprocessor.kt | 72 +++-- .../scanely/core/lookup/LookupEngine.kt | 9 +- .../scanely/core/lookup/LookupOrchestrator.kt | 11 +- .../skeler/scanely/core/lookup/ProductInfo.kt | 12 +- .../skeler/scanely/core/lookup/RetryUtils.kt | 9 +- .../core/lookup/engines/GoogleBooksEngine.kt | 11 +- .../lookup/engines/OpenBeautyFactsEngine.kt | 4 +- .../core/lookup/engines/OpenFDAEngine.kt | 12 +- .../lookup/engines/OpenFoodFactsEngine.kt | 8 +- .../core/lookup/engines/OpenLibraryEngine.kt | 15 +- .../lookup/engines/OpenPetFoodFactsEngine.kt | 4 +- .../scanely/core/network/NetworkObserver.kt | 6 +- .../skeler/scanely/core/ocr/TextOcrService.kt | 9 +- .../scanely/core/ocr/paddle/Geometry.kt | 141 +++++--- .../scanely/core/ocr/paddle/ImageOps.kt | 33 +- .../core/ocr/paddle/PaddleOcrEngine.kt | 23 +- .../core/ocr/paddle/PaddleOcrService.kt | 37 ++- .../skeler/scanely/core/ocr/paddle/RtlText.kt | 12 +- .../scanely/core/ocr/paddle/ScriptPack.kt | 16 +- .../skeler/scanely/core/pdf/MarkdownPdf.kt | 2 +- .../com/skeler/scanely/core/pdf/PdfHelper.kt | 3 +- .../skeler/scanely/core/pdf/ScanExporter.kt | 30 +- .../components/svg/_DrawableVectors.kt | 2 +- .../components/svg/vectors/NoSearchResult.kt | 2 +- .../scanely/core/scan/UnifiedScanService.kt | 39 +-- .../skeler/scanely/core/security/Secrets.kt | 6 +- .../scanely/history/data/HistoryManager.kt | 15 +- .../scanely/navigation/LocalNavController.kt | 2 +- .../skeler/scanely/navigation/Navigation.kt | 4 +- .../scanely/settings/data/SettingsKeys.kt | 3 +- .../data/datastore/SettingsDataStore.kt | 2 +- .../data/repository/SettingsRepositoryImpl.kt | 2 +- .../settings/domain/model/SettingsState.kt | 2 +- .../domain/repository/SettingsRepository.kt | 2 +- .../presentation/screen/AiProvidersScreen.kt | 2 +- .../presentation/screen/LookAndFeelScreen.kt | 7 +- .../presentation/screen/SettingsScreen.kt | 6 +- .../screen/TextRecognitionScreen.kt | 6 +- .../ui/components/AiModeBottomSheet.kt | 6 +- .../ui/components/BarcodeScannerComponents.kt | 42 ++- .../scanely/ui/components/GalleryPicker.kt | 10 +- .../scanely/ui/components/LanguageChipRow.kt | 6 +- .../scanely/ui/components/MarkdownContent.kt | 4 +- .../scanely/ui/components/MarkdownPrint.kt | 5 +- .../ui/components/ProductDetailSheet.kt | 18 +- .../scanely/ui/components/RateLimitSheet.kt | 8 +- .../ui/components/ResultsComponents.kt | 4 +- .../components/product/FoodContentSection.kt | 9 +- .../ui/screens/BarcodeScannerScreen.kt | 8 +- .../skeler/scanely/ui/screens/CameraScreen.kt | 1 - .../skeler/scanely/ui/screens/HomeScreen.kt | 37 ++- .../scanely/ui/screens/ResultsScreen.kt | 306 +++++++++--------- .../scanely/ui/screens/ScanReviewScreen.kt | 15 +- .../ui/screens/UnifiedResultsScreen.kt | 6 +- .../skeler/scanely/ui/theme/ThemePalettes.kt | 7 +- .../scanely/ui/viewmodel/AiScanViewModel.kt | 6 +- .../ui/viewmodel/DocumentScanViewModel.kt | 8 +- .../ui/viewmodel/UnifiedScanViewModel.kt | 5 +- .../com/skeler/scanely/ExampleUnitTest.kt | 5 +- .../ocr/paddle/StructureReviewFindingsTest.kt | 6 +- .../core/ocr/paddle/TableDecoderTest.kt | 9 +- .../scanely/core/pdf/ScanExporterTest.kt | 9 +- .../settings/LookAndFeelSettingsTest.kt | 2 +- 86 files changed, 899 insertions(+), 606 deletions(-) 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/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