Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<out T> {
data class Success<T>(val value: T) : HistoryJsonResult<T>

data class Failure(val reason: HistoryJsonFailure) : HistoryJsonResult<Nothing>
}

/**
* 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<MatchSession>,
exportedAt: Instant = Instant.now(),
zoneId: ZoneId = ZoneId.systemDefault(),
): HistoryJsonResult<File> = 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<MatchSession>,
exportedAt: Instant,
zoneId: ZoneId,
writeToCache: (Context, String, Instant, ZoneId) -> File,
): HistoryJsonResult<File> = 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<Intent> = 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<Unit> = 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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -253,6 +287,7 @@ fun HistoryRoute(modifier: Modifier = Modifier) {
onBack = goBack,
onSavePdf = ::startSave,
onSharePdf = ::startShare,
onShareAllHistory = ::startShareAllHistory,
modifier = Modifier.fillMaxSize(),
)
}
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/res/values-en/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
<string name="history_pdf_save_error">Could not save the PDF.</string>
<string name="history_pdf_share_error">Could not share the PDF.</string>
<string name="history_pdf_retry">Retry</string>
<string name="history_share_all_failed">Could not export the history</string>

<string name="settings_screen_description">App settings for automatic next comparison and language</string>
<string name="settings_headline">Settings</string>
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
<string name="history_pdf_save_error">PDFを保存できませんでした。</string>
<string name="history_pdf_share_error">PDFを共有できませんでした。</string>
<string name="history_pdf_retry">再試行</string>
<string name="history_share_all_failed">照合履歴の書き出しに失敗しました</string>

<string name="settings_screen_description">自動次工程と言語などのアプリ設定画面</string>
<string name="settings_headline">設定</string>
Expand Down
3 changes: 3 additions & 0 deletions android/app/src/main/res/xml/file_paths.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@
<cache-path
name="history_pdf"
path="codematch-pdf/" />
<cache-path
name="history_export"
path="codematch-export/" />
</paths>
Original file line number Diff line number Diff line change
@@ -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>(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<Uri>(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 "
}
}
3 changes: 3 additions & 0 deletions android/core/export/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading