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:
-
Capture from document scanners.
-
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
- Dynamsoft Service from Dynamic Web TWAIN which provides REST APIs for accessing document scanners.
- Dynamsoft Document Normalizer which provides the ability to detect document boundaries and perform perspective correction.
New Project
Open Android Studio and create a new project with an empty activity.
Add Dependencies
-
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" } } }
-
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' }
-
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.
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.
-
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.
-
In the Android app, add the following to
AndroidManifest.xml
to enable HTTP requests.<application android:usesCleartextTraffic="true"> </application>
-
Add a scan settings dialog to configure settings like which scanner to use and the pixel type.
@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" )
-
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.
-
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") } } }
-
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 }) } } } }
-
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 }
-
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/