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..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,13 +11,16 @@ 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 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,10 +46,13 @@ 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 = {} ) { val context = LocalContext.current + val scope = rememberCoroutineScope() // Shared OCR state var selectedImageUri by remember { mutableStateOf(null) } @@ -56,14 +62,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()) } } } @@ -85,12 +91,15 @@ fun ScanelyNavigation( navController.navigate(Routes.RESULTS) - kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + scope.launch(kotlinx.coroutines.Dispatchers.IO) { 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 +112,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,6 +163,7 @@ fun ScanelyNavigation( } // Process image when selected (skip if PDF is being processed OR if shouldAutoScan is false) + // Note: ocrQuality is intentionally NOT in dependencies - quality changes only affect new scans LaunchedEffect(selectedImageUri, isPdfProcessing, shouldAutoScan) { if (isPdfProcessing) return@LaunchedEffect @@ -167,9 +177,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 +242,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..5cc6cb9 --- /dev/null +++ b/app/src/main/java/com/skeler/scanely/ocr/MlKitOcrHelper.kt @@ -0,0 +1,170 @@ +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. + * Note: ML Kit handles thread safety internally, so no mutex is needed. + */ + suspend fun recognizeText(bitmap: Bitmap): OcrResult? = withContext(Dispatchers.IO) { + 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 + } + } + + /** + * 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 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 element-level confidence scores + val confidences = visionText.textBlocks.flatMap { block -> + block.lines.flatMap { line -> + line.elements.mapNotNull { it.confidence } + } + } + + 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..cc39c53 --- /dev/null +++ b/app/src/main/java/com/skeler/scanely/ocr/OcrEngine.kt @@ -0,0 +1,136 @@ +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" +private const val LANG_ARABIC = "ara" + +/** + * 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(LANG_ARABIC) + } + + /** + * 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..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 @@ -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,44 @@ 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)) + + 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() + Spacer(modifier = Modifier.height(16.dp)) + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -285,3 +326,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" }