Streamline Your Scanning with a Custom Barcode Solution
Introduction
Are you tired of the limitations and frustrations that come with third-party barcode scanners? It’s time to consider a custom solution tailored to your specific needs. In the world of barcode scanning, a one-size-fits-all approach often falls short, leading to compatibility issues, performance hiccups, and integration headaches. That’s where owning a custom, lightweight barcode scanner comes into play.
By developing your own barcode scanning solution, you gain unparalleled control over functionality, efficiency, and integration. Unlike third-party SDKs, which can come with their own set of challenges such as inconsistent updates, lack of support, and compatibility issues, a custom-built scanner is designed with your unique requirements in mind. This means you can optimize performance, enhance user experience, and avoid the pitfalls of generic solutions.
In this discussion, I’ll dive into why a custom-built, lightweight barcode scanner is not just a luxury but a necessity for those who demand reliability and precision in their scanning operations. Let’s explore how this approach can elevate your barcode scanning experience and overcome the common issues faced with third-party SDKs.
Code Implementation
Let’s start setting up our dependencies
App Level Gradle
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
id("kotlin-kapt")
id("com.google.dagger.hilt.android")
}
android {
namespace = "com.ovais.barcodescanner"
compileSdk = 34
defaultConfig {
applicationId = "com.ovais.barcodescanner"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
kapt {
correctErrorTypes = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.video)
implementation(libs.androidx.camera.view)
implementation(libs.androidx.camera.extensions)
implementation(libs.play.services.mlkit.barcode.scanning)
implementation(libs.hilt.android)
kapt(libs.hilt.android.compiler)
implementation(libs.androidx.hilt.navigation.compose)
}
Project Level Gradle
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.jetbrains.kotlin.android) apply false
id("com.google.dagger.hilt.android") version "2.48" apply false
}
Version Catalog File
[versions]
agp = "8.4.1"
cameraCore = "1.3.4"
hiltAndroid = "2.48"
hiltNavigationCompose = "1.2.0"
kotlin = "1.9.0"
coreKtx = "1.13.1"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
lifecycleRuntimeKtx = "2.8.4"
activityCompose = "1.9.1"
composeBom = "2023.08.00"
appcompat = "1.7.0"
material = "1.12.0"
playServicesMlkitBarcodeScanning = "18.3.1"
[libraries]
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraCore" }
androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "cameraCore" }
androidx-camera-extensions = { module = "androidx.camera:camera-extensions", version.ref = "cameraCore" }
androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "cameraCore" }
androidx-camera-video = { module = "androidx.camera:camera-video", version.ref = "cameraCore" }
androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "cameraCore" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hiltAndroid" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hiltAndroid" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
play-services-mlkit-barcode-scanning = { module = "com.google.android.gms:play-services-mlkit-barcode-scanning", version.ref = "playServicesMlkitBarcodeScanning" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
As we are following Clean Architecture with Single Activity Architecture
Let’s see our Manifest and request permission for Camera
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature android:name="android.hardware.camera" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:name=".core.app.BarcodeScanner"
android:supportsRtl="true"
android:theme="@style/Theme.BarcodeScanner"
tools:targetApi="31">
<activity
android:name=".core.app.BarcodeScannerActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.BarcodeScanner">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Let’s see our application and activity file
@HiltAndroidApp
class BarcodeScanner : Application()
@AndroidEntryPoint
class BarcodeScannerActivity : ComponentActivity() {
@Inject
lateinit var toastManager: ToastManager
@Inject
lateinit var permissionManager: PermissionManager
private lateinit var requestPermissionLauncher: ActivityResultLauncher<String>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
if (isGranted) {
setContent {
BarcodeScannerTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
CameraPreviewScreen(
modifier = Modifier.fillMaxSize(),
onError = {
toastManager.show(it)
},
onResult = {
// do something with the barcode
}
)
}
}
}
} else {
toastManager.show("Camera permission is required to use this feature")
}
}
when {
permissionManager.isPermissionGranted(Manifest.permission.CAMERA) -> {
setContent {
BarcodeScannerTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
CameraPreviewScreen(
modifier = Modifier.fillMaxSize(),
onError = {
toastManager.show(it)
},
onResult = {
// do something with the barcode
}
)
}
}
}
}
else -> {
requestPermissionLauncher.launch(android.Manifest.permission.CAMERA)
}
}
}
}
Lets setup our Toast Manager and Permission Manager with Hilt
fun interface PermissionManager {
fun isPermissionGranted(permission: String): Boolean
}
class PermissionManagerImpl @Inject constructor(
@ApplicationContext private val context: Context
) : PermissionManager {
override fun isPermissionGranted(permission: String): Boolean =
ContextCompat.checkSelfPermission(
context,
permission
) == PackageManager.PERMISSION_GRANTED
}
fun interface ToastManager {
fun show(message: String)
}
class ToastManagerImpl @Inject constructor(
@ApplicationContext private val context: Context
) : ToastManager {
override fun show(message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
Let’s add scopes for them
@InstallIn(ActivityComponent::class)
@Module
interface ActivityModule {
@Binds
fun bindToastManager(
default: ToastManagerImpl
): ToastManager
@Binds
fun bindPermissionManager(
default: PermissionManagerImpl
): PermissionManager
}
Let’s see what are scopes and what are their lifecycle
The Hilt module ActivityModule is annotated with @InstallIn(ActivityComponent.class) because you want Hilt to inject that dependency into BarcodeScannerActivity. This annotation means that all of the dependencies in AnalyticsModule are available in all of the app’s activities.
Let’s start building our view
typealias CameraError = String
typealias ScannedResult = String
private const val CAMERA_PREVIEW_ERROR_TAG = "CAMERA_PREVIEW_ERROR_TAG"
@Composable
fun CameraPreviewScreen(
modifier: Modifier = Modifier,
cameraType: CameraSelector = CameraSelector.DEFAULT_BACK_CAMERA,
viewModel: BarcodeScannerViewModel = hiltViewModel(),
onError: (CameraError) -> Unit,
onResult: (ScannedResult) -> Unit
) {
val scannedBarcodeResult by viewModel.barcodeResult.collectAsState()
LaunchedEffect(key1 = true) {
when (scannedBarcodeResult) {
is BarcodeResult.Success -> {
onResult((scannedBarcodeResult as BarcodeResult.Success).result)
}
is BarcodeResult.Failure -> {
onError((scannedBarcodeResult as BarcodeResult.Failure).message)
}
else -> Unit
}
}
val scanner = BarcodeScanning.getClient(viewModel.barcodeOptions)
val analysisUseCase = ImageAnalysis.Builder()
.build().apply {
setAnalyzer(Executors.newSingleThreadExecutor()) { imageProxy ->
viewModel.processImageProxy(scanner, imageProxy)
}
}
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
AndroidView(
factory = {
PreviewView(context).apply {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
val preview = Preview.Builder().build()
preview.setSurfaceProvider(surfaceProvider)
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraType,
preview,
analysisUseCase
)
} catch (e: Exception) {
Log.e(CAMERA_PREVIEW_ERROR_TAG, e.stackTraceToString())
onError(e.message.toString())
}
}, ContextCompat.getMainExecutor(context))
}
},
modifier = modifier
)
}
Our ViewModel Class
@HiltViewModel
class BarcodeScannerViewModel @Inject constructor(
private val barcodeOptionsUseCase: BarcodeOptionsUseCase,
private val processBarcodeResultUseCase: ProcessBarcodeResultUseCase
) : ViewModel() {
private val _barcodeOptions by lazy {
barcodeOptionsUseCase()
}
val barcodeOptions: BarcodeScannerOptions
get() = _barcodeOptions
private val _barcodeResult by lazy { MutableStateFlow<BarcodeResult>(BarcodeResult.Idle) }
val barcodeResult: StateFlow<BarcodeResult>
get() = _barcodeResult
fun processImageProxy(scanner: BarcodeScanner, imageProxy: ImageProxy) {
viewModelScope.launch {
_barcodeResult.update {
processBarcodeResultUseCase(scanner, imageProxy)
}
}
}
}
Our Domain Layer with Usecases
fun interface BarcodeOptionsUseCase {
operator fun invoke(): BarcodeScannerOptions
}
class BarcodeOptionsUseCaseImpl @Inject constructor() : BarcodeOptionsUseCase {
override fun invoke(): BarcodeScannerOptions {
return BarcodeScannerOptions.Builder().setBarcodeFormats(
Barcode.FORMAT_CODE_128,
Barcode.FORMAT_CODE_39,
Barcode.FORMAT_CODE_93,
Barcode.FORMAT_EAN_8,
Barcode.FORMAT_EAN_13,
Barcode.FORMAT_QR_CODE,
Barcode.FORMAT_UPC_A,
Barcode.FORMAT_UPC_E,
Barcode.FORMAT_PDF417
).build()
}
}
fun interface ProcessBarcodeResultUseCase {
suspend operator fun invoke(scanner: BarcodeScanner, imageProxy: ImageProxy): BarcodeResult
}
class ProcessBarcodeResultUseCaseImpl @Inject constructor() : ProcessBarcodeResultUseCase {
@OptIn(ExperimentalGetImage::class)
override suspend fun invoke(scanner: BarcodeScanner, imageProxy: ImageProxy): BarcodeResult {
return suspendCancellableCoroutine { continuation ->
imageProxy.image?.let { image ->
val inputImage =
InputImage.fromMediaImage(
image,
imageProxy.imageInfo.rotationDegrees
)
scanner.process(inputImage).addOnCompleteListener { task ->
task.result?.let { barcodeList ->
barcodeList.getOrNull(0)?.rawValue?.let {
imageProxy.image?.close()
imageProxy.close()
continuation.resume(BarcodeResult.Success(it))
} ?: run {
continuation.resume(
BarcodeResult.Failure("Barcode list is empty!")
)
}
} ?: run {
continuation.resume(BarcodeResult.Failure(task.exception?.message.toString()))
}
}
}
}
}
}
Our result class
sealed interface BarcodeResult {
data class Failure(val message: String) : BarcodeResult
data class Success(val result: String) : BarcodeResult
data object Idle : BarcodeResult
}
Conclusion
In conclusion, embracing a custom-built, lightweight barcode scanner tailored to your specific needs can significantly enhance your operations. Take, for example, a simple app designed to scan barcodes exclusively through your device’s camera. This streamlined approach not only simplifies the scanning process but also eliminates the complexities associated with third-party SDKs.
By focusing on a solution that leverages the native camera functionality, you achieve a more intuitive and user-friendly experience. This method reduces the risk of compatibility issues and ensures a smoother integration with your existing systems. Plus, with a custom app, you can optimize performance, tailor features, and maintain control over updates and support.
In essence, a dedicated barcode scanning app using your camera provides a straightforward yet powerful tool for your scanning needs. It’s a perfect example of how a well-designed, lightweight solution can offer superior performance and reliability compared to generic, third-party options.
Feel free to connect with me on LinkedIn(@syedovaisakhtar) and follow my projects on GitHub(@syedovaiss) to stay updated and engage with my work.