Feature/6524/grouped files - #6586
Conversation
Files sent from the Upload Preview Screen in one batch now get a cross-client referenceId (sha256(uploadId)-order, matching nextcloud/spreed#19040) and render as a single WhatsApp-style grouped bubble instead of one message per file - an adaptive media grid for images/videos plus a stacked list for other file types. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
Grouping previously only kicked in once every file in a batch had synced back from the server, so a batch showed as separate bubbles for the whole upload and only merged into one afterwards. The grid and file-list tiles now render the still-uploading state directly (local preview, progress spinner, cancel button) so a batch groups into one bubble as soon as the upload starts. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
The grid tile fell back straight to the bare mimetype icon whenever the server hadn't reported a preview link yet for a just-synced message - a brief but reliably visible window right after every upload. Bridged the same local-file fallback (and, for video, the locally-extracted first frame) the single-message MediaMessage already uses, so the tile keeps showing what it has on disk instead of flashing the icon while waiting for the server preview. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
The tile's "real preview" layer used rememberAsyncImagePainter() directly on a request whose placeholder is the generic mimetype icon, and rendered it unconditionally at full opacity - so the icon sat fully opaque on top of the correct base layer (local file/ blurhash) for as long as the network image was still loading. Only fading that layer in once it has actually finished loading (same technique the single-message MediaMessage already uses) keeps the base layer visible the whole time instead. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
MediaGroupItem.stableKey() was derived from groupHash(), the hash shared by every file in an upload batch. A failed upload in the middle of a batch excludes that one message from grouping and splits the rest into two separate groups (see combineFileShareGroups()) - both of which share that same batch hash, so they collided on the same LazyColumn key and crashed with "Key ... was already used." Keyed off the group's own first message's referenceId instead, which is unique per message and therefore unique per group, while still surviving the swap from upload placeholder to synced message the same way MessageItem's key already does. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
Uploading a batch chains uploads sequentially, so a file near the end of the batch can sit waiting for a while before its own upload (and the local-file reads that feed the "uploading" preview) start. The system Photo Picker's content:// uris are only guaranteed readable for a short window after picking, and once that grant is gone, FileUtils.getFileName()/resolveMimeType()/copyFileToCache() made a raw, unguarded ContentResolver call that threw a SecurityException straight past every caller (describeFile(), the aspect-ratio/video-thumbnail reads in the media composables) and crashed the app. Caught the failure at its source in FileUtils instead, falling back to what's still derivable (the uri's own path for the filename, the file extension for the mime type) so every caller degrades instead of crashing. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
cancelUpload() bailed out entirely when uploadReferenceToWorkId had no entry for the given referenceId. That map is session-local and never persisted, so it's always empty after an app restart - meaning cancel silently did nothing for any placeholder still showing as "uploading" from before the restart (e.g. one left behind by a crash, or a chained upload whose WorkManager queue got replaced by a later enqueue), with no other way to clear it from the chat. The placeholder is now always deleted regardless of whether a live work id is known for it; the WorkManager cancellation only happens when one is. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
Tapping an image or video in chat now opens a Signal-style pager
instead of the old single-item viewer: swipe left/right to move
between media items, with a bottom thumbnail strip for the current
upload-batch group, centered on the active item. Crossing a group's
edge moves to the adjacent group - toward older history by paging
through SharedItemsRepository (getUrlForChatSharedItems), toward
newer items bounded by what the chat screen already has loaded
locally, since that endpoint has no "items newer than X" mode.
New com.nextcloud.talk.mediaviewer package (MediaViewerActivity/
Screen/ViewModel) reuses the existing PhotoView/GIF/ExoPlayer
rendering techniques and the DownloadFileToCacheWorker pipeline
rather than introducing new streaming infrastructure.
FullScreenImageActivity/FullScreenMediaActivity are unchanged and
still serve every other entry point (audio, non-image/video files).
Also fixes a latent bug in SharedItemsRepositoryImpl: shared items
were sorted lexicographically by message id string ("10" before
"9"), which the new viewer's group/page-adjacency reconstruction
depends on being truly chronological.
Assisted-by: Claude Code:claude-sonnet-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
SwipeToCloseLayout (added in #5756 for the single-item viewers) wraps the whole screen in a ViewDragHelper that intercepts drags at the parent level before the HorizontalPager's own gesture handling ever sees them. A real-world swipe is rarely perfectly horizontal, so its small vertical component was enough to trigger the dismiss gesture, closing the viewer on what was meant as a page-navigation swipe. Drop it from MediaViewerActivity entirely rather than trying to make it coexist with horizontal paging - closing is still available via the top bar's Close button and the system back gesture/button. FullScreenImageActivity/FullScreenMediaActivity (which have no horizontal gesture to conflict with) keep using it unchanged. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
Every video landed on became playWhenReady=true, so swiping to a video auto-started it too. Now only the item the viewer was directly opened on from chat autoplays, once; anything reached by swiping - including swiping back to that same item later - stays paused until the user taps play. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
The old centering math estimated an offset by hand and applied it as a scroll target adjustment, which only worked once the LazyRow had already been laid out and never let the first/last thumbnail actually reach the center (the row simply ran out of content to scroll). Compute the row's side content padding from its own measured width (via BoxWithConstraints) so every item, including the first and last, can scroll all the way to the exact horizontal center - then just animateScrollToItem() the current index on each change, so swiping smoothly slides the row until the new current item lands centered. Also splits the strip's padding so the bottom margin (space to the screen edge) is distinct from the top. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
Mirrors FullScreenImageScreen/FullScreenMediaScreen's existing tap-to-toggle-fullscreen behavior: tapping the currently displayed image, gif or video now hides the top bar and thumbnail strip so only the media itself is visible, and tapping again brings them back. Video pages sync to ExoPlayer's own controller visibility (PlayerView.setControllerVisibilityListener) rather than an independent toggle, since the controller already shows/hides itself on tap and auto-hides after a timeout - keeping a single source of truth for "are controls visible" instead of two that could disagree. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
The top bar and thumbnail strip were plain-conditionally included in
composition (if (showControls) { ... }), so every reveal remounted
the thumbnail strip's LazyListState from scratch and re-ran its
scroll-to-current-item animation from an unscrolled starting point -
visually reading as the thumbnails sliding in from the side, not the
intended toggle.
Hoisted the thumbnail strip's LazyListState up to MediaViewerScreen
so it survives the strip being hidden and shown again, and replaced
both conditionals with AnimatedVisibility: the top bar fades in/out
while sliding from/to the top, the thumbnail strip fades in/out
while sliding from/to the bottom.
Assisted-by: Claude Code:claude-sonnet-5
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
ExoPlayer's controller (progress bar, play/pause row) rendered at the bottom of the video regardless of whether the thumbnail strip was also showing there, so the strip could sit on top of it. Rather than shrinking the video content area - which would visibly resize the video every time controls are toggled on/off - push the controller's own bottom margin up by the strip's (fixed) height on top of the system nav bar inset, the same technique FullScreenMediaScreen's MediaPlayerView already uses to keep the controller clear of the nav bar. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
The top bar already showed the sender as its title. Added a subtitle showing when the item was sent, using the same DateUtils.getLocalDateTimeStringFromTimestamp() formatting the shared-items gallery already uses, so both look consistent regardless of whether the item came from the locally loaded chat or a network-fetched older page. StandardAppBar gains an optional subtitle parameter (defaults to null, so every other caller is unaffected) rendering a second, smaller line under the title. MediaViewerItem and SharedFileItem both gain a raw timestamp field to carry this through. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
The media grid clipped each tile to a fixed RoundedCornerShape(4.dp) and sat flush against the bubble edge - unlike a single image, which gets a 4dp inset and a corner shape (MediaMessage's shape()) that nests concentrically inside the bubble's own corner based on its incoming/isGrouped/isGroupedWithNext state. Wrapped the grid as a whole in that same inset + shape() so a grouped album's outer frame now looks the same as a single image's, regardless of how many tiles it contains; individual tile corners are unchanged. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
Matches the bottom thumbnail strip's own Color.Black.copy(alpha = TOOLBAR_ALPHA) background instead of a fully transparent container, for consistent legibility and a matching look between the top and bottom chrome. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
Tapping the current item hid the top bar and thumbnail strip but left the system status/nav bars showing. Wired a windowInsetsController already set up in MediaViewerActivity (previously unused) through a new onControlsVisibilityChanged callback on MediaViewerScreen, fired whenever the controls-visible state changes, so the system bars now hide and show together with the rest of the chrome - matching FullScreenImageScreen/FullScreenMediaScreen's existing behavior. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
…er, split video/audio Images/videos tapped in the Shared Items gallery now open MediaViewerActivity, seeded with every image/video currently loaded in that gallery instead of the single-item FullScreenImageActivity/FullScreenMediaActivity. FileViewerUtils now routes video straight to the media viewer too (matching chat), so FullScreenMediaActivity is only reached for audio going forward. The SharedFileItem -> MediaViewerItem mapper moves to the shared model file so both MediaViewerViewModel and SharedItemsAdapter can use it. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
…aced Images reached FullScreenImageActivity only through a fallback path in FileViewerUtils that was already unreachable in practice (Shared Items and chat both route images to MediaViewerActivity directly). Generalized that fallback to cover images the same way it already did for video, then removed FullScreenImageActivity/FullScreenImageScreen and their manifest entry. FullScreenImageTheme stays, since MediaViewerActivity reuses it. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
|
APK file: https://github.com/nextcloud/talk-android/actions/runs/32973201420/artifacts/9609457966 |
…viewer MessageTypeContent.Media is the content type for any file attachment, not just images/videos (see getMediaContent() - it works for any mimetype). ChatViewModel.toMediaViewerItem() only checked for that content type, so a non-media file sharing an upload batch with a photo/video (or even a lone file message) would end up in the media viewer's seed and fail to render once swiped to. The other two seed-building paths (SharedItemsAdapter, MediaViewerViewModel.loadOlderGroups) already filtered by mimetype; this one was missed. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
Collapse the fileId null-check into the final expression via ?.let instead of a second early return, bringing the function back to 2 return statements. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
|
APK file: https://github.com/nextcloud/talk-android/actions/runs/32978478859/artifacts/9611148756 |
…rgeException Both seed-building call sites passed every locally known image/video through the launching Intent's Parcelable extras with no upper bound: SharedItemsAdapter.openMediaViewer() seeds from the Shared Items gallery's adapter list, which accumulates every scrolled-in page indefinitely, and ChatViewModel.mediaViewerSeed() from the chat's loaded message window. A room with hundreds/thousands of shared images could exceed Android's ~1MB Binder transaction limit and crash with TransactionTooLargeException. Added MediaViewerItem.capSeedAroundMessage(), which windows the seed to 200 items centered on the tapped message. Trimming is safe in the "older" direction since MediaViewerViewModel.loadOlderGroups() already pages further back via the network once the seed's oldest item is reached; "newer" beyond the window was already not network-fetchable (the shared-items endpoint only pages backwards), so this only lowers an existing ceiling rather than removing a capability. Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
91051e2 to
e8da0ad
Compare
|
APK file: https://github.com/nextcloud/talk-android/actions/runs/32986763014/artifacts/9613353964 |
🖼️ Screenshots
New swipe left/right MediaViewer:
Screen_recording_20260826_180422.mp4
🏁 Checklist
/backport to stable-xx.x🤖 AI (if applicable)