chore(ui): migrate LocalClipboardManager to LocalClipboard #286

Merged
JMR-dev merged 2 commits from chore-237-localclipboard-migration into main 2026-07-04 05:37:57 +00:00
3 changed files with 98 additions and 5 deletions
@@ -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
@@ -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(
@@ -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<Clipboard>()
private val clipData = mockk<ClipData>()
private val textSlot = slot<CharSequence>()
@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 }) }
}
}