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("-")) + } +}