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
85 changes: 85 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 "<name pattern>"` 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<Spec>('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.
24 changes: 14 additions & 10 deletions android/src/main/java/com/lecomscan/LecomScanModule.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.lecomscan

import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
}
}
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
29 changes: 14 additions & 15 deletions src/index.android.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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
Expand Down Expand Up @@ -91,24 +91,23 @@ 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

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()
}
Expand All @@ -118,7 +117,7 @@ export const useLecomScan: LecomHook = ({
if (subscription) subscription.remove()
if (isDevice) stop()
}
}, [isActive, isDevice, onScanSuccess])
}, [isActive, isDevice])

return {
code,
Expand Down