How To
Use Android (Kotlin)
Using RustO! in native Android Kotlin and Jetpack Compose applications via JitPack.
The Android SDK is distributed via JitPack as an AAR containing pre-compiled JNI shared libraries for arm64-v8a, armeabi-v7a, x86, and x86_64.
Installation
Add JitPack to your root settings.gradle (or build.gradle):
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url 'https://jitpack.io' }
}
}
In your app module's build.gradle:
dependencies {
// Core OCR runtime with JNI native binaries
implementation 'com.github.byrizki.rusto-rs:rusto-android:0.3.2'
// Bundled PP-OCRv6 Tiny model package (~6 MB)
implementation 'com.github.byrizki.rusto-rs:rusto-models-ppocrv6-tiny:0.3.2'
}
1. Engine Lifecycle & Asset Resolution
Passing an Android Context to RustO.initialize() automatically unpacks and caches bundled model and dictionary assets on first launch:
package com.example.ocr
import android.content.Context
import com.byrizki.rusto.*
class OcrManager(private val context: Context) : AutoCloseable {
// 1. Initialize with default bundled PP-OCRv6 models
private val ocr: RustO = RustO.initialize(context)
// 2. Or initialize with custom configuration:
fun initCustom(): RustO {
val config = InitializeConfig(
template = "ppv6",
detection = DetectionConfig(modelPath = "custom_det.rten"),
recognition = RecognitionConfig(
modelPath = "custom_rec.rten",
dictPath = "custom_dict.txt"
)
)
return RustO.initialize(context, config)
}
override fun close() {
ocr.close()
}
}
2. Supported Image Sources
ImageSource natively resolves Android content:// URIs, file:// URIs, filesystem paths, and in-memory ByteArray:
// 1. Content URI from Photo Picker or Camera Intent (automatic stream decoding)
val uriSource = ImageSource.Uri("content://media/external/images/media/1002")
// 2. Local filesystem path
val pathSource = ImageSource.Uri("/data/user/0/com.example/cache/doc.jpg")
// 3. Raw in-memory byte array
val rawBytes: ByteArray = file.readBytes()
val byteSource = ImageSource.Bytes(rawBytes)
3. Request-Local Options (OcrRunOptions)
Configure options dynamically per scan call:
val options = OcrRunOptions(
output = OutputGranularity.LINES, // LINES, WORDS, or SPATIAL
lineYThreshold = 0.5f, // Dynamic row grouping tolerance
wordXThreshold = 0.4f, // Word spacing tolerance
textScore = 0.55f, // Minimum recognition score cutoff
maxSideLen = 1600f, // Maximum image side bound
detection = DetectionRunOptions(
limitSideLen = 960,
limitType = "max"
),
postprocess = PostprocessRunOptions(
useDilation = true // Morphological dilation for faint receipts
)
)
4. Complete Jetpack Compose Document Scanner
Here is a full Kotlin Coroutine & Jetpack Compose implementation using Android's modern Photo Picker:
package com.example.ocr
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.byrizki.rusto.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun DocumentScannerScreen() {
val context = LocalContext.current
var selectedUri by remember { mutableStateOf<Uri?>(null) }
var results by remember { mutableStateOf<List<TextResult>>(emptyList()) }
var spatialText by remember { mutableStateOf("") }
var isSpatial by remember { mutableStateOf(false) }
var isLoading by remember { mutableStateOf(false) }
val photoPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri ->
if (uri != null) {
selectedUri = uri
}
}
LaunchedEffect(selectedUri, isSpatial) {
val uri = selectedUri ?: return@LaunchedEffect
isLoading = true
withContext(Dispatchers.Default) {
RustO.initialize(context).use { ocr ->
val source = ImageSource.Uri(uri.toString())
if (isSpatial) {
val spatialRes = ocr.detectText(
source,
OcrRunOptions(output = OutputGranularity.SPATIAL, lineYThreshold = 0.5f)
)
if (spatialRes is DetectTextResult.Spatial) {
spatialText = spatialRes.text
results = emptyList()
}
} else {
val structRes = ocr.detectText(
source,
OcrRunOptions(output = OutputGranularity.LINES, textScore = 0.5f)
)
if (structRes is DetectTextResult.Structured) {
results = structRes.items
spatialText = ""
}
}
}
}
isLoading = false
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { photoPicker.launch(androidx.activity.result.PickVisualMediaRequest()) },
modifier = Modifier.weight(1f)
) {
Text("Select Document")
}
FilterChip(
selected = isSpatial,
onClick = { isSpatial = !isSpatial },
label = { Text(if (isSpatial) "Spatial Layout" else "Structured Lines") }
)
}
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.padding(vertical = 16.dp))
}
Spacer(modifier = Modifier.height(16.dp))
if (isSpatial && spatialText.isNotEmpty()) {
Text(
text = spatialText,
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall
)
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(results) { item ->
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = item.text, modifier = Modifier.weight(1f))
Text(
text = "${(item.score * 100).toInt()}%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
}
}