Merge remote-tracking branch 'upstream/develop' into feature/scroll-to-unread-messages

Resolved conflicts:
- libraries/matrix/impl/.../fakes/FakeFfiSpaceRoomList.kt: took upstream's
  direct return for rooms() — the simulateLongTask wrapper is for IO-style
  operations like paginate/reset, not a quick read.
- libraries/ui-strings/src/main/res/values/temporary.xml: kept the file with
  only the live strings (action_mark_as_read, a11y_jump_to_unread_messages).
  Dropped common_black (upstream moved theme strings to module-local R.string)
  and a11y_jump_to_unread_messages_count (badge count was removed earlier).
- RoomInfo.kt and its fixtures/tests: took upstream's version since both
  branches independently added fullyReadEventId via the same SDK bump
  (matrix-rust-components-kotlin 26.05.20).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jenna Vassar
2026-05-21 11:26:14 -07:00
369 changed files with 4556 additions and 2075 deletions
@@ -16,5 +16,12 @@ import kotlinx.parcelize.Parcelize
@Immutable
sealed interface Attachment : Parcelable {
@Parcelize
data class Media(val localMedia: LocalMedia) : Attachment
data class Media(
val localMedia: LocalMedia,
// When true, the media was picked through the "Files" picker and should be
// uploaded without image recompression; videos still use the highest available
// / best-fit preset rather than an additional size-reduction optimization pass.
// See https://github.com/element-hq/element-x-android/issues/6365
val sendAsFile: Boolean = false,
) : Attachment
}
@@ -23,6 +23,8 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorPresenter
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.features.messages.impl.attachments.video.VideoCompressionPresetSelector
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.androidutils.hash.hash
@@ -61,6 +63,7 @@ class AttachmentsPreviewPresenter(
private val permalinkBuilder: PermalinkBuilder,
private val temporaryUriDeleter: TemporaryUriDeleter,
private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory,
private val videoCompressionPresetSelector: VideoCompressionPresetSelector,
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
@@ -96,7 +99,10 @@ class AttachmentsPreviewPresenter(
val mediaAttachment = attachment as Attachment.Media
val mediaOptimizationSelectorPresenter = remember {
mediaOptimizationSelectorPresenterFactory.create(mediaAttachment.localMedia)
mediaOptimizationSelectorPresenterFactory.create(
localMedia = mediaAttachment.localMedia,
sendAsFile = mediaAttachment.sendAsFile,
)
}
val mediaOptimizationSelectorState by rememberUpdatedState(mediaOptimizationSelectorPresenter.present())
@@ -104,14 +110,25 @@ class AttachmentsPreviewPresenter(
var displayFileTooLargeError by remember { mutableStateOf(false) }
LaunchedEffect(mediaOptimizationSelectorState.displayMediaSelectorViews) {
LaunchedEffect(
mediaOptimizationSelectorState.displayMediaSelectorViews,
mediaOptimizationSelectorState.videoSizeEstimations,
) {
// If the media optimization selector is not displayed, we can pre-process the media
// to prepare it for sending. This is done to avoid blocking the UI thread when the
// user clicks on the send button.
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false) {
preprocessMediaJob = preProcessAttachment(
if (mediaOptimizationSelectorState.displayMediaSelectorViews == false && preprocessMediaJob == null) {
if (mediaAttachment.localMedia.info.mimeType.isMimeTypeVideo() && mediaOptimizationSelectorState.videoSizeEstimations.dataOrNull() == null) {
Timber.d("Waiting for video size estimations to be able to select the best video compression preset before pre-processing the media")
return@LaunchedEffect
}
val config = getAutoPreprocessMediaOptimizationConfig(
mediaAttachment = mediaAttachment,
mediaOptimizationSelectorState = mediaOptimizationSelectorState,
) ?: return@LaunchedEffect
preprocessMediaJob = coroutineScope.preProcessAttachment(
attachment = attachment,
mediaOptimizationConfig = mediaOptimizationConfigProvider.get(),
mediaOptimizationConfig = config,
displayProgress = false,
sendActionState = sendActionState,
)
@@ -233,6 +250,28 @@ class AttachmentsPreviewPresenter(
)
}
private suspend fun getAutoPreprocessMediaOptimizationConfig(
mediaAttachment: Attachment.Media,
mediaOptimizationSelectorState: MediaOptimizationSelectorState,
): MediaOptimizationConfig? {
return if (mediaAttachment.sendAsFile) {
// If we're sending the media as a file, we can skip image compression and we should select the highest video compression preset that still fits
// the upload limit (if the estimations are available)
val videoCompressionPreset = videoCompressionPresetSelector.selectBestVideoPreset(
expectedVideoPreset = VideoCompressionPreset.HIGH,
videoSizeEstimations = mediaOptimizationSelectorState.videoSizeEstimations,
).dataOrNull() ?: VideoCompressionPreset.HIGH
MediaOptimizationConfig(
compressImages = false,
videoCompressionPreset = videoCompressionPreset,
)
} else {
// Otherwise, we just rely on the user preferences for media optimization
mediaOptimizationConfigProvider.get()
}
}
private fun CoroutineScope.preProcessAttachment(
attachment: Attachment,
mediaOptimizationConfig: MediaOptimizationConfig,
@@ -37,9 +37,11 @@ import kotlin.math.roundToLong
@AssistedInject
class DefaultMediaOptimizationSelectorPresenter(
@Assisted private val localMedia: LocalMedia,
@Assisted private val sendAsFile: Boolean,
private val maxUploadSizeProvider: MaxUploadSizeProvider,
private val featureFlagService: FeatureFlagService,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val videoCompressionPresetSelector: VideoCompressionPresetSelector,
mediaExtractorFactory: VideoMetadataExtractor.Factory,
) : MediaOptimizationSelectorPresenter {
@ContributesBinding(SessionScope::class)
@@ -47,6 +49,7 @@ class DefaultMediaOptimizationSelectorPresenter(
interface Factory : MediaOptimizationSelectorPresenter.Factory {
override fun create(
localMedia: LocalMedia,
sendAsFile: Boolean,
): DefaultMediaOptimizationSelectorPresenter
}
@@ -55,7 +58,9 @@ class DefaultMediaOptimizationSelectorPresenter(
@Composable
override fun present(): MediaOptimizationSelectorState {
val displayMediaSelectorViews by produceState<Boolean?>(null) {
value = featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality)
// When sending as a raw file, never show the optimization selector: images skip
// recompression, while videos use the highest available best-fit preset.
value = !sendAsFile && featureFlagService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality)
}
var displayVideoPresetSelectorDialog by remember { mutableStateOf(false) }
@@ -123,12 +128,23 @@ class DefaultMediaOptimizationSelectorPresenter(
var selectedVideoOptimizationPreset by remember { mutableStateOf<AsyncData<VideoCompressionPreset>>(AsyncData.Loading()) }
LaunchedEffect(videoSizeEstimations.dataOrNull()) {
if (sendAsFile) {
// Send-as-file path: pin to no image compression, and pick the highest-quality
// video preset that still fits the upload limit (we have no true "do not re-encode
// video" path in the pre-processor right now).
selectedImageOptimization = AsyncData.Success(false)
selectedVideoOptimizationPreset = videoCompressionPresetSelector.selectBestVideoPreset(
expectedVideoPreset = VideoCompressionPreset.HIGH,
videoSizeEstimations = videoSizeEstimations,
)
return@LaunchedEffect
}
val mediaOptimizationConfig = mediaOptimizationConfigProvider.get()
selectedImageOptimization = AsyncData.Success(mediaOptimizationConfig.compressImages)
// Find the best video preset based on the default preset and the video size estimations
// Since the estimation for the current preset may be way too large to upload, we check the ones that provide lower file sizes
selectedVideoOptimizationPreset = findBestVideoPreset(
defaultVideoPreset = mediaOptimizationConfig.videoCompressionPreset,
selectedVideoOptimizationPreset = videoCompressionPresetSelector.selectBestVideoPreset(
expectedVideoPreset = mediaOptimizationConfig.videoCompressionPreset,
videoSizeEstimations = videoSizeEstimations,
)
}
@@ -176,20 +192,4 @@ class DefaultMediaOptimizationSelectorPresenter(
eventSink = ::handleEvent,
)
}
private fun findBestVideoPreset(
defaultVideoPreset: VideoCompressionPreset,
videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>>,
): AsyncData<VideoCompressionPreset> {
val estimations = videoSizeEstimations.dataOrNull() ?: return AsyncData.Loading()
// This will find the best video preset that can be used to produce a video that can be uploaded
val bestEstimation = estimations.find { it.preset.ordinal >= defaultVideoPreset.ordinal && it.canUpload }?.preset
return if (bestEstimation != null) {
AsyncData.Success(bestEstimation)
} else {
AsyncData.Failure(
IllegalStateException("No suitable video preset found for default preset: $defaultVideoPreset")
)
}
}
}
@@ -15,6 +15,7 @@ fun interface MediaOptimizationSelectorPresenter : Presenter<MediaOptimizationSe
interface Factory {
fun create(
localMedia: LocalMedia,
sendAsFile: Boolean,
): MediaOptimizationSelectorPresenter
}
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2026 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.features.messages.impl.attachments.video
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.collections.immutable.ImmutableList
@Inject
class VideoCompressionPresetSelector {
fun selectBestVideoPreset(
expectedVideoPreset: VideoCompressionPreset,
videoSizeEstimations: AsyncData<ImmutableList<VideoUploadEstimation>>,
): AsyncData<VideoCompressionPreset> {
val estimations = videoSizeEstimations.dataOrNull() ?: return AsyncData.Loading()
val bestEstimation = estimations.find { it.preset.ordinal >= expectedVideoPreset.ordinal && it.canUpload }?.preset
return if (bestEstimation != null) {
AsyncData.Success(bestEstimation)
} else {
AsyncData.Failure(
IllegalStateException("No suitable video preset found for expected preset: $expectedVideoPreset")
)
}
}
}
@@ -30,7 +30,6 @@ interface VideoMetadataExtractor : AutoCloseable {
}
}
@ContributesBinding(AppScope::class)
@AssistedInject
class DefaultVideoMetadataExtractor(
@ApplicationContext private val context: Context,
@@ -179,7 +179,7 @@ class MessageComposerPresenter(
handlePickedMedia(uri, mimeType)
}
val filesPicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType ->
handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream)
handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true)
}
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
handlePickedMedia(uri, MimeTypes.Jpeg)
@@ -571,7 +571,7 @@ class MessageComposerPresenter(
notificationConversationService.onSendMessage(
sessionId = room.sessionId,
roomId = roomInfo.id,
roomName = roomInfo.name ?: roomInfo.id.value,
roomName = roomInfo.name,
roomIsDirect = roomInfo.isDm,
roomAvatarUrl = roomInfo.avatarUrl ?: roomMembers.getDirectRoomMember(roomInfo = roomInfo, sessionId = room.sessionId)?.avatarUrl,
)
@@ -605,6 +605,7 @@ class MessageComposerPresenter(
private fun handlePickedMedia(
uri: Uri?,
mimeType: String? = null,
sendAsFile: Boolean = false,
) {
uri ?: return
val localMedia = localMediaFactory.createFromUri(
@@ -613,7 +614,7 @@ class MessageComposerPresenter(
name = null,
formattedFileSize = null
)
val mediaAttachment = Attachment.Media(localMedia)
val mediaAttachment = Attachment.Media(localMedia, sendAsFile = sendAsFile)
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
navigator.navigateToPreviewAttachments(persistentListOf(mediaAttachment), inReplyToEventId)
@@ -67,7 +67,6 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.alias.matches
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.ui.utils.a11y.hasExternalKeyboard
import io.element.android.libraries.ui.utils.a11y.isTalkbackActive
import io.element.android.services.analytics.api.AnalyticsService
@@ -88,7 +87,6 @@ class ThreadedMessagesNode(
private val presenterFactory: MessagesPresenter.Factory,
private val actionListPresenterFactory: ActionListPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
private val permalinkParser: PermalinkParser,
private val appNavigationStateService: AppNavigationStateService,
private val roomMemberModerationRenderer: RoomMemberModerationRenderer,
@@ -157,9 +155,6 @@ class ThreadedMessagesNode(
onStop = {
appNavigationStateService.onLeavingThread(id)
},
onDestroy = {
mediaPlayer.close()
}
)
}
@@ -31,6 +31,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
@@ -79,7 +82,18 @@ fun ThreadsListView(
topBar = {
TopAppBar(
title = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
val description = stringResource(
CommonStrings.a11y_threads_in_room,
state.roomName,
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clearAndSetSemantics {
heading()
contentDescription = description
},
) {
Avatar(
avatarData = AvatarData(
id = state.roomId.value,
@@ -8,6 +8,7 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.annotation.StringRes
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
@@ -22,17 +23,19 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -42,6 +45,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun TimelineItemCallNotifyView(
timelineRoomInfo: TimelineRoomInfo,
event: TimelineItem.Event,
content: TimelineItemRtcNotificationContent,
onLongClick: (TimelineItem.Event) -> Unit,
@@ -62,37 +66,22 @@ internal fun TimelineItemCallNotifyView(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
avatarData = event.senderAvatar,
avatarType = AvatarType.User,
Icon(
modifier = Modifier.size(20.sp.toDp()),
imageVector = getIcon(timelineRoomInfo, content),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = event.safeSenderName,
style = ElementTheme.typography.fontBodyLgMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
modifier = Modifier.size(20.sp.toDp()),
imageVector =
if (content.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallSolid() else CompoundIcons.VideoCallSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
)
Text(
text = stringResource(CommonStrings.common_call_started),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Text(
modifier = Modifier.weight(1f),
text = stringResource(getTextRes(timelineRoomInfo, content)),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = event.sentTime,
style = ElementTheme.typography.fontBodyMdRegular,
@@ -103,19 +92,56 @@ internal fun TimelineItemCallNotifyView(
}
}
@StringRes
private fun getTextRes(
timelineRoomInfo: TimelineRoomInfo,
content: TimelineItemRtcNotificationContent
): Int = if (timelineRoomInfo.isDm) {
when (content.state) {
is RtcNotificationState.Declined -> {
if (content.state.byMe) CommonStrings.common_call_you_declined else CommonStrings.common_call_declined
}
RtcNotificationState.Started -> CommonStrings.common_call_started
}
} else {
// In Rooms, do not show declined info.
CommonStrings.common_call_started
}
@Composable
private fun getIcon(
timelineRoomInfo: TimelineRoomInfo,
content: TimelineItemRtcNotificationContent
): ImageVector {
val showAsDeclined = timelineRoomInfo.isDm && content.state is RtcNotificationState.Declined
val icon = if (showAsDeclined) {
if (content.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallDeclinedSolid() else CompoundIcons.VideoCallDeclinedSolid()
} else {
if (content.callIntent == CallIntent.AUDIO) CompoundIcons.VoiceCallSolid() else CompoundIcons.VideoCallSolid()
}
return icon
}
@PreviewsDayNight
@Composable
internal fun TimelineItemCallNotifyViewPreview() = ElementPreview {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
listOf(
TimelineItemRtcNotificationContent(CallIntent.AUDIO),
TimelineItemRtcNotificationContent(CallIntent.VIDEO),
).forEach { content ->
TimelineItemCallNotifyView(
event = aTimelineItemEvent(content = content),
content = content,
onLongClick = {},
)
Column(modifier = Modifier.padding(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
listOf(false, true).forEach { isDm ->
listOf(CallIntent.AUDIO, CallIntent.VIDEO).forEach { callIntent ->
listOf(
RtcNotificationState.Started,
RtcNotificationState.Declined(byMe = false),
RtcNotificationState.Declined(byMe = true),
).forEach { state ->
val content = TimelineItemRtcNotificationContent(callIntent, state)
TimelineItemCallNotifyView(
timelineRoomInfo = aTimelineRoomInfo(isDm = isDm),
event = aTimelineItemEvent(content = content),
content = content,
onLongClick = {},
)
}
}
}
}
}
@@ -125,6 +125,7 @@ internal fun TimelineItemRow(
is TimelineItemRtcNotificationContent -> {
TimelineItemCallNotifyView(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp),
timelineRoomInfo = timelineRoomInfo,
event = timelineItem,
content = timelineItem.content,
onLongClick = onLongClick,
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.timeline.factories.event
import dev.zacsweers.metro.Inject
import io.element.android.features.location.api.Location
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@@ -104,7 +105,12 @@ class TimelineItemContentFactory(
is PollContent -> pollFactory.create(eventId, isEditable, isOutgoing, itemContent)
is UnableToDecryptContent -> utdFactory.create(itemContent)
is CallNotifyContent -> TimelineItemRtcNotificationContent(
itemContent.callIntent
callIntent = itemContent.callIntent,
state = if (itemContent.declinedBy.isEmpty()) {
RtcNotificationState.Started
} else {
RtcNotificationState.Declined(itemContent.declinedBy.any { it == sessionId })
}
)
is UnknownContent -> TimelineItemUnknownContent
is LiveLocationContent -> {
@@ -9,7 +9,19 @@
package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
class TimelineItemRtcNotificationContent(val callIntent: CallIntent) : TimelineItemEventContent {
override val type: String = "org.matrix.msc4075.rtc.notification"
// State of the call, for now only isDeclined but in the future could be missed, active.
sealed interface RtcNotificationState {
/** Some users have declined, byMe indicates if the current user is one of them. */
data class Declined(val byMe: Boolean) : RtcNotificationState
object Started : RtcNotificationState
}
class TimelineItemRtcNotificationContent(
val callIntent: CallIntent,
val state: RtcNotificationState,
) : TimelineItemEventContent {
override val type: String = EventType.RTC_NOTIFICATION
}
@@ -80,7 +80,8 @@ internal fun MessagesViewTopBar(
Row(
modifier = Modifier
.clip(roundedCornerShape)
.clickable { onRoomDetailsClick() },
.clickable { onRoomDetailsClick() }
.semantics { heading() },
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -158,10 +159,7 @@ private fun RoomAvatarAndNameRow(
)
Text(
modifier = Modifier
.padding(start = 8.dp)
.semantics {
heading()
},
.padding(start = 8.dp),
text = roomName ?: stringResource(CommonStrings.common_no_room_name),
style = ElementTheme.typography.fontBodyLgMedium,
fontStyle = FontStyle.Italic.takeIf { roomName == null },
@@ -17,8 +17,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -58,7 +59,18 @@ internal fun ThreadTopBar(
BackButton(onClick = onBackClick)
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
val name = roomName ?: stringResource(CommonStrings.common_no_room_name)
val description = stringResource(
CommonStrings.a11y_thread_in_room,
name,
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clearAndSetSemantics {
heading()
contentDescription = description
},
) {
Avatar(
avatarData = roomAvatarData,
avatarType = AvatarType.Room(
@@ -69,17 +81,14 @@ internal fun ThreadTopBar(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.semantics {
heading()
},
.padding(horizontal = 8.dp),
) {
Text(
text = stringResource(CommonStrings.common_thread),
style = ElementTheme.typography.fontBodyLgMedium,
)
Text(
text = roomName ?: stringResource(CommonStrings.common_no_room_name),
text = name,
style = ElementTheme.typography.fontBodySmRegular,
fontStyle = FontStyle.Italic.takeIf { roomName == null },
color = ElementTheme.colors.textSecondary,
@@ -10,6 +10,7 @@ package io.element.android.features.messages.impl.utils.messagesummary
import android.content.Context
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
@@ -56,7 +57,16 @@ class DefaultMessageSummaryFormatter(
is TimelineItemFileContent -> context.getString(CommonStrings.common_file)
is TimelineItemAudioContent -> context.getString(CommonStrings.common_audio)
is TimelineItemLegacyCallInviteContent -> context.getString(CommonStrings.common_unsupported_call)
is TimelineItemRtcNotificationContent -> context.getString(CommonStrings.common_call_started)
is TimelineItemRtcNotificationContent -> when (content.state) {
is RtcNotificationState.Declined -> {
if (content.state.byMe) {
context.getString(CommonStrings.common_call_you_declined)
} else {
context.getString(CommonStrings.common_call_declined)
}
}
RtcNotificationState.Started -> context.getString(CommonStrings.common_call_started)
}
}
// Truncate the message to a safe length to avoid crashes in Compose
.toSafeLength()
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="crypto_event_authenticity_mismatched_sender">"Odesílatel události se neshoduje s vlastníkem zařízení, které ji odeslalo."</string>
<string name="crypto_event_authenticity_not_guaranteed">"Autenticitu této zašifrované zprávy nelze na tomto zařízení zaručit."</string>
<string name="crypto_event_authenticity_not_guaranteed">"Pravost této šifrované zprávy nelze na tomto zařízení zaručit."</string>
<string name="crypto_event_authenticity_previously_verified">"Zašifrováno dříve ověřeným uživatelem."</string>
<string name="crypto_event_authenticity_sent_in_clear">"Není zašifrováno."</string>
<string name="crypto_event_authenticity_unknown_device">"Šifrováno neznámým nebo smazaným zařízením."</string>
@@ -35,7 +35,7 @@
<string name="screen_room_attachment_source_camera_video">"Salvesta video"</string>
<string name="screen_room_attachment_source_files">"Manus"</string>
<string name="screen_room_attachment_source_gallery">"Fotode ja videote galerii"</string>
<string name="screen_room_attachment_source_location">"Asukoht"</string>
<string name="screen_room_attachment_source_location">"Jaga asukohta"</string>
<string name="screen_room_attachment_source_poll">"Küsitlus"</string>
<string name="screen_room_attachment_text_formatting">"Tekstivorming"</string>
<string name="screen_room_encrypted_history_banner">"Sõnumite ajalugu pole hetkel saadaval"</string>
@@ -32,7 +32,7 @@
<string name="screen_report_content_hint">"Powód zgłoszenia treści"</string>
<string name="screen_room_attachment_source_camera">"Kamera"</string>
<string name="screen_room_attachment_source_camera_photo">"Zrób zdjęcie"</string>
<string name="screen_room_attachment_source_camera_video">"Nagraj film"</string>
<string name="screen_room_attachment_source_camera_video">"Nagraj wideo"</string>
<string name="screen_room_attachment_source_files">"Załącznik"</string>
<string name="screen_room_attachment_source_gallery">"Zdjęcia i filmy"</string>
<string name="screen_room_attachment_source_location">"Udostępnij lokalizację"</string>
@@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.crypto.sendfailure.VerifiedUser
import io.element.android.features.messages.impl.fixtures.aMessageEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.TimelineItemThreadInfo
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
@@ -1169,7 +1170,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = TimelineItemRtcNotificationContent(callIntent = CallIntent.VIDEO),
content = TimelineItemRtcNotificationContent(callIntent = CallIntent.VIDEO, state = RtcNotificationState.Started),
)
initialState.eventSink.invoke(
ActionListEvent.ComputeForMessage(
@@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.attachments.preview.Attachments
import io.element.android.features.messages.impl.attachments.preview.OnDoneListener
import io.element.android.features.messages.impl.attachments.preview.SendActionState
import io.element.android.features.messages.impl.attachments.video.MediaOptimizationSelectorState
import io.element.android.features.messages.impl.attachments.video.VideoCompressionPresetSelector
import io.element.android.features.messages.impl.attachments.video.VideoUploadEstimation
import io.element.android.features.messages.impl.fixtures.aMediaAttachment
import io.element.android.features.messages.test.attachments.video.FakeMediaOptimizationSelectorPresenterFactory
@@ -45,6 +46,7 @@ import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfig
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
@@ -548,10 +550,87 @@ class AttachmentsPreviewPresenterTest {
}
}
@Test
fun `present - sendAsFile attachment is pre-processed without image compression`() = runTest {
// Even though the user has enabled "Optimize media quality" globally, picking the file
// through the Files picker (sendAsFile = true) must skip compression. Regression test
// for https://github.com/element-hq/element-x-android/issues/6365
val mediaPreProcessor = FakeMediaPreProcessor()
val presenter = createAttachmentsPreviewPresenter(
localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()),
sendAsFile = true,
mediaPreProcessor = mediaPreProcessor,
// Selector views are hidden in the sendAsFile flow, which triggers the auto pre-process path.
displayMediaQualitySelectorViews = false,
mediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(
config = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
)
),
)
presenter.test {
consumeItemsUntilPredicate { mediaPreProcessor.processCallCount > 0 }
assertThat(mediaPreProcessor.lastMediaOptimizationConfig).isEqualTo(
MediaOptimizationConfig(
compressImages = false,
videoCompressionPreset = VideoCompressionPreset.HIGH,
)
)
}
}
@Test
fun `present - sendAsFile video is pre-processed with best fitting preset`() = runTest {
val mediaPreProcessor = FakeMediaPreProcessor()
val presenter = createAttachmentsPreviewPresenter(
localMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
sendAsFile = true,
mediaPreProcessor = mediaPreProcessor,
// Selector views are hidden in the sendAsFile flow, which triggers the auto pre-process path.
displayMediaQualitySelectorViews = false,
mediaOptimizationSelectorPresenterFactory = FakeMediaOptimizationSelectorPresenterFactory {
MediaOptimizationSelectorState(
maxUploadSize = AsyncData.Success(250_000_000L),
videoSizeEstimations = AsyncData.Success(
persistentListOf(
VideoUploadEstimation(VideoCompressionPreset.HIGH, sizeInBytes = 513_216_000L, canUpload = false),
VideoUploadEstimation(VideoCompressionPreset.STANDARD, sizeInBytes = 228_096_000L, canUpload = true),
VideoUploadEstimation(VideoCompressionPreset.LOW, sizeInBytes = 57_024_000L, canUpload = true),
)
),
isImageOptimizationEnabled = false,
selectedVideoPreset = VideoCompressionPreset.STANDARD,
displayMediaSelectorViews = false,
displayVideoPresetSelectorDialog = false,
eventSink = {},
)
},
mediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(
config = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.LOW,
)
),
)
presenter.test {
consumeItemsUntilPredicate { mediaPreProcessor.processCallCount > 0 }
assertThat(mediaPreProcessor.lastMediaOptimizationConfig).isEqualTo(
MediaOptimizationConfig(
compressImages = false,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
)
)
}
}
private fun TestScope.createAttachmentsPreviewPresenter(
localMedia: LocalMedia = aLocalMedia(
uri = mockMediaUrl,
),
sendAsFile: Boolean = false,
room: JoinedRoom = FakeJoinedRoom(),
timelineMode: Timeline.Mode = Timeline.Mode.Live,
permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(),
@@ -573,9 +652,10 @@ class AttachmentsPreviewPresenterTest {
}
),
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(),
): AttachmentsPreviewPresenter {
return AttachmentsPreviewPresenter(
attachment = aMediaAttachment(localMedia),
attachment = aMediaAttachment(localMedia, sendAsFile = sendAsFile),
onDoneListener = onDoneListener,
mediaSenderFactory = MediaSenderFactory { timelineMode ->
DefaultMediaSender(
@@ -592,6 +672,7 @@ class AttachmentsPreviewPresenterTest {
sessionCoroutineScope = this,
dispatchers = testCoroutineDispatchers(),
mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory,
videoCompressionPresetSelector = videoCompressionPresetSelector,
timelineMode = timelineMode,
inReplyToEventId = null,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
@@ -210,19 +210,88 @@ class DefaultMediaOptimizationSelectorPresenterTest {
}
}
@Test
fun `present - sendAsFile hides selector views and disables image compression for images`() = runTest {
val presenter = createDefaultMediaOptimizationSelectorPresenter(
localMedia = aLocalMedia(mockMediaUrl, anImageMediaInfo()),
// Even with the feature flag on, sendAsFile must hide the selector.
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)),
// And it must override the user's "optimize images" preference.
mediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
sendAsFile = true,
)
presenter.test {
// Initial loading state
skipItems(1)
awaitItem().run {
assertThat(displayMediaSelectorViews).isFalse()
assertThat(isImageOptimizationEnabled).isFalse()
}
}
}
@Test
fun `present - sendAsFile picks HIGH video preset when the video fits the upload limit`() = runTest {
val presenter = createDefaultMediaOptimizationSelectorPresenter(
// Plenty of room: even HIGH preset will fit.
maxUploadSizeProvider = MaxUploadSizeProvider { Result.success(Long.MAX_VALUE) },
mediaExtractorFactory = FakeVideoMetadataExtractorFactory(
FakeVideoMetadataExtractor(
sizeResult = Result.success(Size(1920, 1080)),
duration = Result.success(10.minutes)
)
),
sendAsFile = true,
)
presenter.test {
// Initial loading state, then the one with size estimations loaded.
skipItems(1)
awaitItem().run {
assertThat(displayMediaSelectorViews).isFalse()
assertThat(selectedVideoPreset).isEqualTo(VideoCompressionPreset.HIGH)
}
}
}
@Test
fun `present - sendAsFile picks lower video preset when HIGH exceeds the upload limit`() = runTest {
val presenter = createDefaultMediaOptimizationSelectorPresenter(
maxUploadSizeProvider = MaxUploadSizeProvider { Result.success(250_000_000L) },
mediaExtractorFactory = FakeVideoMetadataExtractorFactory(
FakeVideoMetadataExtractor(
sizeResult = Result.success(Size(1920, 1080)),
duration = Result.success(10.minutes)
)
),
sendAsFile = true,
)
presenter.test {
// Initial loading state, then the one with size estimations loaded.
skipItems(1)
awaitItem().run {
assertThat(displayMediaSelectorViews).isFalse()
assertThat(selectedVideoPreset).isEqualTo(VideoCompressionPreset.STANDARD)
}
}
}
private fun createDefaultMediaOptimizationSelectorPresenter(
localMedia: LocalMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()),
maxUploadSizeProvider: MaxUploadSizeProvider = MaxUploadSizeProvider { Result.success(1_000L) },
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)),
mediaExtractorFactory: FakeVideoMetadataExtractorFactory = FakeVideoMetadataExtractorFactory(),
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
videoCompressionPresetSelector: VideoCompressionPresetSelector = VideoCompressionPresetSelector(),
sendAsFile: Boolean = false,
): DefaultMediaOptimizationSelectorPresenter {
return DefaultMediaOptimizationSelectorPresenter(
localMedia = localMedia,
sendAsFile = sendAsFile,
maxUploadSizeProvider = maxUploadSizeProvider,
featureFlagService = featureFlagService,
mediaExtractorFactory = mediaExtractorFactory,
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
videoCompressionPresetSelector = videoCompressionPresetSelector,
)
}
}
@@ -0,0 +1,92 @@
/*
* Copyright (c) 2026 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.features.messages.impl.attachments.video
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.preferences.api.store.VideoCompressionPreset
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
class VideoCompressionPresetSelectorTest {
private val selector = VideoCompressionPresetSelector()
@Test
fun `selectBestVideoPreset - returns expected preset when it can upload`() {
val result = selector.selectBestVideoPreset(
expectedVideoPreset = VideoCompressionPreset.HIGH,
videoSizeEstimations = AsyncData.Success(
persistentListOf(
VideoUploadEstimation(VideoCompressionPreset.HIGH, sizeInBytes = 100, canUpload = true),
VideoUploadEstimation(VideoCompressionPreset.STANDARD, sizeInBytes = 50, canUpload = true),
VideoUploadEstimation(VideoCompressionPreset.LOW, sizeInBytes = 25, canUpload = true),
)
)
)
assertThat(result.dataOrNull()).isEqualTo(VideoCompressionPreset.HIGH)
}
@Test
fun `selectBestVideoPreset - falls back to the highest fitting preset`() {
val result = selector.selectBestVideoPreset(
expectedVideoPreset = VideoCompressionPreset.HIGH,
videoSizeEstimations = AsyncData.Success(
persistentListOf(
VideoUploadEstimation(VideoCompressionPreset.HIGH, sizeInBytes = 100, canUpload = false),
VideoUploadEstimation(VideoCompressionPreset.STANDARD, sizeInBytes = 50, canUpload = true),
VideoUploadEstimation(VideoCompressionPreset.LOW, sizeInBytes = 25, canUpload = true),
)
)
)
assertThat(result.dataOrNull()).isEqualTo(VideoCompressionPreset.STANDARD)
}
@Test
fun `selectBestVideoPreset - starts from the expected preset`() {
val result = selector.selectBestVideoPreset(
expectedVideoPreset = VideoCompressionPreset.STANDARD,
videoSizeEstimations = AsyncData.Success(
persistentListOf(
VideoUploadEstimation(VideoCompressionPreset.HIGH, sizeInBytes = 100, canUpload = true),
VideoUploadEstimation(VideoCompressionPreset.STANDARD, sizeInBytes = 50, canUpload = true),
VideoUploadEstimation(VideoCompressionPreset.LOW, sizeInBytes = 25, canUpload = true),
)
)
)
assertThat(result.dataOrNull()).isEqualTo(VideoCompressionPreset.STANDARD)
}
@Test
fun `selectBestVideoPreset - returns failure when no preset can upload`() {
val result = selector.selectBestVideoPreset(
expectedVideoPreset = VideoCompressionPreset.HIGH,
videoSizeEstimations = AsyncData.Success(
persistentListOf(
VideoUploadEstimation(VideoCompressionPreset.HIGH, sizeInBytes = 100, canUpload = false),
VideoUploadEstimation(VideoCompressionPreset.STANDARD, sizeInBytes = 50, canUpload = false),
VideoUploadEstimation(VideoCompressionPreset.LOW, sizeInBytes = 25, canUpload = false),
)
)
)
assertThat(result).isInstanceOf(AsyncData.Failure::class.java)
}
@Test
fun `selectBestVideoPreset - returns loading while estimations are missing`() {
val result = selector.selectBestVideoPreset(
expectedVideoPreset = VideoCompressionPreset.HIGH,
videoSizeEstimations = AsyncData.Loading(),
)
assertThat(result).isInstanceOf(AsyncData.Loading::class.java)
}
}
@@ -11,6 +11,7 @@ package io.element.android.features.messages.impl.fixtures
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
fun aMediaAttachment(localMedia: LocalMedia) = Attachment.Media(
fun aMediaAttachment(localMedia: LocalMedia, sendAsFile: Boolean = false) = Attachment.Media(
localMedia = localMedia,
sendAsFile = sendAsFile,
)
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2026 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.features.messages.impl.utils
import android.content.Context
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location
import io.element.android.features.messages.impl.timeline.model.event.RtcNotificationState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent.Mode
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent
import io.element.android.features.messages.impl.utils.messagesummary.DefaultMessageSummaryFormatter
import io.element.android.libraries.matrix.api.notification.CallIntent
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.timeline.aProfileDetails
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
class DefaultMessageSummaryFormatterTest {
private val formatter = DefaultMessageSummaryFormatter(
RuntimeEnvironment.getApplication() as Context
)
@Test
@Config(qualifiers = "en")
fun `format call notification started`() {
val expected = formatter.format(
TimelineItemRtcNotificationContent(
callIntent = CallIntent.VIDEO,
state = RtcNotificationState.Started
)
)
assertThat(expected).isEqualTo("Call started")
}
@Test
@Config(qualifiers = "en")
fun `format call notification declined by me`() {
val expected = formatter.format(
TimelineItemRtcNotificationContent(
callIntent = CallIntent.VIDEO,
state = RtcNotificationState.Declined(byMe = true)
)
)
assertThat(expected).isEqualTo("You declined a call")
}
@Test
@Config(qualifiers = "en")
fun `format call notification declined`() {
val expected = formatter.format(
TimelineItemRtcNotificationContent(
callIntent = CallIntent.VIDEO,
state = RtcNotificationState.Declined(byMe = false)
)
)
assertThat(expected).isEqualTo("Call declined")
}
@Test
@Config(qualifiers = "en")
fun `format live location`() {
val expected = formatter.format(
aLocationContent(isLive = true)
)
assertThat(expected).isEqualTo("Shared live location")
}
@Test
@Config(qualifiers = "en")
fun `format static location`() {
val expected = formatter.format(
aLocationContent(isLive = false)
)
assertThat(expected).isEqualTo("Shared location")
}
}
private fun aLocationContent(isLive: Boolean) = TimelineItemLocationContent(
senderId = A_USER_ID,
senderProfile = aProfileDetails(),
description = null,
assetType = null,
mode = if (isLive) {
Mode.Live(
lastKnownLocation = Location.fromGeoUri("geo:1,5"),
isActive = true,
endsAt = "",
endTimestamp = 0,
isOwnUser = true,
)
} else {
Mode.Static(
location = Location.fromGeoUri("geo:1,5")!!,
)
}
)