Merge branch 'main' into feat-contacts-permission-onboarding

This commit is contained in:
Jason Ross
2026-07-02 10:12:04 -05:00
committed by GitHub
3 changed files with 147 additions and 15 deletions
@@ -6,6 +6,8 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.SavedStateHandle
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
@@ -20,7 +22,7 @@ import org.libremail.ui.FakeMailRepository
import org.libremail.ui.navigation.Routes
import org.libremail.ui.theme.LibreMailTheme
/** End-to-end test that the reader marks an already-cached attachment as downloaded. */
/** Compose UI tests for the reader's attachment list: the downloaded indicator and the accordion. */
@RunWith(AndroidJUnit4::class)
class ReaderScreenTest {
@@ -29,6 +31,9 @@ class ReaderScreenTest {
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun seeMore(extraCount: Int) =
composeTestRule.activity.resources.getQuantityString(R.plurals.attachments_see_more, extraCount, extraCount)
private val messageId = "imap:a:INBOX:1"
private val message = Message(
id = messageId, accountId = "imap:a", sender = "Sender", senderEmail = "s@example.org",
@@ -36,29 +41,74 @@ class ReaderScreenTest {
isRead = true, isStarred = false,
)
@Test
fun reader_showsDownloadedIndicator_forCachedAttachment() {
private fun attachment(partIndex: Int, filename: String) =
Attachment(messageId, partIndex, filename, "application/pdf", 1_000L)
/** Renders [ReaderScreen] for the fixed [message] with the given [attachments] and awaits load. */
private fun renderReader(attachments: List<Attachment>, downloadedParts: Set<Int> = emptySet()) {
val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
val repo = FakeMailRepository(
messages = listOf(message),
attachments = listOf(Attachment(messageId, 0, "report.pdf", "application/pdf", 1234L)),
downloadedParts = setOf(0),
attachments = attachments,
downloadedParts = downloadedParts,
)
val viewModel = ReaderViewModel(
SavedStateHandle(mapOf(Routes.READER_ARG_ID to messageId)),
repo,
SettingsRepository(context),
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ReaderScreen(onBack = {}, onReply = { _, _, _ -> }, viewModel = viewModel)
}
}
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText("report.pdf").fetchSemanticsNodes().isNotEmpty()
composeTestRule.onAllNodesWithText(attachments.first().filename).fetchSemanticsNodes().isNotEmpty()
}
}
@Test
fun reader_showsDownloadedIndicator_forCachedAttachment() {
renderReader(listOf(attachment(0, "report.pdf")), downloadedParts = setOf(0))
composeTestRule.onNodeWithText("report.pdf").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription(string(R.string.attachment_downloaded)).assertIsDisplayed()
}
@Test
fun reader_singleAttachment_showsNoAccordion() {
renderReader(listOf(attachment(0, "solo.pdf")))
composeTestRule.onNodeWithText("solo.pdf").assertIsDisplayed()
// A lone attachment has no "See more" control.
composeTestRule.onNodeWithContentDescription(string(R.string.attachments_expand)).assertDoesNotExist()
}
@Test
fun reader_multipleAttachments_collapseExtrasUntilExpanded() {
renderReader(listOf(attachment(0, "one.pdf"), attachment(1, "two.pdf"), attachment(2, "three.pdf")))
// First row shown; the two extras are hidden behind the collapsed accordion.
composeTestRule.onNodeWithText("one.pdf").assertIsDisplayed()
composeTestRule.onNodeWithText(seeMore(2)).assertIsDisplayed()
composeTestRule.onNodeWithText("two.pdf").assertDoesNotExist()
composeTestRule.onNodeWithText("three.pdf").assertDoesNotExist()
// Tapping the control reveals the remaining rows.
composeTestRule.onNodeWithText(seeMore(2)).performClick()
composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText("two.pdf").fetchSemanticsNodes().isNotEmpty()
}
composeTestRule.onNodeWithText("two.pdf").assertIsDisplayed()
composeTestRule.onNodeWithText("three.pdf").assertIsDisplayed()
}
@Test
fun reader_twoAttachments_useSingularPlural() {
renderReader(listOf(attachment(0, "a.pdf"), attachment(1, "b.pdf")))
// Exactly one extra: the singular plural form, e.g. "See 1 more attachment".
composeTestRule.onNodeWithText(seeMore(1)).assertIsDisplayed()
composeTestRule.onNodeWithText("b.pdf").assertDoesNotExist()
}
}
@@ -4,6 +4,8 @@ package org.libremail.ui.reader
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@@ -25,6 +27,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -42,12 +45,18 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
@@ -213,18 +222,83 @@ private fun Attachments(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
attachments.forEach { attachment ->
AttachmentRow(
attachment = attachment,
downloading = attachment.partIndex in downloading,
downloaded = attachment.partIndex in downloaded,
onClick = { onDownload(attachment) },
// The first attachment always shows. Any extras collapse behind an accordion so a message
// with many attachments can't push its body off-screen (#134).
val first = attachments.first()
AttachmentRow(
attachment = first,
downloading = first.partIndex in downloading,
downloaded = first.partIndex in downloaded,
onClick = { onDownload(first) },
)
Spacer(Modifier.height(8.dp))
val extras = attachments.drop(1)
if (extras.isNotEmpty()) {
var expanded by rememberSaveable { mutableStateOf(false) }
AttachmentsToggle(
extraCount = extras.size,
expanded = expanded,
onToggle = { expanded = !expanded },
)
Spacer(Modifier.height(8.dp))
AnimatedVisibility(visible = expanded) {
Column {
extras.forEach { attachment ->
Spacer(Modifier.height(8.dp))
AttachmentRow(
attachment = attachment,
downloading = attachment.partIndex in downloading,
downloaded = attachment.partIndex in downloaded,
onClick = { onDownload(attachment) },
)
}
}
}
}
}
}
/**
* Collapsed-by-default control that reveals the 2nd..Nth attachments. It is a single clickable
* [Role.Button] whose label ("See x more attachments" / "See fewer attachments") and rotating
* chevron expose the expanded state to screen readers.
*/
@Composable
private fun AttachmentsToggle(extraCount: Int, expanded: Boolean, onToggle: () -> Unit) {
val label = if (expanded) {
stringResource(R.string.attachments_see_fewer)
} else {
pluralStringResource(R.plurals.attachments_see_more, extraCount, extraCount)
}
val chevronDescription = stringResource(
if (expanded) R.string.attachments_collapse else R.string.attachments_expand,
)
val rotation by animateFloatAsState(
targetValue = if (expanded) 180f else 0f,
label = "attachmentsChevronRotation",
)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.small)
.clickable(role = Role.Button, onClick = onToggle)
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = chevronDescription,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.rotate(rotation),
)
Spacer(Modifier.width(8.dp))
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
}
}
@Composable
private fun AttachmentRow(attachment: Attachment, downloading: Boolean, downloaded: Boolean, onClick: () -> Unit) {
Surface(
+8
View File
@@ -131,6 +131,14 @@
<string name="message_available_offline">Available offline</string>
<string name="attachment_download_failed">Couldn\'t download %1$s</string>
<string name="attachment_no_app">No app can open this file</string>
<!-- Reader attachment accordion (#134). %1$d = attachments beyond the first, always one or more. -->
<plurals name="attachments_see_more">
<item quantity="one">See %1$d more attachment</item>
<item quantity="other">See %1$d more attachments</item>
</plurals>
<string name="attachments_see_fewer">See fewer attachments</string>
<string name="attachments_expand">Expand attachments</string>
<string name="attachments_collapse">Collapse attachments</string>
<!-- Onboarding -->
<string name="onboarding_welcome_title">Welcome to LibreMail</string>