Merge branch 'develop' into feature/fga/space_members_access

This commit is contained in:
ganfra 2026-01-08 13:46:02 +01:00
commit 0668135d0e
215 changed files with 2349 additions and 1664 deletions

View file

@ -10,14 +10,15 @@ package io.element.android.libraries.androidutils.json
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Provider
import dev.zacsweers.metro.SingleIn
import kotlinx.serialization.json.Json
/**
* Provides a Json instance configured to ignore unknown keys.
*/
typealias JsonProvider = Provider<Json>
fun interface JsonProvider {
operator fun invoke(): Json
}
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ac84a7175c4a4897aa28eddcf722b7997c6576f612eb38fa09ffabcf7be11e00
size 119496
oid sha256:32b12d0b26cd016a632a4cb87b71d5efcb2c0d816bf565bc90aee9963ce2d5df
size 134117

View file

@ -17,13 +17,14 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.compoundTypography
@Preview
@Composable
internal fun TypographyPreview() = ElementTheme {
Surface {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
with(ElementTheme.materialTypography) {
with(compoundTypography) {
TypographyTokenPreview(displayLarge, "Display large")
TypographyTokenPreview(displayMedium, "Display medium")
TypographyTokenPreview(displaySmall, "Display small")
@ -44,6 +45,33 @@ internal fun TypographyPreview() = ElementTheme {
}
}
@Preview
@Composable
internal fun CompoundTypographyPreview() = ElementTheme {
Surface {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
with(ElementTheme.typography) {
TypographyTokenPreview(fontHeadingXlBold, "fontHeadingXlBold")
TypographyTokenPreview(fontHeadingXlRegular, "fontHeadingXlRegular")
TypographyTokenPreview(fontHeadingLgBold, "fontHeadingLgBold")
TypographyTokenPreview(fontHeadingLgRegular, "fontHeadingLgRegular")
TypographyTokenPreview(fontHeadingMdBold, "fontHeadingMdBold")
TypographyTokenPreview(fontHeadingMdRegular, "fontHeadingMdRegular")
TypographyTokenPreview(fontHeadingSmMedium, "fontHeadingSmMedium")
TypographyTokenPreview(fontHeadingSmRegular, "fontHeadingSmRegular")
TypographyTokenPreview(fontBodyLgMedium, "fontBodyLgMedium")
TypographyTokenPreview(fontBodyLgRegular, "fontBodyLgRegular")
TypographyTokenPreview(fontBodyMdMedium, "fontBodyMdMedium")
TypographyTokenPreview(fontBodyMdRegular, "fontBodyMdRegular")
TypographyTokenPreview(fontBodySmMedium, "fontBodySmMedium")
TypographyTokenPreview(fontBodySmRegular, "fontBodySmRegular")
TypographyTokenPreview(fontBodyXsMedium, "fontBodyXsMedium")
TypographyTokenPreview(fontBodyXsRegular, "fontBodyXsRegular")
}
}
}
}
@Composable
private fun TypographyTokenPreview(style: TextStyle, text: String) {
Text(text = text, style = style)

View file

@ -62,14 +62,6 @@ object ElementTheme {
*/
val typography: TypographyTokens = TypographyTokens
/**
* Material 3 [Typography] tokens. In Figma, these have the `M3 Typography/` prefix.
*/
val materialTypography: Typography
@Composable
@ReadOnlyComposable
get() = MaterialTheme.typography
/**
* Returns whether the theme version used is the light or the dark one.
*/

View file

@ -8,18 +8,10 @@
package io.element.android.compound.screenshot
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import io.element.android.compound.previews.CompoundTypographyPreview
import io.element.android.compound.screenshot.utils.screenshotFile
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.TypographyTokens
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@ -32,35 +24,7 @@ class CompoundTypographyTest {
@Config(sdk = [35], qualifiers = "h2048dp-xxhdpi")
fun screenshots() {
captureRoboImage(file = screenshotFile("Compound Typography.png")) {
ElementTheme {
Surface {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
with(TypographyTokens) {
TypographyTokenPreview(fontHeadingXlBold, "Heading XL Bold")
TypographyTokenPreview(fontHeadingXlRegular, "Heading XL Regular")
TypographyTokenPreview(fontHeadingLgBold, "Heading LG Bold")
TypographyTokenPreview(fontHeadingLgRegular, "Heading LG Regular")
TypographyTokenPreview(fontHeadingMdBold, "Heading MD Bold")
TypographyTokenPreview(fontHeadingMdRegular, "Heading MD Regular")
TypographyTokenPreview(fontHeadingSmMedium, "Heading SM Medium")
TypographyTokenPreview(fontHeadingSmRegular, "Heading SM Regular")
TypographyTokenPreview(fontBodyLgMedium, "Body LG Medium")
TypographyTokenPreview(fontBodyLgRegular, "Body LG Regular")
TypographyTokenPreview(fontBodyMdMedium, "Body MD Medium")
TypographyTokenPreview(fontBodyMdRegular, "Body MD Regular")
TypographyTokenPreview(fontBodySmMedium, "Body SM Medium")
TypographyTokenPreview(fontBodySmRegular, "Body SM Regular")
TypographyTokenPreview(fontBodyXsMedium, "Body XS Medium")
TypographyTokenPreview(fontBodyXsRegular, "Body XS Regular")
}
}
}
}
CompoundTypographyPreview()
}
}
@Composable
private fun TypographyTokenPreview(style: TextStyle, text: String) {
Text(text = text, style = style)
}
}

View file

@ -0,0 +1,75 @@
/*
* Copyright (c) 2025 Element Creations 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.atomic.atoms
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.messageFromMeBackground
@Composable
fun PlaybackSpeedButton(
speed: Float,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val speedText = when (speed) {
0.5f -> "0.5×"
1.0f -> "1×"
1.5f -> "1.5×"
2.0f -> "2×"
else -> "$speed×"
}
Box(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(
color = ElementTheme.colors.bgCanvasDefault,
)
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 4.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = speedText,
color = ElementTheme.colors.iconSecondary,
style = ElementTheme.typography.fontBodyXsMedium,
)
}
}
@PreviewsDayNight
@Composable
internal fun PlaybackSpeedButtonPreview() = ElementPreview {
Row(
modifier = Modifier
.background(ElementTheme.colors.messageFromMeBackground)
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf(0.5f, 1.0f, 1.5f, 2.0f, 3.0f).forEach { speed ->
PlaybackSpeedButton(
speed = speed,
onClick = {},
)
}
}
}

View file

@ -34,7 +34,7 @@ fun SelectedIndicatorAtom(
Icon(
modifier = modifier.toggleable(
value = true,
role = Role.Companion.Checkbox,
role = Role.Checkbox,
enabled = enabled,
onValueChange = {},
),

View file

@ -59,7 +59,7 @@ fun DmAvatars(
Avatar(
avatarData = userAvatarData,
avatarType = AvatarType.User,
contentDescription = userAvatarData.url?.let { stringResource(CommonStrings.a11y_your_avatar) },
contentDescription = stringResource(CommonStrings.a11y_your_avatar),
modifier = Modifier
.align(Alignment.BottomStart)
.graphicsLayer {
@ -94,7 +94,7 @@ fun DmAvatars(
Avatar(
avatarData = otherUserAvatarData,
avatarType = AvatarType.User,
contentDescription = otherUserAvatarData.url?.let { stringResource(CommonStrings.a11y_other_user_avatar) },
contentDescription = stringResource(CommonStrings.a11y_other_user_avatar),
modifier = Modifier
.align(Alignment.TopEnd)
.clip(CircleShape)

View file

@ -36,7 +36,7 @@ internal fun ImageAvatar(
SubcomposeAsyncImage(
model = avatarData,
contentDescription = contentDescription,
contentScale = ContentScale.Companion.Crop,
contentScale = ContentScale.Crop,
modifier = modifier
.size(size)
.clip(avatarShape)

View file

@ -57,14 +57,14 @@ fun ErrorDialogWithDoNotShowAgain(
Column {
Text(
text = content,
style = ElementTheme.materialTypography.bodyMedium,
style = ElementTheme.typography.fontBodyMdRegular,
)
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = doNotShowAgain, onCheckedChange = { doNotShowAgain = it })
Text(
text = stringResource(id = CommonStrings.common_do_not_show_this_again),
style = ElementTheme.materialTypography.bodyMedium,
style = ElementTheme.typography.fontBodyMdRegular,
)
}
}

View file

@ -76,7 +76,7 @@ fun TextFieldDialog(
item {
Text(
text = content,
style = ElementTheme.materialTypography.bodyMedium,
style = ElementTheme.typography.fontBodyMdRegular,
)
}
}

View file

@ -65,7 +65,7 @@ internal fun SimpleAlertDialogContent(
content = {
Text(
text = content,
style = ElementTheme.materialTypography.bodyMedium,
style = ElementTheme.typography.fontBodyMdRegular,
)
},
submitText = submitText,

View file

@ -15,9 +15,15 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.KeyboardActionHandler
import androidx.compose.foundation.text.input.OutputTransformation
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TextFieldLabelScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@ -135,6 +141,51 @@ fun FilledTextField(
)
}
@Composable
fun FilledTextField(
state: TextFieldState,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
label: @Composable (TextFieldLabelScope.() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
supportingText: @Composable (() -> Unit)? = null,
isError: Boolean = false,
inputTransformation: InputTransformation? = null,
outputTransformation: OutputTransformation? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActionHandler? = null,
lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default,
interactionSource: MutableInteractionSource? = null,
shape: Shape = TextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.colors()
) {
androidx.compose.material3.TextField(
state = state,
modifier = modifier,
enabled = enabled,
readOnly = readOnly,
textStyle = textStyle,
label = label,
placeholder = placeholder,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
supportingText = supportingText,
isError = isError,
inputTransformation = inputTransformation,
outputTransformation = outputTransformation,
keyboardOptions = keyboardOptions,
onKeyboardAction = keyboardActions,
lineLimits = lineLimits,
interactionSource = interactionSource,
shape = shape,
colors = colors,
)
}
@Preview(group = PreviewGroup.TextFields)
@Composable
internal fun FilledTextFieldLightPreview() =

View file

@ -114,7 +114,7 @@ fun ListItem(
val decoratedHeadlineContent: @Composable () -> Unit = {
CompositionLocalProvider(
LocalTextStyle provides ElementTheme.materialTypography.bodyLarge,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular,
LocalContentColor provides headlineColor,
) {
headlineContent()
@ -123,7 +123,7 @@ fun ListItem(
val decoratedSupportingContent: (@Composable () -> Unit)? = supportingContent?.let { content ->
{
CompositionLocalProvider(
LocalTextStyle provides ElementTheme.materialTypography.bodyMedium,
LocalTextStyle provides ElementTheme.typography.fontBodyMdRegular,
LocalContentColor provides supportingColor,
) {
content()

View file

@ -50,6 +50,6 @@ object ElementNavigationBarItemDefaults {
selectedTextColor = ElementTheme.colors.textPrimary,
unselectedIconColor = ElementTheme.colors.iconTertiary,
unselectedTextColor = ElementTheme.colors.textDisabled,
selectedIndicatorColor = Color.Companion.Transparent,
selectedIndicatorColor = Color.Transparent,
)
}

View file

@ -70,12 +70,6 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
Space(
key = "feature.space",
title = "Spaces",
defaultValue = { true },
isFinished = true,
),
SpaceSettings(
key = "feature.spaceSettings",
title = "Space settings",

View file

@ -25,10 +25,14 @@ class DefaultFeatureFlagService(
private val featuresProvider: FeaturesProvider,
) : FeatureFlagService {
override fun isFeatureEnabledFlow(feature: Feature): Flow<Boolean> {
return providers.filter { it.hasFeature(feature) }
.maxByOrNull(FeatureFlagProvider::priority)
?.isFeatureEnabledFlow(feature)
?: flowOf(feature.defaultValue(buildMeta))
return if (feature.isFinished) {
flowOf(feature.defaultValue(buildMeta))
} else {
providers.filter { it.hasFeature(feature) }
.maxByOrNull(FeatureFlagProvider::priority)
?.isFeatureEnabledFlow(feature)
?: flowOf(feature.defaultValue(buildMeta))
}
}
override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean {

View file

@ -177,4 +177,9 @@ interface JoinedRoom : BaseRoom {
*
*/
suspend fun withdrawVerificationAndResend(userIds: List<UserId>, sendHandle: SendHandle): Result<Unit>
/**
* Subscribe to a [Flow] of [SendQueueUpdate] related to this room.
*/
fun subscribeToSendQueueUpdates(): Flow<SendQueueUpdate>
}

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations 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.matrix.api.room
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.media.MediaSource
sealed interface SendQueueUpdate {
data class NewLocalEvent(val transactionId: TransactionId) : SendQueueUpdate
data class CancelledLocalEvent(val transactionId: TransactionId) : SendQueueUpdate
data class ReplacedLocalEvent(val transactionId: TransactionId) : SendQueueUpdate
data class SendError(val transactionId: TransactionId) : SendQueueUpdate
data class RetrySendingEvent(val transactionId: TransactionId) : SendQueueUpdate
data class SentEvent(val transactionId: TransactionId, val eventId: EventId) : SendQueueUpdate
data class MediaUpload(val relatedTo: EventId, val file: MediaSource?, val index: Long, val progress: Float) : SendQueueUpdate
}

View file

@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.room.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.IntentionalMention
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomNotificationSettingsState
import io.element.android.libraries.matrix.api.room.SendQueueUpdate
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
@ -66,6 +67,8 @@ import org.matrix.rustcomponents.sdk.DateDividerMode
import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
import org.matrix.rustcomponents.sdk.KnockRequestsListener
import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType
import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate
import org.matrix.rustcomponents.sdk.SendQueueListener
import org.matrix.rustcomponents.sdk.TimelineConfiguration
import org.matrix.rustcomponents.sdk.TimelineFilter
import org.matrix.rustcomponents.sdk.TimelineFocus
@ -486,6 +489,16 @@ class JoinedRustRoom(
}
}
override fun subscribeToSendQueueUpdates(): Flow<SendQueueUpdate> {
return mxCallbackFlow {
innerRoom.subscribeToSendQueueUpdates(object : SendQueueListener {
override fun onUpdate(update: RoomSendQueueUpdate) {
trySend(update.map())
}
})
}
}
override fun close() = destroy()
override fun destroy() {

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Element Creations 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.matrix.impl.room
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.room.SendQueueUpdate
import io.element.android.libraries.matrix.impl.media.map
import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate
fun RoomSendQueueUpdate.map(): SendQueueUpdate = when (this) {
is RoomSendQueueUpdate.NewLocalEvent -> SendQueueUpdate.NewLocalEvent(TransactionId(transactionId))
is RoomSendQueueUpdate.CancelledLocalEvent -> SendQueueUpdate.CancelledLocalEvent(TransactionId(transactionId))
is RoomSendQueueUpdate.MediaUpload -> SendQueueUpdate.MediaUpload(
relatedTo = EventId(relatedTo),
file = file?.map(),
index = index.toLong(),
progress = progress.current.toFloat() / progress.total.toFloat(),
)
is RoomSendQueueUpdate.ReplacedLocalEvent -> SendQueueUpdate.ReplacedLocalEvent(TransactionId(transactionId))
is RoomSendQueueUpdate.RetryEvent -> SendQueueUpdate.RetrySendingEvent(TransactionId(transactionId))
is RoomSendQueueUpdate.SendError -> SendQueueUpdate.SendError(TransactionId(transactionId))
is RoomSendQueueUpdate.SentEvent -> SendQueueUpdate.SentEvent(TransactionId(transactionId), EventId(eventId))
}

View file

@ -30,7 +30,7 @@ import org.matrix.rustcomponents.sdk.QueueWedgeError
import org.matrix.rustcomponents.sdk.Reaction
import org.matrix.rustcomponents.sdk.ShieldState
import org.matrix.rustcomponents.sdk.TimelineItemContent
import uniffi.matrix_sdk_common.ShieldStateCode
import uniffi.matrix_sdk_ui.TimelineEventShieldStateCode
import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState
import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem
import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo
@ -58,7 +58,7 @@ class EventTimelineItemMapper(
content = contentMapper.map(content),
origin = origin?.map(),
timelineItemDebugInfoProvider = { lazyProvider.debugInfo().map() },
messageShieldProvider = { strict -> lazyProvider.getShields(strict)?.map() },
messageShieldProvider = { strict -> lazyProvider.getShields(strict).map() },
sendHandleProvider = { lazyProvider.getSendHandle()?.let(::RustSendHandle) }
)
}
@ -182,13 +182,13 @@ private fun ShieldState?.map(): MessageShield? {
is ShieldState.Red -> true
}
return when (shieldStateCode) {
ShieldStateCode.AUTHENTICITY_NOT_GUARANTEED -> MessageShield.AuthenticityNotGuaranteed(isCritical)
ShieldStateCode.UNKNOWN_DEVICE -> MessageShield.UnknownDevice(isCritical)
ShieldStateCode.UNSIGNED_DEVICE -> MessageShield.UnsignedDevice(isCritical)
ShieldStateCode.UNVERIFIED_IDENTITY -> MessageShield.UnverifiedIdentity(isCritical)
ShieldStateCode.SENT_IN_CLEAR -> MessageShield.SentInClear(isCritical)
ShieldStateCode.VERIFICATION_VIOLATION -> MessageShield.VerificationViolation(isCritical)
ShieldStateCode.MISMATCHED_SENDER -> MessageShield.MismatchedSender(isCritical)
TimelineEventShieldStateCode.AUTHENTICITY_NOT_GUARANTEED -> MessageShield.AuthenticityNotGuaranteed(isCritical)
TimelineEventShieldStateCode.UNKNOWN_DEVICE -> MessageShield.UnknownDevice(isCritical)
TimelineEventShieldStateCode.UNSIGNED_DEVICE -> MessageShield.UnsignedDevice(isCritical)
TimelineEventShieldStateCode.UNVERIFIED_IDENTITY -> MessageShield.UnverifiedIdentity(isCritical)
TimelineEventShieldStateCode.SENT_IN_CLEAR -> MessageShield.SentInClear(isCritical)
TimelineEventShieldStateCode.VERIFICATION_VIOLATION -> MessageShield.VerificationViolation(isCritical)
TimelineEventShieldStateCode.MISMATCHED_SENDER -> MessageShield.MismatchedSender(isCritical)
}
}

