chore(ui): migrate LocalClipboardManager to LocalClipboard #286
@@ -1,12 +1,14 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
package org.libremail.ui.reporting
|
package org.libremail.ui.reporting
|
||||||
|
|
||||||
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.assertIsEnabled
|
import androidx.compose.ui.test.assertIsEnabled
|
||||||
import androidx.compose.ui.test.assertIsNotEnabled
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performScrollTo
|
import androidx.compose.ui.test.performScrollTo
|
||||||
@@ -17,6 +19,7 @@ import androidx.test.platform.app.InstrumentationRegistry
|
|||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -123,6 +126,23 @@ class ReportReviewScreenTest {
|
|||||||
composeTestRule.onNodeWithText(string(R.string.report_submit)).performScrollTo().assertIsEnabled()
|
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
|
@Test
|
||||||
fun tappingDiscard_deletesReport_andLeavesTheScreen() {
|
fun tappingDiscard_deletesReport_andLeavesTheScreen() {
|
||||||
var done = false
|
var done = false
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
package org.libremail.ui.reporting
|
package org.libremail.ui.reporting
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -39,10 +40,11 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
@@ -57,7 +59,7 @@ import org.libremail.R
|
|||||||
fun ReportReviewScreen(onDone: () -> Unit, viewModel: ReportReviewViewModel = hiltViewModel()) {
|
fun ReportReviewScreen(onDone: () -> Unit, viewModel: ReportReviewViewModel = hiltViewModel()) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val clipboard = LocalClipboardManager.current
|
val clipboard = LocalClipboard.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
// Latches once the post-submit confirmation dialog is acknowledged, so it can't reappear
|
// 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()) {
|
Row(Modifier.fillMaxWidth()) {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
clipboard.setText(AnnotatedString(viewModel.payload()))
|
scope.launch {
|
||||||
scope.launch { snackbarHostState.showSnackbar(copiedMessage) }
|
copyReportPayloadToClipboard(clipboard, viewModel.payload())
|
||||||
|
snackbarHostState.showSnackbar(copiedMessage)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f),
|
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
|
@Composable
|
||||||
private fun PiiDisclaimer() {
|
private fun PiiDisclaimer() {
|
||||||
Surface(
|
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 }) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user