Skip to content
Merged
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
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,16 @@ Native mobile monorepo for `code-match`: an operator scans a QR code (delivery s
- `ios/`: SwiftUI/AVFoundation app (iOS 17+, Swift 5, bundle `jp.rimtty.CodeMatch`). Feature-complete; camera and Inateck BCST-47 Bluetooth input.
- `android/`: independent Gradle project (Kotlin, Jetpack Compose, Material 3, minSdk 31, compile/target SDK 37). Feature parity with iOS; camera (CameraX + bundled ML Kit) and Inateck BLE via the official Android SDK.

The cross-platform behavior contract lives in [docs/PRODUCT_SPEC.md](docs/PRODUCT_SPEC.md). iOS design rationale and acceptance checklist: [docs/ios/IMPLEMENTATION_GUIDE.md](docs/ios/IMPLEMENTATION_GUIDE.md). Android porting plan and status: [docs/android/IMPLEMENTATION_PLAN.md](docs/android/IMPLEMENTATION_PLAN.md), [docs/android/STATUS.md](docs/android/STATUS.md), [docs/android/TEST_PARITY.md](docs/android/TEST_PARITY.md). Read the relevant ones before changing scanner flow or UI behavior.
The cross-platform behavior contract lives in [docs/PRODUCT_SPEC.md](docs/PRODUCT_SPEC.md). [docs/label-variation-playbook.html](docs/label-variation-playbook.html) is the runbook for a new label variation in the field (what broke on 2026-09-08, the acceptance-rule principles, and the decode → verify → fixture → release steps). iOS design rationale and acceptance checklist: [docs/ios/IMPLEMENTATION_GUIDE.md](docs/ios/IMPLEMENTATION_GUIDE.md). Android porting plan and status: [docs/android/IMPLEMENTATION_PLAN.md](docs/android/IMPLEMENTATION_PLAN.md), [docs/android/STATUS.md](docs/android/STATUS.md), [docs/android/TEST_PARITY.md](docs/android/TEST_PARITY.md). Read the relevant ones before changing scanner flow or UI behavior.

## Project policy (as of 2026-09)

- **Android is for personal, local use only.** APKs are built and side-loaded by the owner; there is no store submission. Do not add store-submission work. The remaining physical-device / manual gates (Samsung, TalkBack, BLE fault cases, etc.) are waived and will not be verified further; `docs/android/STATUS.md` lists them under 打ち切った確認項目 (see issue #57). Scanner SDK libraries are bundled in the normal `release` build (#56); there is no separate `scannerPoc` build type anymore.
- **iOS is complete.** The "scanner stops reading after continuous use" audit (#58, closed 2026-09-06) landed as #62–#66: setting writes retry and recover instead of parking the session on the camera, `.inactive` no longer rewrites symbologies, the FF04 helper link has a deadline, the command timeout is 10 s with SDK link liveness checks, and the diagnostics log (300 events) can be shared from Settings. Verified on HPRT-4F5F; BCST-47 verification continues through TestFlight 1.0 (4).
- **Scanner tuning is applied on connect by both apps** (#59, closed): illumination `lighting_lamp_control` = 2 with a Settings toggle, plus a fixed profile (multi-code `*_read_more_code`/`*_read_multi` = 0, inverse `read_inverse_color`/`*_read_phase` = 0, red-light time `auto_close_mode` = 20 ≈ 4 s) written only when the inventory differs and confirmed by readback. Values persist on the scanner and are not restored on disconnect. The inventory-name ↔ generic-flag table is in `docs/ios/IMPLEMENTATION_GUIDE.md`. Per-step symbology on iOS (#39) is deferred.
- **Customer-facing data spec**: `docs/qr-barcode-spec-analysis.html` / `.pdf` are deliberately git-ignored (local only). They now cover all three ship-to destinations: the 澤井製作所 QR field table with the 12 real label pairs, §6 for モルテン (§6.6 holds the 2026-09-06 decisions), and §7 for デンソー (§7.5 holds the 2026-09-07 decisions). Keep them in sync when matching or scan-acceptance rules change, and regenerate the PDF from the HTML with headless Chrome.
- The 2026-09-06 spec audit follow-ups landed: camera Code 128 gets the same `4-2-4@code` format validation as BLE on both platforms (#78), and `shared/test-fixtures/matching-cases.json` pins the real label pairs plus same-series mix-ups as mismatches (#79). The fixture is now `schemaVersion` 2 with 47 cases, each carrying the `destination` its QR belongs to: the 12 real 66-char 澤井製作所 decodes, two boxes of one part (`box-a` / `box-b`) that ground the duplicate rule, the real 61-char モルテン records, the real 221-char デンソー kanbans, and two destination-less cases that must never match. The label photos live in git-ignored `tmp/sawai-labels/` (customer data, never commit); decode them with a Vision `VNDetectBarcodesRequest` script when the label set changes.
- The 2026-09-06 spec audit follow-ups landed: camera Code 128 gets the same business-format validation as BLE on both platforms (#78), and `shared/test-fixtures/matching-cases.json` pins the real label pairs plus same-series mix-ups as mismatches (#79). The fixture is now `schemaVersion` 2 with 63 cases, each carrying the `destination` its QR belongs to: the 12 real 66-char 澤井製作所 decodes, two boxes of one part (`box-a` / `box-b`) that ground the duplicate rule, the 16 `sawai-2026-09-08-` field-label cases (#129), the real 61-char モルテン records, the real 221-char デンソー kanbans, and two destination-less cases that must never match. The label photos live in git-ignored `tmp/` (customer data, never commit); decode them with `shared/tools/decode_label_photos.swift` (Vision `VNDetectBarcodesRequest`, prints one TSV line per symbol) when the label set changes.
- **澤井製作所 card numbers and 9-char part numbers (2026-09-08, #129).** The first strict TestFlight build in the field rejected every 澤井製作所 slip because `KanbanQRRecord` required the card number to be `[A-Z]{4}[0-9]{6}` while real slips also carry `DAH4093870` (an alphanumeric 4-char code + 6-digit serial), and because five parts are nine characters (`BCJH5281F ` left-justified in the 10-wide item field, suffix blank or `02`, tag `BCJH-52-81F@01R95K`). Both platforms now accept card `[A-Z0-9]{4}[0-9]{6}`, item field `[A-Z0-9]{9}[A-Z0-9 ]` (stored trimmed), and a `4-2-3` or `4-2-4` sawai tag — the same tag pattern as molten. Do not tighten these back to the 12-label sample; a rejected real slip stops production, an accepted junk 66-char QR only yields a mismatch. Denso is unaffected. The procedure for the next label variation (collect the scan log, decode photos with `shared/tools/decode_label_photos.swift`, run the pairs through the shipped rules with `ios/scripts/verify_label_pairs.sh`, extend the fixture, invert tests, update docs) is written up in `docs/label-variation-playbook.html`.
- **Ship-to destinations (仕向地) landed 2026-09-07** (#84; PRs #93–#100). A slip QR is 澤井製作所 (`sawai`, 66 chars), モルテン (`molten`, 61 chars whose trailing spaces are data) or, since #106, デンソー. The first accepted QR locks the session's destination, and a QR of another destination is rejected until the operator ends the session — no mixed sessions. The duplicate key is the QR alone for sawai and QR + Code 128 for molten; molten boxes are counted per 納品番号 with a cumulative 収容数, and neither app judges completion. Android persists the lock in Room v3 (`sessions.destination`, `scan_checkpoints.destination`, `MIGRATION_2_3`). Existing user-facing strings were left unchanged.
- **仕向地 デンソー landed 2026-09-07** (#106; PRs #113–#116, iOS history/PDF #117). Its kanban QR is a JAMA self-describing record — `JAMA` + version digit + 4-digit header length + 10-char preamble + N × (3-digit item id + 2-digit length) + fixed-width data whose lengths must sum exactly — so it is parsed generically, not by length (the real kanban is 221 chars, header 119, 21 items, 97 data chars). Items 104 (10-digit part number), 112 (収容数) and 152 (かんばん連番, unique per box) are required. `Destination.detect` probes denso **first**, because the lenient sawai parse would otherwise accept `JAMA5011…` as a card number. The product tag is `6-4@code` (`860150-7722@1DZ50O`), and part numbers print `6-4` through the destination-aware `format(partNumber:destination:)` / `formatPartNumber(partNumber, destination)` — the 1-arg / defaulted form is gone on both platforms so the compiler enumerates every call site. Duplicates key on the QR alone and boxes are counted per part number, exactly like sawai; there is no planned-box-count display and no completion judgement. No schema change: Room stays v3 with `denso` stored as the `destination` string, and iOS history JSON keeps decoding it leniently.
- Work items that should be tracked go into GitHub Issues (`gh issue create`).
Expand Down Expand Up @@ -93,12 +94,12 @@ Flow: `CodeMatchApp` → `RootTabView` (owns the single `HistoryStore`, `Bluetoo
- **`BluetoothScannerService`** wraps the Inateck iOS SDK (`#if INATECK_SDK`, simulator mock otherwise): discovery, connect/auto-reconnect, and symbology restriction. During a session it writes all symbology areas (flag 2001–2028) to 0 except `qrcode_on` / `code128_on`, using the `area`/`name` the device reported (never hardcoded areas), snapshots the original values, and restores them on session end/disconnect. All `setSettingInfo` traffic (session symbologies, illumination, tuning) goes through one gate (`settingCommandInFlight`, generation counters, 10 s timeout that closes the link and reconnects); failed writes retry twice and then recover from a fresh `getSettingInfo` every 5 s (max 3). After the session symbologies are ready the service applies illumination OFF, then the tuning profile, each confirmed by readback. Diagnostics keep 300 trace lines (no payloads) and `diagnosticLogText()` feeds the Settings share sheet.
- **`HistoryStore`** (`@MainActor`) holds `[MatchSession]` newest-first; at most one session has `endedAt == nil` (`activeSession`). Persists to `Application Support/CodeMatch/match-history.json` with `.completeFileProtection`, excluded from backup. `init(storageURL:)` exists so tests can point at a temp directory.
- **`ScanLogStore`** (`@MainActor`) is the scan log: one JSON line per event appended to `Application Support/CodeMatch/scan-log.jsonl` (`.completeFileProtection`, directory excluded from backup, `FileHandle` opened and closed per write), trimmed back to the newest 5,000 once the count passes 5,500. `ScannerViewModel.log(...)` records `qr_accepted` / `barcode_candidate` / `barcode_accepted` / `match` / `mismatch` / `duplicate` / `rejected` (reasons `invalid_format`, `wrong_order`, `wrong_destination`, `wrong_symbology`, `result_pending`), `SessionStartView` records `session_start` and `prepareForSessionEnd` records `session_end`. Events carry the raw QR / Code 128 payloads, so the log is device-local and leaves only through the Settings share sheet (`exportText` / `writeTemporaryExport` write a header line plus every event as `codematch-scan-log-yyyyMMdd-HHmm.jsonl`; the schema is shared with Android). This is separate from the BLE diagnostics log, which must stay payload-free. `init(storageURL:arguments:)` exists so tests can point at a temp directory.
- **`CodeMatcher`** is the whole comparison rule, and it is destination-aware. `Destination.detect(qrPayload:)` strips only CR/LF/NUL and decides between `denso` (variable-length JAMA `DensoKanbanRecord`, part number from item 104), `sawai` (66-char `KanbanQRRecord`, part number at chars 11–20) and `molten` (61-char `MoltenQRRecord`, part number at chars 7–16; a payload of 57–61 chars is padded back to 61 because its trailing spaces are data). Denso is probed first — the lenient `KanbanQRRecord.parse` would read `JAMA5011…` as a card number — and sawai is retried on the trimmed payload only after molten is ruled out. The Code 128 is `PART-NO@code`; both sides are normalized and compared exactly — the old substring-containment fallback is gone, so a QR that parses as no record can never match. `format(partNumber:destination:)` prints denso's 10 chars as 6-4, and otherwise 10 chars as 4-2-4 and 9 chars as 4-2-3; there is no 1-arg overload, so every call site must pass the destination it holds. `BoxIdentity.make(qrPayload:barcodePayload:)` builds the per-box key: the QR text for sawai and denso (denso's item 152 かんばん連番 differs per box), canonical QR + `|` + Code 128 for molten. Tests pin this with real label payloads of all three destinations through the shared fixtures.
- **`CodeMatcher`** is the whole comparison rule, and it is destination-aware. `Destination.detect(qrPayload:)` strips only CR/LF/NUL and decides between `denso` (variable-length JAMA `DensoKanbanRecord`, part number from item 104), `sawai` (66-char `KanbanQRRecord`, card number `[A-Z0-9]{4}[0-9]{6}`, part number at chars 11–20 — 10 chars, or 9 chars left-justified with a trailing space that is trimmed) and `molten` (61-char `MoltenQRRecord`, part number at chars 7–16; a payload of 57–61 chars is padded back to 61 because its trailing spaces are data). Denso is probed first — the lenient `KanbanQRRecord.parse` would read `JAMA5011…` as a card number — and sawai is retried on the trimmed payload only after molten is ruled out. The Code 128 is `PART-NO@code`; both sides are normalized and compared exactly — the old substring-containment fallback is gone, so a QR that parses as no record can never match. `format(partNumber:destination:)` prints denso's 10 chars as 6-4, and otherwise 10 chars as 4-2-4 and 9 chars as 4-2-3; there is no 1-arg overload, so every call site must pass the destination it holds. `BoxIdentity.make(qrPayload:barcodePayload:)` builds the per-box key: the QR text for sawai and denso (denso's item 152 かんばん連番 differs per box), canonical QR + `|` + Code 128 for molten. Tests pin this with real label payloads of all three destinations through the shared fixtures.

Two misread defenses that are intentional and easy to break accidentally:

- `scanLocked` suppresses reads for 250ms after accepting a QR value.
- Code 128 from the camera requires the *same* value seen twice within 1.5s (`barcodeCandidate`) before it is accepted; only then does the camera stop and comparison run. A BCST-47 trigger is accepted once. Camera and BLE Code 128 are both validated against the locked destination's business format (`TagBarcodeRecord.isValidScanPayload(_:destination:)` — `4-2-4@code` for sawai, `4-2-3` or `4-2-4` for molten, `6-4` for denso; before the lock any of the three) before the candidate logic runs; an unrelated Code 128 is rejected with a message and a 2 s repeat suppression, exactly like an unrelated QR (#78).
- Code 128 from the camera requires the *same* value seen twice within 1.5s (`barcodeCandidate`) before it is accepted; only then does the camera stop and comparison run. A BCST-47 trigger is accepted once. Camera and BLE Code 128 are both validated against the locked destination's business format (`TagBarcodeRecord.isValidScanPayload(_:destination:)` — `4-2-3` or `4-2-4@code` for sawai and molten, `6-4` for denso; before the lock any of the three) before the candidate logic runs; an unrelated Code 128 is rejected with a message and a 2 s repeat suppression, exactly like an unrelated QR (#78).

Audio/haptics: `FeedbackPlayer` synthesizes PCM tones via `AVAudioEngine` (ambient category, `.mixWithOthers`). It is a single shared instance (`FeedbackPlayer.shared`) — engine setup is expensive and per-view instances caused a re-render loop, so keep it that way. Individual code acceptance is haptic-only plus a short chirp; the success chime and the 4× failure alert fire only at the final verdict. Success is delayed ~0.28s after a barcode accept so the chirp and chime don't collide.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ object CodeMatcher {
* destination's record.
*
* Denso is probed first: [KanbanQrRecord.parse] is deliberately tolerant
* (twenty characters or more whose first ten match `[A-Z]{4}[0-9]{6}`), so
* (twenty characters or more whose first ten match `[A-Z0-9]{4}[0-9]{6}`), so
* it accepts `JAMA501195…` as a card number. A payload that parses as a
* JAMA self-describing record is always Denso.
*/
Expand Down Expand Up @@ -186,6 +186,12 @@ object CodeMatcher {
/**
* A 66-character delivery-slip/kanban QR record.
*
* The card number (characters 1-10) is a four-character alphanumeric code
* followed by a six-digit serial: both `DCLP675300` and `DAH4093870` occur on
* real slips (#129). The item-number field (characters 11-20) is ten wide; a
* nine-character part number is left-justified with a trailing space
* (`BCJH5281F `) and [partNumber] holds it trimmed.
*
* Numeric quantities are encoded as integer hundredths (for example `00001200`
* becomes `12.0`). Parsing is intentionally tolerant of an incomplete payload
* because [parse] is also useful for displaying old saved entries; the scanner
Expand All @@ -205,8 +211,8 @@ data class KanbanQrRecord(
companion object {
const val REQUIRED_SCAN_PAYLOAD_LENGTH = 66

private val cardNumberPattern = Regex("[A-Z]{4}[0-9]{6}")
private val partNumberPattern = Regex("[A-Z0-9]{10}")
private val cardNumberPattern = Regex("[A-Z0-9]{4}[0-9]{6}")
private val partFieldPattern = Regex("[A-Z0-9]{9}[A-Z0-9 ]")

/** Accept only a complete standard QR record at a scanner boundary. */
fun isValidScanPayload(payload: String): Boolean {
Expand All @@ -229,13 +235,13 @@ data class KanbanQrRecord(
value?.trim()?.toDoubleOrNull()?.div(100.0)

val cardNumber = slice(0, 10)
val partNumber = slice(10, 20)
val partField = slice(10, 20)
if (cardNumber == null || !cardNumberPattern.matches(cardNumber)) return null
if (partNumber == null || !partNumberPattern.matches(partNumber)) return null
if (partField == null || !partFieldPattern.matches(partField)) return null

return KanbanQrRecord(
cardNumber = cardNumber,
partNumber = partNumber,
partNumber = partField.trim(),
partSuffix = trimmedOrNull(slice(20, 22)),
deliveryQuantity = quantity(slice(22, 30)),
instructedQuantity = quantity(slice(30, 38)),
Expand All @@ -256,16 +262,17 @@ data class TagBarcodeRecord(
) {
companion object {
private val sawaiFormatPattern =
Regex("[A-Z0-9]{4}-[A-Z0-9]{2}-[A-Z0-9]{4}@[A-Z0-9]+")
Regex("[A-Z0-9]{4}-[A-Z0-9]{2}-[A-Z0-9]{3,4}@[A-Z0-9]+")
private val moltenFormatPattern =
Regex("[A-Z0-9]{4}-[A-Z0-9]{2}-[A-Z0-9]{3,4}@[A-Z0-9]+")
private val densoFormatPattern =
Regex("[A-Z0-9]{6}-[A-Z0-9]{4}@[A-Z0-9]+")

/**
* Strict scanner-boundary validation for the product tag format of one
* destination: a Sawai part number is 4-2-4, a Molten part number is
* 4-2-3 or 4-2-4, and a Denso part number is 6-4. A null destination
* destination: a Sawai or Molten part number is 4-2-4 (ten characters)
* or 4-2-3 (nine characters, e.g. `BCJH-52-81F`), and a Denso part
* number is 6-4. A null destination
* means the session has not locked one yet, so any of the three is
* accepted. Lowercase input is accepted just as Swift's
* uppercase-before-regex implementation accepts it.
Expand Down
Loading
Loading