How to Build a Document Scanner in Jetpack Compose

Jetpack Compose is Android’s recommended modern toolkit for building native UI. It simplifies and accelerates UI development on Android. In this article, we are going to build a document scanner in Jetpack Compose. It can acquire images from document scanners or cameras and run document detection and correction.

Here are the demo videos:

  1. Capture from document scanners.

  2. Capture an image from the camera and edit it.

If you are using XML to build the app, you can check out this article.

SDKs Used

New Project

Open Android Studio and create a new project with an empty activity.

New project

Add Dependencies

  1. Open settings.gradle to add Dynamsoft’s maven repository and jitpack.

     dependencyResolutionManagement {
         repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
         repositories {
             google()
             mavenCentral()
    +        maven { url 'https://jitpack.io' }
    +        maven { url "https://download2.dynamsoft.com/maven/aar" }
         }
     }
    
  2. Add Dynamsoft Document Normalizer and Dynamsoft Camera Enhancer to the module’s build.gradle. Dynamsoft Camera Enhancer is used to provide an editor of the detected polygon.

    dependencies {
        implementation 'com.dynamsoft:dynamsoftcapturevisionrouter:2.0.10'
        implementation 'com.dynamsoft:dynamsoftdocumentnormalizer:2.0.10'
        implementation 'com.dynamsoft:dynamsoftcameraenhancer:4.0.0'
        implementation 'com.dynamsoft:dynamsoftutility:1.0.10'
    }
    
  3. Add a Java library for accessing document scanners using Dynamsoft Service’s REST APIs. The library was built in a previous Java document scanner blog.

    dependencies {
        implementation 'com.github.tony-xlh:docscan4j:v2.0.0'
    }
    

Add Permissions

Add permissions to access the camera, the internet and the storage.

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

How the Documents are Stored

The documents will be saved in the external storage under /storage/Android/data/<your app's ID>/files. Images of a document will be saved in folders with names containing a timestamp like doc-1695890553994. The image is named with a timestamp.

If an image is edited, the original image will be saved with an -original suffix and the edited image will replace the original image.

The images list of a document is stored in a text file named files in order, like the following:

1695890561282.jpg
1695890561283.jpg

A Document Manager class is created for managing the storage of documents.

class DocumentManager {
    private val context:Context
    constructor(context:Context){
        this.context = context
    }
    fun listDocuments(): MutableList<Long> {
        var externalFilesDir = context.getExternalFilesDir("")
        var documentTimestamps = mutableListOf<Long>()
        if (externalFilesDir != null) {
            externalFilesDir.listFiles().forEach {
                if (it.name.startsWith("doc-")) {
                    val date:Long = it.name.replace("doc-","").toLong()
                    documentTimestamps.add(date)
                }
            }
        }
        return documentTimestamps
    }
    fun saveDocument(doc:Document){
        var externalFilesDir = context.getExternalFilesDir("")
        var documentFolder = File(externalFilesDir,"doc-"+doc.date.toString())
        if (!documentFolder.exists()) {
            documentFolder.mkdir()
        }
        saveFilesList(documentFolder,doc)
    }

    fun saveFilesList(documentFolder:File,doc: Document){
        val files = File(documentFolder,"files")
        if (files.exists()) {
            files.delete()
        }
        files.createNewFile()
        val sb:StringBuilder = StringBuilder()
        doc.images.forEach {
            sb.append(it)
            sb.append("\n")
        }
        files.writeText(sb.toString())
    }

    fun getFilesList(documentFolder:File):List<String>{
        val files = File(documentFolder,"files")
        if (files.exists()) {
            return files.readLines()
        }else {
            return listOf()
        }
    }

    fun getDocument(date:Long):Document{
        var externalFilesDir = context.getExternalFilesDir("")
        var documentFolder = File(externalFilesDir,"doc-"+date.toString())
        if (documentFolder.exists()) {
            return Document(date, getFilesList(documentFolder))
        }else{
            throw Exception("Not exist")
        }
    }

    fun removeDocument(date:Long){
        var externalFilesDir = context.getExternalFilesDir("")
        var documentFolder = File(externalFilesDir,"doc-"+date.toString())
        if (documentFolder.exists()) {
            deleteFilesWithin(documentFolder)
            documentFolder.delete()
        }
    }

