From 864ecb2352a028a57aaac019811c8f365e011309 Mon Sep 17 00:00:00 2001 From: rimtty Date: Mon, 7 Sep 2026 01:43:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(android-history):=20=E7=85=A7=E5=90=88?= =?UTF-8?q?=E5=B1=A5=E6=AD=B4=E3=82=92=E3=81=99=E3=81=B9=E3=81=A6=20JSON?= =?UTF-8?q?=20=E3=81=A7=E5=85=B1=E6=9C=89=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 履歴一覧のヘッダーに「照合履歴をすべて共有」(shareAllHistoryButton、履歴が無いときは無効) - HistoryJsonExporter: iOS と共通スキーマ(schemaVersion 1、ISO 8601 UTC、null 明示、ペイロード無加工)を手書き JSON で出力し cache/codematch-export/ に保存 - HistoryJsonBridge / HistoryRoute: FileProvider 経由の ACTION_SEND(application/json)、失敗時はスナックバー - file_paths.xml に専用の cache-path を追加し、verify-release-hardening.sh を 2 経路・2 エクスポータに厳密化 - テスト: HistoryJsonExporterTest 8 本、HistoryJsonBridgeTest 5 本、HistoryScreenTest 2 本(JVM 426 件、Pixel 7 15 件、release 検証成功) --- .../codematch/history/HistoryJsonBridge.kt | 106 ++++++++ .../rimtty/codematch/history/HistoryRoute.kt | 35 +++ .../app/src/main/res/values-en/strings.xml | 1 + android/app/src/main/res/values/strings.xml | 1 + android/app/src/main/res/xml/file_paths.xml | 3 + .../history/HistoryJsonBridgeTest.kt | 197 +++++++++++++++ android/core/export/build.gradle.kts | 3 + .../core/export/HistoryJsonExporter.kt | 191 ++++++++++++++ .../core/export/HistoryJsonExporterTest.kt | 239 ++++++++++++++++++ .../feature/history/HistoryScreenTest.kt | 46 ++++ .../feature/history/HistoryScreen.kt | 35 ++- .../feature/history/HistoryUiResources.kt | 1 + .../feature/history/HistoryUiText.kt | 1 + .../src/main/res/values-en/strings.xml | 1 + .../history/src/main/res/values/strings.xml | 1 + android/scripts/verify-release-hardening.sh | 17 +- 16 files changed, 868 insertions(+), 10 deletions(-) create mode 100644 android/app/src/main/java/jp/rimtty/codematch/history/HistoryJsonBridge.kt create mode 100644 android/app/src/test/java/jp/rimtty/codematch/history/HistoryJsonBridgeTest.kt create mode 100644 android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryJsonExporter.kt create mode 100644 android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryJsonExporterTest.kt 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 new file mode 100644 index 0000000..336354a --- /dev/null +++ b/android/app/src/main/java/jp/rimtty/codematch/history/HistoryJsonBridge.kt @@ -0,0 +1,106 @@ +package jp.rimtty.codematch.history + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.core.content.FileProvider +import java.io.File +import java.time.Instant +import java.time.ZoneId +import jp.rimtty.codematch.core.export.HistoryJsonExporter +import jp.rimtty.codematch.core.model.MatchSession + +/** Failure categories intentionally contain no exception, URI, or path data. */ +internal enum class HistoryJsonFailure { + CACHE_WRITE_FAILED, + FILE_PROVIDER_FAILED, + SHARE_LAUNCH_FAILED, +} + +internal sealed interface HistoryJsonResult { + data class Success(val value: T) : HistoryJsonResult + + data class Failure(val reason: HistoryJsonFailure) : HistoryJsonResult +} + +/** + * Android bridge for sharing the whole history as one JSON document. + * + * The export is a cross-platform data file rather than a report, so it is only + * shared: there is no SAF save flow and, unlike the PDF, nothing is ever + * written outside the app-private cache by this app itself. Serialization + * stays in `core/export`; this object owns FileProvider and Intent handling. + */ +internal object HistoryJsonBridge { + const val JSON_MIME_TYPE: String = HistoryJsonExporter.JSON_MIME_TYPE + + /** Version string embedded in the export, formatted `versionName (versionCode)`. */ + fun appVersion(context: Context): String = try { + val info = context.packageManager.getPackageInfo(context.packageName, 0) + val name = info.versionName?.takeIf { it.isNotBlank() } ?: UNKNOWN_VERSION + "$name (${info.longVersionCode})" + } catch (_: Exception) { + UNKNOWN_VERSION + } + + fun writeShareCache( + context: Context, + sessions: List, + exportedAt: Instant = Instant.now(), + zoneId: ZoneId = ZoneId.systemDefault(), + ): HistoryJsonResult = writeShareCache( + context = context, + sessions = sessions, + exportedAt = exportedAt, + zoneId = zoneId, + writeToCache = { currentContext, json, currentExportedAt, currentZone -> + HistoryJsonExporter.writeToCache(currentContext, json, currentExportedAt, currentZone) + }, + ) + + /** Injectable cache seam so a write failure can be tested deterministically. */ + internal fun writeShareCache( + context: Context, + sessions: List, + exportedAt: Instant, + zoneId: ZoneId, + writeToCache: (Context, String, Instant, ZoneId) -> File, + ): HistoryJsonResult = try { + val json = HistoryJsonExporter.build( + sessions = sessions, + exportedAt = exportedAt, + appVersion = appVersion(context), + ) + HistoryJsonResult.Success(writeToCache(context, json, exportedAt, zoneId)) + } catch (_: Exception) { + HistoryJsonResult.Failure(HistoryJsonFailure.CACHE_WRITE_FAILED) + } + + fun createShareChooser(context: Context, file: File): HistoryJsonResult = try { + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file, + ) + HistoryJsonResult.Success(Intent.createChooser(createShareIntent(uri), null)) + } catch (_: Exception) { + HistoryJsonResult.Failure(HistoryJsonFailure.FILE_PROVIDER_FAILED) + } + + internal fun launchShare(context: Context, chooser: Intent): HistoryJsonResult = try { + context.startActivity(chooser) + HistoryJsonResult.Success(Unit) + } catch (_: Exception) { + HistoryJsonResult.Failure(HistoryJsonFailure.SHARE_LAUNCH_FAILED) + } + + internal fun createShareIntent(uri: Uri): Intent = Intent(Intent.ACTION_SEND).apply { + type = JSON_MIME_TYPE + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + clipData = ClipData.newRawUri(null, uri) + } + + private const val UNKNOWN_VERSION = "unknown" +} diff --git a/android/app/src/main/java/jp/rimtty/codematch/history/HistoryRoute.kt b/android/app/src/main/java/jp/rimtty/codematch/history/HistoryRoute.kt index 297da04..441f03d 100644 --- a/android/app/src/main/java/jp/rimtty/codematch/history/HistoryRoute.kt +++ b/android/app/src/main/java/jp/rimtty/codematch/history/HistoryRoute.kt @@ -71,6 +71,7 @@ fun HistoryRoute(modifier: Modifier = Modifier) { val retryLabel = androidx.compose.ui.res.stringResource(R.string.history_pdf_retry) val saveErrorMessage = androidx.compose.ui.res.stringResource(R.string.history_pdf_save_error) val shareErrorMessage = androidx.compose.ui.res.stringResource(R.string.history_pdf_share_error) + val shareAllErrorMessage = androidx.compose.ui.res.stringResource(R.string.history_share_all_failed) fun reportPdfFailure(message: String, retry: (() -> Unit)?) { feedback = HistoryPdfFeedback(message = message, retry = retry) @@ -132,6 +133,39 @@ fun HistoryRoute(modifier: Modifier = Modifier) { } } + /** + * Share every session — active and ended, with their raw payloads — as one + * JSON file. The document is built from the same repository state the list + * shows, so no extra query can disagree with what the operator sees. + */ + fun startShareAllHistory() { + feedback = null + val generation = exportGeneration + 1L + exportGeneration = generation + pendingDocument = null + val sessions = state.sessions + scope.launch(Dispatchers.IO) { + val result = when ( + val cacheResult = HistoryJsonBridge.writeShareCache(context, sessions) + ) { + is HistoryJsonResult.Success -> + HistoryJsonBridge.createShareChooser(context, cacheResult.value) + + is HistoryJsonResult.Failure -> HistoryJsonResult.Failure(cacheResult.reason) + } + withContext(Dispatchers.Main.immediate) { + if (generation != exportGeneration) return@withContext + val launched = when (result) { + is HistoryJsonResult.Success -> HistoryJsonBridge.launchShare(context, result.value) + is HistoryJsonResult.Failure -> result + } + if (launched is HistoryJsonResult.Failure) { + reportPdfFailure(shareAllErrorMessage) { startShareAllHistory() } + } + } + } + } + fun handleDocumentResult(destination: Uri?) { val pending = pendingDocument pendingDocument = null @@ -253,6 +287,7 @@ fun HistoryRoute(modifier: Modifier = Modifier) { onBack = goBack, onSavePdf = ::startSave, onSharePdf = ::startShare, + onShareAllHistory = ::startShareAllHistory, modifier = Modifier.fillMaxSize(), ) } diff --git a/android/app/src/main/res/values-en/strings.xml b/android/app/src/main/res/values-en/strings.xml index 02950d7..3746de6 100644 --- a/android/app/src/main/res/values-en/strings.xml +++ b/android/app/src/main/res/values-en/strings.xml @@ -28,6 +28,7 @@ Could not save the PDF. Could not share the PDF. Retry + Could not export the history App settings for automatic next comparison and language Settings diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 4d2741c..35e8cff 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -28,6 +28,7 @@ PDFを保存できませんでした。 PDFを共有できませんでした。 再試行 + 照合履歴の書き出しに失敗しました 自動次工程と言語などのアプリ設定画面 設定 diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml index bc214c3..a2fe59c 100644 --- a/android/app/src/main/res/xml/file_paths.xml +++ b/android/app/src/main/res/xml/file_paths.xml @@ -3,4 +3,7 @@ + diff --git a/android/app/src/test/java/jp/rimtty/codematch/history/HistoryJsonBridgeTest.kt b/android/app/src/test/java/jp/rimtty/codematch/history/HistoryJsonBridgeTest.kt new file mode 100644 index 0000000..145250b --- /dev/null +++ b/android/app/src/test/java/jp/rimtty/codematch/history/HistoryJsonBridgeTest.kt @@ -0,0 +1,197 @@ +package jp.rimtty.codematch.history + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.net.Uri +import androidx.core.content.FileProvider +import androidx.test.core.app.ApplicationProvider +import java.io.File +import java.time.Instant +import java.time.ZoneId +import jp.rimtty.codematch.core.export.HistoryJsonExporter +import jp.rimtty.codematch.core.model.MatchEntry +import jp.rimtty.codematch.core.model.MatchSession +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Mirrors the PDF share contract for the JSON history export. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class HistoryJsonBridgeTest { + private lateinit var context: Context + + private val exportedAt = Instant.parse("2026-09-07T01:23:45Z") + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + clearFileProviderPathCache() + } + + /** + * FileProvider caches one path strategy per authority in a static map, and + * Robolectric gives every test its own data directory. A strategy left + * behind by an earlier test therefore points at a directory this test's + * cache files can never be under, which would make the share-contract + * assertion depend on test order rather than on the provider configuration. + */ + private fun clearFileProviderPathCache() { + val field = try { + FileProvider::class.java.getDeclaredField("sCache") + } catch (_: NoSuchFieldException) { + // No cache in this version means there is nothing to go stale. + return + } + field.isAccessible = true + (field.get(null) as? MutableMap<*, *>)?.clear() + } + + @Test + fun shareCacheWritesTheWholeHistoryBelowTheExportCacheDirectory() { + val session = MatchSession( + id = "session-1", + startedAt = Instant.parse("2026-09-07T00:00:00Z").toEpochMilli(), + entries = listOf( + MatchEntry( + id = "entry-1", + code = "D10E-50-N10B", + matchedAt = Instant.parse("2026-09-07T00:05:00Z").toEpochMilli(), + qrPayload = MOLTEN_TRAILING_SPACE_QR, + barcodePayload = "D10E-50-N10B@0UBL00", + ), + ), + ) + + val result = HistoryJsonBridge.writeShareCache( + context = context, + sessions = listOf(session), + exportedAt = exportedAt, + zoneId = ZoneId.of("UTC"), + ) + + assertTrue("result=$result", result is HistoryJsonResult.Success) + val file = (result as HistoryJsonResult.Success).value + try { + assertEquals( + File(context.cacheDir, HistoryJsonExporter.CACHE_DIRECTORY).canonicalFile, + file.parentFile?.canonicalFile, + ) + assertEquals("codematch-history-20260907-0123.json", file.name) + val json = file.readText() + // The trailing spaces of the Molten record must survive the file. + assertTrue(json.contains("\"$MOLTEN_TRAILING_SPACE_QR\"")) + assertTrue(json.contains("\"platform\": \"android\"")) + assertTrue(json.contains("\"appVersion\": \"${HistoryJsonBridge.appVersion(context)}\"")) + } finally { + file.delete() + } + } + + @Test + fun appVersionCombinesNameAndCode() { + val version = HistoryJsonBridge.appVersion(context) + + assertTrue("version=$version", Regex(".+ \\(\\d+\\)").matches(version)) + } + + @Test + fun cacheFailureIsTypedWithoutExposingExceptionDetails() { + val result = HistoryJsonBridge.writeShareCache( + context = context, + sessions = emptyList(), + exportedAt = exportedAt, + zoneId = ZoneId.of("UTC"), + writeToCache = { _, _, _, _ -> error("cache path") }, + ) + + assertEquals( + HistoryJsonFailure.CACHE_WRITE_FAILED, + (result as HistoryJsonResult.Failure).reason, + ) + } + + @Test + fun fileProviderAndShareLaunchFailuresAreTyped() { + val outsideFile = File(context.filesDir, "not-in-provider-roots.json").apply { + writeText("{}") + } + try { + val contextWithoutProvider = object : ContextWrapper(context) { + override fun getPackageName(): String = "jp.rimtty.codematch.no_provider_json" + } + val providerResult = HistoryJsonBridge.createShareChooser(contextWithoutProvider, outsideFile) + assertEquals( + HistoryJsonFailure.FILE_PROVIDER_FAILED, + (providerResult as HistoryJsonResult.Failure).reason, + ) + } finally { + outsideFile.delete() + } + + val failingContext = object : ContextWrapper(context) { + override fun startActivity(intent: Intent) { + throw ActivityNotFoundException() + } + } + val launchResult = HistoryJsonBridge.launchShare( + context = failingContext, + chooser = Intent(Intent.ACTION_CHOOSER), + ) + assertEquals( + HistoryJsonFailure.SHARE_LAUNCH_FAILED, + (launchResult as HistoryJsonResult.Failure).reason, + ) + } + + @Test + fun shareChooserUsesFileProviderUriJsonTypeClipDataAndReadGrant() { + val directory = File(context.cacheDir, HistoryJsonExporter.CACHE_DIRECTORY) + assertTrue(directory.isDirectory || directory.mkdirs()) + val file = File(directory, "share-contract.json").apply { + writeText("{}") + } + + try { + val chooserResult = HistoryJsonBridge.createShareChooser(context, file) + assertTrue("result=$chooserResult", chooserResult is HistoryJsonResult.Success) + val chooser = (chooserResult as HistoryJsonResult.Success).value + assertEquals(Intent.ACTION_CHOOSER, chooser.action) + + @Suppress("DEPRECATION") + val sendIntent = chooser.getParcelableExtra(Intent.EXTRA_INTENT) + val actualSendIntent = requireNotNull(sendIntent) + assertEquals(Intent.ACTION_SEND, actualSendIntent.action) + assertEquals("application/json", actualSendIntent.type) + assertEquals(HistoryJsonBridge.JSON_MIME_TYPE, actualSendIntent.type) + + @Suppress("DEPRECATION") + val sharedUri = actualSendIntent.getParcelableExtra(Intent.EXTRA_STREAM) + assertNotNull(sharedUri) + val uriText = requireNotNull(sharedUri).toString() + assertTrue(uriText.startsWith("content://${context.packageName}.fileprovider/")) + assertTrue(uriText.contains("/history_export/share-contract.json")) + assertTrue( + actualSendIntent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0, + ) + assertEquals( + sharedUri, + actualSendIntent.clipData?.getItemAt(0)?.uri, + ) + } finally { + file.delete() + } + } + + private companion object { + const val MOLTEN_TRAILING_SPACE_QR = + "AK6805D10E50N10B U543820000MB S600700000020908 " + } +} diff --git a/android/core/export/build.gradle.kts b/android/core/export/build.gradle.kts index a9ac884..f8e0b85 100644 --- a/android/core/export/build.gradle.kts +++ b/android/core/export/build.gradle.kts @@ -21,6 +21,9 @@ dependencies { implementation(project(":core:matching")) testImplementation(libs.junit) + // Parses the generated history JSON back in unit tests; the exporter itself + // stays dependency-free so it can run without Android's org.json stubs. + testImplementation(libs.gson) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.test.runner) diff --git a/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryJsonExporter.kt b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryJsonExporter.kt new file mode 100644 index 0000000..c14ea06 --- /dev/null +++ b/android/core/export/src/main/kotlin/jp/rimtty/codematch/core/export/HistoryJsonExporter.kt @@ -0,0 +1,191 @@ +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.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.util.Locale +import jp.rimtty.codematch.core.model.MatchEntry +import jp.rimtty.codematch.core.model.MatchSession + +/** + * Serializes the whole match history to one JSON document for offline analysis. + * + * Unlike the PDF report this export is deliberately lossless: payloads are + * written verbatim, including the trailing spaces a Molten record depends on, + * and a value that was never recorded is emitted as JSON `null` rather than + * being omitted. Both apps must produce the same document, so the schema — + * key names, key order, the ISO 8601 UTC timestamps, and the destination ids — + * is a cross-platform contract; see the iOS `HistoryJSONExporter`. + * + * The document is written by hand rather than through a JSON library so the + * module keeps working in plain JVM unit tests and stays free of an Android + * `org.json` stub, and so escaping and key order remain explicit. + */ +object HistoryJsonExporter { + /** Bump only together with the iOS exporter and any reader of this file. */ + const val SCHEMA_VERSION: Int = 1 + + /** Value of the `platform` field written by this app. */ + const val PLATFORM: String = "android" + + const val JSON_MIME_TYPE: String = "application/json" + + /** Name of the only cache directory used for a shareable history export. */ + const val CACHE_DIRECTORY: String = "codematch-export" + + /** + * Build the export document. + * + * [sessions] are written in the order given: the repository already lists + * them newest first, and each session's entries already arrive in scan + * sequence, so this function never reorders history. + * + * [appVersion] is supplied by the caller (`versionName (versionCode)`) + * because this module has no access to the application's package info. + */ + fun build( + sessions: List, + exportedAt: Instant, + platform: String = PLATFORM, + appVersion: String, + ): String = buildString { + append("{\n") + append(" \"schemaVersion\": ").append(SCHEMA_VERSION).append(",\n") + append(" \"platform\": ").appendJsonString(platform).append(",\n") + append(" \"appVersion\": ").appendJsonString(appVersion).append(",\n") + append(" \"exportedAt\": ").appendJsonString(isoInstant(exportedAt)).append(",\n") + append(" \"sessions\": ") + appendArray(sessions, indent = " ") { session, indent -> + appendSession(session, indent) + } + append("\n}\n") + } + + /** `codematch-history-20260907-0123.json`, stamped in the caller's zone. */ + fun fileName( + exportedAt: Instant, + zoneId: ZoneId = ZoneId.systemDefault(), + ): String = "codematch-history-" + + FILE_STAMP.format(exportedAt.atZone(zoneId)) + + ".json" + + /** + * Write one export below app-private cache storage. + * + * FileProvider URI creation and Intent ownership stay in the app layer, so + * callers only need the returned file. The directory is separate from the + * PDF cache so each export type can be granted on its own provider path. + */ + fun writeToCache( + context: Context, + json: String, + exportedAt: Instant = Instant.now(), + zoneId: ZoneId = ZoneId.systemDefault(), + ): File { + val directory = File(context.cacheDir, CACHE_DIRECTORY) + check(directory.isDirectory || directory.mkdirs()) { + "History export cache directory could not be created" + } + val output = File(directory, fileName(exportedAt, zoneId)) + check(output.canonicalFile.parentFile == directory.canonicalFile) { + "History export filename escaped its private cache directory" + } + output.outputStream().use { stream -> + stream.write(json.toByteArray(Charsets.UTF_8)) + } + return output + } + + private fun StringBuilder.appendSession(session: MatchSession, indent: String) { + val inner = "$indent " + append("{\n") + append(inner).append("\"id\": ").appendJsonString(session.id).append(",\n") + append(inner).append("\"name\": ").appendJsonString(session.name).append(",\n") + append(inner).append("\"destination\": ") + .appendJsonString(session.resolvedDestination()?.id).append(",\n") + append(inner).append("\"startedAt\": ") + .appendJsonString(isoInstant(session.startedAt)).append(",\n") + append(inner).append("\"endedAt\": ") + .appendJsonString(session.endedAt?.let { isoInstant(it) }).append(",\n") + append(inner).append("\"entries\": ") + appendArray(session.entries, inner) { entry, entryIndent -> + appendEntry(entry, entryIndent) + } + append("\n").append(indent).append("}") + } + + private fun StringBuilder.appendEntry(entry: MatchEntry, indent: String) { + val inner = "$indent " + append("{\n") + append(inner).append("\"id\": ").appendJsonString(entry.id).append(",\n") + append(inner).append("\"code\": ").appendJsonString(entry.code).append(",\n") + append(inner).append("\"matchedAt\": ") + .appendJsonString(isoInstant(entry.matchedAt)).append(",\n") + append(inner).append("\"qrPayload\": ").appendJsonString(entry.qrPayload).append(",\n") + append(inner).append("\"barcodePayload\": ") + .appendJsonString(entry.barcodePayload).append("\n") + append(indent).append("}") + } + + private fun StringBuilder.appendArray( + values: List, + indent: String, + appendValue: StringBuilder.(T, String) -> Unit, + ) { + if (values.isEmpty()) { + append("[]") + return + } + val elementIndent = "$indent " + append("[\n") + values.forEachIndexed { index, value -> + append(elementIndent) + appendValue(value, elementIndent) + if (index != values.lastIndex) append(",") + append("\n") + } + append(indent).append("]") + } + + /** `2026-09-07T01:23:45Z`; a null value becomes the JSON literal `null`. */ + private fun isoInstant(epochMillis: Long): String = + isoInstant(Instant.ofEpochMilli(epochMillis)) + + private fun isoInstant(instant: Instant): String = + DateTimeFormatter.ISO_INSTANT.format(instant.truncatedTo(ChronoUnit.SECONDS)) + + /** + * 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. + */ + 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) +} diff --git a/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryJsonExporterTest.kt b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryJsonExporterTest.kt new file mode 100644 index 0000000..73de3d6 --- /dev/null +++ b/android/core/export/src/test/kotlin/jp/rimtty/codematch/core/export/HistoryJsonExporterTest.kt @@ -0,0 +1,239 @@ +package jp.rimtty.codematch.core.export + +import com.google.gson.JsonObject +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.MatchEntry +import jp.rimtty.codematch.core.model.MatchSession +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The export is meant for offline analysis of raw scans, so this test pins the + * document shape and, above all, that payloads survive byte for byte: a Molten + * record's trailing spaces are data and a trimmed payload is a different slip. + */ +class HistoryJsonExporterTest { + private val exportedAt = Instant.parse("2026-09-07T01:23:45.678Z") + + private val moltenSession = MatchSession( + id = "session-molten", + startedAt = Instant.parse("2026-09-07T00:10:00.400Z").toEpochMilli(), + endedAt = Instant.parse("2026-09-07T00:41:30.900Z").toEpochMilli(), + name = "モルテン 午前", + // Deliberately not stored: a session recorded before the destination + // column existed must still export the destination its QRs identify. + destination = null, + entries = listOf( + MatchEntry( + id = "entry-1", + code = "PAF1-15-422", + matchedAt = Instant.parse("2026-09-07T00:12:00Z").toEpochMilli(), + qrPayload = MOLTEN_PAF_QR, + barcodePayload = "PAF1-15-422@0NKD3C", + sequence = 0L, + ), + MatchEntry( + id = "entry-2", + code = "PAF1-15-422", + matchedAt = Instant.parse("2026-09-07T00:19:20Z").toEpochMilli(), + qrPayload = MOLTEN_PAF_QR, + barcodePayload = "PAF1-15-422@0NLL3C", + sequence = 1L, + ), + MatchEntry( + id = "entry-3", + code = "D10E-50-N10B", + matchedAt = Instant.parse("2026-09-07T00:33:05Z").toEpochMilli(), + qrPayload = MOLTEN_TRAILING_SPACE_QR, + barcodePayload = "D10E-50-N10B@0UBL00", + sequence = 2L, + ), + ), + ) + + private val sawaiLegacySession = MatchSession( + id = "session-sawai", + startedAt = Instant.parse("2026-09-06T23:00:00Z").toEpochMilli(), + endedAt = null, + name = null, + destination = Destination.SAWAI, + entries = listOf( + MatchEntry( + id = "entry-legacy", + code = "BCJH-55-81GG", + matchedAt = Instant.parse("2026-09-06T23:05:00Z").toEpochMilli(), + // Recorded before payloads were kept. + qrPayload = null, + barcodePayload = null, + sequence = 0L, + ), + ), + ) + + @Test + fun documentCarriesSchemaPlatformVersionAndUtcExportTimestamp() { + val root = export(listOf(moltenSession, sawaiLegacySession)) + + assertEquals(1, root["schemaVersion"].asInt) + assertEquals(HistoryJsonExporter.SCHEMA_VERSION, root["schemaVersion"].asInt) + assertEquals("android", root["platform"].asString) + assertEquals("0.1.0 (1)", root["appVersion"].asString) + // Seconds precision, UTC, with the trailing Z. + assertEquals("2026-09-07T01:23:45Z", root["exportedAt"].asString) + } + + @Test + fun sessionsKeepRepositoryOrderAndResolveTheirDestinationId() { + val sessions = export(listOf(moltenSession, sawaiLegacySession))["sessions"].asJsonArray + + assertEquals(2, sessions.size()) + val molten = sessions[0].asJsonObject + val sawai = sessions[1].asJsonObject + assertEquals("session-molten", molten["id"].asString) + assertEquals("session-sawai", sawai["id"].asString) + // Derived from the first entry that kept its QR, exactly like the PDF. + assertEquals("molten", molten["destination"].asString) + assertEquals("sawai", sawai["destination"].asString) + assertEquals("モルテン 午前", molten["name"].asString) + assertEquals("2026-09-07T00:10:00Z", molten["startedAt"].asString) + assertEquals("2026-09-07T00:41:30Z", molten["endedAt"].asString) + assertEquals("2026-09-06T23:00:00Z", sawai["startedAt"].asString) + } + + @Test + fun missingValuesAreEmittedAsJsonNullRatherThanOmitted() { + val sessions = export(listOf(moltenSession, sawaiLegacySession))["sessions"].asJsonArray + val sawai = sessions[1].asJsonObject + val entry = sawai["entries"].asJsonArray[0].asJsonObject + + assertTrue(sawai.has("name")) + assertTrue(sawai["name"].isJsonNull) + assertTrue(sawai.has("endedAt")) + assertTrue(sawai["endedAt"].isJsonNull) + assertTrue(entry.has("qrPayload")) + assertTrue(entry["qrPayload"].isJsonNull) + assertTrue(entry.has("barcodePayload")) + assertTrue(entry["barcodePayload"].isJsonNull) + assertEquals("BCJH-55-81GG", entry["code"].asString) + assertEquals("2026-09-06T23:05:00Z", entry["matchedAt"].asString) + } + + @Test + fun entriesStayInSequenceOrderAndPayloadsSurviveByteForByte() { + val entries = export(listOf(moltenSession, sawaiLegacySession))["sessions"] + .asJsonArray[0].asJsonObject["entries"].asJsonArray + + assertEquals(3, entries.size()) + assertEquals( + listOf("entry-1", "entry-2", "entry-3"), + entries.map { it.asJsonObject["id"].asString }, + ) + assertEquals( + listOf("PAF1-15-422@0NKD3C", "PAF1-15-422@0NLL3C", "D10E-50-N10B@0UBL00"), + entries.map { it.asJsonObject["barcodePayload"].asString }, + ) + + val firstQr = entries[0].asJsonObject["qrPayload"].asString + val trailingSpaceQr = entries[2].asJsonObject["qrPayload"].asString + assertEquals(MOLTEN_PAF_QR, firstQr) + assertEquals(61, firstQr.length) + // The four trailing spaces are the blank instruction-time field. + assertEquals(MOLTEN_TRAILING_SPACE_QR, trailingSpaceQr) + assertEquals(61, trailingSpaceQr.length) + assertTrue(trailingSpaceQr.endsWith("020908 ")) + } + + @Test + fun stringsAreEscapedSoAnyRecordedPayloadRoundTrips() { + val hostile = buildString { + append("\"quote\" \\slash\\ \ttab \nnewline ") + append(Char(0x01)).append("ctrl ") + append(Char(0x0C)).append("formfeed ") + append("\bbackspace / end ") + } + val session = MatchSession( + id = "session-escapes", + startedAt = 0L, + name = hostile, + entries = listOf( + MatchEntry( + id = "entry-escapes", + code = "CODE", + matchedAt = 0L, + qrPayload = hostile, + barcodePayload = null, + ), + ), + ) + + val json = HistoryJsonExporter.build( + sessions = listOf(session), + exportedAt = exportedAt, + appVersion = "0.1.0 (1)", + ) + // Control characters must never reach the document unescaped; the only + // raw newlines are the ones the writer uses for indentation. + assertFalse(json.any { it.isISOControl() && it != '\n' }) + + val exported = JsonParser.parseString(json).asJsonObject["sessions"] + .asJsonArray[0].asJsonObject + assertEquals(hostile, exported["name"].asString) + assertEquals( + hostile, + exported["entries"].asJsonArray[0].asJsonObject["qrPayload"].asString, + ) + } + + @Test + fun emptyHistoryStillProducesAValidDocument() { + val root = export(emptyList()) + + assertEquals(0, root["sessions"].asJsonArray.size()) + assertEquals("2026-09-07T01:23:45Z", root["exportedAt"].asString) + } + + @Test + fun platformCanBeOverriddenForACrossPlatformFixture() { + val json = HistoryJsonExporter.build( + sessions = emptyList(), + exportedAt = exportedAt, + platform = "ios", + appVersion = "1.0 (4)", + ) + + assertEquals("ios", JsonParser.parseString(json).asJsonObject["platform"].asString) + } + + @Test + fun fileNameIsStampedToTheMinuteInTheCallersZone() { + assertEquals( + "codematch-history-20260907-1023.json", + HistoryJsonExporter.fileName(exportedAt, ZoneId.of("Asia/Tokyo")), + ) + assertEquals( + "codematch-history-20260907-0123.json", + HistoryJsonExporter.fileName(exportedAt, ZoneId.of("UTC")), + ) + } + + private fun export(sessions: List): JsonObject = JsonParser.parseString( + HistoryJsonExporter.build( + sessions = sessions, + exportedAt = exportedAt, + appVersion = "0.1.0 (1)", + ), + ).asJsonObject + + private companion object { + /** Real モルテン labels; see shared/test-fixtures/matching-cases.json. */ + const val MOLTEN_PAF_QR = + "AK6805PAF115422 UAG5560000FA2P5901FEM000012009080000" + const val MOLTEN_TRAILING_SPACE_QR = + "AK6805D10E50N10B U543820000MB S600700000020908 " + } +} diff --git a/android/feature/history/src/androidTest/kotlin/jp/rimtty/codematch/feature/history/HistoryScreenTest.kt b/android/feature/history/src/androidTest/kotlin/jp/rimtty/codematch/feature/history/HistoryScreenTest.kt index ba54070..8b2cc7c 100644 --- a/android/feature/history/src/androidTest/kotlin/jp/rimtty/codematch/feature/history/HistoryScreenTest.kt +++ b/android/feature/history/src/androidTest/kotlin/jp/rimtty/codematch/feature/history/HistoryScreenTest.kt @@ -2,7 +2,10 @@ package jp.rimtty.codematch.feature.history import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertHeightIsAtLeast +import androidx.compose.ui.test.assertContentDescriptionEquals import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.assert import androidx.compose.ui.test.SemanticsMatcher @@ -69,6 +72,49 @@ class HistoryScreenTest { composeRule.onNodeWithText("No history yet").assertIsDisplayed() } + @Test + fun shareAllHistoryActionIsDisabledWhileThereIsNothingToExport() { + var shared = 0 + composeRule.setContent { + HistoryScreen( + sessions = emptyList(), + language = AppLanguage.ENGLISH, + onShareAllHistory = { shared += 1 }, + ) + } + + composeRule.onNodeWithTag(HistoryTestTags.SHARE_ALL) + .assertIsDisplayed() + .assertContentDescriptionEquals("Share all history") + .assertIsNotEnabled() + .performClick() + + assertEquals(0, shared) + } + + @Test + fun shareAllHistoryActionExportsEverySessionAndIsLabelledInJapanese() { + var shared = 0 + composeRule.setContent { + HistoryScreen( + sessions = listOf( + MatchSession(id = "old", startedAt = 1L, name = "Old"), + MatchSession(id = "new", startedAt = 2L, name = "New"), + ), + onShareAllHistory = { shared += 1 }, + ) + } + + composeRule.onNodeWithTag(HistoryTestTags.SHARE_ALL) + .assertIsDisplayed() + .assertHeightIsAtLeast(48.dp) + .assertContentDescriptionEquals("照合履歴をすべて共有") + .assertIsEnabled() + .performClick() + + assertEquals(1, shared) + } + @Test fun listIsNewestFirstAndSelectionDeleteUseIds() { val selected = mutableListOf() diff --git a/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryScreen.kt b/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryScreen.kt index 0e18bbd..2902f3f 100644 --- a/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryScreen.kt +++ b/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryScreen.kt @@ -94,6 +94,7 @@ object HistoryTestTags { const val BOX_ROW = "boxEntryRow" const val ENTRY_DETAIL = "historyEntryDetail" const val NAME_FIELD = "sessionNameEditField" + const val SHARE_ALL = "shareAllHistoryButton" const val SAVE_PDF = "savePDFButton" const val SHARE_PDF = "sharePDFButton" } @@ -108,6 +109,7 @@ fun HistoryScreen( language: AppLanguage = AppLanguage.JAPANESE, onSessionSelected: (String) -> Unit = {}, onDeleteSession: (String) -> Unit = {}, + onShareAllHistory: () -> Unit = {}, modifier: Modifier = Modifier, ) { HistoryLocalized(language) { @@ -117,6 +119,7 @@ fun HistoryScreen( language = language, onSessionSelected = onSessionSelected, onDeleteSession = onDeleteSession, + onShareAllHistory = onShareAllHistory, modifier = modifier, ) } @@ -143,6 +146,7 @@ fun HistoryContent( onBack: () -> Unit = {}, onSavePdf: (MatchSession) -> Unit = {}, onSharePdf: (MatchSession) -> Unit = {}, + onShareAllHistory: () -> Unit = {}, modifier: Modifier = Modifier, ) { HistoryLocalized(language) { @@ -155,6 +159,7 @@ fun HistoryContent( language = language, onSessionSelected = onSessionSelected, onDeleteSession = onDeleteSession, + onShareAllHistory = onShareAllHistory, modifier = listModifier, ) } @@ -204,6 +209,7 @@ private fun HistorySessionList( language: AppLanguage, onSessionSelected: (String) -> Unit, onDeleteSession: (String) -> Unit, + onShareAllHistory: () -> Unit = {}, modifier: Modifier = Modifier, ) { val labels = HistoryUiResources.labels() @@ -212,11 +218,30 @@ private fun HistorySessionList( .testTag(HistoryTestTags.SCREEN) .semantics { contentDescription = labels.title }, ) { - Text( - text = labels.title, - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = labels.title, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.weight(1f).padding(vertical = 8.dp), + ) + // Exports every session, so it is disabled while there is nothing + // to export rather than sharing an empty document. + IconButton( + onClick = onShareAllHistory, + enabled = sessions.isNotEmpty(), + modifier = Modifier.size(48.dp).testTag(HistoryTestTags.SHARE_ALL), + ) { + Icon( + imageVector = Icons.Outlined.Share, + contentDescription = labels.shareAll, + ) + } + } if (sessions.isEmpty()) { EmptyHistoryState(labels, Modifier.fillMaxSize()) } else { diff --git a/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryUiResources.kt b/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryUiResources.kt index b0bfba1..7d23972 100644 --- a/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryUiResources.kt +++ b/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryUiResources.kt @@ -41,6 +41,7 @@ object HistoryUiResources { inspectionBoxes = stringResource(R.string.history_inspection_boxes), partCount = stringResource(R.string.history_part_count), namePlaceholder = stringResource(R.string.history_name_placeholder), + shareAll = stringResource(R.string.history_share_all), savePdf = stringResource(R.string.history_save_pdf), sharePdf = stringResource(R.string.history_share_pdf), matchedCodes = stringResource(R.string.history_matched_codes), diff --git a/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryUiText.kt b/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryUiText.kt index dcdd266..e69bbe7 100644 --- a/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryUiText.kt +++ b/android/feature/history/src/main/kotlin/jp/rimtty/codematch/feature/history/HistoryUiText.kt @@ -23,6 +23,7 @@ data class HistoryUiLabels( val inspectionBoxes: String, val partCount: String, val namePlaceholder: String, + val shareAll: String, val savePdf: String, val sharePdf: String, val matchedCodes: String, diff --git a/android/feature/history/src/main/res/values-en/strings.xml b/android/feature/history/src/main/res/values-en/strings.xml index c34cbc6..c4ec0e6 100644 --- a/android/feature/history/src/main/res/values-en/strings.xml +++ b/android/feature/history/src/main/res/values-en/strings.xml @@ -15,6 +15,7 @@ Boxes Part numbers Name (optional) + Share all history Save PDF Share Matched codes diff --git a/android/feature/history/src/main/res/values/strings.xml b/android/feature/history/src/main/res/values/strings.xml index f1c05ec..8aff2a5 100644 --- a/android/feature/history/src/main/res/values/strings.xml +++ b/android/feature/history/src/main/res/values/strings.xml @@ -15,6 +15,7 @@ 検査箱数 品番数 名前を入力(任意) + 照合履歴をすべて共有 PDFで保存 共有する 一致したコード diff --git a/android/scripts/verify-release-hardening.sh b/android/scripts/verify-release-hardening.sh index b00d47e..b7f4f23 100755 --- a/android/scripts/verify-release-hardening.sh +++ b/android/scripts/verify-release-hardening.sh @@ -267,13 +267,17 @@ check_backup_dump "$(compiled_xml xml/data_extraction_rules)" "data_extraction_r file_paths_dump="$(compiled_xml xml/file_paths)" grep -q -E '^ *E: paths( |$)' <<< "$file_paths_dump" || die "FileProvider paths root is missing" -[[ "$(grep -c -E '^ *E: cache-path( |$)' <<< "$file_paths_dump")" == "1" ]] || \ - die "FileProvider must expose exactly one cache-path" +# Two scoped cache paths: the PDF report and the JSON history export. Each one +# is a dedicated subdirectory, so no other cache content is ever shareable. +[[ "$(grep -c -E '^ *E: cache-path( |$)' <<< "$file_paths_dump")" == "2" ]] || \ + die "FileProvider must expose exactly two cache-paths" ! grep -q -E '^ *E: (files-path|external-path|root-path|external-files-path|external-cache-path|external-media-path)( |$)' <<< "$file_paths_dump" || \ die "FileProvider exposes a broad or external path" grep -q -F 'A: name="history_pdf"' <<< "$file_paths_dump" || die "FileProvider cache path name is not history_pdf" grep -q -F 'A: path="codematch-pdf/"' <<< "$file_paths_dump" || die "FileProvider cache path is not codematch-pdf/" -note "resources: demo tools off, backup/transfer exclusions present, FileProvider limited to cache/codematch-pdf/" +grep -q -F 'A: name="history_export"' <<< "$file_paths_dump" || die "FileProvider cache path name is not history_export" +grep -q -F 'A: path="codematch-export/"' <<< "$file_paths_dump" || die "FileProvider cache path is not codematch-export/" +note "resources: demo tools off, backup/transfer exclusions present, FileProvider limited to cache/codematch-pdf/ and cache/codematch-export/" # --- DEX and native libraries ----------------------------------------------- @@ -345,8 +349,11 @@ source_hits="$(grep -rn -i -E \ "${production_dirs[@]}" --include='*.kt' --include='*.java' || true)" [[ -z "$source_hits" ]] || die "production source persists or logs frames/images/payloads:"$'\n'"$source_hits" -file_hits="$(grep -rn -E '(^|[^[:alnum:]_])File[[:space:]]*\(' "${production_dirs[@]}" --include='*.kt' --include='*.java' | grep -v 'core/export/src/main/.*/HistoryPdfExporter\.kt:' || true)" -[[ -z "$file_hits" ]] || die "production source creates files outside the dedicated PDF exporter:"$'\n'"$file_hits" +# 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)" +[[ -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' \ "${production_dirs[@]}" --include='*.kt' --include='*.java' || true)"