diff --git a/.github/workflows/NativePipeline.yml b/.github/workflows/NativePipeline.yml index 637ded993..0bbf492bb 100644 --- a/.github/workflows/NativePipeline.yml +++ b/.github/workflows/NativePipeline.yml @@ -854,13 +854,23 @@ jobs: if: env.IOS_APP_CACHED != 'true' working-directory: native-template/ios run: pod install + - name: "Patch MLKit frameworks for arm64 simulator" + if: env.IOS_APP_CACHED != 'true' + # MLKit (via react-native-vision-camera-barcode-scanner) has no arm64-simulator slice; + # re-tag its arm64 device objects as simulator so the arm64 simulator build links. + # Native Template excludes that package from iOS (react-native.config.js, ios: null); once the + # Native Template release with that change is used here, MLKit is no longer installed and this + # step (and EXCLUDED_ARCHS below) can be removed. + run: node native-widgets/scripts/test/patch-mlkit-arm64-simulator.js native-template/ios/Pods - name: "Build iOS app" if: env.IOS_APP_CACHED != 'true' working-directory: native-template/ios # Build for the simulator's native arch (arm64 on Apple Silicon runners). # ONLY_ACTIVE_ARCH=YES already limits to the active arch; the old # VALID_ARCHS="i386 x86_64" forced dead/foreign archs and broke on macos-26. - run: xcodebuild -workspace NativeTemplate.xcworkspace -scheme nativeTemplate -configuration Debug -sdk iphonesimulator -derivedDataPath build ONLY_ACTIVE_ARCH=YES + # EXCLUDED_ARCHS="i386 x86_64" explicitly excludes x86_64 to prevent Xcode from + # building a universal binary when Pods are arm64-only. + run: xcodebuild -workspace NativeTemplate.xcworkspace -scheme nativeTemplate -configuration Debug -sdk iphonesimulator -derivedDataPath build ONLY_ACTIVE_ARCH=YES EXCLUDED_ARCHS="i386 x86_64" - name: "Save built iOS app" if: env.IOS_APP_CACHED != 'true' uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 diff --git a/configs/e2e/native_dependencies.json b/configs/e2e/native_dependencies.json index 2b68899f3..9c61f1253 100644 --- a/configs/e2e/native_dependencies.json +++ b/configs/e2e/native_dependencies.json @@ -9,7 +9,10 @@ "react-native-svg": "15.15.4", "react-native-video": "6.19.2", "@react-native-async-storage/async-storage": "2.2.0", - "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", "react-native-view-shot": "5.1.0", "react-native-blob-util": "0.24.7", "react-native-file-viewer-turbo": "0.7.4", diff --git a/packages/jsActions/mobile-resources-native/CHANGELOG.md b/packages/jsActions/mobile-resources-native/CHANGELOG.md index 81493aabc..3c2dc18db 100644 --- a/packages/jsActions/mobile-resources-native/CHANGELOG.md +++ b/packages/jsActions/mobile-resources-native/CHANGELOG.md @@ -12,8 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [12.5.1] Native Mobile Resources - 2026-10-8 - ## [2.3.0] Gallery + ### Fixed - We've fixed an issue where Gallery widget does not display data. diff --git a/packages/jsActions/nanoflow-actions-native/CHANGELOG.md b/packages/jsActions/nanoflow-actions-native/CHANGELOG.md index a1b2bb536..94b7c825d 100644 --- a/packages/jsActions/nanoflow-actions-native/CHANGELOG.md +++ b/packages/jsActions/nanoflow-actions-native/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- Updated react-native-nitro-modules to 0.37.0. + ### Added - We added mendix-native as a dependency to provide native file system. diff --git a/packages/jsActions/nanoflow-actions-native/package.json b/packages/jsActions/nanoflow-actions-native/package.json index cb04c17a8..59259a37d 100644 --- a/packages/jsActions/nanoflow-actions-native/package.json +++ b/packages/jsActions/nanoflow-actions-native/package.json @@ -29,7 +29,7 @@ "@react-native-async-storage/async-storage": "2.2.0", "js-base64": "~3.7.2", "react-native-nitro-geolocation": "1.4.3", - "react-native-nitro-modules": "0.36.1", + "react-native-nitro-modules": "0.37.0", "mendix-native": "https://github.com/mendix/mendix-native/releases/download/v0.5.2/mendix-native-v0.5.2.tgz", "react-native-permissions": "5.5.1", "react-native-geocoder": "0.5.0" @@ -38,4 +38,4 @@ "@mendix/pluggable-widgets-tools": "*", "mendix": "11.8.0" } -} \ No newline at end of file +} diff --git a/packages/pluggableWidgets/barcode-scanner-native/CHANGELOG.md b/packages/pluggableWidgets/barcode-scanner-native/CHANGELOG.md index dad63beb2..4ce598f0e 100644 --- a/packages/pluggableWidgets/barcode-scanner-native/CHANGELOG.md +++ b/packages/pluggableWidgets/barcode-scanner-native/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### BREAKING + +- We updated react-native-vision-camera from v4.7.3 to v5.2.3. On Android, barcodes are now scanned with react-native-vision-camera-barcode-scanner v5.2.3 (Google ML Kit). On iOS, barcodes are still scanned with Apple's built-in scanner, as before. This version requires Make It Native version 11.12.3 or above. Older versions of Make It Native cannot run this version of the widget. +- For Native Template builds, Native Template version 19.1.8 or above is required. + +### Added + +- We added a `scan delay` property that delays scanning by the specified number of seconds, giving the end user time to position the scanner over the code. + +### Fixed + +- We fixed barcodes with patterned backgrounds not being scanned correctly on Android. + ## [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..980970064 100644 --- a/packages/pluggableWidgets/barcode-scanner-native/src/BarcodeScanner.tsx +++ b/packages/pluggableWidgets/barcode-scanner-native/src/BarcodeScanner.tsx @@ -1,9 +1,11 @@ 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, type CameraRef } from "react-native-vision-camera"; import BarcodeMask from "./components/BarcodeMask"; +import { DetectedCode, Size, UseScannerOutput } from "./scanners/types"; +import { useIosScannerOutput } from "./scanners/useIosScannerOutput"; import { BarcodeScannerProps } from "../typings/BarcodeScannerProps"; import { BarcodeScannerStyle, defaultBarcodeScannerStyle } from "./ui/styles"; @@ -11,6 +13,19 @@ 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; + +// iOS uses Apple's scanner from vision-camera core, Android uses Google ML Kit. The Android scanner package isn't +// linked on iOS and fails when loaded, so it is only required on Android. Keep this require directly behind the +// Platform.OS check: the widget build replaces Platform.OS per platform and drops the require from the iOS bundle. +const useScannerOutput: UseScannerOutput = + Platform.OS === "android" + ? require("./scanners/useAndroidScannerOutput").useAndroidScannerOutput + : useIosScannerOutput; + type CodePositionInfo = { isWithinMask: boolean; distanceToMaskCenterSquared: number; @@ -18,79 +33,19 @@ type CodePositionInfo = { overlapPercentage: number; }; -type TransformedCoordinates = { - codeX: number; - codeY: number; - codeWidth: number; - codeHeight: number; -}; - type OverlapInfo = { overlapArea: number; overlapPercentage: number; }; type CandidateCode = { - code: Code; + code: DetectedCode; isWithinMask: boolean; distanceToMaskCenterSquared: number; overlapArea: number; overlapPercentage: number; }; -/** - * 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. - */ -function transformCodeCoordinates( - codeFrame: { x: number; y: number; width: number; height: number }, - scanFrame: CodeScannerFrame, - viewWidth: number, - viewHeight: number -): TransformedCoordinates { - const { width: frameWidth, height: frameHeight } = scanFrame; - const isPortrait = viewHeight > viewWidth; - - 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, - // 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; - } 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). - // 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; - } 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; - } - - return { codeX, codeY, codeWidth, codeHeight }; -} - /** * Calculates the overlap between a barcode and the mask region. * Returns both the absolute overlap area and the percentage of the barcode that overlaps with the mask. @@ -138,19 +93,54 @@ function compareCodesByPriority(a: CandidateCode, b: CandidateCode): number { export function BarcodeScanner(props: Props): ReactElement { const device = useCameraDevice("back"); + const [cameraViewDimensions, setCameraViewDimensions] = useState(null); + const cameraRef = useRef(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 => { + (code: DetectedCode): CodePositionInfo => { if (!props.showMask) { return { isWithinMask: true, @@ -160,7 +150,8 @@ export function BarcodeScanner(props: Props): ReactElement { }; } - if (!cameraViewDimensions || !code.frame) { + const bounds = cameraViewDimensions ? code.getViewBounds() : undefined; + if (!cameraViewDimensions || !bounds) { return { isWithinMask: false, distanceToMaskCenterSquared: Number.MAX_SAFE_INTEGER, @@ -177,13 +168,7 @@ 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, - viewWidth, - viewHeight - ); + const { x: codeX, y: codeY, width: codeWidth, height: codeHeight } = bounds; const codeCenterX = codeX + codeWidth / 2; const codeCenterY = codeY + codeHeight / 2; @@ -218,10 +203,15 @@ 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) { + const onCodesScanned = useCallback( + (codes: DetectedCode[]) => { + // 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 started and the scan delay has passed + if (scanStartTimeRef.current === null || Date.now() < scanStartTimeRef.current) { return; } @@ -230,26 +220,36 @@ export function BarcodeScanner(props: Props): ReactElement { } const candidates = codes - .map(code => { - const positionInfo = getCodePositionInfo(code, frame); - return { - code, - ...positionInfo - }; - }) + .map(code => ({ + code, + ...getCodePositionInfo(code) + })) .filter(item => item.isWithinMask); if (candidates.length === 0) { return; } - const selectedCode = candidates.sort(compareCodesByPriority)[0].code; + const value = candidates.sort(compareCodesByPriority)[0].code.value; - 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); @@ -259,31 +259,19 @@ export function BarcodeScanner(props: Props): ReactElement { // Lock further scans for 2 seconds isLockedRef.current = true; - setTimeout(() => { + lockTimeoutRef.current = 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 scannerOutput = useScannerOutput({ onCodesScanned, cameraRef, cameraViewDimensions }); + + // 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 +279,17 @@ 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..4850f262d 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 + Number of seconds to wait after the camera starts before barcodes are scanned, so the user has time to position the barcode. Set to 0 to start scanning immediately. + + On detect - + - + diff --git a/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/BarcodeScanner.ios.spec.tsx b/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/BarcodeScanner.ios.spec.tsx new file mode 100644 index 000000000..e0ed64422 --- /dev/null +++ b/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/BarcodeScanner.ios.spec.tsx @@ -0,0 +1,138 @@ +import { actionValue, EditableValueBuilder } from "@mendix/piw-utils-internal"; +import { fireEvent, render } from "@testing-library/react-native"; +import { Platform } from "react-native"; +import type { Props } from "../BarcodeScanner"; + +// The scanner implementation is chosen when the widget module loads, so switch to iOS before requiring it +Object.defineProperty(Platform, "OS", { configurable: true, value: "ios" }); +const { BarcodeScanner } = require("../BarcodeScanner") as typeof import("../BarcodeScanner"); + +type MockScannedObject = { value?: string; viewBounds?: { x: number; y: number; width: number; height: number } }; + +let mockOnObjectsScanned: ((objects: MockScannedObject[]) => void) | undefined; + +jest.mock("react-native-vision-camera", () => { + const { useImperativeHandle } = jest.requireActual("react"); + const { View } = jest.requireActual("react-native"); + return { + Camera: ({ children, onStarted, ref, ...props }: any) => { + useImperativeHandle(ref, () => ({ + convertScannedObjectCoordinatesToViewCoordinates: (object: MockScannedObject) => { + if (!object.viewBounds) { + throw new Error("Failed to transform the Scanned Object's coordinates"); + } + return { boundingBox: object.viewBounds }; + } + })); + // Call onStarted to simulate camera being ready + if (onStarted) { + setTimeout(() => onStarted(), 0); + } + return ( + + {children} + + ); + }, + useCameraDevice: () => "mock-device", + useCameraPermission: () => ({ + hasPermission: true, + canRequestPermission: false, + requestPermission: jest.fn() + }), + useObjectOutput: (options: any) => { + mockOnObjectsScanned = options.onObjectsScanned; + return "mockObjectOutput"; + }, + useVideoOutput: () => "mockResolutionOutput", + CommonResolutions: { FHD_16_9: { width: 1080, height: 1920 } }, + isScannedCode: (object: object) => "value" in object + }; +}); + +// The ML Kit scanner isn't linked on iOS, loading it there crashes the app +jest.mock("react-native-vision-camera-barcode-scanner", () => { + throw new Error("react-native-vision-camera-barcode-scanner must not be loaded on iOS"); +}); + +jest.mock("../components/BarcodeMask", () => "BarcodeMask"); + +describe("BarcodeScanner (iOS)", () => { + let defaultProps: Props; + + beforeEach(() => { + jest.useFakeTimers(); + defaultProps = { + showAnimatedLine: false, + showMask: false, + name: "barcode-scanner-test", + style: [], + barcode: new EditableValueBuilder().build(), + scanDelay: 0 + }; + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it("renders with Apple's object output and a 1080p 30 fps camera format", () => { + const component = render(); + const camera = component.getByTestId("camera"); + expect(camera.props.outputs).toEqual(["mockObjectOutput", "mockResolutionOutput"]); + expect(camera.props.constraints).toEqual([{ resolutionBias: "mockResolutionOutput" }, { fps: 30 }]); + }); + + it("sets a value and executes the onDetect action on the first read", () => { + const onDetectAction = actionValue(); + render(); + jest.runOnlyPendingTimers(); + + mockOnObjectsScanned?.([{ value: "value" }]); + + expect(defaultProps.barcode.setValue).toHaveBeenCalledWith("value"); + expect(onDetectAction.execute).toHaveBeenCalledTimes(1); + + // Further scans are locked for 2 seconds + mockOnObjectsScanned?.([{ value: "value1" }]); + expect(onDetectAction.execute).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(2000); + mockOnObjectsScanned?.([{ value: "value1" }]); + + expect(defaultProps.barcode.setValue).toHaveBeenCalledWith("value1"); + expect(onDetectAction.execute).toHaveBeenCalledTimes(2); + }); + + it("ignores scanned objects that are not codes or have no value", () => { + const onDetectAction = actionValue(); + render(); + jest.runOnlyPendingTimers(); + + mockOnObjectsScanned?.([{} as MockScannedObject, { value: undefined }, { value: "" }]); + + expect(defaultProps.barcode.setValue).not.toHaveBeenCalled(); + expect(onDetectAction.execute).not.toHaveBeenCalled(); + }); + + it("only accepts codes inside the mask, using view coordinates from the camera", () => { + const onDetectAction = actionValue(); + const component = render(); + jest.runOnlyPendingTimers(); + // Default mask is 280x260, centered: x 60..340, y 270..530 + fireEvent(component.getByTestId("camera"), "layout", { nativeEvent: { layout: { width: 400, height: 800 } } }); + + mockOnObjectsScanned?.([ + { value: "outside", viewBounds: { x: 0, y: 0, width: 50, height: 50 } }, + { value: "no-preview-yet" } + ]); + expect(onDetectAction.execute).not.toHaveBeenCalled(); + + mockOnObjectsScanned?.([ + { value: "outside", viewBounds: { x: 0, y: 0, width: 50, height: 50 } }, + { value: "inside", viewBounds: { x: 150, y: 350, width: 100, height: 100 } } + ]); + expect(defaultProps.barcode.setValue).toHaveBeenCalledWith("inside"); + expect(onDetectAction.execute).toHaveBeenCalledTimes(1); + }); +}); 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..441ae54c1 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,18 @@ exports[`BarcodeScanner renders 1`] = ` "minHeight": 100, } } + testID="barcode-scanner-test" > `; @@ -37,13 +41,18 @@ exports[`BarcodeScanner renders with mask 1`] = ` "minHeight": 100, } } + testID="barcode-scanner-test" > - + diff --git a/packages/pluggableWidgets/barcode-scanner-native/src/scanners/types.ts b/packages/pluggableWidgets/barcode-scanner-native/src/scanners/types.ts new file mode 100644 index 000000000..d60aa4bcf --- /dev/null +++ b/packages/pluggableWidgets/barcode-scanner-native/src/scanners/types.ts @@ -0,0 +1,27 @@ +import type { RefObject } from "react"; +import type { CameraOutput, CameraRef, Constraint } from "react-native-vision-camera"; + +export type Size = { width: number; height: number }; + +// Rectangle in camera view space (dp) +export type ViewBounds = { x: number; y: number; width: number; height: number }; + +export type DetectedCode = { + value: string; + // Resolved lazily, only when the mask needs the code position. Undefined when it can't be determined yet. + getViewBounds: () => ViewBounds | undefined; +}; + +export type ScannerOutputOptions = { + onCodesScanned: (codes: DetectedCode[]) => void; + cameraRef: RefObject; + cameraViewDimensions: Size | null; +}; + +export type ScannerOutput = { + // Outputs to attach to the Camera, the first one is the scanner + outputs: CameraOutput[]; + constraints: Constraint[]; +}; + +export type UseScannerOutput = (options: ScannerOutputOptions) => ScannerOutput; diff --git a/packages/pluggableWidgets/barcode-scanner-native/src/scanners/useAndroidScannerOutput.ts b/packages/pluggableWidgets/barcode-scanner-native/src/scanners/useAndroidScannerOutput.ts new file mode 100644 index 000000000..47bdc714a --- /dev/null +++ b/packages/pluggableWidgets/barcode-scanner-native/src/scanners/useAndroidScannerOutput.ts @@ -0,0 +1,100 @@ +// Android only: react-native-vision-camera-barcode-scanner (Google ML Kit) creates its native object when it is +// loaded and isn't linked on iOS, so this file must only be required behind a Platform.OS === "android" check. +import { useCallback, useEffect, useMemo, useRef } from "react"; +import type { CameraOutput } from "react-native-vision-camera"; +import { + useBarcodeScannerOutput, + type Barcode, + type TargetBarcodeFormat +} from "react-native-vision-camera-barcode-scanner"; + +import { DetectedCode, ScannerOutput, ScannerOutputOptions, Size, ViewBounds } from "./types"; + +// Kept as a module constant because the scanner output is recreated whenever this array changes. +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" +]; + +/** + * Transforms barcode coordinates from scanner buffer space to screen view space. + * ML Kit already rotates coordinates to match device orientation. + */ +function toViewBounds(boundingBox: Barcode["boundingBox"], bufferResolution: Size, viewSize: Size): ViewBounds { + const frameWidth = bufferResolution.width; + const frameHeight = bufferResolution.height; + const { width: viewWidth, height: viewHeight } = viewSize; + const isPortrait = viewHeight > viewWidth; + + const codeFrameWidth = boundingBox.right - boundingBox.left; + const codeFrameHeight = boundingBox.bottom - boundingBox.top; + + // Buffer resolution still reports sensor landscape dimensions (e.g. 1920x1080) in portrait, + // so scale using the shorter dimension for X and longer for Y. + const scaleX = isPortrait ? viewWidth / Math.min(frameWidth, frameHeight) : viewWidth / frameWidth; + const scaleY = isPortrait ? viewHeight / Math.max(frameWidth, frameHeight) : viewHeight / frameHeight; + + return { + x: boundingBox.left * scaleX, + y: boundingBox.top * scaleY, + width: codeFrameWidth * scaleX, + height: codeFrameHeight * scaleY + }; +} + +export function useAndroidScannerOutput({ onCodesScanned, cameraViewDimensions }: ScannerOutputOptions): ScannerOutput { + // Read when resolving a code position to get the buffer resolution the scanner is actually using + const scannerOutputRef = useRef(null); + + const onBarcodeScanned = useCallback( + (barcodes: Barcode[]) => { + const codes: DetectedCode[] = []; + for (const barcode of barcodes) { + const value = barcode.rawValue; + if (!value) { + continue; + } + codes.push({ + value, + getViewBounds: () => { + const bufferResolution = scannerOutputRef.current?.currentResolution; + if (!bufferResolution || !cameraViewDimensions) { + return undefined; + } + return toViewBounds(barcode.boundingBox, bufferResolution, cameraViewDimensions); + } + }); + } + onCodesScanned(codes); + }, + [onCodesScanned, cameraViewDimensions] + ); + + 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 + }); + useEffect(() => { + scannerOutputRef.current = scannerOutput; + }, [scannerOutput]); + + return useMemo(() => ({ outputs: [scannerOutput], constraints: [] }), [scannerOutput]); +} diff --git a/packages/pluggableWidgets/barcode-scanner-native/src/scanners/useIosScannerOutput.ts b/packages/pluggableWidgets/barcode-scanner-native/src/scanners/useIosScannerOutput.ts new file mode 100644 index 000000000..598fd13d6 --- /dev/null +++ b/packages/pluggableWidgets/barcode-scanner-native/src/scanners/useIosScannerOutput.ts @@ -0,0 +1,75 @@ +import { useCallback, useMemo } from "react"; +import { + CommonResolutions, + isScannedCode, + useObjectOutput, + useVideoOutput, + type ScannedObject, + type ScannedObjectType +} from "react-native-vision-camera"; + +import { DetectedCode, ScannerOutput, ScannerOutputOptions } from "./types"; + +// Same code types as v4 on iOS (Apple AVFoundation): UPC-A is reported as EAN-13, and v4's "itf" was interleaved 2 of 5. +// Kept as a module constant because the object output is recreated whenever this array changes. +const OBJECT_TYPES: ScannedObjectType[] = [ + "qr", + "aztec", + "codabar", + "code-39", + "code-93", + "code-128", + "data-matrix", + "ean-13", + "ean-8", + "upc-e", + "pdf-417", + "interleaved-2-of-5" +]; + +export function useIosScannerOutput({ onCodesScanned, cameraRef }: ScannerOutputOptions): ScannerOutput { + const onObjectsScanned = useCallback( + (objects: ScannedObject[]) => { + const codes: DetectedCode[] = []; + for (const object of objects) { + if (!isScannedCode(object)) { + continue; + } + const value = object.value; + if (!value) { + continue; + } + codes.push({ + value, + getViewBounds: () => { + try { + // Converts from normalized camera space to view space, handling orientation and cropping natively + return cameraRef.current?.convertScannedObjectCoordinatesToViewCoordinates(object) + .boundingBox; + } catch { + // The preview isn't ready yet + return undefined; + } + } + }); + } + onCodesScanned(codes); + }, + [onCodesScanned, cameraRef] + ); + + const objectOutput = useObjectOutput({ types: OBJECT_TYPES, onObjectsScanned }); + + // Without a resolution target VisionCamera picks the first matching camera format, while v4 kept Apple's + // default 1080p 30 fps setup that dense/patterned QR codes need. Only a created output can be used as + // resolution bias, so a video output is added that never records. + const resolutionOutput = useVideoOutput({ targetResolution: CommonResolutions.FHD_16_9, enableAudio: false }); + + return useMemo( + () => ({ + outputs: [objectOutput, resolutionOutput], + constraints: [{ resolutionBias: resolutionOutput }, { fps: 30 }] + }), + [objectOutput, resolutionOutput] + ); +} 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