From 88135aadf7410cd3a51aaea7fea6a22bb4115005 Mon Sep 17 00:00:00 2001 From: rimtty Date: Tue, 8 Sep 2026 00:45:58 +0900 Subject: [PATCH 1/2] feat(android): record every scan outcome in an on-device scan log and share it from Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Room v4 adds the scan_log table (latest 5,000 events). The scan coordinator records accepted QR/Code 128 values, match / mismatch / duplicate verdicts, every rejection with its reason (including source-mismatch drops and camera candidates), and session start / end, with the raw payloads. Settings gains a 照合ログ card at the bottom that shares or saves the log as JSON Lines (shared schema v1) and clears it after confirmation. BLE diagnostics and InvalidScan stay payload-free; the release gate allow-lists the new exporter. Closes #122 --- CLAUDE.md | 12 +- .../codematch/AppFlowInstrumentationTest.kt | 99 ++++++ ...nViewModelCheckpointInstrumentationTest.kt | 2 + .../codematch/di/DebugAppTestEntryPoint.kt | 3 + .../java/jp/rimtty/codematch/di/AppModule.kt | 7 + .../codematch/history/HistoryJsonBridge.kt | 20 +- .../jp/rimtty/codematch/scan/ScanViewModel.kt | 36 +- .../codematch/settings/SettingsRoute.kt | 94 ++++++ .../codematch/settings/SettingsViewModel.kt | 19 ++ .../scan/ScanViewModelSessionNameTest.kt | 2 + .../4.json | 312 ++++++++++++++++++ .../data/CodeMatchDatabaseMigrationTest.kt | 132 ++++++++ .../core/data/ScanLogRepositoryTest.kt | 141 ++++++++ .../codematch/core/data/CodeMatchDatabase.kt | 54 ++- .../codematch/core/data/ScanLogEntities.kt | 71 ++++ .../codematch/core/data/ScanLogRepository.kt | 79 +++++ .../core/export/ScanLogJsonExporter.kt | 176 ++++++++++ .../core/export/ScanLogJsonExporterTest.kt | 129 ++++++++ .../codematch/core/model/ScanLogEvent.kt | 86 +++++ .../codematch/feature/scan/ScanLogRecorder.kt | 16 + .../feature/scan/ScanSessionCoordinator.kt | 226 ++++++++++++- .../scan/ScanSessionCoordinatorTest.kt | 208 ++++++++++++ .../feature/settings/SettingsScreenTest.kt | 71 ++++ .../feature/settings/SettingsScreen.kt | 100 ++++++ .../feature/settings/SettingsUiState.kt | 15 + .../src/main/res/values-en/strings.xml | 15 + .../settings/src/main/res/values/strings.xml | 15 + .../feature/settings/SettingsUiTextTest.kt | 98 ++++++ android/scripts/verify-release-hardening.sh | 2 +- docs/android/IMPLEMENTATION_PLAN.md | 16 +- docs/android/PRIVACY.md | 7 +- docs/android/STATUS.md | 13 +- docs/android/TEST_PARITY.md | 19 ++ 33 files changed, 2272 insertions(+), 23 deletions(-) create mode 100644 android/core/data/schemas/jp.rimtty.codematch.core.data.CodeMatchDatabase/4.json create mode 100644 android/core/data/src/androidTest/kotlin/jp/rimtty/codematch/core/data/ScanLogRepositoryTest.kt create mode 100644 android/core/data/src/main/kotlin/jp/rimtty/codematch/core/data/ScanLogEntities.kt create mode 100644 android/core/data/src/main/kotlin/jp/rimtty/codematch/core/data/ScanLogRepository.kt create mode 100644 android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ScanLogJsonExporter.kt create mode 100644 android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/ScanLogJsonExporterTest.kt create mode 100644 android/core/model/src/main/kotlin/jp/rimtty/codematch/core/model/ScanLogEvent.kt create mode 100644 android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanLogRecorder.kt create mode 100644 android/feature/settings/src/test/kotlin/jp/rimtty/codematch/feature/settings/SettingsUiTextTest.kt diff --git a/CLAUDE.md b/CLAUDE.md index a4e111f..37c74c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,12 +110,12 @@ 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 @@ -123,7 +123,7 @@ Single `MainActivity` + Compose, Hilt DI, `NavigationSuiteScaffold` with three d - 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. diff --git a/android/app/src/androidTest/java/jp/rimtty/codematch/AppFlowInstrumentationTest.kt b/android/app/src/androidTest/java/jp/rimtty/codematch/AppFlowInstrumentationTest.kt index 1f36be5..f8a024f 100644 --- a/android/app/src/androidTest/java/jp/rimtty/codematch/AppFlowInstrumentationTest.kt +++ b/android/app/src/androidTest/java/jp/rimtty/codematch/AppFlowInstrumentationTest.kt @@ -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 @@ -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 @@ -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), @@ -978,6 +1065,14 @@ class AppFlowInstrumentationTest { } } + private fun awaitScanLogEvents(expected: List) { + composeRule.waitUntil(5_000) { + runBlocking { + dependencies.scanLogRepository().export().map { it.event } == expected + } + } + } + private fun assertSessionCount(expected: Int) { onNodeWithTag("scan_session_count") .assertTextEquals("${expected}件照合済み") @@ -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() } } diff --git a/android/app/src/androidTest/java/jp/rimtty/codematch/scan/ScanViewModelCheckpointInstrumentationTest.kt b/android/app/src/androidTest/java/jp/rimtty/codematch/scan/ScanViewModelCheckpointInstrumentationTest.kt index b714882..be3e1ba 100644 --- a/android/app/src/androidTest/java/jp/rimtty/codematch/scan/ScanViewModelCheckpointInstrumentationTest.kt +++ b/android/app/src/androidTest/java/jp/rimtty/codematch/scan/ScanViewModelCheckpointInstrumentationTest.kt @@ -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 @@ -377,6 +378,7 @@ class ScanViewModelCheckpointInstrumentationTest { settingsRepository = settings, scanner = FakeExternalScanner(), feedbackPlayer = FeedbackPlayer(context), + scanLogRepository = ScanLogRepository(database), ) as T } } diff --git a/android/app/src/debug/java/jp/rimtty/codematch/di/DebugAppTestEntryPoint.kt b/android/app/src/debug/java/jp/rimtty/codematch/di/DebugAppTestEntryPoint.kt index b710920..7e91f55 100644 --- a/android/app/src/debug/java/jp/rimtty/codematch/di/DebugAppTestEntryPoint.kt +++ b/android/app/src/debug/java/jp/rimtty/codematch/di/DebugAppTestEntryPoint.kt @@ -4,6 +4,7 @@ import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent 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.scanner.api.ExternalScanner @@ -22,4 +23,6 @@ interface DebugAppTestEntryPoint { fun historyRepository(): HistoryRepository fun settingsRepository(): SettingsRepository + + fun scanLogRepository(): ScanLogRepository } diff --git a/android/app/src/main/java/jp/rimtty/codematch/di/AppModule.kt b/android/app/src/main/java/jp/rimtty/codematch/di/AppModule.kt index 5bae0b8..dd82942 100644 --- a/android/app/src/main/java/jp/rimtty/codematch/di/AppModule.kt +++ b/android/app/src/main/java/jp/rimtty/codematch/di/AppModule.kt @@ -9,6 +9,7 @@ import dagger.hilt.components.SingletonComponent 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.locale.AndroidFrameworkAppLanguagePort import jp.rimtty.codematch.locale.AppLanguageSynchronizer @@ -30,6 +31,12 @@ object AppModule { database: CodeMatchDatabase, ): HistoryRepository = HistoryRepository(database) + @Provides + @Singleton + fun provideScanLogRepository( + database: CodeMatchDatabase, + ): ScanLogRepository = ScanLogRepository(database) + @Provides @Singleton fun provideSettingsRepository( diff --git a/android/app/src/main/java/jp/rimtty/codematch/history/HistoryJsonBridge.kt b/android/app/src/main/java/jp/rimtty/codematch/history/HistoryJsonBridge.kt index 336354a..33911b5 100644 --- a/android/app/src/main/java/jp/rimtty/codematch/history/HistoryJsonBridge.kt +++ b/android/app/src/main/java/jp/rimtty/codematch/history/HistoryJsonBridge.kt @@ -77,13 +77,22 @@ internal object HistoryJsonBridge { HistoryJsonResult.Failure(HistoryJsonFailure.CACHE_WRITE_FAILED) } - fun createShareChooser(context: Context, file: File): HistoryJsonResult = try { + /** + * [mimeType] exists for the scan log, which is JSON Lines rather than a + * JSON document: declaring it as plain text is what makes it openable in + * the text editors and chat apps a field report actually travels through. + */ + fun createShareChooser( + context: Context, + file: File, + mimeType: String = JSON_MIME_TYPE, + ): HistoryJsonResult = try { val uri = FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", file, ) - HistoryJsonResult.Success(Intent.createChooser(createShareIntent(uri), null)) + HistoryJsonResult.Success(Intent.createChooser(createShareIntent(uri, mimeType), null)) } catch (_: Exception) { HistoryJsonResult.Failure(HistoryJsonFailure.FILE_PROVIDER_FAILED) } @@ -95,8 +104,11 @@ internal object HistoryJsonBridge { HistoryJsonResult.Failure(HistoryJsonFailure.SHARE_LAUNCH_FAILED) } - internal fun createShareIntent(uri: Uri): Intent = Intent(Intent.ACTION_SEND).apply { - type = JSON_MIME_TYPE + internal fun createShareIntent( + uri: Uri, + mimeType: String = JSON_MIME_TYPE, + ): Intent = Intent(Intent.ACTION_SEND).apply { + type = mimeType putExtra(Intent.EXTRA_STREAM, uri) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) clipData = ClipData.newRawUri(null, uri) diff --git a/android/app/src/main/java/jp/rimtty/codematch/scan/ScanViewModel.kt b/android/app/src/main/java/jp/rimtty/codematch/scan/ScanViewModel.kt index 084b250..f99ff80 100644 --- a/android/app/src/main/java/jp/rimtty/codematch/scan/ScanViewModel.kt +++ b/android/app/src/main/java/jp/rimtty/codematch/scan/ScanViewModel.kt @@ -4,16 +4,22 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel 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.AppSettings import jp.rimtty.codematch.core.model.AutoAdvanceDelay import jp.rimtty.codematch.core.model.MatchSession import jp.rimtty.codematch.core.model.MatchResult +import jp.rimtty.codematch.core.model.ScanLogEvent +import jp.rimtty.codematch.core.model.ScanLogEventKind +import jp.rimtty.codematch.core.model.ScanLogSource +import jp.rimtty.codematch.core.model.ScanLogStep import jp.rimtty.codematch.core.model.ScanSessionCheckpoint import jp.rimtty.codematch.feedback.FeedbackPlayer import jp.rimtty.codematch.feature.scan.RecordedBox import jp.rimtty.codematch.feature.scan.ScanEffect import jp.rimtty.codematch.feature.scan.CameraPermissionState +import jp.rimtty.codematch.feature.scan.ScanLogRecorder import jp.rimtty.codematch.feature.scan.ScanPhase import jp.rimtty.codematch.feature.scan.ScanSessionCoordinator import jp.rimtty.codematch.feature.scan.ScanSessionState @@ -81,7 +87,10 @@ internal object ScanFeedbackEventMapper { * The feature module remains stateless: this class translates [ScanUiAction] * into [ScanSessionCoordinator] calls, observes the repositories, and turns * only [ScanEffect.RecordMatch] into a history write. Scan payloads are never - * sent to logs or scanner diagnostics. + * sent to Android logs or scanner diagnostics; the single exception is the + * on-device scan log, which deliberately keeps the raw values in Room so a + * field problem can be reproduced, and leaves the device only when the + * operator shares or saves it from Settings. * * [ScanUiAction.EndSession] is the final action. The destination should show * its confirmation dialog outside this class and dispatch the action only @@ -94,6 +103,7 @@ class ScanViewModel @Inject constructor( private val settingsRepository: SettingsRepository, private val scanner: ExternalScanner, private val feedbackPlayer: FeedbackPlayer, + private val scanLogRepository: ScanLogRepository, ) : ViewModel() { private val _state = MutableStateFlow(ScanUiState()) @@ -367,6 +377,13 @@ class ScanViewModel @Inject constructor( .orEmpty(), sessionDestination = active?.destination, ) + // The coordinator is built before a session id exists, so it records + // events without one and this lambda stamps the active session on. + created.scanLogRecorder = ScanLogRecorder { event -> + viewModelScope.launch { + scanLogRepository.record(event.copy(sessionId = activeSessionId)) + } + } created.onStateChanged = { publishCoordinatorState() } created.onEffects = ::handleEffects created.onInputSourceChanged = { source -> @@ -446,6 +463,23 @@ class ScanViewModel @Inject constructor( val session = historyRepository.getSession(id) activeSessionId = id activeSessionName = session?.name ?: requestedName + // Recorded here rather than from ScanEffect.SessionStarted: a + // checkpoint restore re-fires that effect and would log a second + // start for a session that was already running. + scanLogRepository.record( + ScanLogEvent( + atEpochMillis = System.currentTimeMillis(), + sessionId = id, + source = if (current.inputSource == InputSource.BLUETOOTH) { + ScanLogSource.BLUETOOTH + } else { + ScanLogSource.CAMERA + }, + step = ScanLogStep.NONE, + event = ScanLogEventKind.SESSION_START, + destination = current.state.destination, + ), + ) // beginSession returns an existing active session when another // host won a race. Rebuild while the local coordinator is still diff --git a/android/app/src/main/java/jp/rimtty/codematch/settings/SettingsRoute.kt b/android/app/src/main/java/jp/rimtty/codematch/settings/SettingsRoute.kt index 120fedf..7f8ab19 100644 --- a/android/app/src/main/java/jp/rimtty/codematch/settings/SettingsRoute.kt +++ b/android/app/src/main/java/jp/rimtty/codematch/settings/SettingsRoute.kt @@ -19,11 +19,15 @@ import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import java.time.Instant +import jp.rimtty.codematch.core.export.ScanLogJsonExporter +import jp.rimtty.codematch.history.HistoryJsonBridge +import jp.rimtty.codematch.history.HistoryJsonResult import jp.rimtty.codematch.feature.settings.DiagnosticLogFormatter import jp.rimtty.codematch.feature.settings.R import jp.rimtty.codematch.feature.settings.SettingsScreen import jp.rimtty.codematch.feature.settings.SettingsUiAction import jp.rimtty.codematch.feature.settings.SettingsUiState +import jp.rimtty.codematch.core.model.ScanLogEvent import jp.rimtty.codematch.navigation.CodeMatchBackHandler import jp.rimtty.codematch.scanner.api.ScannerIssue import kotlinx.coroutines.Dispatchers @@ -78,10 +82,87 @@ fun SettingsRoute( } } + // The scan log is a separate document with its own toasts, so it gets its + // own SAF launcher rather than sharing the diagnostics one. + var pendingScanLog by remember { mutableStateOf(null) } + val scanLogSavedMessage = stringResource(R.string.settings_scan_log_saved) + val scanLogSaveFailedMessage = stringResource(R.string.settings_scan_log_save_failed) + val scanLogShareFailedMessage = stringResource(R.string.settings_scan_log_share_failed) + val createScanLogDocument = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("text/plain"), + ) { destination -> + val text = pendingScanLog + pendingScanLog = null + if (destination == null || text == null) return@rememberLauncherForActivityResult + scope.launch { + val written = withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openOutputStream(destination, "wt")?.use { stream -> + stream.write(text.toByteArray(Charsets.UTF_8)) + } ?: error("no output stream") + }.isSuccess + } + Toast.makeText( + context, + if (written) scanLogSavedMessage else scanLogSaveFailedMessage, + Toast.LENGTH_SHORT, + ).show() + } + } + SettingsScreen( state = state, onAction = { action -> when (action) { + SettingsUiAction.ShareScanLog -> scope.launch { + val exportedAt = Instant.now() + val file = withContext(Dispatchers.IO) { + runCatching { + ScanLogJsonExporter.writeToCache( + context = context, + text = scanLogText(context, viewModel.exportScanLog(), exportedAt), + exportedAt = exportedAt, + ) + }.getOrNull() + } + val chooser = file?.let { + HistoryJsonBridge.createShareChooser( + context = context, + file = it, + mimeType = ScanLogJsonExporter.MIME_TYPE, + ) + } + val launched = when (chooser) { + is HistoryJsonResult.Success -> + runCatching { context.startActivity(chooser.value) }.isSuccess + else -> false + } + if (!launched) { + Toast.makeText( + context, + scanLogShareFailedMessage, + Toast.LENGTH_SHORT, + ).show() + } + } + SettingsUiAction.SaveScanLog -> scope.launch { + val exportedAt = Instant.now() + pendingScanLog = withContext(Dispatchers.IO) { + scanLogText(context, viewModel.exportScanLog(), exportedAt) + } + runCatching { + createScanLogDocument.launch( + ScanLogJsonExporter.fileName(exportedAt), + ) + }.onFailure { + pendingScanLog = null + Toast.makeText( + context, + scanLogSaveFailedMessage, + Toast.LENGTH_SHORT, + ).show() + } + } SettingsUiAction.ShareDiagnostics -> { val intent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" @@ -107,6 +188,19 @@ fun SettingsRoute( ) } +/** Serialize the scan log with the app version the module cannot read itself. */ +private fun scanLogText( + context: Context, + events: List, + exportedAt: Instant, +): String = ScanLogJsonExporter.buildJsonLines( + events = events, + header = ScanLogJsonExporter.ExportHeader( + appVersion = HistoryJsonBridge.appVersion(context), + exportedAt = exportedAt, + ), +) + private fun diagnosticLogText(context: Context, state: SettingsUiState): String { val packageInfo = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) diff --git a/android/app/src/main/java/jp/rimtty/codematch/settings/SettingsViewModel.kt b/android/app/src/main/java/jp/rimtty/codematch/settings/SettingsViewModel.kt index 92e228a..2a6263d 100644 --- a/android/app/src/main/java/jp/rimtty/codematch/settings/SettingsViewModel.kt +++ b/android/app/src/main/java/jp/rimtty/codematch/settings/SettingsViewModel.kt @@ -4,7 +4,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import jp.rimtty.codematch.core.data.ScanLogRepository import jp.rimtty.codematch.core.data.SettingsRepository +import jp.rimtty.codematch.core.model.ScanLogEvent import jp.rimtty.codematch.feature.settings.SettingsPresentationState import jp.rimtty.codematch.feature.settings.SettingsUiAction import jp.rimtty.codematch.feature.settings.SettingsUiState @@ -24,6 +26,7 @@ import kotlinx.coroutines.launch @HiltViewModel class SettingsViewModel @Inject constructor( private val repository: SettingsRepository, + private val scanLogRepository: ScanLogRepository, private val scanner: ExternalScanner, private val feedbackPlayer: FeedbackPlayer, private val appLanguageSynchronizer: AppLanguageSynchronizer, @@ -60,8 +63,21 @@ class SettingsViewModel @Inject constructor( _state.update { scannerState(it.copy(settings = settings)) } } } + viewModelScope.launch { + scanLogRepository.count.collect { count -> + _state.update { it.copy(scanLogCount = count) } + } + } } + /** + * The whole scan log, oldest first. + * + * Serialization and every Intent/ContentResolver call stay in the route: + * this only hands over the rows so the host does not need the repository. + */ + suspend fun exportScanLog(): List = scanLogRepository.export() + override fun onCleared() { if (scannerListenerRegistered) { scanner.removeListener(scannerListener) @@ -120,6 +136,9 @@ class SettingsViewModel @Inject constructor( is SettingsUiAction.PreviewFailureSound -> feedbackPlayer.playFailure(action.sound, state.value.feedbackVolume) SettingsUiAction.ShareDiagnostics, SettingsUiAction.SaveDiagnostics -> Unit // host-owned + SettingsUiAction.ShareScanLog, SettingsUiAction.SaveScanLog -> Unit // host-owned + SettingsUiAction.ClearScanLog -> + viewModelScope.launch { scanLogRepository.clear() } is SettingsUiAction.SetLanguage -> viewModelScope.launch { appLanguageSynchronizer.setLanguage(action.language) } diff --git a/android/app/src/test/java/jp/rimtty/codematch/scan/ScanViewModelSessionNameTest.kt b/android/app/src/test/java/jp/rimtty/codematch/scan/ScanViewModelSessionNameTest.kt index f658896..2cb2807 100644 --- a/android/app/src/test/java/jp/rimtty/codematch/scan/ScanViewModelSessionNameTest.kt +++ b/android/app/src/test/java/jp/rimtty/codematch/scan/ScanViewModelSessionNameTest.kt @@ -13,6 +13,7 @@ import androidx.test.core.app.ApplicationProvider 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.MatchSession import jp.rimtty.codematch.feedback.FeedbackPlayer @@ -197,6 +198,7 @@ class ScanViewModelSessionNameTest { settingsRepository = settings, scanner = scanner, feedbackPlayer = FeedbackPlayer(context), + scanLogRepository = ScanLogRepository(database), ) as T } } diff --git a/android/core/data/schemas/jp.rimtty.codematch.core.data.CodeMatchDatabase/4.json b/android/core/data/schemas/jp.rimtty.codematch.core.data.CodeMatchDatabase/4.json new file mode 100644 index 0000000..b372665 --- /dev/null +++ b/android/core/data/schemas/jp.rimtty.codematch.core.data.CodeMatchDatabase/4.json @@ -0,0 +1,312 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "c745e2fde5743d74ebdd42fb6e0d58ed", + "entities": [ + { + "tableName": "sessions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `startedAt` INTEGER NOT NULL, `endedAt` INTEGER, `name` TEXT, `destination` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "startedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "destination", + "columnName": "destination", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sessionId` TEXT NOT NULL, `sequence` INTEGER NOT NULL, `code` TEXT NOT NULL, `matchedAt` INTEGER NOT NULL, `qrPayload` TEXT, `barcodePayload` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`sessionId`) REFERENCES `sessions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sequence", + "columnName": "sequence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "matchedAt", + "columnName": "matchedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "qrPayload", + "columnName": "qrPayload", + "affinity": "TEXT" + }, + { + "fieldPath": "barcodePayload", + "columnName": "barcodePayload", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_entries_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_entries_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + }, + { + "name": "index_entries_sessionId_sequence", + "unique": true, + "columnNames": [ + "sessionId", + "sequence" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_entries_sessionId_sequence` ON `${TABLE_NAME}` (`sessionId`, `sequence`)" + } + ], + "foreignKeys": [ + { + "table": "sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "scan_checkpoints", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `version` INTEGER NOT NULL, `phase` TEXT NOT NULL, `qrPayload` TEXT, `barcodePayload` TEXT, `result` TEXT, `matchedCount` INTEGER NOT NULL, `inputSource` TEXT NOT NULL, `cameraWasSelectedByUser` INTEGER NOT NULL, `destination` TEXT, PRIMARY KEY(`sessionId`), FOREIGN KEY(`sessionId`) REFERENCES `sessions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "phase", + "columnName": "phase", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "qrPayload", + "columnName": "qrPayload", + "affinity": "TEXT" + }, + { + "fieldPath": "barcodePayload", + "columnName": "barcodePayload", + "affinity": "TEXT" + }, + { + "fieldPath": "result", + "columnName": "result", + "affinity": "TEXT" + }, + { + "fieldPath": "matchedCount", + "columnName": "matchedCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inputSource", + "columnName": "inputSource", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "cameraWasSelectedByUser", + "columnName": "cameraWasSelectedByUser", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "destination", + "columnName": "destination", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId" + ] + }, + "foreignKeys": [ + { + "table": "sessions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "scan_log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `at` INTEGER NOT NULL, `sessionId` TEXT, `source` TEXT NOT NULL, `step` TEXT NOT NULL, `event` TEXT NOT NULL, `reason` TEXT, `destination` TEXT, `qrPayload` TEXT, `barcodePayload` TEXT, `code` TEXT, `boxNumber` INTEGER, `message` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "at", + "columnName": "at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "step", + "columnName": "step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "event", + "columnName": "event", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT" + }, + { + "fieldPath": "destination", + "columnName": "destination", + "affinity": "TEXT" + }, + { + "fieldPath": "qrPayload", + "columnName": "qrPayload", + "affinity": "TEXT" + }, + { + "fieldPath": "barcodePayload", + "columnName": "barcodePayload", + "affinity": "TEXT" + }, + { + "fieldPath": "code", + "columnName": "code", + "affinity": "TEXT" + }, + { + "fieldPath": "boxNumber", + "columnName": "boxNumber", + "affinity": "INTEGER" + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_scan_log_at", + "unique": false, + "columnNames": [ + "at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scan_log_at` ON `${TABLE_NAME}` (`at`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c745e2fde5743d74ebdd42fb6e0d58ed')" + ] + } +} \ No newline at end of file diff --git a/android/core/data/src/androidTest/kotlin/jp/rimtty/codematch/core/data/CodeMatchDatabaseMigrationTest.kt b/android/core/data/src/androidTest/kotlin/jp/rimtty/codematch/core/data/CodeMatchDatabaseMigrationTest.kt index a9ffc0c..5860fd4 100644 --- a/android/core/data/src/androidTest/kotlin/jp/rimtty/codematch/core/data/CodeMatchDatabaseMigrationTest.kt +++ b/android/core/data/src/androidTest/kotlin/jp/rimtty/codematch/core/data/CodeMatchDatabaseMigrationTest.kt @@ -209,4 +209,136 @@ class CodeMatchDatabaseMigrationTest { InstrumentationRegistry.getInstrumentation().targetContext.deleteDatabase(databaseName) } } + + @Test + fun versionThreeMigratesToVersionFourWithTheScanLogTable() { + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + CodeMatchDatabase::class.java, + ) + val databaseName = "migration-${UUID.randomUUID()}.db" + val sessionId = "migration-session-${UUID.randomUUID()}" + val entryId = "migration-entry-${UUID.randomUUID()}" + + try { + helper.createDatabase(databaseName, 3).apply { + execSQL( + "INSERT INTO sessions(id, startedAt, endedAt, name, destination) " + + "VALUES ('$sessionId', 100, NULL, 'v3 session', 'molten')", + ) + execSQL( + "INSERT INTO entries(id, sessionId, sequence, code, matchedAt, " + + "qrPayload, barcodePayload) VALUES " + + "('$entryId', '$sessionId', 0, 'ABC1234567', 101, 'qr', 'barcode')", + ) + close() + } + + helper.runMigrationsAndValidate( + databaseName, + 4, + true, + CodeMatchDatabase.MIGRATION_3_4, + ).apply { + query( + "SELECT name, destination FROM sessions WHERE id = ?", + arrayOf(sessionId), + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("v3 session", cursor.getString(0)) + assertEquals("molten", cursor.getString(1)) + } + query("SELECT COUNT(*) FROM scan_log").use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(0, cursor.getInt(0)) + } + // The log outlives its session on purpose, so nothing here is + // a foreign key and a row without a session is valid. + execSQL( + "INSERT INTO scan_log(at, sessionId, source, step, event, reason, " + + "destination, qrPayload, barcodePayload, code, boxNumber, message) " + + "VALUES (10, NULL, 'camera', 'qr', 'rejected', 'wrong_destination', " + + "NULL, 'qr', NULL, NULL, NULL, NULL)", + ) + query("SELECT id, at, sessionId, event FROM scan_log").use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(1L, cursor.getLong(0)) + assertEquals(10L, cursor.getLong(1)) + assertTrue(cursor.isNull(2)) + assertEquals("rejected", cursor.getString(3)) + } + close() + } + } finally { + InstrumentationRegistry.getInstrumentation().targetContext.deleteDatabase(databaseName) + } + } + + @Test + fun versionOneMigratesToVersionFourThroughEveryMigration() { + val helper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + CodeMatchDatabase::class.java, + ) + val databaseName = "migration-${UUID.randomUUID()}.db" + val sessionId = "migration-session-${UUID.randomUUID()}" + val entryId = "migration-entry-${UUID.randomUUID()}" + + try { + helper.createDatabase(databaseName, 1).apply { + execSQL( + "INSERT INTO sessions(id, startedAt, endedAt, name) " + + "VALUES ('$sessionId', 100, 200, 'v1 session')", + ) + execSQL( + "INSERT INTO entries(id, sessionId, sequence, code, matchedAt, " + + "qrPayload, barcodePayload) VALUES " + + "('$entryId', '$sessionId', 0, 'ABC1234567', 101, 'qr', 'barcode')", + ) + close() + } + + // An install that skipped both intermediate releases must reach the + // same schema as a device that took every step in order. + helper.runMigrationsAndValidate( + databaseName, + 4, + true, + CodeMatchDatabase.MIGRATION_1_2, + CodeMatchDatabase.MIGRATION_2_3, + CodeMatchDatabase.MIGRATION_3_4, + ).apply { + query( + "SELECT startedAt, endedAt, name, destination FROM sessions WHERE id = ?", + arrayOf(sessionId), + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(100L, cursor.getLong(0)) + assertEquals(200L, cursor.getLong(1)) + assertEquals("v1 session", cursor.getString(2)) + assertTrue(cursor.isNull(3)) + } + query( + "SELECT code, qrPayload, barcodePayload FROM entries WHERE id = ?", + arrayOf(entryId), + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals("ABC1234567", cursor.getString(0)) + assertEquals("qr", cursor.getString(1)) + assertEquals("barcode", cursor.getString(2)) + } + query("SELECT COUNT(*) FROM scan_checkpoints").use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(0, cursor.getInt(0)) + } + query("SELECT COUNT(*) FROM scan_log").use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(0, cursor.getInt(0)) + } + close() + } + } finally { + InstrumentationRegistry.getInstrumentation().targetContext.deleteDatabase(databaseName) + } + } } diff --git a/android/core/data/src/androidTest/kotlin/jp/rimtty/codematch/core/data/ScanLogRepositoryTest.kt b/android/core/data/src/androidTest/kotlin/jp/rimtty/codematch/core/data/ScanLogRepositoryTest.kt new file mode 100644 index 0000000..2fbee43 --- /dev/null +++ b/android/core/data/src/androidTest/kotlin/jp/rimtty/codematch/core/data/ScanLogRepositoryTest.kt @@ -0,0 +1,141 @@ +package jp.rimtty.codematch.core.data + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import jp.rimtty.codematch.core.model.Destination +import jp.rimtty.codematch.core.model.ScanLogEvent +import jp.rimtty.codematch.core.model.ScanLogEventKind +import jp.rimtty.codematch.core.model.ScanLogReason +import jp.rimtty.codematch.core.model.ScanLogSource +import jp.rimtty.codematch.core.model.ScanLogStep +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ScanLogRepositoryTest { + private lateinit var database: CodeMatchDatabase + private lateinit var repository: ScanLogRepository + + @Before + fun setUp() { + database = CodeMatchDatabaseFactory.inMemory(applicationContext()) + repository = ScanLogRepository(database) + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun everyFieldSurvivesTheRoundTripInInsertionOrder() = runBlocking { + repository.record( + event( + at = 100L, + kind = ScanLogEventKind.MATCH, + destination = Destination.MOLTEN, + // The trailing spaces of a Molten record are data. + qrPayload = "AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000", + barcodePayload = "PAF1-15-422@0NKD3C", + code = "PAF1-15-422", + boxNumber = 3, + ), + ) + repository.record( + event( + at = 200L, + kind = ScanLogEventKind.REJECTED, + reason = ScanLogReason.WRONG_DESTINATION, + ), + ) + + val exported = repository.export() + assertEquals(2, exported.size) + val match = exported.first() + assertEquals(100L, match.atEpochMillis) + assertEquals("session-1", match.sessionId) + assertEquals(ScanLogSource.BLUETOOTH, match.source) + assertEquals(ScanLogStep.BARCODE, match.step) + assertEquals(ScanLogEventKind.MATCH, match.event) + assertNull(match.reason) + assertEquals(Destination.MOLTEN, match.destination) + assertEquals( + "AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000", + match.qrPayload, + ) + assertEquals("PAF1-15-422@0NKD3C", match.barcodePayload) + assertEquals("PAF1-15-422", match.code) + assertEquals(3, match.boxNumber) + assertNull(match.message) + + val rejected = exported.last() + assertEquals(ScanLogReason.WRONG_DESTINATION, rejected.reason) + assertNull(rejected.destination) + assertNull(rejected.code) + assertNull(rejected.boxNumber) + } + + @Test + fun theCountFlowFollowsInsertionsAndClearEmptiesTheLog() = runBlocking { + assertEquals(0, repository.count.first()) + + repeat(3) { index -> repository.record(event(at = index.toLong())) } + assertEquals(3, repository.count.first()) + + repository.clear() + assertEquals(0, repository.count.first()) + assertEquals(emptyList(), repository.export()) + } + + @Test + fun writingPastTheLimitDropsTheOldestEventsOnly() = runBlocking { + // A five-row cap exercises the same SQL the 5,000-row default uses + // without writing five thousand rows on a device. + val capped = ScanLogRepository(database, limit = 5) + + repeat(8) { index -> capped.record(event(at = index.toLong())) } + + assertEquals(5, capped.count.first()) + assertEquals( + listOf(3L, 4L, 5L, 6L, 7L), + capped.export().map { it.atEpochMillis }, + ) + } + + @Test + fun theDefaultLimitIsTheFiveThousandEventsBothAppsPromise() { + assertEquals(5_000, ScanLogRepository.DEFAULT_LIMIT) + } + + private fun event( + at: Long, + kind: String = ScanLogEventKind.QR_ACCEPTED, + reason: String? = null, + destination: Destination? = null, + qrPayload: String? = null, + barcodePayload: String? = null, + code: String? = null, + boxNumber: Int? = null, + ): ScanLogEvent = ScanLogEvent( + atEpochMillis = at, + sessionId = "session-1", + source = ScanLogSource.BLUETOOTH, + step = ScanLogStep.BARCODE, + event = kind, + reason = reason, + destination = destination, + qrPayload = qrPayload, + barcodePayload = barcodePayload, + code = code, + boxNumber = boxNumber, + ) + + private fun applicationContext(): Context = ApplicationProvider.getApplicationContext() +} diff --git a/android/core/data/src/main/kotlin/jp/rimtty/codematch/core/data/CodeMatchDatabase.kt b/android/core/data/src/main/kotlin/jp/rimtty/codematch/core/data/CodeMatchDatabase.kt index f930279..fb7ea1f 100644 --- a/android/core/data/src/main/kotlin/jp/rimtty/codematch/core/data/CodeMatchDatabase.kt +++ b/android/core/data/src/main/kotlin/jp/rimtty/codematch/core/data/CodeMatchDatabase.kt @@ -12,13 +12,18 @@ import androidx.sqlite.db.SupportSQLiteDatabase * * Version 2 adds one durable logical scan checkpoint per session. Version 3 * adds the nullable destination locked by a session's first accepted QR, both - * on the session row and on its checkpoint. `exportSchema = true` is - * intentional: the generated JSON is the contract used by migration tests and - * future schema upgrades. + * on the session row and on its checkpoint. Version 4 adds the capped + * on-device scan log. `exportSchema = true` is intentional: the generated JSON + * is the contract used by migration tests and future schema upgrades. */ @Database( - entities = [SessionEntity::class, EntryEntity::class, ScanCheckpointEntity::class], - version = 3, + entities = [ + SessionEntity::class, + EntryEntity::class, + ScanCheckpointEntity::class, + ScanLogEntity::class, + ], + version = 4, exportSchema = true, ) abstract class CodeMatchDatabase : RoomDatabase() { @@ -28,6 +33,8 @@ abstract class CodeMatchDatabase : RoomDatabase() { abstract fun scanCheckpointDao(): ScanCheckpointDao + abstract fun scanLogDao(): ScanLogDao + companion object { const val DATABASE_NAME: String = "codematch.db" @@ -67,6 +74,42 @@ abstract class CodeMatchDatabase : RoomDatabase() { db.execSQL("ALTER TABLE `scan_checkpoints` ADD COLUMN `destination` TEXT") } } + + /** + * Adds the capped on-device scan log. + * + * The table is deliberately independent of `sessions`: a rejection can + * be recorded before a session exists, and removing a session must not + * delete the log that explains it. Rows are trimmed by insertion order + * (see `ScanLogDao.trimToLatest`), while the `at` index keeps the + * time-ordered reads used by the export cheap. + */ + val MIGRATION_3_4: Migration = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `scan_log` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `at` INTEGER NOT NULL, + `sessionId` TEXT, + `source` TEXT NOT NULL, + `step` TEXT NOT NULL, + `event` TEXT NOT NULL, + `reason` TEXT, + `destination` TEXT, + `qrPayload` TEXT, + `barcodePayload` TEXT, + `code` TEXT, + `boxNumber` INTEGER, + `message` TEXT + ) + """.trimIndent(), + ) + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_scan_log_at` ON `scan_log` (`at`)", + ) + } + } } } @@ -83,6 +126,7 @@ object CodeMatchDatabaseFactory { ).addMigrations( CodeMatchDatabase.MIGRATION_1_2, CodeMatchDatabase.MIGRATION_2_3, + CodeMatchDatabase.MIGRATION_3_4, ).build() /** Factory used by Android tests; data is discarded when the DB is closed. */ diff --git a/android/core/data/src/main/kotlin/jp/rimtty/codematch/core/data/ScanLogEntities.kt b/android/core/data/src/main/kotlin/jp/rimtty/codematch/core/data/ScanLogEntities.kt new file mode 100644 index 0000000..5f9731c --- /dev/null +++ b/android/core/data/src/main/kotlin/jp/rimtty/codematch/core/data/ScanLogEntities.kt @@ -0,0 +1,71 @@ +package jp.rimtty.codematch.core.data + +import androidx.room.Dao +import androidx.room.Entity +import androidx.room.Index +import androidx.room.Insert +import androidx.room.PrimaryKey +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +/** + * One recorded scan-log line. + * + * The table lives in the same excluded-from-backup database as the history so + * the raw payloads it keeps for debugging never reach cloud backup or a + * device-to-device transfer. The row has no foreign key to `sessions`: a + * rejection can happen before a session exists, and deleting a session must + * not silently erase the log that explains what went wrong in it. + * + * [at] is indexed because the log is always read and trimmed in time order. + */ +@Entity( + tableName = "scan_log", + indices = [Index(value = ["at"])], +) +data class ScanLogEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0L, + val at: Long, + val sessionId: String?, + val source: String, + val step: String, + val event: String, + val reason: String?, + val destination: String?, + val qrPayload: String?, + val barcodePayload: String?, + val code: String?, + val boxNumber: Int?, + val message: String?, +) + +/** Room access for the capped scan log. */ +@Dao +interface ScanLogDao { + @Insert + suspend fun insert(event: ScanLogEntity): Long + + /** + * Drop everything older than the newest [limit] rows. + * + * The autoincrement id is the insertion order, so ordering by it keeps the + * newest rows even when two events share a millisecond. + */ + @Query( + """ + DELETE FROM scan_log + WHERE id NOT IN (SELECT id FROM scan_log ORDER BY id DESC LIMIT :limit) + """ + ) + suspend fun trimToLatest(limit: Int) + + @Query("SELECT COUNT(*) FROM scan_log") + fun count(): Flow + + @Query("SELECT * FROM scan_log ORDER BY id ASC") + suspend fun all(): List + + @Query("DELETE FROM scan_log") + suspend fun clear() +} diff --git a/android/core/data/src/main/kotlin/jp/rimtty/codematch/core/data/ScanLogRepository.kt b/android/core/data/src/main/kotlin/jp/rimtty/codematch/core/data/ScanLogRepository.kt new file mode 100644 index 0000000..0cfc55e --- /dev/null +++ b/android/core/data/src/main/kotlin/jp/rimtty/codematch/core/data/ScanLogRepository.kt @@ -0,0 +1,79 @@ +package jp.rimtty.codematch.core.data + +import jp.rimtty.codematch.core.model.Destination +import jp.rimtty.codematch.core.model.ScanLogEvent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.withContext + +/** + * Room-backed store for the on-device scan log. + * + * Every write trims the table back to [DEFAULT_LIMIT] rows, so the log is + * bounded without a background job and an operator can leave it enabled for + * weeks. Nothing here writes a file: the export path in `core/export` turns + * [export] into a document only when the operator asks for one. + */ +class ScanLogRepository( + private val database: CodeMatchDatabase, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, + private val limit: Int = DEFAULT_LIMIT, +) { + private val dao: ScanLogDao = database.scanLogDao() + + /** Number of retained events; the settings screen renders this live. */ + val count: Flow = dao.count().distinctUntilChanged() + + suspend fun record(event: ScanLogEvent) { + withContext(dispatcher) { + dao.insert(event.toEntity()) + dao.trimToLatest(limit) + } + } + + /** The whole log, oldest first, ready to be serialized. */ + suspend fun export(): List = withContext(dispatcher) { + dao.all().map { it.toModel() } + } + + suspend fun clear() { + withContext(dispatcher) { dao.clear() } + } + + companion object { + /** Retention cap shared with the iOS store; see the export header. */ + const val DEFAULT_LIMIT: Int = 5_000 + } +} + +private fun ScanLogEvent.toEntity(): ScanLogEntity = ScanLogEntity( + at = atEpochMillis, + sessionId = sessionId, + source = source, + step = step, + event = event, + reason = reason, + destination = destination?.id, + qrPayload = qrPayload, + barcodePayload = barcodePayload, + code = code, + boxNumber = boxNumber, + message = message, +) + +private fun ScanLogEntity.toModel(): ScanLogEvent = ScanLogEvent( + atEpochMillis = at, + sessionId = sessionId, + source = source, + step = step, + event = event, + reason = reason, + destination = Destination.fromId(destination), + qrPayload = qrPayload, + barcodePayload = barcodePayload, + code = code, + boxNumber = boxNumber, + message = message, +) diff --git a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ScanLogJsonExporter.kt b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ScanLogJsonExporter.kt new file mode 100644 index 0000000..cc45ac0 --- /dev/null +++ b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/ScanLogJsonExporter.kt @@ -0,0 +1,176 @@ +package jp.rimtty.codematch.core.export + +import android.content.Context +import java.io.File +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.util.Locale +import jp.rimtty.codematch.core.model.ScanLogEvent + +/** + * Serializes the on-device scan log as JSON Lines. + * + * The first line is a header object describing the export; every following + * line is exactly one event. A line-oriented document is deliberate: the log + * is read with `grep`/`jq` during a field investigation, and a truncated file + * still parses up to its last complete line. + * + * Key names, key order, the ISO 8601 timestamps, and the vocabularies of + * `event`/`reason`/`step` are a cross-platform contract shared with the iOS + * exporter, so a null is written out explicitly rather than omitted. As in + * [HistoryJsonExporter] the document is built by hand instead of through a + * JSON library, which keeps this class unit-testable on a plain JVM and keeps + * escaping and key order visible. + */ +object ScanLogJsonExporter { + /** Bump only together with the iOS exporter and any reader of this file. */ + const val SCHEMA_VERSION: Int = 1 + + /** Value of the header's `platform` field written by this app. */ + const val PLATFORM: String = "android" + + /** Retention cap reported in the header; see `ScanLogRepository`. */ + const val EVENT_LIMIT: Int = 5_000 + + const val MIME_TYPE: String = "text/plain" + + /** Shared with the history JSON export; the FileProvider exposes it. */ + const val CACHE_DIRECTORY: String = HistoryJsonExporter.CACHE_DIRECTORY + + /** + * Header line values the module cannot derive itself. + * + * [appVersion] is supplied by the caller (`versionName (versionCode)`) + * because this module has no access to the application's package info. + */ + data class ExportHeader( + val appVersion: String, + val exportedAt: Instant, + val platform: String = PLATFORM, + val limit: Int = EVENT_LIMIT, + ) + + /** + * Build the JSON Lines document. + * + * [events] are written in the order given: the repository lists them + * oldest first, so a reader sees the session as it happened. + */ + fun buildJsonLines( + events: List, + header: ExportHeader, + ): String = buildString { + append("{") + append("\"type\":").appendJsonString("header").append(",") + append("\"schemaVersion\":").append(SCHEMA_VERSION).append(",") + append("\"platform\":").appendJsonString(header.platform).append(",") + append("\"appVersion\":").appendJsonString(header.appVersion).append(",") + append("\"exportedAt\":").appendJsonString(isoSeconds(header.exportedAt)).append(",") + append("\"eventCount\":").append(events.size).append(",") + append("\"limit\":").append(header.limit) + append("}\n") + events.forEach { event -> + append("{") + append("\"at\":").appendJsonString(isoMillis(event.atEpochMillis)).append(",") + append("\"session\":").appendJsonString(event.sessionId).append(",") + append("\"source\":").appendJsonString(event.source).append(",") + append("\"step\":").appendJsonString(event.step).append(",") + append("\"event\":").appendJsonString(event.event).append(",") + append("\"reason\":").appendJsonString(event.reason).append(",") + append("\"destination\":").appendJsonString(event.destination?.id).append(",") + append("\"qr\":").appendJsonString(event.qrPayload).append(",") + append("\"barcode\":").appendJsonString(event.barcodePayload).append(",") + append("\"code\":").appendJsonString(event.code).append(",") + append("\"boxNumber\":").appendJsonNumber(event.boxNumber).append(",") + append("\"message\":").appendJsonString(event.message) + append("}\n") + } + } + + /** `codematch-scan-log-20260908-0123.jsonl`, stamped in the caller's zone. */ + fun fileName( + exportedAt: Instant, + zoneId: ZoneId = ZoneId.systemDefault(), + ): String = "codematch-scan-log-" + + FILE_STAMP.format(exportedAt.atZone(zoneId)) + + ".jsonl" + + /** + * Write one export below app-private cache storage. + * + * It shares the history export's cache subdirectory, which is the only + * cache path the FileProvider exposes for data files. FileProvider URI + * creation and Intent ownership stay in the app layer, so callers only + * need the returned file. + */ + fun writeToCache( + context: Context, + text: String, + exportedAt: Instant = Instant.now(), + zoneId: ZoneId = ZoneId.systemDefault(), + ): File { + val directory = File(context.cacheDir, CACHE_DIRECTORY) + check(directory.isDirectory || directory.mkdirs()) { + "Scan log export cache directory could not be created" + } + val output = File(directory, fileName(exportedAt, zoneId)) + check(output.canonicalFile.parentFile == directory.canonicalFile) { + "Scan log export filename escaped its private cache directory" + } + output.outputStream().use { stream -> + stream.write(text.toByteArray(Charsets.UTF_8)) + } + return output + } + + /** `2026-09-08T01:23:45.678Z`; milliseconds are always present. */ + private fun isoMillis(epochMillis: Long): String = + EVENT_STAMP.format(Instant.ofEpochMilli(epochMillis)) + + /** `2026-09-08T01:23:45Z`, matching the history export's header. */ + private fun isoSeconds(instant: Instant): String = + DateTimeFormatter.ISO_INSTANT.format(instant.truncatedTo(ChronoUnit.SECONDS)) + + private fun StringBuilder.appendJsonNumber(value: Int?): StringBuilder = + if (value == null) append("null") else append(value) + + /** + * Appends a JSON string literal, or the literal `null`. + * + * Every character the JSON grammar forbids unescaped is escaped, and no + * other character is altered: a payload's trailing spaces and its exact + * byte sequence survive the round trip. A newline inside a payload becomes + * `\n`, which is what keeps one event on one line. + */ + private fun StringBuilder.appendJsonString(value: String?): StringBuilder { + if (value == null) return append("null") + append('"') + value.forEach { character -> + when (character) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> if (character < ' ') { + append(String.format(Locale.ROOT, "\\u%04x", character.code)) + } else { + append(character) + } + } + } + return append('"') + } + + private val FILE_STAMP: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyyMMdd-HHmm", Locale.ROOT) + + private val EVENT_STAMP: DateTimeFormatter = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ROOT) + .withZone(ZoneOffset.UTC) +} diff --git a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/ScanLogJsonExporterTest.kt b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/ScanLogJsonExporterTest.kt new file mode 100644 index 0000000..e03dc98 --- /dev/null +++ b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/ScanLogJsonExporterTest.kt @@ -0,0 +1,129 @@ +package jp.rimtty.codematch.core.export + +import com.google.gson.JsonParser +import java.time.Instant +import java.time.ZoneId +import jp.rimtty.codematch.core.model.Destination +import jp.rimtty.codematch.core.model.ScanLogEvent +import jp.rimtty.codematch.core.model.ScanLogEventKind +import jp.rimtty.codematch.core.model.ScanLogReason +import jp.rimtty.codematch.core.model.ScanLogSource +import jp.rimtty.codematch.core.model.ScanLogStep +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ScanLogJsonExporterTest { + private val header = ScanLogJsonExporter.ExportHeader( + appVersion = "1.0 (7)", + exportedAt = Instant.parse("2026-09-08T04:05:06.789Z"), + ) + + @Test + fun headerLineDescribesTheExportAndPrecedesOneLinePerEvent() { + val document = ScanLogJsonExporter.buildJsonLines( + events = listOf(matchEvent, rejectedEvent), + header = header, + ) + + val lines = document.trimEnd('\n').split("\n") + assertEquals(3, lines.size) + assertEquals( + """{"type":"header","schemaVersion":1,"platform":"android",""" + + """"appVersion":"1.0 (7)","exportedAt":"2026-09-08T04:05:06Z",""" + + """"eventCount":2,"limit":5000}""", + lines[0], + ) + // Key order and explicit nulls are part of the cross-platform contract. + assertEquals( + """{"at":"2026-09-08T04:05:06.789Z","session":"session-1",""" + + """"source":"bluetooth","step":"barcode","event":"match","reason":null,""" + + """"destination":"sawai","qr":"QR-1","barcode":"BCJH-52-81GG@1N5X0C",""" + + """"code":"BCJH-52-81GG","boxNumber":2,"message":null}""", + lines[1], + ) + assertEquals( + """{"at":"2026-09-08T04:05:07.000Z","session":null,"source":"camera",""" + + """"step":"qr","event":"rejected","reason":"wrong_destination",""" + + """"destination":null,"qr":"QR-2","barcode":null,"code":null,""" + + """"boxNumber":null,"message":null}""", + lines[2], + ) + } + + @Test + fun anEmptyLogStillProducesAHeaderWithZeroEvents() { + val document = ScanLogJsonExporter.buildJsonLines(events = emptyList(), header = header) + + assertEquals(1, document.trimEnd('\n').split("\n").size) + assertTrue(document.contains("\"eventCount\":0")) + } + + @Test + fun quotesBackslashesAndNewlinesInAPayloadStayOnOneLineAndRoundTrip() { + val payload = "AB\"C\\D\nE\tF" + val document = ScanLogJsonExporter.buildJsonLines( + events = listOf(rejectedEvent.copy(qrPayload = payload)), + header = header, + ) + + val lines = document.trimEnd('\n').split("\n") + assertEquals(2, lines.size) + assertTrue(lines[1].contains("""\"C\\D\nE\tF""")) + val parsed = JsonParser.parseString(lines[1]).asJsonObject + assertEquals(payload, parsed.get("qr").asString) + } + + @Test + fun trailingSpacesOfAMoltenRecordSurviveTheExport() { + val padded = "AK6805D10E50N10B U543820000MB S600700000020908 " + val document = ScanLogJsonExporter.buildJsonLines( + events = listOf(matchEvent.copy(qrPayload = padded)), + header = header, + ) + + val parsed = JsonParser.parseString(document.trimEnd('\n').split("\n")[1]).asJsonObject + assertEquals(padded, parsed.get("qr").asString) + } + + @Test + fun fileNameIsStampedInTheCallersZone() { + assertEquals( + "codematch-scan-log-20260908-1305.jsonl", + ScanLogJsonExporter.fileName( + Instant.parse("2026-09-08T04:05:06Z"), + ZoneId.of("Asia/Tokyo"), + ), + ) + assertEquals( + "codematch-scan-log-20260908-0405.jsonl", + ScanLogJsonExporter.fileName( + Instant.parse("2026-09-08T04:05:06Z"), + ZoneId.of("UTC"), + ), + ) + } + + private val matchEvent = ScanLogEvent( + atEpochMillis = Instant.parse("2026-09-08T04:05:06.789Z").toEpochMilli(), + sessionId = "session-1", + source = ScanLogSource.BLUETOOTH, + step = ScanLogStep.BARCODE, + event = ScanLogEventKind.MATCH, + destination = Destination.SAWAI, + qrPayload = "QR-1", + barcodePayload = "BCJH-52-81GG@1N5X0C", + code = "BCJH-52-81GG", + boxNumber = 2, + ) + + private val rejectedEvent = ScanLogEvent( + atEpochMillis = Instant.parse("2026-09-08T04:05:07Z").toEpochMilli(), + sessionId = null, + source = ScanLogSource.CAMERA, + step = ScanLogStep.QR, + event = ScanLogEventKind.REJECTED, + reason = ScanLogReason.WRONG_DESTINATION, + qrPayload = "QR-2", + ) +} diff --git a/android/core/model/src/main/kotlin/jp/rimtty/codematch/core/model/ScanLogEvent.kt b/android/core/model/src/main/kotlin/jp/rimtty/codematch/core/model/ScanLogEvent.kt new file mode 100644 index 0000000..1c2d3af --- /dev/null +++ b/android/core/model/src/main/kotlin/jp/rimtty/codematch/core/model/ScanLogEvent.kt @@ -0,0 +1,86 @@ +package jp.rimtty.codematch.core.model + +/** + * One line of the on-device scan log. + * + * Unlike the Bluetooth diagnostic log — which never carries a scanned value — + * this record deliberately keeps the raw QR and Code 128 payloads so a field + * problem can be reproduced from the exact bytes the scanner delivered. The + * log is stored on the device only and leaves it exclusively when the operator + * shares or saves it from Settings. + * + * The field set, the string vocabularies below, and the exported JSON Lines + * document are a cross-platform contract shared with the iOS `ScanLogEvent`; + * change them on both platforms at once. + * + * [sessionId] is filled in by the host that owns the active session, so the + * producer of an event does not have to know it. + */ +data class ScanLogEvent( + val atEpochMillis: Long, + val sessionId: String?, + /** [ScanLogSource]: which input produced the event. */ + val source: String, + /** [ScanLogStep]: the phase the payload was handled in. */ + val step: String, + /** [ScanLogEventKind]. */ + val event: String, + /** [ScanLogReason]; set for a rejection, null otherwise. */ + val reason: String? = null, + val destination: Destination? = null, + val qrPayload: String? = null, + val barcodePayload: String? = null, + val code: String? = null, + val boxNumber: Int? = null, + /** The message shown to the operator; Android leaves this null. */ + val message: String? = null, +) + +/** Values of [ScanLogEvent.source]. */ +object ScanLogSource { + const val CAMERA: String = "camera" + const val BLUETOOTH: String = "bluetooth" +} + +/** Values of [ScanLogEvent.step]. */ +object ScanLogStep { + const val QR: String = "qr" + const val BARCODE: String = "barcode" + const val RESULT: String = "result" + + /** Session lifecycle events, which belong to no scan step. */ + const val NONE: String = "none" +} + +/** Values of [ScanLogEvent.event]. */ +object ScanLogEventKind { + const val SESSION_START: String = "session_start" + const val SESSION_END: String = "session_end" + const val QR_ACCEPTED: String = "qr_accepted" + const val BARCODE_CANDIDATE: String = "barcode_candidate" + const val BARCODE_ACCEPTED: String = "barcode_accepted" + const val MATCH: String = "match" + const val MISMATCH: String = "mismatch" + const val DUPLICATE: String = "duplicate" + const val REJECTED: String = "rejected" +} + +/** + * Values of [ScanLogEvent.reason]. + * + * The two apps reject on partly different grounds, so each platform emits the + * subset its own scan flow can produce. + */ +object ScanLogReason { + const val INVALID_FORMAT: String = "invalid_format" + const val WRONG_ORDER: String = "wrong_order" + const val WRONG_DESTINATION: String = "wrong_destination" + const val WRONG_SYMBOLOGY: String = "wrong_symbology" + const val RESULT_PENDING: String = "result_pending" + const val INCOMPLETE: String = "incomplete" + const val OVERLONG: String = "overlong" + const val INVALID: String = "invalid" + const val EMPTY: String = "empty" + const val SESSION_NOT_STARTED: String = "session_not_started" + const val SOURCE_MISMATCH: String = "source_mismatch" +} diff --git a/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanLogRecorder.kt b/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanLogRecorder.kt new file mode 100644 index 0000000..e9b68be --- /dev/null +++ b/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanLogRecorder.kt @@ -0,0 +1,16 @@ +package jp.rimtty.codematch.feature.scan + +import jp.rimtty.codematch.core.model.ScanLogEvent + +/** + * Sink for the scan log written by [ScanSessionCoordinator]. + * + * The coordinator is the only place that holds both the raw payload and the + * verdict the reducer produced for it, so it builds the events; where they are + * stored (and which session id they belong to) is the host's business. A null + * recorder disables logging entirely, which is what every pure reducer test + * uses. + */ +fun interface ScanLogRecorder { + fun record(event: ScanLogEvent) +} diff --git a/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinator.kt b/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinator.kt index e0fa1ad..cdfa11d 100644 --- a/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinator.kt +++ b/android/feature/scan/src/main/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinator.kt @@ -1,8 +1,15 @@ package jp.rimtty.codematch.feature.scan +import jp.rimtty.codematch.core.matching.CodeMatcher import jp.rimtty.codematch.core.matching.TagBarcodeRecord import jp.rimtty.codematch.core.model.AutoAdvanceDelay import jp.rimtty.codematch.core.model.Destination +import jp.rimtty.codematch.core.model.MatchResult +import jp.rimtty.codematch.core.model.ScanLogEvent +import jp.rimtty.codematch.core.model.ScanLogEventKind +import jp.rimtty.codematch.core.model.ScanLogReason +import jp.rimtty.codematch.core.model.ScanLogSource +import jp.rimtty.codematch.core.model.ScanLogStep import jp.rimtty.codematch.core.model.ScanSessionCheckpoint import jp.rimtty.codematch.scanner.api.ConfigurationState import jp.rimtty.codematch.scanner.api.ConnectionState @@ -33,7 +40,16 @@ class ScanSessionCoordinator( restoredCheckpoint: ScanSessionCheckpoint? = null, recordedBoxes: Collection = emptyList(), sessionDestination: Destination? = null, + scanLogRecorder: ScanLogRecorder? = null, ) : ExternalScannerListener { + /** + * Sink for the on-device scan log, or null to record nothing. + * + * This is the only place a scanned value is written anywhere outside the + * comparison itself; scanner diagnostics and [ScanEffect.InvalidScan] stay + * payload free. + */ + var scanLogRecorder: ScanLogRecorder? = scanLogRecorder private val cameraAcceptanceLock = ScanAcceptanceLock() private var applyingScannerFormat = false private val restoredBoxes: List = recordedBoxes.toList() @@ -164,7 +180,17 @@ class ScanSessionCoordinator( // CameraX/ML Kit and BLE callbacks may complete after lifecycle stop. // Never let a delayed result mutate a backgrounded session. if (isBackgrounded) return null - if (payload.source != inputSource) return null + if (payload.source != inputSource) { + // A stale camera frame during a Bluetooth step (or the reverse) is + // dropped silently by the flow. Record it: an operator reporting + // "nothing happened" is usually looking at exactly this case. + recordPayloadLog( + payload = payload, + event = ScanLogEventKind.REJECTED, + reason = ScanLogReason.SOURCE_MISMATCH, + ) + return null + } val timestamp = payload.timestampMillis if (payload.source == InputSource.CAMERA && cameraAcceptanceLock.isLocked(timestamp)) { @@ -184,7 +210,16 @@ class ScanSessionCoordinator( ) { when (val stabilization = cameraStabilizer.submit(payload.value, timestamp)) { is ScanStabilizationResult.Accepted -> payload.copy(value = stabilization.value) - ScanStabilizationResult.Pending, + ScanStabilizationResult.Pending -> { + // First of the two observations a camera Code 128 needs. + // Only this one is logged: Locked and Rejected are repeats + // of a value already recorded as a candidate. + recordPayloadLog( + payload = payload, + event = ScanLogEventKind.BARCODE_CANDIDATE, + ) + return null + } ScanStabilizationResult.Locked, ScanStabilizationResult.Rejected, -> return null @@ -293,9 +328,11 @@ class ScanSessionCoordinator( } else -> Unit } + val previousState = state val reduction = reducer.reduce(state, event) state = reduction.state lastEffects = reduction.effects + recordReductionLog(event, previousState, reduction) applyEffects(reduction.effects) onStateChanged?.invoke(state) onEffects?.invoke(reduction.effects) @@ -407,6 +444,191 @@ class ScanSessionCoordinator( state = state.copy(inputSource = source) onInputSourceChanged?.invoke(source) } + + // --- Scan log --------------------------------------------------------- + + /** Log a payload that never reached the reducer, using the current state. */ + private fun recordPayloadLog( + payload: ScanPayload, + event: String, + reason: String? = null, + ) { + val recorder = scanLogRecorder ?: return + val isBarcode = payload.format == ScanFormat.CODE_128 + recorder.record( + ScanLogEvent( + atEpochMillis = System.currentTimeMillis(), + // The host fills the session id in: this object is built while + // the session may still be idle. + sessionId = null, + source = payload.source.scanLogId, + step = state.phase.scanLogId, + event = event, + reason = reason, + destination = state.destination, + qrPayload = payload.value.takeUnless { isBarcode }, + barcodePayload = payload.value.takeIf { isBarcode }, + ), + ) + } + + private fun recordReductionLog( + event: ScanEvent, + previous: ScanSessionState, + reduction: ScanReduction, + ) { + val recorder = scanLogRecorder ?: return + if (event is ScanEvent.PayloadReceived) { + recordPayloadReduction(recorder, event.payload, previous, reduction) + return + } + if (reduction.effects.any { it === ScanEffect.SessionEnded }) { + // Ending resets the input source and clears the destination, so the + // finished session is described by the state before the reduction. + recorder.record( + ScanLogEvent( + atEpochMillis = System.currentTimeMillis(), + sessionId = null, + source = previous.inputSource.scanLogId, + step = ScanLogStep.NONE, + event = ScanLogEventKind.SESSION_END, + destination = previous.destination, + ), + ) + } + } + + /** + * Turn one reduced payload into the log lines it produced. + * + * Both states are needed: the step a value was judged in comes from the + * state before the reduction, while the destination lock and the accepted + * values come from the one after it. + */ + private fun recordPayloadReduction( + recorder: ScanLogRecorder, + payload: ScanPayload, + previous: ScanSessionState, + reduction: ScanReduction, + ) { + val source = payload.source.scanLogId + val step = previous.phase.scanLogId + val destination = reduction.state.destination + fun log( + event: String, + reason: String? = null, + qrPayload: String? = null, + barcodePayload: String? = null, + code: String? = null, + boxNumber: Int? = null, + ) = recorder.record( + ScanLogEvent( + atEpochMillis = System.currentTimeMillis(), + sessionId = null, + source = source, + step = step, + event = event, + reason = reason, + destination = destination, + qrPayload = qrPayload, + barcodePayload = barcodePayload, + code = code, + boxNumber = boxNumber, + ), + ) + + val invalid = reduction.effects.filterIsInstance().firstOrNull() + if (invalid != null) { + // The value is filed under the step the reducer expected rather + // than the symbology the scanner reported, so a Code 128 sent in + // the QR step is still readable as "what arrived at the QR step". + val asBarcode = invalid.expectedFormat == ScanFormat.CODE_128 + log( + event = ScanLogEventKind.REJECTED, + reason = invalid.reason.scanLogId, + qrPayload = payload.value.takeUnless { asBarcode }, + barcodePayload = payload.value.takeIf { asBarcode }, + ) + return + } + + val accepted = reduction.effects.any { it === ScanEffect.ScanAccepted } + when (val scan = reduction.state.scan) { + is ScanState.WaitingCode128 -> if (accepted) { + log(event = ScanLogEventKind.QR_ACCEPTED, qrPayload = scan.qrPayload) + } + + is ScanState.Result -> if (accepted) { + log( + event = ScanLogEventKind.BARCODE_ACCEPTED, + barcodePayload = scan.barcodePayload, + ) + val match = reduction.effects + .filterIsInstance() + .firstOrNull() + log( + event = when (scan.result) { + MatchResult.MATCH -> ScanLogEventKind.MATCH + MatchResult.MISMATCH -> ScanLogEventKind.MISMATCH + MatchResult.DUPLICATE -> ScanLogEventKind.DUPLICATE + }, + qrPayload = scan.qrPayload, + barcodePayload = scan.barcodePayload, + // Only a match carries a RecordMatch effect; the other two + // verdicts still deserve the part number they were about. + code = match?.code ?: recordedCode(scan.qrPayload, scan.barcodePayload), + boxNumber = match?.boxNumber, + ) + } else if (previous.scan is ScanState.Result && reduction.effects.isEmpty()) { + // The reducer deliberately swallows callbacks while a result is + // on screen. Record them: otherwise the most confusing case for + // an operator leaves no trace at all. + val isBarcode = payload.format == ScanFormat.CODE_128 + log( + event = ScanLogEventKind.REJECTED, + reason = ScanLogReason.RESULT_PENDING, + qrPayload = payload.value.takeUnless { isBarcode }, + barcodePayload = payload.value.takeIf { isBarcode }, + ) + } + + ScanState.Idle, is ScanState.WaitingQr -> Unit + } + } + + /** The part number the reducer would have recorded for this pair. */ + private fun recordedCode(qrPayload: String, barcodePayload: String): String { + val part = CodeMatcher.partNumberFromBarcode(barcodePayload) + ?: CodeMatcher.partNumberFromQr(qrPayload) + ?: qrPayload + return CodeMatcher.formatPartNumber(part, CodeMatcher.detectDestination(qrPayload)) + } } +private val InputSource.scanLogId: String + get() = when (this) { + InputSource.CAMERA -> ScanLogSource.CAMERA + InputSource.BLUETOOTH -> ScanLogSource.BLUETOOTH + } + +private val ScanPhase.scanLogId: String + get() = when (this) { + ScanPhase.IDLE -> ScanLogStep.NONE + ScanPhase.WAITING_QR -> ScanLogStep.QR + ScanPhase.WAITING_CODE_128 -> ScanLogStep.BARCODE + ScanPhase.RESULT -> ScanLogStep.RESULT + } + +private val InvalidScanReason.scanLogId: String + get() = when (this) { + InvalidScanReason.SESSION_NOT_STARTED -> ScanLogReason.SESSION_NOT_STARTED + InvalidScanReason.WRONG_ORDER -> ScanLogReason.WRONG_ORDER + InvalidScanReason.EMPTY_PAYLOAD -> ScanLogReason.EMPTY + InvalidScanReason.INCOMPLETE_QR_PAYLOAD -> ScanLogReason.INCOMPLETE + InvalidScanReason.OVERLONG_QR_PAYLOAD -> ScanLogReason.OVERLONG + InvalidScanReason.INVALID_PAYLOAD -> ScanLogReason.INVALID + InvalidScanReason.WRONG_DESTINATION -> ScanLogReason.WRONG_DESTINATION + } + + typealias ScanController = ScanSessionCoordinator diff --git a/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinatorTest.kt b/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinatorTest.kt index aeb02c8..88708b5 100644 --- a/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinatorTest.kt +++ b/android/feature/scan/src/test/kotlin/jp/rimtty/codematch/feature/scan/ScanSessionCoordinatorTest.kt @@ -2,6 +2,11 @@ package jp.rimtty.codematch.feature.scan import jp.rimtty.codematch.core.model.Destination import jp.rimtty.codematch.core.model.MatchResult +import jp.rimtty.codematch.core.model.ScanLogEvent +import jp.rimtty.codematch.core.model.ScanLogEventKind +import jp.rimtty.codematch.core.model.ScanLogReason +import jp.rimtty.codematch.core.model.ScanLogSource +import jp.rimtty.codematch.core.model.ScanLogStep import jp.rimtty.codematch.core.model.ScanCheckpointInputSource import jp.rimtty.codematch.core.model.ScanCheckpointPhase import jp.rimtty.codematch.core.model.ScanSessionCheckpoint @@ -40,6 +45,10 @@ class ScanSessionCoordinatorTest { "DCLP675300BCJH5281GG020000120000001200L000000000000BLBDILLU92 0*" private val barcodePayload = "BCJH-52-81GG@1N5X0C" + // A different part number in the same series; a valid tag that must not + // match the slip above. + private val mismatchBarcodePayload = "BCJH-55-81GG@1KVQ0C" + // Destination Molten, with a nine-character part number printed as a // 4-2-3 tag. The QR's trailing spaces are record data. private val moltenQrPayload = @@ -636,6 +645,205 @@ class ScanSessionCoordinatorTest { assertEquals(ScanPhase.WAITING_CODE_128, sawai.state.phase) } + + @Test + fun scanLogRecordsAcceptedQrBarcodeMatchAndSessionEnd() { + val scanner = TestScanner().apply { markReady() } + val log = RecordingScanLog() + val coordinator = ScanSessionCoordinator(scanner, scanLogRecorder = log) + + coordinator.startSession() + coordinator.submitScanPayload(ScanPayload.qr(qrPayload, InputSource.BLUETOOTH, 1_000L)) + coordinator.submitScanPayload( + ScanPayload.code128(barcodePayload, InputSource.BLUETOOTH, 2_000L), + ) + + assertEquals( + listOf( + ScanLogEventKind.QR_ACCEPTED, + ScanLogEventKind.BARCODE_ACCEPTED, + ScanLogEventKind.MATCH, + ), + log.events.map { it.event }, + ) + val qrAccepted = log.events[0] + assertEquals(ScanLogSource.BLUETOOTH, qrAccepted.source) + assertEquals(ScanLogStep.QR, qrAccepted.step) + assertEquals(qrPayload, qrAccepted.qrPayload) + assertNull(qrAccepted.barcodePayload) + assertEquals(Destination.SAWAI, qrAccepted.destination) + // The host owns the session id; the coordinator never invents one. + assertNull(qrAccepted.sessionId) + + val barcodeAccepted = log.events[1] + assertEquals(ScanLogStep.BARCODE, barcodeAccepted.step) + assertEquals(barcodePayload, barcodeAccepted.barcodePayload) + assertNull(barcodeAccepted.qrPayload) + + val match = log.events[2] + assertEquals(ScanLogStep.BARCODE, match.step) + assertEquals(qrPayload, match.qrPayload) + assertEquals(barcodePayload, match.barcodePayload) + assertEquals("BCJH-52-81GG", match.code) + assertEquals(1, match.boxNumber) + assertNull(match.reason) + assertNull(match.message) + + coordinator.endSession() + + val ended = log.events.last() + assertEquals(ScanLogEventKind.SESSION_END, ended.event) + assertEquals(ScanLogStep.NONE, ended.step) + // Ending resets the source and clears the lock, so both are read from + // the state the session had before the reduction. + assertEquals(ScanLogSource.BLUETOOTH, ended.source) + assertEquals(Destination.SAWAI, ended.destination) + } + + @Test + fun scanLogRecordsMismatchAndDuplicateWithTheirPartNumberButNoBoxNumber() { + val scanner = TestScanner().apply { markReady() } + val log = RecordingScanLog() + val coordinator = ScanSessionCoordinator(scanner, scanLogRecorder = log) + coordinator.startSession() + + coordinator.submitScanPayload(ScanPayload.qr(qrPayload, InputSource.BLUETOOTH, 1_000L)) + coordinator.submitScanPayload( + ScanPayload.code128(mismatchBarcodePayload, InputSource.BLUETOOTH, 2_000L), + ) + + val mismatch = log.events.last() + assertEquals(ScanLogEventKind.MISMATCH, mismatch.event) + assertEquals(mismatchBarcodePayload, mismatch.barcodePayload) + assertEquals("BCJH-55-81GG", mismatch.code) + assertNull(mismatch.boxNumber) + + coordinator.manualNext() + coordinator.submitScanPayload(ScanPayload.qr(qrPayload, InputSource.BLUETOOTH, 3_000L)) + coordinator.submitScanPayload( + ScanPayload.code128(barcodePayload, InputSource.BLUETOOTH, 4_000L), + ) + assertEquals(ScanLogEventKind.MATCH, log.events.last().event) + + coordinator.manualNext() + coordinator.submitScanPayload(ScanPayload.qr(qrPayload, InputSource.BLUETOOTH, 5_000L)) + coordinator.submitScanPayload( + ScanPayload.code128(barcodePayload, InputSource.BLUETOOTH, 6_000L), + ) + + val duplicate = log.events.last() + assertEquals(ScanLogEventKind.DUPLICATE, duplicate.event) + assertEquals("BCJH-52-81GG", duplicate.code) + assertNull(duplicate.boxNumber) + } + + @Test + fun scanLogRecordsEveryRejectionReasonWithTheValueThatCausedIt() { + val scanner = TestScanner().apply { markReady() } + val log = RecordingScanLog() + val coordinator = ScanSessionCoordinator(scanner, scanLogRecorder = log) + + // A connected scanner is promoted only once a session starts, so this + // first callback is still a camera one. + coordinator.submitScanPayload(ScanPayload.qr(qrPayload, InputSource.CAMERA, 1_000L)) + coordinator.startSession() + coordinator.submitScanPayload( + ScanPayload.code128(barcodePayload, InputSource.BLUETOOTH, 2_000L), + ) + coordinator.submitScanPayload(ScanPayload.qr("", InputSource.BLUETOOTH, 3_000L)) + coordinator.submitScanPayload(ScanPayload.qr("SHORT", InputSource.BLUETOOTH, 4_000L)) + coordinator.submitScanPayload(ScanPayload.qr("A".repeat(70), InputSource.BLUETOOTH, 5_000L)) + coordinator.submitScanPayload(ScanPayload.qr(qrPayload, InputSource.BLUETOOTH, 6_000L)) + coordinator.submitScanPayload( + ScanPayload.code128("NOT-A-TAG", InputSource.BLUETOOTH, 7_000L), + ) + // Re-reading the QR keeps the destination lock, so a slip of another + // destination is still refused. + coordinator.rereadQr() + coordinator.submitScanPayload( + ScanPayload.qr(moltenQrPayload, InputSource.BLUETOOTH, 8_000L), + ) + + val rejections = log.events.filter { it.event == ScanLogEventKind.REJECTED } + assertEquals( + listOf( + ScanLogReason.SESSION_NOT_STARTED, + ScanLogReason.WRONG_ORDER, + ScanLogReason.EMPTY, + ScanLogReason.INCOMPLETE, + ScanLogReason.OVERLONG, + ScanLogReason.INVALID, + ScanLogReason.WRONG_DESTINATION, + ), + rejections.map { it.reason }, + ) + assertEquals(ScanLogStep.NONE, rejections[0].step) + assertEquals(qrPayload, rejections[0].qrPayload) + // The value is filed under the step it was judged in: a Code 128 that + // arrives while a QR is expected is recorded as the QR that was read. + assertEquals(barcodePayload, rejections[1].qrPayload) + assertNull(rejections[1].barcodePayload) + assertEquals("", rejections[2].qrPayload) + assertEquals("SHORT", rejections[3].qrPayload) + assertEquals("A".repeat(70), rejections[4].qrPayload) + assertEquals(ScanLogStep.BARCODE, rejections[5].step) + assertEquals("NOT-A-TAG", rejections[5].barcodePayload) + assertNull(rejections[5].qrPayload) + assertEquals(moltenQrPayload, rejections[6].qrPayload) + assertEquals(Destination.SAWAI, rejections[6].destination) + } + + @Test + fun scanLogRecordsDroppedSourceConfirmationCandidateAndResultCallbacks() { + val bluetoothScanner = TestScanner().apply { markReady() } + val bluetoothLog = RecordingScanLog() + val bluetooth = ScanSessionCoordinator(bluetoothScanner, scanLogRecorder = bluetoothLog) + bluetooth.startSession() + + bluetooth.submitScanPayload(ScanPayload.qr(qrPayload, InputSource.CAMERA, 1_000L)) + + val dropped = bluetoothLog.events.single() + assertEquals(ScanLogEventKind.REJECTED, dropped.event) + assertEquals(ScanLogReason.SOURCE_MISMATCH, dropped.reason) + assertEquals(ScanLogSource.CAMERA, dropped.source) + assertEquals(ScanLogStep.QR, dropped.step) + assertEquals(qrPayload, dropped.qrPayload) + + val cameraLog = RecordingScanLog() + val camera = ScanSessionCoordinator(TestScanner(), scanLogRecorder = cameraLog) + camera.startSession() + camera.submitScanPayload(ScanPayload.qr(qrPayload, InputSource.CAMERA, 1_000L)) + camera.submitScanPayload(ScanPayload.code128(barcodePayload, InputSource.CAMERA, 2_000L)) + + assertEquals( + listOf(ScanLogEventKind.QR_ACCEPTED, ScanLogEventKind.BARCODE_CANDIDATE), + cameraLog.events.map { it.event }, + ) + val candidate = cameraLog.events.last() + assertEquals(ScanLogStep.BARCODE, candidate.step) + assertEquals(barcodePayload, candidate.barcodePayload) + assertNull(candidate.reason) + + camera.submitScanPayload(ScanPayload.code128(barcodePayload, InputSource.CAMERA, 2_500L)) + assertEquals(ScanLogEventKind.MATCH, cameraLog.events.last().event) + + camera.submitScanPayload(ScanPayload.qr(qrPayload, InputSource.CAMERA, 5_000L)) + + val swallowed = cameraLog.events.last() + assertEquals(ScanLogEventKind.REJECTED, swallowed.event) + assertEquals(ScanLogReason.RESULT_PENDING, swallowed.reason) + assertEquals(ScanLogStep.RESULT, swallowed.step) + assertEquals(qrPayload, swallowed.qrPayload) + } + + private class RecordingScanLog : ScanLogRecorder { + val events = mutableListOf() + + override fun record(event: ScanLogEvent) { + events += event + } + } + private class TestScanner : ExternalScanner { private val device = ScannerDevice("test", "Test scanner") override var devices: List = listOf(device) diff --git a/android/feature/settings/src/androidTest/kotlin/jp/rimtty/codematch/feature/settings/SettingsScreenTest.kt b/android/feature/settings/src/androidTest/kotlin/jp/rimtty/codematch/feature/settings/SettingsScreenTest.kt index 1f4b322..c9f4c6f 100644 --- a/android/feature/settings/src/androidTest/kotlin/jp/rimtty/codematch/feature/settings/SettingsScreenTest.kt +++ b/android/feature/settings/src/androidTest/kotlin/jp/rimtty/codematch/feature/settings/SettingsScreenTest.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.test.assertHeightIsAtLeast import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextContains import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.junit4.createComposeRule @@ -112,6 +113,7 @@ class SettingsScreenTest { composeRule.onAllNodesWithTag(SettingsTestTags.SUCCESS_SOUNDS).assertCountEquals(1) composeRule.onAllNodesWithTag(SettingsTestTags.FAILURE_SOUNDS).assertCountEquals(1) composeRule.onAllNodesWithTag(SettingsTestTags.LANGUAGE).assertCountEquals(1) + composeRule.onAllNodesWithTag(SettingsTestTags.SCAN_LOG).assertCountEquals(1) composeRule.onAllNodesWithTag(SettingsTestTags.SUCCESS_SOUND).assertCountEquals(5) composeRule.onAllNodesWithTag(SettingsTestTags.FAILURE_SOUND).assertCountEquals(4) composeRule.onAllNodesWithTag(SettingsTestTags.DELAY_CHOICE).assertCountEquals(3) @@ -138,6 +140,75 @@ class SettingsScreenTest { .get(0) .performScrollTo() .assertHeightIsAtLeast(48.dp) + composeRule.onNodeWithTag(SettingsTestTags.SCAN_LOG_SHARE) + .performScrollTo() + .assertHeightIsAtLeast(48.dp) + composeRule.onNodeWithTag(SettingsTestTags.SCAN_LOG_SAVE) + .performScrollTo() + .assertHeightIsAtLeast(48.dp) + composeRule.onNodeWithTag(SettingsTestTags.SCAN_LOG_CLEAR) + .performScrollTo() + .assertHeightIsAtLeast(48.dp) + } + + /** + * The scan log carries the values that were actually read, so sharing and + * saving stay unavailable until there is something to share, and clearing + * always goes through a confirmation. + */ + @Test + fun scanLogCardReportsItsCountAndGatesSharingSavingAndClearing() { + val actions = mutableListOf() + val state = mutableStateOf(SettingsUiState()) + composeRule.setContent { + MaterialTheme { SettingsScreen(state.value, onAction = actions::add) } + } + + composeRule.onNodeWithTag(SettingsTestTags.SCAN_LOG_COUNT) + .performScrollTo() + .assertIsDisplayed() + composeRule.onNodeWithTag(SettingsTestTags.SCAN_LOG_SHARE) + .performScrollTo() + .assertIsNotEnabled() + composeRule.onNodeWithTag(SettingsTestTags.SCAN_LOG_SAVE) + .performScrollTo() + .assertIsNotEnabled() + + composeRule.runOnIdle { state.value = state.value.copy(scanLogCount = 12) } + + composeRule.onNodeWithTag(SettingsTestTags.SCAN_LOG_COUNT) + .performScrollTo() + .assertTextContains("12", substring = true) + composeRule.onNodeWithTag(SettingsTestTags.SCAN_LOG_SHARE) + .performScrollTo() + .assertIsEnabled() + .performClick() + composeRule.onNodeWithTag(SettingsTestTags.SCAN_LOG_SAVE) + .performScrollTo() + .assertIsEnabled() + .performClick() + composeRule.runOnIdle { + assertEquals( + listOf(SettingsUiAction.ShareScanLog, SettingsUiAction.SaveScanLog), + actions.toList(), + ) + } + + // Tapping clear only opens the dialog; nothing is emitted yet. + composeRule.onNodeWithTag(SettingsTestTags.SCAN_LOG_CLEAR) + .performScrollTo() + .performClick() + composeRule.runOnIdle { + assertTrue(!actions.contains(SettingsUiAction.ClearScanLog)) + } + composeRule.onNodeWithTag(SettingsTestTags.SCAN_LOG_CLEAR_CONFIRM) + .assertIsDisplayed() + .performClick() + + composeRule.runOnIdle { + assertEquals(SettingsUiAction.ClearScanLog, actions.last()) + } + composeRule.onAllNodesWithTag(SettingsTestTags.SCAN_LOG_CLEAR_CONFIRM).assertCountEquals(0) } @Test diff --git a/android/feature/settings/src/main/kotlin/jp/rimtty/codematch/feature/settings/SettingsScreen.kt b/android/feature/settings/src/main/kotlin/jp/rimtty/codematch/feature/settings/SettingsScreen.kt index ad91734..b16afba 100644 --- a/android/feature/settings/src/main/kotlin/jp/rimtty/codematch/feature/settings/SettingsScreen.kt +++ b/android/feature/settings/src/main/kotlin/jp/rimtty/codematch/feature/settings/SettingsScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Bluetooth +import androidx.compose.material.icons.outlined.Article import androidx.compose.material.icons.outlined.CameraAlt import androidx.compose.material.icons.outlined.CheckCircle import androidx.compose.material.icons.outlined.Close @@ -43,6 +44,7 @@ import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.VolumeUp +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -270,6 +272,104 @@ private fun SettingsContent( LanguageCard(language = state.language, onLanguageChanged = { onAction(SettingsUiAction.SetLanguage(it)) }) + ScanLogCard(count = state.scanLogCount, onAction = onAction) + } +} + +/** + * The scan log lives at the very bottom of Settings: it is a debugging aid, + * not a preference, and unlike the diagnostics above it contains the values + * that were actually read. Sharing and saving are host-owned; only the clear + * confirmation is handled here, so the dialog stays testable without Hilt. + */ +@Composable +private fun ScanLogCard( + count: Int, + onAction: (SettingsUiAction) -> Unit, +) { + var confirmingClear by rememberSaveable { mutableStateOf(false) } + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(SettingsTestTags.SCAN_LOG), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.Article, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text( + text = stringResource(R.string.settings_scan_log_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.semantics { heading() }, + ) + } + Text( + text = stringResource(R.string.settings_scan_log_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(R.string.settings_scan_log_count, count), + modifier = Modifier.testTag(SettingsTestTags.SCAN_LOG_COUNT), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + TextButton( + onClick = { onAction(SettingsUiAction.ShareScanLog) }, + enabled = count > 0, + modifier = Modifier + .weight(1f) + .heightIn(min = 48.dp) + .testTag(SettingsTestTags.SCAN_LOG_SHARE), + ) { Text(stringResource(R.string.settings_scan_log_share)) } + TextButton( + onClick = { onAction(SettingsUiAction.SaveScanLog) }, + enabled = count > 0, + modifier = Modifier + .weight(1f) + .heightIn(min = 48.dp) + .testTag(SettingsTestTags.SCAN_LOG_SAVE), + ) { Text(stringResource(R.string.settings_scan_log_save)) } + } + TextButton( + onClick = { confirmingClear = true }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .testTag(SettingsTestTags.SCAN_LOG_CLEAR), + ) { Text(stringResource(R.string.settings_scan_log_clear)) } + } + } + + if (confirmingClear) { + AlertDialog( + onDismissRequest = { confirmingClear = false }, + title = { Text(stringResource(R.string.settings_scan_log_clear_confirm_title)) }, + text = { Text(stringResource(R.string.settings_scan_log_clear_confirm_message)) }, + confirmButton = { + TextButton( + onClick = { + confirmingClear = false + onAction(SettingsUiAction.ClearScanLog) + }, + modifier = Modifier + .heightIn(min = 48.dp) + .testTag(SettingsTestTags.SCAN_LOG_CLEAR_CONFIRM), + ) { Text(stringResource(R.string.settings_scan_log_clear_confirm)) } + }, + dismissButton = { + TextButton( + onClick = { confirmingClear = false }, + modifier = Modifier.heightIn(min = 48.dp), + ) { Text(stringResource(R.string.settings_scan_log_clear_cancel)) } + }, + ) } } diff --git a/android/feature/settings/src/main/kotlin/jp/rimtty/codematch/feature/settings/SettingsUiState.kt b/android/feature/settings/src/main/kotlin/jp/rimtty/codematch/feature/settings/SettingsUiState.kt index 4da5788..2dbbd82 100644 --- a/android/feature/settings/src/main/kotlin/jp/rimtty/codematch/feature/settings/SettingsUiState.kt +++ b/android/feature/settings/src/main/kotlin/jp/rimtty/codematch/feature/settings/SettingsUiState.kt @@ -78,6 +78,12 @@ object SettingsTestTags { const val FAILURE_PREVIEW = "settings_failure_preview" const val LANGUAGE = "settings_language" const val LANGUAGE_CHOICE = "settings_language_choice" + const val SCAN_LOG = "settings_scan_log" + const val SCAN_LOG_COUNT = "settings_scan_log_count" + const val SCAN_LOG_SHARE = "settings_scan_log_share" + const val SCAN_LOG_SAVE = "settings_scan_log_save" + const val SCAN_LOG_CLEAR = "settings_scan_log_clear" + const val SCAN_LOG_CLEAR_CONFIRM = "settings_scan_log_clear_confirm" fun setupBarcode(code: BluetoothScannerSetupCode): String = "${SETUP_BARCODE}_${code.accessibilityId}" @@ -112,6 +118,8 @@ data class SettingsUiState( jp.rimtty.codematch.scanner.api.IlluminationState.UNSUPPORTED, val tuningState: jp.rimtty.codematch.scanner.api.TuningState = jp.rimtty.codematch.scanner.api.TuningState.UNSUPPORTED, + /** Retained scan-log events; share and clear are disabled while it is 0. */ + val scanLogCount: Int = 0, ) { /** Compatibility/readability aliases for hosts that name these values explicitly. */ val appSettings: AppSettings get() = settings @@ -163,6 +171,13 @@ sealed interface SettingsUiAction { data object ShareDiagnostics : SettingsUiAction data object SaveDiagnostics : SettingsUiAction + /** Host-owned: the scan log is serialized and shared/saved by the app layer. */ + data object ShareScanLog : SettingsUiAction + data object SaveScanLog : SettingsUiAction + + /** Emitted only after the confirmation dialog is accepted. */ + data object ClearScanLog : SettingsUiAction + data class SetAutoAdvanceEnabled(val enabled: Boolean) : SettingsUiAction data class SetAutoAdvanceDelay(val delay: AutoAdvanceDelay) : SettingsUiAction data class SetFeedbackVolume(val volume: Float) : SettingsUiAction diff --git a/android/feature/settings/src/main/res/values-en/strings.xml b/android/feature/settings/src/main/res/values-en/strings.xml index 850d1a7..3ef6473 100644 --- a/android/feature/settings/src/main/res/values-en/strings.xml +++ b/android/feature/settings/src/main/res/values-en/strings.xml @@ -133,4 +133,19 @@ CodeMatch Bluetooth diagnostic log Diagnostic log saved. Could not save the diagnostic log. + + Scan log + Keeps the latest 5,000 scan results and rejections on this device, camera and Bluetooth alike. The log contains the scanned values, so share it with care. + %1$d events + Share all scan logs + Save scan log + Clear scan log + Clear the scan log? + Every scan log event kept on this device is deleted. This cannot be undone. + Clear + Cancel + CodeMatch scan log + Scan log saved. + Could not save the scan log. + Could not share the scan log. diff --git a/android/feature/settings/src/main/res/values/strings.xml b/android/feature/settings/src/main/res/values/strings.xml index bee7ac9..8ed02ae 100644 --- a/android/feature/settings/src/main/res/values/strings.xml +++ b/android/feature/settings/src/main/res/values/strings.xml @@ -133,4 +133,19 @@ CodeMatch Bluetooth診断ログ 診断ログを保存しました。 診断ログを保存できませんでした。 + + 照合ログ + カメラ・Bluetooth の照合結果と不受理を直近5,000件まで端末内に保持します。読み取った値を含むので共有先に注意してください。 + 記録: %1$d件 + 照合ログをすべて共有 + 照合ログを保存 + 照合ログを消去 + 照合ログを消去しますか? + 端末内に保持している照合ログをすべて削除します。この操作は取り消せません。 + 消去する + キャンセル + CodeMatch 照合ログ + 照合ログを保存しました。 + 照合ログを保存できませんでした。 + 照合ログを共有できませんでした。 diff --git a/android/feature/settings/src/test/kotlin/jp/rimtty/codematch/feature/settings/SettingsUiTextTest.kt b/android/feature/settings/src/test/kotlin/jp/rimtty/codematch/feature/settings/SettingsUiTextTest.kt new file mode 100644 index 0000000..fbd7c32 --- /dev/null +++ b/android/feature/settings/src/test/kotlin/jp/rimtty/codematch/feature/settings/SettingsUiTextTest.kt @@ -0,0 +1,98 @@ +package jp.rimtty.codematch.feature.settings + +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.w3c.dom.Element + +/** + * JVM parity gate for the settings copy, mirroring `HistoryUiTextTest`. + * + * Android lint's `MissingTranslation` already fails a build with a missing + * key, but it runs only on the app's lint task and says nothing about format + * tokens. A `%1$d` present in one language and absent in the other would + * otherwise crash only when that language is selected at runtime. + */ +class SettingsUiTextTest { + @Test + fun japaneseAndEnglishSettingsResourcesHaveTheSameKeys() { + val japanese = resourceShape(resourceFile("values")) + val english = resourceShape(resourceFile("values-en")) + + assertEquals(japanese.keys, english.keys) + assertEquals(japanese.keys.size, japanese.keys.toSet().size) + assertEquals(english.keys.size, english.keys.toSet().size) + assertEquals(japanese.formatTokens, english.formatTokens) + } + + @Test + fun theScanLogCardHasCopyForEveryControlItRenders() { + val japanese = resourceShape(resourceFile("values")).keys + + listOf( + "settings_scan_log_title", + "settings_scan_log_note", + "settings_scan_log_count", + "settings_scan_log_share", + "settings_scan_log_save", + "settings_scan_log_clear", + "settings_scan_log_clear_confirm_title", + "settings_scan_log_clear_confirm_message", + "settings_scan_log_clear_confirm", + "settings_scan_log_clear_cancel", + "settings_scan_log_subject", + "settings_scan_log_saved", + "settings_scan_log_save_failed", + "settings_scan_log_share_failed", + ).forEach { key -> + assertTrue(key, "string:$key" in japanese) + } + } + + private fun resourceFile(directory: String): File { + val relativePath = "src/main/res/$directory/strings.xml" + val projectFile = File(relativePath) + if (projectFile.isFile) return projectFile + + var root = File(System.getProperty("user.dir") ?: ".").absoluteFile + while (true) { + val candidate = File(root, "android/feature/settings/$relativePath") + if (candidate.isFile) return candidate + root = root.parentFile ?: break + } + error("Unable to locate settings resource: $relativePath") + } + + private data class ResourceShape( + val keys: Set, + val formatTokens: Map>, + ) + + private fun resourceShape(file: File): ResourceShape { + val document = DocumentBuilderFactory.newInstance() + .newDocumentBuilder() + .parse(file) + val entries = listOf("string", "plurals").flatMap { resourceType -> + val nodes = document.getElementsByTagName(resourceType) + (0 until nodes.length).map { index -> + resourceType to (nodes.item(index) as Element) + } + } + val keys = entries.map { (type, element) -> "$type:${element.getAttribute("name")}" }.toSet() + val formatTokens = entries.associate { (type, element) -> + "$type:${element.getAttribute("name")}" to FORMAT_TOKEN_PATTERN + .findAll(element.textContent.orEmpty()) + .map { it.value } + .distinct() + .sorted() + .toList() + } + return ResourceShape(keys, formatTokens) + } + + private companion object { + val FORMAT_TOKEN_PATTERN: Regex = Regex("%\\d+\\$[a-zA-Z]") + } +} diff --git a/android/scripts/verify-release-hardening.sh b/android/scripts/verify-release-hardening.sh index b7f4f23..e272155 100755 --- a/android/scripts/verify-release-hardening.sh +++ b/android/scripts/verify-release-hardening.sh @@ -352,7 +352,7 @@ source_hits="$(grep -rn -i -E \ # The only production code allowed to create files is the export layer: the # PDF report and the JSON history export, each writing into its own scoped # app-private cache subdirectory that the FileProvider exposes. -file_hits="$(grep -rn -E '(^|[^[:alnum:]_])File[[:space:]]*\(' "${production_dirs[@]}" --include='*.kt' --include='*.java' | grep -v -E 'core/export/src/main/.*/History(Pdf|Json)Exporter\.kt:' || true)" +file_hits="$(grep -rn -E '(^|[^[:alnum:]_])File[[:space:]]*\(' "${production_dirs[@]}" --include='*.kt' --include='*.java' | grep -v -E 'core/export/src/main/.*/(History(Pdf|Json)|ScanLogJson)Exporter\.kt:' || true)" [[ -z "$file_hits" ]] || die "production source creates files outside the dedicated exporters:"$'\n'"$file_hits" analytics_hits="$(grep -rn -i -E 'FirebaseAnalytics|FirebaseCrashlytics|Crashlytics|Sentry|Bugsnag|NewRelic|Datadog|Mixpanel|PostHog|Countly|AnalyticsTracker|CrashReporter' \ diff --git a/docs/android/IMPLEMENTATION_PLAN.md b/docs/android/IMPLEMENTATION_PLAN.md index 6854408..8469513 100644 --- a/docs/android/IMPLEMENTATION_PLAN.md +++ b/docs/android/IMPLEMENTATION_PLAN.md @@ -255,11 +255,13 @@ MatchResult 仕向地は状態機械が持ち、`EndSession`でだけ解除する。`RereadQr`、手動の次工程、不一致では固定を維持し、別仕向地のQRは`InvalidScanReason.WRONG_DESTINATION`として拒否する。解析できないQRの案内は、固定済みならその仕向地のレコード長(66または61)と比べ、未固定なら57〜66の範囲と比べて「途中で終わった」「余分な情報を含む」を出し分ける。デンソーは可変長なので長さ案内をせず、デンソーで固定済みのセッションと、未固定でも`JAMA`で始まる読取値は`InvalidScanReason.INVALID_PAYLOAD`とする。記録済みの箱は`recordedBoxes`として状態機械に残し、重複判定(澤井製作所とデンソーはQR、モルテンはQR+Code 128)と、モルテンの納品番号ごとの箱数・累計収容数の算出に使う。デンソーの箱数は澤井製作所と同じく品番ごとに数える。 +照合ログ(#122)の記録点は`ScanSessionCoordinator`に置く。`ScanEffect.InvalidScan`は意図的に読取値を持たず、不一致・重複は状態にしか現れないため、payloadと判定の両方を同時に持つ場所はここしかない。`submitScanPayload`は入力元不一致の破棄(`rejected`/`source_mismatch`)とカメラの1フレーム目(`barcode_candidate`)を、`dispatch`はreduce直後に旧状態・新状態・効果から`rejected`(`InvalidScanReason`をsnake_caseへ)、`qr_accepted`、`barcode_accepted`+`match`/`mismatch`/`duplicate`、結果表示中に握りつぶしたpayload(`rejected`/`result_pending`)、`session_end`(旧状態のsourceとdestination)を記録する。`session_start`だけは`ScanViewModel.beginSession`が記録する(checkpoint復元で`SessionStarted`が再発火するため)。session idはViewModelのrecorderラムダが埋める。 + ## 7. 保存、PDF、設定 ### 7.1 Room schema -現行schemaは v3(`core/data/schemas/{1,2,3}.json`)。v2で`scan_checkpoints`を追加し、v3で`sessions`と`scan_checkpoints`へ仕向地列を足した。仕向地は文字列で持つので、デンソーの追加ではschemaもcheckpointの契約versionも上げていない。 +現行schemaは v4(`core/data/schemas/{1,2,3,4}.json`)。v2で`scan_checkpoints`を追加し、v3で`sessions`と`scan_checkpoints`へ仕向地列を足し、v4で照合ログの`scan_log`を追加した。仕向地は文字列で持つので、デンソーの追加ではschemaもcheckpointの契約versionも上げていない。 `sessions` @@ -286,7 +288,17 @@ MatchResult - `matchedCount: Int`、`inputSource: String`、`cameraWasSelectedByUser: Boolean` - `destination: String?`(v3で追加。nullable な追加列なのでcheckpointの契約versionは1のまま) -一致記録とcheckpoint更新は同一トランザクションで行い、checkpointが仕向地を持つ場合はセッション側の固定も同時に書く。DB migration testを最初のschemaから用意し(`MIGRATION_1_2`、`MIGRATION_2_3`、および1→3の連続適用)、active sessionはアプリ再起動後も継続できるようにする。 +`scan_log`(照合ログ。v4で追加。#122) + +- `id: Long`(autoincrement。挿入順がそのまま切り詰めの基準) +- `at: Long`(UTC epoch millis。索引あり) +- `sessionId: String?`、`source: String`(`camera` / `bluetooth`)、`step: String`(`qr` / `barcode` / `result` / `none`) +- `event: String`、`reason: String?`、`destination: String?` +- `qrPayload: String?`、`barcodePayload: String?`、`code: String?`、`boxNumber: Int?`、`message: String?` + +`scan_log`はセッションへの外部キーを持たない。不受理はセッション開始前にも起きるうえ、セッションを削除したときに理由を説明するログまで消えては困るためである。`ScanLogRepository`は1件挿入するたびに`DELETE FROM scan_log WHERE id NOT IN (SELECT id FROM scan_log ORDER BY id DESC LIMIT 5000)`で切り詰めるので、上限は背景処理なしに保たれる。読取値を含むが、release gateがファイル書き込みを禁じている本番sourceでもRoomなら書けること、Auto Backup / D2D transferの除外がDBごと効くことがこの置き場所の理由である。 + +一致記録とcheckpoint更新は同一トランザクションで行い、checkpointが仕向地を持つ場合はセッション側の固定も同時に書く。DB migration testを最初のschemaから用意し(`MIGRATION_1_2`、`MIGRATION_2_3`、`MIGRATION_3_4`、および1→3・3→4・1→4の連続適用)、active sessionはアプリ再起動後も継続できるようにする。migrationは`CodeMatchDatabase`と`CodeMatchDatabaseFactory.create`の両方へ登録する(migration testは`MigrationTestHelper`を直接呼ぶため、factoryへの登録漏れを検出しない)。 ### 7.2 DataStore diff --git a/docs/android/PRIVACY.md b/docs/android/PRIVACY.md index 94d9d76..e19ea51 100644 --- a/docs/android/PRIVACY.md +++ b/docs/android/PRIVACY.md @@ -8,12 +8,15 @@ |---|---|---| | カメラ映像・解析フレーム | CameraX/ML Kitの解析中だけ一時利用 | 保存・送信しない | | 一致履歴 | Roomへ端末内保存。一致したQR/Code 128のpayloadを箱詳細とPDF生成に使う | analytics、クラッシュレポート、サーバーへ送信しない | -| 不一致・無効入力 | 照合状態とフィードバックにだけ使う | 履歴、診断、外部送信へ保存しない | +| 不一致・無効入力 | 照合状態とフィードバックに使い、下記の照合ログへ1行として記録する | 一致履歴、BLE診断、外部送信へは保存しない | +| 照合ログ | カメラ・BLEどちらの入力でも、判定(一致・不一致・重複)・不受理・セッション開始/終了を1件ずつRoomへ端末内保存する。読み取ったQR / Code 128のpayloadを含み、直近5,000件を超えると古いものから削除する。設定画面最下部で件数を表示し、消去できる | analytics、クラッシュレポート、サーバーへ送信しない。Auto Backupとdevice-to-device transferから除外する。利用者が「照合ログをすべて共有」「照合ログを保存」を選んだ時だけ、JSON Linesを共有シートまたは選択先へ渡す | | BLE診断 | 接続・設定・エラーの種別・連番・段階名を最大300件端末内に保持し、設定画面には直近20件の種別と連番だけを表示 | scan payloadを保存・表示・送信しない。利用者が「診断ログを共有」「診断ログを保存」を選んだ時だけ、段階名とアプリ/端末の版情報を含むテキストを共有シートまたは選択先へ渡す | | BLE復旧snapshot | `release`が公式SDK adapterへ接続し、開始前のsymbology設定を端末内に保存する | Auto Backupとdevice-to-device transferから除外する | | BLE既知端末identity | 同じ除外DataStoreへversion/profile、device ID、表示名だけを保存。設定値・scan payload・raw frameは含めない | Auto Backupとdevice-to-device transferから除外する | | PDF | ユーザーが保存を選んだ時は選択先へ、共有を選んだ時は専用cacheからSharesheetへ渡す | 明示操作の時だけアプリ領域外へ出る | +照合ログはデバッグ用で、BLE診断ログとは別物です。BLE診断ログは従来どおり読取値を含みません。照合ログのファイル書き出しは`core/export/ScanLogJsonExporter`が専用cache(`cache/codematch-export/`)へ行い、SAF保存は選択先へ直接書きます。 + 履歴のpayloadは「カメラ画像」ではありませんが、業務データとして扱います。端末内保存が不要な環境では、履歴の削除と端末管理ポリシーを利用してください。 ## 現行releaseの権限 @@ -28,6 +31,8 @@ releaseは公式Inateck SDKのBLE adapterを同梱します。releaseのアプ Room DB、設定DataStore、将来のBLE復旧・既知端末状態は、`android/app/src/main/res/xml/backup_rules.xml` と `data_extraction_rules.xml` のcloud/device-transfer双方で除外します。BLE snapshotのファイル名は `files/datastore/codematch-ble-symbology.preferences_pb` に固定し、汎用の `datastore/` 除外だけに依存しません。 +Room DBの`scan_log`テーブルも同じDBにあるため、上記の除外がそのまま効きます。 + PDF共有の `FileProvider` は `cache/codematch-pdf/` だけを公開し、provider自体は非exportedで一時読み取り権限に限定します。広いfiles/external/root pathは公開しません。 ## 確認方法 diff --git a/docs/android/STATUS.md b/docs/android/STATUS.md index 6b530be..8996362 100644 --- a/docs/android/STATUS.md +++ b/docs/android/STATUS.md @@ -17,7 +17,7 @@ |---|---| | Domain / matching | 純Kotlin matcher/parser、仕向地判定(澤井製作所66桁 / モルテン61桁 / デンソーはJAMA自己記述形式の可変長かんばん)と仕向地ごとのCode 128形式・品番表記・箱固有キー、shared fixture(`matching-cases.json`、schemaVersion 2・47ケース・`destination`は`sawai`/`molten`/`denso`)、JVM test、Swiftの単体/UIテストとの意図対応表([`TEST_PARITY.md`](TEST_PARITY.md)) | | UI / navigation | Composeの照合・履歴・設定、3 destination、system/predictive backの完了・無効・cancel境界、履歴選択のActivity再生成・destination往復・compact back stack、320dp/840dp・font scale 1.3/2.0の主要操作到達、動的案内・結果のpolite live region、emulatorでのQR待機・Code 128待機・一致結果のOS force-stop後UI復元。Pixel 7ではfont scale 1.3/2.0の主要表示・操作をユーザーが承認 | -| History / settings / PDF | Room(schema v3、セッションとcheckpointの仕向地列と`MIGRATION_2_3`を含む)/DataStore、日英リソースとper-app locale双方向同期、0件破棄・名称変更・詳細・削除のapp E2E、履歴詳細とPDFのモルテン項目(納品番号ごとの箱数・累計収容数と解析全項目)とデンソー項目(履歴詳細のかんばん解析13項目、PDFのかんばん要約と箱ごとのかんばん連番)、A4複数ページPDFの実render、SAF保存/専用FileProvider共有の契約test。Pixel 7では日英切替、1ページ/複数ページPDFのDownloads保存と共有先での表示、音量0/通常音量の音・触覚をユーザーが承認 | +| History / settings / PDF | Room(schema v4、セッションとcheckpointの仕向地列と`MIGRATION_2_3`、照合ログ`scan_log`と`MIGRATION_3_4`を含む)/DataStore、照合ログの記録・5,000件の切り詰め・件数表示・JSON Lines書き出し・消去、日英リソースとper-app locale双方向同期、0件破棄・名称変更・詳細・削除のapp E2E、履歴詳細とPDFのモルテン項目(納品番号ごとの箱数・累計収容数と解析全項目)とデンソー項目(履歴詳細のかんばん解析13項目、PDFのかんばん要約と箱ごとのかんばん連番)、A4複数ページPDFの実render、SAF保存/専用FileProvider共有の契約test。Pixel 7では日英切替、1ページ/複数ページPDFのDownloads保存と共有先での表示、音量0/通常音量の音・触覚をユーザーが承認 | | Camera | CameraX/ML Kit adapter、工程別ROI、権限・lifecycle・focus・format切替の非同期境界test。Pixel 7縦画面で実ラベルのQR→Code 128一致、復帰後のCode 128、タップfocus、権限の拒否・恒久拒否・再許可、ガイド枠内外の読取境界、無関係QR拒否、不一致の表示・音・振動・非加算をユーザーが承認 | | BLE | SDK非依存の安全コア(command直列化、全設定snapshot、復元前Ready禁止、known-device store、再接続予算)、公式SDK adapter、公式native通知parser、工程別symbology制限(QR待機はQRのみ、Code 128待機はCode 128のみ)、照明の接続時OFF適用、読取チューニング(差分時のみ書込・readback確認)、診断ログの共有・保存、R8 vendor-log除去。Pixel 7 / BCST-36では検索・接続・fresh readback、QR→Code 128一致、背景復元、QR待機中のapp force-stop後の自動再接続、手動切断後の工程保持と再接続、電源OFF→ONの自動再接続、通常終了・手動切断・電源再起動後の開始前設定との一致(独立probe)、照明の初期OFFと手動ON/OFFをユーザーが承認。2026-09-05に`release` APKでBCST-36と接続し、QR→Code 128の照合完了をユーザーが確認(#56)。2026-09-06にPixel 7で読取チューニング「適用済み」と赤光約4秒、診断ログの共有・保存をユーザーが確認 | | Privacy / release | Manifest、backup/D2D除外規則、専用FileProvider、`verify-release-hardening.sh`によるAPK/依存グラフ/source検査(Fake・analytics・INTERNET・legacy Bluetooth・位置情報の不在、`:scanner:inateck`とarm64 native libraryの同梱、vendor raw-log除去、ML Kit registrar保持)。Pixel 7のnetstatsで当該UIDの通信量エントリなし | @@ -60,6 +60,17 @@ JDK/SDKがない環境ではGradle結果を推測せず、実行不能として ## 履歴 +### 2026-09-08 照合ログ + +現場デバッグ用に、カメラ・BLEどちらの入力でも照合の結果と不受理を端末内へ記録し、設定画面の最下部から書き出せるようにした(Issue #120、Android側 #122)。 + +- Room を v4 へ上げ、`scan_log`(`at`索引つき、autoincrement id)と`MIGRATION_3_4`を追加した。`ScanLogRepository`は1件挿入するたびに直近5,000件へ切り詰め、`count`をFlowで公開し、`export`と`clear`を持つ。セッションへの外部キーは持たない(不受理はセッション開始前にも起き、セッション削除でログまで消してはならないため)。 +- 記録点は`ScanSessionCoordinator`の`scanLogRecorder`(末尾の省略可能引数)に置いた。`submitScanPayload`で入力元不一致(`rejected`/`source_mismatch`)とカメラ1フレーム目(`barcode_candidate`)、`dispatch`のreduce直後に`rejected`(`InvalidScanReason`をsnake_caseへ)・`qr_accepted`・`barcode_accepted`+`match`/`mismatch`/`duplicate`・結果表示中の握りつぶし(`rejected`/`result_pending`)・`session_end`を記録する。`session_start`は`ScanViewModel.beginSession`が記録し、session idはViewModelのrecorderラムダが埋める。`ScanEffect.InvalidScan`と BLE 診断ログは従来どおり読取値を持たない。 +- 書き出しは`core/export/ScanLogJsonExporter`のJSON Lines(1行目がヘッダ、以降1イベント1行、nullも明示、`at`はミリ秒つきISO 8601 UTC、ファイル名`codematch-scan-log-yyyyMMdd-HHmm.jsonl`)。共有は専用cache(`cache/codematch-export/`)+FileProviderの`text/plain`、保存はSAF `CreateDocument("text/plain")`で、いずれも設定画面側(host)が持つ。`verify-release-hardening.sh`の`File(`許可リストへ`ScanLogJsonExporter`を足した以外、release gateは変えていない。 +- 設定画面の最下部に「照合ログ」カードを追加した(`settings_scan_log`、件数`settings_scan_log_count`、共有・保存・消去の3ボタンは48dp、0件では共有・保存を無効化、消去は確認ダイアログ)。文言は日英そろえて追加した。 + +証跡: `lintDebug testDebugUnitTest` 457件(失敗・error 0)、`:app:assembleRelease`、`verify-release-hardening.sh`(全項目通過)、Pixel 7(Android 16 / API 36)で`:core:data` 32件・`:feature:settings` 22件のinstrumentationが成功。`AppFlowInstrumentationTest`の一致→重複→設定画面の件数はCI emulator(API 36)で実行する。実スキャナーでの照合ログ書き出しと共有先での受け取りは未実施。 + ### 2026-09-07 仕向地デンソー対応 3つ目の仕向地デンソーを照合の前提に加えた(Issue #106、子issue #107〜#112)。 diff --git a/docs/android/TEST_PARITY.md b/docs/android/TEST_PARITY.md index cc6888b..d9f6dc9 100644 --- a/docs/android/TEST_PARITY.md +++ b/docs/android/TEST_PARITY.md @@ -205,6 +205,25 @@ Issue #106(PR #113 / #114 / #115 / #116、iOS の履歴・PDF は #117)で | UI 7 | Fake Bluetooth のデンソー流れで仕向地が固定され、品番ごとに箱が数えられる(`CodeMatchUITests::testMockBluetoothScannerDensoFlowLocksDestination`) | `AppFlowInstrumentationTest.kt::fakeScannerDensoFlowCountsBoxesPerPartNumberAndLocksDestination` は同じ debug Fake、app navigation、ViewModel、Room を通す | D(debug Fake) | | PDF 2 | 履歴詳細のデンソー13項目(帳票区分・部品番号・包装・収容数・次区・指示・かんばん連番・管理番号・納入日・便・指示数・アイテムNo・受入)、PDF のかんばん要約3行と箱ごとのかんばん連番・管理コード、カード番号と納品番号数が出ないこと、履歴 JSON の `"destination": "denso"`。Swift 側は #117 で追加する | `HistoryPdfContentTest.kt::densoReportPrintsKanbanBlockAndBoxesPerPartNumber` + `::englishDensoReportUsesEnglishLabels` + `HistoryDeliveryGroupsTest.kt::densoEntriesProduceNoDeliveryGroups` + `HistoryJsonExporterTest.kt::densoSessionExportsItsDestinationIdAndKanbanPayloadByteForByte` + `HistoryScreenTest.kt::densoEntryDetailDisplaysAllParsedFields` + `::densoSessionDetailAndRowShowDestination` | D | +## 照合ログ(#122、Android 側の証拠) + +親 Issue #120 の iOS 側(#121)は別 PR なので、この節は Swift との行対応ではなく Android 側の証拠だけを挙げる。JSONL のスキーマ一致は両 OS の出力を突き合わせて確認する。 + +| 対象 | Android の証拠 | +|---|---| +| 一致・不一致・重複と受理された QR / Code 128 が記録される | `ScanSessionCoordinatorTest.kt::scanLogRecordsAcceptedQrBarcodeMatchAndSessionEnd` + `::scanLogRecordsMismatchAndDuplicateWithTheirPartNumberButNoBoxNumber` | +| 不受理 7 種(`session_not_started` / `wrong_order` / `empty` / `incomplete` / `overlong` / `invalid` / `wrong_destination`)が読取値つきで記録される | `ScanSessionCoordinatorTest.kt::scanLogRecordsEveryRejectionReasonWithTheValueThatCausedIt` | +| 入力元不一致の破棄、カメラの確認 1 フレーム目、結果表示中に握りつぶした読取も残る | `ScanSessionCoordinatorTest.kt::scanLogRecordsDroppedSourceConfirmationCandidateAndResultCallbacks` | +| Room v4 の `scan_log`、3→4 と 1→4 の migration | `CodeMatchDatabaseMigrationTest.kt::versionThreeMigratesToVersionFourWithTheScanLogTable` + `::versionOneMigratesToVersionFourThroughEveryMigration`(Pixel 7) | +| 全項目の往復、件数 Flow、消去、5,000 件の切り詰め | `ScanLogRepositoryTest.kt`(Pixel 7、4 件) | +| JSONL のヘッダ行・1 イベント 1 行・null の明示・引用符/改行/バックスラッシュのエスケープ・末尾空白の保持・ファイル名 | `ScanLogJsonExporterTest.kt`(5 件) | +| 設定画面最下部のカード、件数表示、0 件での共有・保存の無効化、48dp、消去の確認ダイアログ | `SettingsScreenTest.kt::scanLogCardReportsItsCountAndGatesSharingSavingAndClearing` + `::explicitlyOpenedGuideShowsThreeStepsAndAllPreferenceGroups` + `::primarySettingsControlsKeepAccessibleTouchTargets`(Pixel 7) | +| 日英の文言と書式トークンの一致 | `SettingsUiTextTest.kt`(新規、`HistoryUiTextTest` と同じ形) | +| 一致 → 同じ箱の再読み → 設定画面の件数 | `AppFlowInstrumentationTest.kt::matchAndRepeatedBoxAreRecordedInTheScanLogAndCountedInSettings`(CI emulator) | +| BLE 診断ログが読取値を含まないままであること | `BleExternalScannerTest.kt::facadeMapsExternalScannerStateAndKeepsPayloadsOutOfDiagnostics`(変更なし) | + +実スキャナーでの照合ログ書き出し、共有先アプリでの受け取り、5,000 件を実際に超えた運用は未実施である。 + ## 残る物理・手動・未対応の証拠 2026-09-05のIssue #57で、この節に挙がる実機・手動ゲートのうち未実施のものは打ち切りとし、これ以上確認しません。打ち切りは検証成功を意味せず、`P`/`—`の分類は変更しません。一覧は[`STATUS.md`](STATUS.md)の「打ち切った確認項目」を参照してください。 From 6ab671b4995bb69856a60ec5dc50f34002d44f8e Mon Sep 17 00:00:00 2001 From: rimtty Date: Tue, 8 Sep 2026 00:55:22 +0900 Subject: [PATCH 2/2] fix(android-scan): write scan-log events in order and never let a failed write crash Events are queued through one unlimited channel drained by a single coroutine, so the log keeps the coordinator's order (the emulator run interleaved the per-event launches), and a write that fails because the database is already closed is dropped instead of crashing the process. --- .../jp/rimtty/codematch/scan/ScanViewModel.kt | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/jp/rimtty/codematch/scan/ScanViewModel.kt b/android/app/src/main/java/jp/rimtty/codematch/scan/ScanViewModel.kt index f99ff80..0d078d4 100644 --- a/android/app/src/main/java/jp/rimtty/codematch/scan/ScanViewModel.kt +++ b/android/app/src/main/java/jp/rimtty/codematch/scan/ScanViewModel.kt @@ -32,6 +32,7 @@ import jp.rimtty.codematch.scanner.api.ScanPayload import jp.rimtty.codematch.scanner.api.ScannerIssue import jp.rimtty.codematch.scanner.api.scannerIssueFor import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -126,10 +127,30 @@ class ScanViewModel @Inject constructor( private var bluetoothFallbackActive = false private var bluetoothFallbackIssue = ScannerIssue.NONE + /** + * Scan-log writes queued in arrival order. + * + * One consumer drains the queue so the log keeps the exact order the + * coordinator produced (a match must follow its barcode, never precede + * it), and a failing write (for example a database already closed while + * the ViewModel is being torn down) is dropped instead of crashing the + * process: the log is a diagnostic, never a reason to lose a scan. + */ + private val scanLogQueue = Channel(Channel.UNLIMITED) + init { + viewModelScope.launch { + for (event in scanLogQueue) { + runCatching { scanLogRepository.record(event) } + } + } initialize() } + private fun enqueueScanLog(event: ScanLogEvent) { + scanLogQueue.trySend(event) + } + /** * Translate one-way UI intent into repository/coordinator work. * @@ -380,9 +401,7 @@ class ScanViewModel @Inject constructor( // The coordinator is built before a session id exists, so it records // events without one and this lambda stamps the active session on. created.scanLogRecorder = ScanLogRecorder { event -> - viewModelScope.launch { - scanLogRepository.record(event.copy(sessionId = activeSessionId)) - } + enqueueScanLog(event.copy(sessionId = activeSessionId)) } created.onStateChanged = { publishCoordinatorState() } created.onEffects = ::handleEffects @@ -466,7 +485,7 @@ class ScanViewModel @Inject constructor( // Recorded here rather than from ScanEffect.SessionStarted: a // checkpoint restore re-fires that effect and would log a second // start for a session that was already running. - scanLogRepository.record( + enqueueScanLog( ScanLogEvent( atEpochMillis = System.currentTimeMillis(), sessionId = id,