Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,26 +1,27 @@
[versions]
agp = "9.1.0"
kotlin = "2.3.20"
compose = "1.10.2"
kotlinx-coroutines = "1.10.2"
agp = "9.1.1"
kotlin = "2.3.21"
compose = "1.10.3"
kotlinx-coroutines = "1.11.0"
android-compileSdk = "36"
android-minSdk = "24"
android-targetSdk = "36"
androidx-activityCompose = "1.13.0"
androidx-appcompat = "1.7.1"
androidx-core-ktx = "1.18.0"
androidx-lifecycle = "2.9.6"
androidx-camera = "1.5.3"
dokka = "2.1.0"
androidx-lifecycle = "2.10.0"
androidx-camera = "1.6.1"
dokka = "2.2.0"
klint = "14.2.0"
maven-publish = "0.36.0"
mlkitBarcodeScanning-android = "17.3.0"
moko = "0.20.1"
zxing = "3.5.4"
material3 = "1.10.0-alpha05"
material-icons = "1.7.3"
javacv = "1.5.10"
opencv = "4.9.0-1.5.10"
imagepickerkmp = "1.0.41"
javacv = "1.5.13"
opencv = "4.13.0-1.5.13"

[libraries]
androidx-activityCompose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
Expand Down Expand Up @@ -53,6 +54,7 @@ moko-permissions-compose = { module = "dev.icerock.moko:permissions-compose", ve
ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose" }
zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" }
zxing-javase = { module = "com.google.zxing:javase", version.ref = "zxing" }
imagepickerkmp = { module = "io.github.ismoy:imagepickerkmp", version.ref = "imagepickerkmp" }
javacv = { module = "org.bytedeco:javacv", version.ref = "javacv" }
opencv-platform = { module = "org.bytedeco:opencv-platform", version.ref = "opencv" }

Expand Down
8 changes: 8 additions & 0 deletions kotlin-js-store/wasm/yarn.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1


"@js-joda/core@3.2.0":
version "3.2.0"
resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package org.ncgroup.kscan

import android.graphics.BitmapFactory
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.common.InputImage

actual fun scanImage(
imageBytes: ByteArray,
codeTypes: List<BarcodeFormat>,
filter: (Barcode) -> Boolean,
result: (BarcodeResult) -> Unit,
) {
val bitmap = try {
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
} catch (e: Exception) {
result(BarcodeResult.OnFailed(Exception("Failed to decode image: ${e.message}")))
return
}

if (bitmap == null) {
result(BarcodeResult.OnFailed(Exception("Failed to decode image bytes")))
return
}

val inputImage = InputImage.fromBitmap(bitmap, 0)

val options = BarcodeScannerOptions.Builder()
.setBarcodeFormats(BarcodeFormatMapper.toMlKitFormats(codeTypes))
.build()

val scanner = BarcodeScanning.getClient(options)

scanner.process(inputImage)
.addOnSuccessListener { barcodes ->
val hasAllFormats = codeTypes.isEmpty() || codeTypes.contains(BarcodeFormat.FORMAT_ALL_FORMATS)

val matchingBarcode = barcodes.firstOrNull { mlKitBarcode ->
val isFormatMatch = if (hasAllFormats) {
BarcodeFormatMapper.isKnownFormat(mlKitBarcode.format)
} else {
val appFormat = BarcodeFormatMapper.toAppFormat(mlKitBarcode.format)
codeTypes.contains(appFormat)
}

if (!isFormatMatch) return@firstOrNull false

val displayValue = mlKitBarcode.displayValue ?: return@firstOrNull false
val rawBytes = mlKitBarcode.rawBytes ?: displayValue.encodeToByteArray()
val appFormat = BarcodeFormatMapper.toAppFormat(mlKitBarcode.format)

val barcode = Barcode(
data = displayValue,
format = appFormat.toString(),
rawBytes = rawBytes,
)

filter(barcode)
}

if (matchingBarcode != null) {
val displayValue = matchingBarcode.displayValue!!
val rawBytes = matchingBarcode.rawBytes ?: displayValue.encodeToByteArray()
val appFormat = BarcodeFormatMapper.toAppFormat(matchingBarcode.format)

val barcode = Barcode(
data = displayValue,
format = appFormat.toString(),
rawBytes = rawBytes,
)
result(BarcodeResult.OnSuccess(barcode))
} else {
result(BarcodeResult.OnFailed(Exception("No barcode found in image")))
}
}
.addOnFailureListener { exception ->
result(BarcodeResult.OnFailed(exception))
}
}
34 changes: 34 additions & 0 deletions kscan/src/commonMain/kotlin/org/ncgroup/kscan/ImageScanner.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package org.ncgroup.kscan

/**
* Scans a barcode from an image provided as a byte array.
*
* This function allows scanning barcodes from static images (e.g., from gallery,
* screenshots, or downloaded images) rather than from a live camera feed.
*
* @param imageBytes The image data as a byte array (e.g., PNG, JPEG).
* @param codeTypes The barcode formats to scan for. Defaults to all formats.
* @param filter Optional filter to accept or reject detected barcodes.
* @param result Callback invoked with the scan result.
*
* Example usage:
* ```kotlin
* val imageBytes = selectedImage.readBytes()
* scanImage(
* imageBytes = imageBytes,
* codeTypes = listOf(BarcodeFormat.FORMAT_QR_CODE)
* ) { result ->
* when (result) {
* is BarcodeResult.OnSuccess -> println(result.barcode.data)
* is BarcodeResult.OnFailed -> println(result.exception.message)
* is BarcodeResult.OnCanceled -> { /* not applicable */ }
* }
* }
* ```
*/
expect fun scanImage(
imageBytes: ByteArray,
codeTypes: List<BarcodeFormat> = listOf(BarcodeFormat.FORMAT_ALL_FORMATS),
filter: (Barcode) -> Boolean = { true },
result: (BarcodeResult) -> Unit,
)
165 changes: 165 additions & 0 deletions kscan/src/iosMain/kotlin/org/ncgroup/kscan/ImageScanner.ios.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package org.ncgroup.kscan

import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
import platform.CoreGraphics.CGImageRef
import platform.Foundation.NSData
import platform.Foundation.NSError
import platform.Foundation.create
import platform.UIKit.UIImage
import platform.Vision.VNBarcodeObservation
import platform.Vision.VNBarcodeSymbologyAztec
import platform.Vision.VNBarcodeSymbologyCode128
import platform.Vision.VNBarcodeSymbologyCode39
import platform.Vision.VNBarcodeSymbologyCode93
import platform.Vision.VNBarcodeSymbologyDataMatrix
import platform.Vision.VNBarcodeSymbologyEAN13
import platform.Vision.VNBarcodeSymbologyEAN8
import platform.Vision.VNBarcodeSymbologyPDF417
import platform.Vision.VNBarcodeSymbologyQR
import platform.Vision.VNBarcodeSymbologyUPCE
import platform.Vision.VNDetectBarcodesRequest
import platform.Vision.VNImageRequestHandler
import platform.Vision.VNRequest

@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
actual fun scanImage(
imageBytes: ByteArray,
codeTypes: List<BarcodeFormat>,
filter: (Barcode) -> Boolean,
result: (BarcodeResult) -> Unit,
) {
val nsData = imageBytes.usePinned { pinned ->
NSData.create(bytes = pinned.addressOf(0), length = imageBytes.size.toULong())
}

val uiImage = UIImage.imageWithData(nsData)
if (uiImage == null) {
result(BarcodeResult.OnFailed(Exception("Failed to decode image bytes")))
return
}

val cgImage: CGImageRef = uiImage.CGImage ?: run {
result(BarcodeResult.OnFailed(Exception("Failed to get CGImage from UIImage")))
return
}

val handler = VNImageRequestHandler(cgImage, mapOf<Any?, Any?>())

val request = VNDetectBarcodesRequest { request: VNRequest?, error: NSError? ->
if (error != null) {
result(BarcodeResult.OnFailed(Exception(error.localizedDescription)))
return@VNDetectBarcodesRequest
}

val observations = request?.results?.filterIsInstance<VNBarcodeObservation>()
if (observations.isNullOrEmpty()) {
result(BarcodeResult.OnFailed(Exception("No barcode found in image")))
return@VNDetectBarcodesRequest
}

val hasAllFormats = codeTypes.isEmpty() || codeTypes.contains(BarcodeFormat.FORMAT_ALL_FORMATS)

val matchingObservation = observations.firstOrNull { observation ->
val symbology = observation.symbology ?: return@firstOrNull false
val appFormat = symbologyToAppFormat(symbology)

val isFormatMatch = if (hasAllFormats) {
appFormat != BarcodeFormat.TYPE_UNKNOWN
} else {
codeTypes.contains(appFormat)
}

if (!isFormatMatch) return@firstOrNull false

val payloadString = observation.payloadStringValue ?: return@firstOrNull false
val rawBytes = payloadString.encodeToByteArray()

val barcode = Barcode(
data = payloadString,
format = appFormat.toString(),
rawBytes = rawBytes,
)

filter(barcode)
}

if (matchingObservation != null) {
val payloadString = matchingObservation.payloadStringValue!!
val appFormat = symbologyToAppFormat(matchingObservation.symbology ?: "")
val rawBytes = payloadString.encodeToByteArray()

val barcode = Barcode(
data = payloadString,
format = appFormat.toString(),
rawBytes = rawBytes,
)
result(BarcodeResult.OnSuccess(barcode))
} else {
result(BarcodeResult.OnFailed(Exception("No matching barcode found in image")))
}
}

// Set symbologies to detect
val symbologies = toVisionSymbologies(codeTypes)
if (symbologies.isNotEmpty()) {
request.setSymbologies(symbologies)
}

try {
handler.performRequests(listOf(request), null)
} catch (e: Exception) {
result(BarcodeResult.OnFailed(Exception("Failed to perform barcode detection: ${e.message}")))
}
}

private fun symbologyToAppFormat(symbology: String): BarcodeFormat {
return when (symbology) {
VNBarcodeSymbologyQR -> BarcodeFormat.FORMAT_QR_CODE
VNBarcodeSymbologyEAN13 -> BarcodeFormat.FORMAT_EAN_13
VNBarcodeSymbologyEAN8 -> BarcodeFormat.FORMAT_EAN_8
VNBarcodeSymbologyCode128 -> BarcodeFormat.FORMAT_CODE_128
VNBarcodeSymbologyCode39 -> BarcodeFormat.FORMAT_CODE_39
VNBarcodeSymbologyCode93 -> BarcodeFormat.FORMAT_CODE_93
VNBarcodeSymbologyUPCE -> BarcodeFormat.FORMAT_UPC_E
VNBarcodeSymbologyPDF417 -> BarcodeFormat.FORMAT_PDF417
VNBarcodeSymbologyAztec -> BarcodeFormat.FORMAT_AZTEC
VNBarcodeSymbologyDataMatrix -> BarcodeFormat.FORMAT_DATA_MATRIX
else -> BarcodeFormat.TYPE_UNKNOWN
}
}

private fun toVisionSymbologies(appFormats: List<BarcodeFormat>): List<String> {
if (appFormats.isEmpty() || appFormats.contains(BarcodeFormat.FORMAT_ALL_FORMATS)) {
return listOfNotNull(
VNBarcodeSymbologyQR,
VNBarcodeSymbologyEAN13,
VNBarcodeSymbologyEAN8,
VNBarcodeSymbologyCode128,
VNBarcodeSymbologyCode39,
VNBarcodeSymbologyCode93,
VNBarcodeSymbologyUPCE,
VNBarcodeSymbologyPDF417,
VNBarcodeSymbologyAztec,
VNBarcodeSymbologyDataMatrix,
)
}

return appFormats.mapNotNull { format ->
when (format) {
BarcodeFormat.FORMAT_QR_CODE -> VNBarcodeSymbologyQR
BarcodeFormat.FORMAT_EAN_13 -> VNBarcodeSymbologyEAN13
BarcodeFormat.FORMAT_EAN_8 -> VNBarcodeSymbologyEAN8
BarcodeFormat.FORMAT_CODE_128 -> VNBarcodeSymbologyCode128
BarcodeFormat.FORMAT_CODE_39 -> VNBarcodeSymbologyCode39
BarcodeFormat.FORMAT_CODE_93 -> VNBarcodeSymbologyCode93
BarcodeFormat.FORMAT_UPC_E -> VNBarcodeSymbologyUPCE
BarcodeFormat.FORMAT_PDF417 -> VNBarcodeSymbologyPDF417
BarcodeFormat.FORMAT_AZTEC -> VNBarcodeSymbologyAztec
BarcodeFormat.FORMAT_DATA_MATRIX -> VNBarcodeSymbologyDataMatrix
else -> null
}
}
}
Loading
Loading