diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 54182da11ba..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" />
-
-
+
+
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..47544232ddc 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,8 @@ 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.mediaviewer.model.capSeedAroundMessage
import com.nextcloud.talk.models.ExternalSignalingServer
import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.models.json.capabilities.SpreedCapability
@@ -251,6 +253,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
@@ -1285,11 +1288,24 @@ 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)
+
+ 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 {
+ emptyList()
+ }
+
+ if (isViewableMedia && seedItems.any { it.messageId == messageId.toLong() }) {
+ startActivity(
+ MediaViewerActivity.newIntent(this@ChatActivity, roomToken, seedItems, messageId.toLong())
+ )
+ } else {
+ fileViewerUtils.openFile(chatMessage, openWhenDownloadState, downloadState)
+ }
}
}
@@ -2760,6 +2776,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 +2785,9 @@ class ChatActivity :
roomToken = roomToken,
replyToMessageId = getReplyToMessageId(),
displayName = currentConversation?.displayName!!,
- compressImages = compressImages
+ compressImages = compressImages,
+ uploadId = uploadId,
+ order = i + 1
)
}
}
@@ -4155,7 +4174,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 +4185,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..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
@@ -26,8 +26,10 @@ 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
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
@@ -46,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
@@ -70,11 +74,15 @@ 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
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
@@ -129,6 +137,125 @@ 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"
+
+/**
+ * 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? = 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
+// 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 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 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 &&
+ mimeType != VCARD_MIMETYPE &&
+ !MimetypeUtils.isAudioOnly(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
+}
+
+/**
+ * Converts an already-synced media message into a media-viewer item, or null if it isn't one
+ * (text/system/voice/geo/poll/deck messages, a still-uploading placeholder, or a Media message
+ * whose file isn't actually an image/video - MessageTypeContent.Media covers every file
+ * attachment, not just image/video, e.g. a PDF grouped into the same upload batch as a photo).
+ * The viewer is only ever entered by tapping an already-synced image/video message, so anything
+ * else is never navigable to anyway.
+ */
+private fun ChatMessageUi.toMediaViewerItem(): MediaViewerItem? {
+ val content = (content as? MessageTypeContent.Media)
+ ?.takeIf { it.mimeType.startsWith(Mimetype.IMAGE_PREFIX) || it.mimeType.startsWith(Mimetype.VIDEO_PREFIX) }
+ ?: return null
+ val fileParameters = FileParameters(HashMap(messageParameters.mapValues { (_, params) -> HashMap(params) }))
+ 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")
class ChatViewModel @AssistedInject constructor(
private val logger: Logger,
@@ -271,8 +398,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)
}
@@ -282,6 +415,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
@@ -1185,6 +1337,7 @@ class ChatViewModel @AssistedInject constructor(
// ------------------------------
// Build chat items (pure)
// ------------------------------
+ @Suppress("CyclomaticComplexMethod")
private fun buildChatItems(
uiMessages: List,
lastReadMessage: Int,
@@ -1203,32 +1356,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 +2225,7 @@ class ChatViewModel @AssistedInject constructor(
fun getCurrentVoiceRecordFile(): String = mediaRecorderManager.currentVoiceRecordFile
+ @Suppress("LongParameterList")
fun uploadFile(
fileUri: String,
isVoiceMessage: Boolean,
@@ -2064,7 +2233,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 +2262,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,23 +2682,39 @@ class ChatViewModel @AssistedInject constructor(
}
sealed interface ChatItem {
- fun messageOrNull(): ChatMessageUi? = (this as? MessageItem)?.uiMessage
- fun dateOrNull(): LocalDate? = (this as? DateHeaderItem)?.date
+ // 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? =
+ when (this) {
+ is DateHeaderItem -> date
+ is UnreadMessagesMarkerItem -> date
+ else -> null
+ }
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}"
+ 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"
}
data class MessageItem(val uiMessage: ChatMessageUi) : ChatItem
+
+ // 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
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/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/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/fullscreenfile/FullScreenImageActivity.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenImageActivity.kt
deleted file mode 100644
index e2d7004a48e..00000000000
--- a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenImageActivity.kt
+++ /dev/null
@@ -1,156 +0,0 @@
-/*
- * Nextcloud Talk - Android Client
- *
- * SPDX-FileCopyrightText: 2023 Ezhil Shanmugham
- * 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
new file mode 100644
index 00000000000..8f851d414b4
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt
@@ -0,0 +1,153 @@
+/*
+ * 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 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.WindowInsetsCompat
+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.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
+ private lateinit var windowInsetsController: WindowInsetsControllerCompat
+
+ 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)
+ )
+ windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
+ windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+
+ // 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
+ // top bar's Close button and the system back gesture/button.
+ 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,
+ onControlsVisibilityChanged = { visible ->
+ if (visible) exitImmersiveMode() else enterImmersiveMode()
+ }
+ )
+ }
+ }
+ }
+
+ 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)
+ 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..8e725a60444
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt
@@ -0,0 +1,523 @@
+/*
+ * 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 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
+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
+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.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
+import androidx.compose.foundation.lazy.LazyListState
+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.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
+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.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
+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.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
+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 thumbnailStripVerticalPadding = 12.dp
+private val controlsSlideDistance = 24.dp
+
+@Suppress("Detekt.LongMethod", "CyclomaticComplexMethod")
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MediaViewerScreen(
+ viewModel: MediaViewerViewModel,
+ onShare: (MediaViewerItem, String) -> Unit,
+ onSave: (MediaViewerItem, String) -> Unit,
+ onControlsVisibilityChanged: (Boolean) -> Unit = {}
+) {
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+ 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
+ }
+ 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] }
+
+ // 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()))
+ val shouldAutoPlay = !hasAutoPlayed && currentItem.messageId == initialMessageId
+ exoPlayer.playWhenReady = shouldAutoPlay
+ if (shouldAutoPlay) hasAutoPlayed = true
+ exoPlayer.prepare()
+ } else {
+ exoPlayer.stop()
+ }
+ }
+
+ // Tapping the currently shown item toggles this off, hiding the top bar and thumbnail strip so
+ // 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 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
+ // sliding in from the side instead of the intended plain fade.
+ val thumbnailListState = rememberLazyListState()
+ 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. The strip's
+ // navigation bar inset is excluded here: VideoPlayerView adds that inset itself.
+ val thumbnailStripHeightPx = with(density) {
+ (thumbnailSize + thumbnailStripVerticalPadding * 2).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
+ MediaPage(
+ item = item,
+ localPath = uiState.cachedFilePaths[item.messageId],
+ // 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 },
+ controllerExtraBottomInsetPx = controllerExtraBottomInsetPx
+ )
+ }
+
+ AnimatedVisibility(
+ visible = showControls,
+ enter = fadeIn() + slideInVertically(initialOffsetY = { -slideOffsetPx }),
+ exit = fadeOut() + slideOutVertically(targetOffsetY = { -slideOffsetPx }),
+ modifier = Modifier.align(Alignment.TopCenter)
+ ) {
+ val toolbarColors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color.Black.copy(alpha = TOOLBAR_ALPHA),
+ 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) })
+ }
+ }
+ val sentDateTime = currentItem?.let {
+ dateUtils.getLocalDateTimeStringFromTimestamp(it.timestamp * DateConstants.SECOND_DIVIDER)
+ }
+ StandardAppBar(
+ title = currentItem?.actorDisplayName.orEmpty(),
+ menuItems = menuItems,
+ colors = toolbarColors,
+ subtitle = sentDateTime
+ )
+ }
+
+ // 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
+ )
+ }
+
+ AnimatedVisibility(
+ visible = showControls && hasThumbnailStrip,
+ 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) }
+ }
+ }
+ )
+ }
+ }
+ }
+}
+
+@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
+}
+
+@Suppress("LongParameterList")
+@OptIn(UnstableApi::class)
+@Composable
+private fun MediaPage(
+ item: MediaViewerItem,
+ localPath: String?,
+ isCurrentPage: Boolean,
+ exoPlayer: ExoPlayer,
+ onToggleControls: () -> Unit,
+ onControlsVisibilityChanged: (Boolean) -> Unit,
+ controllerExtraBottomInsetPx: Int
+) {
+ 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, onToggleControls = onToggleControls)
+ isVideo -> if (isCurrentPage) {
+ VideoPlayerView(
+ exoPlayer = exoPlayer,
+ onControlsVisibilityChanged = onControlsVisibilityChanged,
+ extraBottomInsetPx = controllerExtraBottomInsetPx
+ )
+ } else {
+ PreviewPlaceholder(item, onToggleControls = onToggleControls)
+ }
+ isGif -> GifPage(localPath = localPath, onToggleControls = onToggleControls)
+ else -> ImagePage(localPath = localPath, onToggleControls = onToggleControls)
+ }
+ }
+}
+
+@Composable
+private fun PreviewPlaceholder(item: MediaViewerItem, onToggleControls: () -> Unit) {
+ if (item.previewUrl != null) {
+ AsyncImage(
+ model = item.previewUrl,
+ contentDescription = item.fileName,
+ modifier = Modifier.fillMaxSize().clickable(onClick = onToggleControls),
+ contentScale = ContentScale.Fit
+ )
+ }
+}
+
+@Composable
+private fun GifPage(localPath: String, onToggleControls: () -> Unit) {
+ AndroidView(
+ factory = { ctx ->
+ GifImageView(ctx).apply {
+ setImageDrawable(GifDrawable(localPath))
+ setOnClickListener { onToggleControls() }
+ }
+ },
+ modifier = Modifier.fillMaxSize()
+ )
+}
+
+@Composable
+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,
+ 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()
+ )
+}
+
+// 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,
+ 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 {
+ player = exoPlayer
+ useController = true
+ showController()
+ setControllerVisibilityListener(
+ PlayerView.ControllerVisibilityListener { visibility ->
+ onControlsVisibilityChanged(visibility == View.VISIBLE)
+ }
+ )
+ }
+ },
+ 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)
+ }
+ },
+ // 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()
+ )
+}
+
+@Composable
+private fun ThumbnailStrip(
+ group: MediaViewerGroup,
+ currentItem: MediaViewerItem?,
+ listState: LazyListState,
+ modifier: Modifier = Modifier,
+ onThumbnailClick: (MediaViewerItem) -> Unit
+) {
+ val currentIndex = currentItem?.let { item -> group.items.indexOfFirst { it.messageId == item.messageId } } ?: -1
+
+ BoxWithConstraints(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(Color.Black.copy(alpha = TOOLBAR_ALPHA))
+ .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
+ // 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) }
+ )
+ }
+ }
+ }
+}
+
+@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..4874f7266e7
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItem.kt
@@ -0,0 +1,118 @@
+/*
+ * 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.shareditems.model.SharedFileItem
+import com.nextcloud.talk.utils.Mimetype
+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,
+ /** Epoch seconds, matching ChatMessage/ChatMessageJson's own timestamp convention. */
+ val timestamp: Long
+) : 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
+}
+
+/**
+ * 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)
+
+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
new file mode 100644
index 00000000000..99cea888581
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt
@@ -0,0 +1,241 @@
+/*
+ * 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.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 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 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/adapters/SharedItemsAdapter.kt b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt
index c616decefd6..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
@@ -14,6 +14,10 @@ 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.capSeedAroundMessage
+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 +68,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 +107,20 @@ 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() }
+ .capSeedAroundMessage(item.messageId)
+ 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/shareditems/model/SharedFileItem.kt b/app/src/main/java/com/nextcloud/talk/shareditems/model/SharedFileItem.kt
index 17b09c78d58..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
@@ -18,5 +18,9 @@ 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?,
+ /** 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 01eed88f83b..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,
@@ -103,7 +104,10 @@ 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,
+ it.value.timestamp
)
} else if (it.value.messageParameters?.containsKey("object") == true) {
val objectParameters = it.value.messageParameters!!["object"]!!
@@ -115,7 +119,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/ui/chat/ChatView.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt
index 78e2055334f..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)
@@ -328,18 +328,8 @@ 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 -> ""
- }
+ item.dateOrNull()?.let { formatTime(it) }
+ ?: item.messageOrNull()?.let { formatTime(it.timestamp * LONG_1000) }
} ?: ""
} ?: ""
}
@@ -368,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)
}
@@ -433,6 +423,35 @@ 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,
+ onCancelUpload = callbacks.messageCallbacks.onCancelUpload
+ )
+ )
+ }
+ }
+
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..5c7b571b5fd
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/ui/chat/MediaGroupMessage.kt
@@ -0,0 +1,662 @@
+/*
+ * 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 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
+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.fillMaxSize
+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.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
+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
+import androidx.compose.ui.draw.alpha
+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 androidx.core.net.toUri
+import coil.compose.AsyncImagePainter
+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
+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
+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
+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 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
+
+// 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
+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
+
+ // 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,
+ 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()) {
+ Box(
+ modifier = Modifier
+ .padding(mediaGroupInset)
+ .clip(groupShape)
+ ) {
+ MediaGrid(
+ items = mediaItems,
+ chatViewDownloadingFileState = context.downloadingFileState,
+ onItemClick = callbacks.onFileClick,
+ onCancelUpload = callbacks.onCancelUpload
+ )
+ }
+ }
+ 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) },
+ onCancelUpload = callbacks.onCancelUpload
+ )
+ }
+ }
+ }
+ }
+ }
+ )
+ }
+ }
+ }
+ }
+}
+
+private fun ChatMessageUi.isPreviewableMedia(): Boolean {
+ 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", "LongParameterList")
+@Composable
+private fun MediaGrid(
+ items: List,
+ chatViewDownloadingFileState: List,
+ onItemClick: (Int) -> Unit,
+ onCancelUpload: (String) -> 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,
+ onCancelUpload = onCancelUpload,
+ 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,
+ onCancelUpload = onCancelUpload,
+ 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,
+ onCancelUpload = onCancelUpload,
+ 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,
+ onCancelUpload = onCancelUpload,
+ 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,
+ onCancelUpload = onCancelUpload,
+ 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
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+// 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 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", "CyclomaticComplexMethod")
+@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) }))
+ }
+ val isVideo = typeContent.mimeType.startsWith(Mimetype.VIDEO_PREFIX)
+ val isGif = MimetypeUtils.isGif(typeContent.mimeType)
+ val showPlayButton = isVideo || (isGif && !typeContent.animateGif)
+ 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)
+ ?.asImageBitmap()
+ ?.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
+ } else {
+ load(
+ imageUri = typeContent.previewUrl,
+ context = context,
+ errorPlaceholderImage = typeContent.drawableResourceId,
+ animated = typeContent.animateGif
+ )
+ }
+ }
+
+ if (showsGenericIcon) {
+ 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 (basePainter != null) {
+ Image(
+ painter = basePainter,
+ contentDescription = null,
+ modifier = Modifier.fillMaxWidth().fillMaxHeight(),
+ contentScale = ContentScale.Crop
+ )
+ }
+ 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 = loadedPainter,
+ contentDescription = stringResource(R.string.media_message_content_description),
+ modifier = Modifier.fillMaxWidth().fillMaxHeight().alpha(loadedAlpha),
+ 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)
+ }
+}
+
+@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(
+ imageVector = Icons.Default.Close,
+ contentDescription = stringResource(R.string.nc_cancel),
+ tint = Color.White
+ )
+ }
+ }
+ }
+}
+
+@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 SyncedFileRow(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
+ )
+ }
+}
+
+@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/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/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt
index 7c3ea29d42c..9aa63187c25 100644
--- a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt
+++ b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt
@@ -29,10 +29,11 @@ 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
+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
@@ -41,14 +42,15 @@ 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
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
-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
@@ -60,6 +62,7 @@ import java.util.concurrent.ExecutionException
* Example:
* - SharedItemsViewHolder
*/
+@Suppress("TooManyFunctions")
class FileViewerUtils(private val context: Context, private val user: User) {
fun openFile(
@@ -67,8 +70,15 @@ class FileViewerUtils(private val context: Context, private val user: User) {
openWhenDownloadState: MutableState,
downloadState: MutableState>
) {
- val fileName = message.fileParameters.name
val mimetype = message.fileParameters.mimetype
+
+ val isViewableMedia = mimetype.startsWith(IMAGE_PREFIX) || mimetype.startsWith(VIDEO_PREFIX)
+ if (isViewableMedia) {
+ openInMediaViewer(message)
+ return
+ }
+
+ val fileName = message.fileParameters.name
val link = message.fileParameters.link
val fileId = message.fileParameters.id
@@ -83,6 +93,38 @@ class FileViewerUtils(private val context: Context, private val user: User) {
)
}
+ 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 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,16 +193,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)
-
- IMAGE_PNG,
- IMAGE_JPEG,
- IMAGE_GIF,
- IMAGE_HEIC -> openImageView(filename, mimetype)
+ VIDEO_WEBM -> openVideoView(filename, mimetype)
TEXT_MARKDOWN,
TEXT_PLAIN -> openTextView(filename, mimetype, link, fileId)
@@ -228,14 +269,14 @@ 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)
+ fullScreenMediaIntent.putExtra("AUDIO_ONLY", isAudioOnly(mimetype))
+ context.startActivity(fullScreenMediaIntent)
}
- private fun openMediaView(filename: String, mimetype: String) {
+ 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))
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..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()
@@ -18,6 +32,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 +54,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..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
@@ -10,11 +10,14 @@ 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
+@Suppress("TooManyFunctions")
class ChatViewModelTest {
// The unread marker latch: the marker position must only be derived from the visible window
@@ -59,6 +62,169 @@ 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 `a failed message is excluded and breaks the group around it`() {
+ val uploadId = "batch-6"
+ val messages = listOf(
+ mediaMessage(1, refUtils.generateGroupedReferenceId(uploadId, 1)),
+ 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 `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 })
+ }
+
+ // 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(
+ 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 +242,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/mediaviewer/model/MediaViewerItemTest.kt b/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItemTest.kt
new file mode 100644
index 00000000000..5e2a780f358
--- /dev/null
+++ b/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerItemTest.kt
@@ -0,0 +1,143 @@
+/*
+ * 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",
+ timestamp = messageId
+ )
+
+ @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)
+ }
+
+ @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)
+ }
+}
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
+ }
+}
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("-"))
+ }
+}