From e720a393ff98791297942e4448f3a949f7a24e33 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:47:54 -0600 Subject: [PATCH] feat(build): Implement flavor-specific barcode scanning and build improvements (#4611) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- app/build.gradle.kts | 10 + .../main/kotlin/AnalyticsConventionPlugin.kt | 8 +- core/barcode/build.gradle.kts | 6 +- .../core/barcode/BarcodeScannerProvider.kt | 256 ++++++++++++++++++ .../core/barcode/BarcodeScannerProvider.kt | 0 5 files changed, 272 insertions(+), 8 deletions(-) create mode 100644 core/barcode/src/fdroid/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt rename core/barcode/src/{main => google}/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt (100%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 96b53b543..94f153c7c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -128,6 +128,16 @@ configure { } ndk { abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") } + // Enable ABI splits to generate smaller APKs per architecture for F-Droid/IzzyOnDroid + splits { + abi { + isEnable = true + reset() + include("armeabi-v7a", "arm64-v8a", "x86", "x86_64") + isUniversalApk = true + } + } + dependenciesInfo { // Disables dependency metadata when building APKs (for IzzyOnDroid/F-Droid) includeInApk = false diff --git a/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt index 0d3249f44..a5697cf9d 100644 --- a/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt @@ -50,7 +50,7 @@ class AnalyticsConventionPlugin : Plugin { // This avoids iterating all tasks with a generic filter and improves configuration performance. plugins.withId("com.google.gms.google-services") { tasks.configureEach { - if (name.contains("fdroid", ignoreCase = true) && name.contains("GoogleServices")) { + if (name.contains("fdroid", ignoreCase = true)) { enabled = false } } @@ -58,9 +58,7 @@ class AnalyticsConventionPlugin : Plugin { plugins.withId("com.google.firebase.crashlytics") { tasks.configureEach { - if (name.contains("fdroid", ignoreCase = true) && - (name.contains("Crashlytics", ignoreCase = true) || name.contains("buildId", ignoreCase = true)) - ) { + if (name.contains("fdroid", ignoreCase = true)) { enabled = false } } @@ -68,7 +66,7 @@ class AnalyticsConventionPlugin : Plugin { plugins.withId("com.datadoghq.dd-sdk-android-gradle-plugin") { tasks.configureEach { - if (name.contains("fdroid", ignoreCase = true) && name.contains("Datadog", ignoreCase = true)) { + if (name.contains("fdroid", ignoreCase = true)) { enabled = false } } diff --git a/core/barcode/build.gradle.kts b/core/barcode/build.gradle.kts index 2416e6022..46ece16e7 100644 --- a/core/barcode/build.gradle.kts +++ b/core/barcode/build.gradle.kts @@ -39,9 +39,9 @@ dependencies { implementation(libs.accompanist.permissions) implementation(libs.kermit) - // Consistently use ML Kit's bundled barcode scanner across all flavors - // to avoid the GMS-dependent "google's silly overlay". - implementation(libs.mlkit.barcode.scanning) + // ML Kit is used for the Google flavor, while ZXing is used for F-Droid to avoid GMS dependencies. + googleImplementation(libs.mlkit.barcode.scanning) + fdroidImplementation(libs.zxing.core) implementation(libs.androidx.camera.core) implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.lifecycle) diff --git a/core/barcode/src/fdroid/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt b/core/barcode/src/fdroid/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt new file mode 100644 index 000000000..6761e997a --- /dev/null +++ b/core/barcode/src/fdroid/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:OptIn(ExperimentalPermissionsApi::class) + +package org.meshtastic.core.barcode + +import android.Manifest +import androidx.camera.compose.CameraXViewfinder +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.core.SurfaceRequest +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import co.touchlab.kermit.Logger +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.zxing.BinaryBitmap +import com.google.zxing.MultiFormatReader +import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.common.HybridBinarizer +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.close +import java.nio.ByteBuffer +import java.util.concurrent.Executors + +@Composable +fun rememberBarcodeScanner(onResult: (String?) -> Unit): BarcodeScanner { + var showDialog by remember { mutableStateOf(false) } + var pendingScan by remember { mutableStateOf(false) } + val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA) + + LaunchedEffect(cameraPermissionState.status.isGranted) { + if (cameraPermissionState.status.isGranted && pendingScan) { + showDialog = true + pendingScan = false + } + } + + if (showDialog) { + BarcodeScannerDialog( + onResult = { + showDialog = false + onResult(it) + }, + onDismiss = { + showDialog = false + onResult(null) + }, + ) + } + + return remember { + object : BarcodeScanner { + override fun startScan() { + if (cameraPermissionState.status.isGranted) { + showDialog = true + } else { + pendingScan = true + cameraPermissionState.launchPermissionRequest() + } + } + } + } +} + +@Composable +private fun BarcodeScannerDialog(onResult: (String?) -> Unit, onDismiss: () -> Unit) { + var isCameraReady by remember { mutableStateOf(false) } + + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Box(modifier = Modifier.fillMaxSize()) { + ScannerView(onResult = onResult, onCameraReady = { isCameraReady = it }) + if (isCameraReady) { + ScannerReticule() + } + IconButton(onClick = onDismiss, modifier = Modifier.align(Alignment.TopStart).padding(16.dp)) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(Res.string.close), + tint = Color.White, + ) + } + } + } +} + +@Suppress("MagicNumber") +@Composable +private fun ScannerReticule() { + Canvas(modifier = Modifier.fillMaxSize()) { + val width = size.width + val height = size.height + val reticleSize = width.coerceAtMost(height) * 0.7f + val left = (width - reticleSize) / 2 + val top = (height - reticleSize) / 2 + val rect = Rect(left, top, left + reticleSize, top + reticleSize) + + // Draw semi-transparent background with a hole + clipPath(Path().apply { addRect(rect) }, clipOp = ClipOp.Difference) { + drawRect(Color.Black.copy(alpha = 0.6f)) + } + + // Draw reticle corners + val strokeWidth = 3.dp.toPx() + val cornerLength = 40.dp.toPx() + val color = Color.White + + // Corners + val path = + Path().apply { + // Top Left + moveTo(left, top + cornerLength) + lineTo(left, top) + lineTo(left + cornerLength, top) + + // Top Right + moveTo(left + reticleSize - cornerLength, top) + lineTo(left + reticleSize, top) + lineTo(left + reticleSize, top + cornerLength) + + // Bottom Right + moveTo(left + reticleSize, top + reticleSize - cornerLength) + lineTo(left + reticleSize, top + reticleSize) + lineTo(left + reticleSize - cornerLength, top + reticleSize) + + // Bottom Left + moveTo(left + cornerLength, top + reticleSize) + lineTo(left, top + reticleSize) + lineTo(left, top + reticleSize - cornerLength) + } + + drawPath(path, color, style = Stroke(strokeWidth)) + } +} + +@Suppress("LongMethod") +@androidx.annotation.OptIn(ExperimentalGetImage::class) +@Composable +private fun ScannerView(onResult: (String?) -> Unit, onCameraReady: (Boolean) -> Unit) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val cameraExecutor = remember { Executors.newSingleThreadExecutor() } + var surfaceRequest by remember { mutableStateOf(null) } + + val barcodeScanner = remember { MultiFormatReader() } + + DisposableEffect(Unit) { onDispose { cameraExecutor.shutdown() } } + + LaunchedEffect(Unit) { + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + cameraProviderFuture.addListener( + { + val cameraProvider = cameraProviderFuture.get() + + val preview = Preview.Builder().build() + preview.setSurfaceProvider { request -> + surfaceRequest = request + onCameraReady(true) + } + + val imageAnalysis = + ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { analysis -> + analysis.setAnalyzer(cameraExecutor) { imageProxy -> + try { + val buffer: ByteBuffer = imageProxy.planes[0].buffer + val data = ByteArray(buffer.remaining()) + buffer.get(data) + + val width = imageProxy.width + val height = imageProxy.height + + val source = + PlanarYUVLuminanceSource(data, width, height, 0, 0, width, height, false) + val binaryBitmap = BinaryBitmap(HybridBinarizer(source)) + + val result = barcodeScanner.decodeWithState(binaryBitmap) + result.text?.let { onResult(it) } + } catch (e: Exception) { + // Ignore decoding errors + } finally { + imageProxy.close() + } + } + } + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + imageAnalysis, + ) + } catch (exc: IllegalStateException) { + Logger.e(exc) { "Use case binding failed" } + } catch (exc: IllegalArgumentException) { + Logger.e(exc) { "Use case binding failed" } + } catch (exc: UnsupportedOperationException) { + Logger.e(exc) { "Use case binding failed" } + } + }, + ContextCompat.getMainExecutor(context), + ) + } + + surfaceRequest?.let { CameraXViewfinder(surfaceRequest = it, modifier = Modifier.fillMaxSize()) } +} diff --git a/core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt b/core/barcode/src/google/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt similarity index 100% rename from core/barcode/src/main/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt rename to core/barcode/src/google/kotlin/org/meshtastic/core/barcode/BarcodeScannerProvider.kt