Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c36471b
feat(android): build inspection report rows per destination from a se…
rimtty Sep 10, 2026
46f778a
feat(android): add inspection report labels, table blocks and pure PD…
rimtty Sep 10, 2026
8310dbd
feat(android): render the inspection report as a paged table with rep…
rimtty Sep 10, 2026
af9bd16
feat(android): add 検品レポート save/share row above the match history PDF row
rimtty Sep 10, 2026
cb9409e
feat(ios): add the 検品レポート PDF — one table row per part number, delive…
rimtty Sep 10, 2026
a44e17b
feat(ios): add 検品レポート save/share row above the match history PDF row
rimtty Sep 10, 2026
177cbc5
refactor(ios): render the match history PDF through PDFPageWriter
rimtty Sep 10, 2026
b9da973
docs: describe the 検品レポート PDF in the spec, parity table, status and i…
rimtty Sep 10, 2026
fedaa98
fix(inspection-report): sort molten rows by part number, then deliver…
rimtty Sep 10, 2026
f4801aa
feat(android): share a report as a pre-filled e-mail with the PDF att…
rimtty Sep 10, 2026
5b1d1fb
feat(ios): share a report through the Mail composer with recipient, s…
rimtty Sep 10, 2026
a8c9130
docs: describe the report e-mail hand-off
rimtty Sep 10, 2026
dc96308
feat(report-mail): subject and attachment named by destination and st…
rimtty Sep 10, 2026
c1e11db
feat(report-mail): address report mails to takemoto1075@icloud.com
rimtty Sep 10, 2026
7ecb4a7
feat(report-mail): drop the greeting and the CodeMatch prefix from th…
rimtty Sep 10, 2026
b579ceb
feat(report-mail): name the match history PDF like the inspection rep…
rimtty Sep 10, 2026
26d96bb
chore(ios): bump build number to 10 for TestFlight (App Store Connect…
rimtty Sep 10, 2026
4d412d6
test(android): scroll the session detail list for the plural box-coun…
rimtty Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@
android:name="android.hardware.camera.any"
android:required="false" />

<!-- Package visibility for the report e-mail hand-off: the app only asks
which installed apps accept mailto:, it never talks to them itself. -->
<queries>
<intent>
<action android:name="android.intent.action.SENDTO" />
<data android:scheme="mailto" />
</intent>
</queries>

<application
android:name=".CodeMatchApplication"
android:allowBackup="false"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import java.io.File
import java.io.OutputStream
import java.time.ZoneId
import jp.rimtty.codematch.core.export.HistoryPdfExporter
import jp.rimtty.codematch.core.export.HistoryReportKind
import jp.rimtty.codematch.core.export.ReportMail
import jp.rimtty.codematch.core.export.ReportMailContent
import jp.rimtty.codematch.core.model.AppLanguage
import jp.rimtty.codematch.core.model.MatchSession

Expand Down Expand Up @@ -87,15 +90,16 @@ internal object HistoryPdfBridge {
session: MatchSession,
language: AppLanguage,
zoneId: ZoneId = ZoneId.systemDefault(),
kind: HistoryReportKind = HistoryReportKind.MATCH_HISTORY,
): HistoryPdfResult<PendingHistoryPdf> = createDocument(
session = session,
language = language,
zoneId = zoneId,
generate = { currentSession, currentLanguage, currentZone ->
HistoryPdfExporter.generate(currentSession, currentLanguage, currentZone)
HistoryPdfExporter.generate(currentSession, currentLanguage, currentZone, kind)
},
fileName = { currentSession, currentLanguage, currentZone ->
HistoryPdfExporter.fileName(currentSession, currentLanguage, currentZone)
HistoryPdfExporter.fileName(currentSession, currentLanguage, currentZone, kind)
},
)

Expand Down Expand Up @@ -155,9 +159,10 @@ internal object HistoryPdfBridge {
session: MatchSession,
language: AppLanguage,
zoneId: ZoneId = ZoneId.systemDefault(),
kind: HistoryReportKind = HistoryReportKind.MATCH_HISTORY,
): HistoryPdfResult<File> = try {
HistoryPdfResult.Success(
HistoryPdfExporter.writeToCache(context, session, language, zoneId),
HistoryPdfExporter.writeToCache(context, session, language, zoneId, kind),
)
} catch (_: Exception) {
HistoryPdfResult.Failure(HistoryPdfFailure.CACHE_WRITE_FAILED)
Expand Down Expand Up @@ -187,6 +192,59 @@ internal object HistoryPdfBridge {
HistoryPdfResult.Failure(HistoryPdfFailure.FILE_PROVIDER_FAILED)
}

/**
* The e-mail hand-off for a report: a mail app opens with the recipient,
* subject, body and the PDF attached, and the operator only has to send.
* When no mail app can take the intent the plain share chooser is used so
* the PDF can still leave the device.
*/
fun createMailOrShareChooser(context: Context, file: File, mail: ReportMail): HistoryPdfResult<Intent> =
createMailOrShareChooser(
file = file,
mail = mail,
uriForFile = { current ->
FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", current)
},
hasMailApp = { intent ->
// A resolver failure only means "no mail app to hand off to"; the
// share chooser must still open, so it is not a provider failure.
runCatching { context.packageManager.queryIntentActivities(intent, 0).isNotEmpty() }
.getOrDefault(false)
},
)

/** Injectable URI and resolver seams so both branches are testable without a real FileProvider root. */
internal fun createMailOrShareChooser(
file: File,
mail: ReportMail,
uriForFile: (File) -> Uri,
hasMailApp: (Intent) -> Boolean,
): HistoryPdfResult<Intent> = try {
val uri = uriForFile(file)
val mailIntent = createMailIntent(uri, mail)
HistoryPdfResult.Success(
if (hasMailApp(mailIntent)) mailIntent else Intent.createChooser(createShareIntent(uri), null),
)
} catch (_: Exception) {
HistoryPdfResult.Failure(HistoryPdfFailure.FILE_PROVIDER_FAILED)
}

/**
* `ACTION_SEND` carrying the PDF plus the mail fields, restricted to mail
* apps through a `mailto:` selector. The system resolver lets the operator
* pin one mail app ("always") when several are installed.
*/
internal fun createMailIntent(uri: Uri, mail: ReportMail): Intent = Intent(Intent.ACTION_SEND).apply {
type = PDF_MIME_TYPE
putExtra(Intent.EXTRA_EMAIL, ReportMailContent.recipients)
putExtra(Intent.EXTRA_SUBJECT, mail.subject)
putExtra(Intent.EXTRA_TEXT, mail.body)
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
clipData = ClipData.newRawUri(null, uri)
selector = Intent(Intent.ACTION_SENDTO, Uri.parse("mailto:"))
}

internal fun launchShare(context: Context, chooser: Intent): HistoryPdfResult<Unit> = try {
context.startActivity(chooser)
HistoryPdfResult.Success(Unit)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import jp.rimtty.codematch.R
import jp.rimtty.codematch.core.export.HistoryReportKind
import jp.rimtty.codematch.core.export.ReportMailContent
import jp.rimtty.codematch.core.model.AppLanguage
import jp.rimtty.codematch.core.model.MatchSession
import jp.rimtty.codematch.feature.history.HistoryContent
Expand Down Expand Up @@ -65,6 +67,7 @@ fun HistoryRoute(modifier: Modifier = Modifier) {
// intentionally not persisted; an interrupted picker flow is regenerated
// from the current Room session when the user retries.
var latestSaveSessionId by rememberSaveable { mutableStateOf<String?>(null) }
var latestSaveKind by rememberSaveable { mutableStateOf(HistoryReportKind.MATCH_HISTORY) }
var exportGeneration by remember { mutableStateOf(0L) }
var launchSavePicker: ((PendingHistoryPdf) -> Unit)? = null

Expand All @@ -77,35 +80,37 @@ fun HistoryRoute(modifier: Modifier = Modifier) {
feedback = HistoryPdfFeedback(message = message, retry = retry)
}

fun startSave(session: MatchSession) {
fun startSave(session: MatchSession, kind: HistoryReportKind) {
feedback = null
latestSaveSessionId = session.id
latestSaveKind = kind
val generation = exportGeneration + 1L
exportGeneration = generation
pendingDocument = null
preparePdfForSave(
session = session,
language = state.language,
kind = kind,
scope = scope,
) { result ->
if (generation != exportGeneration) return@preparePdfForSave
when (result) {
is HistoryPdfResult.Success -> {
val launch = launchSavePicker
if (launch == null) {
reportPdfFailure(saveErrorMessage) { startSave(session) }
reportPdfFailure(saveErrorMessage) { startSave(session, kind) }
} else {
launch(result.value)
}
}
is HistoryPdfResult.Failure -> {
reportPdfFailure(saveErrorMessage) { startSave(session) }
reportPdfFailure(saveErrorMessage) { startSave(session, kind) }
}
}
}
}

fun startShare(session: MatchSession) {
fun startShare(session: MatchSession, kind: HistoryReportKind) {
feedback = null
val generation = exportGeneration + 1L
exportGeneration = generation
Expand All @@ -114,6 +119,7 @@ fun HistoryRoute(modifier: Modifier = Modifier) {
context = context,
session = session,
language = state.language,
kind = kind,
scope = scope,
) { result ->
if (generation != exportGeneration) return@preparePdfForShare
Expand All @@ -122,12 +128,12 @@ fun HistoryRoute(modifier: Modifier = Modifier) {
when (HistoryPdfBridge.launchShare(context, result.value)) {
is HistoryPdfResult.Success -> Unit
is HistoryPdfResult.Failure -> {
reportPdfFailure(shareErrorMessage) { startShare(session) }
reportPdfFailure(shareErrorMessage) { startShare(session, kind) }
}
}
}
is HistoryPdfResult.Failure -> {
reportPdfFailure(shareErrorMessage) { startShare(session) }
reportPdfFailure(shareErrorMessage) { startShare(session, kind) }
}
}
}
Expand Down Expand Up @@ -175,7 +181,8 @@ fun HistoryRoute(modifier: Modifier = Modifier) {
}
HistoryPdfPickerResult.MissingPendingDocument -> {
val retrySession = state.sessions.firstOrNull { it.id == latestSaveSessionId }
reportPdfFailure(saveErrorMessage, retrySession?.let { session -> { startSave(session) } })
val retryKind = latestSaveKind
reportPdfFailure(saveErrorMessage, retrySession?.let { session -> { startSave(session, retryKind) } })
}
is HistoryPdfPickerResult.Selected -> {
val generation = exportGeneration
Expand Down Expand Up @@ -211,7 +218,8 @@ fun HistoryRoute(modifier: Modifier = Modifier) {
is HistoryPdfResult.Failure -> {
pendingDocument = null
val retrySession = state.sessions.firstOrNull { it.id == latestSaveSessionId }
reportPdfFailure(saveErrorMessage, retrySession?.let { session -> { startSave(session) } })
val retryKind = latestSaveKind
reportPdfFailure(saveErrorMessage, retrySession?.let { session -> { startSave(session, retryKind) } })
}
}
}
Expand Down Expand Up @@ -285,8 +293,10 @@ fun HistoryRoute(modifier: Modifier = Modifier) {
},
onEntrySelected = { entryId -> selectedEntryId = entryId },
onBack = goBack,
onSavePdf = ::startSave,
onSharePdf = ::startShare,
onSavePdf = { session -> startSave(session, HistoryReportKind.MATCH_HISTORY) },
onSharePdf = { session -> startShare(session, HistoryReportKind.MATCH_HISTORY) },
onSaveInspectionReport = { session -> startSave(session, HistoryReportKind.INSPECTION) },
onShareInspectionReport = { session -> startShare(session, HistoryReportKind.INSPECTION) },
onShareAllHistory = ::startShareAllHistory,
modifier = Modifier.fillMaxSize(),
)
Expand Down Expand Up @@ -369,11 +379,12 @@ internal fun HistoryPdfFeedbackHost(
private fun preparePdfForSave(
session: MatchSession,
language: AppLanguage,
kind: HistoryReportKind,
scope: kotlinx.coroutines.CoroutineScope,
onResult: (HistoryPdfResult<PendingHistoryPdf>) -> Unit,
) {
scope.launch(Dispatchers.IO) {
val result = HistoryPdfBridge.createDocument(session, language)
val result = HistoryPdfBridge.createDocument(session, language, kind = kind)
withContext(Dispatchers.Main.immediate) { onResult(result) }
}
}
Expand All @@ -382,12 +393,19 @@ private fun preparePdfForShare(
context: Context,
session: MatchSession,
language: AppLanguage,
kind: HistoryReportKind,
scope: kotlinx.coroutines.CoroutineScope,
onResult: (HistoryPdfResult<Intent>) -> Unit,
) {
scope.launch(Dispatchers.IO) {
val result = when (val cacheResult = HistoryPdfBridge.writeShareCache(context, session, language)) {
is HistoryPdfResult.Success -> HistoryPdfBridge.createShareChooser(context, cacheResult.value)
val result = when (
val cacheResult = HistoryPdfBridge.writeShareCache(context, session, language, kind = kind)
) {
is HistoryPdfResult.Success -> HistoryPdfBridge.createMailOrShareChooser(
context,
cacheResult.value,
ReportMailContent.build(session, kind, cacheResult.value.name, language),
)
is HistoryPdfResult.Failure -> HistoryPdfResult.Failure(cacheResult.reason)
}
withContext(Dispatchers.Main.immediate) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import java.io.ByteArrayOutputStream
import java.io.File
import java.time.ZoneId
import jp.rimtty.codematch.core.export.HistoryPdfExporter
import jp.rimtty.codematch.core.export.HistoryReportKind
import jp.rimtty.codematch.core.export.ReportMail
import jp.rimtty.codematch.core.export.ReportMailContent
import jp.rimtty.codematch.core.model.AppLanguage
import jp.rimtty.codematch.core.model.MatchSession
import org.junit.Assert.assertArrayEquals
Expand Down Expand Up @@ -52,6 +55,23 @@ class HistoryPdfBridgeTest {
assertFalse(fileName.contains(".."))
}

@Test
fun inspectionReportDocumentUsesTheInspectionPrefixAndStaysAPdf() {
val session = MatchSession(startedAt = 0L, name = "morning")

val fileName = HistoryPdfExporter.fileName(
session,
AppLanguage.ENGLISH,
ZoneId.of("UTC"),
HistoryReportKind.INSPECTION,
)
val document = PendingHistoryPdf(bytes = "%PDF-test".toByteArray(), fileName = fileName)

assertTrue(fileName, fileName.startsWith("InspectionReport_") && fileName.endsWith(".pdf"))
assertFalse(fileName.contains("morning"))
assertEquals(HistoryPdfBridge.PDF_MIME_TYPE, document.mimeType)
}

@Test(expected = IllegalArgumentException::class)
fun pendingDocumentRejectsPathTraversal() {
PendingHistoryPdf(
Expand Down Expand Up @@ -245,6 +265,49 @@ class HistoryPdfBridgeTest {
)
}

@Test
fun mailIntentCarriesRecipientSubjectBodyAttachmentAndMailtoSelector() {
val uri = Uri.parse("content://${context.packageName}.fileprovider/history_pdf/report.pdf")
val mail = ReportMail(subject = "[CodeMatch] 検品レポート 朝便", body = "本文")

val intent = HistoryPdfBridge.createMailIntent(uri, mail)

assertEquals(Intent.ACTION_SEND, intent.action)
assertEquals(HistoryPdfBridge.PDF_MIME_TYPE, intent.type)
assertEquals(listOf(ReportMailContent.RECIPIENT), intent.getStringArrayExtra(Intent.EXTRA_EMAIL)?.toList())
assertEquals(mail.subject, intent.getStringExtra(Intent.EXTRA_SUBJECT))
assertEquals(mail.body, intent.getStringExtra(Intent.EXTRA_TEXT))
@Suppress("DEPRECATION")
assertEquals(uri, intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM))
assertTrue(intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION != 0)
assertEquals(uri, intent.clipData?.getItemAt(0)?.uri)
val selector = requireNotNull(intent.selector)
assertEquals(Intent.ACTION_SENDTO, selector.action)
assertEquals("mailto", selector.data?.scheme)
}

@Test
fun mailHandOffOpensTheMailIntentWhenAMailAppExistsAndTheChooserOtherwise() {
val uri = Uri.parse("content://${context.packageName}.fileprovider/history_pdf/report.pdf")
val file = File(context.cacheDir, "report.pdf")
val mail = ReportMail(subject = "s", body = "b")

val withMailApp = HistoryPdfBridge.createMailOrShareChooser(file, mail, { uri }, { true })
val withoutMailApp = HistoryPdfBridge.createMailOrShareChooser(file, mail, { uri }, { false })
val providerFailure = HistoryPdfBridge.createMailOrShareChooser(file, mail, { error("no root") }, { true })

assertTrue("result=$withMailApp", withMailApp is HistoryPdfResult.Success)
val mailIntent = (withMailApp as HistoryPdfResult.Success).value
assertEquals(Intent.ACTION_SEND, mailIntent.action)
assertEquals(mail.subject, mailIntent.getStringExtra(Intent.EXTRA_SUBJECT))

assertTrue("result=$withoutMailApp", withoutMailApp is HistoryPdfResult.Success)
assertEquals(Intent.ACTION_CHOOSER, (withoutMailApp as HistoryPdfResult.Success).value.action)

assertTrue(providerFailure is HistoryPdfResult.Failure)
assertEquals(HistoryPdfFailure.FILE_PROVIDER_FAILED, (providerFailure as HistoryPdfResult.Failure).reason)
}

@Test
fun shareChooserUsesFileProviderUriPdfTypeClipDataAndReadGrant() {
val directory = File(context.cacheDir, HistoryPdfExporter.CACHE_DIRECTORY)
Expand Down
Loading
Loading