A powerful, cross-platform Document Scanner built with Compose Multiplatform (CMP) to share UI and core logic between Android and iOS. This open-source project allows users to effortlessly scan documents using their device's camera, providing a seamless and high-quality experience on both platforms.
Provides fast, accurate, and offline-capable document capture while leveraging the best native scanning technologies on each platform.
-
Cross-Platform UI: A single codebase for a consistent, modern UI on both Android and iOS, powered by Compose Multiplatform.
-
High-Quality Scanning: Leverages native platform APIs for sharp, high-quality document capture.
-
Automatic Edge Detection & Cropping: Intelligent algorithms automatically detect the document's boundaries for a perfect crop every time.
-
Offline-First: All scanning and processing are done on-device, ensuring the app works perfectly without an internet connection.
android_doc_cmp.mp4
ScreenRecording_08-19-2025.14-45-29_1.mp4
@Composable
expect fun ScanDocument(onResult: (Result<ImageBitmap>) -> Unit)@Composable
fun ScanAndroidDocument(onResult: (Uri) -> Unit){
val context = LocalContext.current
val activity = context.getActivity()
val options = GmsDocumentScannerOptions.Builder()
.setGalleryImportAllowed(false)
.setPageLimit(1)
.setResultFormats(GmsDocumentScannerOptions.RESULT_FORMAT_JPEG)
.setScannerMode(GmsDocumentScannerOptions.SCANNER_MODE_FULL)
.build()
val scanner = GmsDocumentScanning.getClient(options)
val scannerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartIntentSenderForResult()
) { result ->
if (result.resultCode == RESULT_OK) {
val scanningResult = GmsDocumentScanningResult.fromActivityResultIntent(result.data)
scanningResult?.pages?.forEach {
onResult(it.imageUri)
Log.i("TAG", "BeneficiaryDetailsFormInfo: ML Kit Uri:${it.imageUri}")
}
}
}
if (activity != null) {
scanner.getStartScanIntent(activity)
.addOnSuccessListener { intentSender ->
scannerLauncher.launch(IntentSenderRequest.Builder(intentSender).build())
}
.addOnFailureListener {
context.showToast(it.message ?: "Ml Kit Error")
}
}
}@Composable
fun IosDocumentScanner(onResult: (Result<ImageBitmap>) -> Unit){
var delegate by remember { mutableStateOf<DocumentScannerDelegate?>(null) }
val controller = VNDocumentCameraViewController()
val localViewController = LocalUIViewController.current
controller.setDelegate(
DocumentScannerDelegate(
onError = {
controller.dismissViewControllerAnimated(true) {}
val exception = when (it.code) {
AVErrorApplicationIsNotAuthorizedToUseDevice -> DocumentScannerException.NotAuthorized(
it.localizedDescription
)
else -> DocumentScannerException.Unknown(it.localizedDescription)
}
onResult(Result.failure(exception))
},
onCancel = {
println("onCancel")
controller.dismissViewControllerAnimated(true) {}
},
onResult = { result ->
println("success")
controller.dismissViewControllerAnimated(true) {}
val documents = (0..<result.pageCount.toInt()).map {
val image = result.imageOfPageAtIndex(it.toULong())
val bitmap = image.toImageBitmap()
bitmap
}
println(documents.first())
if (documents.isNotEmpty()) {
val firstImage = documents.first()
onResult(Result.success(firstImage))
} else {
onResult(Result.failure(DocumentScannerException.Unknown("No documents were scanned or converted.")))
}
}
).also {
delegate = it // need to remember delegate else it is dereferenced
}
)
localViewController.presentViewController(controller, animated = true){}
}
@OptIn(ExperimentalForeignApi::class)
fun NSData.toByteArray(): ByteArray {
val length = this.length.toInt()
if (length == 0) return ByteArray(0)
val bytes = this.bytes ?: return ByteArray(0) // Pointer to the data
return ByteArray(length).apply {
usePinned { pinned ->
memcpy(pinned.addressOf(0), bytes, this@toByteArray.length)
}
}
}
@OptIn(ExperimentalForeignApi::class)
fun UIImage.toImageBitmap(): ImageBitmap {
val imageData = UIImagePNGRepresentation(this) ?: error("Could not get PNG representation of UIImage")
val byteArray = imageData.toByteArray() // Use the new extension function
val skiaImage = makeFromEncoded(byteArray)
return skiaImage.toComposeImageBitmap()
}This project showcases the power of Kotlin Multiplatform by sharing UI and logic, while using platform-specific integrations for the core document scanning functionality to achieve optimal performance.
- Kotlin Multiplatform (CMP) – Shared business logic, data models, and utilities.
- Language: Kotlin
- Scanning API: ML Kit Document Scanner
- Minimum API: 24 (Nougat)
- Language: Swift (with Kotlin/Native integration)
- Scanning API: VisionKit
- Minimum iOS: 13+
Contributions are welcome! Please follow these steps:
- Fork the repository.
- Create a new branch (git checkout -b feature-branch).
- Commit your changes (git commit -m 'Add some feature').
- Push to the branch (git push origin feature-branch).
- Open a pull request.
|
Muhammad Waqas |