mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:55:41 +00:00
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:
+8
-1
@@ -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
|
||||
}
|
||||
|
||||
+44
-5
@@ -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,
|
||||
|
||||
+19
-19
@@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ fun interface MediaOptimizationSelectorPresenter : Presenter<MediaOptimizationSe
|
||||
interface Factory {
|
||||
fun create(
|
||||
localMedia: LocalMedia,
|
||||
sendAsFile: Boolean,
|
||||
): MediaOptimizationSelectorPresenter
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
-1
@@ -30,7 +30,6 @@ interface VideoMetadataExtractor : AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@AssistedInject
|
||||
class DefaultVideoMetadataExtractor(
|
||||
@ApplicationContext private val context: Context,
|
||||
|
||||
+4
-3
@@ -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)
|
||||
|
||||
|
||||
-5
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+15
-1
@@ -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,
|
||||
|
||||
+68
-42
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+7
-1
@@ -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 -> {
|
||||
|
||||
+14
-2
@@ -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
|
||||
}
|
||||
|
||||
+3
-5
@@ -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 },
|
||||
|
||||
+16
-7
@@ -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,
|
||||
|
||||
+11
-1
@@ -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>
|
||||
|
||||
+2
-1
@@ -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(
|
||||
|
||||
+82
-1
@@ -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,
|
||||
|
||||
+69
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+92
@@ -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)
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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,
|
||||
)
|
||||
|
||||
+106
@@ -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")!!,
|
||||
)
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user