    private fun deleteFilesWithin(folder:File){
        folder.listFiles().forEach {
            it.delete()
        }
    }

    fun getFirstDocumentImage(date:Long):ImageBitmap{
        return readFileAsImageBitmapByIndex(date,0)
    }

    fun readFileAsImageBitmapByIndex(date: Long, index: Int): ImageBitmap {
        var externalFilesDir = context.getExternalFilesDir("")
        var documentFolder = File(externalFilesDir, "doc-" + date.toString())
        if (documentFolder.exists()) {
            val filesList = getFilesList(documentFolder)
            if (filesList.size>index) {
                val name = filesList.get(index)
                val file = File(documentFolder, name)
                if (file.exists()) {
                    val bytes = file.readBytes()
                    val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
                    return bitmap.asImageBitmap()
                }
            }
        }
        return getEmptyThumbNail()
    }

    private fun getEmptyThumbNail():ImageBitmap{
        val db = ContextCompat.getDrawable(context, R.drawable.thumbnail)
        var bit = Bitmap.createBitmap(
            db!!.intrinsicWidth, db.intrinsicHeight, Bitmap.Config.ARGB_8888)
        // on below line we are
        // creating a variable for canvas.
        val canvas = Canvas(bit)
        // on below line we are setting bounds for our bitmap.
        db.setBounds(0, 0, canvas.width, canvas.height)
        // on below line we are simply
        // calling draw to draw our canvas.
        db.draw(canvas)
        return bit.asImageBitmap()
    }

    fun readFileAsImageBitmapByName(date: Long, name: String): ImageBitmap {
        var externalFilesDir = context.getExternalFilesDir("")
        var documentFolder = File(externalFilesDir, "doc-" + date.toString())
        if (documentFolder.exists()) {
            val file = File(documentFolder,name)
            if (file.exists()) {
                try {
                    val bytes = file.readBytes()
                    val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
                    return bitmap.asImageBitmap()
                }catch (e:Exception){
                    e.printStackTrace()
                }
            }
        }
        return getEmptyThumbNail()
    }

    fun saveOneImage(date:Long,image:ByteArray):String{
        var externalFilesDir = context.getExternalFilesDir("")
        var documentFolder = File(externalFilesDir,"doc-"+date.toString())
        if (!documentFolder.exists()) {
            documentFolder.mkdir()
        }
        var imageFile = File(documentFolder, Date().time.toString()+".jpg")
        imageFile.createNewFile()
        imageFile.writeBytes(image)
        return imageFile.name
    }
    fun replaceOneImage(date:Long,filename:String,image:ByteArray){
        var externalFilesDir = context.getExternalFilesDir("")
        var documentFolder = File(externalFilesDir,"doc-"+date.toString())
        var imageFile = File(documentFolder, filename)
        if (imageFile.exists()) {
            imageFile.delete()
            imageFile.createNewFile()
            imageFile.writeBytes(image)
        }
    }
    fun saveOriginalImage(date:Long,filename:String,image:ByteArray):String{
        var externalFilesDir = context.getExternalFilesDir("")
        var documentFolder = File(externalFilesDir,"doc-"+date.toString())
        if (!documentFolder.exists()) {
            documentFolder.mkdir()
        }
        var imageFile = File(documentFolder, filename+"-original")
        imageFile.createNewFile()
        imageFile.writeBytes(image)
        return imageFile.name
    }

    fun getOriginalImage(date:Long,filename:String):ImageBitmap{
        var externalFilesDir = context.getExternalFilesDir("")
        var documentFolder = File(externalFilesDir,"doc-"+date.toString())
        var imageFile = File(documentFolder, filename)
        var originalFile = File(documentFolder, filename+"-original")
        var targetFile:File
        if (originalFile.exists()) {
            targetFile = originalFile
        }else{
            targetFile = imageFile
        }
        try {
            val bytes = targetFile.readBytes()
            val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
            return bitmap.asImageBitmap()
        }catch (e:Exception){
            e.printStackTrace()
        }
        return getEmptyThumbNail()
    }

