Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -11,6 +11,11 @@ import { executeAction } from "@mendix/piw-utils-internal";

export type Props = BarcodeScannerProps<BarcodeScannerStyle>;

// 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;
Expand Down Expand Up @@ -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<ReturnType<typeof setTimeout> | null>(null);

// Time from which detected codes may be accepted, null until the camera is initialized
const scanStartTimeRef = useRef<number | null>(null);

// Last detected value and how many consecutive reads returned it
const lastReadValueRef = useRef<string | null>(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);

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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({
Expand All @@ -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 });
Expand All @@ -301,6 +358,7 @@ export function BarcodeScanner(props: Props): ReactElement {
isActive
device={device}
codeScanner={codeScanner}
onInitialized={handleCameraInitialized}
onLayout={handleCameraLayout}
/>
{props.showMask && (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8" ?>
<widget id="com.mendix.widget.native.barcodescanner.BarcodeScanner" supportedPlatform="Native" needsEntityContext="true" offlineCapable="true" pluginWidget="true" xmlns="http://www.mendix.com/widget/1.0/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.mendix.com/widget/1.0/ ../../../node_modules/mendix/custom_widget.xsd">
<name>Barcode scanner</name>
<description>Scan barcode and QR code values.</description>
Expand All @@ -11,7 +11,7 @@
<caption>Barcode</caption>
<description>The attribute that will receive the scanned barcode value.</description>
<attributeTypes>
<attributeType name="String"/>
<attributeType name="String" />
</attributeTypes>
</property>
</propertyGroup>
Expand All @@ -25,14 +25,20 @@
<description />
</property>
</propertyGroup>
<propertyGroup caption="Scanning">
<property key="scanDelay" type="integer" required="true" defaultValue="0">
<caption>Scan delay (seconds)</caption>
<description>Time after the camera opens before barcodes are detected. Gives the user time to place the barcode inside the mask.</description>
</property>
</propertyGroup>
<propertyGroup caption="Events">
<property key="onDetect" type="action" required="false">
<caption>On detect</caption>
<description/>
<description />
</property>
</propertyGroup>
<propertyGroup caption="Common">
<systemProperty key="Name"/>
<systemProperty key="Name" />
</propertyGroup>
</propertyGroup>
</properties>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => <View {...props}>{children}</View>,
Camera: ({ children, onInitialized, ...props }: any) => {
// Call onInitialized to simulate camera being ready
if (onInitialized) {
setTimeout(() => onInitialized(), 0);
}
return <View {...props}>{children}</View>;
},
useCameraDevice: () => "mock-device",
useCodeScanner: (options: any) => {
mockOnCodeScanned = options.onCodeScanned;
Expand All @@ -28,7 +34,8 @@ describe("BarcodeScanner", () => {
showMask: false,
name: "barcode-scanner-test",
style: [],
barcode: new EditableValueBuilder<string>().build()
barcode: new EditableValueBuilder<string>().build(),
scanDelay: 0
};
});

Expand All @@ -55,7 +62,12 @@ describe("BarcodeScanner", () => {
const onDetectAction = actionValue();
render(<BarcodeScanner {...defaultProps} onDetect={onDetectAction} />);

// 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);

Expand All @@ -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");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8" ?>
<package xmlns="http://www.mendix.com/package/1.0/">
<clientModule name="BarcodeScanner" version="4.5.0" xmlns="http://www.mendix.com/clientModule/1.0/">
<clientModule name="BarcodeScanner" version="4.5.1" xmlns="http://www.mendix.com/clientModule/1.0/">
<widgetFiles>
<widgetFile path="BarcodeScanner.xml" />
</widgetFiles>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface BarcodeScannerProps<Style> {
barcode: EditableValue<string>;
showMask: boolean;
showAnimatedLine: boolean;
scanDelay: number;
onDetect?: ActionValue;
}

Expand All @@ -29,5 +30,6 @@ export interface BarcodeScannerPreviewProps {
barcode: string;
showMask: boolean;
showAnimatedLine: boolean;
scanDelay: number | null;
onDetect: {} | null;
}
Loading