diff --git a/packages/pluggableWidgets/barcode-scanner-native/CHANGELOG.md b/packages/pluggableWidgets/barcode-scanner-native/CHANGELOG.md index dad63beb2..7856cbd28 100644 --- a/packages/pluggableWidgets/barcode-scanner-native/CHANGELOG.md +++ b/packages/pluggableWidgets/barcode-scanner-native/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We fixed the barcode with patterned backgrounds not getting scanned correctly on Android. +- We Added a property `scan delay` which will delays the scanning process by mentioned seconds, so end user will get an time to place the scanner over QR code correctly. + ## [4.5.0] - 2026-9-2 - We fixed the barcode mask not visible issue and selection logic of QR code. diff --git a/packages/pluggableWidgets/barcode-scanner-native/package.json b/packages/pluggableWidgets/barcode-scanner-native/package.json index 20b5cbf1a..8ce7bdda9 100644 --- a/packages/pluggableWidgets/barcode-scanner-native/package.json +++ b/packages/pluggableWidgets/barcode-scanner-native/package.json @@ -1,7 +1,7 @@ { "name": "barcode-scanner-native", "widgetName": "BarcodeScanner", - "version": "4.5.0", + "version": "4.5.1", "license": "Apache-2.0", "repository": { "type": "git", diff --git a/packages/pluggableWidgets/barcode-scanner-native/src/BarcodeScanner.tsx b/packages/pluggableWidgets/barcode-scanner-native/src/BarcodeScanner.tsx index a48767b4b..0de2639eb 100644 --- a/packages/pluggableWidgets/barcode-scanner-native/src/BarcodeScanner.tsx +++ b/packages/pluggableWidgets/barcode-scanner-native/src/BarcodeScanner.tsx @@ -1,6 +1,6 @@ import { flattenStyles } from "@mendix/piw-native-utils-internal"; import { ValueStatus } from "mendix"; -import { ReactElement, useCallback, useMemo, useRef, useState } from "react"; +import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { View, LayoutChangeEvent, Platform } from "react-native"; import { Camera, useCodeScanner, Code, useCameraDevice, CodeScannerFrame } from "react-native-vision-camera"; import BarcodeMask from "./components/BarcodeMask"; @@ -11,6 +11,11 @@ import { executeAction } from "@mendix/piw-utils-internal"; export type Props = BarcodeScannerProps; +// ML Kit on Android can misread 1D barcodes (e.g. Code 39) on busy backgrounds for a single frame, +// so a value is only accepted after it is detected on this many consecutive reads. +const REQUIRED_CONSECUTIVE_READS_ANDROID = 3; +const SCAN_LOCK_DURATION_MS = 2000; + type CodePositionInfo = { isWithinMask: boolean; distanceToMaskCenterSquared: number; @@ -143,6 +148,32 @@ export function BarcodeScanner(props: Props): ReactElement { // Ref to track the lock state const isLockedRef = useRef(false); + // Timer that releases the lock, kept so it can be cleared when the widget unmounts + const lockTimeoutRef = useRef | null>(null); + + // Time from which detected codes may be accepted, null until the camera is initialized + const scanStartTimeRef = useRef(null); + + // Last detected value and how many consecutive reads returned it + const lastReadValueRef = useRef(null); + const consecutiveReadsRef = useRef(0); + + // Starts a new scan: codes are accepted again after the configured scan delay, + // and the consecutive read count starts from zero + const resetScanState = useCallback(() => { + scanStartTimeRef.current = Date.now() + props.scanDelay * 1000; + lastReadValueRef.current = null; + consecutiveReadsRef.current = 0; + }, [props.scanDelay]); + + // Clear a pending lock timer when the widget unmounts + useEffect(() => { + return () => { + if (lockTimeoutRef.current) { + clearTimeout(lockTimeoutRef.current); + } + }; + }, []); const [cameraViewDimensions, setCameraViewDimensions] = useState<{ width: number; height: number } | null>(null); @@ -220,8 +251,13 @@ export function BarcodeScanner(props: Props): ReactElement { const onCodeScanned = useCallback( (codes: Code[], frame: CodeScannerFrame) => { - // Block if still in cooldown - if (isLockedRef.current) { + // Block if still in cooldown or the previous detection is still being handled + if (isLockedRef.current || props.onDetect?.isExecuting) { + return; + } + + // Block until the camera is initialized and the scan delay has passed + if (scanStartTimeRef.current === null || Date.now() < scanStartTimeRef.current) { return; } @@ -251,19 +287,35 @@ export function BarcodeScanner(props: Props): ReactElement { const { value } = selectedCode; + // Android only: accept the value after it was read on several consecutive reads, + // a different value restarts the count + if (Platform.OS === "android") { + if (value === lastReadValueRef.current) { + consecutiveReadsRef.current += 1; + } else { + lastReadValueRef.current = value; + consecutiveReadsRef.current = 1; + } + + if (consecutiveReadsRef.current < REQUIRED_CONSECUTIVE_READS_ANDROID) { + return; + } + } + if (value !== props.barcode.value) { props.barcode.setValue(value); } executeAction(props.onDetect); - // Lock further scans for 2 seconds + // Lock further scans for 2 seconds, then start a new scan (including the scan delay) isLockedRef.current = true; - setTimeout(() => { + lockTimeoutRef.current = setTimeout(() => { + resetScanState(); isLockedRef.current = false; - }, 2000); + }, SCAN_LOCK_DURATION_MS); }, - [props.barcode, props.onDetect, getCodePositionInfo] + [props.barcode, props.onDetect, getCodePositionInfo, resetScanState] ); const codeScanner = useCodeScanner({ @@ -285,6 +337,11 @@ export function BarcodeScanner(props: Props): ReactElement { onCodeScanned }); + // The camera is ready, start the scan delay from now + const handleCameraInitialized = useCallback(() => { + resetScanState(); + }, [resetScanState]); + const handleCameraLayout = useCallback((event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; setCameraViewDimensions({ width, height }); @@ -301,6 +358,7 @@ export function BarcodeScanner(props: Props): ReactElement { isActive device={device} codeScanner={codeScanner} + onInitialized={handleCameraInitialized} onLayout={handleCameraLayout} /> {props.showMask && ( diff --git a/packages/pluggableWidgets/barcode-scanner-native/src/BarcodeScanner.xml b/packages/pluggableWidgets/barcode-scanner-native/src/BarcodeScanner.xml index b8f87cf56..0ca0c616e 100644 --- a/packages/pluggableWidgets/barcode-scanner-native/src/BarcodeScanner.xml +++ b/packages/pluggableWidgets/barcode-scanner-native/src/BarcodeScanner.xml @@ -1,4 +1,4 @@ - + Barcode scanner Scan barcode and QR code values. @@ -11,7 +11,7 @@ Barcode The attribute that will receive the scanned barcode value. - + @@ -25,14 +25,20 @@ + + + Scan delay (seconds) + Time after the camera opens before barcodes are detected. Gives the user time to place the barcode inside the mask. + + On detect - + - + diff --git a/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/BarcodeScanner.spec.tsx b/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/BarcodeScanner.spec.tsx index 256765ee9..170c16999 100644 --- a/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/BarcodeScanner.spec.tsx +++ b/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/BarcodeScanner.spec.tsx @@ -8,7 +8,13 @@ import { defaultBarcodeScannerStyle } from "../ui/styles"; let mockOnCodeScanned: ((codes: Array<{ value: string }>) => void) | undefined; jest.mock("react-native-vision-camera", () => ({ - Camera: ({ children, ...props }: any) => {children}, + Camera: ({ children, onInitialized, ...props }: any) => { + // Call onInitialized to simulate camera being ready + if (onInitialized) { + setTimeout(() => onInitialized(), 0); + } + return {children}; + }, useCameraDevice: () => "mock-device", useCodeScanner: (options: any) => { mockOnCodeScanned = options.onCodeScanned; @@ -28,7 +34,8 @@ describe("BarcodeScanner", () => { showMask: false, name: "barcode-scanner-test", style: [], - barcode: new EditableValueBuilder().build() + barcode: new EditableValueBuilder().build(), + scanDelay: 0 }; }); @@ -55,7 +62,12 @@ describe("BarcodeScanner", () => { const onDetectAction = actionValue(); render(); - // Simulate scanning + // Wait for camera initialization and advance time past scan delay + jest.runOnlyPendingTimers(); + + // Simulate scanning (3 consecutive reads required on Android) + mockOnCodeScanned?.([{ value: "value" }]); + mockOnCodeScanned?.([{ value: "value" }]); mockOnCodeScanned?.([{ value: "value" }]); jest.advanceTimersByTime(2000); @@ -64,6 +76,8 @@ describe("BarcodeScanner", () => { // Another scan mockOnCodeScanned?.([{ value: "value1" }]); + mockOnCodeScanned?.([{ value: "value1" }]); + mockOnCodeScanned?.([{ value: "value1" }]); jest.advanceTimersByTime(2000); expect(defaultProps.barcode.setValue).toHaveBeenCalledWith("value1"); diff --git a/packages/pluggableWidgets/barcode-scanner-native/src/package.xml b/packages/pluggableWidgets/barcode-scanner-native/src/package.xml index aa5591818..4f9cf8675 100644 --- a/packages/pluggableWidgets/barcode-scanner-native/src/package.xml +++ b/packages/pluggableWidgets/barcode-scanner-native/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/barcode-scanner-native/typings/BarcodeScannerProps.d.ts b/packages/pluggableWidgets/barcode-scanner-native/typings/BarcodeScannerProps.d.ts index d303b6d4b..a961fea4d 100644 --- a/packages/pluggableWidgets/barcode-scanner-native/typings/BarcodeScannerProps.d.ts +++ b/packages/pluggableWidgets/barcode-scanner-native/typings/BarcodeScannerProps.d.ts @@ -12,6 +12,7 @@ export interface BarcodeScannerProps