From 71d0d99ff7685f30ccdfb7f8bcc77289ef55eca7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 02:18:59 +0000 Subject: [PATCH 1/6] Initial plan From 1e2184540bbd989dc4f0bf8db150c784347f8fab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 02:33:24 +0000 Subject: [PATCH 2/6] Add OCR quality toggle with ML Kit and Tesseract options Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- app/build.gradle.kts | 3 + .../java/com/skeler/scanely/MainActivity.kt | 14 ++ .../skeler/scanely/navigation/Navigation.kt | 31 ++-- .../com/skeler/scanely/ocr/MlKitOcrHelper.kt | 165 ++++++++++++++++++ .../java/com/skeler/scanely/ocr/OcrEngine.kt | 135 ++++++++++++++ .../java/com/skeler/scanely/ocr/OcrQuality.kt | 25 +++ .../scanely/ui/screens/SettingsScreen.kt | 73 ++++++++ gradle/libs.versions.toml | 3 + 8 files changed, 438 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/skeler/scanely/ocr/MlKitOcrHelper.kt create mode 100644 app/src/main/java/com/skeler/scanely/ocr/OcrEngine.kt create mode 100644 app/src/main/java/com/skeler/scanely/ocr/OcrQuality.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 41c3f3c..6bf946d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -79,6 +79,9 @@ dependencies { // ML Kit Barcode Scanning implementation(libs.mlkit.barcode) + // ML Kit Text Recognition (High Quality OCR) + implementation(libs.mlkit.text.recognition) + // Testing testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/app/src/main/java/com/skeler/scanely/MainActivity.kt b/app/src/main/java/com/skeler/scanely/MainActivity.kt index 14e81fe..1975d22 100644 --- a/app/src/main/java/com/skeler/scanely/MainActivity.kt +++ b/app/src/main/java/com/skeler/scanely/MainActivity.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import com.skeler.scanely.navigation.ScanelyNavigation +import com.skeler.scanely.ocr.OcrQuality import com.skeler.scanely.ui.theme.ScanelyTheme import com.skeler.scanely.ui.theme.ThemeMode import androidx.core.content.edit @@ -33,6 +34,13 @@ class MainActivity : ComponentActivity() { mutableStateOf(mode) } + // Load OCR quality preference + var ocrQuality by remember { + val name = prefs.getString("ocr_quality", OcrQuality.FAST.name) ?: OcrQuality.FAST.name + val quality = try { OcrQuality.valueOf(name) } catch (e: Exception) { OcrQuality.FAST } + mutableStateOf(quality) + } + val initialLangs = remember { prefs.getStringSet("ocr_langs", com.skeler.scanely.ocr.OcrHelper.SUPPORTED_LANGUAGES_MAP.keys) ?: com.skeler.scanely.ocr.OcrHelper.SUPPORTED_LANGUAGES_MAP.keys @@ -48,6 +56,12 @@ class MainActivity : ComponentActivity() { // Commit immediately to ensure persistence prefs.edit(commit = true) { putString("theme_mode", newMode.name) } }, + ocrQuality = ocrQuality, + onOcrQualityChanged = { newQuality -> + ocrQuality = newQuality + // Commit immediately to ensure persistence + prefs.edit(commit = true) { putString("ocr_quality", newQuality.name) } + }, ocrLanguages = ocrLanguages, onOcrLanguagesChanged = { newLangs -> ocrLanguages = newLangs 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 098de1b..3d52f02 100644 --- a/app/src/main/java/com/skeler/scanely/navigation/Navigation.kt +++ b/app/src/main/java/com/skeler/scanely/navigation/Navigation.kt @@ -17,7 +17,9 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import com.skeler.scanely.ocr.OcrEngine import com.skeler.scanely.ocr.OcrHelper +import com.skeler.scanely.ocr.OcrQuality import com.skeler.scanely.ocr.OcrResult import com.skeler.scanely.ui.components.GalleryPicker import com.skeler.scanely.ui.screens.CameraScreen @@ -43,6 +45,8 @@ fun ScanelyNavigation( navController: NavHostController = rememberNavController(), currentTheme: ThemeMode = ThemeMode.System, onThemeChanged: (ThemeMode) -> Unit = {}, + ocrQuality: OcrQuality = OcrQuality.FAST, + onOcrQualityChanged: (OcrQuality) -> Unit = {}, ocrLanguages: Set = setOf("eng", "ara"), onOcrLanguagesChanged: (Set) -> Unit = {} ) { @@ -56,14 +60,14 @@ fun ScanelyNavigation( // Flag to control if OCR should run automatically when selectedImageUri changes var shouldAutoScan by remember { mutableStateOf(true) } - val ocrHelper = remember { OcrHelper(context) } + val ocrEngine = remember { OcrEngine(context) } val historyManager = remember { com.skeler.scanely.data.HistoryManager(context) } - // Auto-initialize on Language change - LaunchedEffect(ocrLanguages) { + // Auto-initialize on Quality or Language change + LaunchedEffect(ocrQuality, ocrLanguages) { if (ocrLanguages.isNotEmpty()) { kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - ocrHelper.reinitialize(ocrLanguages.toList()) + ocrEngine.reinitialize(ocrQuality, ocrLanguages.toList()) } } } @@ -87,10 +91,13 @@ fun ScanelyNavigation( kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { try { - // Ensure OCR is initialized - if (!ocrHelper.isReady()) { + // Get Tesseract helper for PDF processing (always use Tesseract for multi-language PDFs) + val tesseractHelper = ocrEngine.getTesseractHelper() + + // Ensure Tesseract is initialized + if (!tesseractHelper.isReady()) { progressMessage = "Initializing OCR..." - val initSuccess = ocrHelper.initialize(ocrLanguages.toList()) + val initSuccess = tesseractHelper.initialize(ocrLanguages.toList()) if (!initSuccess) { isProcessing = false isPdfProcessing = false @@ -103,7 +110,7 @@ fun ScanelyNavigation( val pdfResult = com.skeler.scanely.ocr.PdfProcessor.extractTextFromPdf( context = context, pdfUri = uri, - ocrHelper = ocrHelper, + ocrHelper = tesseractHelper, enabledLanguages = ocrLanguages.toList(), onProgress = { update -> progressMessage = if (update.statusMessage.isNotEmpty()) { @@ -154,7 +161,7 @@ fun ScanelyNavigation( } // Process image when selected (skip if PDF is being processed OR if shouldAutoScan is false) - LaunchedEffect(selectedImageUri, isPdfProcessing, shouldAutoScan) { + LaunchedEffect(selectedImageUri, isPdfProcessing, shouldAutoScan, ocrQuality) { if (isPdfProcessing) return@LaunchedEffect // Only run if we have a URI and auto-scan is enabled @@ -167,9 +174,9 @@ fun ScanelyNavigation( isProcessing = true ocrResult = null - val initialized = ocrHelper.initialize(ocrLanguages.toList()) + val initialized = ocrEngine.initialize(ocrQuality, ocrLanguages.toList()) if (initialized) { - val result = ocrHelper.recognizeText(uri) + val result = ocrEngine.recognizeText(uri) if (result != null) { ocrResult = result // Save to history automatically if successful @@ -232,6 +239,8 @@ fun ScanelyNavigation( SettingsScreen( currentTheme = currentTheme, onThemeChange = onThemeChanged, + ocrQuality = ocrQuality, + onOcrQualityChanged = onOcrQualityChanged, ocrLanguages = ocrLanguages, onOcrLanguagesChanged = onOcrLanguagesChanged, onNavigateBack = { navController.popBackStack() } diff --git a/app/src/main/java/com/skeler/scanely/ocr/MlKitOcrHelper.kt b/app/src/main/java/com/skeler/scanely/ocr/MlKitOcrHelper.kt new file mode 100644 index 0000000..afe30b9 --- /dev/null +++ b/app/src/main/java/com/skeler/scanely/ocr/MlKitOcrHelper.kt @@ -0,0 +1,165 @@ +package com.skeler.scanely.ocr + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.util.Log +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.text.TextRecognition +import com.google.mlkit.vision.text.TextRecognizer +import com.google.mlkit.vision.text.latin.TextRecognizerOptions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume + +private const val TAG = "MlKitOcrHelper" + +/** + * ML Kit Text Recognition OCR helper. + * Provides high-quality text recognition for Latin script. + * + * Uses Google's ML Kit which runs on-device for privacy. + */ +class MlKitOcrHelper(private val context: Context) { + + private val mutex = Mutex() + private var recognizer: TextRecognizer? = null + private var isInitialized = false + + /** + * Initialize ML Kit Text Recognizer. + * ML Kit automatically handles model downloading and initialization. + * + * @param languages Ignored for ML Kit (uses Latin recognizer) + * @return true if initialization succeeded + */ + suspend fun initialize(languages: List = emptyList()): Boolean = withContext(Dispatchers.IO) { + mutex.withLock { + try { + if (isInitialized && recognizer != null) { + return@withContext true + } + + // Create text recognizer with Latin script options + recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) + isInitialized = true + + Log.d(TAG, "ML Kit Text Recognizer initialized successfully") + true + } catch (e: Exception) { + Log.e(TAG, "ML Kit initialization failed", e) + false + } + } + } + + /** + * Recognize text from an image URI. + */ + suspend fun recognizeText(imageUri: Uri): OcrResult? = withContext(Dispatchers.IO) { + if (!isInitialized || recognizer == null) { + Log.e(TAG, "MlKitOcrHelper not initialized") + return@withContext null + } + + try { + val inputImage = InputImage.fromFilePath(context, imageUri) + recognizeFromInputImage(inputImage) + } catch (e: Exception) { + Log.e(TAG, "Failed to create InputImage from URI", e) + null + } + } + + /** + * Recognize text from a bitmap. + */ + suspend fun recognizeText(bitmap: Bitmap): OcrResult? = withContext(Dispatchers.IO) { + mutex.withLock { + if (!isInitialized || recognizer == null) { + Log.e(TAG, "MlKitOcrHelper not initialized") + return@withLock null + } + + try { + val inputImage = InputImage.fromBitmap(bitmap, 0) + recognizeFromInputImage(inputImage) + } catch (e: Exception) { + Log.e(TAG, "Failed to create InputImage from Bitmap", e) + null + } + } + } + + private suspend fun recognizeFromInputImage(inputImage: InputImage): OcrResult? { + return suspendCancellableCoroutine { continuation -> + val startTime = System.currentTimeMillis() + + recognizer?.process(inputImage) + ?.addOnSuccessListener { visionText -> + val processingTime = System.currentTimeMillis() - startTime + + val text = visionText.text + + // Calculate confidence from block-level confidence + val confidences = visionText.textBlocks.mapNotNull { block -> + block.lines.flatMap { line -> + line.elements.mapNotNull { it.confidence } + } + }.flatten() + + val avgConfidence = if (confidences.isNotEmpty()) { + (confidences.average() * 100).toInt() + } else { + 0 + } + + Log.d(TAG, "OCR completed in ${processingTime}ms, confidence: $avgConfidence%") + + continuation.resume( + OcrResult( + text = text, + confidence = avgConfidence, + languages = listOf("eng"), // ML Kit Latin + processingTimeMs = processingTime + ) + ) + } + ?.addOnFailureListener { e -> + Log.e(TAG, "ML Kit text recognition failed", e) + continuation.resume(null) + } + ?: continuation.resume(null) + } + } + + /** + * Check if the helper is ready. + */ + fun isReady(): Boolean = isInitialized && recognizer != null + + /** + * Release ML Kit resources. + */ + fun release() { + try { + recognizer?.close() + recognizer = null + isInitialized = false + Log.d(TAG, "ML Kit resources released") + } catch (e: Exception) { + Log.e(TAG, "Error releasing ML Kit", e) + } + } + + /** + * Reinitialize (for compatibility with OcrHelper interface). + */ + suspend fun reinitialize(languages: List): Boolean { + release() + return initialize(languages) + } +} diff --git a/app/src/main/java/com/skeler/scanely/ocr/OcrEngine.kt b/app/src/main/java/com/skeler/scanely/ocr/OcrEngine.kt new file mode 100644 index 0000000..8bb0a8c --- /dev/null +++ b/app/src/main/java/com/skeler/scanely/ocr/OcrEngine.kt @@ -0,0 +1,135 @@ +package com.skeler.scanely.ocr + +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.util.Log + +private const val TAG = "OcrEngine" + +/** + * Unified OCR engine that can switch between different OCR backends. + * + * - FAST mode: Uses Tesseract OCR (supports more languages including Arabic) + * - BEST mode: Uses ML Kit Text Recognition (higher accuracy for Latin text) + * + * Usage: + * ``` + * val engine = OcrEngine(context) + * engine.initialize(OcrQuality.BEST, listOf("eng")) + * val result = engine.recognizeText(bitmap) + * engine.release() + * ``` + */ +class OcrEngine(private val context: Context) { + + private val tesseractHelper: OcrHelper = OcrHelper(context) + private val mlKitHelper: MlKitOcrHelper = MlKitOcrHelper(context) + + private var currentQuality: OcrQuality = OcrQuality.FAST + private var currentLanguages: List = emptyList() + private var isInitialized = false + + /** + * Initialize the OCR engine with specified quality and languages. + * + * @param quality OCR quality mode (FAST or BEST) + * @param languages Language codes for Tesseract (ignored for ML Kit) + * @return true if initialization succeeded + */ + suspend fun initialize(quality: OcrQuality, languages: List): Boolean { + Log.d(TAG, "Initializing OCR engine with quality=$quality, languages=$languages") + + currentQuality = quality + currentLanguages = languages + + val success = when (quality) { + OcrQuality.FAST -> tesseractHelper.initialize(languages) + OcrQuality.BEST -> mlKitHelper.initialize(languages) + } + + isInitialized = success + return success + } + + /** + * Recognize text from an image URI. + */ + suspend fun recognizeText(imageUri: Uri): OcrResult? { + if (!isInitialized) { + Log.e(TAG, "OcrEngine not initialized") + return null + } + + return when (currentQuality) { + OcrQuality.FAST -> tesseractHelper.recognizeText(imageUri) + OcrQuality.BEST -> mlKitHelper.recognizeText(imageUri) + } + } + + /** + * Recognize text from a bitmap. + */ + suspend fun recognizeText(bitmap: Bitmap): OcrResult? { + if (!isInitialized) { + Log.e(TAG, "OcrEngine not initialized") + return null + } + + return when (currentQuality) { + OcrQuality.FAST -> tesseractHelper.recognizeText(bitmap) + OcrQuality.BEST -> mlKitHelper.recognizeText(bitmap) + } + } + + /** + * Check if the engine is ready for OCR. + */ + fun isReady(): Boolean { + return when (currentQuality) { + OcrQuality.FAST -> tesseractHelper.isReady() + OcrQuality.BEST -> mlKitHelper.isReady() + } + } + + /** + * Get the current quality mode. + */ + fun getCurrentQuality(): OcrQuality = currentQuality + + /** + * Get the current languages (for Tesseract). + */ + fun getCurrentLanguages(): List = currentLanguages + + /** + * Check if current mode has Arabic support. + */ + fun hasArabic(): Boolean { + return currentQuality == OcrQuality.FAST && currentLanguages.contains("ara") + } + + /** + * Reinitialize with new quality/languages. + */ + suspend fun reinitialize(quality: OcrQuality, languages: List): Boolean { + release() + return initialize(quality, languages) + } + + /** + * Release all OCR resources. + */ + fun release() { + tesseractHelper.release() + mlKitHelper.release() + isInitialized = false + Log.d(TAG, "OcrEngine resources released") + } + + /** + * Get the underlying Tesseract helper for PDF processing. + * PDF processing uses Tesseract directly for multi-language support. + */ + fun getTesseractHelper(): OcrHelper = tesseractHelper +} diff --git a/app/src/main/java/com/skeler/scanely/ocr/OcrQuality.kt b/app/src/main/java/com/skeler/scanely/ocr/OcrQuality.kt new file mode 100644 index 0000000..68bc85d --- /dev/null +++ b/app/src/main/java/com/skeler/scanely/ocr/OcrQuality.kt @@ -0,0 +1,25 @@ +package com.skeler.scanely.ocr + +/** + * OCR Quality modes that control which OCR engine is used. + * + * @property label Human-readable label for UI + * @property description Description of the quality mode + */ +enum class OcrQuality(val label: String, val description: String) { + /** + * Fast mode using Tesseract OCR. + * - Faster processing + * - Supports more languages + * - Lower accuracy + */ + FAST("Fast", "Tesseract OCR - faster, supports more languages"), + + /** + * Best mode using Google ML Kit Text Recognition. + * - Higher accuracy + * - Better for printed text + * - Limited to Latin script + */ + BEST("Best", "ML Kit - higher accuracy for Latin text") +} diff --git a/app/src/main/java/com/skeler/scanely/ui/screens/SettingsScreen.kt b/app/src/main/java/com/skeler/scanely/ui/screens/SettingsScreen.kt index 85e27d6..bf2d849 100644 --- a/app/src/main/java/com/skeler/scanely/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/skeler/scanely/ui/screens/SettingsScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.skeler.scanely.R +import com.skeler.scanely.ocr.OcrQuality import com.skeler.scanely.ui.theme.ThemeMode @OptIn(ExperimentalMaterial3Api::class) @@ -44,6 +45,8 @@ import com.skeler.scanely.ui.theme.ThemeMode fun SettingsScreen( currentTheme: ThemeMode, onThemeChange: (ThemeMode) -> Unit, + ocrQuality: OcrQuality, + onOcrQualityChanged: (OcrQuality) -> Unit, ocrLanguages: Set, onOcrLanguagesChanged: (Set) -> Unit, onNavigateBack: () -> Unit @@ -106,6 +109,40 @@ fun SettingsScreen( HorizontalDivider() Spacer(modifier = Modifier.height(16.dp)) + // OCR Quality Section + Text( + text = "OCR Quality", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Choose between speed and accuracy", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + + OcrQualityOptionRow( + label = "Fast (Tesseract)", + description = "Faster, supports more languages including Arabic", + quality = OcrQuality.FAST, + currentQuality = ocrQuality, + onSelect = onOcrQualityChanged + ) + OcrQualityOptionRow( + label = "Best (ML Kit)", + description = "Higher accuracy for Latin text", + quality = OcrQuality.BEST, + currentQuality = ocrQuality, + onSelect = onOcrQualityChanged + ) + + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -285,3 +322,39 @@ private fun ThemeOptionRow( ) } } + +@Composable +private fun OcrQualityOptionRow( + label: String, + description: String, + quality: OcrQuality, + currentQuality: OcrQuality, + onSelect: (OcrQuality) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(quality) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = (quality == currentQuality), + onClick = { onSelect(quality) } + ) + Column( + modifier = Modifier.padding(start = 8.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 062a3f7..569328e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ coil = "2.7.0" navigationCompose = "2.9.0" accompanist = "0.36.0" mlkitBarcode = "17.3.0" +mlkitTextRecognition = "16.0.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -48,6 +49,8 @@ accompanist-permissions = { group = "com.google.accompanist", name = "accompanis androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } # ML Kit Barcode Scanning mlkit-barcode = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcode" } +# ML Kit Text Recognition +mlkit-text-recognition = { group = "com.google.mlkit", name = "text-recognition", version.ref = "mlkitTextRecognition" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 8e0d858ae391765265be568f36f4056b71a1e5be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 02:36:17 +0000 Subject: [PATCH 3/6] Address code review feedback: fix mutex usage and improve code quality Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .../skeler/scanely/navigation/Navigation.kt | 3 ++- .../com/skeler/scanely/ocr/MlKitOcrHelper.kt | 25 +++++++++---------- .../java/com/skeler/scanely/ocr/OcrEngine.kt | 3 ++- 3 files changed, 16 insertions(+), 15 deletions(-) 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 3d52f02..0f8015a 100644 --- a/app/src/main/java/com/skeler/scanely/navigation/Navigation.kt +++ b/app/src/main/java/com/skeler/scanely/navigation/Navigation.kt @@ -161,7 +161,8 @@ fun ScanelyNavigation( } // Process image when selected (skip if PDF is being processed OR if shouldAutoScan is false) - LaunchedEffect(selectedImageUri, isPdfProcessing, shouldAutoScan, ocrQuality) { + // Note: ocrQuality is intentionally NOT in dependencies - quality changes only affect new scans + LaunchedEffect(selectedImageUri, isPdfProcessing, shouldAutoScan) { if (isPdfProcessing) return@LaunchedEffect // Only run if we have a URI and auto-scan is enabled diff --git a/app/src/main/java/com/skeler/scanely/ocr/MlKitOcrHelper.kt b/app/src/main/java/com/skeler/scanely/ocr/MlKitOcrHelper.kt index afe30b9..46833b5 100644 --- a/app/src/main/java/com/skeler/scanely/ocr/MlKitOcrHelper.kt +++ b/app/src/main/java/com/skeler/scanely/ocr/MlKitOcrHelper.kt @@ -76,21 +76,20 @@ class MlKitOcrHelper(private val context: Context) { /** * Recognize text from a bitmap. + * Note: ML Kit handles thread safety internally, so no mutex is needed. */ suspend fun recognizeText(bitmap: Bitmap): OcrResult? = withContext(Dispatchers.IO) { - mutex.withLock { - if (!isInitialized || recognizer == null) { - Log.e(TAG, "MlKitOcrHelper not initialized") - return@withLock null - } - - try { - val inputImage = InputImage.fromBitmap(bitmap, 0) - recognizeFromInputImage(inputImage) - } catch (e: Exception) { - Log.e(TAG, "Failed to create InputImage from Bitmap", e) - null - } + if (!isInitialized || recognizer == null) { + Log.e(TAG, "MlKitOcrHelper not initialized") + return@withContext null + } + + try { + val inputImage = InputImage.fromBitmap(bitmap, 0) + recognizeFromInputImage(inputImage) + } catch (e: Exception) { + Log.e(TAG, "Failed to create InputImage from Bitmap", e) + null } } diff --git a/app/src/main/java/com/skeler/scanely/ocr/OcrEngine.kt b/app/src/main/java/com/skeler/scanely/ocr/OcrEngine.kt index 8bb0a8c..cc39c53 100644 --- a/app/src/main/java/com/skeler/scanely/ocr/OcrEngine.kt +++ b/app/src/main/java/com/skeler/scanely/ocr/OcrEngine.kt @@ -6,6 +6,7 @@ import android.net.Uri import android.util.Log private const val TAG = "OcrEngine" +private const val LANG_ARABIC = "ara" /** * Unified OCR engine that can switch between different OCR backends. @@ -106,7 +107,7 @@ class OcrEngine(private val context: Context) { * Check if current mode has Arabic support. */ fun hasArabic(): Boolean { - return currentQuality == OcrQuality.FAST && currentLanguages.contains("ara") + return currentQuality == OcrQuality.FAST && currentLanguages.contains(LANG_ARABIC) } /** From 73bf347d897053721572e398a034bb599221c114 Mon Sep 17 00:00:00 2001 From: Ahmed Elgharabawy <131464726+Godzilla675@users.noreply.github.com> Date: Mon, 29 Dec 2025 04:40:57 +0200 Subject: [PATCH 4/6] Update app/src/main/java/com/skeler/scanely/ui/screens/SettingsScreen.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../scanely/ui/screens/SettingsScreen.kt | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/skeler/scanely/ui/screens/SettingsScreen.kt b/app/src/main/java/com/skeler/scanely/ui/screens/SettingsScreen.kt index bf2d849..3ebad61 100644 --- a/app/src/main/java/com/skeler/scanely/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/skeler/scanely/ui/screens/SettingsScreen.kt @@ -124,20 +124,24 @@ fun SettingsScreen( ) Spacer(modifier = Modifier.height(8.dp)) - OcrQualityOptionRow( - label = "Fast (Tesseract)", - description = "Faster, supports more languages including Arabic", - quality = OcrQuality.FAST, - currentQuality = ocrQuality, - onSelect = onOcrQualityChanged - ) - OcrQualityOptionRow( - label = "Best (ML Kit)", - description = "Higher accuracy for Latin text", - quality = OcrQuality.BEST, - currentQuality = ocrQuality, - onSelect = onOcrQualityChanged - ) + OcrQuality.entries.forEach { quality -> + val label = when (quality) { + OcrQuality.FAST -> "Fast (Tesseract)" + OcrQuality.BEST -> "Best (ML Kit)" + } + val description = when (quality) { + OcrQuality.FAST -> "Faster, supports more languages including Arabic" + OcrQuality.BEST -> "Higher accuracy for Latin text" + } + + OcrQualityOptionRow( + label = label, + description = description, + quality = quality, + currentQuality = ocrQuality, + onSelect = onOcrQualityChanged + ) + } Spacer(modifier = Modifier.height(24.dp)) HorizontalDivider() From 15fb8bc07d8b5e297929ca209abd5894e1891bb6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 02:48:33 +0000 Subject: [PATCH 5/6] Fix thread-safety and memory leak issues from Gemini code review Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .../skeler/scanely/navigation/Navigation.kt | 4 +- .../com/skeler/scanely/ocr/MlKitOcrHelper.kt | 78 ++++++++++--------- 2 files changed, 45 insertions(+), 37 deletions(-) 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 0f8015a..1365511 100644 --- a/app/src/main/java/com/skeler/scanely/navigation/Navigation.kt +++ b/app/src/main/java/com/skeler/scanely/navigation/Navigation.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.navigation.NavHostController @@ -51,6 +52,7 @@ fun ScanelyNavigation( onOcrLanguagesChanged: (Set) -> Unit = {} ) { val context = LocalContext.current + val scope = rememberCoroutineScope() // Shared OCR state var selectedImageUri by remember { mutableStateOf(null) } @@ -89,7 +91,7 @@ fun ScanelyNavigation( navController.navigate(Routes.RESULTS) - kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + scope.launch(kotlinx.coroutines.Dispatchers.IO) { try { // Get Tesseract helper for PDF processing (always use Tesseract for multi-language PDFs) val tesseractHelper = ocrEngine.getTesseractHelper() diff --git a/app/src/main/java/com/skeler/scanely/ocr/MlKitOcrHelper.kt b/app/src/main/java/com/skeler/scanely/ocr/MlKitOcrHelper.kt index 46833b5..8efeb4a 100644 --- a/app/src/main/java/com/skeler/scanely/ocr/MlKitOcrHelper.kt +++ b/app/src/main/java/com/skeler/scanely/ocr/MlKitOcrHelper.kt @@ -93,45 +93,51 @@ class MlKitOcrHelper(private val context: Context) { } } + /** + * Perform text recognition from InputImage. + * Uses mutex to ensure thread-safety since TextRecognizer is not thread-safe. + */ private suspend fun recognizeFromInputImage(inputImage: InputImage): OcrResult? { - return suspendCancellableCoroutine { continuation -> - val startTime = System.currentTimeMillis() - - recognizer?.process(inputImage) - ?.addOnSuccessListener { visionText -> - val processingTime = System.currentTimeMillis() - startTime - - val text = visionText.text - - // Calculate confidence from block-level confidence - val confidences = visionText.textBlocks.mapNotNull { block -> - block.lines.flatMap { line -> - line.elements.mapNotNull { it.confidence } + return mutex.withLock { + suspendCancellableCoroutine { continuation -> + val startTime = System.currentTimeMillis() + + recognizer?.process(inputImage) + ?.addOnSuccessListener { visionText -> + val processingTime = System.currentTimeMillis() - startTime + + val text = visionText.text + + // Calculate confidence from block-level confidence + val confidences = visionText.textBlocks.mapNotNull { block -> + block.lines.flatMap { line -> + line.elements.mapNotNull { it.confidence } + } + }.flatten() + + val avgConfidence = if (confidences.isNotEmpty()) { + (confidences.average() * 100).toInt() + } else { + 0 } - }.flatten() - - val avgConfidence = if (confidences.isNotEmpty()) { - (confidences.average() * 100).toInt() - } else { - 0 - } - - Log.d(TAG, "OCR completed in ${processingTime}ms, confidence: $avgConfidence%") - - continuation.resume( - OcrResult( - text = text, - confidence = avgConfidence, - languages = listOf("eng"), // ML Kit Latin - processingTimeMs = processingTime + + Log.d(TAG, "OCR completed in ${processingTime}ms, confidence: $avgConfidence%") + + continuation.resume( + OcrResult( + text = text, + confidence = avgConfidence, + languages = listOf("eng"), // ML Kit Latin + processingTimeMs = processingTime + ) ) - ) - } - ?.addOnFailureListener { e -> - Log.e(TAG, "ML Kit text recognition failed", e) - continuation.resume(null) - } - ?: continuation.resume(null) + } + ?.addOnFailureListener { e -> + Log.e(TAG, "ML Kit text recognition failed", e) + continuation.resume(null) + } + ?: continuation.resume(null) + } } } From 9f334f3cb1fdadc5d2192a1be3aca66b08cc07ed Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 02:50:06 +0000 Subject: [PATCH 6/6] Simplify confidence calculation with flatMap Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- app/src/main/java/com/skeler/scanely/ocr/MlKitOcrHelper.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/skeler/scanely/ocr/MlKitOcrHelper.kt b/app/src/main/java/com/skeler/scanely/ocr/MlKitOcrHelper.kt index 8efeb4a..5cc6cb9 100644 --- a/app/src/main/java/com/skeler/scanely/ocr/MlKitOcrHelper.kt +++ b/app/src/main/java/com/skeler/scanely/ocr/MlKitOcrHelper.kt @@ -108,12 +108,12 @@ class MlKitOcrHelper(private val context: Context) { val text = visionText.text - // Calculate confidence from block-level confidence - val confidences = visionText.textBlocks.mapNotNull { block -> + // Calculate confidence from element-level confidence scores + val confidences = visionText.textBlocks.flatMap { block -> block.lines.flatMap { line -> line.elements.mapNotNull { it.confidence } } - }.flatten() + } val avgConfidence = if (confidences.isNotEmpty()) { (confidences.average() * 100).toInt()