    fun deleteImage(date:Long,filename:String) {
        var externalFilesDir = context.getExternalFilesDir("")
        var documentFolder = File(externalFilesDir,"doc-"+date.toString())
        if (documentFolder.exists()) {
            var imageFile = File(documentFolder, filename)
            var originalFile = File(documentFolder, filename+"-original")
            if (imageFile.exists()) {
                imageFile.delete()
            }
            if (originalFile.exists()) {
                originalFile.delete()
            }
        }
    }
}

A Document class is created to represend a document:

class Document(val date: Long, val images: List<String>)

Main Activity

In the main activity, list scanned documents and add a floating action button to create a new document.

Home

class MainActivity : ComponentActivity() {
    @OptIn(ExperimentalMaterial3Api::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        var documentTimestamps by mutableStateOf(emptyList<Long>())
        setContent {
            DocumentScannerTheme {
                val context = LocalContext.current
                val lifecycleOwner = LocalLifecycleOwner.current
                val manager = DocumentManager(context)
                DisposableEffect(lifecycleOwner) {
                    // Create an observer that triggers our remembered callbacks
                    // for sending analytics events
                    val observer = LifecycleEventObserver { _, event ->
                        if (event == Lifecycle.Event.ON_START) {
                            documentTimestamps = manager.listDocuments()
                        }
                    }

                    // Add the observer to the lifecycle
                    lifecycleOwner.lifecycle.addObserver(observer)

                    // When the effect leaves the Composition, remove the observer
                    onDispose {
                        lifecycleOwner.lifecycle.removeObserver(observer)
                    }
                }
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier
                        .fillMaxSize()
                        .padding(5.dp),
                ) {
                    Box(modifier = Modifier.fillMaxSize()) {
                        Column(
                            modifier = Modifier.fillMaxSize(),
                        ) {
                            TopAppBar(
                                title = {
                                    Text("Document Scanner")
                                },
                            )
                            documentTimestamps.forEach { timestamp ->
                                DocumentItem(timestamp,manager,{
                                    documentTimestamps = manager.listDocuments()
                                })
                            }
                        }
                        FloatingActionButton(
                            modifier = Modifier
                                .padding(all = 16.dp)
                                .align(alignment = Alignment.BottomEnd),
                            onClick = {
                                context.startActivity(Intent(context, ScannerActivity::class.java))
                                Log.d("DBR","clicked");
                            }
                        ) {
                            Icon(imageVector = Icons.Filled.Add, contentDescription = "Add")
                        }
                    }
                }
            }
        }
    }
}

@Composable
fun DocumentItem(date:Long,manager: DocumentManager,onDeleted: (date:Long) -> Unit) {
    val context = LocalContext.current
    var deleteConfirmationAlertDialog by remember {  mutableStateOf(false)}
    Row(modifier = Modifier
        .padding(all = 8.dp)
        .height(56.dp)
        .fillMaxWidth()
        .clickable(onClick = {
            var intent = Intent(context, ScannerActivity::class.java)
            intent.putExtra("date", date)
            context.startActivity(intent)
        })) {
        Image(
            bitmap = manager.getFirstDocumentImage(date),
            contentDescription = null,
            modifier = Modifier
                .size(40.dp)
                .clip(CircleShape)
                .border(1.5.dp, MaterialTheme.colorScheme.secondary, CircleShape)
        )
        Spacer(modifier = Modifier.width(8.dp))
        Text(
            text = formattedDate(date),
            color = MaterialTheme.colorScheme.secondary
        )
        //https://stackoverflow.com/questions/71594277/how-to-set-component-at-end-of-row-in-jetpack-compose
        Spacer(
            Modifier
                .weight(1f)
                .fillMaxHeight())
        IconButton(
            onClick = {
                deleteConfirmationAlertDialog = true
            }
        ){
            Icon(imageVector = Icons.Default.Delete, contentDescription = null)
        }
        when {
            deleteConfirmationAlertDialog -> {
                ConfirmationAlertDialog(
                    {
                        deleteConfirmationAlertDialog = false
                    },
                    {
                        deleteConfirmationAlertDialog = false
                        manager.removeDocument(date)
                        onDeleted(date)
                    },"Alert","Delete this document?")
            }
        }
    }
}

