test(compose): Robolectric JVM tests for mail list screens (#379)
Convert the VM-driven mail list screens (DraftsScreen, OutboxScreen, ProblemReportsScreen) to Robolectric JVM Compose tests in the `test` source set, driving each real ViewModel over a mocked MailRepository / ReportStore + DiagnosticsCollector via the v2 createComposeRule() — no emulator. Each test covers the empty/populated render states, item rendering (subject/recipient/body, queued-vs-failed status, crash/manual kind labels), and the interactions (open, delete, cancel, retry, create). Drop the three now-JVM-covered globs from jacocoNonJvmTestableSurface so the screens count toward the JaCoCo denominator; measured coverage is DraftsScreen 100%, OutboxScreen 100%, ProblemReportsScreen 97%, and the bundle line ratio rises to ~82.7% (floor 0.79 unchanged). The instrumented androidTest E2Es (DraftsScreenTest / OutboxScreenTest / ProblemReportsScreenTest) stay as the on-device tests. Part of the Robolectric Compose umbrella (#373); mirrors the #375/#376 pattern (AddAnotherAccountScreenJvmTest, format-control JVM tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -318,16 +318,15 @@ val jacocoNonJvmTestableSurface = listOf(
|
||||
"**/ComposeScreen*",
|
||||
// ColorSwatch(Row), FontPicker, FontSizePicker & ParagraphAlignmentControl converted to
|
||||
// Robolectric JVM Compose tests (#376) — now JVM-covered.
|
||||
"**/DraftsScreen*",
|
||||
// DraftsScreen, OutboxScreen & ProblemReportsScreen converted to Robolectric JVM Compose tests
|
||||
// (#379) — now JVM-covered.
|
||||
// LockScreen converted to a Robolectric JVM Compose test (#377) — now JVM-covered.
|
||||
"**/AppLockGateHost*",
|
||||
"**/FolderDrawer*",
|
||||
"**/MailboxScreen*",
|
||||
// AddAnotherAccountScreen (#373) plus the onboarding welcome/license and contacts/battery steps
|
||||
// (#377) converted to Robolectric JVM Compose tests — now JVM-covered.
|
||||
"**/OutboxScreen*",
|
||||
"**/ReaderScreen*",
|
||||
"**/ProblemReportsScreen*",
|
||||
"**/AccountSettingsScreen*",
|
||||
"**/SettingsScreen*",
|
||||
"**/SettingsComponents*",
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.drafts
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.domain.model.Draft
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* Robolectric JVM port of the instrumented [DraftsScreenTest] (batch 4/9 of umbrella #373): drives
|
||||
* [DraftsScreen] + the real [DraftsViewModel] over a mocked [MailRepository] on the JVM via the v2
|
||||
* `createComposeRule()` — no emulator — so [DraftsScreen]'s render + interaction code counts toward
|
||||
* JaCoCo's JVM-testable surface. Covers the empty state, the subject/recipient/body rendering with
|
||||
* the blank-field fallbacks, opening a draft, and deleting one (row disappears + delegates to the
|
||||
* repository). The instrumented [DraftsScreenTest] stays as the on-device E2E. See
|
||||
* [org.libremail.ui.onboarding.AddAnotherAccountScreenJvmTest] for the pattern.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36])
|
||||
class DraftsScreenJvmTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private val context: Context get() = RuntimeEnvironment.getApplication()
|
||||
|
||||
private fun string(resId: Int) = context.getString(resId)
|
||||
|
||||
private fun draft(id: String, to: String, subject: String, body: String) = Draft(
|
||||
id = id,
|
||||
accountId = "imap:a",
|
||||
to = to,
|
||||
cc = "",
|
||||
subject = subject,
|
||||
body = body,
|
||||
updatedAt = 1_000L,
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds a relaxed [MailRepository] whose observed drafts are backed by a [MutableStateFlow], so a
|
||||
* `deleteDraft` mutates the observed list and the row actually disappears (mirroring the DB-backed
|
||||
* repository's reactivity), and constructs the real [DraftsViewModel] over it.
|
||||
*/
|
||||
private fun setContent(drafts: List<Draft>, onOpenDraft: (String) -> Unit = {}): MailRepository {
|
||||
val draftsFlow = MutableStateFlow(drafts)
|
||||
val repo = mockk<MailRepository>(relaxed = true)
|
||||
every { repo.observeDrafts() } returns draftsFlow
|
||||
coEvery { repo.deleteDraft(any()) } answers {
|
||||
val id = firstArg<String>()
|
||||
draftsFlow.value = draftsFlow.value.filterNot { it.id == id }
|
||||
}
|
||||
val viewModel = DraftsViewModel(repo)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
DraftsScreen(onBack = {}, onOpenDraft = onOpenDraft, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
private fun waitForText(text: String) = composeTestRule.waitUntil(TIMEOUT_MS) {
|
||||
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyDrafts_showsEmptyState() {
|
||||
setContent(emptyList())
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.drafts_empty)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun populatedDraft_showsSubjectRecipientAndBody() {
|
||||
setContent(listOf(draft("d1", "alice@example.org", "Lunch plans", "See you at noon")))
|
||||
waitForText("Lunch plans")
|
||||
|
||||
composeTestRule.onNodeWithText("Lunch plans").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("alice@example.org").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("See you at noon").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankDraft_usesNoSubjectAndNoRecipientFallbacks() {
|
||||
setContent(listOf(draft("d1", to = "", subject = "", body = "")))
|
||||
waitForText(string(R.string.draft_no_subject))
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.draft_no_subject)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.draft_no_recipient)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingRow_invokesOnOpenDraft() {
|
||||
var opened: String? = null
|
||||
setContent(
|
||||
listOf(draft("d1", "alice@example.org", "Lunch plans", "body")),
|
||||
onOpenDraft = { opened = it },
|
||||
)
|
||||
waitForText("Lunch plans")
|
||||
|
||||
composeTestRule.onNodeWithText("Lunch plans").performClick()
|
||||
|
||||
composeTestRule.waitUntil(TIMEOUT_MS) { opened == "d1" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingDelete_removesRow_andRecordsDeletion() {
|
||||
val repo = setContent(
|
||||
listOf(
|
||||
draft("d1", "alice@example.org", "Lunch plans", "body"),
|
||||
draft("d2", "bob@example.org", "Second draft", "body"),
|
||||
),
|
||||
)
|
||||
waitForText("Lunch plans")
|
||||
|
||||
// Delete the first draft: the fake removes it from the observed list, so its row disappears.
|
||||
composeTestRule.onAllNodesWithContentDescription(string(R.string.draft_delete))[0].performClick()
|
||||
|
||||
composeTestRule.waitUntil(TIMEOUT_MS) {
|
||||
composeTestRule.onAllNodesWithText("Lunch plans").fetchSemanticsNodes().isEmpty()
|
||||
}
|
||||
coVerify(exactly = 1) { repo.deleteDraft("d1") }
|
||||
composeTestRule.onNodeWithText("Second draft").assertIsDisplayed()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.outbox
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.domain.model.OutboxMessage
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* Robolectric JVM port of the instrumented [OutboxScreenTest] (batch 4/9 of umbrella #373): drives
|
||||
* [OutboxScreen] + the real [OutboxViewModel] over a mocked [MailRepository] on the JVM via the v2
|
||||
* `createComposeRule()` — no emulator — so [OutboxScreen]'s render + interaction code counts toward
|
||||
* JaCoCo's JVM-testable surface. Covers the empty state (no retry action), the queued-vs-failed row
|
||||
* rendering, retry, and cancel (row disappears + delegates to the repository). The instrumented
|
||||
* [OutboxScreenTest] stays as the on-device E2E. See
|
||||
* [org.libremail.ui.onboarding.AddAnotherAccountScreenJvmTest] for the pattern.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36])
|
||||
class OutboxScreenJvmTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private val context: Context get() = RuntimeEnvironment.getApplication()
|
||||
|
||||
private fun string(resId: Int) = context.getString(resId)
|
||||
|
||||
private fun queued(id: String, subject: String, to: String) =
|
||||
OutboxMessage(id = id, to = to, subject = subject, body = "body", createdAt = 1_000L, lastError = null)
|
||||
|
||||
private fun failed(id: String, subject: String, to: String) =
|
||||
OutboxMessage(id = id, to = to, subject = subject, body = "body", createdAt = 2_000L, lastError = "SMTP 550")
|
||||
|
||||
/**
|
||||
* Builds a relaxed [MailRepository] whose observed outbox is backed by a [MutableStateFlow], so a
|
||||
* `cancelOutboxMessage` mutates the observed list and the row disappears (mirroring the DB-backed
|
||||
* repository's reactivity), and constructs the real [OutboxViewModel] over it.
|
||||
*/
|
||||
private fun setContent(messages: List<OutboxMessage>): MailRepository {
|
||||
val outboxFlow = MutableStateFlow(messages)
|
||||
val repo = mockk<MailRepository>(relaxed = true)
|
||||
every { repo.observeOutbox() } returns outboxFlow
|
||||
coEvery { repo.cancelOutboxMessage(any()) } answers {
|
||||
val id = firstArg<String>()
|
||||
outboxFlow.value = outboxFlow.value.filterNot { it.id == id }
|
||||
}
|
||||
val viewModel = OutboxViewModel(repo)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
OutboxScreen(onBack = {}, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
private fun waitForText(text: String) = composeTestRule.waitUntil(TIMEOUT_MS) {
|
||||
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyOutbox_showsEmptyState_andNoRetryAction() {
|
||||
setContent(emptyList())
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.outbox_empty)).assertIsDisplayed()
|
||||
// Retry only appears when there is something to retry.
|
||||
composeTestRule.onNodeWithText(string(R.string.outbox_retry)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun populatedOutbox_showsQueuedAndFailedRows_andRetryAction() {
|
||||
setContent(
|
||||
listOf(
|
||||
queued("q", "Queued mail", "alice@example.org"),
|
||||
failed("f", "Failed mail", "bob@example.org"),
|
||||
),
|
||||
)
|
||||
waitForText("Queued mail")
|
||||
|
||||
composeTestRule.onNodeWithText("Queued mail").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Failed mail").assertIsDisplayed()
|
||||
// The queued row shows the "queued" status; the failed row shows the "failed" status.
|
||||
composeTestRule.onNodeWithText(string(R.string.outbox_status_queued)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.outbox_status_failed)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.outbox_retry)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingRetry_callsRepositoryRetry() {
|
||||
var retryCount = 0
|
||||
val repo = setContent(listOf(failed("f", "Failed mail", "bob@example.org")))
|
||||
coEvery { repo.retryOutbox() } answers { retryCount++ }
|
||||
waitForText("Failed mail")
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.outbox_retry)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(TIMEOUT_MS) { retryCount == 1 }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingCancel_removesRow_andRecordsCancellation() {
|
||||
val repo = setContent(
|
||||
listOf(
|
||||
queued("q", "Queued mail", "alice@example.org"),
|
||||
queued("q2", "Second mail", "carol@example.org"),
|
||||
),
|
||||
)
|
||||
waitForText("Queued mail")
|
||||
|
||||
// Cancel the first row: the fake removes it from the observed outbox, so the row disappears.
|
||||
composeTestRule.onAllNodesWithContentDescription(string(R.string.outbox_cancel))[0].performClick()
|
||||
|
||||
composeTestRule.waitUntil(TIMEOUT_MS) {
|
||||
composeTestRule.onAllNodesWithText("Queued mail").fetchSemanticsNodes().isEmpty()
|
||||
}
|
||||
coVerify(exactly = 1) { repo.cancelOutboxMessage("q") }
|
||||
composeTestRule.onNodeWithText("Second mail").assertIsDisplayed()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.ui.reporting
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.R
|
||||
import org.libremail.reporting.DebugReport
|
||||
import org.libremail.reporting.DiagnosticsCollector
|
||||
import org.libremail.reporting.ReportKind
|
||||
import org.libremail.reporting.ReportStore
|
||||
import org.libremail.ui.theme.LibreMailTheme
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* Robolectric JVM port of the instrumented [ProblemReportsScreenTest] (batch 4/9 of umbrella #373):
|
||||
* drives [ProblemReportsScreen] + the real [ProblemReportsViewModel] over a mocked [ReportStore] and
|
||||
* [DiagnosticsCollector] on the JVM via the v2 `createComposeRule()` — no emulator — so
|
||||
* [ProblemReportsScreen]'s render + interaction code counts toward JaCoCo's JVM-testable surface.
|
||||
* Covers the always-present create button and auto-delete notice, the empty state, the crash/manual
|
||||
* row rendering, opening a report, and the create-report flow which saves a manual report and
|
||||
* immediately opens it for review. The instrumented [ProblemReportsScreenTest] stays as the on-device
|
||||
* E2E. See [org.libremail.ui.onboarding.AddAnotherAccountScreenJvmTest] for the pattern.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36])
|
||||
class ProblemReportsScreenJvmTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private val context: Context get() = RuntimeEnvironment.getApplication()
|
||||
|
||||
private fun string(resId: Int) = context.getString(resId)
|
||||
|
||||
private fun report(id: String, kind: ReportKind) = DebugReport(
|
||||
id = id,
|
||||
createdAtMillis = 1_000L,
|
||||
kind = kind,
|
||||
appVersionName = "1.0",
|
||||
appVersionCode = 1L,
|
||||
androidRelease = "14",
|
||||
androidSdkInt = 34,
|
||||
deviceManufacturer = "Test",
|
||||
deviceModel = "Model",
|
||||
stackTrace = if (kind == ReportKind.CRASH) "boom" else null,
|
||||
settings = emptyMap(),
|
||||
logs = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds a real [ProblemReportsViewModel] over a mocked [ReportStore] (its [ReportStore.reports]
|
||||
* seeded with [reports]) and a mocked [DiagnosticsCollector] whose `collectManual` returns
|
||||
* [manualReport] (drives the create-and-open flow), then renders [ProblemReportsScreen].
|
||||
*/
|
||||
private fun setContent(
|
||||
reports: List<DebugReport>,
|
||||
manualReport: DebugReport? = null,
|
||||
onOpenReport: (String) -> Unit = {},
|
||||
) {
|
||||
val store = mockk<ReportStore>(relaxed = true)
|
||||
every { store.reports } returns MutableStateFlow(reports)
|
||||
val collector = mockk<DiagnosticsCollector>(relaxed = true)
|
||||
if (manualReport != null) coEvery { collector.collectManual() } returns manualReport
|
||||
val viewModel = ProblemReportsViewModel(store, collector)
|
||||
composeTestRule.setContent {
|
||||
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||
ProblemReportsScreen(onBack = {}, onOpenReport = onOpenReport, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun waitForText(text: String) = composeTestRule.waitUntil(TIMEOUT_MS) {
|
||||
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noReports_showsEmptyState_withCreateButtonAndAutoDeleteNotice() {
|
||||
setContent(emptyList())
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.reports_empty)).assertIsDisplayed()
|
||||
// The create control and the retention notice are always visible, even with no reports.
|
||||
composeTestRule.onNodeWithText(string(R.string.reports_create)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.report_auto_delete_notice)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun storedReports_showCrashAndManualKindLabels() {
|
||||
setContent(listOf(report("r-crash", ReportKind.CRASH), report("r-manual", ReportKind.MANUAL)))
|
||||
waitForText(string(R.string.report_kind_crash))
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.report_kind_crash)).assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText(string(R.string.report_kind_manual)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingReportRow_invokesOnOpenReport() {
|
||||
var opened: String? = null
|
||||
setContent(listOf(report("r-crash", ReportKind.CRASH)), onOpenReport = { opened = it })
|
||||
waitForText(string(R.string.report_kind_crash))
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.report_kind_crash)).performClick()
|
||||
|
||||
composeTestRule.waitUntil(TIMEOUT_MS) { opened == "r-crash" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingCreate_savesManualReport_andOpensItForReview() {
|
||||
var opened: String? = null
|
||||
setContent(
|
||||
emptyList(),
|
||||
manualReport = report("r-new", ReportKind.MANUAL),
|
||||
onOpenReport = { opened = it },
|
||||
)
|
||||
|
||||
composeTestRule.onNodeWithText(string(R.string.reports_create)).performClick()
|
||||
|
||||
// Creating a manual report emits its id so the screen opens it straight into review.
|
||||
composeTestRule.waitUntil(TIMEOUT_MS) { opened == "r-new" }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user