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
                            )
                        }
                    }
                }
            }
        }
    }
}