Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions app/src/main/java/com/skeler/scanely/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }
Comment on lines +39 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic to load the OCR quality preference from SharedPreferences can be simplified. The getString method with a default value will never return null, making the elvis operator (?:) on line 39 redundant. You can make this logic more concise and idiomatic using runCatching.

Suggested change
val name = prefs.getString("ocr_quality", OcrQuality.FAST.name) ?: OcrQuality.FAST.name
val quality = try { OcrQuality.valueOf(name) } catch (e: Exception) { OcrQuality.FAST }
val name = prefs.getString("ocr_quality", OcrQuality.FAST.name)!!
val quality = runCatching { OcrQuality.valueOf(name) }.getOrDefault(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
Expand All @@ -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
Expand Down
34 changes: 23 additions & 11 deletions app/src/main/java/com/skeler/scanely/navigation/Navigation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String> = setOf("eng", "ara"),
onOcrLanguagesChanged: (Set<String>) -> Unit = {}
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()

// Shared OCR state
var selectedImageUri by remember { mutableStateOf<Uri?>(null) }
Expand All @@ -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) }

Copilot AI Dec 29, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The OcrEngine is created with remember but never released. Unlike the old single OcrHelper, the OcrEngine now manages two OCR helpers (Tesseract and ML Kit) that both allocate native resources. These resources should be released when the composable leaves composition using DisposableEffect to prevent memory leaks.

Copilot uses AI. Check for mistakes.
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())
}
}
}
Expand All @@ -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()
Comment on lines +96 to +97

Copilot AI Dec 29, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment explains that PDF processing uses Tesseract for multi-language support, but this creates an inconsistency in user experience. If a user selects "Best (ML Kit)" quality and then processes a Latin-script PDF, they would expect higher quality OCR, but they'll get Tesseract instead. This should either be documented in the UI (e.g., "Note: PDFs always use Tesseract engine") or the logic should respect the user's quality preference for single-language PDFs.

Copilot uses AI. Check for mistakes.

// 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
Expand All @@ -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()) {
Expand Down Expand Up @@ -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) {
Comment on lines +166 to 167

Copilot AI Dec 29, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states "ocrQuality is intentionally NOT in dependencies" but this could lead to unexpected behavior. When a user changes OCR quality in settings and returns to the main screen with a previously selected image, the quality change won't take effect until they select a new image. This behavior should be clearly documented in the UI or the dependency should be added to reinitialize when quality changes.

Suggested change
// Note: ocrQuality is intentionally NOT in dependencies - quality changes only affect new scans
LaunchedEffect(selectedImageUri, isPdfProcessing, shouldAutoScan) {
LaunchedEffect(selectedImageUri, isPdfProcessing, shouldAutoScan, ocrQuality) {

Copilot uses AI. Check for mistakes.
if (isPdfProcessing) return@LaunchedEffect

Expand All @@ -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
Expand Down Expand Up @@ -232,6 +242,8 @@ fun ScanelyNavigation(
SettingsScreen(
currentTheme = currentTheme,
onThemeChange = onThemeChanged,
ocrQuality = ocrQuality,
onOcrQualityChanged = onOcrQualityChanged,
ocrLanguages = ocrLanguages,
onOcrLanguagesChanged = onOcrLanguagesChanged,
onNavigateBack = { navController.popBackStack() }
Expand Down
170 changes: 170 additions & 0 deletions app/src/main/java/com/skeler/scanely/ocr/MlKitOcrHelper.kt
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +20 to +24

Copilot AI Dec 29, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation states that both OCR engines run locally/offline and maintain privacy-first design, but there's no mention that ML Kit may download models on first use. While ML Kit does support on-device processing, it typically needs to download the text recognition model initially, which requires an internet connection. This should be documented to set proper user expectations about the "offline" capability.

Copilot uses AI. Check for mistakes.
*/
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<String> = 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This comment is misleading. According to the official ML Kit documentation, TextRecognizer instances are not thread-safe. Your implementation correctly uses a Mutex in recognizeFromInputImage to ensure thread safety, which contradicts this comment. To avoid confusion for future maintenance, this comment should be removed.

*/
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)
}
}
}
Comment on lines +100 to +142

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

According to the official ML Kit documentation, TextRecognizer instances are not thread-safe and should not be accessed from multiple threads concurrently. Since the recognizeText methods can be called from multiple coroutines on Dispatchers.IO, there is a risk of a race condition when calling recognizer.process().

To ensure thread safety, you should wrap the logic in recognizeFromInputImage within a mutex.withLock block. This will serialize access to the recognizer, preventing crashes and unpredictable behavior.

    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
                    }
                    
                    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<String>): Boolean {
release()
return initialize(languages)
}
}
Loading