Merge branch 'develop' into feature/fga/space_members_access
This commit is contained in:
commit
0668135d0e
215 changed files with 2349 additions and 1664 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ac84a7175c4a4897aa28eddcf722b7997c6576f612eb38fa09ffabcf7be11e00
|
||||
size 119496
|
||||
oid sha256:32b12d0b26cd016a632a4cb87b71d5efcb2c0d816bf565bc90aee9963ce2d5df
|
||||
size 134117
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ fun SelectedIndicatorAtom(
|
|||
Icon(
|
||||
modifier = modifier.toggleable(
|
||||
value = true,
|
||||
role = Role.Companion.Checkbox,
|
||||
role = Role.Checkbox,
|
||||
enabled = enabled,
|
||||
onValueChange = {},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ fun TextFieldDialog(
|
|||
item {
|
||||
Text(
|
||||
text = content,
|
||||
style = ElementTheme.materialTypography.bodyMedium,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ internal fun SimpleAlertDialogContent(
|
|||
content = {
|
||||
Text(
|
||||
text = content,
|
||||
style = ElementTheme.materialTypography.bodyMedium,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
)
|
||||
},
|
||||
submitText = submitText,
|
||||
|
|
|
|||
|
|
@ -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() =
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -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(
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -159,6 +159,10 @@ class DefaultMediaPlayer(
|
|||
}
|
||||
}
|
||||
|
||||
override fun setPlaybackSpeed(speed: Float) {
|
||||
player.setPlaybackSpeed(speed)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
player.release()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -96,6 +96,10 @@ class FakeMediaPlayer(
|
|||
}
|
||||
}
|
||||
|
||||
override fun setPlaybackSpeed(speed: Float) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
// no-op
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 l’espace"</string>
|
||||
<string name="notification_space_invite_body_with_sender">"%1$s vous a invité à rejoindre l’espace"</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>
|
||||
|
|
|
|||
|
|
@ -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 s’est 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 l’historique. %1$s"</string>
|
||||
<string name="crypto_identity_change_pin_violation">"L’identité de %1$s a été réinitialisée. %2$s"</string>
|
||||
<string name="crypto_identity_change_pin_violation_new">"L’identité 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue