fix(deps): update dependency androidx.compose:compose-bom to v2025.04.01 (#4631)

* fix(deps): update dependency androidx.compose:compose-bom to v2025.04.01

* Fix autofill deprecations

* Adapt our custom BottomSheetState and scaffold to the new APIs

* Get rid of all the custom bottom sheet implementation

It doesn't seem to be needed anymore 🎉

* Replace `semantics { invisibleToUser() }`  with `hideFromAccessibility()`

* Update screenshots

* Add commit and cancel callbacks for autofill on the login view

* Fix broken tests caused mainly by https://issuetracker.google.com/issues/366255137

Add `LocalUiTestMode` composition local and helper functions.

* Remove dependency that caused a new license to need to be approved

* Let setSafeContent handle setting the value for LocalUiTestMode

* Fix broken test

* Apply fix to RoomMemberModerationViewTest and RoomListDeclineInviteMenuTest

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jorge Martín <jorgem@element.io>
Co-authored-by: ElementBot <android@element.io>
Co-authored-by: Benoit Marty <benoit@matrix.org>
This commit is contained in:
renovate[bot] 2025-06-03 21:05:43 +00:00 committed by GitHub
parent 9efbdc95a8
commit 9d5e8aa308
37 changed files with 189 additions and 1012 deletions

View file

@ -17,6 +17,7 @@ import io.element.android.features.createroom.impl.R
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.setSafeContent
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule import org.junit.rules.TestRule
@ -56,7 +57,7 @@ class JoinBaseRoomByAddressViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setJoinRoomByAddressView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setJoinRoomByAddressView(
state: JoinRoomByAddressState, state: JoinRoomByAddressState,
) { ) {
setContent { setSafeContent {
JoinRoomByAddressView(state = state) JoinRoomByAddressView(state = state)
} }
} }

View file

@ -34,10 +34,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
@ -54,7 +56,6 @@ import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrgani
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.form.textFieldState import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.components.list.SwitchListItem import io.element.android.libraries.designsystem.components.list.SwitchListItem
import io.element.android.libraries.designsystem.modifiers.autofill
import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -276,14 +277,9 @@ private fun Content(
.fillMaxWidth() .fillMaxWidth()
.onTabOrEnterKeyFocusNext(focusManager) .onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginPassword) .testTag(TestTags.loginPassword)
.autofill( .semantics {
autofillTypes = listOf(AutofillType.Password), contentType = ContentType.Password
onFill = { },
val sanitized = it.sanitize()
passwordFieldState = sanitized
eventSink(AccountDeactivationEvents.SetPassword(sanitized))
}
),
onValueChange = { onValueChange = {
val sanitized = it.sanitize() val sanitized = it.sanitize()
passwordFieldState = sanitized passwordFieldState = sanitized

View file

@ -20,6 +20,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.SheetValue import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -46,8 +48,6 @@ import io.element.android.libraries.designsystem.theme.components.FloatingAction
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberBottomSheetScaffoldState
import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberStandardBottomSheetState
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.maplibre.compose.CameraMode import io.element.android.libraries.maplibre.compose.CameraMode
import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason

View file

@ -7,6 +7,7 @@
package io.element.android.features.login.impl.screens.loginpassword package io.element.android.features.login.impl.screens.loginpassword
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -30,10 +31,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalAutofillManager
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
@ -51,7 +55,6 @@ import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.form.textFieldState import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.modifiers.autofill
import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext import io.element.android.libraries.designsystem.modifiers.onTabOrEnterKeyFocusNext
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -71,6 +74,13 @@ fun LoginPasswordView(
onBackClick: () -> Unit, onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val autofillManager = LocalAutofillManager.current
BackHandler {
autofillManager?.cancel()
onBackClick()
}
val isLoading by remember(state.loginAction) { val isLoading by remember(state.loginAction) {
derivedStateOf { derivedStateOf {
state.loginAction is AsyncData.Loading state.loginAction is AsyncData.Loading
@ -82,6 +92,8 @@ fun LoginPasswordView(
// Clear focus to prevent keyboard issues with textfields // Clear focus to prevent keyboard issues with textfields
focusManager.clearFocus(force = true) focusManager.clearFocus(force = true)
autofillManager?.commit()
state.eventSink(LoginPasswordEvents.Submit) state.eventSink(LoginPasswordEvents.Submit)
} }
@ -90,7 +102,12 @@ fun LoginPasswordView(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = {}, title = {},
navigationIcon = { BackButton(onClick = onBackClick) }, navigationIcon = {
BackButton(onClick = {
autofillManager?.cancel()
onBackClick()
})
},
) )
} }
) { padding -> ) { padding ->
@ -175,14 +192,9 @@ private fun LoginForm(
.fillMaxWidth() .fillMaxWidth()
.onTabOrEnterKeyFocusNext(focusManager) .onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginEmailUsername) .testTag(TestTags.loginEmailUsername)
.autofill( .semantics {
autofillTypes = listOf(AutofillType.Username), contentType = ContentType.Username
onFill = { },
val sanitized = it.sanitize()
loginFieldState = sanitized
eventSink(LoginPasswordEvents.SetLogin(sanitized))
}
),
placeholder = stringResource(CommonStrings.common_username), placeholder = stringResource(CommonStrings.common_username),
onValueChange = { onValueChange = {
val sanitized = it.sanitize() val sanitized = it.sanitize()
@ -227,14 +239,9 @@ private fun LoginForm(
.fillMaxWidth() .fillMaxWidth()
.onTabOrEnterKeyFocusNext(focusManager) .onTabOrEnterKeyFocusNext(focusManager)
.testTag(TestTags.loginPassword) .testTag(TestTags.loginPassword)
.autofill( .semantics {
autofillTypes = listOf(AutofillType.Password), contentType = ContentType.Password
onFill = { },
val sanitized = it.sanitize()
passwordFieldState = sanitized
eventSink(LoginPasswordEvents.SetPassword(sanitized))
}
),
onValueChange = { onValueChange = {
val sanitized = it.sanitize() val sanitized = it.sanitize()
passwordFieldState = sanitized passwordFieldState = sanitized

View file

@ -8,17 +8,21 @@
package io.element.android.features.login.impl.screens.loginpassword package io.element.android.features.login.impl.screens.loginpassword
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assert
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.hasText
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
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.performTextInput import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.test.A_PASSWORD import io.element.android.libraries.matrix.test.A_PASSWORD
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
@ -120,15 +124,15 @@ class LoginPasswordViewTest {
eventSink = eventsRecorder, eventSink = eventsRecorder,
), ),
) )
rule.onNodeWithText(A_PASSWORD).assertDoesNotExist() rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText("••••••••"))
// Show password // Show password
val a11yShowPassword = rule.activity.getString(CommonStrings.a11y_show_password) val a11yShowPassword = rule.activity.getString(CommonStrings.a11y_show_password)
rule.onNodeWithContentDescription(a11yShowPassword).performClick() rule.onNodeWithContentDescription(a11yShowPassword).performClick()
rule.onNodeWithText(A_PASSWORD).assertExists() rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText(A_PASSWORD))
// Hide password // Hide password
val a11yHidePassword = rule.activity.getString(CommonStrings.a11y_hide_password) val a11yHidePassword = rule.activity.getString(CommonStrings.a11y_hide_password)
rule.onNodeWithContentDescription(a11yHidePassword).performClick() rule.onNodeWithContentDescription(a11yHidePassword).performClick()
rule.onNodeWithText(A_PASSWORD).assertDoesNotExist() rule.onNodeWithTag(TestTags.loginPassword.value).assert(hasText("••••••••"))
} }
@Test @Test

View file

@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@ -29,10 +31,8 @@ import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min import androidx.compose.ui.unit.min
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
import io.element.android.libraries.designsystem.theme.components.bottomsheet.CustomSheetState
import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberBottomSheetScaffoldState
import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberStandardBottomSheetState
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
@ -139,8 +139,8 @@ internal fun ExpandableBottomSheetScaffold(
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight(),
measurePolicy = { measurables, constraints -> measurePolicy = { measurables, constraints ->
val constraintHeight = constraints.maxHeight val constraintHeight = constraints.maxHeight
val offset = scaffoldState.bottomSheetState.getIntOffset() ?: 0 val offset = tryOrNull { scaffoldState.bottomSheetState.requireOffset() } ?: 0f
val height = Integer.max(0, constraintHeight - offset) val height = Integer.max(0, constraintHeight - offset.roundToInt())
val top = measurables[0].measure( val top = measurables[0].measure(
constraints.copy( constraints.copy(
minHeight = height, minHeight = height,
@ -165,12 +165,6 @@ internal fun ExpandableBottomSheetScaffold(
) )
} }
private fun CustomSheetState.getIntOffset(): Int? = try {
requireOffset().roundToInt()
} catch (e: IllegalStateException) {
null
}
private sealed interface Slot { private sealed interface Slot {
data class SheetContent(val key: Int?) : Slot data class SheetContent(val key: Int?) : Slot
data object DragHandle : Slot data object DragHandle : Slot

View file

@ -47,6 +47,7 @@ import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.messageFromMeBackground import io.element.android.libraries.designsystem.theme.messageFromMeBackground
import io.element.android.libraries.designsystem.theme.messageFromOtherBackground import io.element.android.libraries.designsystem.theme.messageFromOtherBackground
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.utils.time.isTalkbackActive import io.element.android.libraries.ui.utils.time.isTalkbackActive
@ -112,7 +113,9 @@ fun MessageEventBubble(
state.isMine -> ElementTheme.colors.messageFromMeBackground state.isMine -> ElementTheme.colors.messageFromMeBackground
else -> ElementTheme.colors.messageFromOtherBackground else -> ElementTheme.colors.messageFromOtherBackground
} }
val bubbleShape = bubbleShape() // If we're running in UI test mode, we want to use a different shape to avoid
// this issue: https://issuetracker.google.com/issues/366255137
val bubbleShape = if (LocalUiTestMode.current) RoundedCornerShape(12.dp) else bubbleShape()
val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx() val radiusPx = (avatarRadius + SENDER_AVATAR_BORDER_WIDTH).toPx()
val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx() val yOffsetPx = -(NEGATIVE_MARGIN_FOR_BUBBLE + avatarRadius).toPx()
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl

View file

@ -18,7 +18,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -80,7 +80,7 @@ fun TimelineEventTimestampView(
.clickable(isVerifiedUserSendFailure) { .clickable(isVerifiedUserSendFailure) {
eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event)) eventSink(TimelineEvents.ComputeVerifiedUserSendFailure(event))
} }
.semantics { invisibleToUser() } .semantics { hideFromAccessibility() }
) )
} }
@ -95,7 +95,7 @@ fun TimelineEventTimestampView(
.clickable { .clickable {
eventSink(TimelineEvents.ShowShieldDialog(shield)) eventSink(TimelineEvents.ShowShieldDialog(shield))
} }
.semantics { invisibleToUser() }, .semantics { hideFromAccessibility() },
tint = shield.toIconColor(), tint = shield.toIconColor(),
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))

View file

@ -39,7 +39,7 @@ import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex import androidx.compose.ui.semantics.traversalIndex
@ -439,7 +439,7 @@ private fun MessageSenderInformation(
// Add external clickable modifier with no indicator so the touch target is larger than just the display name // Add external clickable modifier with no indicator so the touch target is larger than just the display name
.clickable(onClick = onClick, enabled = true, interactionSource = remember { MutableInteractionSource() }, indication = null) .clickable(onClick = onClick, enabled = true, interactionSource = remember { MutableInteractionSource() }, indication = null)
.clearAndSetSemantics { .clearAndSetSemantics {
invisibleToUser() hideFromAccessibility()
} }
) { ) {
Avatar( Avatar(

View file

@ -16,7 +16,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -43,7 +43,7 @@ fun TimelineItemReactionsView(
var expanded: Boolean by rememberSaveable { mutableStateOf(false) } var expanded: Boolean by rememberSaveable { mutableStateOf(false) }
TimelineItemReactionsView( TimelineItemReactionsView(
modifier = modifier.semantics { modifier = modifier.semantics {
invisibleToUser() hideFromAccessibility()
}, },
reactions = reactionsState.reactions, reactions = reactionsState.reactions,
userCanSendReaction = userCanSendReaction, userCanSendReaction = userCanSendReaction,

View file

@ -36,7 +36,7 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -136,7 +136,7 @@ fun TimelineItemVideoView(
imageVector = CompoundIcons.PlaySolid(), imageVector = CompoundIcons.PlaySolid(),
contentDescription = stringResource(id = CommonStrings.a11y_play), contentDescription = stringResource(id = CommonStrings.a11y_play),
colorFilter = ColorFilter.tint(Color.White), colorFilter = ColorFilter.tint(Color.White),
modifier = Modifier.semantics { invisibleToUser() } modifier = Modifier.semantics { hideFromAccessibility() }
) )
} }
} }

View file

@ -26,7 +26,7 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.invisibleToUser import androidx.compose.ui.semantics.hideFromAccessibility
import androidx.compose.ui.semantics.testTag import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -59,7 +59,7 @@ fun TimelineItemReadReceiptView(
if (renderReadReceipts) { if (renderReadReceipts) {
ReadReceiptsRow( ReadReceiptsRow(
modifier = modifier.clearAndSetSemantics { modifier = modifier.clearAndSetSemantics {
invisibleToUser() hideFromAccessibility()
} }
) { ) {
ReadReceiptsAvatars( ReadReceiptsAvatars(

View file

@ -68,6 +68,7 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.setSafeContent
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -567,11 +568,9 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMessa
onJoinCallClick: () -> Unit = EnsureNeverCalled(), onJoinCallClick: () -> Unit = EnsureNeverCalled(),
onViewAllPinnedMessagesClick: () -> Unit = EnsureNeverCalled(), onViewAllPinnedMessagesClick: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setSafeContent {
// Cannot use the RichTextEditor, so simulate a LocalInspectionMode // Cannot use the RichTextEditor, so simulate a LocalInspectionMode
CompositionLocalProvider( CompositionLocalProvider(LocalInspectionMode provides true) {
LocalInspectionMode provides true
) {
MessagesView( MessagesView(
state = state, state = state,
onBackClick = onBackClick, onBackClick = onBackClick,

View file

@ -14,6 +14,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.setSafeContent
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule import org.junit.rules.TestRule
@ -54,7 +55,7 @@ class ResolveVerifiedUserSendFailureViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setResolveVerifiedUserSendFailureView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setResolveVerifiedUserSendFailureView(
state: ResolveVerifiedUserSendFailureState, state: ResolveVerifiedUserSendFailureState,
) { ) {
setContent { setSafeContent {
ResolveVerifiedUserSendFailureView(state = state) ResolveVerifiedUserSendFailureView(state = state)
} }
} }

View file

@ -103,7 +103,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPinne
onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(), onLinkClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
onLinkLongClick: (Link) -> Unit = EnsureNeverCalledWithParam(), onLinkLongClick: (Link) -> Unit = EnsureNeverCalledWithParam(),
) { ) {
setSafeContent { setSafeContent(clearAndroidUiDispatcher = true) {
PinnedMessagesListView( PinnedMessagesListView(
state = state, state = state,
onBackClick = onBackClick, onBackClick = onBackClick,

View file

@ -187,7 +187,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setTimel
onJoinCallClick: () -> Unit = EnsureNeverCalled(), onJoinCallClick: () -> Unit = EnsureNeverCalled(),
forceJumpToBottomVisibility: Boolean = false, forceJumpToBottomVisibility: Boolean = false,
) { ) {
setSafeContent { setSafeContent(clearAndroidUiDispatcher = true) {
TimelineView( TimelineView(
state = state, state = state,
timelineProtectionState = timelineProtectionState, timelineProtectionState = timelineProtectionState,

View file

@ -21,6 +21,7 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledTimes import io.element.android.tests.testutils.ensureCalledTimes
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.setSafeContent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -159,14 +160,14 @@ class RolesAndPermissionsViewTest {
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRolesAndPermissionsView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRolesAndPermissionsView(
state: RolesAndPermissionsState = aRolesAndPermissionsState( state: RolesAndPermissionsState = aRolesAndPermissionsState(
eventSink = EventsRecorder(expectEvents = false), eventSink = EventsRecorder(expectEvents = false),
), ),
goBack: () -> Unit = EnsureNeverCalled(), goBack: () -> Unit = EnsureNeverCalled(),
openAdminList: () -> Unit = EnsureNeverCalled(), openAdminList: () -> Unit = EnsureNeverCalled(),
openModeratorList: () -> Unit = EnsureNeverCalled(), openModeratorList: () -> Unit = EnsureNeverCalled(),
openPermissionScreens: () -> Unit = EnsureNeverCalled(), openPermissionScreens: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setSafeContent {
RolesAndPermissionsView( RolesAndPermissionsView(
state = state, state = state,
rolesAndPermissionsNavigator = object : RolesAndPermissionsNavigator { rolesAndPermissionsNavigator = object : RolesAndPermissionsNavigator {

View file

@ -8,6 +8,7 @@
package io.element.android.features.roomlist.impl package io.element.android.features.roomlist.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -16,6 +17,7 @@ import io.element.android.tests.testutils.EnsureCalledOnceWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithParam import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.setSafeContent
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -28,15 +30,10 @@ class RoomListContextMenuTest {
fun `clicking on Mark as read generates expected Events`() { fun `clicking on Mark as read generates expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvents>()
val contextMenu = aContextMenuShown(hasNewContent = true) val contextMenu = aContextMenuShown(hasNewContent = true)
rule.setContent { rule.setRoomListContextMenu(
RoomListContextMenu( contextMenu = contextMenu,
contextMenu = contextMenu, eventSink = eventsRecorder,
canReportRoom = false, )
eventSink = eventsRecorder,
onRoomSettingsClick = EnsureNeverCalledWithParam(),
onReportRoomClick = EnsureNeverCalledWithParam(),
)
}
rule.clickOn(R.string.screen_roomlist_mark_as_read) rule.clickOn(R.string.screen_roomlist_mark_as_read)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
@ -50,15 +47,10 @@ class RoomListContextMenuTest {
fun `clicking on Mark as unread generates expected Events`() { fun `clicking on Mark as unread generates expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvents>()
val contextMenu = aContextMenuShown(hasNewContent = false) val contextMenu = aContextMenuShown(hasNewContent = false)
rule.setContent { rule.setRoomListContextMenu(
RoomListContextMenu( contextMenu = contextMenu,
contextMenu = contextMenu, eventSink = eventsRecorder,
canReportRoom = false, )
eventSink = eventsRecorder,
onRoomSettingsClick = EnsureNeverCalledWithParam(),
onReportRoomClick = EnsureNeverCalledWithParam(),
)
}
rule.clickOn(R.string.screen_roomlist_mark_as_unread) rule.clickOn(R.string.screen_roomlist_mark_as_unread)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
@ -72,15 +64,10 @@ class RoomListContextMenuTest {
fun `clicking on Leave room generates expected Events`() { fun `clicking on Leave room generates expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvents>()
val contextMenu = aContextMenuShown(isDm = false) val contextMenu = aContextMenuShown(isDm = false)
rule.setContent { rule.setRoomListContextMenu(
RoomListContextMenu( contextMenu = contextMenu,
contextMenu = contextMenu, eventSink = eventsRecorder,
canReportRoom = false, )
eventSink = eventsRecorder,
onRoomSettingsClick = EnsureNeverCalledWithParam(),
onReportRoomClick = EnsureNeverCalledWithParam(),
)
}
rule.clickOn(CommonStrings.action_leave_room) rule.clickOn(CommonStrings.action_leave_room)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
@ -95,15 +82,13 @@ class RoomListContextMenuTest {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvents>()
val contextMenu = aContextMenuShown() val contextMenu = aContextMenuShown()
val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit) val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit)
rule.setContent { rule.setRoomListContextMenu(
RoomListContextMenu( contextMenu = contextMenu,
contextMenu = contextMenu, canReportRoom = true,
canReportRoom = true, eventSink = eventsRecorder,
eventSink = eventsRecorder, onRoomSettingsClick = EnsureNeverCalledWithParam(),
onRoomSettingsClick = EnsureNeverCalledWithParam(), onReportRoomClick = callback,
onReportRoomClick = callback, )
)
}
rule.clickOn(CommonStrings.action_report_room) rule.clickOn(CommonStrings.action_report_room)
eventsRecorder.assertSingle(RoomListEvents.HideContextMenu) eventsRecorder.assertSingle(RoomListEvents.HideContextMenu)
callback.assertSuccess() callback.assertSuccess()
@ -114,15 +99,11 @@ class RoomListContextMenuTest {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvents>()
val contextMenu = aContextMenuShown() val contextMenu = aContextMenuShown()
val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit) val callback = EnsureCalledOnceWithParam(contextMenu.roomId, Unit)
rule.setContent { rule.setRoomListContextMenu(
RoomListContextMenu( contextMenu = contextMenu,
contextMenu = contextMenu, eventSink = eventsRecorder,
canReportRoom = false, onRoomSettingsClick = callback,
eventSink = eventsRecorder, )
onRoomSettingsClick = callback,
onReportRoomClick = EnsureNeverCalledWithParam(),
)
}
rule.clickOn(CommonStrings.common_settings) rule.clickOn(CommonStrings.common_settings)
eventsRecorder.assertSingle(RoomListEvents.HideContextMenu) eventsRecorder.assertSingle(RoomListEvents.HideContextMenu)
callback.assertSuccess() callback.assertSuccess()
@ -133,15 +114,11 @@ class RoomListContextMenuTest {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvents>()
val contextMenu = aContextMenuShown(isDm = false, isFavorite = false) val contextMenu = aContextMenuShown(isDm = false, isFavorite = false)
val callback = EnsureNeverCalledWithParam<RoomId>() val callback = EnsureNeverCalledWithParam<RoomId>()
rule.setContent { rule.setRoomListContextMenu(
RoomListContextMenu( contextMenu = contextMenu,
contextMenu = contextMenu, eventSink = eventsRecorder,
canReportRoom = false, onRoomSettingsClick = callback,
eventSink = eventsRecorder, )
onRoomSettingsClick = callback,
onReportRoomClick = EnsureNeverCalledWithParam(),
)
}
rule.clickOn(CommonStrings.common_favourite) rule.clickOn(CommonStrings.common_favourite)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
@ -149,4 +126,22 @@ class RoomListContextMenuTest {
) )
) )
} }
private fun AndroidComposeTestRule<*, *>.setRoomListContextMenu(
contextMenu: RoomListState.ContextMenu.Shown,
canReportRoom: Boolean = false,
eventSink: (RoomListEvents) -> Unit,
onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
) {
setSafeContent {
RoomListContextMenu(
contextMenu = contextMenu,
canReportRoom = canReportRoom,
onRoomSettingsClick = onRoomSettingsClick,
onReportRoomClick = onReportRoomClick,
eventSink = eventSink,
)
}
}
} }

View file

@ -16,6 +16,7 @@ import io.element.android.tests.testutils.EnsureCalledOnceWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithParam import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.setSafeContent
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -28,7 +29,7 @@ class RoomListDeclineInviteMenuTest {
fun `clicking on decline emits the expected Events`() { fun `clicking on decline emits the expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvents>()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary()) val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setContent { rule.setSafeContent {
RoomListDeclineInviteMenu( RoomListDeclineInviteMenu(
menu = menu, menu = menu,
canReportRoom = false, canReportRoom = false,
@ -49,7 +50,7 @@ class RoomListDeclineInviteMenuTest {
fun `clicking on decline and block when canReportRoom=true, it emits the expected Events and callback`() { fun `clicking on decline and block when canReportRoom=true, it emits the expected Events and callback`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvents>()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary()) val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setContent { rule.setSafeContent {
RoomListDeclineInviteMenu( RoomListDeclineInviteMenu(
menu = menu, menu = menu,
canReportRoom = true, canReportRoom = true,
@ -66,7 +67,7 @@ class RoomListDeclineInviteMenuTest {
fun `clicking on decline and block when canReportRoom=false, it emits the expected Events`() { fun `clicking on decline and block when canReportRoom=false, it emits the expected Events`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvents>()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary()) val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setContent { rule.setSafeContent {
RoomListDeclineInviteMenu( RoomListDeclineInviteMenu(
menu = menu, menu = menu,
canReportRoom = false, canReportRoom = false,
@ -86,7 +87,7 @@ class RoomListDeclineInviteMenuTest {
fun `clicking on cancel emits the expected Event`() { fun `clicking on cancel emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>() val eventsRecorder = EventsRecorder<RoomListEvents>()
val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary()) val menu = RoomListState.DeclineInviteMenu.Shown(roomSummary = aRoomListRoomSummary())
rule.setContent { rule.setSafeContent {
RoomListDeclineInviteMenu( RoomListDeclineInviteMenu(
menu = menu, menu = menu,
canReportRoom = false, canReportRoom = false,

View file

@ -29,6 +29,7 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.setSafeContent
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -52,7 +53,7 @@ class RoomListViewTest {
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListEvents.UpdateVisibleRange(IntRange.EMPTY), RoomListEvents.UpdateVisibleRange(IntRange.EMPTY),
RoomListEvents.UpdateVisibleRange(0 until 2), RoomListEvents.UpdateVisibleRange(0..2),
) )
) )
} }
@ -273,7 +274,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomL
onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(),
onDeclineInviteAndBlockUser: (RoomListRoomSummary) -> Unit = EnsureNeverCalledWithParam(), onDeclineInviteAndBlockUser: (RoomListRoomSummary) -> Unit = EnsureNeverCalledWithParam(),
) { ) {
setContent { setSafeContent {
RoomListView( RoomListView(
state = state, state = state,
onRoomClick = onRoomClick, onRoomClick = onRoomClick,

View file

@ -22,6 +22,7 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnceWithTwoParams import io.element.android.tests.testutils.ensureCalledOnceWithTwoParams
import io.element.android.tests.testutils.pressTag import io.element.android.tests.testutils.pressTag
import io.element.android.tests.testutils.setSafeContent
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule import org.junit.rules.TestRule
@ -217,7 +218,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomM
state: InternalRoomMemberModerationState, state: InternalRoomMemberModerationState,
onSelectAction: (ModerationAction, MatrixUser) -> Unit = EnsureNeverCalledWithTwoParams(), onSelectAction: (ModerationAction, MatrixUser) -> Unit = EnsureNeverCalledWithTwoParams(),
) { ) {
setContent { setSafeContent {
RoomMemberModerationView( RoomMemberModerationView(
state = state, state = state,
onSelectAction = onSelectAction, onSelectAction = onSelectAction,

View file

@ -24,10 +24,12 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillType import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentType
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
@ -39,7 +41,6 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.securebackup.impl.R import io.element.android.features.securebackup.impl.R
import io.element.android.features.securebackup.impl.tools.RecoveryKeyVisualTransformation import io.element.android.features.securebackup.impl.tools.RecoveryKeyVisualTransformation
import io.element.android.libraries.designsystem.modifiers.autofill
import io.element.android.libraries.designsystem.modifiers.clickableIfNotNull import io.element.android.libraries.designsystem.modifiers.clickableIfNotNull
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -186,10 +187,9 @@ private fun RecoveryKeyFormContent(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.testTag(TestTags.recoveryKey) .testTag(TestTags.recoveryKey)
.autofill( .semantics {
autofillTypes = listOf(AutofillType.Password), contentType = ContentType.Password
onFill = { onChange(it) }, },
),
minLines = 2, minLines = 2,
value = state.formattedRecoveryKey.orEmpty(), value = state.formattedRecoveryKey.orEmpty(),
onValueChange = onChange, onValueChange = onChange,

View file

@ -20,7 +20,7 @@ media3 = "1.7.1"
camera = "1.4.2" camera = "1.4.2"
# Compose # Compose
compose_bom = "2025.04.00" compose_bom = "2025.04.01"
composecompiler = "1.5.15" composecompiler = "1.5.15"
# Coroutines # Coroutines

View file

@ -1,43 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.modifiers
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillNode
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.composed
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalAutofill
import androidx.compose.ui.platform.LocalAutofillTree
@Suppress("ModifierComposed")
@OptIn(ExperimentalComposeUiApi::class)
fun Modifier.autofill(autofillTypes: List<AutofillType>, onFill: (String) -> Unit) = composed {
val autofillNode = AutofillNode(autofillTypes, onFill = onFill)
LocalAutofillTree.current += autofillNode
val autofill = LocalAutofill.current
this
.onGloballyPositioned {
// Inform autofill framework of where our composable is so it can show the popup in the right place
autofillNode.boundingBox = it.boundsInWindow()
}
.onFocusChanged {
autofill?.run {
if (it.isFocused) {
requestAutofillForNode(autofillNode)
} else {
cancelAutofillForNode(autofillNode)
}
}
}
}

View file

@ -10,19 +10,18 @@ package io.element.android.libraries.designsystem.theme.components
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.BottomSheetScaffoldState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.contentColorFor import androidx.compose.material3.contentColorFor
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import io.element.android.libraries.designsystem.theme.components.bottomsheet.BottomSheetScaffoldState
import io.element.android.libraries.designsystem.theme.components.bottomsheet.CustomBottomSheetScaffold
import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberBottomSheetScaffoldState
@Composable @Composable
@ExperimentalMaterial3Api @ExperimentalMaterial3Api
@ -44,7 +43,7 @@ fun BottomSheetScaffold(
contentColor: Color = contentColorFor(containerColor), contentColor: Color = contentColorFor(containerColor),
content: @Composable (PaddingValues) -> Unit content: @Composable (PaddingValues) -> Unit
) { ) {
CustomBottomSheetScaffold( androidx.compose.material3.BottomSheetScaffold(
sheetContent = sheetContent, sheetContent = sheetContent,
modifier = modifier, modifier = modifier,
scaffoldState = scaffoldState, scaffoldState = scaffoldState,

View file

@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -33,6 +34,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.preview.sheetStateForPreview import io.element.android.libraries.designsystem.preview.sheetStateForPreview
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -52,11 +54,14 @@ fun ModalBottomSheet(
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
val safeSheetState = if (LocalInspectionMode.current) sheetStateForPreview() else sheetState val safeSheetState = if (LocalInspectionMode.current) sheetStateForPreview() else sheetState
// If we're running in UI test mode, we want to use a different shape to avoid
// this issue: https://issuetracker.google.com/issues/366255137
val safeShape = if (LocalUiTestMode.current) RoundedCornerShape(12.dp) else shape
androidx.compose.material3.ModalBottomSheet( androidx.compose.material3.ModalBottomSheet(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
modifier = modifier, modifier = modifier,
sheetState = safeSheetState, sheetState = safeSheetState,
shape = shape, shape = safeShape,
containerColor = containerColor, containerColor = containerColor,
contentColor = contentColor, contentColor = contentColor,
tonalElevation = tonalElevation, tonalElevation = tonalElevation,

View file

@ -1,516 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalFoundationApi::class)
package io.element.android.libraries.designsystem.theme.components.bottomsheet
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.SheetValue.Expanded
import androidx.compose.material3.SheetValue.PartiallyExpanded
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.collapse
import androidx.compose.ui.semantics.dismiss
import androidx.compose.ui.semantics.expand
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.Velocity
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt
// These are needed until https://issuetracker.google.com/issues/306464779 is fixed
@Composable
@ExperimentalMaterial3Api
fun CustomBottomSheetScaffold(
sheetContent: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(),
sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight,
sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
sheetShape: Shape = BottomSheetDefaults.ExpandedShape,
sheetContainerColor: Color = Color.White,
sheetContentColor: Color = contentColorFor(sheetContainerColor),
sheetTonalElevation: Dp = BottomSheetDefaults.Elevation,
sheetShadowElevation: Dp = BottomSheetDefaults.Elevation,
sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
sheetSwipeEnabled: Boolean = true,
topBar: @Composable (() -> Unit)? = null,
snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
containerColor: Color = MaterialTheme.colorScheme.surface,
contentColor: Color = contentColorFor(containerColor),
content: @Composable (PaddingValues) -> Unit
) {
val peekHeightPx = with(LocalDensity.current) {
sheetPeekHeight.roundToPx()
}
CustomBottomSheetScaffoldLayout(
modifier = modifier,
topBar = topBar,
body = content,
snackbarHost = {
snackbarHost(scaffoldState.snackbarHostState)
},
sheetPeekHeight = sheetPeekHeight,
sheetOffset = { scaffoldState.bottomSheetState.requireOffset() },
sheetState = scaffoldState.bottomSheetState,
containerColor = containerColor,
contentColor = contentColor,
bottomSheet = { layoutHeight ->
CustomStandardBottomSheet(
state = scaffoldState.bottomSheetState,
peekHeight = sheetPeekHeight,
sheetMaxWidth = sheetMaxWidth,
sheetSwipeEnabled = sheetSwipeEnabled,
calculateAnchors = { sheetSize ->
val sheetHeight = sheetSize.height
io.element.android.libraries.designsystem.theme.components.bottomsheet.DraggableAnchors {
if (!scaffoldState.bottomSheetState.skipPartiallyExpanded) {
PartiallyExpanded at (layoutHeight - peekHeightPx).toFloat()
}
if (sheetHeight != peekHeightPx) {
Expanded at maxOf(layoutHeight - sheetHeight, 0).toFloat()
}
if (!scaffoldState.bottomSheetState.skipHiddenState) {
SheetValue.Hidden at layoutHeight.toFloat()
}
}
},
shape = sheetShape,
containerColor = sheetContainerColor,
contentColor = sheetContentColor,
tonalElevation = sheetTonalElevation,
shadowElevation = sheetShadowElevation,
dragHandle = sheetDragHandle,
content = sheetContent
)
}
)
}
@SuppressWarnings("ModifierWithoutDefault")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CustomBottomSheetScaffoldLayout(
modifier: Modifier,
topBar: @Composable (() -> Unit)?,
body: @Composable (innerPadding: PaddingValues) -> Unit,
bottomSheet: @Composable (layoutHeight: Int) -> Unit,
snackbarHost: @Composable () -> Unit,
sheetPeekHeight: Dp,
sheetOffset: () -> Float,
sheetState: CustomSheetState,
containerColor: Color,
contentColor: Color,
) {
// b/291735717 Remove this once deprecated methods without density are removed
val density = LocalDensity.current
SideEffect {
sheetState.density = density
}
SubcomposeLayout { constraints ->
val layoutWidth = constraints.maxWidth
val layoutHeight = constraints.maxHeight
val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0)
val sheetPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Sheet) {
bottomSheet(layoutHeight)
}[0].measure(looseConstraints)
val topBarPlaceable = topBar?.let {
subcompose(BottomSheetScaffoldLayoutSlot.TopBar) { topBar() }[0]
.measure(looseConstraints)
}
val topBarHeight = topBarPlaceable?.height ?: 0
val bodyConstraints = looseConstraints.copy(maxHeight = layoutHeight - topBarHeight)
val bodyPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Body) {
Surface(
modifier = modifier,
color = containerColor,
contentColor = contentColor,
) { body(PaddingValues(bottom = sheetPeekHeight)) }
}[0].measure(bodyConstraints)
val snackbarPlaceable = subcompose(BottomSheetScaffoldLayoutSlot.Snackbar, snackbarHost)[0]
.measure(looseConstraints)
layout(layoutWidth, layoutHeight) {
val sheetOffsetY = sheetOffset().roundToInt()
val sheetOffsetX = Integer.max(0, (layoutWidth - sheetPlaceable.width) / 2)
val snackbarOffsetX = (layoutWidth - snackbarPlaceable.width) / 2
val snackbarOffsetY = when (sheetState.currentValue) {
SheetValue.PartiallyExpanded -> sheetOffsetY - snackbarPlaceable.height
SheetValue.Expanded, SheetValue.Hidden -> layoutHeight - snackbarPlaceable.height
}
// Placement order is important for elevation
bodyPlaceable.placeRelative(0, topBarHeight)
topBarPlaceable?.placeRelative(0, 0)
sheetPlaceable.placeRelative(sheetOffsetX, sheetOffsetY)
snackbarPlaceable.placeRelative(snackbarOffsetX, snackbarOffsetY)
}
}
}
private enum class BottomSheetScaffoldLayoutSlot { TopBar, Body, Sheet, Snackbar }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
private fun CustomStandardBottomSheet(
state: CustomSheetState,
@Suppress("PrimitiveInLambda")
calculateAnchors: (sheetSize: IntSize) -> DraggableAnchors<SheetValue>,
peekHeight: Dp,
sheetMaxWidth: Dp,
sheetSwipeEnabled: Boolean,
shape: Shape,
containerColor: Color,
contentColor: Color,
tonalElevation: Dp,
shadowElevation: Dp,
dragHandle: @Composable (() -> Unit)?,
content: @Composable ColumnScope.() -> Unit
) {
val scope = rememberCoroutineScope()
val orientation = Orientation.Vertical
Surface(
modifier = Modifier
.widthIn(max = sheetMaxWidth)
.fillMaxWidth()
.requiredHeightIn(min = peekHeight)
.apply {
if (sheetSwipeEnabled) {
nestedScroll(
remember(state.anchoredDraggableState) {
ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
sheetState = state,
orientation = orientation,
onFling = { scope.launch { state.settle(it) } }
)
}
)
}
}
.anchoredDraggable(
state = state.anchoredDraggableState,
orientation = orientation,
enabled = sheetSwipeEnabled
)
.onSizeChanged { layoutSize ->
val newAnchors = calculateAnchors(layoutSize)
val newTarget = when (state.anchoredDraggableState.targetValue) {
SheetValue.Hidden, SheetValue.PartiallyExpanded -> SheetValue.PartiallyExpanded
SheetValue.Expanded -> {
if (newAnchors.hasAnchorFor(SheetValue.Expanded)) SheetValue.Expanded else SheetValue.PartiallyExpanded
}
}
state.anchoredDraggableState.updateAnchors(newAnchors, newTarget)
},
shape = shape,
color = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation,
shadowElevation = shadowElevation,
) {
Column(Modifier.fillMaxWidth()) {
if (dragHandle != null) {
val partialExpandActionLabel =
"Partial Expand"
val dismissActionLabel = "Dismiss"
val expandActionLabel = "Expand"
Box(
Modifier
.align(Alignment.CenterHorizontally)
.semantics(mergeDescendants = true) {
with(state) {
// Provides semantics to interact with the bottomsheet if there is more
// than one anchor to swipe to and swiping is enabled.
if (anchoredDraggableState.anchors.size > 1 && sheetSwipeEnabled) {
if (currentValue == SheetValue.PartiallyExpanded) {
expand(expandActionLabel) {
scope.launch { expand() }
true
}
} else {
collapse(partialExpandActionLabel) {
scope.launch { partialExpand() }
true
}
}
if (!state.skipHiddenState) {
dismiss(dismissActionLabel) {
scope.launch { hide() }
true
}
}
}
}
},
) {
dragHandle()
}
}
content()
}
}
}
/**
* [DraggableAnchorsConfig] stores a mutable configuration anchors, comprised of values of [T] and
* corresponding [Float] positions. This [DraggableAnchorsConfig] is used to construct an immutable
* [DraggableAnchors] instance later on.
*/
@ExperimentalFoundationApi
class DraggableAnchorsConfig<T> {
internal val anchors = mutableMapOf<T, Float>()
/**
* Set the anchor position for [this] anchor.
*
* @param position The anchor position.
*/
@Suppress("BuilderSetStyle")
infix fun T.at(position: Float) {
anchors[this] = position
}
}
/**
* Create a new [DraggableAnchors] instance using a builder function.
*
* @param T The type of the anchor values.
* @param builder A function with a [DraggableAnchorsConfig] that offers APIs to configure anchors
* @return A new [DraggableAnchors] instance with the anchor positions set by the `builder`
* function.
*/
@OptIn(ExperimentalFoundationApi::class)
@ExperimentalMaterial3Api
@SuppressWarnings("FunctionName")
internal fun <T : Any> DraggableAnchors(
builder: DraggableAnchorsConfig<T>.() -> Unit
): DraggableAnchors<T> = MapDraggableAnchors(DraggableAnchorsConfig<T>().apply(builder).anchors)
private class MapDraggableAnchors<T>(private val anchors: Map<T, Float>) : DraggableAnchors<T> {
override fun positionOf(value: T): Float = anchors[value] ?: Float.NaN
override fun hasAnchorFor(value: T) = anchors.containsKey(value)
override fun closestAnchor(position: Float): T? = anchors.minByOrNull {
abs(position - it.value)
}?.key
override fun closestAnchor(
position: Float,
searchUpwards: Boolean
): T? {
return anchors.minByOrNull { (_, anchor) ->
val delta = if (searchUpwards) anchor - position else position - anchor
if (delta < 0) Float.POSITIVE_INFINITY else delta
}?.key
}
override fun minAnchor() = anchors.values.minOrNull() ?: Float.NaN
override fun maxAnchor() = anchors.values.maxOrNull() ?: Float.NaN
override val size: Int
get() = anchors.size
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is MapDraggableAnchors<*>) return false
return anchors == other.anchors
}
override fun forEach(block: (anchor: T, position: Float) -> Unit) {
for (anchor in anchors) {
block(anchor.key, anchor.value)
}
}
override fun hashCode() = 31 * anchors.hashCode()
override fun toString() = "MapDraggableAnchors($anchors)"
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@SuppressWarnings("FunctionName")
internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
sheetState: CustomSheetState,
orientation: Orientation,
onFling: (velocity: Float) -> Unit
): NestedScrollConnection = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat()
return if (delta < 0 && source == NestedScrollSource.UserInput) {
sheetState.anchoredDraggableState.dispatchRawDelta(delta).toOffset()
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return if (source == NestedScrollSource.UserInput) {
sheetState.anchoredDraggableState.dispatchRawDelta(available.toFloat()).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = available.toFloat()
val currentOffset = sheetState.requireOffset()
val minAnchor = sheetState.anchoredDraggableState.anchors.minAnchor()
return if (toFling < 0 && currentOffset > minAnchor) {
onFling(toFling)
// since we go to the anchor with tween settling, consume all for the best UX
available
} else {
Velocity.Zero
}
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
onFling(available.toFloat())
return available
}
private fun Float.toOffset(): Offset = Offset(
x = if (orientation == Orientation.Horizontal) this else 0f,
y = if (orientation == Orientation.Vertical) this else 0f
)
@JvmName("velocityToFloat")
private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y
@JvmName("offsetToFloat")
private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y
}
/**
* State of the [BottomSheetScaffold] composable.
*
* @param bottomSheetState the state of the persistent bottom sheet
* @param snackbarHostState the [SnackbarHostState] used to show snackbars inside the scaffold
*/
@ExperimentalMaterial3Api
@Stable
@SuppressWarnings("UseDataClass")
class BottomSheetScaffoldState(
val bottomSheetState: CustomSheetState,
val snackbarHostState: SnackbarHostState
)
/**
* Create and [remember] a [BottomSheetScaffoldState].
*
* @param bottomSheetState the state of the standard bottom sheet. See
* [rememberStandardBottomSheetState]
* @param snackbarHostState the [SnackbarHostState] used to show snackbars inside the scaffold
*/
@Composable
@ExperimentalMaterial3Api
fun rememberBottomSheetScaffoldState(
bottomSheetState: CustomSheetState = rememberStandardBottomSheetState(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
): BottomSheetScaffoldState {
return remember(bottomSheetState, snackbarHostState) {
BottomSheetScaffoldState(
bottomSheetState = bottomSheetState,
snackbarHostState = snackbarHostState
)
}
}
/**
* Create and [remember] a [SheetState] for [BottomSheetScaffold].
*
* @param initialValue the initial value of the state. Should be either [PartiallyExpanded] or
* [Expanded] if [skipHiddenState] is true
* @param confirmValueChange optional callback invoked to confirm or veto a pending state change
* @param [skipHiddenState] whether Hidden state is skipped for [BottomSheetScaffold]
*/
@Composable
@ExperimentalMaterial3Api
fun rememberStandardBottomSheetState(
initialValue: SheetValue = PartiallyExpanded,
confirmValueChange: (SheetValue) -> Boolean = { true },
skipHiddenState: Boolean = true,
) = rememberSheetState(false, confirmValueChange, initialValue, skipHiddenState)
@Composable
@ExperimentalMaterial3Api
internal fun rememberSheetState(
skipPartiallyExpanded: Boolean = false,
confirmValueChange: (SheetValue) -> Boolean = { true },
initialValue: SheetValue = SheetValue.Hidden,
skipHiddenState: Boolean = false,
): CustomSheetState {
val density = LocalDensity.current
return rememberSaveable(
skipPartiallyExpanded,
confirmValueChange,
saver = CustomSheetState.Saver(
skipPartiallyExpanded = skipPartiallyExpanded,
confirmValueChange = confirmValueChange,
density = density
)
) {
CustomSheetState(
skipPartiallyExpanded,
density,
initialValue,
confirmValueChange,
skipHiddenState
)
}
}

View file

@ -1,303 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.theme.components.bottomsheet
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.animateTo
import androidx.compose.foundation.gestures.snapTo
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetValue
import androidx.compose.material3.SheetValue.Expanded
import androidx.compose.material3.SheetValue.Hidden
import androidx.compose.material3.SheetValue.PartiallyExpanded
import androidx.compose.runtime.Stable
import androidx.compose.runtime.saveable.Saver
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException
@OptIn(ExperimentalFoundationApi::class)
@Stable
@ExperimentalMaterial3Api
class CustomSheetState
@Deprecated(
message = "This constructor is deprecated. " +
"Please use the constructor that provides a [Density]",
replaceWith = ReplaceWith(
"SheetState(" +
"skipPartiallyExpanded, LocalDensity.current, initialValue, " +
"confirmValueChange, skipHiddenState)"
)
)
constructor(
internal val skipPartiallyExpanded: Boolean,
initialValue: SheetValue = Hidden,
confirmValueChange: (SheetValue) -> Boolean = { true },
internal val skipHiddenState: Boolean = false,
) {
/**
* State of a sheet composable, such as [ModalBottomSheet]
*
* Contains states relating to its swipe position as well as animations between state values.
*
* @param skipPartiallyExpanded Whether the partially expanded state, if the sheet is large
* enough, should be skipped. If true, the sheet will always expand to the [Expanded] state and move
* to the [Hidden] state if available when hiding the sheet, either programmatically or by user
* interaction.
* @param density The density that this state can use to convert values to and from dp.
* @param initialValue The initial value of the state.
* @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
* @param skipHiddenState Whether the hidden state should be skipped. If true, the sheet will always
* expand to the [Expanded] state and move to the [PartiallyExpanded] if available, either
* programmatically or by user interaction.
*/
@ExperimentalMaterial3Api
@Suppress("Deprecation")
constructor(
skipPartiallyExpanded: Boolean,
density: Density,
initialValue: SheetValue = Hidden,
confirmValueChange: (SheetValue) -> Boolean = { true },
skipHiddenState: Boolean = false,
) : this(skipPartiallyExpanded, initialValue, confirmValueChange, skipHiddenState) {
this.density = density
}
init {
if (skipPartiallyExpanded) {
require(initialValue != PartiallyExpanded) {
"The initial value must not be set to PartiallyExpanded if skipPartiallyExpanded " +
"is set to true."
}
}
if (skipHiddenState) {
require(initialValue != Hidden) {
"The initial value must not be set to Hidden if skipHiddenState is set to true."
}
}
}
/**
* The current value of the state.
*
* If no swipe or animation is in progress, this corresponds to the state the bottom sheet is
* currently in. If a swipe or an animation is in progress, this corresponds the state the sheet
* was in before the swipe or animation started.
*/
val currentValue: SheetValue get() = anchoredDraggableState.currentValue
/**
* The target value of the bottom sheet state.
*
* If a swipe is in progress, this is the value that the sheet would animate to if the
* swipe finishes. If an animation is running, this is the target value of that animation.
* Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
*/
val targetValue: SheetValue get() = anchoredDraggableState.targetValue
/**
* Whether the modal bottom sheet is visible.
*/
val isVisible: Boolean
get() = anchoredDraggableState.currentValue != Hidden
/**
* Require the current offset (in pixels) of the bottom sheet.
*
* The offset will be initialized during the first measurement phase of the provided sheet
* content.
*
* These are the phases:
* Composition { -> Effects } -> Layout { Measurement -> Placement } -> Drawing
*
* During the first composition, an [IllegalStateException] is thrown. In subsequent
* compositions, the offset will be derived from the anchors of the previous pass. Always prefer
* accessing the offset from a LaunchedEffect as it will be scheduled to be executed the next
* frame, after layout.
*
* @throws IllegalStateException If the offset has not been initialized yet
*/
fun requireOffset(): Float = anchoredDraggableState.requireOffset()
fun getOffset(): Float? = anchoredDraggableState.offset.takeIf { !it.isNaN() }
/**
* Whether the sheet has an expanded state defined.
*/
val hasExpandedState: Boolean
get() = anchoredDraggableState.anchors.hasAnchorFor(Expanded)
/**
* Whether the modal bottom sheet has a partially expanded state defined.
*/
val hasPartiallyExpandedState: Boolean
get() = anchoredDraggableState.anchors.hasAnchorFor(PartiallyExpanded)
/**
* Fully expand the bottom sheet with animation and suspend until it is fully expanded or
* animation has been cancelled.
* *
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun expand() {
anchoredDraggableState.animateTo(Expanded)
}
/**
* Animate the bottom sheet and suspend until it is partially expanded or animation has been
* cancelled.
* @throws [CancellationException] if the animation is interrupted
* @throws [IllegalStateException] if [skipPartiallyExpanded] is set to true
*/
suspend fun partialExpand() {
check(!skipPartiallyExpanded) {
"Attempted to animate to partial expanded when skipPartiallyExpanded was enabled. Set" +
" skipPartiallyExpanded to false to use this function."
}
animateTo(PartiallyExpanded)
}
/**
* Expand the bottom sheet with animation and suspend until it is [PartiallyExpanded] if defined
* else [Expanded].
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun show() {
val targetValue = when {
hasPartiallyExpandedState -> PartiallyExpanded
else -> Expanded
}
animateTo(targetValue)
}
/**
* Hide the bottom sheet with animation and suspend until it is fully hidden or animation has
* been cancelled.
* @throws [CancellationException] if the animation is interrupted
*/
suspend fun hide() {
check(!skipHiddenState) {
"Attempted to animate to hidden when skipHiddenState was enabled. Set skipHiddenState" +
" to false to use this function."
}
animateTo(Hidden)
}
/**
* Animate to a [targetValue].
* If the [targetValue] is not in the set of anchors, the [currentValue] will be updated to the
* [targetValue] without updating the offset.
*
* @throws CancellationException if the interaction interrupted by another interaction like a
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
*
* @param targetValue The target value of the animation
*/
@OptIn(ExperimentalFoundationApi::class)
internal suspend fun animateTo(
targetValue: SheetValue,
) {
anchoredDraggableState.animateTo(targetValue)
}
/**
* Snap to a [targetValue] without any animation.
*
* @throws CancellationException if the interaction interrupted by another interaction like a
* gesture interaction or another programmatic interaction like a [animateTo] or [snapTo] call.
*
* @param targetValue The target value of the animation
*/
@OptIn(ExperimentalFoundationApi::class)
internal suspend fun snapTo(targetValue: SheetValue) {
anchoredDraggableState.snapTo(targetValue)
}
/**
* Find the closest anchor taking into account the velocity and settle at it with an animation.
*/
@OptIn(ExperimentalFoundationApi::class)
internal suspend fun settle(velocity: Float) {
anchoredDraggableState.settle(velocity)
}
@OptIn(ExperimentalFoundationApi::class)
internal var anchoredDraggableState = androidx.compose.foundation.gestures.AnchoredDraggableState(
initialValue = initialValue,
snapAnimationSpec = AnchoredDraggableDefaults.SnapAnimationSpec,
decayAnimationSpec = AnchoredDraggableDefaults.DecayAnimationSpec,
confirmValueChange = confirmValueChange,
positionalThreshold = { with(requireDensity()) { 56.dp.toPx() } },
velocityThreshold = { with(requireDensity()) { 125.dp.toPx() } }
)
@OptIn(ExperimentalFoundationApi::class)
internal val offset: Float? get() = anchoredDraggableState.offset
internal var density: Density? = null
private fun requireDensity() = requireNotNull(density) {
"SheetState did not have a density attached. Are you using SheetState with " +
"BottomSheetScaffold or ModalBottomSheet component?"
}
companion object {
/**
* The default [Saver] implementation for [SheetState].
*/
@SuppressWarnings("FunctionName")
fun Saver(
skipPartiallyExpanded: Boolean,
confirmValueChange: (SheetValue) -> Boolean,
density: Density
) = Saver<CustomSheetState, SheetValue>(
save = { it.currentValue },
restore = { savedValue ->
CustomSheetState(skipPartiallyExpanded, density, savedValue, confirmValueChange)
}
)
/**
* The default [Saver] implementation for [SheetState].
*/
@Deprecated(
message = "This function is deprecated. Please use the overload where Density is" +
" provided.",
replaceWith = ReplaceWith(
"Saver(skipPartiallyExpanded, confirmValueChange, LocalDensity.current)"
)
)
@Suppress("Deprecation", "FunctionName")
fun Saver(
skipPartiallyExpanded: Boolean,
confirmValueChange: (SheetValue) -> Boolean
) = Saver<CustomSheetState, SheetValue>(
save = { it.currentValue },
restore = { savedValue ->
CustomSheetState(skipPartiallyExpanded, savedValue, confirmValueChange)
}
)
}
}
@Stable
@ExperimentalMaterial3Api
internal object AnchoredDraggableDefaults {
/**
* The default animation used by [AnchoredDraggableState].
*/
@ExperimentalMaterial3Api
val SnapAnimationSpec = SpringSpec<Float>()
@ExperimentalMaterial3Api
val DecayAnimationSpec = exponentialDecay<Float>()
}

View file

@ -0,0 +1,15 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.designsystem.utils
import androidx.compose.runtime.staticCompositionLocalOf
/**
* A composition local that indicates whether the app is running in UI test mode.
*/
val LocalUiTestMode = staticCompositionLocalOf { false }

View file

@ -19,6 +19,7 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.setSafeContent
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule import org.junit.rules.TestRule
@ -60,7 +61,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMedia
onDelete: (EventId) -> Unit = EnsureNeverCalledWithParam(), onDelete: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onDismiss: () -> Unit = EnsureNeverCalled(), onDismiss: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setSafeContent {
MediaDeleteConfirmationBottomSheet( MediaDeleteConfirmationBottomSheet(
state = state, state = state,
onDelete = onDelete, onDelete = onDelete,

View file

@ -18,6 +18,7 @@ import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.setSafeContent
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule import org.junit.rules.TestRule
@ -100,7 +101,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMedia
onDelete: (EventId) -> Unit = EnsureNeverCalledWithParam(), onDelete: (EventId) -> Unit = EnsureNeverCalledWithParam(),
onDismiss: () -> Unit = EnsureNeverCalled(), onDismiss: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setSafeContent {
MediaDetailsBottomSheet( MediaDetailsBottomSheet(
state = state, state = state,
onViewInTimeline = onViewInTimeline, onViewInTimeline = onViewInTimeline,

View file

@ -26,6 +26,7 @@ import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.setSafeContent
import io.mockk.mockk import io.mockk.mockk
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -249,7 +250,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMedia
state: MediaViewerState, state: MediaViewerState,
onBackClick: () -> Unit = EnsureNeverCalled(), onBackClick: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setSafeContent {
MediaViewerView( MediaViewerView(
state = state, state = state,
audioFocus = null, audioFocus = null,

View file

@ -23,6 +23,7 @@ dependencies {
implementation(projects.libraries.androidutils) implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.services.toolbox.api) implementation(projects.services.toolbox.api)
implementation(libs.test.turbine) implementation(libs.test.turbine)

View file

@ -9,7 +9,9 @@ package io.element.android.tests.testutils
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import io.element.android.libraries.designsystem.utils.LocalUiTestMode
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.rules.TestRule import org.junit.rules.TestRule
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
@ -49,7 +51,16 @@ object RobolectricDispatcherCleaner {
} }
} }
fun <R : TestRule, A : ComponentActivity> AndroidComposeTestRule<R, A>.setSafeContent(content: @Composable () -> Unit) { fun <R : TestRule, A : ComponentActivity> AndroidComposeTestRule<R, A>.setSafeContent(
RobolectricDispatcherCleaner.clearAndroidUiDispatcher() clearAndroidUiDispatcher: Boolean = false,
setContent(content) content: @Composable () -> Unit,
) {
if (clearAndroidUiDispatcher) {
RobolectricDispatcherCleaner.clearAndroidUiDispatcher()
}
setContent {
CompositionLocalProvider(LocalUiTestMode provides true) {
content()
}
}
} }

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ea4f3ed733159e0413383d8994943b5b7b3100160728138b7014a9567f665021 oid sha256:3ecb9e2af8ad8221225e71463a1da8589ff1a6c9ba393f636c2dbec08fae513b
size 30632 size 30991

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:657c14f3e42751aefbac5fc0b7d94462d2b90da1707ab7ce7997f5db1b29c6d2 oid sha256:f7f456872a423c0edeb87e5a9fbc7e73896f3cb75609a64c4acb959ffd77e72d
size 31013 size 31404

View file

@ -230,6 +230,7 @@ Compose:
- LocalMentionSpanUpdater - LocalMentionSpanUpdater
- LocalAnalyticsService - LocalAnalyticsService
- LocalBuildMeta - LocalBuildMeta
- LocalUiTestMode
CompositionLocalNaming: CompositionLocalNaming:
active: true active: true
ContentEmitterReturningValues: ContentEmitterReturningValues: