diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..42c9876 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,85 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +`react-native-lecom-scan` is a thin React Native wrapper around the vendored Lecom Android scanner SDK (`android/libs/LecomScan_SDK_release_v2.2.1.jar`). It targets Lecom T80 (`Platform.constants.Brand === 'alps'`) and N60 (`Brand === 'N60'`) PDAs. **iOS is intentionally not supported** — it was removed in commit `1f511cd` and the JS surface degrades to noops on every non-Android platform. + +The package is published to npm and uses Yarn workspaces (Yarn 3, Berry — `npm` will not work). The example app under `example/` is a `react-native-test-app` host used for manual verification on real hardware. + +## Commands + +Always use `yarn`, never `npm`. The `.nvmrc` pins Node. + +```sh +yarn # install (root + example workspace) +yarn typecheck # tsc, no emit +yarn lint # eslint +yarn lint --fix # autofix +yarn test # jest (currently a single it.todo placeholder) +yarn prepare # bob build — runs on install; regenerate lib/ after src/ changes +yarn clean # delete lib/ and example build folders +yarn release # release-it (conventional-changelog, publishes to npm + GitHub) +``` + +Run a single Jest test: `yarn test -t ""` or `yarn test path/to/file.test.tsx`. + +Example app (must be run on a real Lecom device — emulators won't have `android.device.ScanDevice`): + +```sh +yarn example start +yarn example android +ORG_GRADLE_PROJECT_newArchEnabled=true yarn example android # new arch (Fabric/Turbo) +``` + +Pre-commit hooks (`lefthook.yml`) run `yarn lint --quiet` and `tsc` in parallel; `commit-msg` runs commitlint with the conventional-commits config. Commit prefixes: `fix`, `feat`, `refactor`, `docs`, `test`, `chore`. + +## Architecture + +### Platform split (the non-obvious part) + +There are two entry points selected by Metro at bundle time: + +- `src/index.ts` — default/fallback. Every export is a noop or returns `{ code: '', isDevice: false, model: undefined }`. This is what iOS, web, and any non-Android platform get. +- `src/index.android.ts` — the real implementation. Wires up `NativeModules.LecomScan` (or the TurboModule via `require('./NativeLecomScan').default` when `global.__turboModuleProxy` is present) and a `NativeEventEmitter`. + +Public API: `useLecomScan(options)`, `toggleScan()`, `initScanner()`, `stopScanner()`. Types live in `src/NativeLecomScan.ts` and are shared between both entry points. + +### New arch / old arch dual support + +The module compiles for both Bridge and TurboModule architectures from one source tree: + +- `src/NativeLecomScan.ts` is the codegen spec (`TurboModuleRegistry.getEnforcing('LecomScan')`). `package.json#codegenConfig` emits Java into `android/generated/` with package `com.lecomscan` and library name `RNLecomScanSpec`. +- `android/src/newarch/LecomScanSpec.kt` extends the codegen-generated `NativeLecomScanSpec` (TurboModule path). +- `android/src/oldarch/LecomScanSpec.kt` is the bridge-era equivalent. +- `android/build.gradle` swaps `srcDirs` between `src/newarch` + `generated/{java,jni}` and `src/oldarch` based on `newArchEnabled`. `BuildConfig.IS_NEW_ARCHITECTURE_ENABLED` flows into `LecomScanPackage` so `ReactModuleInfo.isTurboModule` is set correctly. +- `LecomScanModule.kt` extends whichever `LecomScanSpec` is on the classpath and works under both arches. + +When changing the spec (`src/NativeLecomScan.ts`), both Kotlin Spec stubs must be updated in lockstep, and consumers will need a clean rebuild because `android/generated/` is regenerated. + +### Native scan flow + +`LecomScanModule` (`android/src/main/java/com/lecomscan/LecomScanModule.kt`) is the heart of the integration: + +1. `init()` instantiates `android.device.ScanDevice`, calls `setOutScanMode(0)` (broadcast mode), and registers a `BroadcastReceiver` for the action `scan.rcv.message`. +2. The receiver reads the `barocode` byte array (note: SDK typo — do not "fix" it) and `length` int from the intent, decodes to a String, and emits `EventLecomScanSuccess` to JS via `RCTNativeAppEventEmitter`. Emission is posted to the main `Handler` because the receiver fires on a binder thread. +3. `toggleScan()` flips `isScanning`; on transition to active it calls `sd.startScan()` (the trigger), otherwise `stop()`. +4. `LifecycleEventListener` pauses/resumes the scan with the host activity and tears everything down on destroy. `onCatalystInstanceDestroy` is overridden as a deprecated-but-still-called safety net. + +`IS_SCAN_DEVICE_AVAILABLE` is a static `Class.forName("android.device.ScanDevice")` probe so the module is safe to load on non-Lecom Android devices (returns false → `init()` becomes a noop). + +### React hook contract + +`useLecomScan` (in `src/index.android.ts`): + +- `isDevice` is computed from `Platform.constants.Model === 'PDA'` AND `Platform.constants.Brand` matching `N60`, `alps`, or a caller-supplied `model` override. Anything else → noop hook. +- The hook deduplicates consecutive identical scans via `lastCodeRef` before calling `callback` and `setCode`. If you need every trigger pull (including repeats), this dedup is the thing to revisit. +- The effect calls `init()` when `isActive && isDevice`, subscribes to `EventLecomScanSuccess`, and always calls `stop()` on cleanup. + +## Things to know before changing native code + +- Do not rename the intent extra `barocode` — that key is defined by the Lecom SDK, not by us. +- The vendored JAR is the only source of `android.device.ScanDevice`; don't try to mock or shim it for unit tests, just gate on `IS_SCAN_DEVICE_AVAILABLE`. +- `RCTNativeAppEventEmitter` is used directly (not `DeviceEventManagerModule`) — JS subscribes via `NativeEventEmitter(LecomScan)`. `addListener`/`removeListeners` exist on the spec only to satisfy the JS-side EventEmitter contract; they are intentional noops. +- `react-native-builder-bob` outputs to `lib/` (commonjs + ESM module + d.ts). `lib/` is gitignored but shipped in the npm tarball; never edit it by hand. diff --git a/android/src/main/java/com/lecomscan/LecomScanModule.kt b/android/src/main/java/com/lecomscan/LecomScanModule.kt index e47face..9553d50 100644 --- a/android/src/main/java/com/lecomscan/LecomScanModule.kt +++ b/android/src/main/java/com/lecomscan/LecomScanModule.kt @@ -1,6 +1,5 @@ package com.lecomscan -import android.app.Activity import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -78,14 +77,7 @@ class LecomScanModule internal constructor(private val mContext: ReactApplicatio } } - private fun isScanDeviceAvailable(): Boolean { - return try { - Class.forName("android.device.ScanDevice") - true - } catch (e: ClassNotFoundException) { - false - } - } + private fun isScanDeviceAvailable(): Boolean = IS_SCAN_DEVICE_AVAILABLE // Initialize the ScanDevice and register the receiver @ReactMethod @@ -108,7 +100,11 @@ class LecomScanModule internal constructor(private val mContext: ReactApplicatio // Stop the ScanDevice and unregister the receiver @ReactMethod override fun stop() { - sd?.stopScan() + try { + sd?.stopScan() + } catch (e: Exception) { + Log.w("LecomScanModule", "stopScan threw: $e") + } sd = null unregisterReceiver() isScanning = false @@ -120,10 +116,12 @@ class LecomScanModule internal constructor(private val mContext: ReactApplicatio if (isScanning) { stop() } else { + if (!isScanDeviceAvailable()) return if (sd == null) { sd = ScanDevice() sd?.setOutScanMode(0) } + registerReceiver() sd?.startScan() isScanning = true } @@ -165,5 +163,11 @@ class LecomScanModule internal constructor(private val mContext: ReactApplicatio companion object { const val NAME = "LecomScan" + private val IS_SCAN_DEVICE_AVAILABLE: Boolean = try { + Class.forName("android.device.ScanDevice") + true + } catch (e: ClassNotFoundException) { + false + } } } diff --git a/package.json b/package.json index 7c6d788..3bd474c 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "react-native-lecom-scan", - "version": "0.12.12", + "version": "0.13.1-beta.3", "description": "React Native implementation of the Lecom scanner SDK.", "source": "./src/index", - "main": "./lib/commonjs/index", - "module": "./lib/module/index", + "main": "./lib/commonjs/index.js", + "module": "./lib/module/index.js", "react-native": "src/index", "types": "./lib/typescript/module/src/index.d.ts", "exports": { diff --git a/src/index.android.ts b/src/index.android.ts index a4070ab..3e59039 100644 --- a/src/index.android.ts +++ b/src/index.android.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { NativeEventEmitter, NativeModules, Platform } from 'react-native' import type { EmitterSubscription } from 'react-native' @@ -10,8 +10,8 @@ const LINKING_ERROR = '- You rebuilt the app after installing the package\n' + '- You are not using Expo Go\n' -// @ts-expect-error -const isTurboModuleEnabled = global.__turboModuleProxy != null +const isTurboModuleEnabled = + (globalThis as { __turboModuleProxy?: unknown }).__turboModuleProxy != null const LecomScanModule = isTurboModuleEnabled ? require('./NativeLecomScan').default @@ -91,14 +91,12 @@ export const useLecomScan: LecomHook = ({ const [code, setCode] = useState('') const isDevice = checkLecom(model) - const onScanSuccess = useCallback( - async (c: string) => { - if (callback) await callback(c) - - setCode(c) - }, - [callback] - ) + // Hold the latest callback in a ref so changing its identity does not tear down + // and re-init the native receiver on every render. + const callbackRef = useRef(callback) + useEffect(() => { + callbackRef.current = callback + }, [callback]) useEffect(() => { let subscription: EmitterSubscription | undefined @@ -106,9 +104,10 @@ export const useLecomScan: LecomHook = ({ if (Platform.OS === 'android' && LecomScanEmitter && isDevice) { if (isActive) { init() - subscription = LecomScanEmitter.addListener(LecomEvents.ScanSuccess, (c) => - onScanSuccess(c) - ) + subscription = LecomScanEmitter.addListener(LecomEvents.ScanSuccess, async (c: string) => { + await callbackRef.current?.(c) + setCode(c) + }) } else { stop() } @@ -118,7 +117,7 @@ export const useLecomScan: LecomHook = ({ if (subscription) subscription.remove() if (isDevice) stop() } - }, [isActive, isDevice, onScanSuccess]) + }, [isActive, isDevice]) return { code,