fun formattedDate(timestamp:Long):String{
    var date = Date(timestamp)
    val f1 = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
    val s2 = f1.format(date)
    return s2
}

Scanner Activity

The scanner activity can list the images of a document, acquire new images from document scanners or cameras and edit existing images.

Acquire Images from Document Scanners

We can acquire images from document scanners via the REST APIs of the Dynamsoft Service.

  1. Install Dynamsoft Service and make it accessible in an intranet. You can configure its IP on the configuration page and you can find the download links here.

  2. In the Android app, add the following to AndroidManifest.xml to enable HTTP requests.

    <application
       android:usesCleartextTraffic="true">
    </application>
    
  3. Add a scan settings dialog to configure settings like which scanner to use and the pixel type.

    Scan Settings

    @OptIn(ExperimentalMaterial3Api::class)
    @Composable
    fun ScannerSettingsDialog(onDismissRequest: (scanConfig:ScanConfig?) -> Unit,currentScanConfig:ScanConfig?, scanners:List<Scanner>) {
        var scannedExpanded by remember { mutableStateOf(false) }
        var pixelTypesExpanded by remember { mutableStateOf(false) }
        var selectedScannerName by remember { mutableStateOf("") }
        var selectedPixelType by remember { mutableStateOf("Black & White") }
        var selectedScanner:Scanner? = null
        var deviceConfig:DeviceConfiguration = DeviceConfiguration()
        var pixelType:CapabilitySetup = CapabilitySetup()
        if (currentScanConfig != null){
            selectedScanner = currentScanConfig.scanner
            selectedScannerName = selectedScanner.name
            deviceConfig = currentScanConfig.deviceConfig
            pixelType = currentScanConfig.pixelType
            if (pixelType.curValue == 1){
                selectedPixelType = "Gray"
            }else if (pixelType.curValue == 2) {
                selectedPixelType = "Color"
            }
        }else{
            pixelType.curValue = 0
            pixelType.exception = "ignore"
            pixelType.capability = 257
        }
        Dialog(onDismissRequest = { onDismissRequest(null) }) {
            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(300.dp)
                    .padding(16.dp),
            ) {
                Column(
                    modifier = Modifier.padding(15.dp)
                ) {
                    Text(
                        text = "Scanners:",
                    )
                    ExposedDropdownMenuBox(
                        expanded = scannedExpanded,
                        onExpandedChange = {
                            scannedExpanded = !scannedExpanded
                        }
                    ) {
                        TextField(
                            value = selectedScannerName,
                            onValueChange = {},
                            readOnly = true,
                            trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = scannedExpanded) },
                            modifier = Modifier.menuAnchor(),
                            singleLine = true
                        )
                        ExposedDropdownMenu(
                            expanded = scannedExpanded,
                            onDismissRequest = { scannedExpanded = false }
                        ) {
                            scanners.forEach { scanner ->
                                DropdownMenuItem(
                                    text = { Text(text = scanner.name) },
                                    onClick = {
                                        selectedScanner = scanner
                                        selectedScannerName = scanner.name
                                        scannedExpanded = false
                                    }
                                )
                            }
                        }
                    }
                    Text(
                        text = "Pixel Type:",
                    )
                    ExposedDropdownMenuBox(
                        expanded = pixelTypesExpanded,
                        onExpandedChange = {
                            pixelTypesExpanded = !pixelTypesExpanded
                        }
                    ) {
                        TextField(
                            value = selectedPixelType,
                            onValueChange = {},
                            readOnly = true,
                            trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = pixelTypesExpanded) },
                            modifier = Modifier.menuAnchor(),
                            singleLine = true
                        )
                        ExposedDropdownMenu(
                            expanded = pixelTypesExpanded,
                            onDismissRequest = { pixelTypesExpanded = false }
                        ) {
                            DropdownMenuItem(
                                text = { Text(text = "Black & White") },
                                onClick = {
                                    selectedPixelType = "Black & White"
                                    pixelType.curValue = 0
                                    pixelTypesExpanded = false
                                }
                            )
                            DropdownMenuItem(
                                text = { Text(text = "Gray") },
                                onClick = {
                                    selectedPixelType = "Gray"
                                    pixelType.curValue = 1
                                    pixelTypesExpanded = false
                                }
                            )
                            DropdownMenuItem(
                                text = { Text(text = "Color") },
                                onClick = {
                                    selectedPixelType = "Color"
                                    pixelType.curValue = 2
                                    pixelTypesExpanded = false
                                }
                            )
                        }
                    }
                    Button(onClick = {
                        if (selectedScanner != null) {
                            val scanConfig = ScanConfig(selectedScanner!!,deviceConfig,pixelType)
                            onDismissRequest(scanConfig)
                        }else{
                            onDismissRequest(null)
                        }
                    }) {
                        Text("Save")
                    }
                }
            }
        }
    }
    

    The settings are saved as an instance of the SconConfig class.

    class ScanConfig (
        var scanner: Scanner,
        var deviceConfig: DeviceConfiguration,
        var pixelType: CapabilitySetup
    )
    

    The scanners list is loaded in LaunchedEffect

    var scanners by mutableStateOf(emptyList<Scanner>())
    LaunchedEffect(key1 = true){
        var scannersFound = loadScanners()
        scanners = scannersFound
        if (scanners.size>0) {
            var pixelType = CapabilitySetup()
            pixelType.capability=257
            pixelType.curValue=0
            pixelType.exception="ignore"
            scanConfig = ScanConfig(scanners.get(0),
                DeviceConfiguration(),
                pixelType
            )
        }
    }
       
    suspend fun loadScanners():MutableList<Scanner>{
        var newScanners = mutableListOf<Scanner>()
        try {
            withContext(Dispatchers.IO) {
                service.getScanners().forEach {
                    newScanners.add(it)
                }
            }
        }catch (e:Exception){
            e.printStackTrace()
        }
        return newScanners
    }
    

    A Dynamsoft Service instance is created for communication with the Dynamsoft Service. A license is needed to use it. You can apply for one here.

    val service = DynamsoftService(
        "http://192.168.8.65:18622",
        "license"
    )
    
  4. Add a scan button to acquire images and display the images in a LazyColumn.

    Column {
        Row (
            modifier = Modifier.padding(5.dp),
        ) {
            Button(
                onClick = {
                    status.value = "Scanning..."
                    coroutineScope.launch {
                        val scanned = scan(manager)
                        var newImages = mutableListOf<String>()
                        images.forEach {
                            newImages.add(it)
                        }
                        scanned.forEach {
                            newImages.add(it)
                        }
                        images = newImages
                        saveDocument(manager,images)
                        if (images.size>0) {
                            listState.animateScrollToItem(index = images.size - 1)
                        }
                        status.value = ""
                    }
                }
            ){
                Text("Scan")
            }
            Spacer(modifier = Modifier.width(10.dp))
            Text(
                text = status.value,
                modifier = Modifier
                    .height(50.dp)
                    .wrapContentHeight(align = Alignment.CenterVertically)
            )
        }
        LazyColumn(state = listState) {
            items(images.size){
                Image(
                    bitmap = manager.readFileAsImageBitmapByName(date,images.get(it)),
                    contentDescription = "",
                    modifier = Modifier
                        .padding(10.dp)
                )
            }
        }
    }
    

    The functions for scanning and saving the documents.

    suspend fun scan(manager: DocumentManager): MutableList<String> {
        var images = mutableListOf<String>();
        withContext(Dispatchers.IO) {
            try {
                var caps = Capabilities()
                caps.capabilities.add(scanConfig!!.pixelType)
                val jobID = service.createScanJob(scanConfig!!.scanner,scanConfig!!.deviceConfig,caps)
                var image = service.nextDocument(jobID)
                while (image != null) {
                    val name = manager.saveOneImage(date, image)
                    images.add(name)
                    image = service.nextDocument(jobID)
                }
            }catch (e:Exception){
                e.printStackTrace()
            }
        }
        return images
    }
    
    fun saveDocument(manager: DocumentManager,images:List<String>){
        manager.saveDocument(Document(date,images))
    }
    

