How to Build a Document Scanner in Jetpack Compose

An Android document scanner in Jetpack Compose can combine three pieces: a REST service for physical document scanners, the system camera for mobile capture, and Dynamsoft Document Normalizer for document boundary detection and perspective correction. This article builds such a Compose app end to end.

What you’ll build: a Jetpack Compose document scanner app that acquires images from document scanners through the Dynamic Web TWAIN Service REST API (docscan4j) or from the device camera, stores documents on the device, and lets the user detect, adjust, and normalize document boundaries with Dynamsoft Capture Vision (com.dynamsoft:capturevisionbundle:3.6.2000) and the ImageEditorView polygon editor.

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.

Key Takeaways

  • Document scanning has three sources in the sample: a physical document scanner reached through the Dynamic Web TWAIN Service REST API (wrapped by the docscan4j library), the system camera, and existing images; all of them land in the same on-device document store.
  • Images are kept under /storage/Android/data/<your app's ID>/files in doc-<timestamp> folders with a files list; an edited image keeps an -original copy so no capture is lost.
  • Document normalization is preset-driven: PT_DETECT_DOCUMENT_BOUNDARIES finds the document quad, ImageEditorView lets the user correct the quad, and PT_NORMALIZE_DOCUMENT with the quad as the ROI produces the corrected (deskewed) image.
  • The current SDK exposes detected quads and deskewed images through ProcessedDocumentResult (detectedQuadResultItems / deskewedImageResultItems) on the CapturedResult.

Common Developer Questions

How does the app acquire images from a document scanner?

It talks to the Dynamic Web TWAIN Service over its REST API. The docscan4j library wraps the API in Kotlin-friendly classes (DynamsoftService, Scanner, Capabilities), and the sample points DynamsoftService at the machine that runs the service (for example http://192.168.8.65:18622).

How do I run document detection and perspective correction?

CaptureVisionRouter.capture(bitmap, EnumPresetTemplate.PT_DETECT_DOCUMENT_BOUNDARIES) returns the detected document quads. After the user confirms or adjusts the polygon in the ImageEditorView, capture the image again with PT_NORMALIZE_DOCUMENT and the quad as the ROI (settings.roi, roiMeasuredInPercentage = false) to get the deskewed image from result.processedDocumentResult?.deskewedImageResultItems.

Can the user correct a wrong detection result?

Yes. The sample draws the detected quads on the editor’s DDN drawing layer as QuadDrawingItem items, and the user can select and adjust the polygon; the normalized image is generated from the adjusted quad.

What happens to the original image when I save an edit?

The edited image replaces the file, and the original is saved with an -original suffix so it can be restored later. The DocumentManager class implements this naming convention.

Prerequisites

  • Android Studio with the Android SDK (API level 24 or higher).
  • An Android device or emulator with a camera, or a machine on the same network running the Dynamic Web TWAIN Service with a document scanner attached.
  • A Dynamsoft license key - the sample uses a time-limited trial key that requires a network connection on first use.

Get a 30-day free trial license

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. In the current Capture Vision SDKs, the Capture Vision Router, Document Normalizer and Camera Enhancer are distributed as a single capturevisionbundle artifact.

    dependencies {
        implementation 'com.dynamsoft:capturevisionbundle:3.6.2000'
    }
    
  3. Add a Java library for accessing document scanners using Dynamic Web TWAIN 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 Dynamic Web TWAIN Service.

  1. Install Dynamic Web TWAIN 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 Dynamic Web TWAIN Service instance is created for communication with the Dynamic Web TWAIN Service. The service and the client need valid licenses.

    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. Initialize the license for Dynamsoft Capture Vision with the key you obtained in the prerequisites.

    private fun initLicense(context: Context){
        LicenseManager.initLicense(
            "LICENSE-KEY",
            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<*>>()
        val detectedQuads = result.processedDocumentResult?.detectedQuadResultItems
        if (detectedQuads != null) {
            for (item in detectedQuads) {
                val quad: DetectedQuadResultItem = item 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 deskewedItems = result.processedDocumentResult?.deskewedImageResultItems ?: emptyArray()
        if (deskewedItems.isEmpty()) {
            throw RuntimeException("Document normalization failed.")
        }
        val deskewedItem = deskewedItems[0] as DeskewedImageResultItem
        return deskewedItem.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.

Common Issues & Edge Cases

  • The scanner list stays empty. The sample hardcodes the service address (http://192.168.8.65:18622) in ScannerActivity. Update it to the IP of the machine that runs the Dynamic Web TWAIN Service, and make sure the device is on the same network.
  • No documents are detected on a captured image. Detection needs a document that contrasts with the background. If the automatic quad is missing, the editor creates a default adjustable quad so the user can still mark the document manually.
  • Normalization fails after editing the quad. If deskewedImageResultItems comes back empty, the ROI settings are invalid (for example the quad is outside the image). Reset the quad with the detected polygon or with the default fallback quad.
  • Stored images cannot be found. The app writes into its own external files directory (/storage/Android/data/<your app's ID>/files). Files disappear if the user clears the app data, so treat the storage as app-private.

Source Code

Get the complete sample project source code on GitHub