From 6a798ee81e36946c6cef53e8dace9a25f2092def Mon Sep 17 00:00:00 2001 From: Saurabh Chavan Date: Tue, 13 Oct 2026 18:37:07 +0530 Subject: [PATCH 01/15] fix: updated vision camera to v5 NATIVE_DEPENDENCY_APPROVED --- .../barcode-scanner-native/CHANGELOG.md | 7 + .../barcode-scanner-native/package.json | 7 +- .../src/BarcodeScanner.tsx | 228 ++++++++++++------ .../src/BarcodeScanner.xml | 14 +- .../src/__tests__/BarcodeScanner.spec.tsx | 40 ++- .../BarcodeScanner.spec.tsx.snap | 27 ++- .../barcode-scanner-native/src/package.xml | 2 +- .../typings/BarcodeScannerProps.d.ts | 2 + pnpm-lock.yaml | 80 +++++- 9 files changed, 299 insertions(+), 108 deletions(-) diff --git a/packages/pluggableWidgets/barcode-scanner-native/CHANGELOG.md b/packages/pluggableWidgets/barcode-scanner-native/CHANGELOG.md index dad63beb2..2ea755aec 100644 --- a/packages/pluggableWidgets/barcode-scanner-native/CHANGELOG.md +++ b/packages/pluggableWidgets/barcode-scanner-native/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- We updated react-native-vision-camera from version v4.7.3 to v5.2.3. +- We added react-native-vision-camera-barcode-scanner v5.2.3 for barcode scanning. +- 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..f8c252327 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.6.0", "license": "Apache-2.0", "repository": { "type": "git", @@ -22,7 +22,10 @@ "@mendix/piw-native-utils-internal": "*", "@mendix/piw-utils-internal": "*", "react-native-svg": "15.15.4", - "react-native-vision-camera": "4.7.3" + "react-native-vision-camera": "5.2.3", + "react-native-vision-camera-barcode-scanner": "5.2.3", + "react-native-nitro-modules": "0.37.0", + "react-native-nitro-image": "0.15.2" }, "devDependencies": { "@mendix/pluggable-widgets-tools": "*" diff --git a/packages/pluggableWidgets/barcode-scanner-native/src/BarcodeScanner.tsx b/packages/pluggableWidgets/barcode-scanner-native/src/BarcodeScanner.tsx index a48767b4b..953d40ad0 100644 --- a/packages/pluggableWidgets/barcode-scanner-native/src/BarcodeScanner.tsx +++ b/packages/pluggableWidgets/barcode-scanner-native/src/BarcodeScanner.tsx @@ -1,8 +1,13 @@ import { flattenStyles } from "@mendix/piw-native-utils-internal"; import { ValueStatus } from "mendix"; -import { ReactElement, useCallback, useMemo, useRef, useState } from "react"; +import { ReactElement, useCallback, useMemo, useRef, useState, useEffect } from "react"; import { View, LayoutChangeEvent, Platform } from "react-native"; -import { Camera, useCodeScanner, Code, useCameraDevice, CodeScannerFrame } from "react-native-vision-camera"; +import { Camera, useCameraDevice, useCameraPermission } from "react-native-vision-camera"; +import { + useBarcodeScannerOutput, + type Barcode, + type TargetBarcodeFormat +} from "react-native-vision-camera-barcode-scanner"; import BarcodeMask from "./components/BarcodeMask"; import { BarcodeScannerProps } from "../typings/BarcodeScannerProps"; @@ -11,6 +16,29 @@ 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; + +const BARCODE_FORMATS: TargetBarcodeFormat[] = [ + "qr-code", + "aztec", + "codabar", + "code-39", + "code-93", + "code-128", + "data-matrix", + "ean-13", + "ean-8", + "upc-a", + "upc-e", + "pdf-417", + "itf" +]; + +type Size = { width: number; height: number }; + type CodePositionInfo = { isWithinMask: boolean; distanceToMaskCenterSquared: number; @@ -31,7 +59,7 @@ type OverlapInfo = { }; type CandidateCode = { - code: Code; + barcode: Barcode; isWithinMask: boolean; distanceToMaskCenterSquared: number; overlapArea: number; @@ -39,53 +67,62 @@ type CandidateCode = { }; /** - * Transforms barcode coordinates from camera sensor space to screen view space. - * Handles platform-specific coordinate systems (iOS sensor landscape vs Android ML Kit rotated) - * and applies appropriate scaling based on device orientation. + * Transforms barcode coordinates from scanner buffer space to screen view space. + * Handles platform-specific coordinate systems: + * - iOS: Barcode coordinates are in sensor's native landscape space, need 90° rotation for portrait + * - Android: ML Kit already rotates coordinates to match device orientation */ function transformCodeCoordinates( - codeFrame: { x: number; y: number; width: number; height: number }, - scanFrame: CodeScannerFrame, + boundingBox: Barcode["boundingBox"], + bufferResolution: Size, viewWidth: number, viewHeight: number ): TransformedCoordinates { - const { width: frameWidth, height: frameHeight } = scanFrame; + const frameWidth = bufferResolution.width; + const frameHeight = bufferResolution.height; const isPortrait = viewHeight > viewWidth; + const left = boundingBox.left; + const top = boundingBox.top; + const right = boundingBox.right; + const bottom = boundingBox.bottom; + const codeFrameWidth = right - left; + const codeFrameHeight = bottom - top; + let codeX: number; let codeY: number; let codeWidth: number; let codeHeight: number; if (isPortrait && Platform.OS === "ios") { - // iOS: code.frame coordinates are in the sensor's native landscape space, + // iOS: barcode coordinates are in the sensor's native landscape space, // so we need a 90° rotation to map to portrait view coordinates. const scaleX = viewWidth / frameHeight; const scaleY = viewHeight / frameWidth; - codeX = (frameHeight - codeFrame.y - codeFrame.height) * scaleX; - codeY = codeFrame.x * scaleY; - codeWidth = codeFrame.height * scaleX; - codeHeight = codeFrame.width * scaleY; + codeX = (frameHeight - top - codeFrameHeight) * scaleX; + codeY = left * scaleY; + codeWidth = codeFrameHeight * scaleX; + codeHeight = codeFrameWidth * scaleY; } else if (isPortrait && Platform.OS === "android") { - // Android: ML Kit already rotates code.frame coordinates to match device orientation, - // but CodeScannerFrame still reports sensor landscape dimensions (e.g. 1920x1080). + // Android: ML Kit already rotates barcode coordinates to match device orientation, + // but buffer resolution still reports sensor landscape dimensions (e.g. 1920x1080). // Scale using the shorter dimension for X and longer for Y. const scaleX = viewWidth / Math.min(frameWidth, frameHeight); const scaleY = viewHeight / Math.max(frameWidth, frameHeight); - codeX = codeFrame.x * scaleX; - codeY = codeFrame.y * scaleY; - codeWidth = codeFrame.width * scaleX; - codeHeight = codeFrame.height * scaleY; + codeX = left * scaleX; + codeY = top * scaleY; + codeWidth = codeFrameWidth * scaleX; + codeHeight = codeFrameHeight * scaleY; } else { const scaleX = viewWidth / frameWidth; const scaleY = viewHeight / frameHeight; - codeX = codeFrame.x * scaleX; - codeY = codeFrame.y * scaleY; - codeWidth = codeFrame.width * scaleX; - codeHeight = codeFrame.height * scaleY; + codeX = left * scaleX; + codeY = top * scaleY; + codeWidth = codeFrameWidth * scaleX; + codeHeight = codeFrameHeight * scaleY; } return { codeX, codeY, codeWidth, codeHeight }; @@ -138,19 +175,53 @@ function compareCodesByPriority(a: CandidateCode, b: CandidateCode): number { export function BarcodeScanner(props: Props): ReactElement { const device = useCameraDevice("back"); + const [cameraViewDimensions, setCameraViewDimensions] = useState(null); + + const { hasPermission, canRequestPermission, requestPermission } = useCameraPermission(); + + // VisionCamera v5 no longer prompts implicitly when the session starts; it throws without permission + useEffect(() => { + if (canRequestPermission) { + requestPermission(); + } + }, [canRequestPermission, requestPermission]); const styles = useMemo(() => flattenStyles(defaultBarcodeScannerStyle, props.style), [props.style]); // Ref to track the lock state const isLockedRef = useRef(false); - - const [cameraViewDimensions, setCameraViewDimensions] = useState<{ width: number; height: number } | null>(null); + // 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 started + 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 maskWidth = styles.mask.width || 280; const maskHeight = styles.mask.height || 260; const getCodePositionInfo = useCallback( - (code: Code, scanFrame: CodeScannerFrame): CodePositionInfo => { + (barcode: Barcode, bufferResolution: Size | undefined): CodePositionInfo => { if (!props.showMask) { return { isWithinMask: true, @@ -160,7 +231,7 @@ export function BarcodeScanner(props: Props): ReactElement { }; } - if (!cameraViewDimensions || !code.frame) { + if (!cameraViewDimensions || !bufferResolution) { return { isWithinMask: false, distanceToMaskCenterSquared: Number.MAX_SAFE_INTEGER, @@ -177,10 +248,9 @@ export function BarcodeScanner(props: Props): ReactElement { const maskCenterX = maskX + maskWidth / 2; const maskCenterY = maskY + maskHeight / 2; - // Transform Qr barcode coordinates from camera sensor space to view space const { codeX, codeY, codeWidth, codeHeight } = transformCodeCoordinates( - code.frame, - scanFrame, + barcode.boundingBox, + bufferResolution, viewWidth, viewHeight ); @@ -218,38 +288,58 @@ export function BarcodeScanner(props: Props): ReactElement { [props.showMask, cameraViewDimensions, maskWidth, maskHeight] ); - const onCodeScanned = useCallback( - (codes: Code[], frame: CodeScannerFrame) => { - // Block if still in cooldown - if (isLockedRef.current) { + // Read inside the scan callback to get the buffer resolution the scanner is actually using + const scannerOutputRef = useRef | null>(null); + + const onBarcodeScanned = useCallback( + (barcodes: Barcode[]) => { + // Block if still in cooldown or the previous detection is still being handled + if (isLockedRef.current || props.onDetect?.isExecuting) { return; } - if (props.barcode.status !== ValueStatus.Available || codes.length === 0) { + // Block until the camera is started and the scan delay has passed + if (scanStartTimeRef.current === null || Date.now() < scanStartTimeRef.current) { return; } - const candidates = codes - .map(code => { - const positionInfo = getCodePositionInfo(code, frame); - return { - code, - ...positionInfo - }; - }) + if (props.barcode.status !== ValueStatus.Available || barcodes.length === 0) { + return; + } + + const bufferResolution = scannerOutputRef.current?.currentResolution; + + const candidates = barcodes + .map(barcode => ({ + barcode, + ...getCodePositionInfo(barcode, bufferResolution) + })) .filter(item => item.isWithinMask); if (candidates.length === 0) { return; } - const selectedCode = candidates.sort(compareCodesByPriority)[0].code; + const value = candidates.sort(compareCodesByPriority)[0].barcode.rawValue; - if (!selectedCode.value) { + if (!value) { return; } - 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); @@ -261,29 +351,28 @@ export function BarcodeScanner(props: Props): ReactElement { isLockedRef.current = true; setTimeout(() => { isLockedRef.current = false; - }, 2000); + }, SCAN_LOCK_DURATION_MS); }, [props.barcode, props.onDetect, getCodePositionInfo] ); - const codeScanner = useCodeScanner({ - codeTypes: [ - "qr", - "aztec", - "codabar", - "code-39", - "code-93", - "code-128", - "data-matrix", - "ean-13", - "ean-8", - "upc-a", - "upc-e", - "pdf-417", - "itf" - ], - onCodeScanned + const onScanError = useCallback((error: Error) => { + console.warn("Barcode scanner error:", error); + }, []); + + const scannerOutput = useBarcodeScannerOutput({ + barcodeFormats: BARCODE_FORMATS, + // Full resolution so dense/patterned QR codes can be decoded + outputResolution: "full", + onBarcodeScanned, + onError: onScanError }); + scannerOutputRef.current = scannerOutput; + + // The camera is ready, start the scan delay from now + const handleCameraStarted = useCallback(() => { + resetScanState(); + }, [resetScanState]); const handleCameraLayout = useCallback((event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; @@ -291,16 +380,15 @@ export function BarcodeScanner(props: Props): ReactElement { }, []); return ( - - {device && ( + + {device && hasPermission && ( <> {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..a4c1d2655 100644 --- a/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/BarcodeScanner.spec.tsx +++ b/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/BarcodeScanner.spec.tsx @@ -5,14 +5,28 @@ import { View } from "react-native"; import { BarcodeScanner, Props } from "../BarcodeScanner"; import { defaultBarcodeScannerStyle } from "../ui/styles"; -let mockOnCodeScanned: ((codes: Array<{ value: string }>) => void) | undefined; +let mockOnBarcodeScanned: ((barcodes: Array<{ rawValue: string }>) => void) | undefined; jest.mock("react-native-vision-camera", () => ({ - Camera: ({ children, ...props }: any) => {children}, + Camera: ({ children, onStarted, ...props }: any) => { + // Call onStarted to simulate camera being ready + if (onStarted) { + setTimeout(() => onStarted(), 0); + } + return {children}; + }, useCameraDevice: () => "mock-device", - useCodeScanner: (options: any) => { - mockOnCodeScanned = options.onCodeScanned; - return "mockCodeScanner"; + useCameraPermission: () => ({ + hasPermission: true, + canRequestPermission: false, + requestPermission: jest.fn() + }) +})); + +jest.mock("react-native-vision-camera-barcode-scanner", () => ({ + useBarcodeScannerOutput: (options: any) => { + mockOnBarcodeScanned = options.onBarcodeScanned; + return "mockScannerOutput"; } })); @@ -28,7 +42,8 @@ describe("BarcodeScanner", () => { showMask: false, name: "barcode-scanner-test", style: [], - barcode: new EditableValueBuilder().build() + barcode: new EditableValueBuilder().build(), + scanDelay: 0 }; }); @@ -55,15 +70,22 @@ describe("BarcodeScanner", () => { const onDetectAction = actionValue(); render(); - // Simulate scanning - mockOnCodeScanned?.([{ value: "value" }]); + // Wait for camera start and advance time past scan delay + jest.runOnlyPendingTimers(); + + // Simulate scanning (3 consecutive reads required on Android) + mockOnBarcodeScanned?.([{ rawValue: "value" }]); + mockOnBarcodeScanned?.([{ rawValue: "value" }]); + mockOnBarcodeScanned?.([{ rawValue: "value" }]); jest.advanceTimersByTime(2000); expect(defaultProps.barcode.setValue).toHaveBeenCalledWith("value"); expect(onDetectAction.execute).toHaveBeenCalledTimes(1); // Another scan - mockOnCodeScanned?.([{ value: "value1" }]); + mockOnBarcodeScanned?.([{ rawValue: "value1" }]); + mockOnBarcodeScanned?.([{ rawValue: "value1" }]); + mockOnBarcodeScanned?.([{ rawValue: "value1" }]); jest.advanceTimersByTime(2000); expect(defaultProps.barcode.setValue).toHaveBeenCalledWith("value1"); diff --git a/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/__snapshots__/BarcodeScanner.spec.tsx.snap b/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/__snapshots__/BarcodeScanner.spec.tsx.snap index 6fcf797a8..ff064b8db 100644 --- a/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/__snapshots__/BarcodeScanner.spec.tsx.snap +++ b/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/__snapshots__/BarcodeScanner.spec.tsx.snap @@ -9,13 +9,17 @@ exports[`BarcodeScanner renders 1`] = ` "minHeight": 100, } } + testID="barcode-scanner-test" > `; @@ -37,13 +40,17 @@ exports[`BarcodeScanner renders with mask 1`] = ` "minHeight": 100, } } + testID="barcode-scanner-test" > - + 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