From 1e9898c78e1f4a8065599d25f3fbf397767d0375 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 10:21:26 +0200 Subject: [PATCH 01/31] feat(chat): Group files uploaded together into one album message Files sent from the Upload Preview Screen in one batch now get a cross-client referenceId (sha256(uploadId)-order, matching nextcloud/spreed#19040) and render as a single WhatsApp-style grouped bubble instead of one message per file - an adaptive media grid for images/videos plus a stacked list for other file types. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 14 +- .../talk/chat/viewmodels/ChatViewModel.kt | 154 ++++++- .../com/nextcloud/talk/ui/chat/ChatView.kt | 28 ++ .../talk/ui/chat/MediaGroupMessage.kt | 432 ++++++++++++++++++ .../talk/utils/message/SendMessageUtils.kt | 17 + .../talk/chat/viewmodels/ChatViewModelTest.kt | 159 +++++++ .../utils/message/SendMessageUtilsTest.kt | 60 +++ 7 files changed, 847 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt create mode 100644 app/src/test/java/com/nextcloud/talk/utils/message/SendMessageUtilsTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 9e1b0aa4829..d990b9fe6ed 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -251,6 +251,7 @@ import java.time.ZoneId import java.time.ZonedDateTime import java.util.Date import java.util.Locale +import java.util.UUID import java.util.concurrent.ExecutionException import javax.inject.Inject import java.util.concurrent.CancellationException @@ -2760,6 +2761,7 @@ class ChatActivity : } private fun uploadFiles(files: MutableList, caption: String = "", compressImages: Boolean = false) { + val uploadId = UUID.randomUUID().toString() for (i in 0 until files.size) { uploadFile( fileUri = files[i], @@ -2768,7 +2770,9 @@ class ChatActivity : roomToken = roomToken, replyToMessageId = getReplyToMessageId(), displayName = currentConversation?.displayName!!, - compressImages = compressImages + compressImages = compressImages, + uploadId = uploadId, + order = i + 1 ) } } @@ -4155,7 +4159,9 @@ class ChatActivity : roomToken: String = "", replyToMessageId: Int? = null, displayName: String, - compressImages: Boolean = false + compressImages: Boolean = false, + uploadId: String? = null, + order: Int = 1 ) { chatViewModel.uploadFile( fileUri, @@ -4164,7 +4170,9 @@ class ChatActivity : roomToken, replyToMessageId, displayName, - compressImages + compressImages, + uploadId, + order ) cancelReply() } diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 7e9ee3ca0e6..ef8bfafbb9c 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -28,6 +28,7 @@ import com.nextcloud.talk.chat.data.io.MediaRecorderManager import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.MessageStatusIcon import com.nextcloud.talk.chat.ui.model.MessageTypeContent import com.nextcloud.talk.chat.ui.model.toUiModel import com.nextcloud.talk.chat.viewmodels.ChatViewModel.Companion.POST_UPLOAD_FETCH_RETRY_DELAYS_MS @@ -70,11 +71,13 @@ import com.nextcloud.talk.ui.PlaybackSpeed import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.ConversationUtils +import com.nextcloud.talk.utils.MimetypeUtils import com.nextcloud.talk.utils.ParticipantPermissions import com.nextcloud.talk.utils.SpreedFeatures import com.nextcloud.talk.utils.UserIdUtils import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.database.user.CurrentUserProvider +import com.nextcloud.talk.utils.message.SendMessageUtils import com.nextcloud.talk.utils.preferences.AppPreferences import com.nextcloud.talk.webrtc.WebSocketConnectionHelper import dagger.assisted.Assisted @@ -129,6 +132,96 @@ import java.util.UUID import javax.inject.Inject import androidx.core.net.toUri +private const val FILE_PLACEHOLDER_MESSAGE = "{file}" +private const val VCARD_MIMETYPE = "text/vcard" + +// Matches referenceIds built by SendMessageUtils.generateGroupedReferenceId(): +// sha256(uploadId)[0:60] + "-" + order, zero-padded to 3 digits. Shared cross-client format, +// see https://github.com/nextcloud/spreed/pull/19040 +private val groupedReferenceIdRegex = Regex("^([a-f0-9]{60})-[0-9]{3}$") + +/** + * A file's referenceId marks it as part of an upload batch when it matches the cross-client + * format `sha256(uploadId)[0:60]-order`. Returns the shared hash prefix identifying the batch, + * or null if the id doesn't match (plain-text messages, or older/unrelated referenceIds). + */ +internal fun groupHash(referenceId: String?): String? = + referenceId?.let { groupedReferenceIdRegex.matchEntire(it)?.groupValues?.get(1) } + +// A run of consecutive, already-synced single-file share messages from the same author, uploaded +// together in one batch, either kept as-is (Single) or merged into one grouped "album" bubble +// (Group) - see ChatViewModel.combineFileShareGroups(). +internal sealed interface CombinedUnit { + val messages: List + + data class Single(val message: ChatMessageUi) : CombinedUnit { + override val messages: List get() = listOf(message) + } + + data class Group(override val messages: List) : CombinedUnit +} + +/** + * A plain, already-synced single-file share that can be combined with its neighbours. + * Mirrors web's isCombinableFileMessage(): excludes system/voice/geo/poll/deck/text messages + * (via the content-type check), deleted or failed messages, still-uploading placeholders (their + * content is UploadingMedia, not Media), contact cards and audio files. + */ +internal fun isCombinableFileShare(message: ChatMessageUi): Boolean { + val content = message.content as? MessageTypeContent.Media ?: return false + return !message.isDeleted && + message.statusIcon != MessageStatusIcon.FAILED && + content.mimeType != VCARD_MIMETYPE && + !MimetypeUtils.isAudioOnly(content.mimeType) +} + +/** Two file shares belong to the same batch: same reply target and same upload-batch hash. */ +internal fun canCombineFileShares(a: ChatMessageUi, b: ChatMessageUi): Boolean { + val hash = groupHash(a.referenceId) + return a.parentMessage?.id == b.parentMessage?.id && hash != null && hash == groupHash(b.referenceId) +} + +/** + * Replaces consecutive file shares uploaded together in one batch with a single grouped unit. + * A message that isn't a plain file share, a reply to a different message, or part of another + * batch interrupts the run. A file share with a real caption (not just the "{file}" placeholder) + * ends its group - the caption belongs to the group's last item, same as web. + */ +internal fun combineFileShareGroups(uiMessages: List): List { + val result = mutableListOf() + var pending = mutableListOf() + + fun flush() { + when (pending.size) { + 0 -> {} + 1 -> result.add(CombinedUnit.Single(pending[0])) + else -> result.add(CombinedUnit.Group(pending.toList())) + } + pending = mutableListOf() + } + + for (message in uiMessages) { + if (!isCombinableFileShare(message)) { + flush() + result.add(CombinedUnit.Single(message)) + continue + } + + if (pending.isNotEmpty() && !canCombineFileShares(pending.last(), message)) { + flush() + } + + pending.add(message) + + if (message.plainMessage != FILE_PLACEHOLDER_MESSAGE) { + flush() + } + } + flush() + + return result +} + @Suppress("TooManyFunctions", "LongParameterList") class ChatViewModel @AssistedInject constructor( private val logger: Logger, @@ -1185,6 +1278,7 @@ class ChatViewModel @AssistedInject constructor( // ------------------------------ // Build chat items (pure) // ------------------------------ + @Suppress("CyclomaticComplexMethod") private fun buildChatItems( uiMessages: List, lastReadMessage: Int, @@ -1203,32 +1297,47 @@ class ChatViewModel @AssistedInject constructor( Log.d(TAG, "conversation.lastReadMessage = $lastReadMessage") } - for (uiMessage in uiMessages) { - if (uiMessage.isExpandableParent) { - lastExpandableParentId = uiMessage.id + for (unit in combineFileShareGroups(uiMessages)) { + val messages = unit.messages + // The representative stands in for the whole unit for date/unread-marker purposes - + // for a group this is always its last (newest) message. Expandable system-message + // collapsing never applies to a group: isCombinableFileShare() excludes system + // messages entirely, so system-message-only checks are skipped for groups below. + val representative = messages.last() + + if (unit is CombinedUnit.Single && representative.isExpandableParent) { + lastExpandableParentId = representative.id } - if (uiMessage.isHiddenByCollapse && lastExpandableParentId !in expandedParents) { + if (unit is CombinedUnit.Single && + representative.isHiddenByCollapse && + lastExpandableParentId !in expandedParents + ) { continue } - val date = uiMessage.date + val date = representative.date if (date != lastDate) { add(ChatItem.DateHeaderItem(date)) lastDate = date } - if (!oneOrMoreMessagesWereSent && uiMessage.id == firstUnreadMessageId) { + if (!oneOrMoreMessagesWereSent && messages.any { it.id == firstUnreadMessageId }) { add(ChatItem.UnreadMessagesMarkerItem(date)) } - val adjustedMessage = if (uiMessage.isExpandableParent) { - uiMessage.copy(isExpanded = uiMessage.id in expandedParents) - } else { - uiMessage + when (unit) { + is CombinedUnit.Single -> { + val adjustedMessage = if (representative.isExpandableParent) { + representative.copy(isExpanded = representative.id in expandedParents) + } else { + representative + } + add(ChatItem.MessageItem(adjustedMessage)) + } + is CombinedUnit.Group -> add(ChatItem.MediaGroupItem(messages)) } - add(ChatItem.MessageItem(adjustedMessage)) } }.asReversed() } @@ -2057,6 +2166,7 @@ class ChatViewModel @AssistedInject constructor( fun getCurrentVoiceRecordFile(): String = mediaRecorderManager.currentVoiceRecordFile + @Suppress("LongParameterList") fun uploadFile( fileUri: String, isVoiceMessage: Boolean, @@ -2064,7 +2174,9 @@ class ChatViewModel @AssistedInject constructor( roomToken: String = "", replyToMessageId: Int? = null, displayName: String, - compressImages: Boolean = false + compressImages: Boolean = false, + uploadId: String? = null, + order: Int = 1 ) { val metaDataMap = mutableMapOf() var room = "" @@ -2091,7 +2203,7 @@ class ChatViewModel @AssistedInject constructor( metaDataMap["caption"] = caption } - val referenceId = UUID.randomUUID().toString().replace("-", "") + val referenceId = SendMessageUtils().generateGroupedReferenceId(uploadId ?: UUID.randomUUID().toString(), order) metaDataMap["referenceId"] = referenceId referenceIdSendSequence[referenceId] = nextSendSequenceValue++ @@ -2511,7 +2623,13 @@ class ChatViewModel @AssistedInject constructor( } sealed interface ChatItem { - fun messageOrNull(): ChatMessageUi? = (this as? MessageItem)?.uiMessage + // For a MediaGroupItem, the last (newest) message stands in as the representative - + // matches how the grouped bubble itself is anchored (see MediaGroupItem docs below). + fun messageOrNull(): ChatMessageUi? = when (this) { + is MessageItem -> uiMessage + is MediaGroupItem -> messages.lastOrNull() + else -> null + } fun dateOrNull(): LocalDate? = (this as? DateHeaderItem)?.date fun stableKey(): Any = @@ -2522,12 +2640,20 @@ class ChatViewModel @AssistedInject constructor( // re-inserting a new one - which is what caused the visible flicker/pop. is MessageItem -> uiMessage.referenceId?.takeIf { it.isNotBlank() }?.let { "msg_ref_$it" } ?: "msg_${uiMessage.id}" + is MediaGroupItem -> "msg_group_${groupHash(messages.first().referenceId) ?: messages.first().id}" is DateHeaderItem -> "header_$date" is UnreadMessagesMarkerItem -> "last_read_$date" is LoadGapItem -> "load_gap_$anchorMessageId" } data class MessageItem(val uiMessage: ChatMessageUi) : ChatItem + + // A run of consecutive, already-synced single-file share messages from the same author, + // uploaded together in one batch (matching referenceId hash, see groupHash()), rendered as + // one grouped "album" bubble instead of N separate bubbles. Ordered chronologically - + // messages.last() is the newest and stands in for the group wherever a single representative + // message is needed (bubble anchor, reactions, read status, pagination anchor id). + data class MediaGroupItem(val messages: List) : ChatItem data class DateHeaderItem(val date: LocalDate) : ChatItem data class UnreadMessagesMarkerItem(val date: LocalDate) : ChatItem data class LoadGapItem(val anchorMessageId: Int) : ChatItem diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt index 78e2055334f..475efca8537 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -433,6 +433,34 @@ fun ChatView( } } + is ChatViewModel.ChatItem.MediaGroupItem -> { + Box( + modifier = Modifier.padding( + top = if (!chatItem.messages.first().isGrouped) 4.dp else 0.dp + ) + ) { + MediaGroupMessage( + messages = chatItem.messages, + context = ChatMessageContext( + isOneToOneConversation = state.isOneToOneConversation, + conversationThreadId = state.conversationThreadId, + hasChatPermission = state.hasChatPermission, + downloadingFileState = state.downloadingFileState + ), + callbacks = ChatMessageCallbacks( + onLongClick = callbacks.messageCallbacks.onLongClick, + onSwipeReply = callbacks.messageCallbacks.onSwipeReply, + onFileClick = callbacks.messageCallbacks.onFileClick, + onReactionClick = callbacks.messageCallbacks.onReactionClick, + onReactionLongClick = callbacks.messageCallbacks.onReactionLongClick, + onOpenThreadClick = callbacks.messageCallbacks.onOpenThreadClick, + onQuotedMessageClick = handleQuotedMessageClick, + onAvatarClick = callbacks.messageCallbacks.onAvatarClick + ) + ) + } + } + is ChatViewModel.ChatItem.DateHeaderItem -> { Box(modifier = Modifier.padding(top = 6.dp, start = 12.dp, end = 12.dp)) { DateHeader(chatItem.date) diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt new file mode 100644 index 00000000000..c3c83a0c5f2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt @@ -0,0 +1,432 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2017-2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui.chat + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.rememberAsyncImagePainter +import com.nextcloud.talk.R +import com.nextcloud.talk.chat.data.model.FileParameters +import com.nextcloud.talk.chat.data.model.decodeBlurhashPlaceholder +import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.MessageTypeContent +import com.nextcloud.talk.contacts.load +import com.nextcloud.talk.ui.theme.mimetypeIconTint +import com.nextcloud.talk.utils.DrawableUtils +import com.nextcloud.talk.utils.Mimetype +import com.nextcloud.talk.utils.MimetypeUtils + +private const val FILE_PLACEHOLDER_MESSAGE = "{file}" +private const val MAX_VISIBLE_GRID_TILES = 4 +private const val THREE_TILE_LAYOUT = 3 +private const val SQUARE_GRID_COLUMNS = 2 +private const val SQUARE_GRID_ROWS = 2 +private const val PLAY_BUTTON_ALPHA = 0.45f +private const val OVERFLOW_SCRIM_ALPHA = 0.45f +private const val OVERFLOW_TEXT_SIZE = 20 + +private val gridSpacing = 2.dp +private val gridTileShape = RoundedCornerShape(4.dp) +private val singleTileHeight = 220.dp +private val rowTileHeight = 160.dp +private val threeTileRowHeight = 200.dp +private val squareGridHeight = 220.dp +private val genericIconSize = 40.dp +private val gridPlayButtonSize = 36.dp +private val gridPlayIconSize = 20.dp + +/** + * Renders a run of file-share messages uploaded together in one batch (see + * ChatViewModel.MediaGroupItem) as a single WhatsApp-style "album" bubble: image/video attachments + * as an adaptive thumbnail grid, any other file types as a stacked list of rows below it. The + * bubble itself is anchored on the group's last (newest) message, same as a single MediaMessage - + * its timestamp, reactions, read status, reply target and long-press/swipe-reply all target that + * representative message, same as web bases its combined message on the group's last real message. + */ +@Suppress("Detekt.LongMethod") +@Composable +fun MediaGroupMessage( + messages: List, + context: ChatMessageContext = ChatMessageContext(), + callbacks: ChatMessageCallbacks = ChatMessageCallbacks() +) { + val representative = messages.last() + val mediaItems = messages.filter { it.isPreviewableMedia() } + val fileItems = messages.filterNot { it.isPreviewableMedia() } + + val hasExplicitCaption = representative.plainMessage != FILE_PLACEHOLDER_MESSAGE + val captionText = if (hasExplicitCaption) representative.message else null + + CompositionLocalProvider( + LocalMessageLongClickHandler provides { id -> callbacks.onLongClick?.invoke(id) ?: Unit }, + LocalReactionClickHandler provides callbacks.onReactionClick, + LocalReactionLongClickHandler provides callbacks.onReactionLongClick, + LocalOpenThreadHandler provides callbacks.onOpenThreadClick, + LocalQuotedMessageClickHandler provides callbacks.onQuotedMessageClick, + LocalAvatarClickHandler provides callbacks.onAvatarClick + ) { + SwipeToReplyContainer( + replyable = representative.replyable && context.hasChatPermission, + onSwipeReply = { callbacks.onSwipeReply?.invoke(representative.id) } + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + indication = ripple(), + interactionSource = remember { MutableInteractionSource() }, + onClick = { callbacks.onLongClick?.invoke(representative.id) }, + onDoubleClick = { callbacks.onLongClick?.invoke(representative.id) }, + onLongClick = { callbacks.onLongClick?.invoke(representative.id) } + ) + ) { + Box(modifier = Modifier.padding(horizontal = 12.dp)) { + MessageScaffold( + uiMessage = representative, + isOneToOneConversation = context.isOneToOneConversation, + conversationThreadId = context.conversationThreadId, + includePadding = mediaItems.isEmpty(), + captionText = captionText, + forceTimeOverlay = captionText == null && fileItems.isEmpty(), + content = { + Column { + if (mediaItems.isNotEmpty()) { + MediaGrid( + items = mediaItems, + chatViewDownloadingFileState = context.downloadingFileState, + onItemClick = callbacks.onFileClick + ) + } + if (fileItems.isNotEmpty()) { + Column( + modifier = Modifier.padding( + horizontal = 8.dp, + vertical = if (mediaItems.isNotEmpty()) 4.dp else 0.dp + ) + ) { + fileItems.forEach { message -> + GroupedFileRow( + message = message, + onClick = { callbacks.onFileClick(message.id) } + ) + } + } + } + } + } + ) + } + } + } + } +} + +private fun ChatMessageUi.isPreviewableMedia(): Boolean { + val mimeType = (content as? MessageTypeContent.Media)?.mimeType.orEmpty() + return mimeType.startsWith(Mimetype.IMAGE_PREFIX) || mimeType.startsWith(Mimetype.VIDEO_PREFIX) +} + +@Suppress("Detekt.LongMethod") +@Composable +private fun MediaGrid( + items: List, + chatViewDownloadingFileState: List, + onItemClick: (Int) -> Unit +) { + val visible = items.take(MAX_VISIBLE_GRID_TILES) + val overflowCount = items.size - visible.size + + when (visible.size) { + 1 -> { + val message = visible[0] + MediaGridTile( + message = message, + chatViewDownloadingFileState = chatViewDownloadingFileState, + modifier = Modifier + .fillMaxWidth() + .height(singleTileHeight) + .clip(gridTileShape), + onClick = { onItemClick(message.id) } + ) + } + + 2 -> { + Row( + modifier = Modifier.fillMaxWidth().height(rowTileHeight), + horizontalArrangement = Arrangement.spacedBy(gridSpacing) + ) { + visible.forEach { message -> + MediaGridTile( + message = message, + chatViewDownloadingFileState = chatViewDownloadingFileState, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clip(gridTileShape), + onClick = { onItemClick(message.id) } + ) + } + } + } + + THREE_TILE_LAYOUT -> { + Row( + modifier = Modifier.fillMaxWidth().height(threeTileRowHeight), + horizontalArrangement = Arrangement.spacedBy(gridSpacing) + ) { + val big = visible[0] + MediaGridTile( + message = big, + chatViewDownloadingFileState = chatViewDownloadingFileState, + modifier = Modifier + .weight(2f) + .fillMaxHeight() + .clip(gridTileShape), + onClick = { onItemClick(big.id) } + ) + Column( + modifier = Modifier.weight(1f).fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(gridSpacing) + ) { + visible.drop(1).forEach { message -> + MediaGridTile( + message = message, + chatViewDownloadingFileState = chatViewDownloadingFileState, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .clip(gridTileShape), + onClick = { onItemClick(message.id) } + ) + } + } + } + } + + else -> { + Column( + modifier = Modifier.fillMaxWidth().height(squareGridHeight), + verticalArrangement = Arrangement.spacedBy(gridSpacing) + ) { + for (rowIndex in 0 until SQUARE_GRID_ROWS) { + Row( + modifier = Modifier.weight(1f).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(gridSpacing) + ) { + for (columnIndex in 0 until SQUARE_GRID_COLUMNS) { + val index = rowIndex * SQUARE_GRID_COLUMNS + columnIndex + val message = visible[index] + Box(modifier = Modifier.weight(1f).fillMaxHeight()) { + MediaGridTile( + message = message, + chatViewDownloadingFileState = chatViewDownloadingFileState, + modifier = Modifier.fillMaxWidth().fillMaxHeight().clip(gridTileShape), + onClick = { onItemClick(message.id) } + ) + if (index == MAX_VISIBLE_GRID_TILES - 1 && overflowCount > 0) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .clip(gridTileShape) + .background(Color.Black.copy(alpha = OVERFLOW_SCRIM_ALPHA)), + contentAlignment = Alignment.Center + ) { + Text( + text = "+$overflowCount", + color = Color.White, + fontSize = OVERFLOW_TEXT_SIZE.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } + } + } + } + } +} + +@Suppress("Detekt.LongMethod") +@Composable +private fun MediaGridTile( + message: ChatMessageUi, + chatViewDownloadingFileState: List, + modifier: Modifier, + onClick: () -> Unit +) { + val typeContent = message.content as MessageTypeContent.Media + val context = LocalContext.current + val fileParameters = remember(message.id) { + FileParameters(HashMap(message.messageParameters.mapValues { (_, params) -> HashMap(params) })) + } + val isVideo = typeContent.mimeType.startsWith(Mimetype.VIDEO_PREFIX) + val isGif = MimetypeUtils.isGif(typeContent.mimeType) + val showPlayButton = isVideo || (isGif && !typeContent.animateGif) + val showsGenericIcon = typeContent.previewUrl.isNullOrEmpty() + + val blurhashPainter = remember(typeContent.blurhash, typeContent.width, typeContent.height) { + decodeBlurhashPlaceholder(typeContent.blurhash, typeContent.width, typeContent.height) + ?.asImageBitmap() + ?.let { BitmapPainter(it) } + } + val fallbackPainter = painterResource(typeContent.drawableResourceId) + val loadedImage = remember(typeContent.previewUrl, typeContent.isClassified) { + if (typeContent.isClassified || typeContent.previewUrl.isNullOrEmpty()) { + null + } else { + load( + imageUri = typeContent.previewUrl, + context = context, + errorPlaceholderImage = typeContent.drawableResourceId, + animated = typeContent.animateGif + ) + } + } + + val messageLongClickHandler = LocalMessageLongClickHandler.current + val clickableModifier = modifier.combinedClickable( + onClick = onClick, + onLongClick = { messageLongClickHandler(message.id) } + ) + + Box(modifier = clickableModifier, contentAlignment = Alignment.Center) { + if (showsGenericIcon) { + if (blurhashPainter != null) { + Image( + painter = blurhashPainter, + contentDescription = null, + modifier = Modifier.fillMaxWidth().fillMaxHeight(), + contentScale = ContentScale.Crop + ) + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surfaceVariant) + ) + } + Icon( + painter = fallbackPainter, + contentDescription = stringResource(R.string.media_message_content_description), + modifier = Modifier.size(genericIconSize), + tint = mimetypeIconTint(typeContent.drawableResourceId) + ) + } else { + if (blurhashPainter != null) { + Image( + painter = blurhashPainter, + contentDescription = null, + modifier = Modifier.fillMaxWidth().fillMaxHeight(), + contentScale = ContentScale.Crop + ) + } + Image( + painter = rememberAsyncImagePainter(model = loadedImage), + contentDescription = stringResource(R.string.media_message_content_description), + modifier = Modifier.fillMaxWidth().fillMaxHeight(), + contentScale = ContentScale.Crop + ) + } + + if (showPlayButton) { + Box( + modifier = Modifier + .size(gridPlayButtonSize) + .clip(CircleShape) + .background(Color.Black.copy(alpha = PLAY_BUTTON_ALPHA)), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_baseline_play_arrow_voice_message_24), + contentDescription = stringResource(R.string.media_message_content_play), + modifier = Modifier.size(gridPlayIconSize), + tint = Color.White + ) + } + } + + if (chatViewDownloadingFileState.contains(fileParameters.id)) { + CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 2.dp) + } + } +} + +@Composable +private fun GroupedFileRow(message: ChatMessageUi, onClick: () -> Unit) { + val fileParameters = remember(message.id) { + FileParameters(HashMap(message.messageParameters.mapValues { (_, params) -> HashMap(params) })) + } + val drawableResourceId = DrawableUtils.getDrawableResourceIdForMimeType(fileParameters.mimetype) + val messageLongClickHandler = LocalMessageLongClickHandler.current + + Row( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = onClick, + onLongClick = { messageLongClickHandler(message.id) } + ) + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(drawableResourceId), + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = mimetypeIconTint(drawableResourceId) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = fileParameters.name.orEmpty(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/message/SendMessageUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/message/SendMessageUtils.kt index 6830800f92e..54a548121f0 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/message/SendMessageUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/message/SendMessageUtils.kt @@ -18,6 +18,18 @@ class SendMessageUtils { return hashBytes.joinToString("") { "%02x".format(it) } } + /** + * Builds a referenceId for a file shared as part of an upload batch, in the cross-client + * format `sha256(uploadId)[0:60]-order`, so that clients (including this one) can recognize + * files uploaded together and render them as a single grouped message. + * See https://github.com/nextcloud/spreed/pull/19040 + */ + fun generateGroupedReferenceId(uploadId: String, order: Int): String { + val digest = MessageDigest.getInstance("SHA-256") + val hashHex = digest.digest(uploadId.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) } + return hashHex.take(GROUPED_REFERENCE_ID_HASH_LENGTH) + "-" + order.toString().padStart(ORDER_PADDING, '0') + } + @Suppress("MagicNumber") fun timeOfDayMillis(timestampMillis: Long): Int { val calendar = Calendar.getInstance().apply { timeInMillis = timestampMillis } @@ -28,4 +40,9 @@ class SendMessageUtils { val millis = calendar.get(Calendar.MILLISECOND) return (hour * 3_600_000) + (minute * 60_000) + (second * 1_000) + millis } + + companion object { + private const val GROUPED_REFERENCE_ID_HASH_LENGTH = 60 + private const val ORDER_PADDING = 3 + } } diff --git a/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelTest.kt index 22a292c7e08..fef17caa58f 100644 --- a/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelTest.kt @@ -10,8 +10,10 @@ package com.nextcloud.talk.chat.viewmodels import com.nextcloud.talk.chat.ui.model.ChatMessageUi import com.nextcloud.talk.chat.ui.model.MessageStatusIcon import com.nextcloud.talk.chat.ui.model.MessageTypeContent +import com.nextcloud.talk.utils.message.SendMessageUtils import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import java.time.LocalDate @@ -59,6 +61,116 @@ class ChatViewModelTest { assertNull(ChatViewModel.findFirstUnreadMessageId(messages, lastReadMessage = 40)) } + // combineFileShareGroups(): combining batch-uploaded file shares into grouped "album" bubbles. + // Mirrors web's combineFileMessages.ts tests - see https://github.com/nextcloud/spreed/pull/19040 + + private val refUtils = SendMessageUtils() + + @Test + fun `consecutive file shares from the same batch combine into one group`() { + val uploadId = "batch-1" + val messages = listOf( + mediaMessage(1, refUtils.generateGroupedReferenceId(uploadId, 1)), + mediaMessage(2, refUtils.generateGroupedReferenceId(uploadId, 2)), + mediaMessage(3, refUtils.generateGroupedReferenceId(uploadId, 3)) + ) + + val result = combineFileShareGroups(messages) + + assertEquals(1, result.size) + assertTrue(result[0] is CombinedUnit.Group) + assertEquals(listOf(1, 2, 3), result[0].messages.map { it.id }) + } + + @Test + fun `a real caption ends the group at that message`() { + val uploadId = "batch-2" + val messages = listOf( + mediaMessage(1, refUtils.generateGroupedReferenceId(uploadId, 1)), + mediaMessage(2, refUtils.generateGroupedReferenceId(uploadId, 2), plainMessage = "check these out"), + mediaMessage(3, refUtils.generateGroupedReferenceId(uploadId, 3)) + ) + + val result = combineFileShareGroups(messages) + + assertEquals(2, result.size) + assertEquals(listOf(1, 2), (result[0] as CombinedUnit.Group).messages.map { it.id }) + assertEquals(3, (result[1] as CombinedUnit.Single).message.id) + } + + @Test + fun `a different reply target breaks the group`() { + val uploadId = "batch-3" + val parentA = mediaMessage(100, referenceId = null) + val parentB = mediaMessage(200, referenceId = null) + val messages = listOf( + mediaMessage(1, refUtils.generateGroupedReferenceId(uploadId, 1), parentMessage = parentA), + mediaMessage(2, refUtils.generateGroupedReferenceId(uploadId, 2), parentMessage = parentB) + ) + + val result = combineFileShareGroups(messages) + + assertEquals(2, result.size) + assertTrue(result.all { it is CombinedUnit.Single }) + } + + @Test + fun `an interleaved non-file message breaks the group`() { + val uploadId = "batch-4" + val messages = listOf( + mediaMessage(1, refUtils.generateGroupedReferenceId(uploadId, 1)), + uiMessage(2), + mediaMessage(3, refUtils.generateGroupedReferenceId(uploadId, 3)) + ) + + val result = combineFileShareGroups(messages) + + assertEquals(3, result.size) + assertTrue(result.all { it is CombinedUnit.Single }) + } + + @Test + fun `audio and vcard file shares never combine even from the same batch`() { + val uploadId = "batch-5" + val messages = listOf( + mediaMessage(1, refUtils.generateGroupedReferenceId(uploadId, 1), mimeType = "audio/mpeg"), + mediaMessage(2, refUtils.generateGroupedReferenceId(uploadId, 2), mimeType = "text/vcard") + ) + + val result = combineFileShareGroups(messages) + + assertEquals(2, result.size) + assertTrue(result.all { it is CombinedUnit.Single }) + } + + @Test + fun `still-uploading and failed messages are excluded from grouping`() { + val uploadId = "batch-6" + val messages = listOf( + mediaMessage(1, refUtils.generateGroupedReferenceId(uploadId, 1), isTemporary = true), + mediaMessage(2, refUtils.generateGroupedReferenceId(uploadId, 2), statusIcon = MessageStatusIcon.FAILED), + mediaMessage(3, refUtils.generateGroupedReferenceId(uploadId, 3)) + ) + + val result = combineFileShareGroups(messages) + + assertEquals(3, result.size) + assertTrue(result.all { it is CombinedUnit.Single }) + } + + @Test + fun `messages with non-grouped referenceIds never combine`() { + val messages = listOf( + mediaMessage(1, referenceId = "abcdef0123456789abcdef0123456789"), + mediaMessage(2, referenceId = "abcdef0123456789abcdef0123456789") + ) + + val result = combineFileShareGroups(messages) + + assertEquals(2, result.size) + assertTrue(result.all { it is CombinedUnit.Single }) + } + private fun uiMessage(id: Int): ChatMessageUi = ChatMessageUi( id = id, @@ -76,4 +188,51 @@ class ChatViewModelTest { date = LocalDate.of(2026, 8, 12), content = MessageTypeContent.RegularText ) + + @Suppress("LongParameterList") + private fun mediaMessage( + id: Int, + referenceId: String?, + mimeType: String = "image/jpeg", + plainMessage: String = "{file}", + parentMessage: ChatMessageUi? = null, + isDeleted: Boolean = false, + statusIcon: MessageStatusIcon = MessageStatusIcon.SENT, + isTemporary: Boolean = false + ): ChatMessageUi { + val content = if (isTemporary) { + MessageTypeContent.UploadingMedia( + localFileUri = "file://local", + fileName = "file-$id", + caption = null, + mimeType = mimeType, + drawableResourceId = 0 + ) + } else { + MessageTypeContent.Media( + previewUrl = null, + drawableResourceId = 0, + mimeType = mimeType + ) + } + return ChatMessageUi( + id = id, + message = plainMessage, + plainMessage = plainMessage, + renderMarkdown = false, + actorDisplayName = "Other User", + isThread = false, + threadTitle = "", + threadReplies = 0, + incoming = true, + isDeleted = isDeleted, + avatarUrl = null, + statusIcon = statusIcon, + timestamp = id.toLong(), + date = LocalDate.of(2026, 8, 12), + content = content, + parentMessage = parentMessage, + referenceId = referenceId + ) + } } diff --git a/app/src/test/java/com/nextcloud/talk/utils/message/SendMessageUtilsTest.kt b/app/src/test/java/com/nextcloud/talk/utils/message/SendMessageUtilsTest.kt new file mode 100644 index 00000000000..c8de400a935 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/message/SendMessageUtilsTest.kt @@ -0,0 +1,60 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils.message + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SendMessageUtilsTest { + + private val sut = SendMessageUtils() + + // Matches the cross-client format from https://github.com/nextcloud/spreed/pull/19040: + // sha256(uploadId)[0:60] + "-" + order, zero-padded to 3 digits. + private val groupedReferenceIdPattern = Regex("^[a-f0-9]{60}-[0-9]{3}$") + + @Test + fun `generated id matches the cross-client grouped format`() { + val referenceId = sut.generateGroupedReferenceId("upload-id-1", 1) + + assertTrue(groupedReferenceIdPattern.matches(referenceId)) + } + + @Test + fun `order is zero-padded to three digits`() { + assertTrue(sut.generateGroupedReferenceId("upload-id-1", 1).endsWith("-001")) + assertTrue(sut.generateGroupedReferenceId("upload-id-1", 42).endsWith("-042")) + assertTrue(sut.generateGroupedReferenceId("upload-id-1", 123).endsWith("-123")) + } + + @Test + fun `same uploadId and order always produce the same id`() { + val first = sut.generateGroupedReferenceId("upload-id-1", 3) + val second = sut.generateGroupedReferenceId("upload-id-1", 3) + + assertEquals(first, second) + } + + @Test + fun `different orders for the same uploadId share the hash prefix`() { + val first = sut.generateGroupedReferenceId("upload-id-1", 1) + val second = sut.generateGroupedReferenceId("upload-id-1", 2) + + assertEquals(first.substringBefore("-"), second.substringBefore("-")) + assertNotEquals(first, second) + } + + @Test + fun `different uploadIds never share the hash prefix`() { + val first = sut.generateGroupedReferenceId("upload-id-1", 1) + val second = sut.generateGroupedReferenceId("upload-id-2", 1) + + assertNotEquals(first.substringBefore("-"), second.substringBefore("-")) + } +} From 2bdfabcce6f575a057cef73ccb8afd9027ad1178 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 10:40:57 +0200 Subject: [PATCH 02/31] fix(chat): Group files immediately while they are still uploading Grouping previously only kicked in once every file in a batch had synced back from the server, so a batch showed as separate bubbles for the whole upload and only merged into one afterwards. The grid and file-list tiles now render the still-uploading state directly (local preview, progress spinner, cancel button) so a batch groups into one bubble as soon as the upload starts. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../talk/chat/viewmodels/ChatViewModel.kt | 32 +- .../com/nextcloud/talk/ui/chat/ChatView.kt | 3 +- .../talk/ui/chat/MediaGroupMessage.kt | 280 ++++++++++++++---- .../talk/chat/viewmodels/ChatViewModelTest.kt | 36 ++- 4 files changed, 276 insertions(+), 75 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index ef8bfafbb9c..74b9f0f3464 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -148,9 +148,9 @@ private val groupedReferenceIdRegex = Regex("^([a-f0-9]{60})-[0-9]{3}$") internal fun groupHash(referenceId: String?): String? = referenceId?.let { groupedReferenceIdRegex.matchEntire(it)?.groupValues?.get(1) } -// A run of consecutive, already-synced single-file share messages from the same author, uploaded -// together in one batch, either kept as-is (Single) or merged into one grouped "album" bubble -// (Group) - see ChatViewModel.combineFileShareGroups(). +// A run of consecutive single-file share messages from the same author, uploaded together in one +// batch - whether still uploading or already synced - either kept as-is (Single) or merged into +// one grouped "album" bubble (Group) - see ChatViewModel.combineFileShareGroups(). internal sealed interface CombinedUnit { val messages: List @@ -162,17 +162,22 @@ internal sealed interface CombinedUnit { } /** - * A plain, already-synced single-file share that can be combined with its neighbours. - * Mirrors web's isCombinableFileMessage(): excludes system/voice/geo/poll/deck/text messages - * (via the content-type check), deleted or failed messages, still-uploading placeholders (their - * content is UploadingMedia, not Media), contact cards and audio files. + * A plain single-file share that can be combined with its neighbours - either already synced + * (Media) or still mid-upload (UploadingMedia), so a batch groups into one bubble immediately as + * it starts uploading rather than only once every file has synced. Mirrors web's + * isCombinableFileMessage() otherwise: excludes system/voice/geo/poll/deck/text messages (via the + * content-type check), deleted or failed messages, contact cards and audio files. */ internal fun isCombinableFileShare(message: ChatMessageUi): Boolean { - val content = message.content as? MessageTypeContent.Media ?: return false + val mimeType = when (val content = message.content) { + is MessageTypeContent.Media -> content.mimeType + is MessageTypeContent.UploadingMedia -> content.mimeType.orEmpty() + else -> return false + } return !message.isDeleted && message.statusIcon != MessageStatusIcon.FAILED && - content.mimeType != VCARD_MIMETYPE && - !MimetypeUtils.isAudioOnly(content.mimeType) + mimeType != VCARD_MIMETYPE && + !MimetypeUtils.isAudioOnly(mimeType) } /** Two file shares belong to the same batch: same reply target and same upload-batch hash. */ @@ -2648,9 +2653,10 @@ class ChatViewModel @AssistedInject constructor( data class MessageItem(val uiMessage: ChatMessageUi) : ChatItem - // A run of consecutive, already-synced single-file share messages from the same author, - // uploaded together in one batch (matching referenceId hash, see groupHash()), rendered as - // one grouped "album" bubble instead of N separate bubbles. Ordered chronologically - + // A run of consecutive single-file share messages from the same author - whether still + // uploading or already synced - uploaded together in one batch (matching referenceId hash, + // see groupHash()), rendered as one grouped "album" bubble instead of N separate bubbles. + // Ordered chronologically - // messages.last() is the newest and stands in for the group wherever a single representative // message is needed (bubble anchor, reactions, read status, pagination anchor id). data class MediaGroupItem(val messages: List) : ChatItem diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt index 475efca8537..4cff050e9f6 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -455,7 +455,8 @@ fun ChatView( onReactionLongClick = callbacks.messageCallbacks.onReactionLongClick, onOpenThreadClick = callbacks.messageCallbacks.onOpenThreadClick, onQuotedMessageClick = handleQuotedMessageClick, - onAvatarClick = callbacks.messageCallbacks.onAvatarClick + onAvatarClick = callbacks.messageCallbacks.onAvatarClick, + onCancelUpload = callbacks.messageCallbacks.onCancelUpload ) ) } diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt index c3c83a0c5f2..1e21908d7a0 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -24,8 +25,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.ripple @@ -46,11 +50,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.net.toUri import coil.compose.rememberAsyncImagePainter import com.nextcloud.talk.R import com.nextcloud.talk.chat.data.model.FileParameters import com.nextcloud.talk.chat.data.model.decodeBlurhashPlaceholder import com.nextcloud.talk.chat.ui.model.ChatMessageUi +import com.nextcloud.talk.chat.ui.model.MessageStatusIcon import com.nextcloud.talk.chat.ui.model.MessageTypeContent import com.nextcloud.talk.contacts.load import com.nextcloud.talk.ui.theme.mimetypeIconTint @@ -66,6 +72,9 @@ private const val SQUARE_GRID_ROWS = 2 private const val PLAY_BUTTON_ALPHA = 0.45f private const val OVERFLOW_SCRIM_ALPHA = 0.45f private const val OVERFLOW_TEXT_SIZE = 20 +private const val UPLOAD_SCRIM_ALPHA = 0.25f +private const val UPLOAD_TRACK_ALPHA = 0.3f +private const val PERCENT = 100 private val gridSpacing = 2.dp private val gridTileShape = RoundedCornerShape(4.dp) @@ -136,7 +145,8 @@ fun MediaGroupMessage( MediaGrid( items = mediaItems, chatViewDownloadingFileState = context.downloadingFileState, - onItemClick = callbacks.onFileClick + onItemClick = callbacks.onFileClick, + onCancelUpload = callbacks.onCancelUpload ) } if (fileItems.isNotEmpty()) { @@ -149,7 +159,8 @@ fun MediaGroupMessage( fileItems.forEach { message -> GroupedFileRow( message = message, - onClick = { callbacks.onFileClick(message.id) } + onClick = { callbacks.onFileClick(message.id) }, + onCancelUpload = callbacks.onCancelUpload ) } } @@ -164,16 +175,21 @@ fun MediaGroupMessage( } private fun ChatMessageUi.isPreviewableMedia(): Boolean { - val mimeType = (content as? MessageTypeContent.Media)?.mimeType.orEmpty() + val mimeType = when (val currentContent = content) { + is MessageTypeContent.Media -> currentContent.mimeType + is MessageTypeContent.UploadingMedia -> currentContent.mimeType.orEmpty() + else -> "" + } return mimeType.startsWith(Mimetype.IMAGE_PREFIX) || mimeType.startsWith(Mimetype.VIDEO_PREFIX) } -@Suppress("Detekt.LongMethod") +@Suppress("Detekt.LongMethod", "LongParameterList") @Composable private fun MediaGrid( items: List, chatViewDownloadingFileState: List, - onItemClick: (Int) -> Unit + onItemClick: (Int) -> Unit, + onCancelUpload: (String) -> Unit ) { val visible = items.take(MAX_VISIBLE_GRID_TILES) val overflowCount = items.size - visible.size @@ -184,6 +200,7 @@ private fun MediaGrid( MediaGridTile( message = message, chatViewDownloadingFileState = chatViewDownloadingFileState, + onCancelUpload = onCancelUpload, modifier = Modifier .fillMaxWidth() .height(singleTileHeight) @@ -201,6 +218,7 @@ private fun MediaGrid( MediaGridTile( message = message, chatViewDownloadingFileState = chatViewDownloadingFileState, + onCancelUpload = onCancelUpload, modifier = Modifier .weight(1f) .fillMaxHeight() @@ -220,6 +238,7 @@ private fun MediaGrid( MediaGridTile( message = big, chatViewDownloadingFileState = chatViewDownloadingFileState, + onCancelUpload = onCancelUpload, modifier = Modifier .weight(2f) .fillMaxHeight() @@ -234,6 +253,7 @@ private fun MediaGrid( MediaGridTile( message = message, chatViewDownloadingFileState = chatViewDownloadingFileState, + onCancelUpload = onCancelUpload, modifier = Modifier .weight(1f) .fillMaxWidth() @@ -262,6 +282,7 @@ private fun MediaGrid( MediaGridTile( message = message, chatViewDownloadingFileState = chatViewDownloadingFileState, + onCancelUpload = onCancelUpload, modifier = Modifier.fillMaxWidth().fillMaxHeight().clip(gridTileShape), onClick = { onItemClick(message.id) } ) @@ -291,15 +312,41 @@ private fun MediaGrid( } } -@Suppress("Detekt.LongMethod") +// Dispatches a grid slot to the right renderer for the message's current state: already synced +// (Media) or still mid-upload (UploadingMedia) - a batch groups into one bubble immediately as it +// starts uploading rather than only once every file in it has synced, so both must render here. @Composable private fun MediaGridTile( message: ChatMessageUi, chatViewDownloadingFileState: List, + onCancelUpload: (String) -> Unit, modifier: Modifier, onClick: () -> Unit ) { - val typeContent = message.content as MessageTypeContent.Media + val messageLongClickHandler = LocalMessageLongClickHandler.current + val clickableModifier = modifier.combinedClickable( + onClick = onClick, + onLongClick = { messageLongClickHandler(message.id) } + ) + + Box(modifier = clickableModifier, contentAlignment = Alignment.Center) { + when (val typeContent = message.content) { + is MessageTypeContent.Media -> + SyncedMediaTileContent(message, typeContent, chatViewDownloadingFileState) + is MessageTypeContent.UploadingMedia -> + UploadingMediaTileContent(message, typeContent, onCancelUpload) + else -> Unit + } + } +} + +@Suppress("Detekt.LongMethod") +@Composable +private fun SyncedMediaTileContent( + message: ChatMessageUi, + typeContent: MessageTypeContent.Media, + chatViewDownloadingFileState: List +) { val context = LocalContext.current val fileParameters = remember(message.id) { FileParameters(HashMap(message.messageParameters.mapValues { (_, params) -> HashMap(params) })) @@ -328,77 +375,151 @@ private fun MediaGridTile( } } - val messageLongClickHandler = LocalMessageLongClickHandler.current - val clickableModifier = modifier.combinedClickable( - onClick = onClick, - onLongClick = { messageLongClickHandler(message.id) } - ) - - Box(modifier = clickableModifier, contentAlignment = Alignment.Center) { - if (showsGenericIcon) { - if (blurhashPainter != null) { - Image( - painter = blurhashPainter, - contentDescription = null, - modifier = Modifier.fillMaxWidth().fillMaxHeight(), - contentScale = ContentScale.Crop - ) - } else { - Box( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .background(MaterialTheme.colorScheme.surfaceVariant) - ) - } - Icon( - painter = fallbackPainter, - contentDescription = stringResource(R.string.media_message_content_description), - modifier = Modifier.size(genericIconSize), - tint = mimetypeIconTint(typeContent.drawableResourceId) + if (showsGenericIcon) { + if (blurhashPainter != null) { + Image( + painter = blurhashPainter, + contentDescription = null, + modifier = Modifier.fillMaxWidth().fillMaxHeight(), + contentScale = ContentScale.Crop ) } else { - if (blurhashPainter != null) { - Image( - painter = blurhashPainter, - contentDescription = null, - modifier = Modifier.fillMaxWidth().fillMaxHeight(), - contentScale = ContentScale.Crop - ) - } + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surfaceVariant) + ) + } + Icon( + painter = fallbackPainter, + contentDescription = stringResource(R.string.media_message_content_description), + modifier = Modifier.size(genericIconSize), + tint = mimetypeIconTint(typeContent.drawableResourceId) + ) + } else { + if (blurhashPainter != null) { Image( - painter = rememberAsyncImagePainter(model = loadedImage), - contentDescription = stringResource(R.string.media_message_content_description), + painter = blurhashPainter, + contentDescription = null, modifier = Modifier.fillMaxWidth().fillMaxHeight(), contentScale = ContentScale.Crop ) } + Image( + painter = rememberAsyncImagePainter(model = loadedImage), + contentDescription = stringResource(R.string.media_message_content_description), + modifier = Modifier.fillMaxWidth().fillMaxHeight(), + contentScale = ContentScale.Crop + ) + } - if (showPlayButton) { - Box( - modifier = Modifier - .size(gridPlayButtonSize) - .clip(CircleShape) - .background(Color.Black.copy(alpha = PLAY_BUTTON_ALPHA)), - contentAlignment = Alignment.Center + if (showPlayButton) { + Box( + modifier = Modifier + .size(gridPlayButtonSize) + .clip(CircleShape) + .background(Color.Black.copy(alpha = PLAY_BUTTON_ALPHA)), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_baseline_play_arrow_voice_message_24), + contentDescription = stringResource(R.string.media_message_content_play), + modifier = Modifier.size(gridPlayIconSize), + tint = Color.White + ) + } + } + + if (chatViewDownloadingFileState.contains(fileParameters.id)) { + CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 2.dp) + } +} + +@Suppress("Detekt.LongMethod") +@Composable +private fun UploadingMediaTileContent( + message: ChatMessageUi, + typeContent: MessageTypeContent.UploadingMedia, + onCancelUpload: (String) -> Unit +) { + val progress = LocalUploadProgressProvider.current(message.referenceId.orEmpty()) + val isSent = message.statusIcon == MessageStatusIcon.SENT + val mimeType = typeContent.mimeType.orEmpty() + val hasLocalPreview = (mimeType.startsWith(Mimetype.IMAGE_PREFIX) || mimeType.startsWith(Mimetype.VIDEO_PREFIX)) && + typeContent.localFileUri.isNotEmpty() + + if (hasLocalPreview) { + Image( + painter = rememberAsyncImagePainter(model = typeContent.localFileUri.toUri()), + contentDescription = typeContent.fileName, + modifier = Modifier.fillMaxWidth().fillMaxHeight(), + contentScale = ContentScale.Crop + ) + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surfaceVariant) + ) + Icon( + painter = painterResource(typeContent.drawableResourceId), + contentDescription = typeContent.fileName, + modifier = Modifier.size(genericIconSize), + tint = mimetypeIconTint(typeContent.drawableResourceId) + ) + } + + if (!isSent) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .background(Color.Black.copy(alpha = UPLOAD_SCRIM_ALPHA)) + ) + Box(modifier = Modifier.size(gridPlayButtonSize), contentAlignment = Alignment.Center) { + if (progress != null) { + CircularProgressIndicator( + progress = { progress / PERCENT.toFloat() }, + modifier = Modifier.fillMaxSize(), + color = Color.White, + trackColor = Color.White.copy(alpha = UPLOAD_TRACK_ALPHA), + strokeWidth = 2.dp + ) + } else { + CircularProgressIndicator( + modifier = Modifier.fillMaxSize(), + color = Color.White, + trackColor = Color.White.copy(alpha = UPLOAD_TRACK_ALPHA), + strokeWidth = 2.dp + ) + } + IconButton( + onClick = { onCancelUpload(message.referenceId.orEmpty()) }, + modifier = Modifier.align(Alignment.Center) ) { Icon( - painter = painterResource(R.drawable.ic_baseline_play_arrow_voice_message_24), - contentDescription = stringResource(R.string.media_message_content_play), - modifier = Modifier.size(gridPlayIconSize), + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.nc_cancel), tint = Color.White ) } } + } +} - if (chatViewDownloadingFileState.contains(fileParameters.id)) { - CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 2.dp) - } +@Composable +private fun GroupedFileRow(message: ChatMessageUi, onClick: () -> Unit, onCancelUpload: (String) -> Unit) { + when (val content = message.content) { + is MessageTypeContent.Media -> SyncedFileRow(message, onClick) + is MessageTypeContent.UploadingMedia -> UploadingFileRow(message, content, onCancelUpload) + else -> Unit } } @Composable -private fun GroupedFileRow(message: ChatMessageUi, onClick: () -> Unit) { +private fun SyncedFileRow(message: ChatMessageUi, onClick: () -> Unit) { val fileParameters = remember(message.id) { FileParameters(HashMap(message.messageParameters.mapValues { (_, params) -> HashMap(params) })) } @@ -430,3 +551,44 @@ private fun GroupedFileRow(message: ChatMessageUi, onClick: () -> Unit) { ) } } + +@Composable +private fun UploadingFileRow( + message: ChatMessageUi, + typeContent: MessageTypeContent.UploadingMedia, + onCancelUpload: (String) -> Unit +) { + val messageLongClickHandler = LocalMessageLongClickHandler.current + val isSent = message.statusIcon == MessageStatusIcon.SENT + + Row( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = {}, + onLongClick = { messageLongClickHandler(message.id) } + ) + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(typeContent.drawableResourceId), + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = mimetypeIconTint(typeContent.drawableResourceId) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = typeContent.fileName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + if (!isSent) { + IconButton(onClick = { onCancelUpload(message.referenceId.orEmpty()) }) { + Icon(imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.nc_cancel)) + } + } + } +} diff --git a/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelTest.kt index fef17caa58f..2b9672dfa51 100644 --- a/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelTest.kt @@ -17,6 +17,7 @@ import org.junit.Assert.assertTrue import org.junit.Test import java.time.LocalDate +@Suppress("TooManyFunctions") class ChatViewModelTest { // The unread marker latch: the marker position must only be derived from the visible window @@ -144,10 +145,10 @@ class ChatViewModelTest { } @Test - fun `still-uploading and failed messages are excluded from grouping`() { + fun `a failed message is excluded and breaks the group around it`() { val uploadId = "batch-6" val messages = listOf( - mediaMessage(1, refUtils.generateGroupedReferenceId(uploadId, 1), isTemporary = true), + mediaMessage(1, refUtils.generateGroupedReferenceId(uploadId, 1)), mediaMessage(2, refUtils.generateGroupedReferenceId(uploadId, 2), statusIcon = MessageStatusIcon.FAILED), mediaMessage(3, refUtils.generateGroupedReferenceId(uploadId, 3)) ) @@ -158,6 +159,37 @@ class ChatViewModelTest { assertTrue(result.all { it is CombinedUnit.Single }) } + @Test + fun `still-uploading files from the same batch combine into one group immediately`() { + val uploadId = "batch-7" + val messages = listOf( + mediaMessage(1, refUtils.generateGroupedReferenceId(uploadId, 1), isTemporary = true), + mediaMessage(2, refUtils.generateGroupedReferenceId(uploadId, 2), isTemporary = true), + mediaMessage(3, refUtils.generateGroupedReferenceId(uploadId, 3), isTemporary = true) + ) + + val result = combineFileShareGroups(messages) + + assertEquals(1, result.size) + assertTrue(result[0] is CombinedUnit.Group) + assertEquals(listOf(1, 2, 3), result[0].messages.map { it.id }) + } + + @Test + fun `a mix of an already-synced and a still-uploading file from the same batch combine`() { + val uploadId = "batch-8" + val messages = listOf( + mediaMessage(1, refUtils.generateGroupedReferenceId(uploadId, 1), isTemporary = false), + mediaMessage(2, refUtils.generateGroupedReferenceId(uploadId, 2), isTemporary = true) + ) + + val result = combineFileShareGroups(messages) + + assertEquals(1, result.size) + assertTrue(result[0] is CombinedUnit.Group) + assertEquals(listOf(1, 2), result[0].messages.map { it.id }) + } + @Test fun `messages with non-grouped referenceIds never combine`() { val messages = listOf( From 25eaf47d631f80232a088c4bc3434baf11f2fd81 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 10:50:14 +0200 Subject: [PATCH 03/31] fix(chat): Stop the generic-icon flash when a grouped upload syncs The grid tile fell back straight to the bare mimetype icon whenever the server hadn't reported a preview link yet for a just-synced message - a brief but reliably visible window right after every upload. Bridged the same local-file fallback (and, for video, the locally-extracted first frame) the single-message MediaMessage already uses, so the tile keeps showing what it has on disk instead of flashing the icon while waiting for the server preview. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../talk/ui/chat/MediaGroupMessage.kt | 85 +++++++++++++------ 1 file changed, 60 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt index 1e21908d7a0..27b8a74c76f 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt @@ -7,6 +7,7 @@ package com.nextcloud.talk.ui.chat +import android.graphics.Bitmap import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -35,6 +36,8 @@ import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -53,6 +56,7 @@ import androidx.compose.ui.unit.sp import androidx.core.net.toUri import coil.compose.rememberAsyncImagePainter import com.nextcloud.talk.R +import com.nextcloud.talk.attachmentpreview.describeFile import com.nextcloud.talk.chat.data.model.FileParameters import com.nextcloud.talk.chat.data.model.decodeBlurhashPlaceholder import com.nextcloud.talk.chat.ui.model.ChatMessageUi @@ -63,6 +67,9 @@ import com.nextcloud.talk.ui.theme.mimetypeIconTint import com.nextcloud.talk.utils.DrawableUtils import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.MimetypeUtils +import com.nextcloud.talk.utils.VideoThumbnailCache +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext private const val FILE_PLACEHOLDER_MESSAGE = "{file}" private const val MAX_VISIBLE_GRID_TILES = 4 @@ -340,7 +347,7 @@ private fun MediaGridTile( } } -@Suppress("Detekt.LongMethod") +@Suppress("Detekt.LongMethod", "CyclomaticComplexMethod") @Composable private fun SyncedMediaTileContent( message: ChatMessageUi, @@ -354,7 +361,39 @@ private fun SyncedMediaTileContent( val isVideo = typeContent.mimeType.startsWith(Mimetype.VIDEO_PREFIX) val isGif = MimetypeUtils.isGif(typeContent.mimeType) val showPlayButton = isVideo || (isGif && !typeContent.animateGif) - val showsGenericIcon = typeContent.previewUrl.isNullOrEmpty() + val hasServerPreview = !typeContent.previewUrl.isNullOrEmpty() + + // Bridges the gap right after this message synced but before the server has a preview link + // ready for it (or we just haven't fetched it yet) - same as the single-message MediaMessage, + // reusing the local file we already have on disk instead of falling back to the bare mimetype + // icon, which would otherwise flash for a moment on every upload. + val getLocalPreviewUri = LocalUploadedLocalPreviewProvider.current + val localPreviewUri = if (typeContent.mimeType.startsWith(Mimetype.IMAGE_PREFIX) || isVideo) { + message.referenceId?.let(getLocalPreviewUri) + } else { + null + } + val localPreviewPainter = if (!isVideo && !localPreviewUri.isNullOrEmpty()) { + rememberAsyncImagePainter(model = localPreviewUri.toUri()) + } else { + null + } + val localVideoFramePainter = if (isVideo && !hasServerPreview) { + val refId = message.referenceId + val videoFrame by produceState(initialValue = null, key1 = refId, key2 = localPreviewUri) { + value = withContext(Dispatchers.IO) { + refId?.let { VideoThumbnailCache.get(context, it) } + ?: localPreviewUri?.let { uri -> + describeFile(context, uri, compress = false).videoThumbnail?.also { bitmap -> + refId?.let { VideoThumbnailCache.put(context, it, bitmap) } + } + } + } + } + videoFrame?.let { BitmapPainter(it.asImageBitmap()) } + } else { + null + } val blurhashPainter = remember(typeContent.blurhash, typeContent.width, typeContent.height) { decodeBlurhashPlaceholder(typeContent.blurhash, typeContent.width, typeContent.height) @@ -362,6 +401,9 @@ private fun SyncedMediaTileContent( ?.let { BitmapPainter(it) } } val fallbackPainter = painterResource(typeContent.drawableResourceId) + val basePainter = localVideoFramePainter ?: localPreviewPainter ?: blurhashPainter + val showsGenericIcon = !hasServerPreview && basePainter == null + val loadedImage = remember(typeContent.previewUrl, typeContent.isClassified) { if (typeContent.isClassified || typeContent.previewUrl.isNullOrEmpty()) { null @@ -376,21 +418,12 @@ private fun SyncedMediaTileContent( } if (showsGenericIcon) { - if (blurhashPainter != null) { - Image( - painter = blurhashPainter, - contentDescription = null, - modifier = Modifier.fillMaxWidth().fillMaxHeight(), - contentScale = ContentScale.Crop - ) - } else { - Box( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .background(MaterialTheme.colorScheme.surfaceVariant) - ) - } + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surfaceVariant) + ) Icon( painter = fallbackPainter, contentDescription = stringResource(R.string.media_message_content_description), @@ -398,20 +431,22 @@ private fun SyncedMediaTileContent( tint = mimetypeIconTint(typeContent.drawableResourceId) ) } else { - if (blurhashPainter != null) { + if (basePainter != null) { Image( - painter = blurhashPainter, + painter = basePainter, contentDescription = null, modifier = Modifier.fillMaxWidth().fillMaxHeight(), contentScale = ContentScale.Crop ) } - Image( - painter = rememberAsyncImagePainter(model = loadedImage), - contentDescription = stringResource(R.string.media_message_content_description), - modifier = Modifier.fillMaxWidth().fillMaxHeight(), - contentScale = ContentScale.Crop - ) + if (hasServerPreview) { + Image( + painter = rememberAsyncImagePainter(model = loadedImage), + contentDescription = stringResource(R.string.media_message_content_description), + modifier = Modifier.fillMaxWidth().fillMaxHeight(), + contentScale = ContentScale.Crop + ) + } } if (showPlayButton) { From 530c7c68f4908b0f3e0faaa75a799fff91d747f1 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 11:02:25 +0200 Subject: [PATCH 04/31] fix(chat): Stop the generic icon overlaying the uploaded image The tile's "real preview" layer used rememberAsyncImagePainter() directly on a request whose placeholder is the generic mimetype icon, and rendered it unconditionally at full opacity - so the icon sat fully opaque on top of the correct base layer (local file/ blurhash) for as long as the network image was still loading. Only fading that layer in once it has actually finished loading (same technique the single-message MediaMessage already uses) keeps the base layer visible the whole time instead. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../talk/ui/chat/MediaGroupMessage.kt | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt index 27b8a74c76f..dd7d3e54627 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt @@ -8,6 +8,8 @@ package com.nextcloud.talk.ui.chat import android.graphics.Bitmap +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -41,6 +43,7 @@ import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap @@ -54,6 +57,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.net.toUri +import coil.compose.AsyncImagePainter import coil.compose.rememberAsyncImagePainter import com.nextcloud.talk.R import com.nextcloud.talk.attachmentpreview.describeFile @@ -82,6 +86,7 @@ private const val OVERFLOW_TEXT_SIZE = 20 private const val UPLOAD_SCRIM_ALPHA = 0.25f private const val UPLOAD_TRACK_ALPHA = 0.3f private const val PERCENT = 100 +private const val TILE_CROSSFADE_DURATION_MS = 300 private val gridSpacing = 2.dp private val gridTileShape = RoundedCornerShape(4.dp) @@ -440,10 +445,21 @@ private fun SyncedMediaTileContent( ) } if (hasServerPreview) { + // rememberAsyncImagePainter() paints the request's own placeholder (the generic + // mimetype icon, set by load() below) while the real image is still loading - shown + // unconditionally, that placeholder would sit fully opaque on top of basePainter above. + // Only fading it in once actually loaded keeps the base layer visible until then. + val loadedPainter = rememberAsyncImagePainter(model = loadedImage) + val isLoaded = loadedPainter.state is AsyncImagePainter.State.Success + val loadedAlpha by animateFloatAsState( + targetValue = if (isLoaded) 1f else 0f, + animationSpec = tween(durationMillis = TILE_CROSSFADE_DURATION_MS), + label = "gridTileLoadedAlpha" + ) Image( - painter = rememberAsyncImagePainter(model = loadedImage), + painter = loadedPainter, contentDescription = stringResource(R.string.media_message_content_description), - modifier = Modifier.fillMaxWidth().fillMaxHeight(), + modifier = Modifier.fillMaxWidth().fillMaxHeight().alpha(loadedAlpha), contentScale = ContentScale.Crop ) } From 97c3d6f0614975a656e3b2fcd58bfdbdfc8a5ce1 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 11:15:57 +0200 Subject: [PATCH 05/31] fix(chat): Fix crash from duplicate keys when a batch upload fails MediaGroupItem.stableKey() was derived from groupHash(), the hash shared by every file in an upload batch. A failed upload in the middle of a batch excludes that one message from grouping and splits the rest into two separate groups (see combineFileShareGroups()) - both of which share that same batch hash, so they collided on the same LazyColumn key and crashed with "Key ... was already used." Keyed off the group's own first message's referenceId instead, which is unique per message and therefore unique per group, while still surviving the swap from upload placeholder to synced message the same way MessageItem's key already does. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../talk/chat/viewmodels/ChatViewModel.kt | 10 ++++++++- .../talk/chat/viewmodels/ChatViewModelTest.kt | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 74b9f0f3464..f2d1c9aabc5 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -2645,7 +2645,15 @@ class ChatViewModel @AssistedInject constructor( // re-inserting a new one - which is what caused the visible flicker/pop. is MessageItem -> uiMessage.referenceId?.takeIf { it.isNotBlank() }?.let { "msg_ref_$it" } ?: "msg_${uiMessage.id}" - is MediaGroupItem -> "msg_group_${groupHash(messages.first().referenceId) ?: messages.first().id}" + // Keyed off the group's first message specifically (not the shared batch hash from + // groupHash()) - a failed upload mid-batch excludes that one message and splits the + // rest into two separate groups (see combineFileShareGroups()), and both halves + // share the same batch hash, which collided here and crashed the LazyColumn (two + // items must never report the same key). Each message's own referenceId is unique, + // so keying off the first one is unique per group instance while still surviving the + // swap from upload placeholder to synced message, same as MessageItem above. + is MediaGroupItem -> messages.first().referenceId?.takeIf { it.isNotBlank() } + ?.let { "msg_group_$it" } ?: "msg_group_${messages.first().id}" is DateHeaderItem -> "header_$date" is UnreadMessagesMarkerItem -> "last_read_$date" is LoadGapItem -> "load_gap_$anchorMessageId" diff --git a/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelTest.kt index 2b9672dfa51..42f76826864 100644 --- a/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelTest.kt @@ -190,6 +190,28 @@ class ChatViewModelTest { assertEquals(listOf(1, 2), result[0].messages.map { it.id }) } + // Regression test: a failed upload in the middle of a batch excludes that one message and + // splits the rest into two separate groups (see combineFileShareGroups()) - both halves share + // the same batch hash from groupHash(), which crashed the LazyColumn when MediaGroupItem's + // stableKey() was derived from that shared hash instead of from the group's own first message. + @Test + fun `two groups split by a failed upload in the same batch get distinct stable keys`() { + val uploadId = "batch-9" + val messages = listOf( + mediaMessage(1, refUtils.generateGroupedReferenceId(uploadId, 1)), + mediaMessage(2, refUtils.generateGroupedReferenceId(uploadId, 2)), + mediaMessage(3, refUtils.generateGroupedReferenceId(uploadId, 3), statusIcon = MessageStatusIcon.FAILED), + mediaMessage(4, refUtils.generateGroupedReferenceId(uploadId, 4)), + mediaMessage(5, refUtils.generateGroupedReferenceId(uploadId, 5)) + ) + + val groups = combineFileShareGroups(messages).filterIsInstance() + assertEquals(2, groups.size) + + val keys = groups.map { ChatViewModel.ChatItem.MediaGroupItem(it.messages).stableKey() } + assertEquals(keys.size, keys.toSet().size) + } + @Test fun `messages with non-grouped referenceIds never combine`() { val messages = listOf( From 1056695b602997ceeb6112ddceb198fb7ea7e3e6 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 11:25:35 +0200 Subject: [PATCH 06/31] fix(uploads): Don't crash when a picked file's uri access is revoked Uploading a batch chains uploads sequentially, so a file near the end of the batch can sit waiting for a while before its own upload (and the local-file reads that feed the "uploading" preview) start. The system Photo Picker's content:// uris are only guaranteed readable for a short window after picking, and once that grant is gone, FileUtils.getFileName()/resolveMimeType()/copyFileToCache() made a raw, unguarded ContentResolver call that threw a SecurityException straight past every caller (describeFile(), the aspect-ratio/video-thumbnail reads in the media composables) and crashed the app. Caught the failure at its source in FileUtils instead, falling back to what's still derivable (the uri's own path for the filename, the file extension for the mime type) so every caller degrades instead of crashing. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/utils/FileUtils.kt | 52 +++++++++++----- .../utils/FileUtilsContentResolverTest.kt | 60 +++++++++++++++++++ 2 files changed, 98 insertions(+), 14 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/talk/utils/FileUtilsContentResolverTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt index 8072e88cfd3..80d814f02a0 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt @@ -177,6 +177,11 @@ object FileUtils { outputStream.flush() } catch (e: FileNotFoundException) { Log.w(TAG, "failed to copy file to cache", e) + } catch (e: SecurityException) { + // Most notably the system Photo Picker's ephemeral uris, whose read grant can be + // revoked at any point after it was first handed to us, well before we're done + // using it - this must never crash the caller, just leave the file uncopied. + Log.w(TAG, "no longer permitted to read $sourceFileUri", e) } } return cachedFile @@ -185,16 +190,9 @@ object FileUtils { fun getFileName(uri: Uri, context: Context?): String { var filename: String? = null if (uri.scheme == "content" && context != null) { - context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val displayNameColumnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (displayNameColumnIndex != -1) { - filename = cursor.getString(displayNameColumnIndex) - } - } - } + filename = queryDisplayName(context, uri) } - // if it was no content uri, read filename from path + // if it was no content uri (or the query above couldn't read one), read filename from path if (filename == null) { filename = uri.path } @@ -207,16 +205,42 @@ object FileUtils { return filename } + // The content provider behind a uri (most notably the system Photo Picker's ephemeral uris) + // can revoke read access at any point after it was first granted, turning a plain metadata + // query into a SecurityException well after the uri was handed to us - this must never crash + // the caller, just fall back to deriving the filename from the uri's own path instead. + @Suppress("TooGenericExceptionCaught") + private fun queryDisplayName(context: Context, uri: Uri): String? = + try { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val displayNameColumnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (cursor.moveToFirst() && displayNameColumnIndex != -1) { + cursor.getString(displayNameColumnIndex) + } else { + null + } + } + } catch (e: Exception) { + Log.w(TAG, "failed to query display name for $uri", e) + null + } + /** * Resolves the MIME type of [uri]. [ContentResolver.getType] only resolves `content://` URIs, so for * `file://` URIs (e.g. the in-app camera writes plain files) this falls back to guessing from the - * file extension. + * file extension. Also falls back there if the provider's read grant for [uri] was revoked in the + * meantime (most notably the system Photo Picker's ephemeral uris) - never lets that crash the caller. */ + @Suppress("TooGenericExceptionCaught") fun resolveMimeType(context: Context, uri: Uri): String? = - context.contentResolver.getType(uri) - ?: MimeTypeMap.getSingleton().getMimeTypeFromExtension( - MimeTypeMap.getFileExtensionFromUrl(uri.toString()).lowercase() - ) + try { + context.contentResolver.getType(uri) + } catch (e: Exception) { + Log.w(TAG, "failed to resolve mime type for $uri", e) + null + } ?: MimeTypeMap.getSingleton().getMimeTypeFromExtension( + MimeTypeMap.getFileExtensionFromUrl(uri.toString()).lowercase() + ) @JvmStatic fun md5Sum(file: File): String { diff --git a/app/src/test/java/com/nextcloud/talk/utils/FileUtilsContentResolverTest.kt b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsContentResolverTest.kt new file mode 100644 index 00000000000..600713ab7fd --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsContentResolverTest.kt @@ -0,0 +1,60 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import android.app.Application +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +// Regression coverage for a crash: the system Photo Picker's ephemeral content:// uris can have +// their read grant revoked at any point after being handed to us (e.g. while a batch upload is +// still chained behind an earlier, slower one), turning a plain metadata query into an uncaught +// SecurityException that took the whole app down. +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class FileUtilsContentResolverTest { + + @Test + fun `getFileName falls back to the uri path when the content provider query throws`() { + val uri = Uri.parse("content://media/picker/0/com.android.providers.media.photopicker/media/123") + val context = mockContextWhoseResolverThrows(uri) + + val name = FileUtils.getFileName(uri, context) + + assertEquals("123", name) + } + + @Test + fun `resolveMimeType falls back to the extension guess when the content provider throws`() { + val uri = Uri.parse("content://media/picker/0/com.android.providers.media.photopicker/media/photo.jpg") + val context = mock(Context::class.java) + val resolver = mock(ContentResolver::class.java) + `when`(context.contentResolver).thenReturn(resolver) + `when`(resolver.getType(uri)).thenThrow(SecurityException("permission revoked")) + + val mimeType = FileUtils.resolveMimeType(context, uri) + + assertEquals("image/jpeg", mimeType) + } + + private fun mockContextWhoseResolverThrows(uri: Uri): Context { + val context = mock(Context::class.java) + val resolver = mock(ContentResolver::class.java) + `when`(context.contentResolver).thenReturn(resolver) + `when`(resolver.query(uri, null, null, null, null)) + .thenThrow(SecurityException("permission revoked")) + return context + } +} From dc6c7910e37bf9ac58854c71a1eafb1f59e8444b Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 11:40:46 +0200 Subject: [PATCH 07/31] fix(chat): Fix cancel doing nothing for uploads from a prior session cancelUpload() bailed out entirely when uploadReferenceToWorkId had no entry for the given referenceId. That map is session-local and never persisted, so it's always empty after an app restart - meaning cancel silently did nothing for any placeholder still showing as "uploading" from before the restart (e.g. one left behind by a crash, or a chained upload whose WorkManager queue got replaced by a later enqueue), with no other way to clear it from the chat. The placeholder is now always deleted regardless of whether a live work id is known for it; the WorkManager cancellation only happens when one is. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/chat/viewmodels/ChatViewModel.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index f2d1c9aabc5..ec0b41031c0 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -369,8 +369,14 @@ class ChatViewModel @AssistedInject constructor( } fun cancelUpload(referenceId: String) { - val workId = uploadReferenceToWorkId.remove(referenceId) ?: return - UploadAndShareFilesWorker.cancelUpload(referenceId, workId) + // uploadReferenceToWorkId is session-local (never persisted) - after an app restart it's + // empty even for an upload that's still stuck showing as "uploading" from a previous + // session, so a placeholder must still be removable even when there's no known work id to + // also cancel. Without this, cancel silently did nothing for any such placeholder, leaving + // the user with no way to clear a permanently stuck upload. + uploadReferenceToWorkId.remove(referenceId)?.let { workId -> + UploadAndShareFilesWorker.cancelUpload(referenceId, workId) + } viewModelScope.launch { chatRepository.deleteTempMessageByReferenceId(referenceId) } From e0c4b078b814bee5e010983ff08e9ce7a327f4ac Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 12:44:16 +0200 Subject: [PATCH 08/31] feat(chat): Add a swipeable, group-aware media viewer Tapping an image or video in chat now opens a Signal-style pager instead of the old single-item viewer: swipe left/right to move between media items, with a bottom thumbnail strip for the current upload-batch group, centered on the active item. Crossing a group's edge moves to the adjacent group - toward older history by paging through SharedItemsRepository (getUrlForChatSharedItems), toward newer items bounded by what the chat screen already has loaded locally, since that endpoint has no "items newer than X" mode. New com.nextcloud.talk.mediaviewer package (MediaViewerActivity/ Screen/ViewModel) reuses the existing PhotoView/GIF/ExoPlayer rendering techniques and the DownloadFileToCacheWorker pipeline rather than introducing new streaming infrastructure. FullScreenImageActivity/FullScreenMediaActivity are unchanged and still serve every other entry point (audio, non-image/video files). Also fixes a latent bug in SharedItemsRepositoryImpl: shared items were sorted lexicographically by message id string ("10" before "9"), which the new viewer's group/page-adjacency reconstruction depends on being truly chronological. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- app/src/main/AndroidManifest.xml | 5 + .../com/nextcloud/talk/chat/ChatActivity.kt | 23 +- .../talk/chat/viewmodels/ChatViewModel.kt | 54 ++- .../talk/dagger/modules/ViewModelModule.kt | 6 + .../activities/MediaViewerActivity.kt | 147 ++++++++ .../activities/MediaViewerScreen.kt | 351 ++++++++++++++++++ .../talk/mediaviewer/model/MediaViewerItem.kt | 71 ++++ .../viewmodels/MediaViewerViewModel.kt | 256 +++++++++++++ .../talk/shareditems/model/SharedFileItem.kt | 4 +- .../repositories/SharedItemsRepositoryImpl.kt | 9 +- .../talk/utils/message/SendMessageUtils.kt | 14 + .../mediaviewer/model/MediaViewerItemTest.kt | 94 +++++ 12 files changed, 1019 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt create mode 100644 app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt create mode 100644 app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt create mode 100644 app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt create mode 100644 app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItemTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 54182da11ba..88a3319b901 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -226,6 +226,11 @@ android:configChanges="orientation|keyboardHidden|screenSize" android:theme="@style/FullScreenTextTheme" /> + + diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index d990b9fe6ed..205b2d8c895 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -142,6 +142,7 @@ import com.nextcloud.talk.jobs.DownloadFileToCacheWorker import com.nextcloud.talk.jobs.ShareOperationWorker import com.nextcloud.talk.jobs.UploadAndShareFilesWorker import com.nextcloud.talk.location.LocationPickerActivity +import com.nextcloud.talk.mediaviewer.activities.MediaViewerActivity import com.nextcloud.talk.models.ExternalSignalingServer import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.capabilities.SpreedCapability @@ -1286,11 +1287,23 @@ class ChatActivity : ) { lifecycleScope.launch { val chatMessage = chatViewModel.getMessageById(messageId.toLong()).first() - FileViewerUtils(this@ChatActivity, conversationUser).openFile( - chatMessage, - openWhenDownloadState, - downloadState - ) + val mimetype = chatMessage.fileParameters.mimetype + val fileViewerUtils = FileViewerUtils(this@ChatActivity, conversationUser) + + // Images/videos open the swipeable media viewer instead of the single-item viewer - + // everything else (audio, text, generic files) keeps today's behavior unchanged. + val isViewableMedia = mimetype != null && + (mimetype.startsWith(Mimetype.IMAGE_PREFIX) || mimetype.startsWith(Mimetype.VIDEO_PREFIX)) && + fileViewerUtils.isSupportedForInternalViewer(mimetype) + val seedItems = if (isViewableMedia) chatViewModel.mediaViewerSeed().flatMap { it.items } else emptyList() + + if (isViewableMedia && seedItems.any { it.messageId == messageId.toLong() }) { + startActivity( + MediaViewerActivity.newIntent(this@ChatActivity, roomToken, seedItems, messageId.toLong()) + ) + } else { + fileViewerUtils.openFile(chatMessage, openWhenDownloadState, downloadState) + } } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index ec0b41031c0..09077f6ce70 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -26,6 +26,7 @@ import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager import com.nextcloud.talk.chat.data.io.MediaPlayerManager import com.nextcloud.talk.chat.data.io.MediaRecorderManager import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.chat.data.model.FileParameters import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource import com.nextcloud.talk.chat.ui.model.ChatMessageUi import com.nextcloud.talk.chat.ui.model.MessageStatusIcon @@ -47,6 +48,8 @@ import androidx.lifecycle.asFlow import androidx.work.WorkManager import com.nextcloud.talk.jobs.UploadAndShareFilesWorker import com.nextcloud.talk.logger.Logger +import com.nextcloud.talk.mediaviewer.model.MediaViewerGroup +import com.nextcloud.talk.mediaviewer.model.MediaViewerItem import com.nextcloud.talk.messagesearch.MessageSearchHelper import com.nextcloud.talk.models.MessageDraft import com.nextcloud.talk.models.domain.ConversationModel @@ -78,6 +81,7 @@ import com.nextcloud.talk.utils.UserIdUtils import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.database.user.CurrentUserProvider import com.nextcloud.talk.utils.message.SendMessageUtils +import com.nextcloud.talk.utils.message.groupHashOf import com.nextcloud.talk.utils.preferences.AppPreferences import com.nextcloud.talk.webrtc.WebSocketConnectionHelper import dagger.assisted.Assisted @@ -135,18 +139,12 @@ import androidx.core.net.toUri private const val FILE_PLACEHOLDER_MESSAGE = "{file}" private const val VCARD_MIMETYPE = "text/vcard" -// Matches referenceIds built by SendMessageUtils.generateGroupedReferenceId(): -// sha256(uploadId)[0:60] + "-" + order, zero-padded to 3 digits. Shared cross-client format, -// see https://github.com/nextcloud/spreed/pull/19040 -private val groupedReferenceIdRegex = Regex("^([a-f0-9]{60})-[0-9]{3}$") - /** * A file's referenceId marks it as part of an upload batch when it matches the cross-client * format `sha256(uploadId)[0:60]-order`. Returns the shared hash prefix identifying the batch, * or null if the id doesn't match (plain-text messages, or older/unrelated referenceIds). */ -internal fun groupHash(referenceId: String?): String? = - referenceId?.let { groupedReferenceIdRegex.matchEntire(it)?.groupValues?.get(1) } +internal fun groupHash(referenceId: String?): String? = groupHashOf(referenceId) // A run of consecutive single-file share messages from the same author, uploaded together in one // batch - whether still uploading or already synced - either kept as-is (Single) or merged into @@ -227,6 +225,29 @@ internal fun combineFileShareGroups(uiMessages: List): List HashMap(params) })) + val fileId = fileParameters.id ?: return null + return MediaViewerItem( + messageId = id.toLong(), + referenceId = referenceId, + fileId = fileId, + fileName = fileParameters.name.orEmpty(), + mimeType = content.mimeType, + path = fileParameters.path.orEmpty(), + link = fileParameters.link.orEmpty(), + fileSize = fileParameters.size ?: 0L, + previewUrl = content.previewUrl, + actorDisplayName = actorDisplayName + ) +} + @Suppress("TooManyFunctions", "LongParameterList") class ChatViewModel @AssistedInject constructor( private val logger: Logger, @@ -386,6 +407,25 @@ class ChatViewModel @AssistedInject constructor( fun getChatRepository(): ChatMessageRepository = chatRepository + /** + * The locally known media (image/video) groups for the media viewer, oldest first - every + * already-synced MessageItem/MediaGroupItem currently loaded in this chat. This is what lets + * the viewer navigate toward newer items instantly (bounded by what's already loaded here) and + * toward older items without a network round trip until this local knowledge is exhausted - + * see MediaViewerViewModel.loadOlderGroups(). The caller locates the tapped message's own + * position within the result. + */ + fun mediaViewerSeed(): List = + uiState.value.items.asReversed().mapNotNull { item -> + when (item) { + is ChatItem.MessageItem -> item.uiMessage.toMediaViewerItem()?.let { MediaViewerGroup(listOf(it)) } + is ChatItem.MediaGroupItem -> item.messages.mapNotNull { it.toMediaViewerItem() } + .takeIf { it.isNotEmpty() } + ?.let { MediaViewerGroup(it) } + else -> null + } + } + override fun onResume(owner: LifecycleOwner) { super.onResume(owner) val isReturningFromBackground = ::currentLifeCycleFlag.isInitialized diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index 6c402357d82..421eb093b1b 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -25,6 +25,7 @@ import com.nextcloud.talk.conversationtags.viewmodels.ConversationTagsViewModel import com.nextcloud.talk.diagnosis.DiagnosisViewModel import com.nextcloud.talk.logger.ui.LogsViewModel import com.nextcloud.talk.invitation.viewmodels.InvitationsViewModel +import com.nextcloud.talk.mediaviewer.viewmodels.MediaViewerViewModel import com.nextcloud.talk.messagesearch.MessageSearchViewModel import com.nextcloud.talk.openconversations.viewmodels.OpenConversationsViewModel import com.nextcloud.talk.ui.chooseaccount.ChooseAccountShareToViewModel @@ -78,6 +79,11 @@ abstract class ViewModelModule { @ViewModelKey(SharedItemsViewModel::class) abstract fun sharedItemsViewModel(viewModel: SharedItemsViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(MediaViewerViewModel::class) + abstract fun mediaViewerViewModel(viewModel: MediaViewerViewModel): ViewModel + @Binds @IntoMap @ViewModelKey(MessageSearchViewModel::class) diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt new file mode 100644 index 00000000000..90c12e0db3f --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt @@ -0,0 +1,147 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.mediaviewer.activities + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.widget.FrameLayout +import androidx.activity.SystemBarStyle +import androidx.activity.enableEdgeToEdge +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.content.FileProvider +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.ViewModelProvider +import autodagger.AutoInjector +import com.google.android.material.snackbar.Snackbar +import com.nextcloud.talk.BuildConfig +import com.nextcloud.talk.R +import com.nextcloud.talk.activities.BaseActivity +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider +import com.nextcloud.talk.mediaviewer.model.MediaViewerItem +import com.nextcloud.talk.mediaviewer.viewmodels.MediaViewerViewModel +import com.nextcloud.talk.ui.SwipeToCloseLayout +import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment +import com.nextcloud.talk.utils.FileUtils +import com.nextcloud.talk.utils.Mimetype.IMAGE_PREFIX_GENERIC +import com.nextcloud.talk.utils.Mimetype.VIDEO_PREFIX_GENERIC +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN +import java.io.File +import javax.inject.Inject + +/** + * Swipeable, group-aware media viewer - the entry point for every image/video tap in chat. See + * MediaViewerViewModel for the navigation/paging model and MediaViewerScreen for the UI. + */ +@AutoInjector(NextcloudTalkApplication::class) +class MediaViewerActivity : BaseActivity() { + + @Inject + lateinit var viewModelFactory: ViewModelProvider.Factory + + private lateinit var viewModel: MediaViewerViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + + val roomToken = intent.getStringExtra(KEY_ROOM_TOKEN) + val seedItems = intent.getParcelableArrayListExtraProvider(EXTRA_SEED_ITEMS) + val startMessageId = intent.getLongExtra(EXTRA_START_MESSAGE_ID, -1L) + val user = currentUserProviderOld.currentUser.blockingGet() + + if (roomToken == null || seedItems.isNullOrEmpty() || user == null) { + Log.e(TAG, "Missing data to open the media viewer") + finish() + return + } + + viewModel = ViewModelProvider(this, viewModelFactory)[MediaViewerViewModel::class.java] + viewModel.initialize(user, roomToken, seedItems, startMessageId) + + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) + ) + val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + + val swipeToCloseLayout = SwipeToCloseLayout(this) + swipeToCloseLayout.setOnSwipeToCloseListener(object : SwipeToCloseLayout.OnSwipeToCloseListener { + override fun onSwipeToClose() { + finish() + } + }) + + val composeView = ComposeView(this).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val colorScheme = viewThemeUtils.getColorScheme(this@MediaViewerActivity) + MaterialTheme(colorScheme = colorScheme) { + MediaViewerScreen( + viewModel = viewModel, + onShare = ::shareFile, + onSave = ::showSaveDialog + ) + } + } + } + + swipeToCloseLayout.addView( + composeView, + FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + ) + setContentView(swipeToCloseLayout) + } + + private fun shareFile(item: MediaViewerItem, localPath: String) { + val file = File(localPath) + val shareUri = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID, file) + val isVideo = item.mimeType.startsWith("video/") + val shareIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_STREAM, shareUri) + type = if (isVideo) VIDEO_PREFIX_GENERIC else IMAGE_PREFIX_GENERIC + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + startActivity(Intent.createChooser(shareIntent, resources.getText(R.string.send_to))) + } + + private fun showSaveDialog(item: MediaViewerItem, localPath: String) { + val safeFile = FileUtils.resolveSharedAttachmentFile(cacheDir, File(localPath).name) + if (safeFile == null) { + Snackbar.make(window.decorView, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() + return + } + val saveFragment: DialogFragment = SaveToStorageDialogFragment.newInstance(safeFile.name) + saveFragment.show(supportFragmentManager, SaveToStorageDialogFragment.TAG) + } + + companion object { + private val TAG = MediaViewerActivity::class.java.simpleName + private const val EXTRA_SEED_ITEMS = "MEDIA_VIEWER_SEED_ITEMS" + private const val EXTRA_START_MESSAGE_ID = "MEDIA_VIEWER_START_MESSAGE_ID" + + fun newIntent( + context: Context, + roomToken: String, + seedItems: List, + startMessageId: Long + ): Intent = + Intent(context, MediaViewerActivity::class.java).apply { + putExtra(KEY_ROOM_TOKEN, roomToken) + putParcelableArrayListExtra(EXTRA_SEED_ITEMS, ArrayList(seedItems)) + putExtra(EXTRA_START_MESSAGE_ID, startMessageId) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt new file mode 100644 index 00000000000..5ae412bc56f --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt @@ -0,0 +1,351 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.mediaviewer.activities + +import android.util.Log +import androidx.annotation.OptIn +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.net.toUri +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.media3.common.AudioAttributes +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import coil.compose.AsyncImage +import com.github.chrisbanes.photoview.PhotoView +import com.nextcloud.talk.R +import com.nextcloud.talk.components.StandardAppBar +import com.nextcloud.talk.mediaviewer.model.MediaViewerGroup +import com.nextcloud.talk.mediaviewer.model.MediaViewerItem +import com.nextcloud.talk.mediaviewer.viewmodels.MediaViewerViewModel +import com.nextcloud.talk.utils.BitmapShrinker +import com.nextcloud.talk.utils.DrawableUtils +import com.nextcloud.talk.utils.Mimetype +import com.nextcloud.talk.utils.MimetypeUtils +import kotlinx.coroutines.launch +import pl.droidsonroids.gif.GifDrawable +import pl.droidsonroids.gif.GifImageView + +private const val TOOLBAR_ALPHA = 0.6f +private const val MAX_SCALE = 6.0f +private const val MEDIUM_SCALE = 2.45f + +private val thumbnailSize = 48.dp +private val thumbnailSpacing = 4.dp +private val thumbnailStripPadding = 12.dp + +@Suppress("Detekt.LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MediaViewerScreen( + viewModel: MediaViewerViewModel, + onShare: (MediaViewerItem, String) -> Unit, + onSave: (MediaViewerItem, String) -> Unit +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val items = uiState.flattenedItems + if (items.isEmpty()) return + + val pagerState = rememberPagerState(initialPage = uiState.currentGlobalIndex.coerceIn(0, items.size - 1)) { + items.size + } + val coroutineScope = rememberCoroutineScope() + + // See MediaViewerViewModel.indexShiftEvents: prepending older groups shifts every existing + // index, so the pager must silently jump to keep the same item on screen. + LaunchedEffect(viewModel) { + viewModel.indexShiftEvents.collect { shift -> + if (shift != 0) { + pagerState.scrollToPage((pagerState.currentPage + shift).coerceIn(0, items.size - 1)) + } + } + } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage }.collect { page -> + viewModel.onPageSettled(page) + } + } + + val exoPlayer = rememberViewerExoPlayer() + val currentItem = uiState.currentItem + val currentLocalPath = currentItem?.let { uiState.cachedFilePaths[it.messageId] } + LaunchedEffect(currentItem?.messageId, currentLocalPath) { + val isVideo = currentItem?.mimeType?.startsWith(Mimetype.VIDEO_PREFIX) == true + if (isVideo && currentLocalPath != null) { + exoPlayer.setMediaItem(MediaItem.fromUri(currentLocalPath.toUri())) + exoPlayer.playWhenReady = true + exoPlayer.prepare() + } else { + exoPlayer.stop() + } + } + + Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { + HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> + val item = items.getOrNull(page) ?: return@HorizontalPager + MediaPage( + item = item, + localPath = uiState.cachedFilePaths[item.messageId], + isCurrentPage = page == pagerState.currentPage, + exoPlayer = exoPlayer + ) + } + + val toolbarColors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + actionIconContentColor = Color.White + ) + val menuItems = buildList { + if (currentItem != null && currentLocalPath != null) { + add(stringResource(R.string.share) to { onShare(currentItem, currentLocalPath) }) + add(stringResource(R.string.nc_save_message) to { onSave(currentItem, currentLocalPath) }) + } + } + StandardAppBar(title = currentItem?.actorDisplayName.orEmpty(), menuItems = menuItems, colors = toolbarColors) + + if (currentItem != null && currentLocalPath == null) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center).size(48.dp), + color = Color.White + ) + } + + val group = uiState.currentGroup + if (group != null && group.items.size > 1) { + ThumbnailStrip( + group = group, + currentItem = currentItem, + modifier = Modifier.align(Alignment.BottomCenter), + onThumbnailClick = { clickedItem -> + val globalIndex = items.indexOfFirst { it.messageId == clickedItem.messageId } + if (globalIndex >= 0) { + coroutineScope.launch { pagerState.animateScrollToPage(globalIndex) } + } + } + ) + } + } +} + +@OptIn(UnstableApi::class) +@Composable +private fun rememberViewerExoPlayer(): ExoPlayer { + val context = LocalContext.current + val player = remember { + ExoPlayer.Builder(context) + .setAudioAttributes(AudioAttributes.DEFAULT, true) + .setHandleAudioBecomingNoisy(true) + .build() + } + DisposableEffect(Unit) { + onDispose { player.release() } + } + return player +} + +@OptIn(UnstableApi::class) +@Composable +private fun MediaPage(item: MediaViewerItem, localPath: String?, isCurrentPage: Boolean, exoPlayer: ExoPlayer) { + val isVideo = item.mimeType.startsWith(Mimetype.VIDEO_PREFIX) + val isGif = MimetypeUtils.isGif(item.mimeType) + + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + when { + localPath == null -> PreviewPlaceholder(item) + isVideo -> if (isCurrentPage) { + VideoPlayerView(exoPlayer = exoPlayer) + } else { + PreviewPlaceholder(item) + } + isGif -> GifPage(localPath = localPath) + else -> ImagePage(localPath = localPath) + } + } +} + +@Composable +private fun PreviewPlaceholder(item: MediaViewerItem) { + if (item.previewUrl != null) { + AsyncImage( + model = item.previewUrl, + contentDescription = item.fileName, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit + ) + } +} + +@Composable +private fun GifPage(localPath: String) { + AndroidView( + factory = { ctx -> GifImageView(ctx).apply { setImageDrawable(GifDrawable(localPath)) } }, + modifier = Modifier.fillMaxSize() + ) +} + +@Composable +private fun ImagePage(localPath: String) { + AndroidView( + factory = { ctx -> + PhotoView(ctx).apply { + maximumScale = MAX_SCALE + mediumScale = MEDIUM_SCALE + val displayMetrics = ctx.resources.displayMetrics + val bitmap = BitmapShrinker.shrinkBitmap( + localPath, + displayMetrics.widthPixels * 2, + displayMetrics.heightPixels * 2 + ) + if (bitmap != null) { + setImageBitmap(bitmap) + } else { + Log.e(TAG, "bitmap could not be decoded from path: $localPath") + } + } + }, + modifier = Modifier.fillMaxSize() + ) +} + +@OptIn(UnstableApi::class) +@Composable +private fun VideoPlayerView(exoPlayer: ExoPlayer) { + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + player = exoPlayer + useController = true + } + }, + modifier = Modifier.fillMaxSize() + ) +} + +@Composable +private fun ThumbnailStrip( + group: MediaViewerGroup, + currentItem: MediaViewerItem?, + modifier: Modifier = Modifier, + onThumbnailClick: (MediaViewerItem) -> Unit +) { + val listState = rememberLazyListState() + val density = LocalDensity.current + val currentIndex = currentItem?.let { item -> group.items.indexOfFirst { it.messageId == item.messageId } } ?: -1 + + LaunchedEffect(group.key, currentIndex) { + if (currentIndex < 0) return@LaunchedEffect + val itemWidthPx = with(density) { (thumbnailSize + thumbnailSpacing).toPx() } + val viewportWidthPx = listState.layoutInfo.viewportSize.width + val centerOffset = (viewportWidthPx / 2f - itemWidthPx / 2f).toInt() + listState.animateScrollToItem(currentIndex, -centerOffset) + } + + LazyRow( + state = listState, + horizontalArrangement = Arrangement.spacedBy(thumbnailSpacing), + contentPadding = PaddingValues(horizontal = thumbnailStripPadding), + modifier = modifier + .fillMaxWidth() + .background(Color.Black.copy(alpha = TOOLBAR_ALPHA)) + .padding(vertical = thumbnailStripPadding) + ) { + items(group.items, key = { it.messageId }) { item -> + ThumbnailTile( + item = item, + isSelected = item.messageId == currentItem?.messageId, + onClick = { onThumbnailClick(item) } + ) + } + } +} + +@Composable +private fun ThumbnailTile(item: MediaViewerItem, isSelected: Boolean, onClick: () -> Unit) { + val isVideo = item.mimeType.startsWith(Mimetype.VIDEO_PREFIX) + Box( + modifier = Modifier + .size(thumbnailSize) + .clip(RoundedCornerShape(4.dp)) + .then( + if (isSelected) { + Modifier.border(2.dp, Color.White, RoundedCornerShape(4.dp)) + } else { + Modifier + } + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + if (item.previewUrl != null) { + AsyncImage( + model = item.previewUrl, + contentDescription = item.fileName, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Image( + painter = painterResource(DrawableUtils.getDrawableResourceIdForMimeType(item.mimeType)), + contentDescription = item.fileName, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit + ) + } + if (isVideo) { + Icon( + painter = painterResource(R.drawable.ic_baseline_play_arrow_voice_message_24), + contentDescription = stringResource(R.string.media_message_content_play), + tint = Color.White, + modifier = Modifier.size(16.dp) + ) + } + } +} + +private const val TAG = "MediaViewerScreen" diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt new file mode 100644 index 00000000000..7f222bb8bf5 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt @@ -0,0 +1,71 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.mediaviewer.model + +import android.os.Parcelable +import com.nextcloud.talk.utils.message.groupHashOf +import kotlinx.parcelize.Parcelize + +/** + * A single swipeable item in the media viewer - built from either a locally loaded chat message + * (already-synced upload batches) or a network-fetched [com.nextcloud.talk.shareditems.model.SharedFileItem] + * (older history beyond what's loaded locally). Both sources converge on this shape so the viewer + * and its grouping logic don't need to know which one an item came from. Parcelable so a seed list + * of these can be passed directly through the Intent that launches the viewer. + */ +@Parcelize +data class MediaViewerItem( + val messageId: Long, + val referenceId: String?, + val fileId: String, + val fileName: String, + val mimeType: String, + val path: String, + val link: String, + val fileSize: Long, + val previewUrl: String?, + val actorDisplayName: String +) : Parcelable + +/** + * A run of consecutive [MediaViewerItem]s uploaded together in one batch (matching referenceId + * hash, see [groupHashOf]), or a single lone item - mirrors ChatViewModel.MediaGroupItem, but + * independent of it since it must also represent items fetched from the network. Ordered + * chronologically, oldest first. + */ +data class MediaViewerGroup(val items: List) { + val key: Any get() = items.first().referenceId?.takeIf { it.isNotBlank() } ?: items.first().messageId +} + +/** + * Clusters a chronologically ordered (oldest first) list of items into [MediaViewerGroup]s, + * exactly like ChatViewModel.combineFileShareGroups() does for the chat list - consecutive items + * sharing the same upload-batch hash become one group, anything else stays a group of one. + */ +fun List.toMediaViewerGroups(): List { + val result = mutableListOf() + var pending = mutableListOf() + + fun flush() { + if (pending.isNotEmpty()) { + result.add(MediaViewerGroup(pending.toList())) + } + pending = mutableListOf() + } + + for (item in this) { + val pendingHash = pending.lastOrNull()?.let { groupHashOf(it.referenceId) } + val itemHash = groupHashOf(item.referenceId) + if (pending.isNotEmpty() && (pendingHash == null || pendingHash != itemHash)) { + flush() + } + pending.add(item) + } + flush() + + return result +} diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt new file mode 100644 index 00000000000..fdef40e606b --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt @@ -0,0 +1,256 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.mediaviewer.viewmodels + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow +import androidx.lifecycle.viewModelScope +import androidx.work.Data +import androidx.work.OneTimeWorkRequest +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.jobs.DownloadFileToCacheWorker +import com.nextcloud.talk.mediaviewer.model.MediaViewerGroup +import com.nextcloud.talk.mediaviewer.model.MediaViewerItem +import com.nextcloud.talk.mediaviewer.model.toMediaViewerGroups +import com.nextcloud.talk.shareditems.model.SharedFileItem +import com.nextcloud.talk.shareditems.model.SharedItemType +import com.nextcloud.talk.shareditems.model.SharedItems +import com.nextcloud.talk.shareditems.repositories.SharedItemsRepository +import com.nextcloud.talk.utils.CapabilitiesUtil +import com.nextcloud.talk.utils.FileUtils +import com.nextcloud.talk.utils.Mimetype +import io.reactivex.Observable +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * Backs the swipeable media viewer: a flattened, chronologically ordered (oldest first) sequence + * of [MediaViewerGroup]s. Seeded synchronously from whatever the chat screen already had loaded + * (see ChatViewModel.mediaViewerSeed()) - which alone covers all "newer" navigation, since that + * direction has no network fallback (see class doc on the pending-index-shift mechanism below for + * why "older" does). "Older" navigation pages further back through conversation history via + * [SharedItemsRepository] once the locally seeded items run out. + */ +class MediaViewerViewModel @Inject constructor(private val sharedItemsRepository: SharedItemsRepository) : + ViewModel() { + + data class UiState( + val groups: List = emptyList(), + val currentGlobalIndex: Int = 0, + val loadingOlder: Boolean = false, + val canLoadOlder: Boolean = true, + val cachedFilePaths: Map = emptyMap(), + val downloadingMessageIds: Set = emptySet() + ) { + val flattenedItems: List get() = groups.flatMap { it.items } + val currentItem: MediaViewerItem? get() = flattenedItems.getOrNull(currentGlobalIndex) + val currentGroup: MediaViewerGroup? + get() { + val item = currentItem ?: return null + return groups.firstOrNull { group -> group.items.any { it.messageId == item.messageId } } + } + } + + private val _uiState = MutableStateFlow(UiState()) + val uiState: StateFlow = _uiState + + // HorizontalPager indexes into the flattened item list; prepending older groups shifts every + // existing index by the number of items prepended. This is a one-shot event (not part of + // UiState) so the pager can silently jump to the shifted position - via + // pagerState.scrollToPage(pagerState.currentPage + shift) - the moment it fires, keeping the + // same item on screen instead of visually jumping to whatever is now at the old index. + private val _indexShiftEvents = MutableSharedFlow(extraBufferCapacity = 1) + val indexShiftEvents = _indexShiftEvents + + private lateinit var user: User + private lateinit var repositoryParameters: SharedItemsRepository.Parameters + private var oldestKnownMessageId: Long? = null + + fun initialize(user: User, roomToken: String, seedItems: List, startMessageId: Long) { + this.user = user + repositoryParameters = SharedItemsRepository.Parameters( + user.userId!!, + user.token!!, + user.baseUrl!!, + roomToken + ) + val groups = seedItems.toMediaViewerGroups() + val startIndex = seedItems.indexOfFirst { it.messageId == startMessageId }.coerceAtLeast(0) + oldestKnownMessageId = seedItems.minOfOrNull { it.messageId } + + _uiState.value = UiState(groups = groups, currentGlobalIndex = startIndex) + ensureCachedAround(startIndex) + } + + fun onPageSettled(globalIndex: Int) { + _uiState.update { it.copy(currentGlobalIndex = globalIndex) } + ensureCachedAround(globalIndex) + if (globalIndex <= EDGE_LOAD_THRESHOLD) { + loadOlderGroups() + } + } + + fun jumpTo(globalIndex: Int) { + onPageSettled(globalIndex) + } + + private fun ensureCachedAround(globalIndex: Int) { + val items = _uiState.value.flattenedItems + for (index in (globalIndex - PREFETCH_RADIUS)..(globalIndex + PREFETCH_RADIUS)) { + items.getOrNull(index)?.let(::ensureCached) + } + } + + private fun ensureCached(item: MediaViewerItem) { + if (_uiState.value.cachedFilePaths.containsKey(item.messageId) || + _uiState.value.downloadingMessageIds.contains(item.messageId) + ) { + return + } + + val context = NextcloudTalkApplication.sharedApplication!! + val existing = FileUtils.resolveSharedAttachmentFile(context.cacheDir, item.fileName) + if (existing != null && existing.exists()) { + _uiState.update { + it.copy(cachedFilePaths = it.cachedFilePaths + (item.messageId to existing.absolutePath)) + } + return + } + + _uiState.update { it.copy(downloadingMessageIds = it.downloadingMessageIds + item.messageId) } + + val data = Data.Builder() + .putString(DownloadFileToCacheWorker.KEY_BASE_URL, user.baseUrl) + .putString(DownloadFileToCacheWorker.KEY_USER_ID, user.userId) + .putString( + DownloadFileToCacheWorker.KEY_ATTACHMENT_FOLDER, + CapabilitiesUtil.getAttachmentFolder(user.capabilities!!.spreedCapability!!) + ) + .putString(DownloadFileToCacheWorker.KEY_FILE_NAME, item.fileName) + .putString(DownloadFileToCacheWorker.KEY_FILE_PATH, item.path) + .putLong(DownloadFileToCacheWorker.KEY_FILE_SIZE, item.fileSize) + .build() + + val request = OneTimeWorkRequest.Builder(DownloadFileToCacheWorker::class.java) + .setInputData(data) + .addTag(item.fileId) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + WorkManager.getInstance(context).enqueue(request) + + viewModelScope.launch { + WorkManager.getInstance(context).getWorkInfoByIdLiveData(request.id).asFlow().collect { workInfo -> + if (workInfo == null) return@collect + when (workInfo.state) { + WorkInfo.State.SUCCEEDED -> { + val downloaded = FileUtils.resolveSharedAttachmentFile(context.cacheDir, item.fileName) + _uiState.update { + it.copy( + downloadingMessageIds = it.downloadingMessageIds - item.messageId, + cachedFilePaths = downloaded?.let { file -> + it.cachedFilePaths + (item.messageId to file.absolutePath) + } ?: it.cachedFilePaths + ) + } + } + WorkInfo.State.FAILED, WorkInfo.State.CANCELLED -> { + _uiState.update { it.copy(downloadingMessageIds = it.downloadingMessageIds - item.messageId) } + } + else -> Unit + } + } + } + } + + @Suppress("TooGenericExceptionCaught") + private fun loadOlderGroups() { + val state = _uiState.value + if (state.loadingOlder || !state.canLoadOlder) return + val cursor = oldestKnownMessageId ?: run { + _uiState.update { it.copy(canLoadOlder = false) } + return + } + + _uiState.update { it.copy(loadingOlder = true) } + viewModelScope.launch { + val sharedItems = try { + sharedItemsRepository.media(repositoryParameters, SharedItemType.MEDIA, cursor.toInt())?.awaitFirst() + } catch (e: Exception) { + Log.e(TAG, "Failed to load older shared items", e) + null + } + + val olderItems = sharedItems?.items.orEmpty() + .filterIsInstance() + .filter { it.isImageOrVideo() } + .map { it.toMediaViewerItem() } + .sortedBy { it.messageId } + + if (olderItems.isEmpty()) { + _uiState.update { it.copy(loadingOlder = false, canLoadOlder = false) } + return@launch + } + + oldestKnownMessageId = olderItems.first().messageId + val previousCount = _uiState.value.flattenedItems.size + _uiState.update { current -> + val combined = (olderItems + current.flattenedItems).toMediaViewerGroups() + current.copy( + groups = combined, + currentGlobalIndex = current.currentGlobalIndex + olderItems.size, + loadingOlder = false, + canLoadOlder = sharedItems?.moreItemsExisting == true + ) + } + _indexShiftEvents.tryEmit(_uiState.value.flattenedItems.size - previousCount) + } + } + + private fun SharedFileItem.isImageOrVideo(): Boolean = + mimeType.startsWith(Mimetype.IMAGE_PREFIX) || mimeType.startsWith(Mimetype.VIDEO_PREFIX) + + private fun SharedFileItem.toMediaViewerItem() = MediaViewerItem( + messageId = messageId, + referenceId = referenceId, + fileId = id, + fileName = name, + mimeType = mimeType, + path = path, + link = link, + fileSize = fileSize, + previewUrl = previewLink.takeIf { previewAvailable }, + actorDisplayName = actorName + ) + + private suspend fun Observable.awaitFirst(): SharedItems = + suspendCancellableCoroutine { continuation -> + val disposable = subscribe( + { continuation.resume(it) }, + { continuation.resumeWithException(it) } + ) + continuation.invokeOnCancellation { disposable.dispose() } + } + + companion object { + private val TAG = MediaViewerViewModel::class.simpleName + private const val PREFETCH_RADIUS = 1 + private const val EDGE_LOAD_THRESHOLD = 1 + } +} diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/model/SharedFileItem.kt b/app/src/main/java/com/nextcloud/talk/shareditems/model/SharedFileItem.kt index 17b09c78d58..9842f4fa078 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/model/SharedFileItem.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/model/SharedFileItem.kt @@ -18,5 +18,7 @@ data class SharedFileItem( val link: String, val mimeType: String, val previewAvailable: Boolean = false, - val previewLink: String + val previewLink: String, + val messageId: Long, + val referenceId: String? ) : SharedItem diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepositoryImpl.kt index 01eed88f83b..bf0eb8d3d5d 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepositoryImpl.kt @@ -103,7 +103,9 @@ class SharedItemsRepositoryImpl @Inject constructor(private val ncApi: NcApi, pr fileParameters["link"]!!, fileParameters["mimetype"]!!, previewAvailable, - previewLink(fileParameters["id"], parameters.baseUrl!!) + previewLink(fileParameters["id"], parameters.baseUrl!!), + it.value.id, + it.value.referenceId ) } else if (it.value.messageParameters?.containsKey("object") == true) { val objectParameters = it.value.messageParameters!!["object"]!! @@ -115,7 +117,10 @@ class SharedItemsRepositoryImpl @Inject constructor(private val ncApi: NcApi, pr } } - val sortedMutableItems = items.toSortedMap().values.toList().reversed().toMutableList() + // Sort by numeric message id, not the map key's lexicographic String order (which would + // sort "10" before "9") - the viewer that consumes this list depends on true chronological + // order to reconstruct upload-batch groups and page adjacency correctly. + val sortedMutableItems = items.toSortedMap(compareBy { it.toLong() }).values.toList().reversed().toMutableList() val moreItemsExisting = items.count() == BATCH_SIZE return SharedItems( diff --git a/app/src/main/java/com/nextcloud/talk/utils/message/SendMessageUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/message/SendMessageUtils.kt index 54a548121f0..07fd80b21c4 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/message/SendMessageUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/message/SendMessageUtils.kt @@ -10,6 +10,20 @@ import java.security.MessageDigest import java.util.Calendar import java.util.UUID +// Matches referenceIds built by SendMessageUtils.generateGroupedReferenceId(): +// sha256(uploadId)[0:60] + "-" + order, zero-padded to 3 digits. Shared cross-client format, +// see https://github.com/nextcloud/spreed/pull/19040 +private val groupedReferenceIdRegex = Regex("^([a-f0-9]{60})-[0-9]{3}$") + +/** + * Returns the shared upload-batch hash from a referenceId built by + * [SendMessageUtils.generateGroupedReferenceId] (or an equivalent from another client), or null if + * [referenceId] doesn't match that format. Two items belong to the same upload batch when this + * returns the same non-null value for both. + */ +fun groupHashOf(referenceId: String?): String? = + referenceId?.let { groupedReferenceIdRegex.matchEntire(it)?.groupValues?.get(1) } + class SendMessageUtils { fun generateReferenceId(): String { val randomString = UUID.randomUUID().toString() diff --git a/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItemTest.kt b/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItemTest.kt new file mode 100644 index 00000000000..09e790df3cb --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItemTest.kt @@ -0,0 +1,94 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.mediaviewer.model + +import com.nextcloud.talk.utils.message.SendMessageUtils +import org.junit.Assert.assertEquals +import org.junit.Test + +class MediaViewerItemTest { + + private val refUtils = SendMessageUtils() + + private fun item(messageId: Long, referenceId: String?) = + MediaViewerItem( + messageId = messageId, + referenceId = referenceId, + fileId = "file-$messageId", + fileName = "file-$messageId.jpg", + mimeType = "image/jpeg", + path = "/remote.php/dav/files/user/file-$messageId.jpg", + link = "https://example.com/f/$messageId", + fileSize = 1024L, + previewUrl = null, + actorDisplayName = "Jane Doe" + ) + + @Test + fun `consecutive items from the same batch form one group`() { + val uploadId = "batch-1" + val items = listOf( + item(1, refUtils.generateGroupedReferenceId(uploadId, 1)), + item(2, refUtils.generateGroupedReferenceId(uploadId, 2)), + item(3, refUtils.generateGroupedReferenceId(uploadId, 3)) + ) + + val groups = items.toMediaViewerGroups() + + assertEquals(1, groups.size) + assertEquals(listOf(1L, 2L, 3L), groups[0].items.map { it.messageId }) + } + + @Test + fun `a different batch hash starts a new group`() { + val items = listOf( + item(1, refUtils.generateGroupedReferenceId("batch-1", 1)), + item(2, refUtils.generateGroupedReferenceId("batch-1", 2)), + item(3, refUtils.generateGroupedReferenceId("batch-2", 1)) + ) + + val groups = items.toMediaViewerGroups() + + assertEquals(2, groups.size) + assertEquals(listOf(1L, 2L), groups[0].items.map { it.messageId }) + assertEquals(listOf(3L), groups[1].items.map { it.messageId }) + } + + @Test + fun `items with no grouped referenceId each stay their own group`() { + val items = listOf( + item(1, "plain-hex-reference-id-not-matching-the-batch-format"), + item(2, null) + ) + + val groups = items.toMediaViewerGroups() + + assertEquals(2, groups.size) + assertEquals(listOf(1L), groups[0].items.map { it.messageId }) + assertEquals(listOf(2L), groups[1].items.map { it.messageId }) + } + + @Test + fun `an empty list produces no groups`() { + val groups = emptyList().toMediaViewerGroups() + + assertEquals(0, groups.size) + } + + @Test + fun `group key is stable and unique per group`() { + val items = listOf( + item(1, refUtils.generateGroupedReferenceId("batch-1", 1)), + item(2, refUtils.generateGroupedReferenceId("batch-1", 2)), + item(3, refUtils.generateGroupedReferenceId("batch-2", 1)) + ) + + val keys = items.toMediaViewerGroups().map { it.key } + + assertEquals(keys.size, keys.toSet().size) + } +} From 18ee6d39c9f06b2d64a15d146faeacc117981d00 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 13:01:54 +0200 Subject: [PATCH 09/31] fix(mediaviewer): Remove the swipe-to-close gesture from the viewer SwipeToCloseLayout (added in #5756 for the single-item viewers) wraps the whole screen in a ViewDragHelper that intercepts drags at the parent level before the HorizontalPager's own gesture handling ever sees them. A real-world swipe is rarely perfectly horizontal, so its small vertical component was enough to trigger the dismiss gesture, closing the viewer on what was meant as a page-navigation swipe. Drop it from MediaViewerActivity entirely rather than trying to make it coexist with horizontal paging - closing is still available via the top bar's Close button and the system back gesture/button. FullScreenImageActivity/FullScreenMediaActivity (which have no horizontal gesture to conflict with) keep using it unchanged. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../activities/MediaViewerActivity.kt | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt index 90c12e0db3f..95cf9b3fc85 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt @@ -10,7 +10,6 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.util.Log -import android.widget.FrameLayout import androidx.activity.SystemBarStyle import androidx.activity.enableEdgeToEdge import androidx.compose.material3.MaterialTheme @@ -30,7 +29,6 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider import com.nextcloud.talk.mediaviewer.model.MediaViewerItem import com.nextcloud.talk.mediaviewer.viewmodels.MediaViewerViewModel -import com.nextcloud.talk.ui.SwipeToCloseLayout import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.Mimetype.IMAGE_PREFIX_GENERIC @@ -76,13 +74,12 @@ class MediaViewerActivity : BaseActivity() { val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - val swipeToCloseLayout = SwipeToCloseLayout(this) - swipeToCloseLayout.setOnSwipeToCloseListener(object : SwipeToCloseLayout.OnSwipeToCloseListener { - override fun onSwipeToClose() { - finish() - } - }) - + // Deliberately no SwipeToCloseLayout here (unlike FullScreenImageActivity/ + // FullScreenMediaActivity): its ViewDragHelper intercepts drags at the parent level before + // the HorizontalPager below ever sees them, and a real swipe is rarely perfectly + // horizontal - the small vertical component was enough to trigger it, closing the viewer + // on what the user meant as a page-navigation swipe. Closing is still available via the + // top bar's Close button and the system back gesture/button. val composeView = ComposeView(this).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { @@ -97,11 +94,7 @@ class MediaViewerActivity : BaseActivity() { } } - swipeToCloseLayout.addView( - composeView, - FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) - ) - setContentView(swipeToCloseLayout) + setContentView(composeView) } private fun shareFile(item: MediaViewerItem, localPath: String) { From c927c6642529ae8db5d0299ef53e078d9b7043b1 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 13:13:50 +0200 Subject: [PATCH 10/31] fix(mediaviewer): Only autoplay the video the viewer was opened on Every video landed on became playWhenReady=true, so swiping to a video auto-started it too. Now only the item the viewer was directly opened on from chat autoplays, once; anything reached by swiping - including swiping back to that same item later - stays paused until the user taps play. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../mediaviewer/activities/MediaViewerScreen.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt index 5ae412bc56f..8eb5e3899e4 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt @@ -33,8 +33,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -77,7 +79,7 @@ private val thumbnailSize = 48.dp private val thumbnailSpacing = 4.dp private val thumbnailStripPadding = 12.dp -@Suppress("Detekt.LongMethod") +@Suppress("Detekt.LongMethod", "CyclomaticComplexMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable fun MediaViewerScreen( @@ -113,11 +115,19 @@ fun MediaViewerScreen( val exoPlayer = rememberViewerExoPlayer() val currentItem = uiState.currentItem val currentLocalPath = currentItem?.let { uiState.cachedFilePaths[it.messageId] } + + // Autoplay only the item the viewer was directly opened on from chat - once the user has + // swiped to any other item, nothing autoplays again, even swiping back to this one. + val initialMessageId = remember { items.getOrNull(uiState.currentGlobalIndex)?.messageId } + var hasAutoPlayed by remember { mutableStateOf(false) } + LaunchedEffect(currentItem?.messageId, currentLocalPath) { val isVideo = currentItem?.mimeType?.startsWith(Mimetype.VIDEO_PREFIX) == true if (isVideo && currentLocalPath != null) { exoPlayer.setMediaItem(MediaItem.fromUri(currentLocalPath.toUri())) - exoPlayer.playWhenReady = true + val shouldAutoPlay = !hasAutoPlayed && currentItem.messageId == initialMessageId + exoPlayer.playWhenReady = shouldAutoPlay + if (shouldAutoPlay) hasAutoPlayed = true exoPlayer.prepare() } else { exoPlayer.stop() From 7575125abe94fa540790d5296cfa02ca6067fba4 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 13:21:12 +0200 Subject: [PATCH 11/31] fix(mediaviewer): Properly center the current thumbnail in the strip The old centering math estimated an offset by hand and applied it as a scroll target adjustment, which only worked once the LazyRow had already been laid out and never let the first/last thumbnail actually reach the center (the row simply ran out of content to scroll). Compute the row's side content padding from its own measured width (via BoxWithConstraints) so every item, including the first and last, can scroll all the way to the exact horizontal center - then just animateScrollToItem() the current index on each change, so swiping smoothly slides the row until the new current item lands centered. Also splits the strip's padding so the bottom margin (space to the screen edge) is distinct from the top. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../activities/MediaViewerScreen.kt | 51 +++++++++++-------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt index 8eb5e3899e4..b47c3c04451 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -44,7 +45,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -77,7 +77,8 @@ private const val MEDIUM_SCALE = 2.45f private val thumbnailSize = 48.dp private val thumbnailSpacing = 4.dp -private val thumbnailStripPadding = 12.dp +private val thumbnailStripTopPadding = 12.dp +private val thumbnailStripBottomPadding = 24.dp @Suppress("Detekt.LongMethod", "CyclomaticComplexMethod") @OptIn(ExperimentalMaterial3Api::class) @@ -285,32 +286,38 @@ private fun ThumbnailStrip( onThumbnailClick: (MediaViewerItem) -> Unit ) { val listState = rememberLazyListState() - val density = LocalDensity.current val currentIndex = currentItem?.let { item -> group.items.indexOfFirst { it.messageId == item.messageId } } ?: -1 - LaunchedEffect(group.key, currentIndex) { - if (currentIndex < 0) return@LaunchedEffect - val itemWidthPx = with(density) { (thumbnailSize + thumbnailSpacing).toPx() } - val viewportWidthPx = listState.layoutInfo.viewportSize.width - val centerOffset = (viewportWidthPx / 2f - itemWidthPx / 2f).toInt() - listState.animateScrollToItem(currentIndex, -centerOffset) - } - - LazyRow( - state = listState, - horizontalArrangement = Arrangement.spacedBy(thumbnailSpacing), - contentPadding = PaddingValues(horizontal = thumbnailStripPadding), + BoxWithConstraints( modifier = modifier .fillMaxWidth() .background(Color.Black.copy(alpha = TOOLBAR_ALPHA)) - .padding(vertical = thumbnailStripPadding) + .padding(top = thumbnailStripTopPadding, bottom = thumbnailStripBottomPadding) ) { - items(group.items, key = { it.messageId }) { item -> - ThumbnailTile( - item = item, - isSelected = item.messageId == currentItem?.messageId, - onClick = { onThumbnailClick(item) } - ) + // Side padding equal to half the leftover viewport width, so the row can scroll any item - + // including the first/last - all the way to the exact horizontal center, not just as close + // to it as the natural content bounds allow. + val sidePadding = ((maxWidth - thumbnailSize) / 2).coerceAtLeast(0.dp) + + LaunchedEffect(group.key, currentIndex) { + if (currentIndex >= 0) { + listState.animateScrollToItem(currentIndex) + } + } + + LazyRow( + state = listState, + horizontalArrangement = Arrangement.spacedBy(thumbnailSpacing), + contentPadding = PaddingValues(horizontal = sidePadding), + modifier = Modifier.fillMaxWidth() + ) { + items(group.items, key = { it.messageId }) { item -> + ThumbnailTile( + item = item, + isSelected = item.messageId == currentItem?.messageId, + onClick = { onThumbnailClick(item) } + ) + } } } } From 72d73c7aa96cf6c3722f4d128f3b396e697356c7 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 13:33:01 +0200 Subject: [PATCH 12/31] feat(mediaviewer): Tap the shown item to toggle top bar and strip Mirrors FullScreenImageScreen/FullScreenMediaScreen's existing tap-to-toggle-fullscreen behavior: tapping the currently displayed image, gif or video now hides the top bar and thumbnail strip so only the media itself is visible, and tapping again brings them back. Video pages sync to ExoPlayer's own controller visibility (PlayerView.setControllerVisibilityListener) rather than an independent toggle, since the controller already shows/hides itself on tap and auto-hides after a timeout - keeping a single source of truth for "are controls visible" instead of two that could disagree. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../activities/MediaViewerScreen.kt | 87 +++++++++++++------ 1 file changed, 62 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt index b47c3c04451..3de8c6b4a20 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt @@ -7,6 +7,7 @@ package com.nextcloud.talk.mediaviewer.activities import android.util.Log +import android.view.View import androidx.annotation.OptIn import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -135,6 +136,13 @@ fun MediaViewerScreen( } } + // Tapping the currently shown item toggles this off, hiding the top bar and thumbnail strip so + // only the media itself is visible - mirrors FullScreenImageScreen/FullScreenMediaScreen's own + // tap-to-toggle-fullscreen behavior. For video, ExoPlayer's own controller visibility is the + // source of truth (see VideoPlayerView) rather than an independently toggled flag, since the + // controller already auto-hides itself after a timeout. + var showControls by remember { mutableStateOf(true) } + Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> val item = items.getOrNull(page) ?: return@HorizontalPager @@ -142,23 +150,31 @@ fun MediaViewerScreen( item = item, localPath = uiState.cachedFilePaths[item.messageId], isCurrentPage = page == pagerState.currentPage, - exoPlayer = exoPlayer + exoPlayer = exoPlayer, + onToggleControls = { showControls = !showControls }, + onControlsVisibilityChanged = { showControls = it } ) } - val toolbarColors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - titleContentColor = Color.White, - navigationIconContentColor = Color.White, - actionIconContentColor = Color.White - ) - val menuItems = buildList { - if (currentItem != null && currentLocalPath != null) { - add(stringResource(R.string.share) to { onShare(currentItem, currentLocalPath) }) - add(stringResource(R.string.nc_save_message) to { onSave(currentItem, currentLocalPath) }) + if (showControls) { + val toolbarColors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + titleContentColor = Color.White, + navigationIconContentColor = Color.White, + actionIconContentColor = Color.White + ) + val menuItems = buildList { + if (currentItem != null && currentLocalPath != null) { + add(stringResource(R.string.share) to { onShare(currentItem, currentLocalPath) }) + add(stringResource(R.string.nc_save_message) to { onSave(currentItem, currentLocalPath) }) + } } + StandardAppBar( + title = currentItem?.actorDisplayName.orEmpty(), + menuItems = menuItems, + colors = toolbarColors + ) } - StandardAppBar(title = currentItem?.actorDisplayName.orEmpty(), menuItems = menuItems, colors = toolbarColors) if (currentItem != null && currentLocalPath == null) { CircularProgressIndicator( @@ -168,7 +184,7 @@ fun MediaViewerScreen( } val group = uiState.currentGroup - if (group != null && group.items.size > 1) { + if (showControls && group != null && group.items.size > 1) { ThumbnailStrip( group = group, currentItem = currentItem, @@ -200,53 +216,68 @@ private fun rememberViewerExoPlayer(): ExoPlayer { return player } +@Suppress("LongParameterList") @OptIn(UnstableApi::class) @Composable -private fun MediaPage(item: MediaViewerItem, localPath: String?, isCurrentPage: Boolean, exoPlayer: ExoPlayer) { +private fun MediaPage( + item: MediaViewerItem, + localPath: String?, + isCurrentPage: Boolean, + exoPlayer: ExoPlayer, + onToggleControls: () -> Unit, + onControlsVisibilityChanged: (Boolean) -> Unit +) { val isVideo = item.mimeType.startsWith(Mimetype.VIDEO_PREFIX) val isGif = MimetypeUtils.isGif(item.mimeType) Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { when { - localPath == null -> PreviewPlaceholder(item) + localPath == null -> PreviewPlaceholder(item, onToggleControls = onToggleControls) isVideo -> if (isCurrentPage) { - VideoPlayerView(exoPlayer = exoPlayer) + VideoPlayerView(exoPlayer = exoPlayer, onControlsVisibilityChanged = onControlsVisibilityChanged) } else { - PreviewPlaceholder(item) + PreviewPlaceholder(item, onToggleControls = onToggleControls) } - isGif -> GifPage(localPath = localPath) - else -> ImagePage(localPath = localPath) + isGif -> GifPage(localPath = localPath, onToggleControls = onToggleControls) + else -> ImagePage(localPath = localPath, onToggleControls = onToggleControls) } } } @Composable -private fun PreviewPlaceholder(item: MediaViewerItem) { +private fun PreviewPlaceholder(item: MediaViewerItem, onToggleControls: () -> Unit) { if (item.previewUrl != null) { AsyncImage( model = item.previewUrl, contentDescription = item.fileName, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().clickable(onClick = onToggleControls), contentScale = ContentScale.Fit ) } } @Composable -private fun GifPage(localPath: String) { +private fun GifPage(localPath: String, onToggleControls: () -> Unit) { AndroidView( - factory = { ctx -> GifImageView(ctx).apply { setImageDrawable(GifDrawable(localPath)) } }, + factory = { ctx -> + GifImageView(ctx).apply { + setImageDrawable(GifDrawable(localPath)) + setOnClickListener { onToggleControls() } + } + }, modifier = Modifier.fillMaxSize() ) } @Composable -private fun ImagePage(localPath: String) { +private fun ImagePage(localPath: String, onToggleControls: () -> Unit) { AndroidView( factory = { ctx -> PhotoView(ctx).apply { maximumScale = MAX_SCALE mediumScale = MEDIUM_SCALE + setOnPhotoTapListener { _, _, _ -> onToggleControls() } + setOnOutsidePhotoTapListener { onToggleControls() } val displayMetrics = ctx.resources.displayMetrics val bitmap = BitmapShrinker.shrinkBitmap( localPath, @@ -266,12 +297,18 @@ private fun ImagePage(localPath: String) { @OptIn(UnstableApi::class) @Composable -private fun VideoPlayerView(exoPlayer: ExoPlayer) { +private fun VideoPlayerView(exoPlayer: ExoPlayer, onControlsVisibilityChanged: (Boolean) -> Unit) { AndroidView( factory = { ctx -> PlayerView(ctx).apply { player = exoPlayer useController = true + showController() + setControllerVisibilityListener( + PlayerView.ControllerVisibilityListener { visibility -> + onControlsVisibilityChanged(visibility == View.VISIBLE) + } + ) } }, modifier = Modifier.fillMaxSize() From 7a2383c6db4787369e83e7856a97759c7e2d0bd2 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 13:41:00 +0200 Subject: [PATCH 13/31] fix(mediaviewer): Fade controls in/out instead of sliding sideways The top bar and thumbnail strip were plain-conditionally included in composition (if (showControls) { ... }), so every reveal remounted the thumbnail strip's LazyListState from scratch and re-ran its scroll-to-current-item animation from an unscrolled starting point - visually reading as the thumbnails sliding in from the side, not the intended toggle. Hoisted the thumbnail strip's LazyListState up to MediaViewerScreen so it survives the strip being hidden and shown again, and replaced both conditionals with AnimatedVisibility: the top bar fades in/out while sliding from/to the top, the thumbnail strip fades in/out while sliding from/to the bottom. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../activities/MediaViewerScreen.kt | 54 ++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt index 3de8c6b4a20..a8bd5d0e906 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt @@ -9,6 +9,11 @@ package com.nextcloud.talk.mediaviewer.activities import android.util.Log import android.view.View import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -21,6 +26,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -46,6 +52,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -80,6 +87,7 @@ private val thumbnailSize = 48.dp private val thumbnailSpacing = 4.dp private val thumbnailStripTopPadding = 12.dp private val thumbnailStripBottomPadding = 24.dp +private val controlsSlideDistance = 24.dp @Suppress("Detekt.LongMethod", "CyclomaticComplexMethod") @OptIn(ExperimentalMaterial3Api::class) @@ -143,6 +151,14 @@ fun MediaViewerScreen( // controller already auto-hides itself after a timeout. var showControls by remember { mutableStateOf(true) } + // Hoisted here (rather than inside ThumbnailStrip) so its scroll position survives showControls + // toggling the strip in and out of composition - otherwise every reveal remounted a fresh, + // unscrolled LazyListState and re-animated the scroll from the start, reading as the thumbnails + // sliding in from the side instead of the intended plain fade. + val thumbnailListState = rememberLazyListState() + val density = LocalDensity.current + val slideOffsetPx = with(density) { controlsSlideDistance.roundToPx() } + Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> val item = items.getOrNull(page) ?: return@HorizontalPager @@ -156,7 +172,12 @@ fun MediaViewerScreen( ) } - if (showControls) { + AnimatedVisibility( + visible = showControls, + enter = fadeIn() + slideInVertically(initialOffsetY = { -slideOffsetPx }), + exit = fadeOut() + slideOutVertically(targetOffsetY = { -slideOffsetPx }), + modifier = Modifier.align(Alignment.TopCenter) + ) { val toolbarColors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, titleContentColor = Color.White, @@ -184,18 +205,25 @@ fun MediaViewerScreen( } val group = uiState.currentGroup - if (showControls && group != null && group.items.size > 1) { - ThumbnailStrip( - group = group, - currentItem = currentItem, - modifier = Modifier.align(Alignment.BottomCenter), - onThumbnailClick = { clickedItem -> - val globalIndex = items.indexOfFirst { it.messageId == clickedItem.messageId } - if (globalIndex >= 0) { - coroutineScope.launch { pagerState.animateScrollToPage(globalIndex) } + AnimatedVisibility( + visible = showControls && group != null && group.items.size > 1, + enter = fadeIn() + slideInVertically(initialOffsetY = { slideOffsetPx }), + exit = fadeOut() + slideOutVertically(targetOffsetY = { slideOffsetPx }), + modifier = Modifier.align(Alignment.BottomCenter) + ) { + if (group != null) { + ThumbnailStrip( + group = group, + currentItem = currentItem, + listState = thumbnailListState, + onThumbnailClick = { clickedItem -> + val globalIndex = items.indexOfFirst { it.messageId == clickedItem.messageId } + if (globalIndex >= 0) { + coroutineScope.launch { pagerState.animateScrollToPage(globalIndex) } + } } - } - ) + ) + } } } } @@ -319,10 +347,10 @@ private fun VideoPlayerView(exoPlayer: ExoPlayer, onControlsVisibilityChanged: ( private fun ThumbnailStrip( group: MediaViewerGroup, currentItem: MediaViewerItem?, + listState: LazyListState, modifier: Modifier = Modifier, onThumbnailClick: (MediaViewerItem) -> Unit ) { - val listState = rememberLazyListState() val currentIndex = currentItem?.let { item -> group.items.indexOfFirst { it.messageId == item.messageId } } ?: -1 BoxWithConstraints( From a09a2cfea70f9ba8cee69e86e621f27556e55ec8 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 13:49:21 +0200 Subject: [PATCH 14/31] fix(mediaviewer): Keep the video's own controller clear of the strip ExoPlayer's controller (progress bar, play/pause row) rendered at the bottom of the video regardless of whether the thumbnail strip was also showing there, so the strip could sit on top of it. Rather than shrinking the video content area - which would visibly resize the video every time controls are toggled on/off - push the controller's own bottom margin up by the strip's (fixed) height on top of the system nav bar inset, the same technique FullScreenMediaScreen's MediaPlayerView already uses to keep the controller clear of the nav bar. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../activities/MediaViewerScreen.kt | 70 +++++++++++++++++-- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt index a8bd5d0e906..3f24558588c 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt @@ -8,6 +8,8 @@ package com.nextcloud.talk.mediaviewer.activities import android.util.Log import android.view.View +import android.view.ViewGroup.MarginLayoutParams +import android.widget.FrameLayout import androidx.annotation.OptIn import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -22,10 +24,12 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -53,16 +57,20 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.net.toUri +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.AudioAttributes import androidx.media3.common.MediaItem import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.PlayerView import coil.compose.AsyncImage import com.github.chrisbanes.photoview.PhotoView @@ -159,6 +167,16 @@ fun MediaViewerScreen( val density = LocalDensity.current val slideOffsetPx = with(density) { controlsSlideDistance.roundToPx() } + val group = uiState.currentGroup + val hasThumbnailStrip = group != null && group.items.size > 1 + // Reserved so the video's own ExoPlayer controller (progress bar, play/pause row) never renders + // underneath the thumbnail strip - the strip's height is fixed (a constant thumbnail size plus + // its own fixed padding), so this is knowable up front rather than measured. + val thumbnailStripHeightPx = with(density) { + (thumbnailSize + thumbnailStripTopPadding + thumbnailStripBottomPadding).roundToPx() + } + val controllerExtraBottomInsetPx = if (hasThumbnailStrip) thumbnailStripHeightPx else 0 + Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> val item = items.getOrNull(page) ?: return@HorizontalPager @@ -168,7 +186,8 @@ fun MediaViewerScreen( isCurrentPage = page == pagerState.currentPage, exoPlayer = exoPlayer, onToggleControls = { showControls = !showControls }, - onControlsVisibilityChanged = { showControls = it } + onControlsVisibilityChanged = { showControls = it }, + controllerExtraBottomInsetPx = controllerExtraBottomInsetPx ) } @@ -204,9 +223,8 @@ fun MediaViewerScreen( ) } - val group = uiState.currentGroup AnimatedVisibility( - visible = showControls && group != null && group.items.size > 1, + visible = showControls && hasThumbnailStrip, enter = fadeIn() + slideInVertically(initialOffsetY = { slideOffsetPx }), exit = fadeOut() + slideOutVertically(targetOffsetY = { slideOffsetPx }), modifier = Modifier.align(Alignment.BottomCenter) @@ -253,7 +271,8 @@ private fun MediaPage( isCurrentPage: Boolean, exoPlayer: ExoPlayer, onToggleControls: () -> Unit, - onControlsVisibilityChanged: (Boolean) -> Unit + onControlsVisibilityChanged: (Boolean) -> Unit, + controllerExtraBottomInsetPx: Int ) { val isVideo = item.mimeType.startsWith(Mimetype.VIDEO_PREFIX) val isGif = MimetypeUtils.isGif(item.mimeType) @@ -262,7 +281,11 @@ private fun MediaPage( when { localPath == null -> PreviewPlaceholder(item, onToggleControls = onToggleControls) isVideo -> if (isCurrentPage) { - VideoPlayerView(exoPlayer = exoPlayer, onControlsVisibilityChanged = onControlsVisibilityChanged) + VideoPlayerView( + exoPlayer = exoPlayer, + onControlsVisibilityChanged = onControlsVisibilityChanged, + extraBottomInsetPx = controllerExtraBottomInsetPx + ) } else { PreviewPlaceholder(item, onToggleControls = onToggleControls) } @@ -323,9 +346,27 @@ private fun ImagePage(localPath: String, onToggleControls: () -> Unit) { ) } +// Pushes ExoPlayer's own controller (progress bar, play/pause row) up by extraBottomInsetPx (the +// thumbnail strip's height, when one is showing for the current group) on top of the system nav +// bar inset, same technique FullScreenMediaScreen's MediaPlayerView already uses to keep the +// controller clear of the nav bar - so the controller never renders underneath the strip instead +// of shrinking the video content itself, which would visibly resize the video on every +// show/hide-controls tap. @OptIn(UnstableApi::class) @Composable -private fun VideoPlayerView(exoPlayer: ExoPlayer, onControlsVisibilityChanged: (Boolean) -> Unit) { +private fun VideoPlayerView( + exoPlayer: ExoPlayer, + onControlsVisibilityChanged: (Boolean) -> Unit, + extraBottomInsetPx: Int +) { + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val systemBarsBottomPx = WindowInsets.systemBars.getBottom(density) + val leftPx = WindowInsets.systemBars.getLeft(density, layoutDirection) + val rightPx = WindowInsets.systemBars.getRight(density, layoutDirection) + val bottomPx = systemBarsBottomPx + extraBottomInsetPx + val originalProgressMarginBottom = remember { intArrayOf(-1) } + AndroidView( factory = { ctx -> PlayerView(ctx).apply { @@ -339,6 +380,23 @@ private fun VideoPlayerView(exoPlayer: ExoPlayer, onControlsVisibilityChanged: ( ) } }, + update = { playerView -> + val exoControls = playerView.findViewById(R.id.exo_bottom_bar) + val exoProgress = playerView.findViewById(R.id.exo_progress) + exoControls?.apply { + updateLayoutParams { bottomMargin = bottomPx } + updatePadding(left = leftPx, right = rightPx) + } + exoProgress?.apply { + if (originalProgressMarginBottom[0] < 0) { + originalProgressMarginBottom[0] = (layoutParams as? MarginLayoutParams)?.bottomMargin ?: 0 + } + updateLayoutParams { + bottomMargin = bottomPx + originalProgressMarginBottom[0] + } + updatePadding(left = leftPx, right = rightPx) + } + }, modifier = Modifier.fillMaxSize() ) } From 73a8ffa16cc0b8955a0a515e49c0212211147adc Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 14:00:45 +0200 Subject: [PATCH 15/31] feat(mediaviewer): Show sent date/time as an app bar subtitle The top bar already showed the sender as its title. Added a subtitle showing when the item was sent, using the same DateUtils.getLocalDateTimeStringFromTimestamp() formatting the shared-items gallery already uses, so both look consistent regardless of whether the item came from the locally loaded chat or a network-fetched older page. StandardAppBar gains an optional subtitle parameter (defaults to null, so every other caller is unaffected) rendering a second, smaller line under the title. MediaViewerItem and SharedFileItem both gain a raw timestamp field to carry this through. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../talk/chat/viewmodels/ChatViewModel.kt | 3 +- .../talk/components/StandardAppBar.kt | 28 +++++++++++++++++-- .../activities/MediaViewerScreen.kt | 11 +++++++- .../talk/mediaviewer/model/MediaViewerItem.kt | 4 ++- .../viewmodels/MediaViewerViewModel.kt | 3 +- .../talk/shareditems/model/SharedFileItem.kt | 4 ++- .../repositories/SharedItemsRepositoryImpl.kt | 4 ++- .../mediaviewer/model/MediaViewerItemTest.kt | 3 +- 8 files changed, 51 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 09077f6ce70..7e244ecf833 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -244,7 +244,8 @@ private fun ChatMessageUi.toMediaViewerItem(): MediaViewerItem? { link = fileParameters.link.orEmpty(), fileSize = fileParameters.size ?: 0L, previewUrl = content.previewUrl, - actorDisplayName = actorDisplayName + actorDisplayName = actorDisplayName, + timestamp = timestamp ) } diff --git a/app/src/main/java/com/nextcloud/talk/components/StandardAppBar.kt b/app/src/main/java/com/nextcloud/talk/components/StandardAppBar.kt index d6539b94742..ebcf37f78e8 100644 --- a/app/src/main/java/com/nextcloud/talk/components/StandardAppBar.kt +++ b/app/src/main/java/com/nextcloud/talk/components/StandardAppBar.kt @@ -10,11 +10,14 @@ package com.nextcloud.talk.components import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.ui.text.style.TextOverflow @@ -33,19 +36,22 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import com.nextcloud.talk.R +private const val SUBTITLE_ALPHA = 0.75f + @OptIn(ExperimentalMaterial3Api::class) @Composable fun StandardAppBar( title: String, menuItems: List Unit>>?, - colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors() + colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(), + subtitle: String? = null ) { val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher var expanded by remember { mutableStateOf(false) } TopAppBar( - title = { Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + title = { StandardAppBarTitle(title = title, subtitle = subtitle) }, colors = colors, navigationIcon = { IconButton( @@ -88,6 +94,24 @@ fun StandardAppBar( ) } +@Composable +private fun StandardAppBarTitle(title: String, subtitle: String?) { + if (subtitle != null) { + Column { + Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + text = subtitle, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(alpha = SUBTITLE_ALPHA) + ) + } + } else { + Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + @Preview(name = "Light Mode") @Composable fun AppBarPreview() { diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt index 3f24558588c..9688e2956fc 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt @@ -80,6 +80,8 @@ import com.nextcloud.talk.mediaviewer.model.MediaViewerGroup import com.nextcloud.talk.mediaviewer.model.MediaViewerItem import com.nextcloud.talk.mediaviewer.viewmodels.MediaViewerViewModel import com.nextcloud.talk.utils.BitmapShrinker +import com.nextcloud.talk.utils.DateConstants +import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.DrawableUtils import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.MimetypeUtils @@ -109,6 +111,9 @@ fun MediaViewerScreen( val items = uiState.flattenedItems if (items.isEmpty()) return + val context = LocalContext.current + val dateUtils = remember { DateUtils(context) } + val pagerState = rememberPagerState(initialPage = uiState.currentGlobalIndex.coerceIn(0, items.size - 1)) { items.size } @@ -209,10 +214,14 @@ fun MediaViewerScreen( add(stringResource(R.string.nc_save_message) to { onSave(currentItem, currentLocalPath) }) } } + val sentDateTime = currentItem?.let { + dateUtils.getLocalDateTimeStringFromTimestamp(it.timestamp * DateConstants.SECOND_DIVIDER) + } StandardAppBar( title = currentItem?.actorDisplayName.orEmpty(), menuItems = menuItems, - colors = toolbarColors + colors = toolbarColors, + subtitle = sentDateTime ) } diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt index 7f222bb8bf5..43bc8e62259 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt @@ -28,7 +28,9 @@ data class MediaViewerItem( val link: String, val fileSize: Long, val previewUrl: String?, - val actorDisplayName: String + val actorDisplayName: String, + /** Epoch seconds, matching ChatMessage/ChatMessageJson's own timestamp convention. */ + val timestamp: Long ) : Parcelable /** diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt index fdef40e606b..a3930bf2e58 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt @@ -236,7 +236,8 @@ class MediaViewerViewModel @Inject constructor(private val sharedItemsRepository link = link, fileSize = fileSize, previewUrl = previewLink.takeIf { previewAvailable }, - actorDisplayName = actorName + actorDisplayName = actorName, + timestamp = timestamp ) private suspend fun Observable.awaitFirst(): SharedItems = diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/model/SharedFileItem.kt b/app/src/main/java/com/nextcloud/talk/shareditems/model/SharedFileItem.kt index 9842f4fa078..a2ea08dbbb5 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/model/SharedFileItem.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/model/SharedFileItem.kt @@ -20,5 +20,7 @@ data class SharedFileItem( val previewAvailable: Boolean = false, val previewLink: String, val messageId: Long, - val referenceId: String? + val referenceId: String?, + /** Epoch seconds, matching ChatMessage/ChatMessageJson's own timestamp convention. */ + val timestamp: Long ) : SharedItem diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepositoryImpl.kt index bf0eb8d3d5d..d083ca9ddea 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/repositories/SharedItemsRepositoryImpl.kt @@ -52,6 +52,7 @@ class SharedItemsRepositoryImpl @Inject constructor(private val ncApi: NcApi, pr ).map { map(it, parameters, type) } } + @Suppress("LongMethod") private fun map( response: Response, parameters: SharedItemsRepository.Parameters, @@ -105,7 +106,8 @@ class SharedItemsRepositoryImpl @Inject constructor(private val ncApi: NcApi, pr previewAvailable, previewLink(fileParameters["id"], parameters.baseUrl!!), it.value.id, - it.value.referenceId + it.value.referenceId, + it.value.timestamp ) } else if (it.value.messageParameters?.containsKey("object") == true) { val objectParameters = it.value.messageParameters!!["object"]!! diff --git a/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItemTest.kt b/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItemTest.kt index 09e790df3cb..ab516b1fc5f 100644 --- a/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItemTest.kt +++ b/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItemTest.kt @@ -25,7 +25,8 @@ class MediaViewerItemTest { link = "https://example.com/f/$messageId", fileSize = 1024L, previewUrl = null, - actorDisplayName = "Jane Doe" + actorDisplayName = "Jane Doe", + timestamp = messageId ) @Test From eb3b8c68579303d03d7df4d1b6c6d41b1bb3b4d8 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 14:07:09 +0200 Subject: [PATCH 16/31] fix(chat): Give grouped media the same border as single images The media grid clipped each tile to a fixed RoundedCornerShape(4.dp) and sat flush against the bubble edge - unlike a single image, which gets a 4dp inset and a corner shape (MediaMessage's shape()) that nests concentrically inside the bubble's own corner based on its incoming/isGrouped/isGroupedWithNext state. Wrapped the grid as a whole in that same inset + shape() so a grouped album's outer frame now looks the same as a single image's, regardless of how many tiles it contains; individual tile corners are unchanged. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../talk/ui/chat/MediaGroupMessage.kt | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt index dd7d3e54627..5c7b571b5fd 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt @@ -88,6 +88,10 @@ private const val UPLOAD_TRACK_ALPHA = 0.3f private const val PERCENT = 100 private const val TILE_CROSSFADE_DURATION_MS = 300 +// Matches MediaMessage's own mediaInset - so the grid, as a whole, nests inside the bubble's corner +// with the same inset gap a single image has, rather than sitting flush against the bubble edge. +private val mediaGroupInset = 4.dp + private val gridSpacing = 2.dp private val gridTileShape = RoundedCornerShape(4.dp) private val singleTileHeight = 220.dp @@ -120,6 +124,13 @@ fun MediaGroupMessage( val hasExplicitCaption = representative.plainMessage != FILE_PLACEHOLDER_MESSAGE val captionText = if (hasExplicitCaption) representative.message else null + // Same corner-shape function MediaMessage uses for a single image, so the grid's own outer + // corner nests inside the bubble's corner the same way a single image's does, instead of a + // fixed, position-unaware radius. + val groupShape = remember(representative.incoming, representative.isGrouped, representative.isGroupedWithNext) { + shape(representative.incoming, representative.isGrouped, representative.isGroupedWithNext) + } + CompositionLocalProvider( LocalMessageLongClickHandler provides { id -> callbacks.onLongClick?.invoke(id) ?: Unit }, LocalReactionClickHandler provides callbacks.onReactionClick, @@ -154,12 +165,18 @@ fun MediaGroupMessage( content = { Column { if (mediaItems.isNotEmpty()) { - MediaGrid( - items = mediaItems, - chatViewDownloadingFileState = context.downloadingFileState, - onItemClick = callbacks.onFileClick, - onCancelUpload = callbacks.onCancelUpload - ) + Box( + modifier = Modifier + .padding(mediaGroupInset) + .clip(groupShape) + ) { + MediaGrid( + items = mediaItems, + chatViewDownloadingFileState = context.downloadingFileState, + onItemClick = callbacks.onFileClick, + onCancelUpload = callbacks.onCancelUpload + ) + } } if (fileItems.isNotEmpty()) { Column( From 80182d38b17d5a9305b7af25893858fde34b47ae Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 14:19:54 +0200 Subject: [PATCH 17/31] fix(mediaviewer): Make the top app bar semi-transparent Matches the bottom thumbnail strip's own Color.Black.copy(alpha = TOOLBAR_ALPHA) background instead of a fully transparent container, for consistent legibility and a matching look between the top and bottom chrome. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt index 9688e2956fc..9b632a4cdd5 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt @@ -203,7 +203,7 @@ fun MediaViewerScreen( modifier = Modifier.align(Alignment.TopCenter) ) { val toolbarColors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, + containerColor = Color.Black.copy(alpha = TOOLBAR_ALPHA), titleContentColor = Color.White, navigationIconContentColor = Color.White, actionIconContentColor = Color.White From 7ce9139a1b3625c625542484425f13d3fd129636 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 14:30:11 +0200 Subject: [PATCH 18/31] feat(mediaviewer): Toggle the status/nav bars along with the controls Tapping the current item hid the top bar and thumbnail strip but left the system status/nav bars showing. Wired a windowInsetsController already set up in MediaViewerActivity (previously unused) through a new onControlsVisibilityChanged callback on MediaViewerScreen, fired whenever the controls-visible state changes, so the system bars now hide and show together with the rest of the chrome - matching FullScreenImageScreen/FullScreenMediaScreen's existing behavior. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../activities/MediaViewerActivity.kt | 17 +++++++++++++++-- .../mediaviewer/activities/MediaViewerScreen.kt | 9 ++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt index 95cf9b3fc85..b58d7399351 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.FileProvider import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.DialogFragment import androidx.lifecycle.ViewModelProvider @@ -48,6 +49,7 @@ class MediaViewerActivity : BaseActivity() { lateinit var viewModelFactory: ViewModelProvider.Factory private lateinit var viewModel: MediaViewerViewModel + private lateinit var windowInsetsController: WindowInsetsControllerCompat override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -71,7 +73,7 @@ class MediaViewerActivity : BaseActivity() { statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) ) - val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) + windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE // Deliberately no SwipeToCloseLayout here (unlike FullScreenImageActivity/ @@ -88,7 +90,10 @@ class MediaViewerActivity : BaseActivity() { MediaViewerScreen( viewModel = viewModel, onShare = ::shareFile, - onSave = ::showSaveDialog + onSave = ::showSaveDialog, + onControlsVisibilityChanged = { visible -> + if (visible) exitImmersiveMode() else enterImmersiveMode() + } ) } } @@ -97,6 +102,14 @@ class MediaViewerActivity : BaseActivity() { setContentView(composeView) } + private fun enterImmersiveMode() { + windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) + } + + private fun exitImmersiveMode() { + windowInsetsController.show(WindowInsetsCompat.Type.systemBars()) + } + private fun shareFile(item: MediaViewerItem, localPath: String) { val file = File(localPath) val shareUri = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID, file) diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt index 9b632a4cdd5..0d7a71cb9e2 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt @@ -105,7 +105,8 @@ private val controlsSlideDistance = 24.dp fun MediaViewerScreen( viewModel: MediaViewerViewModel, onShare: (MediaViewerItem, String) -> Unit, - onSave: (MediaViewerItem, String) -> Unit + onSave: (MediaViewerItem, String) -> Unit, + onControlsVisibilityChanged: (Boolean) -> Unit = {} ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val items = uiState.flattenedItems @@ -164,6 +165,12 @@ fun MediaViewerScreen( // controller already auto-hides itself after a timeout. var showControls by remember { mutableStateOf(true) } + // The status/nav bars toggle together with the top bar and thumbnail strip - one tap hides all + // of it, matching FullScreenImageScreen/FullScreenMediaScreen's own tap-to-toggle-fullscreen. + LaunchedEffect(showControls) { + onControlsVisibilityChanged(showControls) + } + // Hoisted here (rather than inside ThumbnailStrip) so its scroll position survives showControls // toggling the strip in and out of composition - otherwise every reveal remounted a fresh, // unscrolled LazyListState and re-animated the scroll from the start, reading as the thumbnails From 8a424abb3b1e620f97da57a850493d2c60a2e668 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 14:55:58 +0200 Subject: [PATCH 19/31] feat(mediaviewer): Migrate Shared Items gallery to the swipeable viewer, split video/audio Images/videos tapped in the Shared Items gallery now open MediaViewerActivity, seeded with every image/video currently loaded in that gallery instead of the single-item FullScreenImageActivity/FullScreenMediaActivity. FileViewerUtils now routes video straight to the media viewer too (matching chat), so FullScreenMediaActivity is only reached for audio going forward. The SharedFileItem -> MediaViewerItem mapper moves to the shared model file so both MediaViewerViewModel and SharedItemsAdapter can use it. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../talk/mediaviewer/model/MediaViewerItem.kt | 19 ++++++ .../viewmodels/MediaViewerViewModel.kt | 20 +----- .../adapters/SharedItemsAdapter.kt | 18 +++++- .../adapters/SharedItemsListViewHolder.kt | 4 +- .../adapters/SharedItemsViewHolder.kt | 10 ++- .../nextcloud/talk/utils/FileViewerUtils.kt | 63 +++++++++++++++++-- 6 files changed, 108 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt index 43bc8e62259..6df499d343d 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt @@ -7,6 +7,8 @@ package com.nextcloud.talk.mediaviewer.model import android.os.Parcelable +import com.nextcloud.talk.shareditems.model.SharedFileItem +import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.message.groupHashOf import kotlinx.parcelize.Parcelize @@ -71,3 +73,20 @@ fun List.toMediaViewerGroups(): List { return result } + +fun SharedFileItem.isImageOrVideo(): Boolean = + mimeType.startsWith(Mimetype.IMAGE_PREFIX) || mimeType.startsWith(Mimetype.VIDEO_PREFIX) + +fun SharedFileItem.toMediaViewerItem() = MediaViewerItem( + messageId = messageId, + referenceId = referenceId, + fileId = id, + fileName = name, + mimeType = mimeType, + path = path, + link = link, + fileSize = fileSize, + previewUrl = previewLink.takeIf { previewAvailable }, + actorDisplayName = actorName, + timestamp = timestamp +) diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt index a3930bf2e58..99cea888581 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt @@ -20,14 +20,15 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.jobs.DownloadFileToCacheWorker import com.nextcloud.talk.mediaviewer.model.MediaViewerGroup import com.nextcloud.talk.mediaviewer.model.MediaViewerItem +import com.nextcloud.talk.mediaviewer.model.isImageOrVideo import com.nextcloud.talk.mediaviewer.model.toMediaViewerGroups +import com.nextcloud.talk.mediaviewer.model.toMediaViewerItem import com.nextcloud.talk.shareditems.model.SharedFileItem import com.nextcloud.talk.shareditems.model.SharedItemType import com.nextcloud.talk.shareditems.model.SharedItems import com.nextcloud.talk.shareditems.repositories.SharedItemsRepository import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.FileUtils -import com.nextcloud.talk.utils.Mimetype import io.reactivex.Observable import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -223,23 +224,6 @@ class MediaViewerViewModel @Inject constructor(private val sharedItemsRepository } } - private fun SharedFileItem.isImageOrVideo(): Boolean = - mimeType.startsWith(Mimetype.IMAGE_PREFIX) || mimeType.startsWith(Mimetype.VIDEO_PREFIX) - - private fun SharedFileItem.toMediaViewerItem() = MediaViewerItem( - messageId = messageId, - referenceId = referenceId, - fileId = id, - fileName = name, - mimeType = mimeType, - path = path, - link = link, - fileSize = fileSize, - previewUrl = previewLink.takeIf { previewAvailable }, - actorDisplayName = actorName, - timestamp = timestamp - ) - private suspend fun Observable.awaitFirst(): SharedItems = suspendCancellableCoroutine { continuation -> val disposable = subscribe( diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt index c616decefd6..4f6c49b791f 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt @@ -14,6 +14,9 @@ import androidx.recyclerview.widget.RecyclerView import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.SharedItemGridBinding import com.nextcloud.talk.databinding.SharedItemListBinding +import com.nextcloud.talk.mediaviewer.activities.MediaViewerActivity +import com.nextcloud.talk.mediaviewer.model.isImageOrVideo +import com.nextcloud.talk.mediaviewer.model.toMediaViewerItem import com.nextcloud.talk.polls.ui.PollMainDialogFragment import com.nextcloud.talk.shareditems.activities.SharedItemsActivity import com.nextcloud.talk.shareditems.model.SharedDeckCardItem @@ -64,7 +67,7 @@ class SharedItemsAdapter( override fun onBindViewHolder(holder: SharedItemsViewHolder, position: Int) { when (val item = items[position]) { is SharedPollItem -> holder.onBind(item, ::showPoll) - is SharedFileItem -> holder.onBind(item) + is SharedFileItem -> holder.onBind(item, ::openMediaViewer) is SharedLocationItem -> holder.onBind(item) is SharedOtherItem -> holder.onBind(item) is SharedDeckCardItem -> holder.onBind(item) @@ -103,6 +106,19 @@ class SharedItemsAdapter( } } + // Seeds the swipeable viewer with every image/video currently loaded in this gallery (not just + // the tapped item), so swiping crosses between them the same way it does from the chat screen. + // Items are displayed newest-first (see SharedItemsRepositoryImpl.map()); toMediaViewerGroups() + // expects oldest-first, so the seed is sorted back before being passed along. + private fun openMediaViewer(item: SharedFileItem, context: Context) { + val seedItems = items + .filterIsInstance() + .filter { it.isImageOrVideo() } + .sortedBy { it.messageId } + .map { it.toMediaViewerItem() } + context.startActivity(MediaViewerActivity.newIntent(context, roomToken, seedItems, item.messageId)) + } + private fun openMessage(item: SharedItem, context: Context) { val credentials = ApiUtils.getCredentials(user.username, user.token) val baseUrl = user.baseUrl diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsListViewHolder.kt b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsListViewHolder.kt index df75d8f1a36..b4e28cc3458 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsListViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsListViewHolder.kt @@ -40,8 +40,8 @@ class SharedItemsListViewHolder( override val progressBar: ProgressBar get() = binding.progressBar - override fun onBind(item: SharedFileItem) { - super.onBind(item) + override fun onBind(item: SharedFileItem, openMediaViewer: (SharedFileItem, Context) -> Unit) { + super.onBind(item, openMediaViewer) binding.fileName.text = item.name binding.fileSize.text = item.fileSize.let { diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsViewHolder.kt b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsViewHolder.kt index 8113ee3d840..10b64829946 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsViewHolder.kt @@ -16,6 +16,7 @@ import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.extensions.loadImage +import com.nextcloud.talk.mediaviewer.model.isImageOrVideo import com.nextcloud.talk.shareditems.model.SharedDeckCardItem import com.nextcloud.talk.shareditems.model.SharedFileItem import com.nextcloud.talk.shareditems.model.SharedItem @@ -40,7 +41,7 @@ abstract class SharedItemsViewHolder( abstract val clickTarget: View abstract val progressBar: ProgressBar - open fun onBind(item: SharedFileItem) { + open fun onBind(item: SharedFileItem, openMediaViewer: (SharedFileItem, Context) -> Unit) { val placeholder = viewThemeUtils.talk.getPlaceholderImage(image.context, item.mimeType) if (item.previewAvailable) { image.loadImage( @@ -52,6 +53,13 @@ abstract class SharedItemsViewHolder( image.setImageDrawable(placeholder) } + if (item.isImageOrVideo()) { + // Images/videos open the swipeable media viewer, seeded with the sibling media + // already loaded in this gallery - see SharedItemsAdapter.openMediaViewer(). + clickTarget.setOnClickListener { openMediaViewer(item, image.context) } + return + } + /* The FileViewerUtils forces us to do things at this points which should be done separated in the activity and the view model. diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt index 7c3ea29d42c..70b18b53a89 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt @@ -33,6 +33,8 @@ import com.nextcloud.talk.fullscreenfile.FullScreenImageActivity import com.nextcloud.talk.fullscreenfile.FullScreenMediaActivity import com.nextcloud.talk.fullscreenfile.FullScreenTextViewerActivity import com.nextcloud.talk.jobs.DownloadFileToCacheWorker +import com.nextcloud.talk.mediaviewer.activities.MediaViewerActivity +import com.nextcloud.talk.mediaviewer.model.MediaViewerItem import com.nextcloud.talk.utils.AccountUtils.canWeOpenFilesApp import com.nextcloud.talk.utils.Mimetype.AUDIO_MPEG import com.nextcloud.talk.utils.Mimetype.AUDIO_OGG @@ -45,6 +47,7 @@ import com.nextcloud.talk.utils.Mimetype.TEXT_MARKDOWN import com.nextcloud.talk.utils.Mimetype.TEXT_PLAIN import com.nextcloud.talk.utils.Mimetype.VIDEO_MP4 import com.nextcloud.talk.utils.Mimetype.VIDEO_OGG +import com.nextcloud.talk.utils.Mimetype.VIDEO_PREFIX import com.nextcloud.talk.utils.Mimetype.VIDEO_QUICKTIME import com.nextcloud.talk.utils.Mimetype.VIDEO_WEBM import com.nextcloud.talk.utils.MimetypeUtils.isAudioOnly @@ -60,6 +63,7 @@ import java.util.concurrent.ExecutionException * Example: * - SharedItemsViewHolder */ +@Suppress("TooManyFunctions") class FileViewerUtils(private val context: Context, private val user: User) { fun openFile( @@ -67,8 +71,16 @@ class FileViewerUtils(private val context: Context, private val user: User) { openWhenDownloadState: MutableState, downloadState: MutableState> ) { - val fileName = message.fileParameters.name val mimetype = message.fileParameters.mimetype + + // Video is fully covered by the swipeable media viewer now, so it skips the single-item + // FullScreenMediaActivity entirely - unlike audio, which that activity still owns. + if (mimetype != null && mimetype.startsWith(VIDEO_PREFIX) && isSupportedForInternalViewer(mimetype)) { + openVideoInMediaViewer(message) + return + } + + val fileName = message.fileParameters.name val link = message.fileParameters.link val fileId = message.fileParameters.id @@ -83,6 +95,38 @@ class FileViewerUtils(private val context: Context, private val user: User) { ) } + private fun openVideoInMediaViewer(message: ChatMessage) { + val fileParameters = message.fileParameters + val fileId = fileParameters.id + val roomToken = message.token + if (fileId == null || roomToken == null) { + Log.e(TAG, "Missing fileId or roomToken, can't open video in media viewer") + return + } + + val previewUrl = user.baseUrl?.let { + ApiUtils.getUrlForFilePreviewWithFileId( + it, + fileId, + context.resources.getDimensionPixelSize(R.dimen.maximum_file_preview_size) + ) + } + val item = MediaViewerItem( + messageId = message.jsonMessageId.toLong(), + referenceId = message.referenceId, + fileId = fileId, + fileName = fileParameters.name.orEmpty(), + mimeType = fileParameters.mimetype.orEmpty(), + path = fileParameters.path.orEmpty(), + link = fileParameters.link.orEmpty(), + fileSize = fileParameters.size ?: 0L, + previewUrl = previewUrl, + actorDisplayName = message.actorDisplayName.orEmpty(), + timestamp = message.timestamp + ) + context.startActivity(MediaViewerActivity.newIntent(context, roomToken, listOf(item), item.messageId)) + } + fun openFile( fileInfo: FileInfo, openWhenDownloadState: MutableState, @@ -151,11 +195,15 @@ class FileViewerUtils(private val context: Context, private val user: User) { when (mimetype) { AUDIO_MPEG, AUDIO_WAV, - AUDIO_OGG, + AUDIO_OGG -> openAudioView(filename, mimetype) + + // Reachable only if a future caller ends up here without the message/room context + // openFile(ChatMessage, ...) needs to route video to the media viewer instead - see + // openVideoInMediaViewer(). Kept as a safety net so video is never left unopenable. VIDEO_MP4, VIDEO_QUICKTIME, VIDEO_OGG, - VIDEO_WEBM -> openMediaView(filename, mimetype) + VIDEO_WEBM -> openVideoView(filename, mimetype) IMAGE_PNG, IMAGE_JPEG, @@ -235,7 +283,14 @@ class FileViewerUtils(private val context: Context, private val user: User) { context.startActivity(fullScreenImageIntent) } - private fun openMediaView(filename: String, mimetype: String) { + private fun openAudioView(filename: String, mimetype: String) { + val fullScreenMediaIntent = Intent(context, FullScreenMediaActivity::class.java) + fullScreenMediaIntent.putExtra("FILE_NAME", filename) + fullScreenMediaIntent.putExtra("AUDIO_ONLY", isAudioOnly(mimetype)) + context.startActivity(fullScreenMediaIntent) + } + + private fun openVideoView(filename: String, mimetype: String) { val fullScreenMediaIntent = Intent(context, FullScreenMediaActivity::class.java) fullScreenMediaIntent.putExtra("FILE_NAME", filename) fullScreenMediaIntent.putExtra("AUDIO_ONLY", isAudioOnly(mimetype)) From 569bda5fd50a25c3deba23fcd0a48d70a421573e Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 15:03:21 +0200 Subject: [PATCH 20/31] refactor(mediaviewer): Remove FullScreenImageActivity, now fully replaced Images reached FullScreenImageActivity only through a fallback path in FileViewerUtils that was already unreachable in practice (Shared Items and chat both route images to MediaViewerActivity directly). Generalized that fallback to cover images the same way it already did for video, then removed FullScreenImageActivity/FullScreenImageScreen and their manifest entry. FullScreenImageTheme stays, since MediaViewerActivity reuses it. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- app/src/main/AndroidManifest.xml | 5 - .../fullscreenfile/FullScreenImageActivity.kt | 156 --------------- .../fullscreenfile/FullScreenImageScreen.kt | 185 ------------------ .../activities/MediaViewerActivity.kt | 4 +- .../activities/MediaViewerScreen.kt | 6 +- .../nextcloud/talk/utils/FileViewerUtils.kt | 31 ++- 6 files changed, 16 insertions(+), 371 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenImageActivity.kt delete mode 100644 app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenImageScreen.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 88a3319b901..609ae683460 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -211,11 +211,6 @@ android:taskAffinity=".call" android:theme="@style/AppTheme.CallLauncher" /> - - - * SPDX-FileCopyrightText: 2021 Andy Scherzinger - * SPDX-FileCopyrightText: 2021 Marcel Hibbe - * SPDX-FileCopyrightText: 2021 Dariusz Olszewski - * SPDX-FileCopyrightText: 2026 Enrique López-Mañas - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.fullscreenfile - -import android.content.Intent -import android.os.Bundle -import android.util.Log -import android.widget.FrameLayout -import androidx.appcompat.app.AppCompatActivity -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.activity.SystemBarStyle -import androidx.activity.enableEdgeToEdge -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.core.content.FileProvider -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.WindowInsetsControllerCompat -import androidx.fragment.app.DialogFragment -import autodagger.AutoInjector -import com.google.android.material.snackbar.Snackbar -import com.nextcloud.talk.BuildConfig -import com.nextcloud.talk.R -import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.ui.SwipeToCloseLayout -import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment -import com.nextcloud.talk.ui.theme.ViewThemeUtils -import com.nextcloud.talk.utils.FileUtils -import com.nextcloud.talk.utils.Mimetype.IMAGE_PREFIX_GENERIC -import java.io.File -import javax.inject.Inject - -@AutoInjector(NextcloudTalkApplication::class) -class FullScreenImageActivity : AppCompatActivity() { - - @Inject - lateinit var viewThemeUtils: ViewThemeUtils - - private lateinit var windowInsetsController: WindowInsetsControllerCompat - private lateinit var path: String - private lateinit var fileName: String - private lateinit var imageFile: File - private lateinit var swipeToCloseLayout: SwipeToCloseLayout - private var showFullscreen by mutableStateOf(false) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - - fileName = intent.getStringExtra("FILE_NAME").orEmpty() - val isGif = intent.getBooleanExtra("IS_GIF", false) - imageFile = FileUtils.resolveSharedAttachmentFile(applicationContext.cacheDir, fileName) ?: run { - Log.e(TAG, "Invalid image filename: $fileName") - finish() - return - } - path = imageFile.absolutePath - - enableEdgeToEdge( - statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), - navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) - ) - initWindowInsetsController() - - swipeToCloseLayout = SwipeToCloseLayout(this) - swipeToCloseLayout.setOnSwipeToCloseListener(object : SwipeToCloseLayout.OnSwipeToCloseListener { - override fun onSwipeToClose() { - finish() - } - }) - - val composeView = ComposeView(this).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - val colorScheme = viewThemeUtils.getColorScheme(this@FullScreenImageActivity) - MaterialTheme(colorScheme = colorScheme) { - FullScreenImageScreen( - title = fileName, - isGif = isGif, - imagePath = path, - showFullscreen = showFullscreen, - actions = FullScreenImageActions( - onShare = { shareFile() }, - onSave = { showSaveDialog() }, - onToggleFullscreen = { toggleFullscreen() }, - onBitmapError = { showBitmapError() } - ) - ) - } - } - } - - swipeToCloseLayout.addView( - composeView, - FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) - ) - setContentView(swipeToCloseLayout) - } - - private fun toggleFullscreen() { - showFullscreen = !showFullscreen - if (showFullscreen) { - enterImmersiveMode() - } else { - exitImmersiveMode() - } - } - - private fun initWindowInsetsController() { - windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) - windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - } - - private fun enterImmersiveMode() { - windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()) - } - - private fun exitImmersiveMode() { - windowInsetsController.show(WindowInsetsCompat.Type.systemBars()) - } - - private fun shareFile() { - val shareUri = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID, imageFile) - val shareIntent = Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_STREAM, shareUri) - type = IMAGE_PREFIX_GENERIC - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - startActivity(Intent.createChooser(shareIntent, resources.getText(R.string.send_to))) - } - - private fun showSaveDialog() { - val saveFragment: DialogFragment = SaveToStorageDialogFragment.newInstance(fileName) - saveFragment.show(supportFragmentManager, SaveToStorageDialogFragment.TAG) - } - - private fun showBitmapError() { - Snackbar.make(swipeToCloseLayout, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - } - - companion object { - private val TAG = FullScreenImageActivity::class.java.simpleName - } -} diff --git a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenImageScreen.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenImageScreen.kt deleted file mode 100644 index 21f91b44410..00000000000 --- a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenImageScreen.kt +++ /dev/null @@ -1,185 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2021 Andy Scherzinger - * SPDX-FileCopyrightText: 2021 Marcel Hibbe - * SPDX-FileCopyrightText: 2021 Dariusz Olszewski - * SPDX-FileCopyrightText: 2026 Andy Scherzinger - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.fullscreenfile - -import android.content.res.Configuration -import android.util.Log -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.windowInsetsBottomHeight -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.viewinterop.AndroidView -import com.github.chrisbanes.photoview.PhotoView -import com.nextcloud.talk.R -import com.nextcloud.talk.components.StandardAppBar -import com.nextcloud.talk.utils.BitmapShrinker -import pl.droidsonroids.gif.GifDrawable -import pl.droidsonroids.gif.GifImageView - -private const val TAG = "FullScreenImageScreen" -private const val MAX_SCALE = 6.0f -private const val MEDIUM_SCALE = 2.45f -private const val HUNDRED_MB = 100 * 1024 * 1024 -private const val TOOLBAR_ALPHA = 0.5f - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun FullScreenImageScreen( - title: String, - isGif: Boolean, - imagePath: String, - showFullscreen: Boolean, - actions: FullScreenImageActions -) { - val toolbarColors = TopAppBarDefaults.topAppBarColors( - containerColor = Color.Transparent, - titleContentColor = Color.White, - navigationIconContentColor = Color.White, - actionIconContentColor = Color.White - ) - - Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { - if (isGif) { - GifView(imagePath = imagePath, onToggleFullscreen = actions.onToggleFullscreen) - } else { - PhotoImageView( - imagePath = imagePath, - onToggleFullscreen = actions.onToggleFullscreen, - onBitmapError = actions.onBitmapError - ) - } - - Box( - modifier = Modifier - .fillMaxWidth() - .windowInsetsBottomHeight(WindowInsets.navigationBars) - .background( - Brush.verticalGradient( - colors = listOf(Color.Transparent, Color.Black.copy(alpha = TOOLBAR_ALPHA)) - ) - ) - .align(Alignment.BottomCenter) - ) - - if (!showFullscreen) { - val menuItems = buildList { - add(stringResource(R.string.share) to actions.onShare) - add(stringResource(R.string.nc_save_message) to actions.onSave) - } - Box { - Box( - modifier = Modifier - .matchParentSize() - .background( - Brush.verticalGradient( - colors = listOf(Color.Black.copy(alpha = TOOLBAR_ALPHA), Color.Transparent) - ) - ) - ) - StandardAppBar(title = title, menuItems = menuItems, colors = toolbarColors) - } - } - } -} - -@Composable -private fun GifView(imagePath: String, onToggleFullscreen: () -> Unit) { - AndroidView( - factory = { ctx -> - GifImageView(ctx).apply { - setImageDrawable(GifDrawable(imagePath)) - setOnClickListener { onToggleFullscreen() } - } - }, - modifier = Modifier.fillMaxSize() - ) -} - -@Composable -private fun PhotoImageView(imagePath: String, onToggleFullscreen: () -> Unit, onBitmapError: () -> Unit) { - AndroidView( - factory = { ctx -> - PhotoView(ctx).apply { - maximumScale = MAX_SCALE - mediumScale = MEDIUM_SCALE - setOnPhotoTapListener { _, _, _ -> onToggleFullscreen() } - setOnOutsidePhotoTapListener { onToggleFullscreen() } - val displayMetrics = ctx.resources.displayMetrics - val bitmap = BitmapShrinker.shrinkBitmap( - imagePath, - displayMetrics.widthPixels * 2, - displayMetrics.heightPixels * 2 - ) - when { - bitmap == null -> { - Log.e(TAG, "bitmap could not be decoded from path: $imagePath") - onBitmapError() - } - bitmap.byteCount > HUNDRED_MB -> { - Log.e(TAG, "bitmap too large to display, skipping to avoid RuntimeException") - onBitmapError() - } - else -> setImageBitmap(bitmap) - } - } - }, - modifier = Modifier.fillMaxSize() - ) -} - -data class FullScreenImageActions( - val onShare: () -> Unit, - val onSave: () -> Unit, - val onToggleFullscreen: () -> Unit, - val onBitmapError: () -> Unit -) - -@Preview(name = "Light", showBackground = true) -@Composable -private fun PreviewFullScreenImageLight() { - MaterialTheme(colorScheme = lightColorScheme()) { - FullScreenImageScreen( - title = "image.jpg", - isGif = false, - imagePath = "", - showFullscreen = false, - actions = FullScreenImageActions(onShare = {}, onSave = {}, onToggleFullscreen = {}, onBitmapError = {}) - ) - } -} - -@Preview(name = "Dark - RTL Arabic", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES, locale = "ar") -@Composable -private fun PreviewFullScreenImageDarkRtl() { - MaterialTheme(colorScheme = darkColorScheme()) { - FullScreenImageScreen( - title = "صورة.jpg", - isGif = false, - imagePath = "", - showFullscreen = false, - actions = FullScreenImageActions(onShare = {}, onSave = {}, onToggleFullscreen = {}, onBitmapError = {}) - ) - } -} diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt index b58d7399351..8f851d414b4 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt @@ -76,8 +76,8 @@ class MediaViewerActivity : BaseActivity() { windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - // Deliberately no SwipeToCloseLayout here (unlike FullScreenImageActivity/ - // FullScreenMediaActivity): its ViewDragHelper intercepts drags at the parent level before + // Deliberately no SwipeToCloseLayout here (unlike FullScreenMediaActivity, still used for + // audio): its ViewDragHelper intercepts drags at the parent level before // the HorizontalPager below ever sees them, and a real swipe is rarely perfectly // horizontal - the small vertical component was enough to trigger it, closing the viewer // on what the user meant as a page-navigation swipe. Closing is still available via the diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt index 0d7a71cb9e2..45eb9842ef9 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt @@ -159,14 +159,14 @@ fun MediaViewerScreen( } // Tapping the currently shown item toggles this off, hiding the top bar and thumbnail strip so - // only the media itself is visible - mirrors FullScreenImageScreen/FullScreenMediaScreen's own - // tap-to-toggle-fullscreen behavior. For video, ExoPlayer's own controller visibility is the + // only the media itself is visible - mirrors FullScreenMediaScreen's own tap-to-toggle-fullscreen + // behavior (still used for audio). For video, ExoPlayer's own controller visibility is the // source of truth (see VideoPlayerView) rather than an independently toggled flag, since the // controller already auto-hides itself after a timeout. var showControls by remember { mutableStateOf(true) } // The status/nav bars toggle together with the top bar and thumbnail strip - one tap hides all - // of it, matching FullScreenImageScreen/FullScreenMediaScreen's own tap-to-toggle-fullscreen. + // of it, matching FullScreenMediaScreen's own tap-to-toggle-fullscreen. LaunchedEffect(showControls) { onControlsVisibilityChanged(showControls) } diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt index 70b18b53a89..8386998f7e8 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt @@ -29,7 +29,6 @@ import com.google.android.material.snackbar.Snackbar import com.nextcloud.talk.R import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.data.user.model.User -import com.nextcloud.talk.fullscreenfile.FullScreenImageActivity import com.nextcloud.talk.fullscreenfile.FullScreenMediaActivity import com.nextcloud.talk.fullscreenfile.FullScreenTextViewerActivity import com.nextcloud.talk.jobs.DownloadFileToCacheWorker @@ -43,6 +42,7 @@ import com.nextcloud.talk.utils.Mimetype.IMAGE_GIF import com.nextcloud.talk.utils.Mimetype.IMAGE_HEIC import com.nextcloud.talk.utils.Mimetype.IMAGE_JPEG import com.nextcloud.talk.utils.Mimetype.IMAGE_PNG +import com.nextcloud.talk.utils.Mimetype.IMAGE_PREFIX import com.nextcloud.talk.utils.Mimetype.TEXT_MARKDOWN import com.nextcloud.talk.utils.Mimetype.TEXT_PLAIN import com.nextcloud.talk.utils.Mimetype.VIDEO_MP4 @@ -51,7 +51,6 @@ import com.nextcloud.talk.utils.Mimetype.VIDEO_PREFIX import com.nextcloud.talk.utils.Mimetype.VIDEO_QUICKTIME import com.nextcloud.talk.utils.Mimetype.VIDEO_WEBM import com.nextcloud.talk.utils.MimetypeUtils.isAudioOnly -import com.nextcloud.talk.utils.MimetypeUtils.isGif import com.nextcloud.talk.utils.MimetypeUtils.isMarkdown import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ACCOUNT import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FILE_ID @@ -73,10 +72,14 @@ class FileViewerUtils(private val context: Context, private val user: User) { ) { val mimetype = message.fileParameters.mimetype - // Video is fully covered by the swipeable media viewer now, so it skips the single-item - // FullScreenMediaActivity entirely - unlike audio, which that activity still owns. - if (mimetype != null && mimetype.startsWith(VIDEO_PREFIX) && isSupportedForInternalViewer(mimetype)) { - openVideoInMediaViewer(message) + // Images/videos are fully covered by the swipeable media viewer now, so they skip the + // single-item FullScreenImageActivity/FullScreenMediaActivity entirely - unlike audio, + // which FullScreenMediaActivity still owns. + val isViewableMedia = mimetype != null && + (mimetype.startsWith(IMAGE_PREFIX) || mimetype.startsWith(VIDEO_PREFIX)) && + isSupportedForInternalViewer(mimetype) + if (isViewableMedia) { + openInMediaViewer(message) return } @@ -95,12 +98,12 @@ class FileViewerUtils(private val context: Context, private val user: User) { ) } - private fun openVideoInMediaViewer(message: ChatMessage) { + private fun openInMediaViewer(message: ChatMessage) { val fileParameters = message.fileParameters val fileId = fileParameters.id val roomToken = message.token if (fileId == null || roomToken == null) { - Log.e(TAG, "Missing fileId or roomToken, can't open video in media viewer") + Log.e(TAG, "Missing fileId or roomToken, can't open in media viewer") return } @@ -205,11 +208,6 @@ class FileViewerUtils(private val context: Context, private val user: User) { VIDEO_OGG, VIDEO_WEBM -> openVideoView(filename, mimetype) - IMAGE_PNG, - IMAGE_JPEG, - IMAGE_GIF, - IMAGE_HEIC -> openImageView(filename, mimetype) - TEXT_MARKDOWN, TEXT_PLAIN -> openTextView(filename, mimetype, link, fileId) @@ -276,13 +274,6 @@ class FileViewerUtils(private val context: Context, private val user: User) { } } - private fun openImageView(filename: String, mimetype: String) { - val fullScreenImageIntent = Intent(context, FullScreenImageActivity::class.java) - fullScreenImageIntent.putExtra("FILE_NAME", filename) - fullScreenImageIntent.putExtra("IS_GIF", isGif(mimetype)) - context.startActivity(fullScreenImageIntent) - } - private fun openAudioView(filename: String, mimetype: String) { val fullScreenMediaIntent = Intent(context, FullScreenMediaActivity::class.java) fullScreenMediaIntent.putExtra("FILE_NAME", filename) From 48db8b3743a96842c977b082db934b687d8f72b2 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 15:28:31 +0200 Subject: [PATCH 21/31] format code Signed-off-by: Marcel Hibbe --- .../talk/chat/viewmodels/ChatViewModel.kt | 11 ++++---- .../talk/mediaviewer/model/MediaViewerItem.kt | 27 ++++++++++--------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 7e244ecf833..b104f91ca66 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -2677,11 +2677,12 @@ class ChatViewModel @AssistedInject constructor( sealed interface ChatItem { // For a MediaGroupItem, the last (newest) message stands in as the representative - // matches how the grouped bubble itself is anchored (see MediaGroupItem docs below). - fun messageOrNull(): ChatMessageUi? = when (this) { - is MessageItem -> uiMessage - is MediaGroupItem -> messages.lastOrNull() - else -> null - } + fun messageOrNull(): ChatMessageUi? = + when (this) { + is MessageItem -> uiMessage + is MediaGroupItem -> messages.lastOrNull() + else -> null + } fun dateOrNull(): LocalDate? = (this as? DateHeaderItem)?.date fun stableKey(): Any = diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt index 6df499d343d..28eb705849b 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt @@ -77,16 +77,17 @@ fun List.toMediaViewerGroups(): List { fun SharedFileItem.isImageOrVideo(): Boolean = mimeType.startsWith(Mimetype.IMAGE_PREFIX) || mimeType.startsWith(Mimetype.VIDEO_PREFIX) -fun SharedFileItem.toMediaViewerItem() = MediaViewerItem( - messageId = messageId, - referenceId = referenceId, - fileId = id, - fileName = name, - mimeType = mimeType, - path = path, - link = link, - fileSize = fileSize, - previewUrl = previewLink.takeIf { previewAvailable }, - actorDisplayName = actorName, - timestamp = timestamp -) +fun SharedFileItem.toMediaViewerItem() = + MediaViewerItem( + messageId = messageId, + referenceId = referenceId, + fileId = id, + fileName = name, + mimeType = mimeType, + path = path, + link = link, + fileSize = fileSize, + previewUrl = previewLink.takeIf { previewAvailable }, + actorDisplayName = actorName, + timestamp = timestamp + ) From 231524d5b2b9bb87b88da050cf959122efa593c2 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 15:54:53 +0200 Subject: [PATCH 22/31] fix(mediaviewer): Exclude non-image/video files from the chat-seeded viewer MessageTypeContent.Media is the content type for any file attachment, not just images/videos (see getMediaContent() - it works for any mimetype). ChatViewModel.toMediaViewerItem() only checked for that content type, so a non-media file sharing an upload batch with a photo/video (or even a lone file message) would end up in the media viewer's seed and fail to render once swiped to. The other two seed-building paths (SharedItemsAdapter, MediaViewerViewModel.loadOlderGroups) already filtered by mimetype; this one was missed. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/chat/viewmodels/ChatViewModel.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index b104f91ca66..6d9aedf9287 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -74,6 +74,7 @@ import com.nextcloud.talk.ui.PlaybackSpeed import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability import com.nextcloud.talk.utils.ConversationUtils +import com.nextcloud.talk.utils.Mimetype import com.nextcloud.talk.utils.MimetypeUtils import com.nextcloud.talk.utils.ParticipantPermissions import com.nextcloud.talk.utils.SpreedFeatures @@ -227,11 +228,16 @@ internal fun combineFileShareGroups(uiMessages: List): List HashMap(params) })) val fileId = fileParameters.id ?: return null return MediaViewerItem( From 0e3f87134d724ab12f958f2fa01d4bd3ec3570c6 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 16:04:38 +0200 Subject: [PATCH 23/31] fix(mediaviewer): Silence ReturnCount finding in toMediaViewerItem Collapse the fileId null-check into the final expression via ?.let instead of a second early return, bringing the function back to 2 return statements. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../talk/chat/viewmodels/ChatViewModel.kt | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 6d9aedf9287..0af34d73bdd 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -239,20 +239,21 @@ private fun ChatMessageUi.toMediaViewerItem(): MediaViewerItem? { ?.takeIf { it.mimeType.startsWith(Mimetype.IMAGE_PREFIX) || it.mimeType.startsWith(Mimetype.VIDEO_PREFIX) } ?: return null val fileParameters = FileParameters(HashMap(messageParameters.mapValues { (_, params) -> HashMap(params) })) - val fileId = fileParameters.id ?: return null - return MediaViewerItem( - messageId = id.toLong(), - referenceId = referenceId, - fileId = fileId, - fileName = fileParameters.name.orEmpty(), - mimeType = content.mimeType, - path = fileParameters.path.orEmpty(), - link = fileParameters.link.orEmpty(), - fileSize = fileParameters.size ?: 0L, - previewUrl = content.previewUrl, - actorDisplayName = actorDisplayName, - timestamp = timestamp - ) + return fileParameters.id?.let { fileId -> + MediaViewerItem( + messageId = id.toLong(), + referenceId = referenceId, + fileId = fileId, + fileName = fileParameters.name.orEmpty(), + mimeType = content.mimeType, + path = fileParameters.path.orEmpty(), + link = fileParameters.link.orEmpty(), + fileSize = fileParameters.size ?: 0L, + previewUrl = content.previewUrl, + actorDisplayName = actorDisplayName, + timestamp = timestamp + ) + } } @Suppress("TooManyFunctions", "LongParameterList") From 3b61dbb1eaae60b9a970d7ee64e1a4f37fcdb8da Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Wed, 26 Aug 2026 16:46:52 +0200 Subject: [PATCH 24/31] fix(mediaviewer): Cap the media viewer seed to avoid TransactionTooLargeException Both seed-building call sites passed every locally known image/video through the launching Intent's Parcelable extras with no upper bound: SharedItemsAdapter.openMediaViewer() seeds from the Shared Items gallery's adapter list, which accumulates every scrolled-in page indefinitely, and ChatViewModel.mediaViewerSeed() from the chat's loaded message window. A room with hundreds/thousands of shared images could exceed Android's ~1MB Binder transaction limit and crash with TransactionTooLargeException. Added MediaViewerItem.capSeedAroundMessage(), which windows the seed to 200 items centered on the tapped message. Trimming is safe in the "older" direction since MediaViewerViewModel.loadOlderGroups() already pages further back via the network once the seed's oldest item is reached; "newer" beyond the window was already not network-fetchable (the shared-items endpoint only pages backwards), so this only lowers an existing ceiling rather than removing a capability. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../com/nextcloud/talk/chat/ChatActivity.kt | 7 ++- .../talk/mediaviewer/model/MediaViewerItem.kt | 25 ++++++++++ .../adapters/SharedItemsAdapter.kt | 2 + .../mediaviewer/model/MediaViewerItemTest.kt | 48 +++++++++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 205b2d8c895..9c440e83932 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -143,6 +143,7 @@ import com.nextcloud.talk.jobs.ShareOperationWorker import com.nextcloud.talk.jobs.UploadAndShareFilesWorker import com.nextcloud.talk.location.LocationPickerActivity import com.nextcloud.talk.mediaviewer.activities.MediaViewerActivity +import com.nextcloud.talk.mediaviewer.model.capSeedAroundMessage import com.nextcloud.talk.models.ExternalSignalingServer import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.capabilities.SpreedCapability @@ -1295,7 +1296,11 @@ class ChatActivity : val isViewableMedia = mimetype != null && (mimetype.startsWith(Mimetype.IMAGE_PREFIX) || mimetype.startsWith(Mimetype.VIDEO_PREFIX)) && fileViewerUtils.isSupportedForInternalViewer(mimetype) - val seedItems = if (isViewableMedia) chatViewModel.mediaViewerSeed().flatMap { it.items } else emptyList() + val seedItems = if (isViewableMedia) { + chatViewModel.mediaViewerSeed().flatMap { it.items }.capSeedAroundMessage(messageId.toLong()) + } else { + emptyList() + } if (isViewableMedia && seedItems.any { it.messageId == messageId.toLong() }) { startActivity( diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt index 28eb705849b..4874f7266e7 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt @@ -74,6 +74,31 @@ fun List.toMediaViewerGroups(): List { return result } +/** + * Caps a chronologically ordered item list to at most [maxItems] centered on the item with + * [anchorMessageId], so the seed passed through the launching Intent's Parcelable extras stays + * well under Android's ~1MB Binder transaction limit. Left uncapped, e.g. every image/video a user + * has scrolled through in the Shared Items gallery (which keeps accumulating pages, unbounded) gets + * Parcelled at once and risks a TransactionTooLargeException crash. + * + * Trimming is safe in the "older" direction - MediaViewerViewModel.loadOlderGroups() pages further + * back via the network once the seed's oldest item is reached. There's no equivalent for "newer": + * the shared-items endpoint only supports paging backwards, so items newer than the window are + * simply unreachable by swiping - an existing, accepted limitation (see MediaViewerViewModel's + * class doc), just with a lower ceiling than "everything currently loaded". + */ +fun List.capSeedAroundMessage( + anchorMessageId: Long, + maxItems: Int = MAX_SEED_ITEMS +): List { + if (size <= maxItems) return this + val anchorIndex = indexOfFirst { it.messageId == anchorMessageId }.coerceAtLeast(0) + val start = (anchorIndex - maxItems / 2).coerceIn(0, size - maxItems) + return subList(start, start + maxItems) +} + +private const val MAX_SEED_ITEMS = 200 + fun SharedFileItem.isImageOrVideo(): Boolean = mimeType.startsWith(Mimetype.IMAGE_PREFIX) || mimeType.startsWith(Mimetype.VIDEO_PREFIX) diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt index 4f6c49b791f..bfe26b12554 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt @@ -15,6 +15,7 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.SharedItemGridBinding import com.nextcloud.talk.databinding.SharedItemListBinding import com.nextcloud.talk.mediaviewer.activities.MediaViewerActivity +import com.nextcloud.talk.mediaviewer.model.capSeedAroundMessage import com.nextcloud.talk.mediaviewer.model.isImageOrVideo import com.nextcloud.talk.mediaviewer.model.toMediaViewerItem import com.nextcloud.talk.polls.ui.PollMainDialogFragment @@ -116,6 +117,7 @@ class SharedItemsAdapter( .filter { it.isImageOrVideo() } .sortedBy { it.messageId } .map { it.toMediaViewerItem() } + .capSeedAroundMessage(item.messageId) context.startActivity(MediaViewerActivity.newIntent(context, roomToken, seedItems, item.messageId)) } diff --git a/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItemTest.kt b/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItemTest.kt index ab516b1fc5f..5e2a780f358 100644 --- a/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItemTest.kt +++ b/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItemTest.kt @@ -92,4 +92,52 @@ class MediaViewerItemTest { assertEquals(keys.size, keys.toSet().size) } + + @Test + fun `capSeedAroundMessage leaves a list at or below the cap untouched`() { + val items = (1L..10L).map { item(it, null) } + + val capped = items.capSeedAroundMessage(anchorMessageId = 5L, maxItems = 10) + + assertEquals(items, capped) + } + + @Test + fun `capSeedAroundMessage centers the window on the anchor`() { + val items = (1L..100L).map { item(it, null) } + + val capped = items.capSeedAroundMessage(anchorMessageId = 50L, maxItems = 10) + + assertEquals(10, capped.size) + assertEquals((45L..54L).toList(), capped.map { it.messageId }) + } + + @Test + fun `capSeedAroundMessage clamps the window at the start of the list`() { + val items = (1L..100L).map { item(it, null) } + + val capped = items.capSeedAroundMessage(anchorMessageId = 2L, maxItems = 10) + + assertEquals(10, capped.size) + assertEquals((1L..10L).toList(), capped.map { it.messageId }) + } + + @Test + fun `capSeedAroundMessage clamps the window at the end of the list`() { + val items = (1L..100L).map { item(it, null) } + + val capped = items.capSeedAroundMessage(anchorMessageId = 99L, maxItems = 10) + + assertEquals(10, capped.size) + assertEquals((91L..100L).toList(), capped.map { it.messageId }) + } + + @Test + fun `capSeedAroundMessage still returns a bounded window when the anchor is missing`() { + val items = (1L..100L).map { item(it, null) } + + val capped = items.capSeedAroundMessage(anchorMessageId = -1L, maxItems = 10) + + assertEquals(10, capped.size) + } } From 6e5344f3daf98fee33effb5405082fe5aeb5e0d1 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 27 Aug 2026 12:39:54 +0200 Subject: [PATCH 25/31] fix(mediaviewer): Show sticky date header for grouped media items The sticky header text lookup only handled MessageItem explicitly, falling through to an empty string for MediaGroupItem and hiding the header whenever a grouped media bubble was the scroll anchor. Use the existing ChatItem.messageOrNull() helper instead, which already resolves the representative message for both item types. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt index 4cff050e9f6..f07dba36d02 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -329,16 +329,13 @@ fun ChatView( targetItem?.let { itemInfo -> state.chatItems.getOrNull(itemInfo.index)?.let { item -> when (item) { - is ChatViewModel.ChatItem.MessageItem -> - formatTime(item.uiMessage.timestamp * LONG_1000) - is ChatViewModel.ChatItem.DateHeaderItem -> formatTime(item.date) is ChatViewModel.ChatItem.UnreadMessagesMarkerItem -> formatTime(item.date) - else -> "" + else -> item.messageOrNull()?.let { formatTime(it.timestamp * LONG_1000) } ?: "" } } ?: "" } ?: "" From 73ef9914edb8c64de8af938a733c4be9f67c2197 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 27 Aug 2026 12:54:18 +0200 Subject: [PATCH 26/31] simplify date handling for stickyHeader Signed-off-by: Marcel Hibbe --- .../nextcloud/talk/chat/viewmodels/ChatViewModel.kt | 7 ++++++- .../main/java/com/nextcloud/talk/ui/chat/ChatView.kt | 11 ++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 0af34d73bdd..4fd8258ed7f 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -2690,7 +2690,12 @@ class ChatViewModel @AssistedInject constructor( is MediaGroupItem -> messages.lastOrNull() else -> null } - fun dateOrNull(): LocalDate? = (this as? DateHeaderItem)?.date + fun dateOrNull(): LocalDate? = + when (this) { + is DateHeaderItem -> date + is UnreadMessagesMarkerItem -> date + else -> null + } fun stableKey(): Any = when (this) { diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt index f07dba36d02..89029ba487b 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -328,15 +328,8 @@ fun ChatView( } targetItem?.let { itemInfo -> state.chatItems.getOrNull(itemInfo.index)?.let { item -> - when (item) { - is ChatViewModel.ChatItem.DateHeaderItem -> - formatTime(item.date) - - is ChatViewModel.ChatItem.UnreadMessagesMarkerItem -> - formatTime(item.date) - - else -> item.messageOrNull()?.let { formatTime(it.timestamp * LONG_1000) } ?: "" - } + item.dateOrNull()?.let { formatTime(it) } + ?: item.messageOrNull()?.let { formatTime(it.timestamp * LONG_1000) } } ?: "" } ?: "" } From da1b63c704ec7e97bd41418d53c3e6b5c6ef8f6c Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 27 Aug 2026 13:40:24 +0200 Subject: [PATCH 27/31] fix(mediaviewer): Stop stale spinner and video-source mismatch on swipe The loading spinner and the shared ExoPlayer's page ownership were both gated on the settle-driven currentItem, which lags behind the pager during a fast swipe. This left the spinner showing for a page the user had already left, and could momentarily attach a video page's PlayerView while the player still held the previous page's source. Gate the spinner on the live pager page instead, gate VideoPlayerView mounting on settledPage (matching the exoPlayer source swap), and detach the shared player from a page's PlayerView on dispose so a swiped-away page doesn't keep a stale listener/surface binding - a likely cause of the one-time freeze during fast swiping. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- .../activities/MediaViewerScreen.kt | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt index 45eb9842ef9..f9a2c1d57db 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt @@ -195,7 +195,11 @@ fun MediaViewerScreen( MediaPage( item = item, localPath = uiState.cachedFilePaths[item.messageId], - isCurrentPage = page == pagerState.currentPage, + // Gated on settledPage, not the live currentPage: the shared exoPlayer's media + // source is only swapped once the pager settles too (see the LaunchedEffect keyed + // on currentItem below), so mounting VideoPlayerView any earlier would attach it + // to a page whose video hasn't been loaded into the player yet. + isCurrentPage = page == pagerState.settledPage, exoPlayer = exoPlayer, onToggleControls = { showControls = !showControls }, onControlsVisibilityChanged = { showControls = it }, @@ -232,7 +236,12 @@ fun MediaViewerScreen( ) } - if (currentItem != null && currentLocalPath == null) { + // Keyed on the live pager page (pagerState.currentPage), not the settle-driven currentItem + // above - otherwise, after a quick swipe that outruns settling, this kept showing the + // spinner for the page the user had already left rather than the one on screen. + val livePageItem = items.getOrNull(pagerState.currentPage) + val livePageLocalPath = livePageItem?.let { uiState.cachedFilePaths[it.messageId] } + if (livePageItem != null && livePageLocalPath == null) { CircularProgressIndicator( modifier = Modifier.align(Alignment.Center).size(48.dp), color = Color.White @@ -413,6 +422,11 @@ private fun VideoPlayerView( updatePadding(left = leftPx, right = rightPx) } }, + // The player instance is shared across pages (see rememberViewerExoPlayer), so when this + // page's PlayerView leaves composition it must let go of it explicitly - otherwise it stays + // registered as a Player.Listener and keeps its (by-then-detached) surface referenced, which + // piles up across swipes instead of being released with the view. + onRelease = { playerView -> playerView.player = null }, modifier = Modifier.fillMaxSize() ) } From a9d1533a0161a38318a3475ac6435b40cf23a47b Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 27 Aug 2026 16:41:04 +0200 Subject: [PATCH 28/31] drop usage of referenceId as stableKey (fix IllegalArgumentException crash?) Pro: Should fix crashes.. referenceId is not suited as stableKey as there is no guarantee they are not used multiple times (might happen for retried sending etc). If the app tries to add messages with the same referenceId it will crash with Exception java.lang.IllegalArgumentException: at androidx.compose.ui.internal.InlineClassHelperKt.throwIllegalArgumentException (InlineClassHelper.kt:36) at androidx.compose.ui.layout.LayoutNodeSubcompositionsState.subcompose (SubcomposeLayout.kt:1592) Contra: this sometimes introduces a short flicker again when replacing a temporary message with the actual message Signed-off-by: Marcel Hibbe --- .../talk/chat/viewmodels/ChatViewModel.kt | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 4fd8258ed7f..d78fa8b8a22 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -2699,21 +2699,8 @@ class ChatViewModel @AssistedInject constructor( fun stableKey(): Any = when (this) { - // Prefer referenceId when present: it survives the swap from the local upload - // placeholder (negative placeholderId) to the real synced message (real server id), - // so Compose recomposes the existing list slot in place instead of removing and - // re-inserting a new one - which is what caused the visible flicker/pop. - is MessageItem -> uiMessage.referenceId?.takeIf { it.isNotBlank() }?.let { "msg_ref_$it" } - ?: "msg_${uiMessage.id}" - // Keyed off the group's first message specifically (not the shared batch hash from - // groupHash()) - a failed upload mid-batch excludes that one message and splits the - // rest into two separate groups (see combineFileShareGroups()), and both halves - // share the same batch hash, which collided here and crashed the LazyColumn (two - // items must never report the same key). Each message's own referenceId is unique, - // so keying off the first one is unique per group instance while still surviving the - // swap from upload placeholder to synced message, same as MessageItem above. - is MediaGroupItem -> messages.first().referenceId?.takeIf { it.isNotBlank() } - ?.let { "msg_group_$it" } ?: "msg_group_${messages.first().id}" + is MessageItem -> "msg_${uiMessage.id}" + is MediaGroupItem -> "msg_group_${messages.first().id}" is DateHeaderItem -> "header_$date" is UnreadMessagesMarkerItem -> "last_read_$date" is LoadGapItem -> "load_gap_$anchorMessageId" From 5f8b5089ca595f22d5c2e466ca1d06d4f40d1f98 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 27 Aug 2026 17:29:41 +0200 Subject: [PATCH 29/31] fix(chat): Open media viewer for images/videos regardless of exact mimetype isSupportedForInternalViewer()'s hardcoded mimetype whitelist gated whether a tapped image/video opened the swipeable media viewer, but ChatViewModel.toMediaViewerItem() (which builds the viewer's own seed list) only ever checked the image/video prefix. Any mimetype outside that whitelist - e.g. from a file shared by another client/device - therefore fell through to an external Intent(ACTION_VIEW), surfacing Android's app chooser instead of the internal viewer. Drop the whitelist check from both gates so any image/video mimetype routes to the media viewer, matching toMediaViewerItem()'s own check. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt | 7 ++----- .../main/java/com/nextcloud/talk/utils/FileViewerUtils.kt | 7 +------ 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 9c440e83932..47544232ddc 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -1291,11 +1291,8 @@ class ChatActivity : val mimetype = chatMessage.fileParameters.mimetype val fileViewerUtils = FileViewerUtils(this@ChatActivity, conversationUser) - // Images/videos open the swipeable media viewer instead of the single-item viewer - - // everything else (audio, text, generic files) keeps today's behavior unchanged. - val isViewableMedia = mimetype != null && - (mimetype.startsWith(Mimetype.IMAGE_PREFIX) || mimetype.startsWith(Mimetype.VIDEO_PREFIX)) && - fileViewerUtils.isSupportedForInternalViewer(mimetype) + val isViewableMedia = mimetype.startsWith(Mimetype.IMAGE_PREFIX) || + mimetype.startsWith(Mimetype.VIDEO_PREFIX) val seedItems = if (isViewableMedia) { chatViewModel.mediaViewerSeed().flatMap { it.items }.capSeedAroundMessage(messageId.toLong()) } else { diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt index 8386998f7e8..9aa63187c25 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt @@ -72,12 +72,7 @@ class FileViewerUtils(private val context: Context, private val user: User) { ) { val mimetype = message.fileParameters.mimetype - // Images/videos are fully covered by the swipeable media viewer now, so they skip the - // single-item FullScreenImageActivity/FullScreenMediaActivity entirely - unlike audio, - // which FullScreenMediaActivity still owns. - val isViewableMedia = mimetype != null && - (mimetype.startsWith(IMAGE_PREFIX) || mimetype.startsWith(VIDEO_PREFIX)) && - isSupportedForInternalViewer(mimetype) + val isViewableMedia = mimetype.startsWith(IMAGE_PREFIX) || mimetype.startsWith(VIDEO_PREFIX) if (isViewableMedia) { openInMediaViewer(message) return From 39acc35a7ea4543f9c4aa47e2948b33961582de8 Mon Sep 17 00:00:00 2001 From: Marcel Hibbe Date: Thu, 27 Aug 2026 17:39:04 +0200 Subject: [PATCH 30/31] fix(chat): Mark grouped media messages as read The read-marker LaunchedEffect (and the pagination-boundary id lookups above it) filtered chatItems with "is ChatItem.MessageItem" only, so a MediaGroupItem was skipped entirely. When the newest item in the chat was a grouped file-share bubble, its message ids never reached advanceLocalLastReadMessageIfNeeded, so the stale read-marker id was what got sent to the server too - leaving the conversation shown as unread after returning to the conversation list. Use the existing ChatItem.messageOrNull() helper instead, which already resolves correctly for MediaGroupItem (same fix already applied to the sticky date header for the same missing-case bug). Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe --- app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt index 89029ba487b..287ed4f360f 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -296,9 +296,9 @@ fun ChatView( val oldestLoadedMessageId = latestChatItems .asReversed() - .firstNotNullOfOrNull { (it as? ChatViewModel.ChatItem.MessageItem)?.uiMessage?.id } + .firstNotNullOfOrNull { it.messageOrNull()?.id } val newestLoadedMessageId = latestChatItems - .firstNotNullOfOrNull { (it as? ChatViewModel.ChatItem.MessageItem)?.uiMessage?.id } + .firstNotNullOfOrNull { it.messageOrNull()?.id } if (shouldLoadOlder && oldestLoadedMessageId != null) { callbacks.onLoadMore?.invoke(oldestLoadedMessageId, ChatViewModel.LoadMoreDirection.OLDER) @@ -358,7 +358,7 @@ fun ChatView( if (!isAtNewest) return@LaunchedEffect state.chatItems - .firstNotNullOfOrNull { (it as? ChatViewModel.ChatItem.MessageItem)?.uiMessage?.id } + .firstNotNullOfOrNull { it.messageOrNull()?.id } ?.let { newestId -> callbacks.advanceLocalLastReadMessageIfNeeded?.invoke(newestId) } From 9c1be99180bb49f5d228da5c1636b99fc230e9ec Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Thu, 27 Aug 2026 17:56:33 +0200 Subject: [PATCH 31/31] fix(mediaviewer): Keep the thumbnail strip clear of the navigation bar The media viewer runs edge-to-edge with a transparent navigation bar, so its Compose content draws behind that bar. The thumbnail strip was bottom-aligned with only a hard-coded 24dp bottom padding and never consumed the navigation bar inset, leaving the bottom of the thumbnails underneath the nav bar on devices using 3-button navigation. Apply navigationBarsPadding() to the strip after its background, so the scrim still extends behind the bar while the thumbnails are pushed clear of it - in landscape this also keeps them clear of a side nav bar. The 24dp bottom padding only existed to fake that clearance, so top/bottom padding is now symmetric and the real inset supplies the rest. The height reserved to keep ExoPlayer's own controller above the strip deliberately excludes the inset: VideoPlayerView already adds the system bars bottom inset itself, so both together still match the strip's full height. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger --- .../talk/mediaviewer/activities/MediaViewerScreen.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt index f9a2c1d57db..8e725a60444 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars @@ -95,8 +96,7 @@ private const val MEDIUM_SCALE = 2.45f private val thumbnailSize = 48.dp private val thumbnailSpacing = 4.dp -private val thumbnailStripTopPadding = 12.dp -private val thumbnailStripBottomPadding = 24.dp +private val thumbnailStripVerticalPadding = 12.dp private val controlsSlideDistance = 24.dp @Suppress("Detekt.LongMethod", "CyclomaticComplexMethod") @@ -183,9 +183,10 @@ fun MediaViewerScreen( val hasThumbnailStrip = group != null && group.items.size > 1 // Reserved so the video's own ExoPlayer controller (progress bar, play/pause row) never renders // underneath the thumbnail strip - the strip's height is fixed (a constant thumbnail size plus - // its own fixed padding), so this is knowable up front rather than measured. + // its own fixed padding), so this is knowable up front rather than measured. The strip's + // navigation bar inset is excluded here: VideoPlayerView adds that inset itself. val thumbnailStripHeightPx = with(density) { - (thumbnailSize + thumbnailStripTopPadding + thumbnailStripBottomPadding).roundToPx() + (thumbnailSize + thumbnailStripVerticalPadding * 2).roundToPx() } val controllerExtraBottomInsetPx = if (hasThumbnailStrip) thumbnailStripHeightPx else 0 @@ -445,7 +446,8 @@ private fun ThumbnailStrip( modifier = modifier .fillMaxWidth() .background(Color.Black.copy(alpha = TOOLBAR_ALPHA)) - .padding(top = thumbnailStripTopPadding, bottom = thumbnailStripBottomPadding) + .navigationBarsPadding() + .padding(vertical = thumbnailStripVerticalPadding) ) { // Side padding equal to half the leftover viewport width, so the row can scroll any item - // including the first/last - all the way to the exact horizontal center, not just as close