Acquire Images from Cameras

Next, let’s add the ability to capture a photo from the camera.

We can start a new activity and call the system’s camera app to take a photo with intent:

var startCamera: ActivityResultLauncher<Intent> =
            registerForActivityResult(
    ActivityResultContracts.StartActivityForResult(),
    ActivityResultCallback<ActivityResult> { result ->
        if (result.getResultCode() === RESULT_OK) {
            if (cam_uri != null) {
                val data = Intent().apply { putExtra("uri", cam_uri.toString()) }
                setResult(RESULT_OK, data)
            }
        }
        finish()
    }
)
val values = ContentValues()
values.put(MediaStore.Images.Media.TITLE, "New Picture")
values.put(MediaStore.Images.Media.DESCRIPTION, "From Camera")
cam_uri = applicationContext.contentResolver.insert(
    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
    values
)
val cameraIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, cam_uri)
startCamera.launch(cameraIntent)

In the scanner activity, launch the activity to capture a photo.

val mediaCaptureLauncher = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.StartActivityForResult(),
    onResult = { result ->
        val uriString = result.data?.getStringExtra("uri")
        if (result.resultCode == RESULT_OK) {
            val uri = Uri.parse(uriString)
            val inp: InputStream? = contentResolver.openInputStream(uri)
            if (inp != null) {
                val outputStream = ByteArrayOutputStream()
                inp.use { input ->
                    outputStream.use { output ->
                        input.copyTo(output)
                    }
                }
                val buffer = outputStream.toByteArray()
                val path = manager.saveOneImage(date,buffer)
                var newImages = mutableStateListOf<String>()
                for (i in 0..images.size-1) {
                    newImages.add(images.get(i))
                }
                newImages.add(path)
                images = newImages
                saveDocument(manager,images)
                coroutineScope.launch {
                    listState.animateScrollToItem(index = images.size - 1)
                }
            }
       }
    }
)
coroutineScope.launch {
    val intent = Intent(context, MediaCaptureActivity::class.java)
    mediaCaptureLauncher.launch(intent)
}

