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
12 changes: 6 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,20 +110,20 @@ The session's destination is locked by the first accepted QR (`ScannerViewModel.
Single `MainActivity` + Compose, Hilt DI, `NavigationSuiteScaffold` with three destinations (scan / history / settings), unidirectional `UiState` + `StateFlow`.

- **`core/model`, `core/matching`**: framework-free domain. `Destination` (`sawai` / `molten` / `denso`, with the same persisted ids as Swift) lives in `core/model`; `CodeMatcher` (`detectDestination`, `expectedQrLength`, `canonicalQrPayload`, `boxIdentity`, `stripTransportTerminators`, `formatPartNumber(partNumber, destination)`), `KanbanQrRecord`, `MoltenQrRecord`, `DensoKanbanQrRecord`, and `TagBarcodeRecord.isValidScanPayload(payload, destination)` mirror the Swift rules and are tested against `shared/test-fixtures/matching-cases.json` (loaded from the test classpath). `expectedQrLength` returns null for `DENSO` because a JAMA kanban declares its own length, and `formatPartNumber` has no default argument so the compiler forces every caller to supply a destination.
- **`feature/scan`**: `ScanReducer` (pure state machine), `ScanStabilizer` (camera Code 128 needs the same value twice; BLE reads are accepted once; a camera value outside the tag format skips the stabilizer and is rejected by `ScanReducer` on the first frame), `ScanSessionCoordinator`, `ScanCheckpointMapping`. `ScanSessionState` carries the locked `destination` and the `recordedBoxes` that feed the duplicate rule and the per-納品番号 molten summary (`moltenResultSummary` returns null unless the lock is `MOLTEN`); a QR of another destination is refused with `InvalidScanReason.WRONG_DESTINATION`. `ScanReducer.invalidPayloadReason` classifies an unparseable QR against the locked record length (66 or 61) or, before the lock, against the whole 57–66 range — but a denso-locked session reports `INVALID_PAYLOAD` with no length hint, and so does an unlocked `JAMA`-prefixed payload (never "incomplete"), because a JAMA kanban has no fixed length. `app/.../scan/ScanViewModel` persists a checkpoint (step, session, accepted values, input source, destination) through Room/DataStore so an OS process kill restores the exact step.
- **`core/data`**: Room (`CodeMatchDatabase`, schema v3, exported schemas in `core/data/schemas`) for history, Preferences DataStore for settings and the scan checkpoint. v3 adds the nullable `destination` column to `sessions` and `scan_checkpoints` (`MIGRATION_2_3`); `SessionDao.lockDestination` only writes it when it is still null, so a session can never change destination. Both stores are excluded from cloud backup and D2D transfer (`res/xml/backup_rules.xml`, `data_extraction_rules.xml`).
- **`core/export`**: A4 multi-page PDF via `PdfDocument`; saved through `CreateDocument` and shared through a `FileProvider` limited to `cache/codematch-pdf/`. `HistoryDeliveryGroups.kt` supplies `resolvedDestination()`, `moltenDeliveryGroups()` and `densoRecord()`, so a molten report prints one delivery block per 納品番号 with its box count and cumulative 収容数, a denso report prints the kanban block (部品番号・収容数・指示数 / 次区・指示・納入日・便 / 管理番号・アイテムNo・受入) plus each box's かんばん連番, and a sawai report stays exactly as before. `HistoryPdfContent.appendGroup` branches `MOLTEN` / `DENSO` / else, so a denso kanban is never drawn as a sawai カード番号.
- **`feature/scan`**: `ScanReducer` (pure state machine), `ScanStabilizer` (camera Code 128 needs the same value twice; BLE reads are accepted once; a camera value outside the tag format skips the stabilizer and is rejected by `ScanReducer` on the first frame), `ScanSessionCoordinator`, `ScanCheckpointMapping`. `ScanSessionState` carries the locked `destination` and the `recordedBoxes` that feed the duplicate rule and the per-納品番号 molten summary (`moltenResultSummary` returns null unless the lock is `MOLTEN`); a QR of another destination is refused with `InvalidScanReason.WRONG_DESTINATION`. `ScanReducer.invalidPayloadReason` classifies an unparseable QR against the locked record length (66 or 61) or, before the lock, against the whole 57–66 range — but a denso-locked session reports `INVALID_PAYLOAD` with no length hint, and so does an unlocked `JAMA`-prefixed payload (never "incomplete"), because a JAMA kanban has no fixed length. `app/.../scan/ScanViewModel` persists a checkpoint (step, session, accepted values, input source, destination) through Room/DataStore so an OS process kill restores the exact step. `ScanSessionCoordinator` also owns the scan-log hook: an optional trailing `scanLogRecorder: ScanLogRecorder?` receives `rejected`/`source_mismatch` and `barcode_candidate` from `submitScanPayload`, and `qr_accepted` / `barcode_accepted` / `match` / `mismatch` / `duplicate` / `rejected` / `session_end` from `dispatch`, built from the state before and after the reduction. It is the only place holding both the raw payload and the verdict — `ScanEffect.InvalidScan` deliberately still carries no value — and the ViewModel's recorder lambda stamps on the active session id and records `session_start` in `beginSession`.
- **`core/data`**: Room (`CodeMatchDatabase`, schema v4, exported schemas in `core/data/schemas`) for history, Preferences DataStore for settings and the scan checkpoint. v3 adds the nullable `destination` column to `sessions` and `scan_checkpoints` (`MIGRATION_2_3`); `SessionDao.lockDestination` only writes it when it is still null, so a session can never change destination. v4 adds the `scan_log` table (`MIGRATION_3_4`, `at` indexed) behind `ScanLogRepository`, which inserts one `ScanLogEvent` and then trims the table back to 5,000 rows; the log has no foreign key to `sessions` because a rejection can precede a session and deleting one must not erase the log that explains it. Every migration must be registered in both `CodeMatchDatabase` and `CodeMatchDatabaseFactory.create`, since the migration tests call `MigrationTestHelper` directly. Both stores are excluded from cloud backup and D2D transfer (`res/xml/backup_rules.xml`, `data_extraction_rules.xml`).
- **`core/export`**: `ScanLogJsonExporter` writes the scan log as JSON Lines (header line + one event per line, explicit nulls, ISO 8601 UTC with milliseconds, file `codematch-scan-log-yyyyMMdd-HHmm.jsonl`), hand-rolled like `HistoryJsonExporter` so it stays JVM-testable; the release gate's `File(` allow-list names exactly `History(Pdf|Json)Exporter` and `ScanLogJsonExporter`, and nothing else in production sources may create a file. Also: A4 multi-page PDF via `PdfDocument`; saved through `CreateDocument` and shared through a `FileProvider` limited to `cache/codematch-pdf/`. `HistoryDeliveryGroups.kt` supplies `resolvedDestination()`, `moltenDeliveryGroups()` and `densoRecord()`, so a molten report prints one delivery block per 納品番号 with its box count and cumulative 収容数, a denso report prints the kanban block (部品番号・収容数・指示数 / 次区・指示・納入日・便 / 管理番号・アイテムNo・受入) plus each box's かんばん連番, and a sawai report stays exactly as before. `HistoryPdfContent.appendGroup` branches `MOLTEN` / `DENSO` / else, so a denso kanban is never drawn as a sawai カード番号.
- **`scanner/api`**: `ExternalScanner` contract shared by camera/BLE/fake. **`scanner/camera`**: CameraX + bundled ML Kit, ROI limited to the on-screen guide (square for QR, wide for Code 128), only the format expected by the current step. **`scanner/ble`**: SDK-agnostic safety core — command queue, connection coordinator, per-step symbology restriction (QR step enables only flag 2022, Code 128 step only 2008, fresh readback required before Ready, full restore on session end/background), known-device store, reconnect budget. **`scanner/inateck`**: adapter over the official Inateck Android SDK 2.0.0 (`AndroidInateckSdkGateway`, native notification parser via JNA), illumination control (`lighting_lamp_control`, default 2 = always off on each connection, not restored on disconnect), and the connect-time tuning profile (`InateckTuningSettings`, applied after illumination settles, differences only, readback-confirmed). The BLE diagnostic log keeps 300 events; Settings can share it (ACTION_SEND text) or save it (SAF) via host-owned actions in `SettingsRoute`.
- **DI per build type**: `app/src/debug` binds `FakeExternalScanner`; `app/src/release` binds `InateckExternalScanner` (`releaseImplementation(project(":scanner:inateck"))`). Release is minified (R8 strips the SDK's raw-payload logging via `app/scanner-rules.pro`), arm64-v8a only, and signed with the debug keystore unless `codematchRelease*` Gradle properties supply a local keystore. `app/src/release/AndroidManifest.xml` adds `BLUETOOTH_SCAN` (neverForLocation) / `BLUETOOTH_CONNECT` and removes the legacy permissions the SDK manifest brings in. `UnavailableExternalScanner` remains in `app/src/main` as a fallback type only.
- **Strings**: `values/strings.xml` is Japanese (default), `values-en/` English; Android lint's `MissingTranslation` check (run by `lintDebug`) fails the build if a key is missing in either, and `HistoryUiTextTest.japaneseAndEnglishHistoryResourcesHaveTheSameKeys` pins the same parity for the history module in JVM tests. In-app language and Android 13+ per-app locale are kept in sync by `AppLanguageSynchronizer`.
- **Strings**: `values/strings.xml` is Japanese (default), `values-en/` English; Android lint's `MissingTranslation` check (run by `lintDebug`) fails the build if a key is missing in either, and `HistoryUiTextTest.japaneseAndEnglishHistoryResourcesHaveTheSameKeys` / `SettingsUiTextTest.japaneseAndEnglishSettingsResourcesHaveTheSameKeys` pin the same key and format-token parity for the history and settings modules in JVM tests. In-app language and Android 13+ per-app locale are kept in sync by `AppLanguageSynchronizer`.

## Conventions

- iOS user-facing strings live in `ios/CodeMatch/Resources/Localizable.xcstrings` (source language **ja**, English translations) and are resolved through `AppLocalization.string(...)` / `AppLanguage`; Japanese is the fallback regardless of system locale. **Every key must keep an explicit `ja` entry with `state: translated`** — otherwise Xcode emits no `ja.lproj/Localizable.strings` and English-locale hosts (CI) silently resolve Japanese to English. When adding copy, add both `ja` and `en` entries.
- Both apps default to Japanese, and UI/instrumentation tests match on the literal Japanese labels (e.g. `一致しました`, `終了する`), so changing copy breaks tests on both platforms.
- Keep SwiftUI view `init`s cheap and side-effect-free: they rerun on every parent body evaluation. Anything heavy (stores, `AVCaptureSession`, audio engines) or side-effectful (UserDefaults resets) belongs inside a `@StateObject(wrappedValue:)` autoclosure (see `RootTabView.makeHistoryStore()`) or a shared instance. Violating this saturated the main thread and hung every UI test.
- iOS UI tests drive the app through launch arguments: `-resetHistory`, `-resetLanguage`, `-resetAutoAdvance`, `-resetBluetoothScanner` / `-demoBluetoothConnected`, and `-demoMatch` / `-demoMismatch`. Keep these sites in sync when adding a hook. The molten and denso flows needed no new argument: they are driven by the simulator-only `demoBluetoothMoltenQRButton` / `demoBluetoothMoltenBarcodeButton` and `demoBluetoothDensoQRButton` / `demoBluetoothDensoBarcodeButton` demo buttons next to the existing Bluetooth ones. Android's equivalents are the `ScanViewModel.SAMPLE_DENSO_QR_PAYLOAD` / `SAMPLE_DENSO_BARCODE_PAYLOAD` constants, which the instrumentation tests feed through the debug Fake scanner; the in-app demo action stays sawai.
- Views are addressed in tests by accessibility identifiers (iOS `.accessibilityIdentifier`, Compose `testTag`) such as `startSessionButton`, `endSessionButton`, `sessionMatchCount`, `sessionDestination`, `resetButton`, `scannerTitle`, `historySessionRow`, `historySessionDestination`, `deliveryGroupRow`, `scan_session_destination`, `scan_result_molten_box_summary`; preserve them when refactoring.
- Views are addressed in tests by accessibility identifiers (iOS `.accessibilityIdentifier`, Compose `testTag`) such as `startSessionButton`, `endSessionButton`, `sessionMatchCount`, `sessionDestination`, `resetButton`, `scannerTitle`, `historySessionRow`, `historySessionDestination`, `deliveryGroupRow`, `scan_session_destination`, `scan_result_molten_box_summary`, `settings_scan_log` (with `_count` / `_share` / `_save` / `_clear` / `_clear_confirm`); preserve them when refactoring.
- Colors come only from `AppTheme` (iOS) / `CodeMatchTheme` (Android, contrast pinned at ≥ 4.5:1 by `CodeMatchThemeContrastTest`); iOS pins `.preferredColorScheme(.light)`.
- Privacy: iOS declares no tracking in `Resources/PrivacyInfo.xcprivacy`; Android release must not request INTERNET, location, or legacy Bluetooth permissions (`docs/android/PRIVACY.md`). Any SDK addition must keep these boundaries.
- Privacy: iOS declares no tracking in `Resources/PrivacyInfo.xcprivacy`; Android release must not request INTERNET, location, or legacy Bluetooth permissions (`docs/android/PRIVACY.md`). Any SDK addition must keep these boundaries. The Android scan log is the one place that stores raw scanned values outside history: on-device Room only, capped at 5,000 events, and shared or saved only from the Settings card the operator taps. BLE diagnostics stay payload free — `BleExternalScannerTest` pins that.
- Do not leave Xcode `DerivedData` (`.derived-*`) or other build output in the repo root; they are git-ignored but consume gigabytes.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import androidx.compose.ui.test.performImeAction
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import dagger.hilt.android.EntryPointAccessors
Expand All @@ -31,6 +33,7 @@ import jp.rimtty.codematch.feature.history.HistoryTestTags
import jp.rimtty.codematch.feature.settings.SettingsTestTags
import jp.rimtty.codematch.core.model.AppLanguage
import jp.rimtty.codematch.core.model.AppSettings
import jp.rimtty.codematch.core.model.ScanLogEventKind
import jp.rimtty.codematch.scanner.api.InputSource
import jp.rimtty.codematch.scanner.api.ScanPayload
import jp.rimtty.codematch.scanner.fake.FakeExternalScanner
Expand Down Expand Up @@ -886,6 +889,90 @@ class AppFlowInstrumentationTest {
assertSessionCount(1)
}


/**
* The scan log must survive the whole flow, not just a match: a repeated
* box is exactly the case an operator asks about afterwards. Session
* start, the accepted QR and Code 128, the match, and the duplicate all
* belong to it, and the count is visible at the bottom of Settings.
*/
@Test
fun matchAndRepeatedBoxAreRecordedInTheScanLogAndCountedInSettings() {
connectFakeScannerThroughSettings()
openDestination(R.string.destination_scan)
onNodeWithTag("scan_start_session").performClick()
waitForTag("scan_waiting_card")

emitBluetooth(
ScanPayload.qr(
value = firstBoxQrPayload,
source = InputSource.BLUETOOTH,
timestampMillis = 1_000L,
),
)
emitBluetooth(
ScanPayload.code128(
value = sharedBoxBarcodePayload,
source = InputSource.BLUETOOTH,
timestampMillis = 2_000L,
),
)
waitForTag("scan_result_card")
onNodeWithText("一致").assertIsDisplayed()

// The same box again: shown as a duplicate, never recorded as a box.
onNodeWithTag("scan_manual_next").performClick()
emitBluetooth(
ScanPayload.qr(
value = firstBoxQrPayload,
source = InputSource.BLUETOOTH,
timestampMillis = 3_000L,
),
)
emitBluetooth(
ScanPayload.code128(
value = sharedBoxBarcodePayload,
source = InputSource.BLUETOOTH,
timestampMillis = 4_000L,
),
)
waitForTag("scan_result_card")
assertSessionCount(1)

awaitScanLogEvents(
listOf(
ScanLogEventKind.SESSION_START,
ScanLogEventKind.QR_ACCEPTED,
ScanLogEventKind.BARCODE_ACCEPTED,
ScanLogEventKind.MATCH,
ScanLogEventKind.QR_ACCEPTED,
ScanLogEventKind.BARCODE_ACCEPTED,
ScanLogEventKind.DUPLICATE,
),
)
val recorded = runBlocking { dependencies.scanLogRepository().export() }
val sessionId = runBlocking {
dependencies.historyRepository().activeSession.first()?.id
}
assertEquals(sessionId, recorded.first().sessionId)
assertEquals(firstBoxQrPayload, recorded[1].qrPayload)
assertEquals(sharedBoxBarcodePayload, recorded[2].barcodePayload)
assertEquals(1, recorded[3].boxNumber)

openDestination(R.string.destination_settings)
composeRule.onNodeWithTag(SettingsTestTags.SCAN_LOG_COUNT)
.performScrollTo()
.assertIsDisplayed()
val shown = composeRule.onNodeWithTag(SettingsTestTags.SCAN_LOG_COUNT)
.fetchSemanticsNode()
.config
.getOrNull(SemanticsProperties.Text)
.orEmpty()
.joinToString("") { it.text }
val count = Regex("\\d+").find(shown)?.value?.toInt() ?: 0
assertTrue("scan log count was $shown", count >= 3)
}

private fun openDestination(destinationRes: Int) {
composeRule.onNodeWithContentDescription(
composeRule.activity.getString(destinationRes),
Expand Down Expand Up @@ -978,6 +1065,14 @@ class AppFlowInstrumentationTest {
}
}

private fun awaitScanLogEvents(expected: List<String>) {
composeRule.waitUntil(5_000) {
runBlocking {
dependencies.scanLogRepository().export().map { it.event } == expected
}
}
}

private fun assertSessionCount(expected: Int) {
onNodeWithTag("scan_session_count")
.assertTextEquals("${expected}件照合済み")
Expand Down Expand Up @@ -1035,10 +1130,14 @@ class AppFlowInstrumentationTest {
private fun clearRepositories() {
val history = dependencies.historyRepository()
val settings = dependencies.settingsRepository()
val scanLog = dependencies.scanLogRepository()
runBlocking {
val ids = history.sessions.first().map { it.id }
history.deleteSessions(ids)
settings.update { AppSettings() }
// The scan log deliberately outlives its session, so deleting the
// sessions above leaves it behind.
scanLog.clear()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import jp.rimtty.codematch.core.data.CodeMatchDatabase
import jp.rimtty.codematch.core.data.CodeMatchDatabaseFactory
import jp.rimtty.codematch.core.data.HistoryRepository
import jp.rimtty.codematch.core.data.ScanLogRepository
import jp.rimtty.codematch.core.data.SettingsRepository
import jp.rimtty.codematch.core.model.Destination
import jp.rimtty.codematch.core.model.MatchResult
Expand Down Expand Up @@ -377,6 +378,7 @@ class ScanViewModelCheckpointInstrumentationTest {
settingsRepository = settings,
scanner = FakeExternalScanner(),
feedbackPlayer = FeedbackPlayer(context),
scanLogRepository = ScanLogRepository(database),
) as T
}
}
Expand Down
Loading
Loading