Skip to content

Feature/6524/grouped files - #6586

Open
mahibi wants to merge 24 commits into
masterfrom
feature/6524/groupedFiles
Open

Feature/6524/grouped files#6586
mahibi wants to merge 24 commits into
masterfrom
feature/6524/groupedFiles

Conversation

@mahibi

@mahibi mahibi commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

🖼️ Screenshots

🏚️ Before 🏡 After
grafik grafik

New swipe left/right MediaViewer:

Screen_recording_20260826_180422.mp4

🏁 Checklist

  • ⛑️ Tests (unit and/or integration) are included or not needed
  • 🔖 Capability is checked or not needed
  • 🔙 Backport requests are created or not needed: /backport to stable-xx.x
  • 📅 Milestone is set
  • 🌸 PR title is meaningful (if it should be in the changelog: is it meaningful to users?)

🤖 AI (if applicable)

  • The content of this PR was partly or fully generated using AI

mahibi added 20 commits August 26, 2026 15:14
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>
@mahibi mahibi self-assigned this Aug 26, 2026
@mahibi mahibi added this to the 25.1.0 milestone Aug 26, 2026
Signed-off-by: Marcel Hibbe <dev@mhibbe.de>
@github-actions

Copy link
Copy Markdown
Contributor

APK file: https://github.com/nextcloud/talk-android/actions/runs/32973201420/artifacts/9609457966
To test this change/fix you can simply download above APK file and install and test it in parallel to your existing Nextcloud app.
qrcode (please click on link to get QR code displayed)

mahibi added 2 commits August 26, 2026 15:59
…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>
@github-actions

Copy link
Copy Markdown
Contributor

APK file: https://github.com/nextcloud/talk-android/actions/runs/32978478859/artifacts/9611148756
To test this change/fix you can simply download above APK file and install and test it in parallel to your existing Nextcloud app.
qrcode (please click on link to get QR code displayed)

…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>
@mahibi
mahibi force-pushed the feature/6524/groupedFiles branch from 91051e2 to e8da0ad Compare August 26, 2026 15:46
@mahibi
mahibi marked this pull request as ready for review August 26, 2026 15:49
@mahibi
mahibi requested a review from rapterjet2004 August 26, 2026 15:49
@mahibi mahibi added the 3. to review Waiting for reviews label Aug 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

APK file: https://github.com/nextcloud/talk-android/actions/runs/32986763014/artifacts/9613353964
To test this change/fix you can simply download above APK file and install and test it in parallel to your existing Nextcloud app.
qrcode (please click on link to get QR code displayed)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3. to review Waiting for reviews AI assisted

Projects

None yet

Development

Successfully merging this pull request may close these issues.

grouped uploads photo/video view: swipe left/right to show next/previous photo/video

1 participant