We also need to request the camera permission beforehand.

val launcher = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.RequestPermission(),
    onResult = { granted ->
        if (granted == true) {
        }
    }
)
launcher.launch(Manifest.permission.CAMERA)

Edit Existing Images

After acquiring images, we can edit them. Here, we can use Dynamsoft Document Normalizer to detect the document boundaries and perform perspective correction.

  1. Init the license for Dynamsoft Document Normalizer. You can apply for a license here.

    private fun initLicense(context: Context){
        LicenseManager.initLicense(
            "DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ==",
            context
        ) { isSuccess, error ->
            if (!isSuccess) {
                Looper.prepare();
                Toast.makeText(applicationContext,"License invalid: $error",Toast.LENGTH_LONG).show()
                Looper.loop();
                Log.e("DYM", "InitLicense Error: $error")
            }else{
                Log.e("DYM", "InitLicense success")
            }
        }
    }
    
  2. Create a new EditorActivity which is called after pressing a document image. In the activity, add an editor view and set the selected document image as its original image.

    var bitmap: Bitmap? = null
    lateinit var editorView: ImageEditorView
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val date = intent.getLongExtra("date",0)
        val filename = intent.getStringExtra("filename")
        val manager = DocumentManager(applicationContext)
        bitmap = manager.getOriginalImage(date,filename!!).asAndroidBitmap()
        setContent {
            DocumentScannerTheme {
                LaunchedEffect(key1 = true){
                    detectQuad(bitmap!!)
                }
                Scaffold(
                    bottomBar = {
                        BottomAppBar(
                            actions = {
                                IconButton(onClick = { finish() }) {
                                    Icon(
                                        Icons.Filled.Close,
                                        contentDescription = "Close the editor",
                                    )
                                }
                                IconButton(onClick = {
                                    setResult(RESULT_OK)
                                    saveNormalizedAndOriginalImage(normalizedImage(), bitmap!!)
                                    finish()
                                }) {
                                    Icon(Icons.Filled.Done, contentDescription = "Complete the editing")
                                }
                            }
                        )
                    },
                ) { innerPadding ->
                    Text(
                        modifier = Modifier.padding(innerPadding),
                        text = "You can adjust the detected polygon."
                    )
                    AndroidView(factory = {context ->
                        editorView = ImageEditorView(context)
                        if (bitmap != null) {
                            editorView.setOriginalImage(bitmap)
                        }
                        editorView
                    })
                }
            }
        }
    }
    
  3. Detect the documents in the image and pass the result to the editor view.

    private fun detectQuad(bitmap:Bitmap){
        val router = CaptureVisionRouter(applicationContext);
        val result = router.capture(bitmap,EnumPresetTemplate.PT_DETECT_DOCUMENT_BOUNDARIES)
        if (result != null) {
            addQuadDrawingItems(result)
        }
        if (result == null || result.items.size == 0) {
            //No documents detected. Create a drawing item for the user to adjust
            val drawingItems = ArrayList<DrawingItem<*>>()
            val left = (bitmap.width*0.1).toInt()
            val right = (bitmap.width*0.9).toInt()
            val top = (bitmap.height*0.2).toInt()
            val bottom = (bitmap.height*0.6).toInt()
            val point1 = Point(left,top)
            val point2 = Point(right,top)
            val point3 = Point(right,bottom)
            val point4 = Point(left,bottom)
            val quad = Quadrilateral(point1,point2,point3,point4)
            drawingItems.add(QuadDrawingItem(quad))
            editorView.getDrawingLayer(DrawingLayer.DDN_LAYER_ID).drawingItems = drawingItems
            Toast.makeText(applicationContext,"No documents detected.",Toast.LENGTH_SHORT).show()
        }
    }
    
    private fun addQuadDrawingItems(result:CapturedResult){
        val drawingItems = ArrayList<DrawingItem<*>>()
        result.items.forEach{
            val quad: DetectedQuadResultItem = it as DetectedQuadResultItem
            drawingItems.add(QuadDrawingItem(quad.location))
        }
        editorView.getDrawingLayer(DrawingLayer.DDN_LAYER_ID).drawingItems = drawingItems
    }
    
  4. If the user confirms the result, save the normalized image.

    IconButton(onClick = {
        setResult(RESULT_OK)
        saveNormalizedAndOriginalImage(normalizedImage(), bitmap!!)
        finish()
    }) {
        Icon(Icons.Filled.Done, contentDescription = "Complete the editing")
    }
    

    Related functions:

    private fun normalizedImage():Bitmap{
        var quadDrawingItem:QuadDrawingItem
        if (editorView.selectedDrawingItem != null) {
            quadDrawingItem = editorView.selectedDrawingItem as QuadDrawingItem
        }else{
            quadDrawingItem = editorView.getDrawingLayer(DrawingLayer.DDN_LAYER_ID).drawingItems.get(0) as QuadDrawingItem
        }
        val router = CaptureVisionRouter(applicationContext);
        val settings = router.getSimplifiedSettings(EnumPresetTemplate.PT_NORMALIZE_DOCUMENT)
        settings.roi = quadDrawingItem.quad
        settings.roiMeasuredInPercentage = false
        router.updateSettings(EnumPresetTemplate.PT_NORMALIZE_DOCUMENT,settings)
        val result = router.capture(bitmap,EnumPresetTemplate.PT_NORMALIZE_DOCUMENT)
        val normalizedResult = result.items[0] as NormalizedImageResultItem
        return normalizedResult.imageData.toBitmap()
    }
    private fun saveNormalizedAndOriginalImage(normalized:Bitmap,original:Bitmap){
        val manager = DocumentManager(applicationContext)
        val date = intent.getLongExtra("date",0)
        val filename = intent.getStringExtra("filename")
        manager.replaceOneImage(date,filename!!,Bitmap2ByteArray(normalized))
        manager.saveOriginalImage(date,filename!!,Bitmap2ByteArray(original))
    }
    
    private fun Bitmap2ByteArray(image:Bitmap):ByteArray{
        val stream = ByteArrayOutputStream()
        image.compress(Bitmap.CompressFormat.JPEG, 100, stream)
        return stream.toByteArray()
    }
    

All right, we’ve covered the key parts of the document scanning app.

Source Code

Get the source code and have a try: https://github.com/tony-xlh/Document-Scanner-Jetpack-Compose/