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 }
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 instance is created with remember, which means it will survive recomposition but not configuration changes (like screen rotation). This could lead to resource leaks because the OcrEngine holds native resources (Tesseract API and ML Kit recognizer) that need to be explicitly released. When the activity is recreated, a new OcrEngine will be created without releasing the old one. Consider using rememberSaveable with a custom Saver, or better yet, move the OcrEngine to a ViewModel with proper lifecycle management and release resources in onCleared().

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())
}
}
}
Comment on lines +68 to 75

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 LaunchedEffect with ocrQuality and ocrLanguages dependencies will trigger reinitialization every time either changes, which could interrupt ongoing OCR operations. If a user is viewing results and changes the OCR quality setting, this will call reinitialize(), which calls release() on both OCR helpers, potentially destroying resources while they might still be referenced or in use. The reinitialization should either check if OCR is currently in use (isProcessing/isPdfProcessing) before reinitializing, or the image processing LaunchedEffect should handle reinitialization lazily when needed.

Copilot uses AI. Check for mistakes.
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()

// 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) {
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.
*/
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
}
}
Comment on lines +39 to +56

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 initialization comment mentions that "ML Kit automatically handles model downloading" but there's no handling for the case where model download fails (e.g., due to no network connectivity or insufficient storage). While the recognizer is created successfully at line 47, the first recognition attempt might fail if the model hasn't been downloaded yet. Consider adding a check or retry mechanism, or at minimum, improve error logging to distinguish between initialization failures and model download failures so users understand why OCR might fail on first use.

Copilot uses AI. Check for mistakes.
}
Comment on lines +39 to +57

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 mutex is used to protect access to the recognizer during text recognition operations, but it's not thread-safe during initialization. The recognizer is checked and set outside the mutex in the initialize method at line 42-43, creating a race condition. If initialize() is called concurrently from multiple coroutines, both could see isInitialized as false, and both could create new recognizer instances. The entire initialization block including the isInitialized check should be inside the mutex.withLock block.

Copilot uses AI. Check for mistakes.

/**
* 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
}
Comment on lines +62 to +74

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 ML Kit OCR implementation for URI recognition doesn't apply preprocessing like Tesseract does. The existing OcrHelper applies preprocessing via ImagePreprocessor.preprocess() before OCR (see OcrHelper.kt lines 137-141), which includes operations like resizing, grayscale conversion, and contrast enhancement that improve OCR accuracy. ML Kit should use the same preprocessing pipeline for consistency and to maximize accuracy, especially since this is supposed to be the "Best" quality option.

Copilot uses AI. Check for mistakes.
}

/**
* 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)

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 rotation parameter is hardcoded to 0 when creating InputImage from bitmap. This assumes all bitmaps are already properly oriented, which may not be true for images loaded from the camera or gallery that contain EXIF orientation data. Unlike the URI-based method which automatically handles EXIF orientation via fromFilePath(), this could result in rotated or mirrored text being processed incorrectly. Consider extracting and applying the correct rotation angle from the bitmap's source, or document that callers must provide pre-rotated bitmaps.

Copilot uses AI. Check for mistakes.
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 +98 to +140

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 ML Kit library is documented to support multiple concurrent calls to process(), so the mutex usage here may introduce unnecessary serialization and performance degradation. Unlike Tesseract's TessBaseAPI which is not thread-safe and requires mutual exclusion, ML Kit handles concurrent requests internally. The comment on line 79 acknowledges thread-safety but line 98 contradicts this by stating the TextRecognizer is not thread-safe. Consider removing the mutex from recognizeFromInputImage to allow parallel processing, which would better leverage ML Kit's capabilities.

Suggested change
* 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)
}
* ML Kit supports concurrent calls to process(), so no mutex is used here.
*/
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 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)

Copilot uses AI. Check for mistakes.
Comment on lines +102 to +140

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 suspendCancellableCoroutine does not handle cancellation properly. If the coroutine is cancelled while waiting for the ML Kit recognition to complete, the continuation will be left hanging and the Task listeners will never be removed. You should register a cancellation handler using continuation.invokeOnCancellation to properly clean up the Task listeners and prevent resource leaks.

Copilot uses AI. Check for mistakes.
}
}

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

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 release() method is missing mutex protection, which could cause race conditions if release() is called while a recognition operation is in progress. This could lead to the recognizer being closed while it's still processing an image, potentially causing crashes or undefined behavior. The release() method should acquire the mutex before accessing and closing the recognizer, similar to the pattern used in OcrHelper.

Copilot uses AI. Check for mistakes.

/**
* Reinitialize (for compatibility with OcrHelper interface).
*/
suspend fun reinitialize(languages: List<String>): Boolean {
release()
return initialize(languages)
}
}
Loading