View file

@ -72,7 +72,8 @@ class RustSessionVerificationService(
// Listen for changes in verification status and update accordingly
private val verificationStateListenerTaskHandle = encryptionService.verificationStateListener(object : VerificationStateListener {
override fun onUpdate(status: VerificationState) {
if (!isInitialized.get()) {
// If the status is verified, just use it. It can't be a false positive like unknown or unverified
if (!isInitialized.get() && status != VerificationState.VERIFIED) {
Timber.d("Discarding new verifications state: $status. E2EE is not initialised yet")
return
}

View file

@ -21,22 +21,24 @@ import org.matrix.rustcomponents.sdk.ShieldState
import org.matrix.rustcomponents.sdk.TimelineItemContent
import uniffi.matrix_sdk_ui.EventItemOrigin
fun aRustEventTimelineItem(
internal fun aRustEventTimelineItem(
isRemote: Boolean = true,
eventOrTransactionId: EventOrTransactionId = EventOrTransactionId.EventId(AN_EVENT_ID.value),
sender: String = A_USER_ID.value,
senderProfile: ProfileDetails = ProfileDetails.Unavailable,
isOwn: Boolean = true,
isEditable: Boolean = true,
content: TimelineItemContent = aRustTimelineItemMessageContent(),
content: TimelineItemContent = aRustTimelineItemContentMsgLike(),
timestamp: ULong = 0uL,
debugInfo: EventTimelineItemDebugInfo = anEventTimelineItemDebugInfo(),
localSendState: EventSendState? = null,
readReceipts: Map<String, Receipt> = emptyMap(),
origin: EventItemOrigin? = EventItemOrigin.SYNC,
canBeRepliedTo: Boolean = true,
shieldsState: ShieldState? = null,
shieldsState: ShieldState = ShieldState.None,
localCreatedAt: ULong? = null,
forwarder: String? = null,
forwarderProfile: ProfileDetails? = null,
) = EventTimelineItem(
isRemote = isRemote,
eventOrTransactionId = eventOrTransactionId,
@ -54,5 +56,7 @@ fun aRustEventTimelineItem(
lazyProvider = FakeFfiLazyTimelineItemProvider(
debugInfo = debugInfo,
shieldsState = shieldsState,
)
),
forwarder = forwarder,
forwarderProfile = forwarderProfile,
)

View file

@ -10,7 +10,7 @@ package io.element.android.libraries.matrix.impl.fixtures.factories
import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo
fun anEventTimelineItemDebugInfo(
internal fun anEventTimelineItemDebugInfo(
model: String = "model",
originalJson: String? = null,
latestEditJson: String? = null,

View file

@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTimelineEv
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_USER_NAME
import org.matrix.rustcomponents.sdk.Action
import org.matrix.rustcomponents.sdk.BatchNotificationResult
import org.matrix.rustcomponents.sdk.JoinRule
import org.matrix.rustcomponents.sdk.NotificationEvent
import org.matrix.rustcomponents.sdk.NotificationItem
@ -21,7 +22,7 @@ import org.matrix.rustcomponents.sdk.NotificationSenderInfo
import org.matrix.rustcomponents.sdk.NotificationStatus
import org.matrix.rustcomponents.sdk.TimelineEvent
fun aRustNotificationItem(
internal fun aRustNotificationItem(
event: NotificationEvent = aRustNotificationEventTimeline(),
senderInfo: NotificationSenderInfo = aRustNotificationSenderInfo(),
roomInfo: NotificationRoomInfo = aRustNotificationRoomInfo(),
@ -39,13 +40,13 @@ fun aRustNotificationItem(
actions = actions,
)
fun aRustBatchNotificationResult(
internal fun aRustBatchNotificationResultOk(
notificationStatus: NotificationStatus = NotificationStatus.Event(aRustNotificationItem()),
) = org.matrix.rustcomponents.sdk.BatchNotificationResult.Ok(
) = BatchNotificationResult.Ok(
status = notificationStatus,
)
fun aRustNotificationSenderInfo(
internal fun aRustNotificationSenderInfo(
displayName: String? = A_USER_NAME,
avatarUrl: String? = null,
isNameAmbiguous: Boolean = false,
@ -55,7 +56,7 @@ fun aRustNotificationSenderInfo(
isNameAmbiguous = isNameAmbiguous,
)
fun aRustNotificationRoomInfo(
internal fun aRustNotificationRoomInfo(
displayName: String = A_ROOM_NAME,
avatarUrl: String? = null,
canonicalAlias: String? = null,
@ -77,7 +78,7 @@ fun aRustNotificationRoomInfo(
isSpace = isSpace,
)
fun aRustNotificationEventTimeline(
internal fun aRustNotificationEventTimeline(
event: TimelineEvent = FakeFfiTimelineEvent(),
) = NotificationEvent.Timeline(
event = event,

View file

@ -22,15 +22,13 @@ internal fun aRustRoomDescription(
joinRule: PublicRoomJoinRule = PublicRoomJoinRule.PUBLIC,
isWorldReadable: Boolean = true,
joinedMembers: ULong = 2u,
): RoomDescription {
return RoomDescription(
roomId = roomId,
name = name,
topic = topic,
alias = alias,
avatarUrl = avatarUrl,
joinRule = joinRule,
isWorldReadable = isWorldReadable,
joinedMembers = joinedMembers,
)
}
) = RoomDescription(
roomId = roomId,
name = name,
topic = topic,
alias = alias,
avatarUrl = avatarUrl,
joinRule = joinRule,
isWorldReadable = isWorldReadable,
joinedMembers = joinedMembers,
)

View file

@ -14,10 +14,8 @@ import org.matrix.rustcomponents.sdk.RoomHero
internal fun aRustRoomHero(
userId: UserId = A_USER_ID,
): RoomHero {
return RoomHero(
userId = userId.value,
displayName = "displayName",
avatarUrl = "avatarUrl",
)
}
) = RoomHero(
userId = userId.value,
displayName = "displayName",
avatarUrl = "avatarUrl",
)

View file

@ -22,7 +22,7 @@ import org.matrix.rustcomponents.sdk.RoomPowerLevels
import org.matrix.rustcomponents.sdk.SuccessorRoom
import uniffi.matrix_sdk_base.EncryptionState
fun aRustRoomInfo(
internal fun aRustRoomInfo(
id: String = A_ROOM_ID.value,
displayName: String? = A_ROOM_NAME,
rawName: String? = A_ROOM_NAME,

View file

@ -14,7 +14,7 @@ import org.matrix.rustcomponents.sdk.PowerLevel
import org.matrix.rustcomponents.sdk.RoomMember
import uniffi.matrix_sdk.RoomMemberRole
fun aRustRoomMember(
internal fun aRustRoomMember(
userId: UserId,
displayName: String? = null,
avatarUrl: String? = null,

View file

@ -11,7 +11,7 @@ package io.element.android.libraries.matrix.impl.fixtures.factories
import org.matrix.rustcomponents.sdk.RoomNotificationMode
import org.matrix.rustcomponents.sdk.RoomNotificationSettings
fun aRustRoomNotificationSettings(
internal fun aRustRoomNotificationSettings(
mode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES,
isDefault: Boolean = true,
) = RoomNotificationSettings(

View file

@ -19,20 +19,18 @@ internal fun aRustRoomPreviewInfo(
canonicalAlias: String? = A_ROOM_ALIAS.value,
membership: Membership? = Membership.JOINED,
joinRule: JoinRule = JoinRule.Public,
): RoomPreviewInfo {
return RoomPreviewInfo(
roomId = A_ROOM_ID.value,
canonicalAlias = canonicalAlias,
name = "name",
topic = "topic",
avatarUrl = "avatarUrl",
numJoinedMembers = 1u,
numActiveMembers = 1u,
isDirect = false,
roomType = RoomType.Room,
isHistoryWorldReadable = true,
membership = membership,
joinRule = joinRule,
heroes = null,
)
}
) = RoomPreviewInfo(
roomId = A_ROOM_ID.value,
canonicalAlias = canonicalAlias,
name = "name",
topic = "topic",
avatarUrl = "avatarUrl",
numJoinedMembers = 1u,
numActiveMembers = 1u,
isDirect = false,
roomType = RoomType.Room,
isHistoryWorldReadable = true,
membership = membership,
joinRule = joinRule,
heroes = null,
)

View file

@ -18,14 +18,12 @@ internal fun aRustSession(
proxy: SlidingSyncVersion = SlidingSyncVersion.NONE,
accessToken: String = "accessToken",
refreshToken: String = "refreshToken",
): Session {
return Session(
accessToken = accessToken,
refreshToken = refreshToken,
userId = A_USER_ID.value,
deviceId = A_DEVICE_ID.value,
homeserverUrl = A_HOMESERVER_URL,
oidcData = null,
slidingSyncVersion = proxy,
)
}
) = Session(
accessToken = accessToken,
refreshToken = refreshToken,
userId = A_USER_ID.value,
deviceId = A_DEVICE_ID.value,
homeserverUrl = A_HOMESERVER_URL,
oidcData = null,
slidingSyncVersion = proxy,
)

View file

@ -16,7 +16,7 @@ import org.matrix.rustcomponents.sdk.RoomHero
import org.matrix.rustcomponents.sdk.RoomType
import org.matrix.rustcomponents.sdk.SpaceRoom
fun aRustSpaceRoom(
internal fun aRustSpaceRoom(
roomId: RoomId = A_ROOM_ID,
isDirect: Boolean = false,
canonicalAlias: String? = null,

View file

@ -15,15 +15,13 @@ import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.TextMessageContent
import org.matrix.rustcomponents.sdk.TimelineEventContent
fun aRustTimelineEventContentMessageLike(
internal fun aRustTimelineEventContentMessageLike(
content: MessageLikeEventContent = aRustMessageLikeEventContentRoomMessage(),
): TimelineEventContent.MessageLike {
return TimelineEventContent.MessageLike(
content = content,
)
}
) = TimelineEventContent.MessageLike(
content = content,
)
fun aRustMessageLikeEventContentRoomMessage(
internal fun aRustMessageLikeEventContentRoomMessage(
messageType: MessageType = aRustMessageTypeText(),
inReplyToEventId: String? = null,
) = MessageLikeEventContent.RoomMessage(
@ -31,13 +29,13 @@ fun aRustMessageLikeEventContentRoomMessage(
inReplyToEventId = inReplyToEventId,
)
fun aRustMessageTypeText(
internal fun aRustMessageTypeText(
content: TextMessageContent = aRustTextMessageContent(),
) = MessageType.Text(
content = content,
)
fun aRustTextMessageContent(
internal fun aRustTextMessageContent(
body: String = A_MESSAGE,
formatted: FormattedBody? = null,
) = TextMessageContent(

View file

@ -15,7 +15,9 @@ import org.matrix.rustcomponents.sdk.MsgLikeKind
import org.matrix.rustcomponents.sdk.TextMessageContent
import org.matrix.rustcomponents.sdk.TimelineItemContent
fun aRustTimelineItemMessageContent(body: String = "Hello") = TimelineItemContent.MsgLike(
internal fun aRustTimelineItemContentMsgLike(
body: String = "Hello",
) = TimelineItemContent.MsgLike(
content = MsgLikeContent(
kind = MsgLikeKind.Message(
content = MessageContent(

View file

@ -19,14 +19,12 @@ internal fun aRustUnableToDecryptInfo(
userTrustsOwnIdentity: Boolean = false,
senderHomeserver: String = "",
ownHomeserver: String = "",
): UnableToDecryptInfo {
return UnableToDecryptInfo(
eventId = eventId,
timeToDecryptMs = timeToDecryptMs,
cause = cause,
eventLocalAgeMillis = eventLocalAgeMillis,
userTrustsOwnIdentity = userTrustsOwnIdentity,
senderHomeserver = senderHomeserver,
ownHomeserver = ownHomeserver,
)
}
) = UnableToDecryptInfo(
eventId = eventId,
timeToDecryptMs = timeToDecryptMs,
cause = cause,
eventLocalAgeMillis = eventLocalAgeMillis,
userTrustsOwnIdentity = userTrustsOwnIdentity,
senderHomeserver = senderHomeserver,
ownHomeserver = ownHomeserver,
)

View file

@ -11,7 +11,7 @@ package io.element.android.libraries.matrix.impl.fixtures.factories
import io.element.android.libraries.matrix.test.A_USER_ID
import org.matrix.rustcomponents.sdk.UserProfile
fun aRustUserProfile(
internal fun aRustUserProfile(
userId: String = A_USER_ID.value,
displayName: String = "displayName",
avatarUrl: String = "avatarUrl",

View file

@ -17,7 +17,7 @@ import org.matrix.rustcomponents.sdk.ShieldState
class FakeFfiLazyTimelineItemProvider(
private val debugInfo: EventTimelineItemDebugInfo = anEventTimelineItemDebugInfo(),
private val shieldsState: ShieldState? = null,
private val shieldsState: ShieldState = ShieldState.None,
) : LazyTimelineItemProvider(NoHandle) {
override fun getShields(strict: Boolean) = shieldsState
override fun debugInfo() = debugInfo

View file

@ -12,7 +12,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.exception.NotificationResolverException
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustBatchNotificationResult
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustBatchNotificationResultOk
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustNotificationEventTimeline
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustNotificationItem
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiNotificationClient
@ -38,7 +38,7 @@ class RustNotificationServiceTest {
@Test
fun test() = runTest {
val notificationClient = FakeFfiNotificationClient(
notificationItemResult = mapOf(AN_EVENT_ID.value to aRustBatchNotificationResult()),
notificationItemResult = mapOf(AN_EVENT_ID.value to aRustBatchNotificationResultOk()),
)
val sut = createRustNotificationService(
notificationClient = notificationClient,
@ -66,10 +66,10 @@ class RustNotificationServiceTest {
}
val notificationClient = FakeFfiNotificationClient(
notificationItemResult = mapOf(
AN_EVENT_ID.value to aRustBatchNotificationResult(
AN_EVENT_ID.value to aRustBatchNotificationResultOk(
notificationStatus = NotificationStatus.Event(aRustNotificationItem(aRustNotificationEventTimeline(faultyEvent)))
),
AN_EVENT_ID_2.value to aRustBatchNotificationResult()
AN_EVENT_ID_2.value to aRustBatchNotificationResultOk()
),
)
val sut = createRustNotificationService(

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomNotificationSettingsState
import io.element.android.libraries.matrix.api.room.SendQueueUpdate
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.knock.KnockRequest
@ -39,6 +40,7 @@ import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.test.TestScope
@ -83,6 +85,8 @@ class FakeJoinedRoom(
private val updateJoinRuleResult: (JoinRule) -> Result<Unit> = { lambdaError() },
private val setSendQueueEnabledResult: (Boolean) -> Unit = { _: Boolean -> },
) : JoinedRoom, BaseRoom by baseRoom {
private val sendQueueUpdates = MutableSharedFlow<SendQueueUpdate>(extraBufferCapacity = 10)
fun givenRoomMembersState(state: RoomMembersState) {
baseRoom.givenRoomMembersState(state)
}
@ -219,6 +223,10 @@ class FakeJoinedRoom(
withdrawVerificationAndResendResult(userIds, sendHandle)
}
override fun subscribeToSendQueueUpdates(): Flow<SendQueueUpdate> {
return sendQueueUpdates
}
private suspend fun simulateSendMediaProgress(progressCallback: ProgressCallback?) {
progressCallbackValues.forEach { (current, total) ->
progressCallback?.onProgress(current, total)
@ -229,4 +237,8 @@ class FakeJoinedRoom(
fun emitSyncUpdate() {
(syncUpdateFlow as MutableStateFlow).value = syncUpdateFlow.value + 1
}
suspend fun givenSendQueueUpdate(sendQueueUpdate: SendQueueUpdate) {
sendQueueUpdates.emit(sendQueueUpdate)
}
}

View file

@ -36,7 +36,7 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags)
implementation(libs.coil.compose)
implementation(libs.jsoup)
implementation(libs.matrix.richtexteditor)
implementation(projects.libraries.previewutils)
testCommonDependencies(libs, true)

View file

@ -12,7 +12,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import org.jsoup.Jsoup
import io.element.android.wysiwyg.utils.HtmlToDomParser
import org.jsoup.nodes.Document
/**
@ -34,9 +34,9 @@ fun FormattedBody.toHtmlDocument(
?.trimEnd()
?.let { formattedBody ->
val dom = if (prefix != null) {
Jsoup.parse("$prefix $formattedBody")
HtmlToDomParser.document("$prefix $formattedBody")
} else {
Jsoup.parse(formattedBody)
HtmlToDomParser.document(formattedBody)
}
// Prepend `@` to mentions

View file

@ -55,8 +55,15 @@ private class PlainTextNodeVisitor : NodeVisitor {
private val builder = StringBuilder()
override fun head(node: Node, depth: Int) {
if (node is TextNode && node.text().isNotBlank()) {
builder.append(node.text())
if (node is TextNode) {
// If the text node is blank, only add a single whitespace char if there wasn't already one
if (node.text().isBlank()) {
if (builder.lastOrNull()?.isWhitespace() == false) {
builder.append(" ")
}
} else {
builder.append(node.text())
}
} else if (node is Element && node.tagName() == "li") {
val index = node.elementSiblingIndex() + 1
val isOrdered = node.parent()?.nodeName()?.lowercase() == "ol"

View file

@ -45,7 +45,7 @@ class ToPlainTextTest {
val formattedBody = FormattedBody(
format = MessageFormat.HTML,
body = """
Hello world
Hello <strong>formatted</strong> <em>world</em>
<ul><li>This is an unordered list.</li></ul>
<ol><li>This is an ordered list.</li></ol>
<br />
@ -53,7 +53,7 @@ class ToPlainTextTest {
)
assertThat(formattedBody.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo(
"""
Hello world
Hello formatted world
This is an unordered list.
1. This is an ordered list.
""".trimIndent()

View file

@ -47,6 +47,12 @@ interface MediaPlayer : AutoCloseable {
*/
fun seekTo(positionMs: Long)
/**
* Sets the playback speed.
* @param speed The playback speed (e.g., 0.5f for half speed, 1.0f for normal, 2.0f for double speed)
*/
fun setPlaybackSpeed(speed: Float)
/**
* Releases any resources associated with this player.
*/

View file

@ -159,6 +159,10 @@ class DefaultMediaPlayer(
}
}
override fun setPlaybackSpeed(speed: Float) {
player.setPlaybackSpeed(speed)
}
override fun close() {
player.release()
}

View file

@ -34,6 +34,7 @@ interface SimplePlayer {
fun isPlaying(): Boolean
fun pause()
fun seekTo(positionMs: Long)
fun setPlaybackSpeed(speed: Float)
fun release()
interface Listener {
fun onIsPlayingChanged(isPlaying: Boolean)
@ -88,5 +89,9 @@ class DefaultSimplePlayer(
override fun seekTo(positionMs: Long) = p.seekTo(positionMs)
override fun setPlaybackSpeed(speed: Float) {
p.setPlaybackParameters(p.playbackParameters.withSpeed(speed))
}
override fun release() = p.release()
}

View file

@ -20,6 +20,7 @@ class FakeSimplePlayer(
private val isPlayingLambda: () -> Boolean = { lambdaError() },
private val pauseLambda: () -> Unit = { lambdaError() },
private val seekToLambda: (Long) -> Unit = { lambdaError() },
private val setPlaybackSpeedLambda: (Float) -> Unit = { lambdaError() },
private val releaseLambda: () -> Unit = { lambdaError() },
) : SimplePlayer {
private val listeners = mutableListOf<SimplePlayer.Listener>()
@ -45,6 +46,7 @@ class FakeSimplePlayer(
override fun isPlaying() = isPlayingLambda()
override fun pause() = pauseLambda()
override fun seekTo(positionMs: Long) = seekToLambda(positionMs)
override fun setPlaybackSpeed(speed: Float) = setPlaybackSpeedLambda(speed)
override fun release() = releaseLambda()
fun simulateIsPlayingChanged(isPlaying: Boolean) {

View file

@ -96,6 +96,10 @@ class FakeMediaPlayer(
}
}
override fun setPlaybackSpeed(speed: Float) {
// no-op
}
override fun close() {
// no-op
}

View file

@ -11,6 +11,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -38,6 +39,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.atoms.PlaybackSpeedButton
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
@ -50,7 +52,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
@ -92,7 +94,7 @@ private fun VoiceInfoRow(
onLongClick: () -> Unit,
) {
fun playPause() {
state.eventSink(VoiceMessageEvents.PlayPause)
state.eventSink(VoiceMessageEvent.PlayPause)
}
Row(
@ -112,21 +114,30 @@ private fun VoiceInfoRow(
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (state.button) {
VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.Button.Downloading -> ProgressButton()
VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false)
when (state.buttonType) {
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Downloading -> ProgressButton()
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
}
Spacer(Modifier.width(8.dp))
Text(
text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
PlaybackSpeedButton(
speed = state.playbackSpeed,
onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) },
)
Text(
text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.width(8.dp))
WaveformPlaybackView(
modifier = Modifier
@ -136,7 +147,7 @@ private fun VoiceInfoRow(
playbackProgress = state.progress,
waveform = voice.mediaInfo.waveform.orEmpty().toImmutableList(),
onSeek = {
state.eventSink(VoiceMessageEvents.Seek(it))
state.eventSink(VoiceMessageEvent.Seek(it))
},
seekEnabled = true,
)

View file

@ -75,7 +75,7 @@ class DefaultRoomGroupMessageCreator(
hasSmartReplyError = smartReplyErrors.isNotEmpty(),
shouldBing = events.any { it.noisy },
customSound = events.last().soundName,
isUpdated = events.last().isUpdated,
isUpdated = events.last().let { it.isUpdated || it.outGoingMessage },
),
threadId = threadId,
largeIcon = largeBitmap,

View file

@ -38,6 +38,8 @@
<string name="notification_room_invite_body_with_sender">"%1$s vous a invité à rejoindre le salon"</string>
<string name="notification_sender_me">"Moi"</string>
<string name="notification_sender_mention_reply">"%1$s mentionné ou en réponse"</string>
<string name="notification_space_invite_body">"Vous a invité à rejoindre lespace"</string>
<string name="notification_space_invite_body_with_sender">"%1$s vous a invité à rejoindre lespace"</string>
<string name="notification_test_push_notification_content">"Vous êtes en train de voir la notification ! Cliquez-moi !"</string>
<string name="notification_thread_in_room">"Discussion dans %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s : %2$s"</string>

View file

@ -161,6 +161,7 @@
<string name="action_static_map_load">"Cliquez pour charger la carte"</string>
<string name="action_take_photo">"Prendre une photo"</string>
<string name="action_tap_for_options">"Appuyez pour afficher les options"</string>
<string name="action_translate">"Traduire"</string>
<string name="action_try_again">"Essayer à nouveau"</string>
<string name="action_unpin">"Désépingler"</string>
<string name="action_view">"Voir"</string>
@ -235,6 +236,7 @@ Raison : %1$s."</string>
<string name="common_light">"Clair"</string>
<string name="common_line_copied_to_clipboard">"Ligne copiée dans le presse-papiers"</string>
<string name="common_link_copied_to_clipboard">"Lien copié dans le presse-papiers"</string>
<string name="common_link_new_device">"Associer un nouvel appareil"</string>
<string name="common_loading">"Chargement…"</string>
<string name="common_loading_more">"Chargement…"</string>
<plurals name="common_many_members">
@ -252,6 +254,7 @@ Raison : %1$s."</string>
<string name="common_message_removed">"Message supprimé"</string>
<string name="common_modern">"Moderne"</string>
<string name="common_mute">"Mettre en sourdine"</string>
<string name="common_name">"Nom"</string>
<string name="common_name_and_id">"%1$s (%2$s)"</string>
<string name="common_no_results">"Aucun résultat"</string>
<string name="common_no_room_name">"Salon sans nom"</string>
@ -326,6 +329,7 @@ Raison : %1$s."</string>
<string name="common_something_went_wrong">"Une erreur sest produite"</string>
<string name="common_something_went_wrong_message">"Nous avons rencontré un problème. Veuillez réessayer."</string>
<string name="common_space">"Espace"</string>
<string name="common_space_topic_placeholder">"Quel est le sujet de cet espace ?"</string>
<plurals name="common_spaces">
<item quantity="one">"%1$d Espace"</item>
<item quantity="other">"%1$d Espaces"</item>
@ -370,7 +374,7 @@ Raison : %1$s."</string>
<string name="common_waiting">"En attente…"</string>
<string name="common_waiting_for_decryption_key">"En attente de la clé de déchiffrement"</string>
<string name="common_you">"Vous"</string>
<string name="crypto_history_visible">"Les messages que vous enverrez seront partagés avec les nouveaux membres invités dans ce salon. %1$s"</string>
<string name="crypto_history_visible">"Ce salon a été configuré pour que les nouveaux membres puissent lire lhistorique. %1$s"</string>
<string name="crypto_identity_change_pin_violation">"Lidentité de %1$s a été réinitialisée. %2$s"</string>
<string name="crypto_identity_change_pin_violation_new">"Lidentité de %1$s %2$s a été réinitialisée. %3$s"</string>
<string name="crypto_identity_change_pin_violation_new_user_id">"(%1$s)"</string>

View file

@ -11,7 +11,7 @@
</plurals>
<string name="a11y_edit_avatar">"Uredi avatar"</string>
<string name="a11y_edit_room_address_hint">"Potpuna adresa bit će %1$s"</string>
<string name="a11y_encryption_details">"Pojedinosti šifriranja"</string>
<string name="a11y_encryption_details">"Pojedinosti o šifriranju"</string>
<string name="a11y_expand_message_text_field">"Proširi tekstno polje poruke"</string>
<string name="a11y_hide_password">"Sakrij zaporku"</string>
<string name="a11y_join_call">"Pridruži se pozivu"</string>
@ -58,7 +58,7 @@
<string name="a11y_your_avatar">"Vaš avatar"</string>
<string name="action_accept">"Prihvati"</string>
<string name="action_add_caption">"Dodaj opis"</string>
<string name="action_add_to_timeline">"Dodaj na vremensku crtu"</string>
<string name="action_add_to_timeline">"Dodaj na vremensku traku"</string>
<string name="action_back">"Natrag"</string>
<string name="action_call">"Poziv"</string>
<string name="action_cancel">"Odustani"</string>
@ -163,6 +163,7 @@
<string name="action_static_map_load">"Dodirnite za učitavanje karte"</string>
<string name="action_take_photo">"Uslikaj"</string>
<string name="action_tap_for_options">"Dodirnite za mogućnosti"</string>
<string name="action_translate">"Prevedi"</string>
<string name="action_try_again">"Pokušajte ponovno"</string>
<string name="action_unpin">"Otkvači"</string>
<string name="action_view">"Prikaz"</string>

View file

@ -8,7 +8,8 @@
package io.element.android.libraries.voiceplayer.api
sealed interface VoiceMessageEvents {
data object PlayPause : VoiceMessageEvents
data class Seek(val percentage: Float) : VoiceMessageEvents
sealed interface VoiceMessageEvent {
data object PlayPause : VoiceMessageEvent
data class Seek(val percentage: Float) : VoiceMessageEvent
data object ChangePlaybackSpeed : VoiceMessageEvent
}

View file

@ -9,13 +9,14 @@
package io.element.android.libraries.voiceplayer.api
data class VoiceMessageState(
val button: Button,
val buttonType: ButtonType,
val progress: Float,
val time: String,
val showCursor: Boolean,
val eventSink: (event: VoiceMessageEvents) -> Unit,
val playbackSpeed: Float,
val eventSink: (event: VoiceMessageEvent) -> Unit,
) {
enum class Button {
enum class ButtonType {
Play,
Pause,
Downloading,

View file

@ -14,29 +14,29 @@ open class VoiceMessageStateProvider : PreviewParameterProvider<VoiceMessageStat
override val values: Sequence<VoiceMessageState>
get() = sequenceOf(
aVoiceMessageState(
VoiceMessageState.Button.Downloading,
VoiceMessageState.ButtonType.Downloading,
progress = 0f,
time = "0:00",
),
aVoiceMessageState(
VoiceMessageState.Button.Retry,
VoiceMessageState.ButtonType.Retry,
progress = 0.5f,
time = "0:01",
),
aVoiceMessageState(
VoiceMessageState.Button.Play,
VoiceMessageState.ButtonType.Play,
progress = 1f,
time = "1:00",
showCursor = true,
),
aVoiceMessageState(
VoiceMessageState.Button.Pause,
VoiceMessageState.ButtonType.Pause,
progress = 0.2f,
time = "10:00",
showCursor = true,
),
aVoiceMessageState(
VoiceMessageState.Button.Disabled,
VoiceMessageState.ButtonType.Disabled,
progress = 0.2f,
time = "30:00",
),
@ -44,14 +44,16 @@ open class VoiceMessageStateProvider : PreviewParameterProvider<VoiceMessageStat
}
fun aVoiceMessageState(
button: VoiceMessageState.Button = VoiceMessageState.Button.Play,
buttonType: VoiceMessageState.ButtonType = VoiceMessageState.ButtonType.Play,
progress: Float = 0f,
time: String = "1:00",
showCursor: Boolean = false,
playbackSpeed: Float = 1.0f,
) = VoiceMessageState(
button = button,
buttonType = buttonType,
progress = progress,
time = time,
showCursor = showCursor,
playbackSpeed = playbackSpeed,
eventSink = {},
)

View file

@ -26,10 +26,12 @@ dependencies {
implementation(projects.libraries.di)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.mediaplayer.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiUtils)
implementation(projects.services.analytics.api)
implementation(libs.androidx.annotationjvm)
implementation(libs.androidx.datastore.preferences)
implementation(libs.coroutines.core)
testCommonDependencies(libs)

View file

@ -26,6 +26,7 @@ class DefaultVoiceMessagePresenterFactory(
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val voiceMessagePlayerFactory: VoiceMessagePlayer.Factory,
private val voicePlayerStore: VoicePlayerStore,
) : VoiceMessagePresenterFactory {
override fun createVoiceMessagePresenter(
eventId: EventId?,
@ -44,6 +45,7 @@ class DefaultVoiceMessagePresenterFactory(
return VoiceMessagePresenter(
analyticsService = analyticsService,
sessionCoroutineScope = sessionCoroutineScope,
voicePlayerStore = voicePlayerStore,
player = player,
eventId = eventId,
duration = duration,

View file

@ -79,6 +79,13 @@ interface VoiceMessagePlayer {
*/
fun seekTo(positionMs: Long)
/**
* Set the playback speed.
*
* @param speed The playback speed (e.g., 0.5f for half speed, 1.0f for normal, 2.0f for double speed)
*/
fun setPlaybackSpeed(speed: Float)
data class State(
/**
* Whether the player is ready to play.
@ -217,6 +224,10 @@ class DefaultVoiceMessagePlayer(
}
}
override fun setPlaybackSpeed(speed: Float) {
mediaPlayer.setPlaybackSpeed(speed)
}
private val MediaPlayer.State.isMyTrack: Boolean
get() = if (eventId == null) false else this.mediaId == eventId.value

View file

@ -9,11 +9,13 @@
package io.element.android.libraries.voiceplayer.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
@ -21,7 +23,7 @@ import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.ui.utils.time.formatShort
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.services.analytics.api.AnalyticsService
@ -33,6 +35,7 @@ import kotlin.time.Duration.Companion.milliseconds
class VoiceMessagePresenter(
private val analyticsService: AnalyticsService,
private val sessionCoroutineScope: CoroutineScope,
private val voicePlayerStore: VoicePlayerStore,
private val player: VoiceMessagePlayer,
private val eventId: EventId?,
private val duration: Duration,
@ -41,6 +44,7 @@ class VoiceMessagePresenter(
@Composable
override fun present(): VoiceMessageState {
val localCoroutineScope = rememberCoroutineScope()
val playerState by player.state.collectAsState(
VoiceMessagePlayer.State(
isReady = false,
@ -51,14 +55,20 @@ class VoiceMessagePresenter(
)
)
val button by remember {
val playbackSpeedIndex by voicePlayerStore.playBackSpeedIndex().collectAsState(0)
LaunchedEffect(playbackSpeedIndex) {
player.setPlaybackSpeed(VoicePlayerConfig.availablePlaybackSpeeds[playbackSpeedIndex])
}
val buttonType by remember {
derivedStateOf {
when {
eventId == null -> VoiceMessageState.Button.Disabled
playerState.isPlaying -> VoiceMessageState.Button.Pause
play.value is AsyncData.Loading -> VoiceMessageState.Button.Downloading
play.value is AsyncData.Failure -> VoiceMessageState.Button.Retry
else -> VoiceMessageState.Button.Play
eventId == null -> VoiceMessageState.ButtonType.Disabled
playerState.isPlaying -> VoiceMessageState.ButtonType.Pause
play.value is AsyncData.Loading -> VoiceMessageState.ButtonType.Downloading
play.value is AsyncData.Failure -> VoiceMessageState.ButtonType.Retry
else -> VoiceMessageState.ButtonType.Play
}
}
}
@ -85,9 +95,9 @@ class VoiceMessagePresenter(
}
}
fun handleEvent(event: VoiceMessageEvents) {
fun handleEvent(event: VoiceMessageEvent) {
when (event) {
is VoiceMessageEvents.PlayPause -> {
is VoiceMessageEvent.PlayPause -> {
if (playerState.isPlaying) {
player.pause()
} else if (playerState.isReady) {
@ -109,17 +119,23 @@ class VoiceMessagePresenter(
}
}
}
is VoiceMessageEvents.Seek -> {
is VoiceMessageEvent.Seek -> {
player.seekTo((event.percentage * duration).toLong())
}
is VoiceMessageEvent.ChangePlaybackSpeed -> localCoroutineScope.launch {
voicePlayerStore.setPlayBackSpeedIndex(
(playbackSpeedIndex + 1) % VoicePlayerConfig.availablePlaybackSpeeds.size
)
}
}
}
return VoiceMessageState(
button = button,
buttonType = buttonType,
progress = progress,
time = time,
showCursor = showCursor,
playbackSpeed = VoicePlayerConfig.availablePlaybackSpeeds[playbackSpeedIndex],
eventSink = ::handleEvent,
)
}

View file

@ -0,0 +1,14 @@
/*
* Copyright (c) 2025 Element Creations 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.voiceplayer.impl
object VoicePlayerConfig {
// Available playback speeds for voice messages, the first one is the default speed, and
// the UI will allow to change to the next speed in the list, in loop.
val availablePlaybackSpeeds = listOf(1.0f, 1.5f, 2.0f, 0.5f)
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Element Creations 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.voiceplayer.impl
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
interface VoicePlayerStore {
suspend fun setPlayBackSpeedIndex(index: Int)
fun playBackSpeedIndex(): Flow<Int>
}
@ContributesBinding(AppScope::class)
class PreferencesVoicePlayerStore(
preferenceDataStoreFactory: PreferenceDataStoreFactory,
) : VoicePlayerStore {
private val store = preferenceDataStoreFactory.create("elementx_voice_player")
private val playbackSpeedIndex = intPreferencesKey("playback_speed_index")
override fun playBackSpeedIndex(): Flow<Int> {
return store.data.map { prefs ->
prefs[playbackSpeedIndex] ?: 0
}
}
override suspend fun setPlayBackSpeedIndex(index: Int) {
store.edit { prefs ->
prefs[playbackSpeedIndex] = index
}
}
}

View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations 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.voiceplayer.impl
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
internal class InMemoryVoicePlayerStore(
defaultPlaybackSpeedIndex: Int = 0,
) : VoicePlayerStore {
private val playBackSpeedIndex = MutableStateFlow(defaultPlaybackSpeedIndex)
override fun playBackSpeedIndex(): Flow<Int> {
return playBackSpeedIndex.asStateFlow()
}
override suspend fun setPlayBackSpeedIndex(index: Int) {
playBackSpeedIndex.emit(index)
}
}

View file

@ -8,19 +8,17 @@
package io.element.android.libraries.voiceplayer.impl
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -31,11 +29,9 @@ class VoiceMessagePresenterTest {
@Test
fun `initial state has proper default values`() = runTest {
val presenter = createVoiceMessagePresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
awaitItem().let {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("1:01")
}
@ -48,29 +44,27 @@ class VoiceMessagePresenterTest {
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
duration = 2_000.milliseconds,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:02")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
initialState.eventSink(VoiceMessageEvent.PlayPause)
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Downloading)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:02")
}
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Downloading)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:00")
}
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Pause)
assertThat(it.progress).isEqualTo(0.5f)
assertThat(it.time).isEqualTo("0:01")
}
@ -86,24 +80,22 @@ class VoiceMessagePresenterTest {
analyticsService = analyticsService,
duration = 2_000.milliseconds,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:02")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
initialState.eventSink(VoiceMessageEvent.PlayPause)
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Downloading)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:02")
}
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Retry)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Retry)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:02")
}
@ -122,27 +114,25 @@ class VoiceMessagePresenterTest {
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
duration = 2_000.milliseconds,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:02")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
initialState.eventSink(VoiceMessageEvent.PlayPause)
skipItems(2) // skip downloading states
val playingState = awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Pause)
assertThat(it.progress).isEqualTo(0.5f)
assertThat(it.time).isEqualTo("0:01")
}
playingState.eventSink(VoiceMessageEvents.PlayPause)
playingState.eventSink(VoiceMessageEvent.PlayPause)
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
assertThat(it.progress).isEqualTo(0.5f)
assertThat(it.time).isEqualTo("0:01")
}
@ -154,11 +144,9 @@ class VoiceMessagePresenterTest {
val presenter = createVoiceMessagePresenter(
eventId = null,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Disabled)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Disabled)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("1:01")
}
@ -171,19 +159,17 @@ class VoiceMessagePresenterTest {
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
duration = 10_000.milliseconds,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:10")
}
initialState.eventSink(VoiceMessageEvents.Seek(0.5f))
initialState.eventSink(VoiceMessageEvent.Seek(0.5f))
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
assertThat(it.progress).isEqualTo(0.5f)
assertThat(it.time).isEqualTo("0:05")
}
@ -195,40 +181,66 @@ class VoiceMessagePresenterTest {
val presenter = createVoiceMessagePresenter(
duration = 10_000.milliseconds,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
presenter.test {
val initialState = awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
assertThat(it.progress).isEqualTo(0f)
assertThat(it.time).isEqualTo("0:10")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
initialState.eventSink(VoiceMessageEvent.PlayPause)
skipItems(2) // skip downloading states
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Pause)
assertThat(it.progress).isEqualTo(0.1f)
assertThat(it.time).isEqualTo("0:01")
it.eventSink(VoiceMessageEvent.Seek(0.5f))
}
initialState.eventSink(VoiceMessageEvents.Seek(0.5f))
awaitItem().also {
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Pause)
assertThat(it.progress).isEqualTo(0.5f)
assertThat(it.time).isEqualTo("0:05")
}
}
}
@Test
fun `changing playback speed cycles through available speeds`() = runTest {
val presenter = createVoiceMessagePresenter(
duration = 10_000.milliseconds,
)
presenter.test {
awaitItem().also {
assertThat(it.playbackSpeed).isEqualTo(1.0f)
it.eventSink(VoiceMessageEvent.ChangePlaybackSpeed)
}
awaitItem().also {
assertThat(it.playbackSpeed).isEqualTo(1.5f)
it.eventSink(VoiceMessageEvent.ChangePlaybackSpeed)
}
awaitItem().also {
assertThat(it.playbackSpeed).isEqualTo(2.0f)
it.eventSink(VoiceMessageEvent.ChangePlaybackSpeed)
}
awaitItem().also {
assertThat(it.playbackSpeed).isEqualTo(0.5f)
it.eventSink(VoiceMessageEvent.ChangePlaybackSpeed)
}
awaitItem().also {
assertThat(it.playbackSpeed).isEqualTo(1.0f)
}
}
}
}
fun TestScope.createVoiceMessagePresenter(
mediaPlayer: FakeMediaPlayer = FakeMediaPlayer(),
voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(),
analyticsService: AnalyticsService = FakeAnalyticsService(),
voicePlayerStore: VoicePlayerStore = InMemoryVoicePlayerStore(),
eventId: EventId? = EventId("\$anEventId"),
filename: String = "filename doesn't really matter for a voice message",
duration: Duration = 61_000.milliseconds,
@ -246,6 +258,7 @@ fun TestScope.createVoiceMessagePresenter(
mimeType = mimeType,
filename = filename
),
voicePlayerStore = voicePlayerStore,
eventId = eventId,
duration = duration,
)