diff --git a/app/src/androidTest/kotlin/org/libremail/ui/reporting/ReportReviewScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/reporting/ReportReviewScreenTest.kt index 75fd0f4..fb68d04 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/reporting/ReportReviewScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/reporting/ReportReviewScreenTest.kt @@ -1,12 +1,14 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.reporting +import android.content.ClipboardManager import android.content.Context import androidx.activity.ComponentActivity import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo @@ -17,6 +19,7 @@ import androidx.test.platform.app.InstrumentationRegistry import io.mockk.every import io.mockk.mockk import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test @@ -123,6 +126,23 @@ class ReportReviewScreenTest { composeTestRule.onNodeWithText(string(R.string.report_submit)).performScrollTo().assertIsEnabled() } + @Test + fun tappingCopy_putsThePayloadOnTheSystemClipboard_andShowsAConfirmation() { + // Exercises the #237 migration off LocalClipboardManager/ClipboardManager end-to-end: the + // real system clipboard (not a fake) must contain the exact payload after the suspend + // LocalClipboard/Clipboard call completes. + val store = setContent() + + composeTestRule.onNodeWithText(string(R.string.report_copy)).performScrollTo().performClick() + + composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText(string(R.string.report_copied)).fetchSemanticsNodes().isNotEmpty() + } + val clipboardManager = context.getSystemService(ClipboardManager::class.java) + val clipText = clipboardManager?.primaryClip?.getItemAt(0)?.text?.toString() + assertEquals(store.find(reportId)!!.toSubmissionPayload(), clipText) + } + @Test fun tappingDiscard_deletesReport_andLeavesTheScreen() { var done = false diff --git a/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt b/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt index ac32080..0a71ddb 100644 --- a/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/reporting/ReportReviewScreen.kt @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.reporting +import android.content.ClipData import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Column @@ -39,10 +40,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -57,7 +59,7 @@ import org.libremail.R fun ReportReviewScreen(onDone: () -> Unit, viewModel: ReportReviewViewModel = hiltViewModel()) { val state by viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current - val clipboard = LocalClipboardManager.current + val clipboard = LocalClipboard.current val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } // Latches once the post-submit confirmation dialog is acknowledged, so it can't reappear @@ -188,8 +190,10 @@ fun ReportReviewScreen(onDone: () -> Unit, viewModel: ReportReviewViewModel = hi Row(Modifier.fillMaxWidth()) { TextButton( onClick = { - clipboard.setText(AnnotatedString(viewModel.payload())) - scope.launch { snackbarHostState.showSnackbar(copiedMessage) } + scope.launch { + copyReportPayloadToClipboard(clipboard, viewModel.payload()) + snackbarHostState.showSnackbar(copiedMessage) + } }, modifier = Modifier.weight(1f), ) { @@ -228,6 +232,21 @@ fun ReportReviewScreen(onDone: () -> Unit, viewModel: ReportReviewViewModel = hi } } +/** Label attached to the clip so OS-level clipboard UI (e.g. clipboard history) can identify it. */ +private const val REPORT_CLIP_LABEL = "LibreMail report" + +/** + * Copies [payload] to the system clipboard as plain text via the suspend [Clipboard] API (#237 — + * `LocalClipboardManager`/`ClipboardManager` are deprecated in favor of `LocalClipboard`). + * + * Kept as a plain suspend function rather than inlined in the composable so the "Copy report" + * action's clipboard interaction is unit-testable directly against a fake/mock [Clipboard], + * without a Compose UI test or emulator. + */ +internal suspend fun copyReportPayloadToClipboard(clipboard: Clipboard, payload: String) { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(REPORT_CLIP_LABEL, payload))) +} + @Composable private fun PiiDisclaimer() { Surface( diff --git a/app/src/test/kotlin/org/libremail/ui/reporting/ReportReviewClipboardTest.kt b/app/src/test/kotlin/org/libremail/ui/reporting/ReportReviewClipboardTest.kt new file mode 100644 index 0000000..0a8d45b --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/reporting/ReportReviewClipboardTest.kt @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.reporting + +import android.content.ClipData +import androidx.compose.ui.platform.Clipboard +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals + +/** + * Unit-level coverage for [copyReportPayloadToClipboard] (#237: `LocalClipboardManager`/ + * `ClipboardManager` — deprecated in favor of the suspend `LocalClipboard`/[Clipboard] API — were + * migrated off of here). The "Copy report" action's clipboard interaction is exercised directly + * against a mocked [Clipboard], with [ClipData]'s static factory mocked the same way + * [org.libremail.contacts.ContactsPermissionManagerTest] mocks `Uri` — so this runs as a plain JVM + * unit test, no Compose UI test or emulator needed. + */ +class ReportReviewClipboardTest { + + private val clipboard = mockk() + private val clipData = mockk() + private val textSlot = slot() + + @Before + fun setUp() { + mockkStatic(ClipData::class) + every { ClipData.newPlainText(any(), capture(textSlot)) } returns clipData + coEvery { clipboard.setClipEntry(any()) } just Runs + } + + @After + fun tearDown() { + unmockkStatic(ClipData::class) + } + + @Test + fun `copying the payload builds a plain-text clip from the exact text and sets it`() = runTest { + copyReportPayloadToClipboard(clipboard, "the report body") + + assertEquals("the report body", textSlot.captured.toString()) + coVerify(exactly = 1) { clipboard.setClipEntry(match { it.clipData === clipData }) } + } +}