From 107c3638e05e6434b257a1a436866cc670dd0b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 9 Mar 2026 09:32:15 +0000 Subject: [PATCH 01/22] Update `AttachmentTypePicker` to only select first mode if none are selected. --- .../compose/ui/messages/attachments/AttachmentTypePicker.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentTypePicker.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentTypePicker.kt index 0a014db5fde..7d75dd6f2df 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentTypePicker.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentTypePicker.kt @@ -88,7 +88,9 @@ internal fun AttachmentTypePicker( trailingContent() } LaunchedEffect(modes) { - modes.firstOrNull()?.let(onModeSelected) + if (selectedMode == null) { + modes.firstOrNull()?.let(onModeSelected) + } } } From b17c8c9fb06823fe4966fdaf3b9960f7af3a4dba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 9 Mar 2026 09:45:40 +0000 Subject: [PATCH 02/22] First refactor `AttachmentsPickerViewModel` to persist selection state across process death and synchronize external attachments. - Implement `SavedStateHandle` persistence for selected URIs, grid attachments, and external attachments (camera/system picker). - Add `addExternalAttachments` and `removeExternalAttachment` to manage one-shot attachment sources. - Update `getSelectedAttachments` to serve as the single source of truth for the message composer. - Ensure `clearSelection` resets all persisted picker state. --- .../api/stream-chat-android-compose.api | 2 + .../compose/ui/messages/MessagesScreen.kt | 3 +- .../attachments/AttachmentPickerActions.kt | 8 +- .../messages/AttachmentsPickerViewModel.kt | 162 ++++++++++++++++-- 4 files changed, 154 insertions(+), 21 deletions(-) diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 1691c350597..c4adc015eb8 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -4339,6 +4339,7 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/Attachme public static final field $stable I public fun (Lio/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper;Lkotlinx/coroutines/flow/StateFlow;Landroidx/lifecycle/SavedStateHandle;)V public synthetic fun (Lio/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper;Lkotlinx/coroutines/flow/StateFlow;Landroidx/lifecycle/SavedStateHandle;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun addExternalAttachments (Ljava/util/List;)V public final fun clearSelection ()V public final fun deselectAttachment (Lio/getstream/chat/android/models/Attachment;)V public final fun getAttachments ()Ljava/util/List; @@ -4349,6 +4350,7 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/Attachme public final fun getSubmittedAttachments ()Lkotlinx/coroutines/flow/Flow; public final fun isPickerVisible ()Z public final fun loadAttachments ()V + public final fun removeExternalAttachment (Lio/getstream/chat/android/models/Attachment;)V public final fun resolveAndSubmitUris (Ljava/util/List;)V public final fun setPickerMode (Lio/getstream/chat/android/compose/state/messages/attachments/AttachmentPickerMode;)V public final fun setPickerVisible (Z)V diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt index eca6e8254d8..1d5014c7bc1 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt @@ -350,8 +350,9 @@ internal fun DefaultBottomBarContent( isAttachmentPickerVisible = attachmentsPickerViewModel.isPickerVisible, onAttachmentsClick = attachmentsPickerViewModel::togglePickerVisibility, onAttachmentRemoved = { attachment -> - composerViewModel.removeSelectedAttachment(attachment) attachmentsPickerViewModel.deselectAttachment(attachment) + attachmentsPickerViewModel.removeExternalAttachment(attachment) + composerViewModel.updateSelectedAttachments(attachmentsPickerViewModel.getSelectedAttachments()) }, onCancelAction = { listViewModel.dismissAllMessageActions() diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPickerActions.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPickerActions.kt index a80e7799b23..88d78d857bb 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPickerActions.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPickerActions.kt @@ -81,7 +81,7 @@ public data class AttachmentPickerActions( val multiSelect = attachmentsPickerViewModel.pickerMode?.allowMultipleSelection == true attachmentsPickerViewModel.toggleSelection(item, multiSelect) }, - onAttachmentsSelected = { attachmentsPickerViewModel.setPickerVisible(visible = false) }, + onAttachmentsSelected = {}, onCreatePollClick = {}, onCreatePoll = {}, onCreatePollDismissed = {}, @@ -108,17 +108,19 @@ public data class AttachmentPickerActions( composerViewModel.updateSelectedAttachments(attachmentsPickerViewModel.getSelectedAttachments()) }, onAttachmentsSelected = { attachments -> - attachmentsPickerViewModel.setPickerVisible(visible = false) - composerViewModel.addSelectedAttachments(attachments) + attachmentsPickerViewModel.addExternalAttachments(attachments) + composerViewModel.updateSelectedAttachments(attachmentsPickerViewModel.getSelectedAttachments()) }, onCreatePollClick = {}, onCreatePoll = { pollConfig -> attachmentsPickerViewModel.setPickerVisible(visible = false) + attachmentsPickerViewModel.clearSelection() composerViewModel.createPoll(pollConfig) }, onCreatePollDismissed = {}, onCommandSelected = { command -> attachmentsPickerViewModel.setPickerVisible(visible = false) + attachmentsPickerViewModel.clearSelection() composerViewModel.selectCommand(command) }, onDismiss = { attachmentsPickerViewModel.setPickerVisible(visible = false) }, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt index 87348af1f9f..63a89789b1b 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt @@ -17,6 +17,8 @@ package io.getstream.chat.android.compose.viewmodel.messages import android.net.Uri +import android.os.Build +import android.os.Bundle import androidx.compose.runtime.getValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel @@ -46,6 +48,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -53,17 +56,27 @@ import kotlinx.coroutines.withContext * ViewModel for the attachment picker. Manages available media and file attachments, * user selection state, and conversion to uploadable [Attachment] objects. * + * This ViewModel is the single source of truth for all attachments in the current composer + * session. The composer's attachment list is always derived from [getSelectedAttachments], + * which merges externally-added attachments (camera, system picker) with grid selections. + * * Media and file items are stored in separate lists so they survive tab switches. * Selection is centralised in a single [Set] of [Uri]s, so items that appear in both * tabs (e.g. an image visible in the gallery and the files list) share selection state * automatically — no explicit cross-tab synchronisation is needed. * - * Picker visibility and active tab survive Activity destruction (e.g. "Don't keep - * activities") so that pending system picker results are delivered on recreation. + * The following state survives Activity destruction (e.g. "Don't keep activities"): + * - Picker visibility and active tab — so pending system picker results are delivered on recreation. + * - URI-based grid selection — so gallery/file picks are not lost when the camera is launched. + * - Externally-added attachments (camera, system picker) — so captures are not lost across + * multiple camera sessions. + * + * All persisted state is cleared by [clearSelection], which must be called when the associated + * attachments are consumed (e.g. message sent, poll created, command selected). * * @param storageHelper Provides device storage queries and attachment conversion. * @param channelState Provides the current [ChannelState] for channel-specific configuration. - * @param savedStateHandle Persists picker visibility and mode across Activity recreation. + * @param savedStateHandle Persists picker state across Activity recreation. */ public class AttachmentsPickerViewModel( private val storageHelper: AttachmentStorageHelper, @@ -92,10 +105,26 @@ public class AttachmentsPickerViewModel( ) private val _mediaItems = MutableStateFlow>(emptyList()) private val _fileItems = MutableStateFlow>(emptyList()) - private val _selectedUris = MutableStateFlow>(linkedSetOf()) + private val _selectedUris = MutableStateFlow>( + savedStateHandle.get>(KeySelectedUris)?.let(::LinkedHashSet) ?: linkedSetOf(), + ) + private val _selectedGridAttachments = MutableStateFlow>( + savedStateHandle.get(KeySelectedGridAttachments) + ?.getBundleList(KeySelectedGridAttachmentItems) + ?.mapNotNull(Bundle::toUriAttachmentPair) + ?.toMap() + ?: emptyMap(), + ) private val _isPickerVisible = MutableStateFlow( savedStateHandle[KeyPickerVisible] ?: false, ) + private val _externalAttachments = MutableStateFlow>( + savedStateHandle.get(KeyExternalAttachments) + ?.getBundleList(KeyExternalAttachmentItems) + ?.mapNotNull(Bundle::toAttachment) + ?: emptyList(), + ) + private val _submittedAttachments = kotlinx.coroutines.channels.Channel(capacity = UNLIMITED) /** * The active picker tab. @@ -125,8 +154,6 @@ public class AttachmentsPickerViewModel( items.map { meta -> AttachmentPickerItemState(meta, isSelected = meta.uri in selected) } }.asState(viewModelScope, emptyList()) - private val _submittedAttachments = kotlinx.coroutines.channels.Channel(capacity = UNLIMITED) - /** * One-shot events for attachments resolved from system picker URIs. * Collected by the parent composable to submit attachments and show error toasts. @@ -184,11 +211,21 @@ public class AttachmentsPickerViewModel( if (!allowMultipleSelection && currentlySelected) return - _selectedUris.value = if (currentlySelected) { - _selectedUris.value - uri + if (currentlySelected) { + _selectedUris.value = _selectedUris.value - uri + _selectedGridAttachments.update { it - uri } } else { - if (allowMultipleSelection) _selectedUris.value + uri else linkedSetOf(uri) + val attachment = storageHelper.toAttachments(listOf(item.attachmentMetaData)).firstOrNull() + if (allowMultipleSelection) { + _selectedUris.value = _selectedUris.value + uri + if (attachment != null) _selectedGridAttachments.update { it + (uri to attachment) } + } else { + _selectedUris.value = linkedSetOf(uri) + _selectedGridAttachments.value = if (attachment != null) mapOf(uri to attachment) else emptyMap() + } } + savedStateHandle[KeySelectedUris] = ArrayList(_selectedUris.value) + persistSelectedGridAttachments(_selectedGridAttachments.value) } /** @@ -201,22 +238,50 @@ public class AttachmentsPickerViewModel( val sourceUri = (attachment.extraData[EXTRA_SOURCE_URI] as? String) ?.let(Uri::parse) ?: return _selectedUris.value -= sourceUri + _selectedGridAttachments.update { it - sourceUri } + savedStateHandle[KeySelectedUris] = ArrayList(_selectedUris.value) + persistSelectedGridAttachments(_selectedGridAttachments.value) } /** * Returns lightweight preview [Attachment] objects for all selected items across both tabs, - * ordered by the sequence in which the user selected them. + * ordered by the sequence in which the user selected them, preceded by any externally-added + * attachments (e.g. from the camera or system file picker). * * Items that appear in both tabs are deduplicated by URI. * No file copying is performed; file resolution is deferred to send time * via [AttachmentStorageHelper.resolveAttachmentFiles]. */ public fun getSelectedAttachments(): List { - val allItemsByUri = (_mediaItems.value + _fileItems.value) - .mapNotNull { meta -> meta.uri?.let { it to meta } } - .toMap() - val orderedMeta = _selectedUris.value.mapNotNull(allItemsByUri::get) - return storageHelper.toAttachments(orderedMeta) + val orderedGridAttachments = _selectedUris.value.mapNotNull { _selectedGridAttachments.value[it] } + return _externalAttachments.value + orderedGridAttachments + } + + /** + * Adds attachments from one-shot sources (e.g. camera capture, system file picker) to the + * picker's selection state so that they are included in [getSelectedAttachments] and survive + * picker close/reopen within the same composer session. + * + * These attachments are cleared by [clearSelection] (e.g. after a message is sent). + * + * @param attachments The attachments to add. + */ + public fun addExternalAttachments(attachments: List) { + _externalAttachments.update { it + attachments } + persistExternalAttachments(_externalAttachments.value) + } + + /** + * Removes an externally-added attachment from the picker's selection state. + * + * Call this when the user removes an attachment that was added via [addExternalAttachments] + * (e.g. from the camera) from the message composer, so the picker state stays consistent. + * + * @param attachment The attachment to remove. + */ + public fun removeExternalAttachment(attachment: Attachment) { + _externalAttachments.update { it - attachment } + persistExternalAttachments(_externalAttachments.value) } /** @@ -277,11 +342,30 @@ public class AttachmentsPickerViewModel( } /** - * Removes all selected URIs. Call this when the associated attachments are consumed - * (e.g. message sent, poll created) so the picker starts fresh on next open. + * Removes all selected URIs and externally-added attachments. Call this when the associated + * attachments are consumed (e.g. message sent, poll created) so the picker starts fresh on + * next open. */ public fun clearSelection() { _selectedUris.value = linkedSetOf() + _selectedGridAttachments.value = emptyMap() + _externalAttachments.value = emptyList() + savedStateHandle.remove(KeyExternalAttachments) + savedStateHandle.remove(KeySelectedGridAttachments) + savedStateHandle.remove>(KeySelectedUris) + } + + private fun persistExternalAttachments(attachments: List) { + savedStateHandle[KeyExternalAttachments] = Bundle().apply { + putParcelableArrayList(KeyExternalAttachmentItems, ArrayList(attachments.map(Attachment::toBundle))) + } + } + + private fun persistSelectedGridAttachments(attachments: Map) { + savedStateHandle[KeySelectedGridAttachments] = Bundle().apply { + val bundleList = ArrayList(attachments.values.map(Attachment::toBundle)) + putParcelableArrayList(KeySelectedGridAttachmentItems, bundleList) + } } private fun clearCachedData() { @@ -294,6 +378,50 @@ public class AttachmentsPickerViewModel( private const val KeyPickerVisible = "stream_picker_visible" private const val KeyPickerMode = "stream_picker_mode" +private const val KeySelectedUris = "stream_selected_uris" +private const val KeyExternalAttachments = "stream_external_attachments" +private const val KeyExternalAttachmentItems = "stream_external_attachment_items" +private const val KeySelectedGridAttachments = "stream_selected_grid_attachments" +private const val KeySelectedGridAttachmentItems = "stream_selected_grid_attachment_items" +private const val KeyBundleUri = "uri" +private const val KeyBundleType = "type" +private const val KeyBundleName = "name" +private const val KeyBundleFileSize = "fileSize" +private const val KeyBundleMimeType = "mimeType" +private const val AttachmentBundleSize = 5 + +private fun Attachment.toBundle(): Bundle = Bundle(AttachmentBundleSize).apply { + (extraData[EXTRA_SOURCE_URI] as? String)?.let { putString(KeyBundleUri, it) } + type?.let { putString(KeyBundleType, it) } + putString(KeyBundleName, name) + putInt(KeyBundleFileSize, fileSize) + mimeType?.let { putString(KeyBundleMimeType, it) } +} + +private fun Bundle.toAttachment(): Attachment? { + val uri = getString(KeyBundleUri) ?: return null + return Attachment( + type = getString(KeyBundleType), + name = getString(KeyBundleName) ?: "", + fileSize = getInt(KeyBundleFileSize), + mimeType = getString(KeyBundleMimeType), + extraData = mapOf(EXTRA_SOURCE_URI to uri), + ) +} + +private fun Bundle.toUriAttachmentPair(): Pair? { + val attachment = toAttachment() ?: return null + val uriString = attachment.extraData[EXTRA_SOURCE_URI] as? String ?: return null + return Uri.parse(uriString) to attachment +} + +@Suppress("DEPRECATION") +private fun Bundle.getBundleList(key: String): List = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableArrayList(key, Bundle::class.java) ?: emptyList() + } else { + getParcelableArrayList(key) ?: emptyList() + } /** * Event emitted when system picker URIs have been resolved into [Attachment]s. From f1cc4d2383d7e8b078e047659ef0e4165f8e1c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 9 Mar 2026 10:52:15 +0000 Subject: [PATCH 03/22] Refactor attachment selection state management between `AttachmentsPickerViewModel` and `MessageComposerViewModel`. - Shift responsibility for the master list of staged attachments from `AttachmentsPickerViewModel` to `MessageComposerViewModel`, making the latter the single source of truth for message attachments. - Implement state persistence for selected attachments in `MessageComposerViewModel` using `SavedStateHandle` to survive Activity recreation. - Update `AttachmentsPickerViewModel` to only manage grid selection state (URI-based checkmarks) and storage browsing. - Refactor `AttachmentPickerActions` to coordinate selection between the picker (grid UI) and the composer (staged list). - Rename attachment manipulation methods in `MessageComposerViewModel` to `addAttachments`, `removeAttachment`, and `clearAttachments`. - Update `MessagesViewModelFactory` to provide `SavedStateHandle` to the composer and picker ViewModels. --- .../api/stream-chat-android-compose.api | 16 +- .../compose/ui/messages/MessagesScreen.kt | 11 +- .../attachments/AttachmentPickerActions.kt | 45 +++- .../ui/messages/composer/MessageComposer.kt | 2 +- .../messages/AttachmentsPickerViewModel.kt | 241 ++++-------------- .../messages/MessageComposerViewModel.kt | 157 ++++++++++-- .../messages/MessagesViewModelFactory.kt | 24 +- .../AttachmentsPickerViewModelTest.kt | 166 ++++-------- .../messages/MessageComposerViewModelTest.kt | 17 +- 9 files changed, 320 insertions(+), 359 deletions(-) diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index c4adc015eb8..d6980bcff8f 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -4339,24 +4339,17 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/Attachme public static final field $stable I public fun (Lio/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper;Lkotlinx/coroutines/flow/StateFlow;Landroidx/lifecycle/SavedStateHandle;)V public synthetic fun (Lio/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper;Lkotlinx/coroutines/flow/StateFlow;Landroidx/lifecycle/SavedStateHandle;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun addExternalAttachments (Ljava/util/List;)V - public final fun clearSelection ()V - public final fun deselectAttachment (Lio/getstream/chat/android/models/Attachment;)V public final fun getAttachments ()Ljava/util/List; public final fun getAttachmentsFromMetadata (Ljava/util/List;)Ljava/util/List; public final fun getChannel ()Lio/getstream/chat/android/models/Channel; public final fun getPickerMode ()Lio/getstream/chat/android/compose/state/messages/attachments/AttachmentPickerMode; - public final fun getSelectedAttachments ()Ljava/util/List; public final fun getSubmittedAttachments ()Lkotlinx/coroutines/flow/Flow; public final fun isPickerVisible ()Z public final fun loadAttachments ()V - public final fun removeExternalAttachment (Lio/getstream/chat/android/models/Attachment;)V public final fun resolveAndSubmitUris (Ljava/util/List;)V public final fun setPickerMode (Lio/getstream/chat/android/compose/state/messages/attachments/AttachmentPickerMode;)V public final fun setPickerVisible (Z)V public final fun togglePickerVisibility ()V - public final fun toggleSelection (Lio/getstream/chat/android/compose/state/messages/attachments/AttachmentPickerItemState;Z)V - public static synthetic fun toggleSelection$default (Lio/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel;Lio/getstream/chat/android/compose/state/messages/attachments/AttachmentPickerItemState;ZILjava/lang/Object;)V } public final class io/getstream/chat/android/compose/viewmodel/messages/AudioPlayerViewModel : androidx/lifecycle/ViewModel { @@ -4373,13 +4366,15 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/AudioPla public final class io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel : androidx/lifecycle/ViewModel { public static final field $stable I - public fun (Lio/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController;Lio/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper;)V - public final fun addSelectedAttachments (Ljava/util/List;)V + public fun (Lio/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController;Lio/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper;Landroidx/lifecycle/SavedStateHandle;)V + public synthetic fun (Lio/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController;Lio/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper;Landroidx/lifecycle/SavedStateHandle;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun addAttachments (Ljava/util/List;)V public final fun buildNewMessage (Ljava/lang/String;Ljava/util/List;)Lio/getstream/chat/android/models/Message; public static synthetic fun buildNewMessage$default (Lio/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lio/getstream/chat/android/models/Message; public final fun cancelLinkPreview ()V public final fun cancelRecording ()V public final fun clearActiveCommand ()V + public final fun clearAttachments ()V public final fun clearData ()V public final fun completeRecording ()V public final fun createPoll (Lio/getstream/chat/android/models/PollConfig;)V @@ -4400,7 +4395,7 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/MessageC public final fun lockRecording ()V public final fun pauseRecording ()V public final fun performMessageAction (Lio/getstream/chat/android/ui/common/state/messages/MessageAction;)V - public final fun removeSelectedAttachment (Lio/getstream/chat/android/models/Attachment;)V + public final fun removeAttachment (Lio/getstream/chat/android/models/Attachment;)V public final fun seekRecordingTo (F)V public final fun selectCommand (Lio/getstream/chat/android/models/Command;)V public final fun selectMention (Lio/getstream/chat/android/models/User;)V @@ -4416,7 +4411,6 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/MessageC public final fun stopRecording ()V public final fun toggleCommandsVisibility ()V public final fun toggleRecordingPlayback ()V - public final fun updateSelectedAttachments (Ljava/util/List;)V } public final class io/getstream/chat/android/compose/viewmodel/messages/MessageListViewModel : androidx/lifecycle/ViewModel { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt index 1d5014c7bc1..3da68011215 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt @@ -72,6 +72,7 @@ import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.ReactionSorting import io.getstream.chat.android.models.ReactionSortingByFirstReactionAt import io.getstream.chat.android.models.User +import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI import io.getstream.chat.android.ui.common.state.messages.Delete import io.getstream.chat.android.ui.common.state.messages.Edit import io.getstream.chat.android.ui.common.state.messages.Flag @@ -350,9 +351,10 @@ internal fun DefaultBottomBarContent( isAttachmentPickerVisible = attachmentsPickerViewModel.isPickerVisible, onAttachmentsClick = attachmentsPickerViewModel::togglePickerVisibility, onAttachmentRemoved = { attachment -> - attachmentsPickerViewModel.deselectAttachment(attachment) - attachmentsPickerViewModel.removeExternalAttachment(attachment) - composerViewModel.updateSelectedAttachments(attachmentsPickerViewModel.getSelectedAttachments()) + attachment.extraData[EXTRA_SOURCE_URI] + ?.let { it as? String } + ?.let(attachmentsPickerViewModel::removeFromGridSelection) + composerViewModel.removeAttachment(attachment) }, onCancelAction = { listViewModel.dismissAllMessageActions() @@ -361,13 +363,14 @@ internal fun DefaultBottomBarContent( onLinkPreviewClick = onComposerLinkPreviewClick, onSendMessage = { message -> attachmentsPickerViewModel.setPickerVisible(visible = false) - attachmentsPickerViewModel.clearSelection() + attachmentsPickerViewModel.clearGridSelection() composerViewModel.sendMessage( message.copy( skipPushNotification = skipPushNotification, skipEnrichUrl = skipEnrichUrl, ), ) + composerViewModel.clearAttachments() }, ) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPickerActions.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPickerActions.kt index 88d78d857bb..3d9f3a68e39 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPickerActions.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPickerActions.kt @@ -69,8 +69,8 @@ public data class AttachmentPickerActions( /** * Lightweight defaults suitable for standalone [AttachmentPicker] usage without a composer. * - * Handles picker-level concerns only: toggling selection state and dismissing the picker. - * Poll, command, and attachment-submission actions are no-ops. + * Handles picker-level concerns only: toggling the grid selection index and dismissing the + * picker. Attachment submission, poll, and command actions are no-ops. * * @param attachmentsPickerViewModel The [AttachmentsPickerViewModel] that drives picker state. */ @@ -78,8 +78,14 @@ public data class AttachmentPickerActions( attachmentsPickerViewModel: AttachmentsPickerViewModel, ): AttachmentPickerActions = AttachmentPickerActions( onAttachmentItemSelected = { item -> + val uriString = item.attachmentMetaData.uri?.toString() ?: return@AttachmentPickerActions val multiSelect = attachmentsPickerViewModel.pickerMode?.allowMultipleSelection == true - attachmentsPickerViewModel.toggleSelection(item, multiSelect) + if (item.isSelected) { + attachmentsPickerViewModel.removeFromGridSelection(uriString) + } else { + if (!multiSelect) attachmentsPickerViewModel.clearGridSelection() + attachmentsPickerViewModel.addToGridSelection(uriString) + } }, onAttachmentsSelected = {}, onCreatePollClick = {}, @@ -92,35 +98,50 @@ public data class AttachmentPickerActions( /** * Default implementation wiring both the picker and composer view models. * - * Handles attachment selection, poll creation, command insertion, and picker dismissal. + * [AttachmentsPickerViewModel] owns the grid selection index (URI checkmarks). + * [MessageComposerViewModel] owns the full attachment list for the message. + * This function is the sole coordination point between the two. * * @param attachmentsPickerViewModel The [AttachmentsPickerViewModel] that drives picker state. - * @param composerViewModel The [MessageComposerViewModel] that receives selected attachments - * and handles poll creation and command insertion. + * @param composerViewModel The [MessageComposerViewModel] that owns the selected attachment list. */ public fun defaultActions( attachmentsPickerViewModel: AttachmentsPickerViewModel, composerViewModel: MessageComposerViewModel, ): AttachmentPickerActions = AttachmentPickerActions( onAttachmentItemSelected = { item -> + val uriString = item.attachmentMetaData.uri?.toString() ?: return@AttachmentPickerActions val multiSelect = attachmentsPickerViewModel.pickerMode?.allowMultipleSelection == true - attachmentsPickerViewModel.toggleSelection(item, multiSelect) - composerViewModel.updateSelectedAttachments(attachmentsPickerViewModel.getSelectedAttachments()) + if (item.isSelected) { + attachmentsPickerViewModel.removeFromGridSelection(uriString) + composerViewModel.removeAttachmentsByUris(setOf(uriString)) + } else { + val attachment = attachmentsPickerViewModel + .getAttachmentsFromMetadata(listOf(item.attachmentMetaData)) + .firstOrNull() ?: return@AttachmentPickerActions + if (!multiSelect) { + composerViewModel.removeAttachmentsByUris(attachmentsPickerViewModel.gridSelectedUris.value) + attachmentsPickerViewModel.clearGridSelection() + } + attachmentsPickerViewModel.addToGridSelection(uriString) + composerViewModel.addAttachments(listOf(attachment)) + } }, onAttachmentsSelected = { attachments -> - attachmentsPickerViewModel.addExternalAttachments(attachments) - composerViewModel.updateSelectedAttachments(attachmentsPickerViewModel.getSelectedAttachments()) + composerViewModel.addAttachments(attachments) }, onCreatePollClick = {}, onCreatePoll = { pollConfig -> attachmentsPickerViewModel.setPickerVisible(visible = false) - attachmentsPickerViewModel.clearSelection() + attachmentsPickerViewModel.clearGridSelection() + composerViewModel.clearAttachments() composerViewModel.createPoll(pollConfig) }, onCreatePollDismissed = {}, onCommandSelected = { command -> attachmentsPickerViewModel.setPickerVisible(visible = false) - attachmentsPickerViewModel.clearSelection() + attachmentsPickerViewModel.clearGridSelection() + composerViewModel.clearAttachments() composerViewModel.selectCommand(command) }, onDismiss = { attachmentsPickerViewModel.setPickerVisible(visible = false) }, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt index 53243969e2d..b5b150f6709 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt @@ -97,7 +97,7 @@ public fun MessageComposer( onSendMessage: (Message) -> Unit = { viewModel.sendMessage(it) }, onAttachmentsClick: () -> Unit = {}, onValueChange: (String) -> Unit = { viewModel.setMessageInput(it) }, - onAttachmentRemoved: (Attachment) -> Unit = { viewModel.removeSelectedAttachment(it) }, + onAttachmentRemoved: (Attachment) -> Unit = { viewModel.removeAttachment(it) }, onCancelAction: () -> Unit = { viewModel.dismissMessageActions() }, onLinkPreviewClick: ((LinkPreview) -> Unit)? = null, onCancelLinkPreviewClick: (() -> Unit)? = { viewModel.cancelLinkPreview() }, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt index 63a89789b1b..b32802b1352 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt @@ -17,8 +17,6 @@ package io.getstream.chat.android.compose.viewmodel.messages import android.net.Uri -import android.os.Build -import android.os.Bundle import androidx.compose.runtime.getValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel @@ -36,7 +34,6 @@ import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.Channel import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper -import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -48,31 +45,27 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** - * ViewModel for the attachment picker. Manages available media and file attachments, - * user selection state, and conversion to uploadable [Attachment] objects. + * ViewModel for the attachment picker. Manages storage browsing and picker UI state. * - * This ViewModel is the single source of truth for all attachments in the current composer - * session. The composer's attachment list is always derived from [getSelectedAttachments], - * which merges externally-added attachments (camera, system picker) with grid selections. + * **Responsibilities:** + * - Active picker tab ([pickerMode]) and visibility ([isPickerVisible]) + * - Loading media and file metadata from device storage ([loadAttachments]) + * - Resolving system-picker URIs into [Attachment]s ([resolveAndSubmitUris]) + * - Tracking which grid items the user has selected via [gridSelectedUris], used to drive + * `isSelected` checkmarks in the attachment grid * - * Media and file items are stored in separate lists so they survive tab switches. - * Selection is centralised in a single [Set] of [Uri]s, so items that appear in both - * tabs (e.g. an image visible in the gallery and the files list) share selection state - * automatically — no explicit cross-tab synchronisation is needed. + * **Not responsible for:** the full content or ordering of the message's attachment list. + * That is owned by [MessageComposerViewModel], which is the single source of truth for all + * attachments in the current composer session. * - * The following state survives Activity destruction (e.g. "Don't keep activities"): - * - Picker visibility and active tab — so pending system picker results are delivered on recreation. - * - URI-based grid selection — so gallery/file picks are not lost when the camera is launched. - * - Externally-added attachments (camera, system picker) — so captures are not lost across - * multiple camera sessions. - * - * All persisted state is cleared by [clearSelection], which must be called when the associated - * attachments are consumed (e.g. message sent, poll created, command selected). + * The [gridSelectedUris] index and picker tab survive Activity destruction + * (e.g. "Don't keep activities") via [savedStateHandle]. + * [gridSelectedUris] is cleared by [clearGridSelection] when the selection is consumed + * (e.g. message sent, poll created, command selected). * * @param storageHelper Provides device storage queries and attachment conversion. * @param channelState Provides the current [ChannelState] for channel-specific configuration. @@ -105,25 +98,20 @@ public class AttachmentsPickerViewModel( ) private val _mediaItems = MutableStateFlow>(emptyList()) private val _fileItems = MutableStateFlow>(emptyList()) - private val _selectedUris = MutableStateFlow>( - savedStateHandle.get>(KeySelectedUris)?.let(::LinkedHashSet) ?: linkedSetOf(), - ) - private val _selectedGridAttachments = MutableStateFlow>( - savedStateHandle.get(KeySelectedGridAttachments) - ?.getBundleList(KeySelectedGridAttachmentItems) - ?.mapNotNull(Bundle::toUriAttachmentPair) - ?.toMap() - ?: emptyMap(), + + /** + * URI strings of grid items (gallery or files tab) currently checked by the user. + * Used only for driving `isSelected` state in the attachment grid — the full attachment + * list for the composer is owned by [MessageComposerViewModel]. + * + * Persisted so checkmarks survive Activity recreation (e.g. when the camera is launched). + */ + private val _gridSelectedUris = MutableStateFlow>( + savedStateHandle.get>(KeyGridSelectedUris)?.toSet() ?: emptySet(), ) private val _isPickerVisible = MutableStateFlow( savedStateHandle[KeyPickerVisible] ?: false, ) - private val _externalAttachments = MutableStateFlow>( - savedStateHandle.get(KeyExternalAttachments) - ?.getBundleList(KeyExternalAttachmentItems) - ?.mapNotNull(Bundle::toAttachment) - ?: emptyList(), - ) private val _submittedAttachments = kotlinx.coroutines.channels.Channel(capacity = UNLIMITED) /** @@ -137,21 +125,26 @@ public class AttachmentsPickerViewModel( public val isPickerVisible: Boolean by _isPickerVisible.asState(viewModelScope) /** - * The attachment list for the active [pickerMode], with each item's selection state - * reflecting whether it is currently selected. + * URI strings of grid items currently selected by the user, used to show checkmarks. + */ + internal val gridSelectedUris: StateFlow> = _gridSelectedUris + + /** + * The attachment list for the active [pickerMode], with each item's [AttachmentPickerItemState.isSelected] + * reflecting whether it appears in [gridSelectedUris]. */ public val attachments: List by combine( _pickerMode, _mediaItems, _fileItems, - _selectedUris, - ) { mode, media, files, selected -> + _gridSelectedUris, + ) { mode, media, files, gridUris -> val items = when (mode) { is GalleryPickerMode, null -> media is FilePickerMode -> files else -> emptyList() } - items.map { meta -> AttachmentPickerItemState(meta, isSelected = meta.uri in selected) } + items.map { meta -> AttachmentPickerItemState(meta, isSelected = meta.uri?.toString() in gridUris) } }.asState(viewModelScope, emptyList()) /** @@ -176,9 +169,9 @@ public class AttachmentsPickerViewModel( /** * Shows or hides the attachment picker. Hiding clears cached data but preserves the - * current selection so items remain checked when the picker is reopened. + * current grid selection so checkmarks remain when the picker is reopened. * - * Call [clearSelection] after this to also reset the selection (e.g. after sending a message). + * Call [clearGridSelection] after this to also reset the selection (e.g. after sending a message). * * @param visible `true` to show the picker, `false` to hide it. */ @@ -196,92 +189,32 @@ public class AttachmentsPickerViewModel( } /** - * Selects or deselects [item]. - * - * @param item The attachment item to select or deselect. - * @param allowMultipleSelection When `false`, selecting a new item replaces the - * current selection. Tapping the already-selected item is a no-op. - */ - public fun toggleSelection( - item: AttachmentPickerItemState, - allowMultipleSelection: Boolean = true, - ) { - val uri = item.attachmentMetaData.uri ?: return - val currentlySelected = uri in _selectedUris.value - - if (!allowMultipleSelection && currentlySelected) return - - if (currentlySelected) { - _selectedUris.value = _selectedUris.value - uri - _selectedGridAttachments.update { it - uri } - } else { - val attachment = storageHelper.toAttachments(listOf(item.attachmentMetaData)).firstOrNull() - if (allowMultipleSelection) { - _selectedUris.value = _selectedUris.value + uri - if (attachment != null) _selectedGridAttachments.update { it + (uri to attachment) } - } else { - _selectedUris.value = linkedSetOf(uri) - _selectedGridAttachments.value = if (attachment != null) mapOf(uri to attachment) else emptyMap() - } - } - savedStateHandle[KeySelectedUris] = ArrayList(_selectedUris.value) - persistSelectedGridAttachments(_selectedGridAttachments.value) - } - - /** - * Deselects the attachment whose content URI matches the [EXTRA_SOURCE_URI] stored - * in [attachment]'s [extraData][Attachment.extraData]. - * - * @param attachment The [Attachment] to deselect. - */ - public fun deselectAttachment(attachment: Attachment) { - val sourceUri = (attachment.extraData[EXTRA_SOURCE_URI] as? String) - ?.let(Uri::parse) ?: return - _selectedUris.value -= sourceUri - _selectedGridAttachments.update { it - sourceUri } - savedStateHandle[KeySelectedUris] = ArrayList(_selectedUris.value) - persistSelectedGridAttachments(_selectedGridAttachments.value) - } - - /** - * Returns lightweight preview [Attachment] objects for all selected items across both tabs, - * ordered by the sequence in which the user selected them, preceded by any externally-added - * attachments (e.g. from the camera or system file picker). + * Marks [uriString] as selected in the grid. Has no effect if already selected. * - * Items that appear in both tabs are deduplicated by URI. - * No file copying is performed; file resolution is deferred to send time - * via [AttachmentStorageHelper.resolveAttachmentFiles]. + * @param uriString The URI string of the grid item to select. */ - public fun getSelectedAttachments(): List { - val orderedGridAttachments = _selectedUris.value.mapNotNull { _selectedGridAttachments.value[it] } - return _externalAttachments.value + orderedGridAttachments + internal fun addToGridSelection(uriString: String) { + _gridSelectedUris.value = _gridSelectedUris.value + uriString + savedStateHandle[KeyGridSelectedUris] = ArrayList(_gridSelectedUris.value) } /** - * Adds attachments from one-shot sources (e.g. camera capture, system file picker) to the - * picker's selection state so that they are included in [getSelectedAttachments] and survive - * picker close/reopen within the same composer session. - * - * These attachments are cleared by [clearSelection] (e.g. after a message is sent). + * Removes [uriString] from the grid selection. Has no effect if not selected. * - * @param attachments The attachments to add. + * @param uriString The URI string of the grid item to deselect. */ - public fun addExternalAttachments(attachments: List) { - _externalAttachments.update { it + attachments } - persistExternalAttachments(_externalAttachments.value) + internal fun removeFromGridSelection(uriString: String) { + _gridSelectedUris.value = _gridSelectedUris.value - uriString + savedStateHandle[KeyGridSelectedUris] = ArrayList(_gridSelectedUris.value) } /** - * Removes an externally-added attachment from the picker's selection state. - * - * Call this when the user removes an attachment that was added via [addExternalAttachments] - * (e.g. from the camera) from the message composer, so the picker state stays consistent. - * - * @param attachment The attachment to remove. + * Clears all grid selections. Call this when the selection is consumed + * (e.g. after a message is sent, a poll is created, or a command is selected). */ - public fun removeExternalAttachment(attachment: Attachment) { - _externalAttachments.update { it - attachment } - persistExternalAttachments(_externalAttachments.value) + internal fun clearGridSelection() { + _gridSelectedUris.value = emptySet() + savedStateHandle.remove>(KeyGridSelectedUris) } /** @@ -341,33 +274,6 @@ public class AttachmentsPickerViewModel( } } - /** - * Removes all selected URIs and externally-added attachments. Call this when the associated - * attachments are consumed (e.g. message sent, poll created) so the picker starts fresh on - * next open. - */ - public fun clearSelection() { - _selectedUris.value = linkedSetOf() - _selectedGridAttachments.value = emptyMap() - _externalAttachments.value = emptyList() - savedStateHandle.remove(KeyExternalAttachments) - savedStateHandle.remove(KeySelectedGridAttachments) - savedStateHandle.remove>(KeySelectedUris) - } - - private fun persistExternalAttachments(attachments: List) { - savedStateHandle[KeyExternalAttachments] = Bundle().apply { - putParcelableArrayList(KeyExternalAttachmentItems, ArrayList(attachments.map(Attachment::toBundle))) - } - } - - private fun persistSelectedGridAttachments(attachments: Map) { - savedStateHandle[KeySelectedGridAttachments] = Bundle().apply { - val bundleList = ArrayList(attachments.values.map(Attachment::toBundle)) - putParcelableArrayList(KeySelectedGridAttachmentItems, bundleList) - } - } - private fun clearCachedData() { _pickerMode.value = null savedStateHandle[KeyPickerMode] = null as String? @@ -378,50 +284,7 @@ public class AttachmentsPickerViewModel( private const val KeyPickerVisible = "stream_picker_visible" private const val KeyPickerMode = "stream_picker_mode" -private const val KeySelectedUris = "stream_selected_uris" -private const val KeyExternalAttachments = "stream_external_attachments" -private const val KeyExternalAttachmentItems = "stream_external_attachment_items" -private const val KeySelectedGridAttachments = "stream_selected_grid_attachments" -private const val KeySelectedGridAttachmentItems = "stream_selected_grid_attachment_items" -private const val KeyBundleUri = "uri" -private const val KeyBundleType = "type" -private const val KeyBundleName = "name" -private const val KeyBundleFileSize = "fileSize" -private const val KeyBundleMimeType = "mimeType" -private const val AttachmentBundleSize = 5 - -private fun Attachment.toBundle(): Bundle = Bundle(AttachmentBundleSize).apply { - (extraData[EXTRA_SOURCE_URI] as? String)?.let { putString(KeyBundleUri, it) } - type?.let { putString(KeyBundleType, it) } - putString(KeyBundleName, name) - putInt(KeyBundleFileSize, fileSize) - mimeType?.let { putString(KeyBundleMimeType, it) } -} - -private fun Bundle.toAttachment(): Attachment? { - val uri = getString(KeyBundleUri) ?: return null - return Attachment( - type = getString(KeyBundleType), - name = getString(KeyBundleName) ?: "", - fileSize = getInt(KeyBundleFileSize), - mimeType = getString(KeyBundleMimeType), - extraData = mapOf(EXTRA_SOURCE_URI to uri), - ) -} - -private fun Bundle.toUriAttachmentPair(): Pair? { - val attachment = toAttachment() ?: return null - val uriString = attachment.extraData[EXTRA_SOURCE_URI] as? String ?: return null - return Uri.parse(uriString) to attachment -} - -@Suppress("DEPRECATION") -private fun Bundle.getBundleList(key: String): List = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - getParcelableArrayList(key, Bundle::class.java) ?: emptyList() - } else { - getParcelableArrayList(key) ?: emptyList() - } +private const val KeyGridSelectedUris = "stream_picker_grid_selected_uris" /** * Event emitted when system picker URIs have been resolved into [Attachment]s. diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt index 87208828def..2e552deee94 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt @@ -16,6 +16,9 @@ package io.getstream.chat.android.compose.viewmodel.messages +import android.os.Build +import android.os.Bundle +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.ChannelCapabilities @@ -27,6 +30,7 @@ import io.getstream.chat.android.models.User import io.getstream.chat.android.ui.common.feature.messages.composer.MessageComposerController import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper +import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI import io.getstream.chat.android.ui.common.state.messages.Edit import io.getstream.chat.android.ui.common.state.messages.MessageAction import io.getstream.chat.android.ui.common.state.messages.MessageMode @@ -39,6 +43,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update /** * ViewModel responsible for handling the composing and sending of messages. @@ -47,12 +52,23 @@ import kotlinx.coroutines.flow.StateFlow * Additionally, all the core data that can be reused across our SDKs is available through shared data sources, while * implementation-specific data is stored in respective in the [ViewModel]. * + * This ViewModel is the **single source of truth for the selected attachments** in the current composer + * session. Attachments are kept in an insertion-ordered map keyed by URI string, so the order in which + * the user adds items (gallery picks, camera captures, system picker results) is preserved in the + * composer's attachment list. + * + * The selected attachment state persists across Activity recreation (e.g. "Don't keep activities") + * via [savedStateHandle]. It is cleared by [clearAttachments], which must be called when the + * attachments are consumed (e.g. message sent, poll created, command selected). + * * @param messageComposerController The controller used to relay all the actions and fetch all the state. * @param storageHelper Resolves deferred attachment files before sending. + * @param savedStateHandle Persists selected attachment state across Activity recreation. */ public class MessageComposerViewModel( private val messageComposerController: MessageComposerController, private val storageHelper: AttachmentStorageHelper, + private val savedStateHandle: SavedStateHandle = SavedStateHandle(), ) : ViewModel() { /** @@ -115,6 +131,26 @@ public class MessageComposerViewModel( */ public val ownCapabilities: StateFlow> = messageComposerController.ownCapabilities + /** + * Insertion-ordered map of URI string → [Attachment] for all attachments currently staged for + * the next message. Covers grid picks (gallery / files tab), camera captures, and system picker + * results, interleaved in the order the user added them. + * + * Persisted via [savedStateHandle] so selections survive Activity recreation. + */ + private val _selectedAttachments = MutableStateFlow( + savedStateHandle.get(KeySelectedAttachments) + ?.getBundleList(KeySelectedAttachmentItems) + ?.mapNotNull(Bundle::toUriStringAttachmentPair) + ?.toMap(LinkedHashMap()) + ?: linkedMapOf(), + ) + + init { + // Re-sync any attachments restored from saved state into the controller on recreation. + if (_selectedAttachments.value.isNotEmpty()) syncToController() + } + /** * Called when the input changes and the internal state needs to be updated. * @@ -153,31 +189,59 @@ public class MessageComposerViewModel( public fun dismissMessageActions(): Unit = messageComposerController.dismissMessageActions() /** - * @see [MessageComposerController.updateSelectedAttachments] - */ - public fun updateSelectedAttachments(attachments: List) { - messageComposerController.updateSelectedAttachments(attachments) + * Adds [attachments] to the staged attachment list, preserving insertion order. + * + * Attachments are keyed by [EXTRA_SOURCE_URI] stored in each attachment's [Attachment.extraData]. + * Duplicate URIs are silently ignored. + * + * @param attachments The attachments to add. + */ + public fun addAttachments(attachments: List) { + _selectedAttachments.update { current -> + LinkedHashMap(current).also { updated -> + attachments.forEach { attachment -> + attachment.sourceUriString()?.let { updated.putIfAbsent(it, attachment) } + } + } + } + persistAndSync() } /** - * Stores the selected attachments from the attachment picker. These will be shown in the UI, - * within the composer component. We upload and send these attachments once the user taps on the - * send button. + * Removes [attachment] from the staged attachment list. + * + * The attachment is identified by [EXTRA_SOURCE_URI] in its [Attachment.extraData]. * - * @param attachments The attachments to store and show in the composer. + * @param attachment The attachment to remove. */ - public fun addSelectedAttachments(attachments: List): Unit = - messageComposerController.addSelectedAttachments(attachments) + public fun removeAttachment(attachment: Attachment) { + val key = attachment.sourceUriString() ?: return + _selectedAttachments.update { LinkedHashMap(it).also { map -> map.remove(key) } } + persistAndSync() + } /** - * Removes a selected attachment from the list, when the user taps on the cancel/delete button. + * Removes all staged attachments whose URI string key is contained in [uris]. * - * This will update the UI to remove it from the composer component. - * - * @param attachment The attachment to remove. + * @param uris The URI string keys to remove. + */ + internal fun removeAttachmentsByUris(uris: Set) { + if (uris.isEmpty()) return + _selectedAttachments.update { current -> + LinkedHashMap(current).also { it.keys.removeAll(uris) } + } + persistAndSync() + } + + /** + * Removes all staged attachments. Call this when the attachments are consumed + * (e.g. after a message is sent, a poll is created, or a command is selected). */ - public fun removeSelectedAttachment(attachment: Attachment): Unit = - messageComposerController.removeSelectedAttachment(attachment) + public fun clearAttachments() { + _selectedAttachments.value = linkedMapOf() + savedStateHandle.remove(KeySelectedAttachments) + messageComposerController.updateSelectedAttachments(emptyList()) + } /** * Creates a poll with the given [pollConfig]. @@ -198,6 +262,7 @@ public class MessageComposerViewModel( * It also dismisses any current message actions. * * @param message The message to send. + * @param callback Invoked when the API call completes. */ public fun sendMessage( message: Message, @@ -271,6 +336,8 @@ public class MessageComposerViewModel( /** * Sets the typing updates buffer. + * + * @param buffer The buffer to use for typing updates. */ public fun setTypingUpdatesBuffer(buffer: TypingUpdatesBuffer) { messageComposerController.typingUpdatesBuffer = buffer @@ -315,4 +382,62 @@ public class MessageComposerViewModel( super.onCleared() messageComposerController.onCleared() } + + private fun persistAndSync() { + savedStateHandle[KeySelectedAttachments] = Bundle().apply { + putParcelableArrayList( + KeySelectedAttachmentItems, + ArrayList(_selectedAttachments.value.values.map(Attachment::toBundle)), + ) + } + syncToController() + } + + private fun syncToController() { + messageComposerController.updateSelectedAttachments(_selectedAttachments.value.values.toList()) + } } + +private const val KeySelectedAttachments = "stream_composer_selected_attachments" +private const val KeySelectedAttachmentItems = "stream_composer_selected_attachment_items" +private const val KeyBundleUri = "uri" +private const val KeyBundleType = "type" +private const val KeyBundleName = "name" +private const val KeyBundleFileSize = "fileSize" +private const val KeyBundleMimeType = "mimeType" +private const val AttachmentBundleSize = 5 + +private fun Attachment.sourceUriString(): String? = extraData[EXTRA_SOURCE_URI] as? String + +private fun Attachment.toBundle(): Bundle = Bundle(AttachmentBundleSize).apply { + sourceUriString()?.let { putString(KeyBundleUri, it) } + type?.let { putString(KeyBundleType, it) } + putString(KeyBundleName, name) + putInt(KeyBundleFileSize, fileSize) + mimeType?.let { putString(KeyBundleMimeType, it) } +} + +private fun Bundle.toAttachment(): Attachment? { + val uri = getString(KeyBundleUri) ?: return null + return Attachment( + type = getString(KeyBundleType), + name = getString(KeyBundleName) ?: "", + fileSize = getInt(KeyBundleFileSize), + mimeType = getString(KeyBundleMimeType), + extraData = mapOf(EXTRA_SOURCE_URI to uri), + ) +} + +private fun Bundle.toUriStringAttachmentPair(): Pair? { + val attachment = toAttachment() ?: return null + val uriString = attachment.sourceUriString() ?: return null + return uriString to attachment +} + +@Suppress("DEPRECATION") +private fun Bundle.getBundleList(key: String): List = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableArrayList(key, Bundle::class.java) ?: emptyList() + } else { + getParcelableArrayList(key) ?: emptyList() + } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt index d8ab78e95b9..57a7ed6b4be 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt @@ -175,12 +175,34 @@ public class MessagesViewModelFactory( * Creates the required [ViewModel] for our use case, based on the [factories] we provided. */ override fun create(modelClass: Class, extras: CreationExtras): T { + val savedStateHandle = extras.createSavedStateHandle() if (modelClass == AttachmentsPickerViewModel::class.java) { @Suppress("UNCHECKED_CAST") return AttachmentsPickerViewModel( storageHelper = storageHelper, channelState = channelStateFlow, - savedStateHandle = extras.createSavedStateHandle(), + savedStateHandle = savedStateHandle, + ) as T + } + if (modelClass == MessageComposerViewModel::class.java) { + @Suppress("UNCHECKED_CAST") + return MessageComposerViewModel( + messageComposerController = MessageComposerController( + chatClient = chatClient, + channelState = channelStateFlow, + mediaRecorder = mediaRecorder, + userLookupHandler = userLookupHandler, + fileToUri = fileToUriConverter, + channelCid = channelId, + config = MessageComposerController.Config( + maxAttachmentCount = maxAttachmentCount, + isLinkPreviewEnabled = isComposerLinkPreviewEnabled, + isDraftMessageEnabled = isComposerDraftMessageEnabled, + isActiveCommandEnabled = true, + ), + ), + storageHelper = storageHelper, + savedStateHandle = savedStateHandle, ) as T } return create(modelClass) diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt index 9f09dcd65dd..c02bef3e8a4 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt @@ -18,6 +18,7 @@ package io.getstream.chat.android.compose.viewmodel.messages import android.net.Uri import io.getstream.chat.android.client.channel.state.ChannelState +import io.getstream.chat.android.compose.state.messages.attachments.AttachmentPickerItemState import io.getstream.chat.android.compose.state.messages.attachments.FilePickerMode import io.getstream.chat.android.compose.state.messages.attachments.GalleryPickerMode import io.getstream.chat.android.models.Attachment @@ -40,7 +41,6 @@ import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.assertInstanceOf -import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -66,7 +66,7 @@ internal class AttachmentsPickerViewModelTest { assertTrue(viewModel.isPickerVisible) assertNull(viewModel.pickerMode) assertEquals(2, viewModel.attachments.size) - assertEquals(0, viewModel.getSelectedAttachments().size) + assertEquals(0, viewModel.attachments.count { it.isSelected }) } @Test @@ -80,22 +80,21 @@ internal class AttachmentsPickerViewModelTest { assertTrue(viewModel.isPickerVisible) assertInstanceOf(viewModel.pickerMode) assertEquals(2, viewModel.attachments.size) - assertEquals(0, viewModel.getSelectedAttachments().size) + assertEquals(0, viewModel.attachments.count { it.isSelected }) } @Test fun `Given images on the file system When selecting an image Should show the selection`() { - whenever(storageHelper.toAttachments(any())) doReturn listOf(Attachment(type = "image")) val viewModel = createViewModel() viewModel.setPickerVisible(visible = true) viewModel.loadMediaItems(imageAttachment1, imageAttachment2) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) assertTrue(viewModel.isPickerVisible) assertNull(viewModel.pickerMode) assertEquals(2, viewModel.attachments.size) - assertEquals(1, viewModel.getSelectedAttachments().size) + assertEquals(1, viewModel.attachments.count { it.isSelected }) } @Test @@ -104,9 +103,9 @@ internal class AttachmentsPickerViewModelTest { viewModel.setPickerVisible(visible = true) viewModel.loadMediaItems(imageAttachment1, imageAttachment2) - viewModel.toggleSelection(viewModel.attachments.first()) - viewModel.toggleSelection(viewModel.attachments.last()) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.last()) + viewModel.deselect(viewModel.attachments.first()) assertFalse(viewModel.attachments.first().isSelected) assertTrue(viewModel.attachments.last().isSelected) @@ -123,7 +122,7 @@ internal class AttachmentsPickerViewModelTest { assertFalse(viewModel.isPickerVisible) assertNull(viewModel.pickerMode) assertEquals(0, viewModel.attachments.size) - assertEquals(0, viewModel.getSelectedAttachments().size) + assertEquals(0, viewModel.attachments.count { it.isSelected }) } @Test @@ -147,21 +146,6 @@ internal class AttachmentsPickerViewModelTest { verify(storageHelper, never()).getMediaMetadata() } - @Test - fun `Given selected attachments When getting selected attachments Should map metadata for preview`() { - val expectedAttachments = listOf(Attachment(type = "image")) - whenever(storageHelper.toAttachments(any())) doReturn expectedAttachments - val viewModel = createViewModel() - - viewModel.setPickerVisible(visible = true) - viewModel.loadMediaItems(imageAttachment1, imageAttachment2) - viewModel.toggleSelection(viewModel.attachments.first()) - - val result = viewModel.getSelectedAttachments() - - assertEquals(expectedAttachments, result) - } - @Test fun `Given attachment metadata When getting attachments from metadata Should return attachments`() { val metadata = listOf(imageAttachment1, imageAttachment2) @@ -194,42 +178,15 @@ internal class AttachmentsPickerViewModelTest { viewModel.setPickerVisible(visible = true) viewModel.loadMediaItems(imageAttachment1, imageAttachment2) - viewModel.toggleSelection(viewModel.attachments.first()) - viewModel.toggleSelection(viewModel.attachments.last()) + viewModel.select(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.last()) - viewModel.deselectAttachment(attachmentWithSourceUri(imageUri1)) + viewModel.removeFromGridSelection(imageUri1.toString()) assertFalse(viewModel.attachments.first().isSelected) assertTrue(viewModel.attachments.last().isSelected) } - @Test - fun `Given single selection mode When selecting another item Should replace selection`() { - val viewModel = createViewModel() - - viewModel.setPickerVisible(visible = true) - viewModel.loadMediaItems(imageAttachment1, imageAttachment2) - - viewModel.toggleSelection(viewModel.attachments.first(), allowMultipleSelection = false) - viewModel.toggleSelection(viewModel.attachments.last(), allowMultipleSelection = false) - - assertFalse(viewModel.attachments.first().isSelected) - assertTrue(viewModel.attachments.last().isSelected) - } - - @Test - fun `Given single selection mode When clicking selected item Should keep it selected`() { - val viewModel = createViewModel() - - viewModel.setPickerVisible(visible = true) - viewModel.loadMediaItems(imageAttachment1) - - viewModel.toggleSelection(viewModel.attachments.first(), allowMultipleSelection = false) - viewModel.toggleSelection(viewModel.attachments.first(), allowMultipleSelection = false) - - assertTrue(viewModel.attachments.first().isSelected) - } - @Test fun `Given selected images When switching to files tab Should preserve image selections`() { val viewModel = createViewModel() @@ -237,7 +194,7 @@ internal class AttachmentsPickerViewModelTest { viewModel.setPickerVisible(visible = true) viewModel.setPickerMode(GalleryPickerMode()) viewModel.loadMediaItems(imageAttachment1, imageAttachment2) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) viewModel.setPickerMode(FilePickerMode()) viewModel.loadFileItems(fileAttachment1) @@ -246,27 +203,6 @@ internal class AttachmentsPickerViewModelTest { assertTrue(viewModel.attachments.first().isSelected) } - @Test - fun `Given selections in both tabs When getting selected attachments Should combine from all tabs`() { - whenever(storageHelper.toAttachments(any())) doReturn listOf( - Attachment(type = "image"), - Attachment(type = "file"), - ) - val viewModel = createViewModel() - - viewModel.setPickerVisible(visible = true) - viewModel.setPickerMode(GalleryPickerMode()) - viewModel.loadMediaItems(imageAttachment1) - viewModel.toggleSelection(viewModel.attachments.first()) - - viewModel.setPickerMode(FilePickerMode()) - viewModel.loadFileItems(fileAttachment1) - viewModel.toggleSelection(viewModel.attachments.first()) - - val result = viewModel.getSelectedAttachments() - assertEquals(2, result.size) - } - @Test fun `Given existing selections When reloading items Should preserve selections`() { val viewModel = createViewModel() @@ -274,7 +210,7 @@ internal class AttachmentsPickerViewModelTest { viewModel.setPickerVisible(visible = true) viewModel.setPickerMode(GalleryPickerMode()) viewModel.loadMediaItems(imageAttachment1, imageAttachment2) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) viewModel.loadMediaItems(imageAttachment1, imageAttachment2) @@ -289,12 +225,12 @@ internal class AttachmentsPickerViewModelTest { viewModel.setPickerVisible(visible = true) viewModel.setPickerMode(GalleryPickerMode()) viewModel.loadMediaItems(imageAttachment1) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) viewModel.setPickerMode(FilePickerMode()) viewModel.loadFileItems(fileAttachment1) - viewModel.deselectAttachment(attachmentWithSourceUri(imageUri1)) + viewModel.removeFromGridSelection(imageUri1.toString()) viewModel.setPickerMode(GalleryPickerMode()) assertFalse(viewModel.attachments.first().isSelected) @@ -306,7 +242,7 @@ internal class AttachmentsPickerViewModelTest { viewModel.setPickerVisible(visible = true) viewModel.loadMediaItems(imageAttachment1, imageAttachment2) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) viewModel.setPickerVisible(visible = false) viewModel.setPickerVisible(visible = true) @@ -317,25 +253,24 @@ internal class AttachmentsPickerViewModelTest { } @Test - fun `Given selections When calling clearSelection Should remove all selections`() { - whenever(storageHelper.toAttachments(any())) doReturn emptyList() + fun `Given selections When calling clearGridSelection Should remove all selections`() { val viewModel = createViewModel() viewModel.setPickerVisible(visible = true) viewModel.setPickerMode(GalleryPickerMode()) viewModel.loadMediaItems(imageAttachment1) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) viewModel.setPickerMode(FilePickerMode()) viewModel.loadFileItems(fileAttachment1) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) - viewModel.clearSelection() + viewModel.clearGridSelection() viewModel.setPickerMode(GalleryPickerMode()) assertFalse(viewModel.attachments.first().isSelected) viewModel.setPickerMode(FilePickerMode()) assertFalse(viewModel.attachments.first().isSelected) - assertEquals(0, viewModel.getSelectedAttachments().size) + assertTrue(viewModel.gridSelectedUris.value.isEmpty()) } @Test @@ -360,7 +295,7 @@ internal class AttachmentsPickerViewModelTest { viewModel.setPickerVisible(visible = true) viewModel.setPickerMode(GalleryPickerMode()) viewModel.loadMediaItems(imageAttachment1, imageAttachment2) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) viewModel.setPickerMode(FilePickerMode()) viewModel.loadFileItems(imageAttachment1, fileAttachment1) @@ -380,7 +315,7 @@ internal class AttachmentsPickerViewModelTest { viewModel.loadFileItems(imageAttachment1, fileAttachment1) viewModel.setPickerMode(GalleryPickerMode()) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) viewModel.setPickerMode(FilePickerMode()) assertTrue(viewModel.attachments.first().isSelected) @@ -397,17 +332,16 @@ internal class AttachmentsPickerViewModelTest { viewModel.loadFileItems(imageAttachment1) viewModel.setPickerMode(GalleryPickerMode()) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) + viewModel.deselect(viewModel.attachments.first()) - viewModel.toggleSelection(viewModel.attachments.first()) assertFalse(viewModel.attachments.first().isSelected) viewModel.setPickerMode(FilePickerMode()) assertFalse(viewModel.attachments.first().isSelected) } @Test - fun `Given same file in both tabs When getting selected attachments Should not duplicate`() { - whenever(storageHelper.toAttachments(any())) doReturn listOf(Attachment(type = "image")) + fun `Given same file in both tabs When selecting Should only count once in gridSelectedUris`() { val viewModel = createViewModel() viewModel.setPickerVisible(visible = true) @@ -417,12 +351,12 @@ internal class AttachmentsPickerViewModelTest { viewModel.loadFileItems(imageAttachment1) viewModel.setPickerMode(GalleryPickerMode()) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) assertTrue(viewModel.attachments.first().isSelected) viewModel.setPickerMode(FilePickerMode()) assertTrue(viewModel.attachments.first().isSelected) - assertEquals(1, viewModel.getSelectedAttachments().size) + assertEquals(1, viewModel.gridSelectedUris.value.size) } @Test @@ -436,35 +370,15 @@ internal class AttachmentsPickerViewModelTest { viewModel.loadFileItems(imageAttachment1) viewModel.setPickerMode(GalleryPickerMode()) - viewModel.toggleSelection(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.first()) - viewModel.deselectAttachment(attachmentWithSourceUri(imageUri1)) + viewModel.removeFromGridSelection(imageUri1.toString()) assertFalse(viewModel.attachments.first().isSelected) viewModel.setPickerMode(FilePickerMode()) assertFalse(viewModel.attachments.first().isSelected) } - @Test - fun `Given multiple images When selecting second then first Should return in selection order`() { - whenever(storageHelper.toAttachments(any())).thenAnswer { invocation -> - @Suppress("UNCHECKED_CAST") - val metadata = invocation.arguments[0] as List - metadata.map { Attachment(type = "image", name = it.title) } - } - val viewModel = createViewModel() - - viewModel.setPickerVisible(visible = true) - viewModel.loadMediaItems(imageAttachment1, imageAttachment2) - viewModel.toggleSelection(viewModel.attachments.last()) - viewModel.toggleSelection(viewModel.attachments.first()) - - val result = viewModel.getSelectedAttachments() - assertEquals(2, result.size) - assertEquals("img_2.png", result[0].name) - assertEquals("img_1.jpeg", result[1].name) - } - @Test fun `loadAttachments loads file metadata for FilePickerMode`() = runTest { val expectedMetadata = listOf(fileAttachment1, fileAttachment2) @@ -552,6 +466,24 @@ internal class AttachmentsPickerViewModelTest { private fun createViewModel(): AttachmentsPickerViewModel = AttachmentsPickerViewModel(storageHelper, channelState) + /** + * Selects an item in the grid by adding its URI to the grid selection. + * Assumes the item is not yet selected. + */ + private fun AttachmentsPickerViewModel.select(item: AttachmentPickerItemState) { + val uriString = item.attachmentMetaData.uri?.toString() ?: return + addToGridSelection(uriString) + } + + /** + * Deselects an item in the grid by removing its URI from the grid selection. + * Assumes the item is currently selected. + */ + private fun AttachmentsPickerViewModel.deselect(item: AttachmentPickerItemState) { + val uriString = item.attachmentMetaData.uri?.toString() ?: return + removeFromGridSelection(uriString) + } + /** * Sets media items on the ViewModel by mocking [AttachmentStorageHelper.getMediaMetadata] * and calling [AttachmentsPickerViewModel.loadAttachments]. diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt index bf859b77d31..84de5f3c239 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt @@ -46,6 +46,7 @@ import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Def import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention import io.getstream.chat.android.ui.common.feature.messages.composer.mention.MentionType import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper +import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI import io.getstream.chat.android.ui.common.state.messages.Edit import io.getstream.chat.android.ui.common.state.messages.MessageMode import io.getstream.chat.android.ui.common.state.messages.Reply @@ -129,10 +130,10 @@ internal class MessageComposerViewModelTest { .givenSendMessage() .get() - viewModel.addSelectedAttachments( + viewModel.addAttachments( listOf( - Attachment(imageUrl = "url1"), - Attachment(imageUrl = "url2"), + Attachment(imageUrl = "url1", extraData = mapOf(EXTRA_SOURCE_URI to "content://media/1")), + Attachment(imageUrl = "url2", extraData = mapOf(EXTRA_SOURCE_URI to "content://media/2")), ), ) val state = viewModel.messageComposerState.value @@ -164,14 +165,14 @@ internal class MessageComposerViewModelTest { .givenChannelState() .get() - viewModel.addSelectedAttachments( + viewModel.addAttachments( listOf( - Attachment(imageUrl = "url1"), - Attachment(imageUrl = "url2"), + Attachment(imageUrl = "url1", extraData = mapOf(EXTRA_SOURCE_URI to "content://media/1")), + Attachment(imageUrl = "url2", extraData = mapOf(EXTRA_SOURCE_URI to "content://media/2")), ), ) - viewModel.removeSelectedAttachment( - Attachment(imageUrl = "url1"), + viewModel.removeAttachment( + Attachment(imageUrl = "url1", extraData = mapOf(EXTRA_SOURCE_URI to "content://media/1")), ) viewModel.messageComposerState.value.attachments.size `should be equal to` 1 From ef85d12400aa7b497dbcb26b0bf07769604a1edb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 9 Mar 2026 11:56:30 +0000 Subject: [PATCH 04/22] Review `AttachmentPicker` --- .../messages/attachments/AttachmentPicker.kt | 24 ++------- .../compose/messages/AttachmentsPicker.kt | 54 ++++++++++--------- 2 files changed, 33 insertions(+), 45 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPicker.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPicker.kt index 07f1e73a8ed..734eb74d4d4 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPicker.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPicker.kt @@ -23,15 +23,11 @@ import androidx.compose.foundation.layout.Column import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import io.getstream.chat.android.compose.R -import io.getstream.chat.android.compose.state.messages.attachments.AttachmentPickerMode -import io.getstream.chat.android.compose.state.messages.attachments.CameraPickerMode -import io.getstream.chat.android.compose.state.messages.attachments.FilePickerMode -import io.getstream.chat.android.compose.state.messages.attachments.GalleryPickerMode -import io.getstream.chat.android.compose.state.messages.attachments.PollPickerMode import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.viewmodel.messages.AttachmentsPickerViewModel import io.getstream.chat.android.ui.common.state.messages.MessageMode @@ -70,7 +66,9 @@ public fun AttachmentPicker( attachmentsPickerViewModel: AttachmentsPickerViewModel, modifier: Modifier = Modifier, messageMode: MessageMode = MessageMode.Normal, - actions: AttachmentPickerActions = AttachmentPickerActions.pickerDefaults(attachmentsPickerViewModel), + actions: AttachmentPickerActions = remember(attachmentsPickerViewModel) { + AttachmentPickerActions.pickerDefaults(attachmentsPickerViewModel) + }, ) { BackHandler(onBack = actions.onDismiss) @@ -130,17 +128,3 @@ public fun AttachmentPicker( } } } - -/** - * A helper property to check if the current [AttachmentPickerMode] supports multiple selections. - * - * This will return: - * - `true` or `false` for [FilePickerMode] and [GalleryPickerMode], based on their `allowMultipleSelection` property. - * - `null` for other modes like [CameraPickerMode] or [PollPickerMode] that do not support this concept. - */ -internal val AttachmentPickerMode.allowMultipleSelection: Boolean? - get() = when (this) { - is FilePickerMode -> allowMultipleSelection - is GalleryPickerMode -> allowMultipleSelection - else -> null - } diff --git a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/AttachmentsPicker.kt b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/AttachmentsPicker.kt index 000739e3ad9..3889b633544 100644 --- a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/AttachmentsPicker.kt +++ b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/AttachmentsPicker.kt @@ -8,6 +8,7 @@ import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import io.getstream.chat.android.compose.ui.messages.attachments.AttachmentPicker @@ -47,18 +48,19 @@ private object AttachmentsPickerUsageSnippet { val isPickerVisible = attachmentsPickerViewModel.isPickerVisible if (isPickerVisible) { - AttachmentPicker( // Add the picker to your UI + AttachmentPicker( + // Add the picker to your UI attachmentsPickerViewModel = attachmentsPickerViewModel, - actions = AttachmentPickerActions.pickerDefaults( - attachmentsPickerViewModel, - ).copy( - onAttachmentsSelected = { - // Handle selected attachments - }, - onDismiss = { - // Handle dismiss - }, - ), + actions = remember(attachmentsPickerViewModel) { + AttachmentPickerActions.pickerDefaults(attachmentsPickerViewModel).copy( + onAttachmentsSelected = { + // Handle selected attachments + }, + onDismiss = { + // Handle dismiss + }, + ) + }, ) } } @@ -101,10 +103,12 @@ private object AttachmentsPickerHandlingActionsSnippet { if (isPickerVisible) { AttachmentPicker( attachmentsPickerViewModel = attachmentsPickerViewModel, - actions = AttachmentPickerActions.defaultActions( - attachmentsPickerViewModel, - composerViewModel, - ), + actions = remember(attachmentsPickerViewModel) { + AttachmentPickerActions.defaultActions( + attachmentsPickerViewModel, + composerViewModel, + ) + }, ) } } @@ -143,16 +147,16 @@ private object AttachmentsPickerCustomizationSnippet { if (isPickerVisible) { AttachmentPicker( attachmentsPickerViewModel = attachmentsPickerViewModel, - actions = AttachmentPickerActions.pickerDefaults( - attachmentsPickerViewModel, - ).copy( - onAttachmentsSelected = { attachments -> - // Handle selected attachments - }, - onDismiss = { - // Handle dismiss - }, - ), + actions = remember(attachmentsPickerViewModel) { + AttachmentPickerActions.pickerDefaults(attachmentsPickerViewModel).copy( + onAttachmentsSelected = { attachments -> + // Handle selected attachments + }, + onDismiss = { + // Handle dismiss + }, + ) + }, ) } } From 1770d71897064b4f114cfc86ebda639e7c7d22d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 9 Mar 2026 11:02:21 +0000 Subject: [PATCH 05/22] Review `MessagesViewModelFactory` --- .../messages/MessagesViewModelFactory.kt | 105 ++++++++---------- .../messages/MessagesViewModelFactoryTest.kt | 4 +- 2 files changed, 49 insertions(+), 60 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt index 57a7ed6b4be..eb02b308ece 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.compose.viewmodel.messages import android.content.ClipboardManager import android.content.Context import androidx.core.net.toUri +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.createSavedStateHandle @@ -49,15 +50,19 @@ import java.io.File /** * Holds all the dependencies needed to build the ViewModels for the Messages Screen. - * Currently builds the [MessageComposerViewModel], [MessageListViewModel] and [AttachmentsPickerViewModel]. - * @param context Used to build the [ClipboardManager]. + * Currently, builds the [MessageComposerViewModel], [MessageListViewModel] and [AttachmentsPickerViewModel]. + * + * @param context Android context used to access system services and device storage. * @param channelId The current channel ID, to load the messages from. * @param messageId The message id to which we want to scroll to when opening the message list. * @param parentMessageId The ID of the parent [Message] if the message we want to scroll to is in a thread. If the * message we want to scroll to is not in a thread, you can pass in a null value. + * @param autoTranslationEnabled Whether auto-translation of messages is enabled. * @param chatClient The client to use for API calls. * @param clientState The current state of the SDK. * @param mediaRecorder The media recorder for async voice messages. + * @param userLookupHandler Handler used to look up users for mention autocomplete. + * @param fileToUriConverter Converts a local [File] to a URI string used as an attachment source. * @param messageLimit The number of messages to load in a single page. * @param clipboardHandler [ClipboardHandler] used to copy messages. * @param enforceUniqueReactions Flag to enforce unique reactions or enable multiple from the same user. @@ -91,7 +96,7 @@ public class MessagesViewModelFactory( private val clipboardHandler: ClipboardHandler = ClipboardHandlerImpl( clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager, autoTranslationEnabled = autoTranslationEnabled, - getCurrentUser = { chatClient.getCurrentUser() }, + getCurrentUser = chatClient::getCurrentUser, ), private val enforceUniqueReactions: Boolean = false, private val maxAttachmentCount: Int = AttachmentConstants.MAX_ATTACHMENTS_COUNT, @@ -120,11 +125,36 @@ public class MessagesViewModelFactory( private val storageHelper by lazy { AttachmentStorageHelper(context) } /** - * The list of factories that can build [ViewModel]s that our Messages feature components use. + * Creates the required [ViewModel] for our use case. + * + * Supports [MessageComposerViewModel], [MessageListViewModel], and [AttachmentsPickerViewModel]. + * [MessageComposerViewModel] and [AttachmentsPickerViewModel] will receive a [SavedStateHandle] + * sourced from [extras], allowing them to survive Activity recreation. + * Throws [IllegalArgumentException] for any other class. + * + * @param modelClass The class of the [ViewModel] to create. + * @param extras [CreationExtras] provided by the [androidx.lifecycle.ViewModelStoreOwner]. + */ + override fun create(modelClass: Class, extras: CreationExtras): T = + createViewModel(modelClass, savedStateHandle = extras.createSavedStateHandle()) + + /** + * Creates the required [ViewModel] for our use case. + * + * Supports [MessageComposerViewModel], [MessageListViewModel], and [AttachmentsPickerViewModel]. + * Throws [IllegalArgumentException] for any other class. + * + * Prefer [create] with [CreationExtras] so that [MessageComposerViewModel] and + * [AttachmentsPickerViewModel] can survive Activity recreation. + * + * @param modelClass The class of the [ViewModel] to create. */ - private val factories: Map, () -> ViewModel> = mapOf( - MessageComposerViewModel::class.java to { - MessageComposerViewModel( + override fun create(modelClass: Class): T = + createViewModel(modelClass, savedStateHandle = null) + + private fun createViewModel(modelClass: Class, savedStateHandle: SavedStateHandle?): T { + val viewModel: ViewModel = when (modelClass) { + MessageComposerViewModel::class.java -> MessageComposerViewModel( messageComposerController = MessageComposerController( chatClient = chatClient, channelState = channelStateFlow, @@ -140,10 +170,9 @@ public class MessagesViewModelFactory( ), ), storageHelper = storageHelper, + savedStateHandle = savedStateHandle ?: SavedStateHandle(), ) - }, - MessageListViewModel::class.java to { - MessageListViewModel( + MessageListViewModel::class.java -> MessageListViewModel( MessageListController( cid = channelId, clipboardHandler = clipboardHandler, @@ -165,58 +194,18 @@ public class MessagesViewModelFactory( showThreadSeparatorInEmptyThread = showThreadSeparatorInEmptyThread, ), ) - }, - AttachmentsPickerViewModel::class.java to { - AttachmentsPickerViewModel(storageHelper, channelStateFlow) - }, - ) - - /** - * Creates the required [ViewModel] for our use case, based on the [factories] we provided. - */ - override fun create(modelClass: Class, extras: CreationExtras): T { - val savedStateHandle = extras.createSavedStateHandle() - if (modelClass == AttachmentsPickerViewModel::class.java) { - @Suppress("UNCHECKED_CAST") - return AttachmentsPickerViewModel( + AttachmentsPickerViewModel::class.java -> AttachmentsPickerViewModel( storageHelper = storageHelper, channelState = channelStateFlow, - savedStateHandle = savedStateHandle, - ) as T - } - if (modelClass == MessageComposerViewModel::class.java) { - @Suppress("UNCHECKED_CAST") - return MessageComposerViewModel( - messageComposerController = MessageComposerController( - chatClient = chatClient, - channelState = channelStateFlow, - mediaRecorder = mediaRecorder, - userLookupHandler = userLookupHandler, - fileToUri = fileToUriConverter, - channelCid = channelId, - config = MessageComposerController.Config( - maxAttachmentCount = maxAttachmentCount, - isLinkPreviewEnabled = isComposerLinkPreviewEnabled, - isDraftMessageEnabled = isComposerDraftMessageEnabled, - isActiveCommandEnabled = true, - ), - ), - storageHelper = storageHelper, - savedStateHandle = savedStateHandle, - ) as T - } - return create(modelClass) - } - - /** - * Creates the required [ViewModel] for our use case, based on the [factories] we provided. - */ - override fun create(modelClass: Class): T { - val viewModel: ViewModel = factories[modelClass]?.invoke() - ?: throw IllegalArgumentException( + savedStateHandle = savedStateHandle ?: SavedStateHandle(), + ) + else -> throw IllegalArgumentException( "MessagesViewModelFactory can only create instances of " + - "the following classes: ${factories.keys.joinToString { it.simpleName }}", + "${MessageComposerViewModel::class.java.simpleName}, " + + "${MessageListViewModel::class.java.simpleName}, or " + + "${AttachmentsPickerViewModel::class.java.simpleName}.", ) + } @Suppress("UNCHECKED_CAST") return viewModel as T diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactoryTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactoryTest.kt index d258d9bf4a8..87fb78ea73e 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactoryTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactoryTest.kt @@ -77,8 +77,8 @@ internal class MessagesViewModelFactoryTest { } assertEquals( - "MessagesViewModelFactory can only create instances of the following classes: " + - "MessageComposerViewModel, MessageListViewModel, AttachmentsPickerViewModel", + "MessagesViewModelFactory can only create instances of " + + "MessageComposerViewModel, MessageListViewModel, or AttachmentsPickerViewModel.", exception.message, ) } From 8a23cd018d5fb4c159c69e6cae237d1fbbc8e379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 9 Mar 2026 11:27:03 +0000 Subject: [PATCH 06/22] Review `AttachmentPickerActions` --- .../attachments/AttachmentPickerActions.kt | 95 +++++++++++-------- .../messages/AttachmentsPickerViewModel.kt | 50 +++++++--- .../AttachmentsPickerViewModelTest.kt | 24 ++--- 3 files changed, 105 insertions(+), 64 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPickerActions.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPickerActions.kt index 3d9f3a68e39..74ed41222fc 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPickerActions.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPickerActions.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.compose.ui.messages.attachments +import androidx.compose.runtime.Stable import io.getstream.chat.android.compose.state.messages.attachments.AttachmentPickerItemState import io.getstream.chat.android.compose.viewmodel.messages.AttachmentsPickerViewModel import io.getstream.chat.android.compose.viewmodel.messages.MessageComposerViewModel @@ -27,10 +28,11 @@ import io.getstream.chat.android.models.PollConfig * Actions that can be performed in the attachment picker. * * Each property maps a user gesture or lifecycle event to a handler. - * Override individual actions via [copy] to customise behaviour while keeping the rest at their defaults. + * To customise individual actions, construct a new instance overriding only the properties you need, + * using the companion object factories as a starting point. * * @property onAttachmentItemSelected Called when a user taps an attachment item to select or deselect it - * inside the in-app picker grid. + * inside the in-app attachment browser. * @property onAttachmentsSelected Called when attachments are confirmed and should be added to the composer. * Receives the list of [Attachment] objects ready to be sent. Triggered by system pickers, camera, and * file browser results. @@ -42,6 +44,7 @@ import io.getstream.chat.android.models.PollConfig * @property onCommandSelected Called when the user selects a slash command from the command picker. * @property onDismiss Called when the attachment picker should be dismissed (back press, outside tap, etc.). */ +@Stable public data class AttachmentPickerActions( val onAttachmentItemSelected: (AttachmentPickerItemState) -> Unit, val onAttachmentsSelected: (List) -> Unit, @@ -69,7 +72,7 @@ public data class AttachmentPickerActions( /** * Lightweight defaults suitable for standalone [AttachmentPicker] usage without a composer. * - * Handles picker-level concerns only: toggling the grid selection index and dismissing the + * Handles picker-level concerns only: toggling the selection index and dismissing the * picker. Attachment submission, poll, and command actions are no-ops. * * @param attachmentsPickerViewModel The [AttachmentsPickerViewModel] that drives picker state. @@ -78,14 +81,7 @@ public data class AttachmentPickerActions( attachmentsPickerViewModel: AttachmentsPickerViewModel, ): AttachmentPickerActions = AttachmentPickerActions( onAttachmentItemSelected = { item -> - val uriString = item.attachmentMetaData.uri?.toString() ?: return@AttachmentPickerActions - val multiSelect = attachmentsPickerViewModel.pickerMode?.allowMultipleSelection == true - if (item.isSelected) { - attachmentsPickerViewModel.removeFromGridSelection(uriString) - } else { - if (!multiSelect) attachmentsPickerViewModel.clearGridSelection() - attachmentsPickerViewModel.addToGridSelection(uriString) - } + handlePickerItemSelection(item, attachmentsPickerViewModel) }, onAttachmentsSelected = {}, onCreatePollClick = {}, @@ -98,53 +94,72 @@ public data class AttachmentPickerActions( /** * Default implementation wiring both the picker and composer view models. * - * [AttachmentsPickerViewModel] owns the grid selection index (URI checkmarks). - * [MessageComposerViewModel] owns the full attachment list for the message. - * This function is the sole coordination point between the two. + * Handles item selection, attachment submission, poll creation, command selection, and dismissal. + * Use this when the [AttachmentPicker] is paired with a [MessageComposerViewModel]. * * @param attachmentsPickerViewModel The [AttachmentsPickerViewModel] that drives picker state. - * @param composerViewModel The [MessageComposerViewModel] that owns the selected attachment list. + * @param composerViewModel The [MessageComposerViewModel] that manages the attachment list for the message. */ public fun defaultActions( attachmentsPickerViewModel: AttachmentsPickerViewModel, composerViewModel: MessageComposerViewModel, ): AttachmentPickerActions = AttachmentPickerActions( onAttachmentItemSelected = { item -> - val uriString = item.attachmentMetaData.uri?.toString() ?: return@AttachmentPickerActions - val multiSelect = attachmentsPickerViewModel.pickerMode?.allowMultipleSelection == true - if (item.isSelected) { - attachmentsPickerViewModel.removeFromGridSelection(uriString) - composerViewModel.removeAttachmentsByUris(setOf(uriString)) - } else { - val attachment = attachmentsPickerViewModel - .getAttachmentsFromMetadata(listOf(item.attachmentMetaData)) - .firstOrNull() ?: return@AttachmentPickerActions - if (!multiSelect) { - composerViewModel.removeAttachmentsByUris(attachmentsPickerViewModel.gridSelectedUris.value) - attachmentsPickerViewModel.clearGridSelection() - } - attachmentsPickerViewModel.addToGridSelection(uriString) - composerViewModel.addAttachments(listOf(attachment)) - } - }, - onAttachmentsSelected = { attachments -> - composerViewModel.addAttachments(attachments) + handleItemSelection(item, attachmentsPickerViewModel, composerViewModel) }, + onAttachmentsSelected = composerViewModel::addAttachments, onCreatePollClick = {}, onCreatePoll = { pollConfig -> - attachmentsPickerViewModel.setPickerVisible(visible = false) - attachmentsPickerViewModel.clearGridSelection() - composerViewModel.clearAttachments() + consumePickerSession(attachmentsPickerViewModel, composerViewModel) composerViewModel.createPoll(pollConfig) }, onCreatePollDismissed = {}, onCommandSelected = { command -> - attachmentsPickerViewModel.setPickerVisible(visible = false) - attachmentsPickerViewModel.clearGridSelection() - composerViewModel.clearAttachments() + consumePickerSession(attachmentsPickerViewModel, composerViewModel) composerViewModel.selectCommand(command) }, onDismiss = { attachmentsPickerViewModel.setPickerVisible(visible = false) }, ) } } + +private fun handlePickerItemSelection( + item: AttachmentPickerItemState, + pickerViewModel: AttachmentsPickerViewModel, +) { + val uriString = item.attachmentMetaData.uri?.toString() ?: return + if (item.isSelected) { + pickerViewModel.removeFromSelection(uriString) + } else { + pickerViewModel.selectItem(uriString) + } +} + +private fun handleItemSelection( + item: AttachmentPickerItemState, + pickerViewModel: AttachmentsPickerViewModel, + composerViewModel: MessageComposerViewModel, +) { + val uriString = item.attachmentMetaData.uri?.toString() ?: return + if (item.isSelected) { + pickerViewModel.removeFromSelection(uriString) + composerViewModel.removeAttachmentsByUris(setOf(uriString)) + } else { + val attachment = pickerViewModel + .getAttachmentsFromMetadata(listOf(item.attachmentMetaData)) + .firstOrNull() ?: return + val replaced = pickerViewModel.selectItem(uriString) + composerViewModel.removeAttachmentsByUris(replaced) + composerViewModel.addAttachments(listOf(attachment)) + } +} + +private fun consumePickerSession( + pickerViewModel: AttachmentsPickerViewModel, + composerViewModel: MessageComposerViewModel, +) { + // Polls and commands are mutually exclusive with file attachments — reset both. + pickerViewModel.setPickerVisible(visible = false) + pickerViewModel.clearSelection() + composerViewModel.clearAttachments() +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt index b32802b1352..c5d63243ff2 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt @@ -100,7 +100,7 @@ public class AttachmentsPickerViewModel( private val _fileItems = MutableStateFlow>(emptyList()) /** - * URI strings of grid items (gallery or files tab) currently checked by the user. + * URI strings of items currently checked by the user in the in-app attachment browser. * Used only for driving `isSelected` state in the attachment grid — the full attachment * list for the composer is owned by [MessageComposerViewModel]. * @@ -125,13 +125,14 @@ public class AttachmentsPickerViewModel( public val isPickerVisible: Boolean by _isPickerVisible.asState(viewModelScope) /** - * URI strings of grid items currently selected by the user, used to show checkmarks. + * URI strings of items currently selected by the user in the in-app attachment browser, + * used to show checkmarks. */ - internal val gridSelectedUris: StateFlow> = _gridSelectedUris + internal val selectedUris: StateFlow> = _gridSelectedUris /** * The attachment list for the active [pickerMode], with each item's [AttachmentPickerItemState.isSelected] - * reflecting whether it appears in [gridSelectedUris]. + * reflecting whether it appears in [selectedUris]. */ public val attachments: List by combine( _pickerMode, @@ -189,30 +190,55 @@ public class AttachmentsPickerViewModel( } /** - * Marks [uriString] as selected in the grid. Has no effect if already selected. + * Selects [uriString] in the picker, respecting the active picker mode's multi-select setting. * - * @param uriString The URI string of the grid item to select. + * In single-select mode, all existing selections are cleared before the new item is selected, + * and the previously selected URIs are returned so callers can react (e.g. remove from the composer). + * In multi-select mode, the item is added to the existing selection and an empty set is returned. + * + * @param uriString The URI string of the item to select. + * @return The set of URI strings that were cleared in single-select mode, or an empty set in multi-select mode. + */ + internal fun selectItem(uriString: String): Set { + val multiSelect = when (val mode = pickerMode) { + is GalleryPickerMode -> mode.allowMultipleSelection + is FilePickerMode -> mode.allowMultipleSelection + else -> false + } + val replaced = if (!multiSelect) { + _gridSelectedUris.value.also { clearSelection() } + } else { + emptySet() + } + addToSelection(uriString) + return replaced + } + + /** + * Marks [uriString] as selected in the picker. Has no effect if already selected. + * + * @param uriString The URI string of the item to select. */ - internal fun addToGridSelection(uriString: String) { + internal fun addToSelection(uriString: String) { _gridSelectedUris.value = _gridSelectedUris.value + uriString savedStateHandle[KeyGridSelectedUris] = ArrayList(_gridSelectedUris.value) } /** - * Removes [uriString] from the grid selection. Has no effect if not selected. + * Removes [uriString] from the picker selection. Has no effect if not selected. * - * @param uriString The URI string of the grid item to deselect. + * @param uriString The URI string of the item to deselect. */ - internal fun removeFromGridSelection(uriString: String) { + internal fun removeFromSelection(uriString: String) { _gridSelectedUris.value = _gridSelectedUris.value - uriString savedStateHandle[KeyGridSelectedUris] = ArrayList(_gridSelectedUris.value) } /** - * Clears all grid selections. Call this when the selection is consumed + * Clears all picker selections. Call this when the selection is consumed * (e.g. after a message is sent, a poll is created, or a command is selected). */ - internal fun clearGridSelection() { + internal fun clearSelection() { _gridSelectedUris.value = emptySet() savedStateHandle.remove>(KeyGridSelectedUris) } diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt index c02bef3e8a4..75cb3ba1ce7 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt @@ -181,7 +181,7 @@ internal class AttachmentsPickerViewModelTest { viewModel.select(viewModel.attachments.first()) viewModel.select(viewModel.attachments.last()) - viewModel.removeFromGridSelection(imageUri1.toString()) + viewModel.removeFromSelection(imageUri1.toString()) assertFalse(viewModel.attachments.first().isSelected) assertTrue(viewModel.attachments.last().isSelected) @@ -230,7 +230,7 @@ internal class AttachmentsPickerViewModelTest { viewModel.setPickerMode(FilePickerMode()) viewModel.loadFileItems(fileAttachment1) - viewModel.removeFromGridSelection(imageUri1.toString()) + viewModel.removeFromSelection(imageUri1.toString()) viewModel.setPickerMode(GalleryPickerMode()) assertFalse(viewModel.attachments.first().isSelected) @@ -253,7 +253,7 @@ internal class AttachmentsPickerViewModelTest { } @Test - fun `Given selections When calling clearGridSelection Should remove all selections`() { + fun `Given selections When calling clearSelection Should remove all selections`() { val viewModel = createViewModel() viewModel.setPickerVisible(visible = true) @@ -264,13 +264,13 @@ internal class AttachmentsPickerViewModelTest { viewModel.loadFileItems(fileAttachment1) viewModel.select(viewModel.attachments.first()) - viewModel.clearGridSelection() + viewModel.clearSelection() viewModel.setPickerMode(GalleryPickerMode()) assertFalse(viewModel.attachments.first().isSelected) viewModel.setPickerMode(FilePickerMode()) assertFalse(viewModel.attachments.first().isSelected) - assertTrue(viewModel.gridSelectedUris.value.isEmpty()) + assertTrue(viewModel.selectedUris.value.isEmpty()) } @Test @@ -341,7 +341,7 @@ internal class AttachmentsPickerViewModelTest { } @Test - fun `Given same file in both tabs When selecting Should only count once in gridSelectedUris`() { + fun `Given same file in both tabs When selecting Should only count once in selectedUris`() { val viewModel = createViewModel() viewModel.setPickerVisible(visible = true) @@ -356,7 +356,7 @@ internal class AttachmentsPickerViewModelTest { assertTrue(viewModel.attachments.first().isSelected) viewModel.setPickerMode(FilePickerMode()) assertTrue(viewModel.attachments.first().isSelected) - assertEquals(1, viewModel.gridSelectedUris.value.size) + assertEquals(1, viewModel.selectedUris.value.size) } @Test @@ -372,7 +372,7 @@ internal class AttachmentsPickerViewModelTest { viewModel.setPickerMode(GalleryPickerMode()) viewModel.select(viewModel.attachments.first()) - viewModel.removeFromGridSelection(imageUri1.toString()) + viewModel.removeFromSelection(imageUri1.toString()) assertFalse(viewModel.attachments.first().isSelected) viewModel.setPickerMode(FilePickerMode()) @@ -467,21 +467,21 @@ internal class AttachmentsPickerViewModelTest { AttachmentsPickerViewModel(storageHelper, channelState) /** - * Selects an item in the grid by adding its URI to the grid selection. + * Selects an item by adding its URI to the picker selection. * Assumes the item is not yet selected. */ private fun AttachmentsPickerViewModel.select(item: AttachmentPickerItemState) { val uriString = item.attachmentMetaData.uri?.toString() ?: return - addToGridSelection(uriString) + addToSelection(uriString) } /** - * Deselects an item in the grid by removing its URI from the grid selection. + * Deselects an item by removing its URI from the picker selection. * Assumes the item is currently selected. */ private fun AttachmentsPickerViewModel.deselect(item: AttachmentPickerItemState) { val uriString = item.attachmentMetaData.uri?.toString() ?: return - removeFromGridSelection(uriString) + removeFromSelection(uriString) } /** From ae92f40a433c476d26c8d5cde6774c3c76773fc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 9 Mar 2026 13:16:28 +0000 Subject: [PATCH 07/22] Review `AttachmentsPickerViewModel` --- .../compose/ui/messages/MessagesScreen.kt | 4 +- .../messages/AttachmentsPickerViewModel.kt | 97 +++++++------------ .../AttachmentsPickerViewModelTest.kt | 6 +- 3 files changed, 40 insertions(+), 67 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt index 3da68011215..58956a05935 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt @@ -353,7 +353,7 @@ internal fun DefaultBottomBarContent( onAttachmentRemoved = { attachment -> attachment.extraData[EXTRA_SOURCE_URI] ?.let { it as? String } - ?.let(attachmentsPickerViewModel::removeFromGridSelection) + ?.let(attachmentsPickerViewModel::removeFromSelection) composerViewModel.removeAttachment(attachment) }, onCancelAction = { @@ -363,7 +363,7 @@ internal fun DefaultBottomBarContent( onLinkPreviewClick = onComposerLinkPreviewClick, onSendMessage = { message -> attachmentsPickerViewModel.setPickerVisible(visible = false) - attachmentsPickerViewModel.clearGridSelection() + attachmentsPickerViewModel.clearSelection() composerViewModel.sendMessage( message.copy( skipPushNotification = skipPushNotification, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt index c5d63243ff2..5d5e3417ef7 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt @@ -47,29 +47,22 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.channels.Channel as CoroutineChannel /** - * ViewModel for the attachment picker. Manages storage browsing and picker UI state. + * ViewModel for the attachment picker. Drives picker tab state, device storage browsing, + * and the `isSelected` checkmarks shown in [attachments]. * - * **Responsibilities:** - * - Active picker tab ([pickerMode]) and visibility ([isPickerVisible]) - * - Loading media and file metadata from device storage ([loadAttachments]) - * - Resolving system-picker URIs into [Attachment]s ([resolveAndSubmitUris]) - * - Tracking which grid items the user has selected via [gridSelectedUris], used to drive - * `isSelected` checkmarks in the attachment grid + * Note: [attachments] reflects only the checkmark selection, not the full attachment list + * staged for the message. The composer attachment list is owned by [MessageComposerViewModel]. * - * **Not responsible for:** the full content or ordering of the message's attachment list. - * That is owned by [MessageComposerViewModel], which is the single source of truth for all - * attachments in the current composer session. - * - * The [gridSelectedUris] index and picker tab survive Activity destruction - * (e.g. "Don't keep activities") via [savedStateHandle]. - * [gridSelectedUris] is cleared by [clearGridSelection] when the selection is consumed - * (e.g. message sent, poll created, command selected). + * The active tab and checkmark selection survive process death (e.g. "Don't keep activities"). + * Checkmarks are not reset on hide — they persist until the session is explicitly consumed + * (e.g. after a message is sent, a poll is created, or a command is selected). * * @param storageHelper Provides device storage queries and attachment conversion. * @param channelState Provides the current [ChannelState] for channel-specific configuration. - * @param savedStateHandle Persists picker state across Activity recreation. + * @param savedStateHandle Persists picker tab and selection state across process death. */ public class AttachmentsPickerViewModel( private val storageHelper: AttachmentStorageHelper, @@ -99,20 +92,17 @@ public class AttachmentsPickerViewModel( private val _mediaItems = MutableStateFlow>(emptyList()) private val _fileItems = MutableStateFlow>(emptyList()) - /** - * URI strings of items currently checked by the user in the in-app attachment browser. - * Used only for driving `isSelected` state in the attachment grid — the full attachment - * list for the composer is owned by [MessageComposerViewModel]. - * - * Persisted so checkmarks survive Activity recreation (e.g. when the camera is launched). - */ - private val _gridSelectedUris = MutableStateFlow>( - savedStateHandle.get>(KeyGridSelectedUris)?.toSet() ?: emptySet(), + // URI strings of items checked by the user. Drives isSelected in attachments. + // Separate from the composer's attachment list — shared across gallery and file tabs. + // Persisted so checkmarks survive process death (e.g. camera launch). + private val _selectedUris = MutableStateFlow( + savedStateHandle.get>(KeySelectedUris)?.toSet() ?: emptySet(), ) private val _isPickerVisible = MutableStateFlow( savedStateHandle[KeyPickerVisible] ?: false, ) - private val _submittedAttachments = kotlinx.coroutines.channels.Channel(capacity = UNLIMITED) + private val _submittedAttachments = CoroutineChannel(capacity = UNLIMITED) + private var loadAttachmentsJob: Job? = null /** * The active picker tab. @@ -124,28 +114,22 @@ public class AttachmentsPickerViewModel( */ public val isPickerVisible: Boolean by _isPickerVisible.asState(viewModelScope) - /** - * URI strings of items currently selected by the user in the in-app attachment browser, - * used to show checkmarks. - */ - internal val selectedUris: StateFlow> = _gridSelectedUris - /** * The attachment list for the active [pickerMode], with each item's [AttachmentPickerItemState.isSelected] - * reflecting whether it appears in [selectedUris]. + * reflecting the current picker selection. */ public val attachments: List by combine( _pickerMode, _mediaItems, _fileItems, - _gridSelectedUris, - ) { mode, media, files, gridUris -> + _selectedUris, + ) { mode, media, files, uris -> val items = when (mode) { is GalleryPickerMode, null -> media is FilePickerMode -> files else -> emptyList() } - items.map { meta -> AttachmentPickerItemState(meta, isSelected = meta.uri?.toString() in gridUris) } + items.map { meta -> AttachmentPickerItemState(meta, isSelected = meta.uri?.toString() in uris) } }.asState(viewModelScope, emptyList()) /** @@ -169,17 +153,15 @@ public class AttachmentsPickerViewModel( } /** - * Shows or hides the attachment picker. Hiding clears cached data but preserves the - * current grid selection so checkmarks remain when the picker is reopened. - * - * Call [clearGridSelection] after this to also reset the selection (e.g. after sending a message). + * Shows or hides the attachment picker. Hiding clears cached media data but preserves the + * current selection so checkmarks remain when the picker is reopened. * * @param visible `true` to show the picker, `false` to hide it. */ public fun setPickerVisible(visible: Boolean) { _isPickerVisible.value = visible savedStateHandle[KeyPickerVisible] = visible - if (!visible) clearCachedData() + if (!visible) resetPickerState() } /** @@ -206,7 +188,7 @@ public class AttachmentsPickerViewModel( else -> false } val replaced = if (!multiSelect) { - _gridSelectedUris.value.also { clearSelection() } + _selectedUris.value.also { clearSelection() } } else { emptySet() } @@ -214,14 +196,9 @@ public class AttachmentsPickerViewModel( return replaced } - /** - * Marks [uriString] as selected in the picker. Has no effect if already selected. - * - * @param uriString The URI string of the item to select. - */ - internal fun addToSelection(uriString: String) { - _gridSelectedUris.value = _gridSelectedUris.value + uriString - savedStateHandle[KeyGridSelectedUris] = ArrayList(_gridSelectedUris.value) + private fun addToSelection(uriString: String) { + _selectedUris.value += uriString + savedStateHandle[KeySelectedUris] = ArrayList(_selectedUris.value) } /** @@ -230,8 +207,8 @@ public class AttachmentsPickerViewModel( * @param uriString The URI string of the item to deselect. */ internal fun removeFromSelection(uriString: String) { - _gridSelectedUris.value = _gridSelectedUris.value - uriString - savedStateHandle[KeyGridSelectedUris] = ArrayList(_gridSelectedUris.value) + _selectedUris.value -= uriString + savedStateHandle[KeySelectedUris] = ArrayList(_selectedUris.value) } /** @@ -239,22 +216,18 @@ public class AttachmentsPickerViewModel( * (e.g. after a message is sent, a poll is created, or a command is selected). */ internal fun clearSelection() { - _gridSelectedUris.value = emptySet() - savedStateHandle.remove>(KeyGridSelectedUris) + _selectedUris.value = emptySet() + savedStateHandle.remove>(KeySelectedUris) } /** - * Converts the given [metaData] into lightweight [Attachment]s. - * - * File resolution is deferred to send time via [AttachmentStorageHelper.resolveAttachmentFiles]. + * Converts the given [metaData] into lightweight [Attachment]s ready to be staged in the composer. * * @param metaData The metadata items to convert. */ public fun getAttachmentsFromMetadata(metaData: List): List = storageHelper.toAttachments(metaData) - private var loadAttachmentsJob: Job? = null - /** * Loads attachment metadata from device storage for the current [pickerMode]. */ @@ -300,7 +273,7 @@ public class AttachmentsPickerViewModel( } } - private fun clearCachedData() { + private fun resetPickerState() { _pickerMode.value = null savedStateHandle[KeyPickerMode] = null as String? _mediaItems.value = emptyList() @@ -310,7 +283,7 @@ public class AttachmentsPickerViewModel( private const val KeyPickerVisible = "stream_picker_visible" private const val KeyPickerMode = "stream_picker_mode" -private const val KeyGridSelectedUris = "stream_picker_grid_selected_uris" +private const val KeySelectedUris = "stream_selected_uris" /** * Event emitted when system picker URIs have been resolved into [Attachment]s. @@ -323,6 +296,8 @@ public data class SubmittedAttachments( val hasUnsupportedFiles: Boolean, ) +// Custom AttachmentPickerMode implementations cannot be serialized; they save as "unknown" +// and restore as null (i.e. the active tab is not preserved across process death for custom modes). private fun AttachmentPickerMode.toSavedKey(): String = when (this) { is GalleryPickerMode -> "gallery" is FilePickerMode -> "file" diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt index 75cb3ba1ce7..fdb6da40c4d 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt @@ -270,7 +270,6 @@ internal class AttachmentsPickerViewModelTest { assertFalse(viewModel.attachments.first().isSelected) viewModel.setPickerMode(FilePickerMode()) assertFalse(viewModel.attachments.first().isSelected) - assertTrue(viewModel.selectedUris.value.isEmpty()) } @Test @@ -341,7 +340,7 @@ internal class AttachmentsPickerViewModelTest { } @Test - fun `Given same file in both tabs When selecting Should only count once in selectedUris`() { + fun `Given same file in both tabs When selecting Should appear selected in both tabs`() { val viewModel = createViewModel() viewModel.setPickerVisible(visible = true) @@ -356,7 +355,6 @@ internal class AttachmentsPickerViewModelTest { assertTrue(viewModel.attachments.first().isSelected) viewModel.setPickerMode(FilePickerMode()) assertTrue(viewModel.attachments.first().isSelected) - assertEquals(1, viewModel.selectedUris.value.size) } @Test @@ -472,7 +470,7 @@ internal class AttachmentsPickerViewModelTest { */ private fun AttachmentsPickerViewModel.select(item: AttachmentPickerItemState) { val uriString = item.attachmentMetaData.uri?.toString() ?: return - addToSelection(uriString) + selectItem(uriString) } /** From bb35cea7102cd77bb44f12d30185c4e3758c98f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 9 Mar 2026 15:17:50 +0000 Subject: [PATCH 08/22] Move attachment staging ownership to the controller and persist selections across process death --- .../compose/ui/theme/ChatComponentFactory.kt | 2 +- .../messages/MessageComposerViewModel.kt | 116 +++++++----------- .../messages/MessageComposerViewModelTest.kt | 5 +- .../ui/guides/AddingCustomAttachments.java | 2 +- .../java/ui/messages/MessageComposer.java | 7 +- .../compose/guides/AddingCustomAttachments.kt | 2 +- .../compose/messages/MessageComposer.kt | 8 +- .../ui/CustomComposerAndAttachmentsPicker.kt | 13 +- .../ui/guides/AddingCustomAttachments.kt | 2 +- .../kotlin/ui/messages/MessageComposer.kt | 14 +-- .../composer/MessageComposerController.kt | 88 ++++++++----- .../composer/MessageComposerControllerTest.kt | 14 ++- .../api/stream-chat-android-ui-components.api | 5 +- .../messages/MessageComposerViewModel.kt | 15 ++- .../MessageComposerViewModelBinding.kt | 14 +-- .../messages/MessageComposerViewModelTest.kt | 16 +-- .../customattachments/MessagesActivity.kt | 2 +- .../customattachments/MessagesActivity.kt | 2 +- 18 files changed, 163 insertions(+), 164 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt index e1a2e97cc4f..e2715bdf6d0 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt @@ -3828,7 +3828,7 @@ public interface ChatComponentFactory { * * Shows a row of buttons that launch system pickers (photo picker, file browser, camera). * This variant does not require storage permissions since it uses system intents. - * Used when [ChatTheme.attachmentPickerConfig.useSystemPicker] is `true`. + * Used when [ChatTheme.config.attachmentPicker.useSystemPicker] is `true`. * * @param channel Used to check channel capabilities for filtering available modes. * @param messageMode Used to filter modes (e.g., polls disabled in threads). diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt index 2e552deee94..8b3fc0fe111 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt @@ -20,6 +20,7 @@ import android.os.Build import android.os.Bundle import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.ChannelCapabilities import io.getstream.chat.android.models.Command @@ -43,23 +44,15 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach /** * ViewModel responsible for handling the composing and sending of messages. * - * It relays all its core actions to a shared data source, as a central place for all the Composer logic. - * Additionally, all the core data that can be reused across our SDKs is available through shared data sources, while - * implementation-specific data is stored in respective in the [ViewModel]. - * - * This ViewModel is the **single source of truth for the selected attachments** in the current composer - * session. Attachments are kept in an insertion-ordered map keyed by URI string, so the order in which - * the user adds items (gallery picks, camera captures, system picker results) is preserved in the - * composer's attachment list. - * - * The selected attachment state persists across Activity recreation (e.g. "Don't keep activities") - * via [savedStateHandle]. It is cleared by [clearAttachments], which must be called when the - * attachments are consumed (e.g. message sent, poll created, command selected). + * Delegates all state management and business logic to [MessageComposerController]. + * Attachment selections are owned by the controller; this ViewModel persists them + * via [savedStateHandle] so they survive Activity recreation. * * @param messageComposerController The controller used to relay all the actions and fetch all the state. * @param storageHelper Resolves deferred attachment files before sending. @@ -131,24 +124,14 @@ public class MessageComposerViewModel( */ public val ownCapabilities: StateFlow> = messageComposerController.ownCapabilities - /** - * Insertion-ordered map of URI string → [Attachment] for all attachments currently staged for - * the next message. Covers grid picks (gallery / files tab), camera captures, and system picker - * results, interleaved in the order the user added them. - * - * Persisted via [savedStateHandle] so selections survive Activity recreation. - */ - private val _selectedAttachments = MutableStateFlow( - savedStateHandle.get(KeySelectedAttachments) - ?.getBundleList(KeySelectedAttachmentItems) - ?.mapNotNull(Bundle::toUriStringAttachmentPair) - ?.toMap(LinkedHashMap()) - ?: linkedMapOf(), - ) - init { - // Re-sync any attachments restored from saved state into the controller on recreation. - if (_selectedAttachments.value.isNotEmpty()) syncToController() + val initial = restoreAttachments() + if (initial.isNotEmpty()) { + messageComposerController.addAttachments(initial) + } + messageComposerController.selectedAttachments + .onEach(::persistAttachments) + .launchIn(viewModelScope) } /** @@ -189,35 +172,23 @@ public class MessageComposerViewModel( public fun dismissMessageActions(): Unit = messageComposerController.dismissMessageActions() /** - * Adds [attachments] to the staged attachment list, preserving insertion order. + * Adds [attachments] to the staged attachment list. * - * Attachments are keyed by [EXTRA_SOURCE_URI] stored in each attachment's [Attachment.extraData]. - * Duplicate URIs are silently ignored. + * Attachments are keyed by URI string, preserving insertion order. * * @param attachments The attachments to add. */ public fun addAttachments(attachments: List) { - _selectedAttachments.update { current -> - LinkedHashMap(current).also { updated -> - attachments.forEach { attachment -> - attachment.sourceUriString()?.let { updated.putIfAbsent(it, attachment) } - } - } - } - persistAndSync() + messageComposerController.addAttachments(attachments) } /** * Removes [attachment] from the staged attachment list. * - * The attachment is identified by [EXTRA_SOURCE_URI] in its [Attachment.extraData]. - * * @param attachment The attachment to remove. */ public fun removeAttachment(attachment: Attachment) { - val key = attachment.sourceUriString() ?: return - _selectedAttachments.update { LinkedHashMap(it).also { map -> map.remove(key) } } - persistAndSync() + messageComposerController.removeAttachment(attachment) } /** @@ -226,21 +197,17 @@ public class MessageComposerViewModel( * @param uris The URI string keys to remove. */ internal fun removeAttachmentsByUris(uris: Set) { - if (uris.isEmpty()) return - _selectedAttachments.update { current -> - LinkedHashMap(current).also { it.keys.removeAll(uris) } - } - persistAndSync() + messageComposerController.removeAttachmentsByUris(uris) } /** - * Removes all staged attachments. Call this when the attachments are consumed - * (e.g. after a message is sent, a poll is created, or a command is selected). + * Removes all staged attachments. + * + * Call this when the attachments are consumed — for example, after a message is sent, + * a poll is created, or a command is selected. */ public fun clearAttachments() { - _selectedAttachments.value = linkedMapOf() - savedStateHandle.remove(KeySelectedAttachments) - messageComposerController.updateSelectedAttachments(emptyList()) + messageComposerController.clearAttachments() } /** @@ -383,19 +350,24 @@ public class MessageComposerViewModel( messageComposerController.onCleared() } - private fun persistAndSync() { - savedStateHandle[KeySelectedAttachments] = Bundle().apply { - putParcelableArrayList( - KeySelectedAttachmentItems, - ArrayList(_selectedAttachments.value.values.map(Attachment::toBundle)), - ) + private fun persistAttachments(attachments: List) { + if (attachments.isEmpty()) { + savedStateHandle.remove(KeySelectedAttachments) + } else { + savedStateHandle[KeySelectedAttachments] = Bundle().apply { + putParcelableArrayList( + KeySelectedAttachmentItems, + ArrayList(attachments.map(Attachment::toBundle)), + ) + } } - syncToController() } - private fun syncToController() { - messageComposerController.updateSelectedAttachments(_selectedAttachments.value.values.toList()) - } + private fun restoreAttachments(): List = + savedStateHandle.get(KeySelectedAttachments) + ?.getBundleList(KeySelectedAttachmentItems) + ?.mapNotNull(Bundle::toAttachment) + ?: emptyList() } private const val KeySelectedAttachments = "stream_composer_selected_attachments" @@ -407,8 +379,6 @@ private const val KeyBundleFileSize = "fileSize" private const val KeyBundleMimeType = "mimeType" private const val AttachmentBundleSize = 5 -private fun Attachment.sourceUriString(): String? = extraData[EXTRA_SOURCE_URI] as? String - private fun Attachment.toBundle(): Bundle = Bundle(AttachmentBundleSize).apply { sourceUriString()?.let { putString(KeyBundleUri, it) } type?.let { putString(KeyBundleType, it) } @@ -428,16 +398,12 @@ private fun Bundle.toAttachment(): Attachment? { ) } -private fun Bundle.toUriStringAttachmentPair(): Pair? { - val attachment = toAttachment() ?: return null - val uriString = attachment.sourceUriString() ?: return null - return uriString to attachment -} - @Suppress("DEPRECATION") private fun Bundle.getBundleList(key: String): List = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { getParcelableArrayList(key, Bundle::class.java) ?: emptyList() } else { - getParcelableArrayList(key) ?: emptyList() + getParcelableArrayList(key) ?: emptyList() } + +private fun Attachment.sourceUriString(): String? = extraData[EXTRA_SOURCE_URI]?.toString() diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt index 84de5f3c239..1b91d7af015 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt @@ -54,6 +54,7 @@ import io.getstream.chat.android.ui.common.state.messages.ThreadReply import io.getstream.chat.android.ui.common.utils.AttachmentConstants import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.amshove.kluent.`should be equal to` @@ -394,7 +395,9 @@ internal class MessageComposerViewModelTest { @Test fun `Given message composer When startRecording is called Then delegates to controller`() { - val controller: MessageComposerController = mock() + val controller: MessageComposerController = mock { + on { selectedAttachments } doReturn emptyFlow() + } val viewModel = MessageComposerViewModel(controller, mock()) viewModel.startRecording() diff --git a/stream-chat-android-docs/src/main/java/io/getstream/chat/docs/java/ui/guides/AddingCustomAttachments.java b/stream-chat-android-docs/src/main/java/io/getstream/chat/docs/java/ui/guides/AddingCustomAttachments.java index ed98e794fb5..b8aee6e1516 100644 --- a/stream-chat-android-docs/src/main/java/io/getstream/chat/docs/java/ui/guides/AddingCustomAttachments.java +++ b/stream-chat-android-docs/src/main/java/io/getstream/chat/docs/java/ui/guides/AddingCustomAttachments.java @@ -192,7 +192,7 @@ private void setLeadingContent(Context context) { .withType("date") .withExtraData(extraData) .build(); - messageComposerViewModel.addSelectedAttachments(Collections.singletonList(attachment)); + messageComposerViewModel.addAttachments(Collections.singletonList(attachment)); }); // Show the date picker dialog on a click on the calendar button diff --git a/stream-chat-android-docs/src/main/java/io/getstream/chat/docs/java/ui/messages/MessageComposer.java b/stream-chat-android-docs/src/main/java/io/getstream/chat/docs/java/ui/messages/MessageComposer.java index 1796348936d..77a9c7d044e 100644 --- a/stream-chat-android-docs/src/main/java/io/getstream/chat/docs/java/ui/messages/MessageComposer.java +++ b/stream-chat-android-docs/src/main/java/io/getstream/chat/docs/java/ui/messages/MessageComposer.java @@ -31,7 +31,6 @@ import io.getstream.chat.android.ui.common.feature.messages.composer.mention.DefaultUserLookupHandler; import io.getstream.chat.android.ui.common.feature.messages.composer.mention.DefaultUserQueryFilter; import io.getstream.chat.android.ui.common.feature.messages.composer.mention.UserLookupHandler; -import io.getstream.chat.android.ui.common.feature.messages.composer.query.filter.DefaultQueryFilter; import io.getstream.chat.android.ui.common.feature.messages.composer.transliteration.DefaultStreamTransliterator; import io.getstream.chat.android.ui.common.feature.messages.composer.transliteration.StreamTransliterator; import io.getstream.chat.android.ui.common.state.messages.Edit; @@ -321,11 +320,11 @@ public void handlingActions2() { return Unit.INSTANCE; }); messageComposerView.setAttachmentSelectionListener((attachments) -> { - messageComposerViewModel.addSelectedAttachments(attachments); + messageComposerViewModel.addAttachments(attachments); return Unit.INSTANCE; }); messageComposerView.setAttachmentRemovalListener((attachment) -> { - messageComposerViewModel.removeSelectedAttachment(attachment); + messageComposerViewModel.removeAttachment(attachment); return Unit.INSTANCE; }); messageComposerView.setMentionSelectionListener((user) -> { @@ -429,7 +428,7 @@ public void contentCustomization2() { return Unit.INSTANCE; }); centerContent.setAttachmentRemovalListener((attachment -> { - messageComposerViewModel.removeSelectedAttachment(attachment); + messageComposerViewModel.removeAttachment(attachment); return Unit.INSTANCE; })); messageComposerView.setCenterContent(centerContent); diff --git a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/guides/AddingCustomAttachments.kt b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/guides/AddingCustomAttachments.kt index 7fb1018ff88..11f1af30087 100644 --- a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/guides/AddingCustomAttachments.kt +++ b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/guides/AddingCustomAttachments.kt @@ -118,7 +118,7 @@ private object AddingCustomAttachmentsSnippet { ) // 3 - composerViewModel.addSelectedAttachments(listOf(attachment)) + composerViewModel.addAttachments(listOf(attachment)) }, ) } diff --git a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/MessageComposer.kt b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/MessageComposer.kt index 6e182e1e95d..73fd0684eb8 100644 --- a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/MessageComposer.kt +++ b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/compose/messages/MessageComposer.kt @@ -116,7 +116,7 @@ private object MessageComposerHandlingActionsSnippet { viewModel = viewModel, onSendMessage = viewModel::sendMessage, onValueChange = viewModel::setMessageInput, - onAttachmentRemoved = viewModel::removeSelectedAttachment, + onAttachmentRemoved = viewModel::removeAttachment, onCancelAction = viewModel::dismissMessageActions, onUserSelected = viewModel::selectMention, onCommandSelected = viewModel::selectCommand, @@ -239,9 +239,9 @@ private object MessageComposerCustomizationSnippet { .weight(7f) .padding(start = 8.dp), messageComposerState = inputState, - onValueChange = { composerViewModel.setMessageInput(it) }, - onAttachmentRemoved = { composerViewModel.removeSelectedAttachment(it) }, - onCancelAction = { composerViewModel.dismissMessageActions() }, + onValueChange = composerViewModel::setMessageInput, + onAttachmentRemoved = composerViewModel::removeAttachment, + onCancelAction = composerViewModel::dismissMessageActions, onSendClick = onSendClick, recordingActions = AudioRecordingActions.defaultActions( viewModel = composerViewModel, diff --git a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/cookbook/ui/CustomComposerAndAttachmentsPicker.kt b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/cookbook/ui/CustomComposerAndAttachmentsPicker.kt index b9b76077f11..bf14e419ba7 100644 --- a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/cookbook/ui/CustomComposerAndAttachmentsPicker.kt +++ b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/cookbook/ui/CustomComposerAndAttachmentsPicker.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import io.getstream.chat.android.compose.state.messages.attachments.AttachmentPickerItemState import io.getstream.chat.android.compose.state.messages.attachments.AttachmentPickerMode import io.getstream.chat.android.compose.state.messages.attachments.CameraPickerMode import io.getstream.chat.android.compose.state.messages.attachments.FilePickerMode @@ -184,9 +185,9 @@ private fun CustomMessageComposer( } MessageInput( messageComposerState = composerState, - onValueChange = { composerViewModel.setMessageInput(it) }, - onAttachmentRemoved = { composerViewModel.removeSelectedAttachment(it) }, - onCancelAction = { composerViewModel.dismissMessageActions() }, + onValueChange = composerViewModel::setMessageInput, + onAttachmentRemoved = composerViewModel::removeAttachment, + onCancelAction = composerViewModel::dismissMessageActions, onSendClick = onSendClick, recordingActions = AudioRecordingActions.defaultActions( viewModel = composerViewModel, @@ -268,7 +269,11 @@ private fun CustomAttachmentsPicker( isSubmitEnabled = attachmentsPickerViewModel.attachments.any { it.isSelected }, onSubmitClick = { actions.onAttachmentsSelected( - attachmentsPickerViewModel.getSelectedAttachments(), + attachmentsPickerViewModel.getAttachmentsFromMetadata( + attachmentsPickerViewModel.attachments + .filter(AttachmentPickerItemState::isSelected) + .map(AttachmentPickerItemState::attachmentMetaData), + ), ) }, ) diff --git a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/ui/guides/AddingCustomAttachments.kt b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/ui/guides/AddingCustomAttachments.kt index c9bef46230a..c87c4072d5d 100644 --- a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/ui/guides/AddingCustomAttachments.kt +++ b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/ui/guides/AddingCustomAttachments.kt @@ -125,7 +125,7 @@ class AddingCustomAttachments { type = "date", extraData = mutableMapOf("payload" to payload) ) - messageComposerViewModel.addSelectedAttachments(listOf(attachment)) + messageComposerViewModel.addAttachments(listOf(attachment)) } // Show the date picker dialog on a click on the calendar button diff --git a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/ui/messages/MessageComposer.kt b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/ui/messages/MessageComposer.kt index 7c74e15dc4e..de11d51278c 100644 --- a/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/ui/messages/MessageComposer.kt +++ b/stream-chat-android-docs/src/main/kotlin/io/getstream/chat/docs/kotlin/ui/messages/MessageComposer.kt @@ -10,17 +10,14 @@ import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModelProvider import com.google.android.material.datepicker.MaterialDatePicker import io.getstream.chat.android.client.ChatClient -import io.getstream.chat.android.client.ChatClient.Companion.instance import io.getstream.chat.android.client.channel.ChannelClient import io.getstream.chat.android.models.Filters.eq import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.User import io.getstream.chat.android.models.querysort.QuerySortByField.Companion.descByName import io.getstream.chat.android.models.querysort.QuerySorter -import io.getstream.chat.android.ui.common.feature.messages.composer.mention.CompatUserLookupHandler import io.getstream.chat.android.ui.common.feature.messages.composer.mention.DefaultUserLookupHandler import io.getstream.chat.android.ui.common.feature.messages.composer.mention.DefaultUserQueryFilter import io.getstream.chat.android.ui.common.feature.messages.composer.mention.UserLookupHandler @@ -48,10 +45,7 @@ import io.getstream.chat.android.ui.viewmodel.messages.MessageListViewModelFacto import io.getstream.chat.android.ui.viewmodel.messages.bindView import io.getstream.chat.docs.R import io.getstream.chat.docs.databinding.MessageComposerLeadingContentBinding -import io.getstream.result.Result -import io.getstream.result.call.Call import io.getstream.result.call.map -import kotlin.jvm.functions.Function1 /** * [Message Composer](https://getstream.io/chat/docs/sdk/android/ui/message-components/message-composer) @@ -199,10 +193,10 @@ private object MessageComposer : Fragment() { messageComposerViewModel.setMessageInput(text) } messageComposerView.attachmentSelectionListener = { attachments -> - messageComposerViewModel.addSelectedAttachments(attachments) + messageComposerViewModel.addAttachments(attachments) } messageComposerView.attachmentRemovalListener = { attachment -> - messageComposerViewModel.removeSelectedAttachment(attachment) + messageComposerViewModel.removeAttachment(attachment) } messageComposerView.mentionSelectionListener = { user -> messageComposerViewModel.selectMention(user) @@ -294,7 +288,7 @@ private object MessageComposer : Fragment() { messageComposerView.setLeadingContent( DefaultMessageComposerLeadingContent(context).also { it.attachmentsButtonClickListener = { - // Show attachment dialog and invoke messageComposerViewModel.addSelectedAttachments(attachments) + // Show attachment dialog and invoke messageComposerViewModel.addAttachments(attachments) } it.commandsButtonClickListener = { messageComposerViewModel.toggleCommandsVisibility() } } @@ -303,7 +297,7 @@ private object MessageComposer : Fragment() { DefaultMessageComposerCenterContent(context).also { it.textInputChangeListener = { text -> messageComposerViewModel.setMessageInput(text) } it.attachmentRemovalListener = - { attachment -> messageComposerViewModel.removeSelectedAttachment(attachment) } + { attachment -> messageComposerViewModel.removeAttachment(attachment) } } ) messageComposerView.setTrailingContent( diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt index 2579581c51f..0a102a98c15 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt @@ -37,6 +37,7 @@ import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Men import io.getstream.chat.android.ui.common.feature.messages.composer.mention.UserLookupHandler import io.getstream.chat.android.ui.common.feature.messages.composer.typing.TypingSuggester import io.getstream.chat.android.ui.common.feature.messages.composer.typing.TypingSuggestionOptions +import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI import io.getstream.chat.android.ui.common.state.messages.Edit import io.getstream.chat.android.ui.common.state.messages.MessageAction import io.getstream.chat.android.ui.common.state.messages.MessageInput @@ -247,6 +248,16 @@ public class MessageComposerController( */ public val inputFocusEvents: SharedFlow = _inputFocusEvents.asSharedFlow() + // Insertion-ordered map keyed by EXTRA_SOURCE_URI. Tracks picker selections + // independently of edit-mode attachments, so selections survive entering and exiting edit mode. + private val _selectedAttachments = MutableStateFlow(linkedMapOf()) + + /** + * Emits the current list of attachments staged by the picker whenever the selection changes. + * Collected by the ViewModel layer to persist selections across process death. + */ + public val selectedAttachments: Flow> = _selectedAttachments.map { it.values.toList() } + /** Full message composer state holding all the required information. */ public val state: MutableStateFlow = MutableStateFlow(MessageComposerState()) @@ -591,52 +602,62 @@ public class MessageComposerController( public fun dismissMessageActions() { if (isInEditMode) { setMessageInputInternal("", MessageInput.Source.Default) - state.update { it.copy(attachments = emptyList()) } + syncAttachments() } this.messageActions.value = emptySet() } /** - * Updates the selected attachments that are shown within the composer UI. - */ - public fun updateSelectedAttachments(attachments: List) { - state.update { it.copy(attachments = attachments) } - handleValidationErrors() - } - - /** - * Stores the selected attachments from the attachment picker. These will be shown in the UI, - * within the composer component. We upload and send these attachments once the user taps on the - * send button. + * Adds [attachments] to the staged list, preserving insertion order. * - * @param attachments The attachments to store and show in the composer. - */ - public fun addSelectedAttachments(attachments: List) { - logger.d { "[addSelectedAttachments] attachments: $attachments" } - state.update { current -> - val merged = (current.attachments + attachments).distinctBy { - if (it.name != null && it.mimeType?.isNotEmpty() == true) { - it.name - } else { - it + * Attachments are keyed by [EXTRA_SOURCE_URI] from [Attachment.extraData], preserving insertion order. + * If a URI is already present, its value is updated in place without changing its position. + * + * @param attachments The attachments to stage. + */ + public fun addAttachments(attachments: List) { + _selectedAttachments.update { current -> + LinkedHashMap(current).also { updated -> + attachments.forEach { attachment -> + attachment.sourceUriString()?.let { updated[it] = attachment } } } - current.copy(attachments = merged) } - handleValidationErrors() + syncAttachments() } /** - * Removes a selected attachment from the list, when the user taps on the cancel/delete button. + * Removes [attachment] from the staged list. * - * This will update the UI to remove it from the composer component. + * The attachment is identified by [EXTRA_SOURCE_URI] in its [Attachment.extraData]. + * Has no effect if the attachment is not staged. * * @param attachment The attachment to remove. */ - public fun removeSelectedAttachment(attachment: Attachment) { - state.update { it.copy(attachments = it.attachments - attachment) } - handleValidationErrors() + public fun removeAttachment(attachment: Attachment) { + val key = attachment.sourceUriString() ?: return + _selectedAttachments.update { LinkedHashMap(it).also { map -> map.remove(key) } } + syncAttachments() + } + + /** + * Removes all staged attachments whose URI string key is contained in [uris]. + * + * @param uris The URI string keys to remove. + */ + public fun removeAttachmentsByUris(uris: Set) { + if (uris.isEmpty()) return + _selectedAttachments.update { current -> LinkedHashMap(current).also { it.keys.removeAll(uris) } } + syncAttachments() + } + + /** + * Removes all staged attachments and updates the composer state. + */ + public fun clearAttachments() { + _selectedAttachments.value = linkedMapOf() + syncAttachments() } /** @@ -662,7 +683,7 @@ public class MessageComposerController( dismissMessageActions() scope.launch { clearDraftMessage(messageMode.value) } messageInput.value = MessageInput() - state.update { it.copy(attachments = emptyList()) } + clearAttachments() clearActiveCommand() validationErrors.value = emptyList() if (!isInThread) { @@ -857,6 +878,11 @@ public class MessageComposerController( } } + private fun syncAttachments() { + state.update { it.copy(attachments = _selectedAttachments.value.values.toList()) } + handleValidationErrors() + } + /** * Checks the current input for validation errors. */ @@ -1179,3 +1205,5 @@ public class MessageComposerController( linkPreviews.value = emptyList() } } + +private fun Attachment.sourceUriString(): String? = extraData[EXTRA_SOURCE_URI]?.toString() diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt index 75806fe4d7e..2178ac87f17 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt @@ -72,6 +72,7 @@ import org.mockito.kotlin.whenever import java.io.File import java.util.Date +@Suppress("LargeClass") @OptIn(ExperimentalCoroutinesApi::class) internal class MessageComposerControllerTest { @@ -278,7 +279,7 @@ internal class MessageComposerControllerTest { } @Test - fun `Given no attachments When updateSelectedAttachments called Then attachments are set`() = runTest { + fun `Given no attachments When addAttachments called Then attachments are set`() = runTest { // Given val controller = Fixture() .givenAppSettings() @@ -287,13 +288,16 @@ internal class MessageComposerControllerTest { .givenGlobalState() .givenChannelState() .get() - val attachments = listOf(randomAttachment(), randomAttachment()) + val attachments = listOf( + randomAttachment(extraData = mapOf("io.getstream.sourceUri" to "uri:1")), + randomAttachment(extraData = mapOf("io.getstream.sourceUri" to "uri:2")), + ) // When - controller.updateSelectedAttachments(attachments) + controller.addAttachments(attachments) // Then - assertEquals(attachments, controller.state.value.attachments) + assertEquals(attachments.size, controller.state.value.attachments.size) } @Test @@ -519,7 +523,7 @@ internal class MessageComposerControllerTest { val controller = fixture.get() controller.setMessageInput("Hello World") - controller.addSelectedAttachments(listOf(randomAttachment())) + controller.addAttachments(listOf(randomAttachment())) val message = Message(cid = CID, text = "Hello World") val callback: Call.Callback = mock() diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index 77b0dc67f0a..aef47162c62 100644 --- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api +++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api @@ -4511,12 +4511,13 @@ public final class io/getstream/chat/android/ui/viewmodel/mentions/MentionListVi public final class io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel : androidx/lifecycle/ViewModel { public fun (Lio/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController;)V - public final fun addSelectedAttachments (Ljava/util/List;)V + public final fun addAttachments (Ljava/util/List;)V public final fun buildNewMessage ()Lio/getstream/chat/android/models/Message; public final fun buildNewMessage (Ljava/lang/String;)Lio/getstream/chat/android/models/Message; public final fun buildNewMessage (Ljava/lang/String;Ljava/util/List;)Lio/getstream/chat/android/models/Message; public static synthetic fun buildNewMessage$default (Lio/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lio/getstream/chat/android/models/Message; public final fun cancelRecording ()V + public final fun clearAttachments ()V public final fun clearData ()V public final fun completeRecording ()V public final fun createPoll (Lio/getstream/chat/android/models/PollConfig;)V @@ -4535,7 +4536,7 @@ public final class io/getstream/chat/android/ui/viewmodel/messages/MessageCompos public final fun lockRecording ()V public final fun pauseRecording ()V public final fun performMessageAction (Lio/getstream/chat/android/ui/common/state/messages/MessageAction;)V - public final fun removeSelectedAttachment (Lio/getstream/chat/android/models/Attachment;)V + public final fun removeAttachment (Lio/getstream/chat/android/models/Attachment;)V public final fun seekRecordingTo (F)V public final fun selectCommand (Lio/getstream/chat/android/models/Command;)V public final fun selectMention (Lio/getstream/chat/android/models/User;)V diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt index cae9ba5a074..f9715d1e347 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt @@ -144,8 +144,8 @@ public class MessageComposerViewModel( * * @param attachments The attachments to store and show in the composer. */ - public fun addSelectedAttachments(attachments: List): Unit = - messageComposerController.addSelectedAttachments(attachments) + public fun addAttachments(attachments: List): Unit = + messageComposerController.addAttachments(attachments) /** * Removes a selected attachment from the list, when the user taps on the cancel/delete button. @@ -154,8 +154,15 @@ public class MessageComposerViewModel( * * @param attachment The attachment to remove. */ - public fun removeSelectedAttachment(attachment: Attachment): Unit = - messageComposerController.removeSelectedAttachment(attachment) + public fun removeAttachment(attachment: Attachment): Unit = + messageComposerController.removeAttachment(attachment) + + /** + * Removes all staged attachments. + * + * Call this when the attachments are consumed — for example, after a message is sent. + */ + public fun clearAttachments(): Unit = messageComposerController.clearAttachments() public fun createPoll(pollConfig: PollConfig) { messageComposerController.createPoll(pollConfig) diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModelBinding.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModelBinding.kt index 05a7e5ef780..f13ac901a33 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModelBinding.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModelBinding.kt @@ -240,19 +240,11 @@ private infix fun (() -> Unit).and(that: (() -> Unit)?): () -> Unit = when (that } internal object MessageComposerViewModelDefaults { - val MessageComposerViewModel.sendMessageButtonClickListener: (Message) -> Unit - get() = { - sendMessage(it) - } + val MessageComposerViewModel.sendMessageButtonClickListener: (Message) -> Unit get() = ::sendMessage val MessageComposerViewModel.textInputChangeListener: (String) -> Unit get() = { setMessageInput(it) } - val MessageComposerViewModel.attachmentSelectionListener: (List) -> Unit - get() = { - addSelectedAttachments( - it, - ) - } + val MessageComposerViewModel.attachmentSelectionListener: (List) -> Unit get() = ::addAttachments val MessageComposerViewModel.pollSubmissionListener: (PollConfig) -> Unit get() = { createPoll(it) } - val MessageComposerViewModel.attachmentRemovalListener: (Attachment) -> Unit get() = { removeSelectedAttachment(it) } + val MessageComposerViewModel.attachmentRemovalListener: (Attachment) -> Unit get() = ::removeAttachment val MessageComposerViewModel.mentionSelectionListener: (User) -> Unit get() = { selectMention(it) } val MessageComposerViewModel.commandSelectionListener: (Command) -> Unit get() = { selectCommand(it) } val MessageComposerViewModel.alsoSendToChannelSelectionListener: (Boolean) -> Unit get() = { setAlsoSendToChannel(it) } diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/messages/MessageComposerViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/messages/MessageComposerViewModelTest.kt index 40bcd254c45..b398202e887 100644 --- a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/messages/MessageComposerViewModelTest.kt +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/messages/MessageComposerViewModelTest.kt @@ -125,10 +125,10 @@ internal class MessageComposerViewModelTest { .givenSendMessage() .get() - viewModel.addSelectedAttachments( + viewModel.addAttachments( listOf( - Attachment(imageUrl = "url1"), - Attachment(imageUrl = "url2"), + Attachment(imageUrl = "url1", extraData = mapOf("io.getstream.sourceUri" to "uri:1")), + Attachment(imageUrl = "url2", extraData = mapOf("io.getstream.sourceUri" to "uri:2")), ), ) val state = viewModel.messageComposerState.value @@ -160,14 +160,14 @@ internal class MessageComposerViewModelTest { .givenChannelState() .get() - viewModel.addSelectedAttachments( + viewModel.addAttachments( listOf( - Attachment(imageUrl = "url1"), - Attachment(imageUrl = "url2"), + Attachment(imageUrl = "url1", extraData = mapOf("io.getstream.sourceUri" to "uri:1")), + Attachment(imageUrl = "url2", extraData = mapOf("io.getstream.sourceUri" to "uri:2")), ), ) - viewModel.removeSelectedAttachment( - Attachment(imageUrl = "url1"), + viewModel.removeAttachment( + Attachment(imageUrl = "url1", extraData = mapOf("io.getstream.sourceUri" to "uri:1")), ) viewModel.messageComposerState.value.attachments.size `should be equal to` 1 diff --git a/stream-chat-android-ui-guides/src/main/java/io/getstream/chat/android/guides/catalog/compose/customattachments/MessagesActivity.kt b/stream-chat-android-ui-guides/src/main/java/io/getstream/chat/android/guides/catalog/compose/customattachments/MessagesActivity.kt index 126194751d7..584ce39755c 100644 --- a/stream-chat-android-ui-guides/src/main/java/io/getstream/chat/android/guides/catalog/compose/customattachments/MessagesActivity.kt +++ b/stream-chat-android-ui-guides/src/main/java/io/getstream/chat/android/guides/catalog/compose/customattachments/MessagesActivity.kt @@ -134,7 +134,7 @@ class MessagesActivity : AppCompatActivity() { ) // 3 - composerViewModel.addSelectedAttachments(listOf(attachment)) + composerViewModel.addAttachments(listOf(attachment)) }, ) }, diff --git a/stream-chat-android-ui-guides/src/main/java/io/getstream/chat/android/guides/catalog/uicomponents/customattachments/MessagesActivity.kt b/stream-chat-android-ui-guides/src/main/java/io/getstream/chat/android/guides/catalog/uicomponents/customattachments/MessagesActivity.kt index a5004b3d0dc..a2ccd5b9410 100644 --- a/stream-chat-android-ui-guides/src/main/java/io/getstream/chat/android/guides/catalog/uicomponents/customattachments/MessagesActivity.kt +++ b/stream-chat-android-ui-guides/src/main/java/io/getstream/chat/android/guides/catalog/uicomponents/customattachments/MessagesActivity.kt @@ -125,7 +125,7 @@ class MessagesActivity : AppCompatActivity() { type = "date", extraData = mutableMapOf("payload" to payload), ) - messageComposerViewModel.addSelectedAttachments(listOf(attachment)) + messageComposerViewModel.addAttachments(listOf(attachment)) } // Show the date picker dialog at the click of the calendar button From 04b822fde3ffd34baa1fb328650857ab18681f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 9 Mar 2026 16:58:36 +0000 Subject: [PATCH 09/22] Add missed unit tests for attachment management and picker logic in `AttachmentsPickerViewModel`, `MessageComposerController`, and `MessageComposerViewModel`. --- .../AttachmentsPickerViewModelTest.kt | 57 ++++++ .../messages/MessageComposerViewModelTest.kt | 41 ++++ .../composer/MessageComposerControllerTest.kt | 182 ++++++++++++++++++ 3 files changed, 280 insertions(+) diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt index fdb6da40c4d..7979a8f0a09 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt @@ -377,6 +377,63 @@ internal class AttachmentsPickerViewModelTest { assertFalse(viewModel.attachments.first().isSelected) } + @Test + fun `Given single-select mode When a second item is selected Then the first is automatically deselected`() { + val viewModel = createViewModel() + + viewModel.setPickerVisible(visible = true) + viewModel.setPickerMode(GalleryPickerMode(allowMultipleSelection = false)) + viewModel.loadMediaItems(imageAttachment1, imageAttachment2) + viewModel.select(viewModel.attachments.first()) + + viewModel.select(viewModel.attachments.last()) + + assertFalse(viewModel.attachments.first().isSelected) + assertTrue(viewModel.attachments.last().isSelected) + } + + @Test + fun `Given single-select mode When selectItem is called Then returns the previously selected URIs`() { + val viewModel = createViewModel() + + viewModel.setPickerVisible(visible = true) + viewModel.setPickerMode(GalleryPickerMode(allowMultipleSelection = false)) + viewModel.loadMediaItems(imageAttachment1, imageAttachment2) + viewModel.select(viewModel.attachments.first()) + + val replaced = viewModel.selectItem(imageUri2.toString()) + + assertEquals(setOf(imageUri1.toString()), replaced) + } + + @Test + fun `Given multi-select gallery mode When selecting multiple items Then all remain selected`() { + val viewModel = createViewModel() + + viewModel.setPickerVisible(visible = true) + viewModel.setPickerMode(GalleryPickerMode(allowMultipleSelection = true)) + viewModel.loadMediaItems(imageAttachment1, imageAttachment2) + viewModel.select(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.last()) + + assertTrue(viewModel.attachments.first().isSelected) + assertTrue(viewModel.attachments.last().isSelected) + } + + @Test + fun `Given multi-select file mode When selecting multiple items Then all remain selected`() { + val viewModel = createViewModel() + + viewModel.setPickerVisible(visible = true) + viewModel.setPickerMode(FilePickerMode(allowMultipleSelection = true)) + viewModel.loadFileItems(fileAttachment1, fileAttachment2) + viewModel.select(viewModel.attachments.first()) + viewModel.select(viewModel.attachments.last()) + + assertTrue(viewModel.attachments.first().isSelected) + assertTrue(viewModel.attachments.last().isSelected) + } + @Test fun `loadAttachments loads file metadata for FilePickerMode`() = runTest { val expectedMetadata = listOf(fileAttachment1, fileAttachment2) diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt index 1b91d7af015..09dc2da330f 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt @@ -179,6 +179,47 @@ internal class MessageComposerViewModelTest { viewModel.messageComposerState.value.attachments.size `should be equal to` 1 } + @Test + fun `Given staged attachments When removeAttachmentsByUris is called Then matching attachments are removed from state`() = + runTest { + val viewModel = Fixture() + .givenCurrentUser() + .givenChannelQuery() + .givenChannelState() + .get() + + viewModel.addAttachments( + listOf( + Attachment(extraData = mapOf(EXTRA_SOURCE_URI to "content://media/1")), + Attachment(extraData = mapOf(EXTRA_SOURCE_URI to "content://media/2")), + Attachment(extraData = mapOf(EXTRA_SOURCE_URI to "content://media/3")), + ), + ) + viewModel.removeAttachmentsByUris(setOf("content://media/1", "content://media/3")) + + viewModel.messageComposerState.value.attachments.size `should be equal to` 1 + } + + @Test + fun `Given staged attachments When clearAttachments is called Then all attachments are removed from state`() = + runTest { + val viewModel = Fixture() + .givenCurrentUser() + .givenChannelQuery() + .givenChannelState() + .get() + + viewModel.addAttachments( + listOf( + Attachment(extraData = mapOf(EXTRA_SOURCE_URI to "content://media/1")), + Attachment(extraData = mapOf(EXTRA_SOURCE_URI to "content://media/2")), + ), + ) + viewModel.clearAttachments() + + viewModel.messageComposerState.value.attachments.size `should be equal to` 0 + } + @Test fun `Given message composer When checking the also send to channel checkbox Should update the checkbox state`() = runTest { diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt index 2178ac87f17..f6556be857c 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt @@ -43,6 +43,7 @@ import io.getstream.chat.android.test.TestCoroutineExtension import io.getstream.chat.android.test.asCall import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention import io.getstream.chat.android.ui.common.feature.messages.composer.mention.MentionType +import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI import io.getstream.chat.android.ui.common.state.messages.Edit import io.getstream.chat.android.ui.common.state.messages.MessageInput import io.getstream.chat.android.ui.common.state.messages.MessageMode @@ -300,6 +301,187 @@ internal class MessageComposerControllerTest { assertEquals(attachments.size, controller.state.value.attachments.size) } + @Test + fun `Given staged attachments When removeAttachment called Then the matching URI is removed`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val a1 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) + val a2 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:2")) + controller.addAttachments(listOf(a1, a2)) + + // When + controller.removeAttachment(a1) + + // Then + assertEquals(1, controller.state.value.attachments.size) + assertEquals("uri:2", controller.state.value.attachments.first().extraData[EXTRA_SOURCE_URI]) + } + + @Test + fun `Given staged attachments When removeAttachment called with unknown URI Then nothing changes`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val a1 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) + controller.addAttachments(listOf(a1)) + val unknown = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:unknown")) + + // When + controller.removeAttachment(unknown) + + // Then + assertEquals(1, controller.state.value.attachments.size) + } + + @Test + fun `Given staged attachments When removeAttachmentsByUris called Then matching URIs are removed`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val a1 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) + val a2 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:2")) + val a3 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:3")) + controller.addAttachments(listOf(a1, a2, a3)) + + // When + controller.removeAttachmentsByUris(setOf("uri:1", "uri:3")) + + // Then + assertEquals(1, controller.state.value.attachments.size) + assertEquals("uri:2", controller.state.value.attachments.first().extraData[EXTRA_SOURCE_URI]) + } + + @Test + fun `Given staged attachments When removeAttachmentsByUris called with empty set Then nothing changes`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val a1 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) + controller.addAttachments(listOf(a1)) + + // When + controller.removeAttachmentsByUris(emptySet()) + + // Then + assertEquals(1, controller.state.value.attachments.size) + } + + @Test + fun `Given staged attachments When clearAttachments called Then attachments list is empty`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + controller.addAttachments( + listOf( + randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")), + randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:2")), + ), + ) + + // When + controller.clearAttachments() + + // Then + assertTrue(controller.state.value.attachments.isEmpty()) + } + + @Test + fun `Given a staged attachment When addAttachments called with same URI Then value is updated and count stays the same`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val original = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) + val updated = original.copy(type = "updated_type", extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) + controller.addAttachments(listOf(original)) + + // When + controller.addAttachments(listOf(updated)) + + // Then + assertEquals(1, controller.state.value.attachments.size) + assertEquals("updated_type", controller.state.value.attachments.first().type) + } + + @Test + fun `Given attachments added and cleared When selectedAttachments is collected Then it emits the updated lists`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val a1 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) + val a2 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:2")) + + controller.selectedAttachments.test { + awaitItem() // initial empty emission from the underlying StateFlow + + // When + controller.addAttachments(listOf(a1, a2)) + assertEquals(2, awaitItem().size) + + controller.clearAttachments() + assertTrue(awaitItem().isEmpty()) + + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Given picker attachments staged When entering and dismissing edit mode Then picker selections are preserved`() = runTest { + // Given + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val pickerAttachment = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) + controller.addAttachments(listOf(pickerAttachment)) + + // When + controller.performMessageAction(Edit(randomMessage(cid = CID))) + controller.dismissMessageActions() + + // Then + assertEquals(1, controller.state.value.attachments.size) + assertEquals("uri:1", controller.state.value.attachments.first().extraData[EXTRA_SOURCE_URI]) + } + @Test fun `Given a command When selectCommand called Then inputFocusEvents emits`() = runTest { // Given From c1546f8d935fe8e33042b2b9f6df6cec20e30dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 10 Mar 2026 09:20:14 +0000 Subject: [PATCH 10/22] Update `AttachmentMetaData.toAttachment` to include the original URI in attachment metadata. - Map the `uri` from `AttachmentMetaData` to `extraData` using the `EXTRA_SOURCE_URI` key. - Ensure the source URI is preserved when converting metadata to an `Attachment` object. --- .../messages/composer/internal/AttachmentMetaDataMapper.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/internal/AttachmentMetaDataMapper.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/internal/AttachmentMetaDataMapper.kt index d343fb304cd..a9e980ed65a 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/internal/AttachmentMetaDataMapper.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/internal/AttachmentMetaDataMapper.kt @@ -18,11 +18,13 @@ package io.getstream.chat.android.ui.feature.messages.composer.internal import android.content.Context import io.getstream.chat.android.models.Attachment +import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI import io.getstream.chat.android.ui.common.helper.internal.StorageHelper import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData internal fun AttachmentMetaData.toAttachment(context: Context): Attachment { val fileFromUri = StorageHelper().getCachedFileFromUri(context, this) + val extra = uri?.let { mapOf(EXTRA_SOURCE_URI to it.toString()) } ?: emptyMap() return Attachment( upload = fileFromUri, type = type, @@ -30,5 +32,6 @@ internal fun AttachmentMetaData.toAttachment(context: Context): Attachment { fileSize = size.toInt(), mimeType = mimeType, title = title, + extraData = extra, ) } From 6b22b8d6731ec6cdb60c585a46fa2bc8bc6730d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 10 Mar 2026 09:31:47 +0000 Subject: [PATCH 11/22] Disable system attachment picker in XML sample app's so it matches with the composer sample app's --- .../src/main/res/layout/fragment_chat.xml | 1 + .../src/main/res/layout/fragment_chat_preview.xml | 1 + 2 files changed, 2 insertions(+) diff --git a/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_chat.xml b/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_chat.xml index 1bdaf511577..b7d7a973b7f 100644 --- a/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_chat.xml +++ b/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_chat.xml @@ -52,6 +52,7 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/messageListView" + app:streamUiMessageComposerAttachmentsPickerSystemPickerEnabled="false" /> diff --git a/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_chat_preview.xml b/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_chat_preview.xml index d2f8ca47593..bae9b8d3367 100644 --- a/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_chat_preview.xml +++ b/stream-chat-android-ui-components-sample/src/main/res/layout/fragment_chat_preview.xml @@ -53,6 +53,7 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/messageListView" + app:streamUiMessageComposerAttachmentsPickerSystemPickerEnabled="false" tools:visibility="visible" /> From 1e5e5682c5fe729c9720af6504180c06c17cd874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 10 Mar 2026 10:22:55 +0000 Subject: [PATCH 12/22] Update `UserRobot` and `AttachmentsTests` to match with the new UX --- .../chat/android/compose/robots/UserRobot.kt | 19 ++++++------------- .../android/compose/tests/AttachmentsTests.kt | 18 ++++++++++++------ 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt index 4cce4e5ce45..e23f930d0ac 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/robots/UserRobot.kt @@ -335,10 +335,10 @@ class UserRobot { return this } - fun uploadAttachment(type: AttachmentType, multiple: Boolean = false, send: Boolean = true): UserRobot { + fun attachFile(type: AttachmentType, multiple: Boolean = false): UserRobot { val count = if (multiple) 2 else 1 + Composer.attachmentsButton.waitToAppear().click() repeat(count) { - Composer.attachmentsButton.waitToAppear().click() AttachmentPicker.filesTab.waitToAppear().click() AttachmentPicker.findFilesButton.waitToAppear().click() @@ -351,19 +351,12 @@ class UserRobot { .click() } - if (type == AttachmentType.FILE) AttachmentPicker.pdf1 else AttachmentPicker.image1 - - if (it == 0) { - val attachment = if (type == AttachmentType.FILE) AttachmentPicker.pdf1 else AttachmentPicker.image1 - attachment.waitToAppear().click() + val attachment = if (it == 0) { + if (type == AttachmentType.FILE) AttachmentPicker.pdf1 else AttachmentPicker.image1 } else { - val attachment = if (type == AttachmentType.FILE) AttachmentPicker.pdf2 else AttachmentPicker.image2 - attachment.waitToAppear().click() + if (type == AttachmentType.FILE) AttachmentPicker.pdf2 else AttachmentPicker.image2 } - } - - if (send) { - Composer.sendButton.waitToAppear().click() + attachment.waitToAppear().click() } return this diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/AttachmentsTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/AttachmentsTests.kt index 9144aeb9252..1c7aacd0e75 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/AttachmentsTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/AttachmentsTests.kt @@ -39,7 +39,7 @@ class AttachmentsTests : StreamTestCase() { userRobot.login().openChannel() } step("WHEN user attaches an image") { - userRobot.uploadAttachment(type = AttachmentType.IMAGE, send = false) + userRobot.attachFile(type = AttachmentType.IMAGE) } step("THEN image is displayed in preview") { userRobot.assertMediaAttachmentInPreview(isDisplayed = true) @@ -59,7 +59,7 @@ class AttachmentsTests : StreamTestCase() { userRobot.login().openChannel() } step("WHEN user attaches multiple images") { - userRobot.uploadAttachment(type = AttachmentType.IMAGE, multiple = true, send = false) + userRobot.attachFile(type = AttachmentType.IMAGE, multiple = true) } step("THEN images are displayed in preview") { userRobot.assertMediaAttachmentInPreview(isDisplayed = true, count = 2) @@ -79,7 +79,10 @@ class AttachmentsTests : StreamTestCase() { userRobot.login().openChannel() } step("WHEN user sends an image") { - userRobot.uploadAttachment(type = AttachmentType.IMAGE) + userRobot.attachFile(type = AttachmentType.IMAGE) + } + step("AND user sends the image") { + userRobot.tapOnSendButton() } step("AND user deletes an image") { userRobot.deleteMessage() @@ -98,7 +101,7 @@ class AttachmentsTests : StreamTestCase() { userRobot.login().openChannel() } step("WHEN user sends a file") { - userRobot.uploadAttachment(type = AttachmentType.FILE, send = false) + userRobot.attachFile(type = AttachmentType.FILE) } step("THEN file is displayed in preview") { userRobot.assertFileAttachmentInPreview(isDisplayed = true) @@ -118,7 +121,7 @@ class AttachmentsTests : StreamTestCase() { userRobot.login().openChannel() } step("WHEN user attaches multiple files") { - userRobot.uploadAttachment(type = AttachmentType.FILE, multiple = true, send = false) + userRobot.attachFile(type = AttachmentType.FILE, multiple = true) } step("THEN files are displayed in preview") { userRobot.assertFileAttachmentInPreview(isDisplayed = true, count = 2) @@ -138,7 +141,10 @@ class AttachmentsTests : StreamTestCase() { userRobot.login().openChannel() } step("WHEN user sends a file") { - userRobot.uploadAttachment(type = AttachmentType.IMAGE) + userRobot.attachFile(type = AttachmentType.IMAGE) + } + step("AND user sends the file") { + userRobot.tapOnSendButton() } step("AND user deletes a file") { userRobot.deleteMessage() From 8de33c5e788db58916da345e7f1e79aa93cc4698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 13 Mar 2026 11:43:21 +0000 Subject: [PATCH 13/22] Add `ComposerSessionRepository` to persist and restore message composer state across process death --- .../build.gradle.kts | 1 + .../composer/ComposerSessionRepository.kt | 197 ++++++++++++ .../composer/ComposerSessionRepositoryTest.kt | 284 ++++++++++++++++++ 3 files changed, 482 insertions(+) create mode 100644 stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepository.kt create mode 100644 stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepositoryTest.kt diff --git a/stream-chat-android-ui-common/build.gradle.kts b/stream-chat-android-ui-common/build.gradle.kts index c86e9ea25e2..1bebb5ebac3 100644 --- a/stream-chat-android-ui-common/build.gradle.kts +++ b/stream-chat-android-ui-common/build.gradle.kts @@ -80,6 +80,7 @@ dependencies { testRuntimeOnly(libs.junit.jupiter.engine) testRuntimeOnly(libs.junit.vintage.engine) + testImplementation(libs.json) testImplementation(libs.threetenbp) testImplementation(libs.androidx.core.testing) testImplementation(libs.androidx.test.junit) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepository.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepository.kt new file mode 100644 index 00000000000..3944f47125b --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepository.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.ui.common.feature.messages.composer + +import androidx.lifecycle.SavedStateHandle +import io.getstream.chat.android.models.Attachment +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI +import org.json.JSONArray +import org.json.JSONObject + +/** + * Persists and restores the message composer session across process death. + * + * Stores both the picker-selected attachments and the current edit-mode state as a single + * JSON string under one key in the provided [SavedStateHandle]. + */ +internal class ComposerSessionRepository(private val savedStateHandle: SavedStateHandle) { + + /** + * Snapshot of the edit-mode state: the message being edited and its base attachments. + * + * @param message The message currently being edited. + * @param attachments The original attachments of [message]. + */ + data class EditMode(val message: Message, val attachments: List) + + /** + * Saves the current composer session to the [SavedStateHandle]. + * + * Removes the persisted entry when both [selectedAttachments] is empty and [editMode] is null. + * + * @param selectedAttachments Picker selections keyed by source URI. + * @param editMode The current edit-mode state, or `null` if not editing. + */ + fun save(selectedAttachments: List, editMode: EditMode?) { + if (selectedAttachments.isEmpty() && editMode == null) { + savedStateHandle.remove(KEY_SESSION) + return + } + val json = JSONObject() + json.put(KEY_SELECTED_ATTACHMENTS, JSONArray(selectedAttachments.map(::attachmentToJson))) + editMode?.let { mode -> + json.put(KEY_EDIT_MESSAGE, messageToJson(mode.message)) + json.put(KEY_EDIT_ATTACHMENTS, JSONArray(mode.attachments.map(::attachmentToJson))) + } + savedStateHandle[KEY_SESSION] = json.toString() + } + + /** + * Restores the picker-selected attachments from the [SavedStateHandle]. + * + * @return The list of previously selected attachments, or an empty list if none were persisted. + */ + fun restoreSelectedAttachments(): List = + sessionJson()?.optJSONArray(KEY_SELECTED_ATTACHMENTS)?.toAttachmentList() ?: emptyList() + + /** + * Restores the edit-mode state from the [SavedStateHandle]. + * + * @return The previously persisted [EditMode], or `null` if no edit-mode state was saved. + */ + fun restoreEditMode(): EditMode? { + val json = sessionJson() ?: return null + val message = json.optJSONObject(KEY_EDIT_MESSAGE)?.let(::jsonToMessage) ?: return null + val attachments = json.optJSONArray(KEY_EDIT_ATTACHMENTS)?.toAttachmentList() ?: emptyList() + return EditMode(message, attachments) + } + + private fun sessionJson(): JSONObject? { + val str = savedStateHandle.get(KEY_SESSION) ?: return null + return try { JSONObject(str) } catch (_: Exception) { null } + } + + private fun JSONArray.toAttachmentList(): List = + (0 until length()).mapNotNull { jsonToAttachment(getJSONObject(it)) } + + private fun attachmentToJson(attachment: Attachment): JSONObject = JSONObject().apply { + attachment.sourceUriString()?.let { put(KEY_URI, it) } + attachment.type?.let { put(KEY_TYPE, it) } + put(KEY_NAME, attachment.name) + put(KEY_FILE_SIZE, attachment.fileSize) + attachment.mimeType?.let { put(KEY_MIME_TYPE, it) } + attachment.assetUrl?.let { put(KEY_ASSET_URL, it) } + attachment.imageUrl?.let { put(KEY_IMAGE_URL, it) } + attachment.thumbUrl?.let { put(KEY_THUMB_URL, it) } + attachment.title?.let { put(KEY_TITLE, it) } + extraDataToJson(attachment.extraData)?.let { put(KEY_EXTRA_DATA, it) } + } + + private fun jsonToAttachment(json: JSONObject): Attachment? { + val uri = json.optString(KEY_URI).takeIf(String::isNotEmpty) + val assetUrl = json.optString(KEY_ASSET_URL).takeIf(String::isNotEmpty) + val imageUrl = json.optString(KEY_IMAGE_URL).takeIf(String::isNotEmpty) + val thumbUrl = json.optString(KEY_THUMB_URL).takeIf(String::isNotEmpty) + val hasIdentifier = uri != null || assetUrl != null || imageUrl != null || thumbUrl != null + if (!hasIdentifier) return null + val extraData = buildMap { + json.optJSONObject(KEY_EXTRA_DATA)?.let { obj -> + for (key in obj.keys()) put(key, obj.get(key)) + } + if (uri != null) put(EXTRA_SOURCE_URI, uri) + } + return Attachment( + type = json.optString(KEY_TYPE).takeIf(String::isNotEmpty), + name = json.optString(KEY_NAME), + fileSize = json.optInt(KEY_FILE_SIZE), + mimeType = json.optString(KEY_MIME_TYPE).takeIf(String::isNotEmpty), + assetUrl = assetUrl, + imageUrl = imageUrl, + thumbUrl = thumbUrl, + title = json.optString(KEY_TITLE).takeIf(String::isNotEmpty), + extraData = extraData, + ) + } + + private fun messageToJson(message: Message): JSONObject = JSONObject().apply { + put(KEY_MSG_ID, message.id) + put(KEY_MSG_CID, message.cid) + put(KEY_MSG_TEXT, message.text) + message.parentId?.let { put(KEY_MSG_PARENT_ID, it) } + put(KEY_MSG_TYPE, message.type) + } + + private fun jsonToMessage(json: JSONObject): Message? { + val id = json.optString(KEY_MSG_ID).takeIf(String::isNotEmpty) ?: return null + val cid = json.optString(KEY_MSG_CID).takeIf(String::isNotEmpty) ?: return null + return Message( + id = id, + cid = cid, + text = json.optString(KEY_MSG_TEXT), + parentId = json.optString(KEY_MSG_PARENT_ID).takeIf(String::isNotEmpty), + type = json.optString(KEY_MSG_TYPE), + ) + } + + /** + * Serializes [extraData] to a [JSONObject], excluding [EXTRA_SOURCE_URI] (stored separately). + * + * Only primitive types (String, Boolean, Int, Long, Double, Float) are included. + * Non-serializable values are silently skipped. + * + * @return A [JSONObject], or `null` if there are no serializable entries. + */ + private fun extraDataToJson(extraData: Map): JSONObject? { + val json = JSONObject() + for ((key, value) in extraData) { + if (key == EXTRA_SOURCE_URI) continue + when (value) { + is String -> json.put(key, value) + is Boolean -> json.put(key, value) + is Int -> json.put(key, value) + is Long -> json.put(key, value) + is Double -> json.put(key, value) + is Float -> json.put(key, value.toDouble()) + } + } + return if (json.length() > 0) json else null + } + + private fun Attachment.sourceUriString(): String? = extraData[EXTRA_SOURCE_URI]?.toString() +} + +private const val KEY_SESSION = "stream_composer_session" +private const val KEY_SELECTED_ATTACHMENTS = "stream_composer_selected_attachments" +private const val KEY_EDIT_MESSAGE = "stream_composer_edit_message" +private const val KEY_EDIT_ATTACHMENTS = "stream_composer_edit_attachments" + +private const val KEY_URI = "uri" +private const val KEY_TYPE = "type" +private const val KEY_NAME = "name" +private const val KEY_FILE_SIZE = "fileSize" +private const val KEY_MIME_TYPE = "mimeType" +private const val KEY_ASSET_URL = "assetUrl" +private const val KEY_IMAGE_URL = "imageUrl" +private const val KEY_THUMB_URL = "thumbUrl" +private const val KEY_TITLE = "title" +private const val KEY_EXTRA_DATA = "extraData" +private const val KEY_MSG_ID = "msgId" +private const val KEY_MSG_CID = "msgCid" +private const val KEY_MSG_TEXT = "msgText" +private const val KEY_MSG_PARENT_ID = "msgParentId" +private const val KEY_MSG_TYPE = "msgType" diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepositoryTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepositoryTest.kt new file mode 100644 index 00000000000..ed0c0ceb5ba --- /dev/null +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepositoryTest.kt @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.ui.common.feature.messages.composer + +import androidx.lifecycle.SavedStateHandle +import io.getstream.chat.android.models.Attachment +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.randomAttachment +import io.getstream.chat.android.randomCID +import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.randomString +import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +internal class ComposerSessionRepositoryTest { + + @Test + fun `Given nothing saved When restoreSelectedAttachments Then returns empty list`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + + assertTrue(repo.restoreSelectedAttachments().isEmpty()) + } + + @Test + fun `Given picker attachment saved When restoreSelectedAttachments Then all fields are restored`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val attachment = Attachment( + type = randomString(), + name = randomString(), + fileSize = 1024, + mimeType = randomString(), + extraData = mapOf(EXTRA_SOURCE_URI to randomString()), + ) + + repo.save(listOf(attachment), editMode = null) + val restored = repo.restoreSelectedAttachments().first() + + assertEquals(attachment.type, restored.type) + assertEquals(attachment.name, restored.name) + assertEquals(attachment.fileSize, restored.fileSize) + assertEquals(attachment.mimeType, restored.mimeType) + assertEquals(attachment.extraData[EXTRA_SOURCE_URI], restored.extraData[EXTRA_SOURCE_URI]) + } + + @Test + fun `Given multiple picker attachments saved When restoreSelectedAttachments Then all are restored`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val uri1 = randomString() + val uri2 = randomString() + val attachments = listOf( + Attachment(name = randomString(), extraData = mapOf(EXTRA_SOURCE_URI to uri1)), + Attachment(name = randomString(), extraData = mapOf(EXTRA_SOURCE_URI to uri2)), + ) + + repo.save(attachments, editMode = null) + val result = repo.restoreSelectedAttachments() + + assertEquals(2, result.size) + assertEquals(uri1, result[0].extraData[EXTRA_SOURCE_URI]) + assertEquals(uri2, result[1].extraData[EXTRA_SOURCE_URI]) + } + + @Test + fun `Given picker attachment with extra extraData When saved Then EXTRA_SOURCE_URI and other keys are both present after restore`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val uri = randomString() + val customValue = randomString() + val attachment = Attachment( + name = randomString(), + extraData = mapOf(EXTRA_SOURCE_URI to uri, "customKey" to customValue), + ) + + repo.save(listOf(attachment), editMode = null) + val restored = repo.restoreSelectedAttachments().first() + + assertEquals(uri, restored.extraData[EXTRA_SOURCE_URI]) + assertEquals(customValue, restored.extraData["customKey"]) + } + + @Test + fun `Given non-empty state When save called with empty Then restoreSelectedAttachments returns empty`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + repo.save( + listOf(Attachment(extraData = mapOf(EXTRA_SOURCE_URI to randomString()))), + editMode = null, + ) + + repo.save(emptyList(), editMode = null) + + assertTrue(repo.restoreSelectedAttachments().isEmpty()) + } + + @Test + fun `Given nothing saved When restoreEditMode Then returns null`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + + assertNull(repo.restoreEditMode()) + } + + @Test + fun `Given only selected attachments saved When restoreEditMode Then returns null`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + repo.save( + listOf(Attachment(extraData = mapOf(EXTRA_SOURCE_URI to randomString()))), + editMode = null, + ) + + assertNull(repo.restoreEditMode()) + } + + @Test + fun `Given edit mode saved When restoreEditMode Then message fields are restored correctly`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val message = Message( + id = randomString(), + cid = randomCID(), + text = randomString(), + parentId = randomString(), + type = randomString(), + ) + + repo.save(emptyList(), editMode = ComposerSessionRepository.EditMode(message, emptyList())) + val result = repo.restoreEditMode() + + assertEquals(message.id, result?.message?.id) + assertEquals(message.cid, result?.message?.cid) + assertEquals(message.text, result?.message?.text) + assertEquals(message.parentId, result?.message?.parentId) + assertEquals(message.type, result?.message?.type) + } + + @Test + fun `Given message without parentId saved When restoreEditMode Then parentId is null`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val message = randomMessage(parentId = null) + + repo.save(emptyList(), editMode = ComposerSessionRepository.EditMode(message, emptyList())) + + assertNull(repo.restoreEditMode()?.message?.parentId) + } + + @Test + fun `Given edit mode with remote attachment saved When restoreEditMode Then attachment fields are restored`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val attachment = randomAttachment(upload = null) + + repo.save( + emptyList(), + editMode = ComposerSessionRepository.EditMode( + randomMessage(), + listOf(attachment), + ), + ) + val restored = repo.restoreEditMode()!!.attachments.first() + + assertEquals(attachment.type, restored.type) + assertEquals(attachment.name, restored.name) + assertEquals(attachment.fileSize, restored.fileSize) + assertEquals(attachment.mimeType, restored.mimeType) + assertEquals(attachment.assetUrl, restored.assetUrl) + assertEquals(attachment.imageUrl, restored.imageUrl) + assertEquals(attachment.thumbUrl, restored.thumbUrl) + assertEquals(attachment.title, restored.title) + } + + @Test + fun `Given edit mode saved When save called with empty Then restoreEditMode returns null`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + repo.save( + emptyList(), + editMode = ComposerSessionRepository.EditMode(randomMessage(), emptyList()), + ) + + repo.save(emptyList(), editMode = null) + + assertNull(repo.restoreEditMode()) + } + + @Test + fun `Given attachment with string extraData When saved in edit mode Then string value is preserved after restore`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val payload = randomString() + val attachment = randomAttachment( + upload = null, + extraData = mapOf("payload" to payload), + ) + + repo.save( + emptyList(), + editMode = ComposerSessionRepository.EditMode(randomMessage(), listOf(attachment)), + ) + + assertEquals(payload, repo.restoreEditMode()!!.attachments.first().extraData["payload"]) + } + + @Test + fun `Given attachment with all supported primitive types in extraData When saved Then all are preserved after restore`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val attachment = randomAttachment( + upload = null, + extraData = mapOf( + "strKey" to "text", + "boolKey" to true, + "intKey" to 42, + "longKey" to 9_000_000_000L, + "doubleKey" to 3.14, + "floatKey" to 1.5f, + ), + ) + + repo.save( + emptyList(), + editMode = ComposerSessionRepository.EditMode(randomMessage(), listOf(attachment)), + ) + val restored = repo.restoreEditMode()!!.attachments.first().extraData + + assertEquals("text", restored["strKey"]) + assertEquals(true, restored["boolKey"]) + assertEquals(42, restored["intKey"]) + assertEquals(9_000_000_000L, restored["longKey"]) + assertEquals(3.14, (restored["doubleKey"] as Number).toDouble(), 1e-10) + assertEquals(1.5, (restored["floatKey"] as Number).toDouble(), 1e-10) + } + + @Test + fun `Given attachment with non-serializable value in extraData When saved Then non-serializable entry is dropped without error`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val validValue = randomString() + val attachment = randomAttachment( + upload = null, + extraData = mapOf("validKey" to validValue, "objectKey" to object {}), + ) + + repo.save( + emptyList(), + editMode = ComposerSessionRepository.EditMode(randomMessage(), listOf(attachment)), + ) + val restored = repo.restoreEditMode()!!.attachments.first().extraData + + assertEquals(validValue, restored["validKey"]) + assertFalse(restored.containsKey("objectKey")) + } + + @Test + fun `Given both selected attachments and edit mode saved When restored Then both are returned independently`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val pickerUri = randomString() + val pickerAttachment = Attachment(extraData = mapOf(EXTRA_SOURCE_URI to pickerUri)) + val editAttachment = randomAttachment(upload = null) + val editMessage = randomMessage() + + repo.save( + listOf(pickerAttachment), + editMode = ComposerSessionRepository.EditMode(editMessage, listOf(editAttachment)), + ) + + val selectedAttachments = repo.restoreSelectedAttachments() + val editMode = repo.restoreEditMode() + + assertEquals(1, selectedAttachments.size) + assertEquals(pickerUri, selectedAttachments.first().extraData[EXTRA_SOURCE_URI]) + assertEquals(editMessage.id, editMode?.message?.id) + assertEquals(1, editMode?.attachments?.size) + assertEquals(editAttachment.assetUrl, editMode?.attachments?.first()?.assetUrl) + } +} From 1c4c2b2fa8d6f6012435d67f1cf6a148b633b72c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 13 Mar 2026 13:31:55 +0000 Subject: [PATCH 14/22] Move attachment and edit-mode state persistence from `MessageComposerViewModel` to `MessageComposerController` via `SavedStateHandle`. --- .../api/stream-chat-android-compose.api | 3 +- .../messages/MessageComposerViewModel.kt | 81 +------------------ .../messages/MessagesViewModelFactory.kt | 2 +- .../messages/MessageComposerViewModelTest.kt | 5 +- .../composer/MessageComposerController.kt | 77 +++++++++++++++--- .../composer/MessageComposerControllerTest.kt | 74 +++++++++++++++-- .../api/stream-chat-android-ui-components.api | 1 + .../messages/MessageListViewModelFactory.kt | 35 +++++--- 8 files changed, 162 insertions(+), 116 deletions(-) diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index d6980bcff8f..30fbe678ec9 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -4366,8 +4366,7 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/AudioPla public final class io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel : androidx/lifecycle/ViewModel { public static final field $stable I - public fun (Lio/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController;Lio/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper;Landroidx/lifecycle/SavedStateHandle;)V - public synthetic fun (Lio/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController;Lio/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper;Landroidx/lifecycle/SavedStateHandle;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController;Lio/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper;)V public final fun addAttachments (Ljava/util/List;)V public final fun buildNewMessage (Ljava/lang/String;Ljava/util/List;)Lio/getstream/chat/android/models/Message; public static synthetic fun buildNewMessage$default (Lio/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lio/getstream/chat/android/models/Message; diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt index 8b3fc0fe111..ad4498de819 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt @@ -16,11 +16,7 @@ package io.getstream.chat.android.compose.viewmodel.messages -import android.os.Build -import android.os.Bundle -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.ChannelCapabilities import io.getstream.chat.android.models.Command @@ -31,7 +27,6 @@ import io.getstream.chat.android.models.User import io.getstream.chat.android.ui.common.feature.messages.composer.MessageComposerController import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper -import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI import io.getstream.chat.android.ui.common.state.messages.Edit import io.getstream.chat.android.ui.common.state.messages.MessageAction import io.getstream.chat.android.ui.common.state.messages.MessageMode @@ -44,24 +39,19 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach /** * ViewModel responsible for handling the composing and sending of messages. * - * Delegates all state management and business logic to [MessageComposerController]. - * Attachment selections are owned by the controller; this ViewModel persists them - * via [savedStateHandle] so they survive Activity recreation. + * Delegates all state management and business logic to [MessageComposerController], + * including persistence of picker selections and edit-mode state across process death. * * @param messageComposerController The controller used to relay all the actions and fetch all the state. * @param storageHelper Resolves deferred attachment files before sending. - * @param savedStateHandle Persists selected attachment state across Activity recreation. */ public class MessageComposerViewModel( private val messageComposerController: MessageComposerController, private val storageHelper: AttachmentStorageHelper, - private val savedStateHandle: SavedStateHandle = SavedStateHandle(), ) : ViewModel() { /** @@ -124,16 +114,6 @@ public class MessageComposerViewModel( */ public val ownCapabilities: StateFlow> = messageComposerController.ownCapabilities - init { - val initial = restoreAttachments() - if (initial.isNotEmpty()) { - messageComposerController.addAttachments(initial) - } - messageComposerController.selectedAttachments - .onEach(::persistAttachments) - .launchIn(viewModelScope) - } - /** * Called when the input changes and the internal state needs to be updated. * @@ -349,61 +329,4 @@ public class MessageComposerViewModel( super.onCleared() messageComposerController.onCleared() } - - private fun persistAttachments(attachments: List) { - if (attachments.isEmpty()) { - savedStateHandle.remove(KeySelectedAttachments) - } else { - savedStateHandle[KeySelectedAttachments] = Bundle().apply { - putParcelableArrayList( - KeySelectedAttachmentItems, - ArrayList(attachments.map(Attachment::toBundle)), - ) - } - } - } - - private fun restoreAttachments(): List = - savedStateHandle.get(KeySelectedAttachments) - ?.getBundleList(KeySelectedAttachmentItems) - ?.mapNotNull(Bundle::toAttachment) - ?: emptyList() -} - -private const val KeySelectedAttachments = "stream_composer_selected_attachments" -private const val KeySelectedAttachmentItems = "stream_composer_selected_attachment_items" -private const val KeyBundleUri = "uri" -private const val KeyBundleType = "type" -private const val KeyBundleName = "name" -private const val KeyBundleFileSize = "fileSize" -private const val KeyBundleMimeType = "mimeType" -private const val AttachmentBundleSize = 5 - -private fun Attachment.toBundle(): Bundle = Bundle(AttachmentBundleSize).apply { - sourceUriString()?.let { putString(KeyBundleUri, it) } - type?.let { putString(KeyBundleType, it) } - putString(KeyBundleName, name) - putInt(KeyBundleFileSize, fileSize) - mimeType?.let { putString(KeyBundleMimeType, it) } -} - -private fun Bundle.toAttachment(): Attachment? { - val uri = getString(KeyBundleUri) ?: return null - return Attachment( - type = getString(KeyBundleType), - name = getString(KeyBundleName) ?: "", - fileSize = getInt(KeyBundleFileSize), - mimeType = getString(KeyBundleMimeType), - extraData = mapOf(EXTRA_SOURCE_URI to uri), - ) } - -@Suppress("DEPRECATION") -private fun Bundle.getBundleList(key: String): List = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - getParcelableArrayList(key, Bundle::class.java) ?: emptyList() - } else { - getParcelableArrayList(key) ?: emptyList() - } - -private fun Attachment.sourceUriString(): String? = extraData[EXTRA_SOURCE_URI]?.toString() diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt index eb02b308ece..19087aaf531 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt @@ -168,9 +168,9 @@ public class MessagesViewModelFactory( isDraftMessageEnabled = isComposerDraftMessageEnabled, isActiveCommandEnabled = true, ), + savedStateHandle = savedStateHandle ?: SavedStateHandle(), ), storageHelper = storageHelper, - savedStateHandle = savedStateHandle ?: SavedStateHandle(), ) MessageListViewModel::class.java -> MessageListViewModel( MessageListController( diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt index 09dc2da330f..e7c4861a124 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt @@ -54,7 +54,6 @@ import io.getstream.chat.android.ui.common.state.messages.ThreadReply import io.getstream.chat.android.ui.common.utils.AttachmentConstants import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.amshove.kluent.`should be equal to` @@ -436,9 +435,7 @@ internal class MessageComposerViewModelTest { @Test fun `Given message composer When startRecording is called Then delegates to controller`() { - val controller: MessageComposerController = mock { - on { selectedAttachments } doReturn emptyFlow() - } + val controller: MessageComposerController = mock() val viewModel = MessageComposerViewModel(controller, mock()) viewModel.startRecording() diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt index 0a102a98c15..17ae0bca8f8 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.ui.common.feature.messages.composer +import androidx.lifecycle.SavedStateHandle import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.state.GlobalState import io.getstream.chat.android.client.api.state.globalStateFlow @@ -108,6 +109,8 @@ import java.util.regex.Pattern * @param fileToUri The function used to convert a file to a URI. * @param config The configuration for the message composer. * @param globalState A flow emitting the current [GlobalState]. + * @param savedStateHandle Handle used to persist and restore picker selections and edit-mode state + * across process death (e.g. caused by opening the system file picker while editing a message). */ @OptIn(ExperimentalCoroutinesApi::class) @InternalStreamChatApi @@ -121,6 +124,7 @@ public class MessageComposerController( fileToUri: (File) -> String, private val config: Config = Config(), private val globalState: Flow = chatClient.globalStateFlow, + savedStateHandle: SavedStateHandle = SavedStateHandle(), ) { private val channelType = channelCid.cidToTypeAndId().first @@ -252,11 +256,13 @@ public class MessageComposerController( // independently of edit-mode attachments, so selections survive entering and exiting edit mode. private val _selectedAttachments = MutableStateFlow(linkedMapOf()) - /** - * Emits the current list of attachments staged by the picker whenever the selection changes. - * Collected by the ViewModel layer to persist selections across process death. - */ - public val selectedAttachments: Flow> = _selectedAttachments.map { it.values.toList() } + // Holds the base attachments from the message being edited. Cleared when edit mode is dismissed. + private val _editModeAttachments = MutableStateFlow>(emptyList()) + + // Holds the message being edited, or null when not in edit mode. + private val _editModeMessage = MutableStateFlow(null) + + private val sessionRepository = ComposerSessionRepository(savedStateHandle) /** Full message composer state holding all the required information. */ public val state: MutableStateFlow = MutableStateFlow(MessageComposerState()) @@ -399,6 +405,8 @@ public class MessageComposerController( }.launchIn(scope) setupComposerState() + restoreSession() + observeSessionChanges() } /** @@ -500,6 +508,38 @@ public class MessageComposerController( } } + private fun restoreSession() { + val restoredAttachments = sessionRepository.restoreSelectedAttachments() + if (restoredAttachments.isNotEmpty()) { + addAttachments(restoredAttachments) + } + sessionRepository.restoreEditMode()?.let { editMode -> + restoreEditMode(editMode.message, editMode.attachments) + } + } + + private fun observeSessionChanges() { + combine( + _selectedAttachments, + _editModeMessage, + _editModeAttachments, + ) { selected, editMessage, editAttachments -> + Triple(selected, editMessage, editAttachments) + }.onEach { (selected, editMessage, editAttachments) -> + sessionRepository.save( + selectedAttachments = selected.values.toList(), + editMode = editMessage?.let { ComposerSessionRepository.EditMode(it, editAttachments) }, + ) + }.launchIn(scope) + } + + private fun restoreEditMode(message: Message, attachments: List) { + _editModeMessage.value = message + _editModeAttachments.value = attachments + messageActions.value += Edit(message) + syncAttachments() + } + /** * Called when the input changes and the internal state needs to be updated. * @@ -588,8 +628,10 @@ public class MessageComposerController( is Edit -> { setMessageInputInternal(messageAction.message.text, MessageInput.Source.Edit) - state.update { it.copy(attachments = messageAction.message.attachments) } - messageActions.value = messageActions.value + messageAction + _editModeMessage.value = messageAction.message + _editModeAttachments.value = messageAction.message.attachments + messageActions.value += messageAction + syncAttachments() } else -> Unit @@ -602,6 +644,8 @@ public class MessageComposerController( public fun dismissMessageActions() { if (isInEditMode) { setMessageInputInternal("", MessageInput.Source.Default) + _editModeMessage.value = null + _editModeAttachments.value = emptyList() syncAttachments() } @@ -630,14 +674,19 @@ public class MessageComposerController( /** * Removes [attachment] from the staged list. * - * The attachment is identified by [EXTRA_SOURCE_URI] in its [Attachment.extraData]. - * Has no effect if the attachment is not staged. + * If the attachment carries a [EXTRA_SOURCE_URI] in its [Attachment.extraData] it is removed from + * the picker selection ([_selectedAttachments]); otherwise it is removed from the edit-mode base + * attachments ([_editModeAttachments]). Has no effect if the attachment is not present in either list. * * @param attachment The attachment to remove. */ public fun removeAttachment(attachment: Attachment) { - val key = attachment.sourceUriString() ?: return - _selectedAttachments.update { LinkedHashMap(it).also { map -> map.remove(key) } } + val key = attachment.sourceUriString() + if (key != null) { + _selectedAttachments.update { LinkedHashMap(it).also { map -> map.remove(key) } } + } else { + _editModeAttachments.update { it.filterNot(attachment::equals) } + } syncAttachments() } @@ -654,8 +703,10 @@ public class MessageComposerController( /** * Removes all staged attachments and updates the composer state. + * Clears both picker selections and edit-mode base attachments. */ public fun clearAttachments() { + _editModeAttachments.value = emptyList() _selectedAttachments.value = linkedMapOf() syncAttachments() } @@ -879,7 +930,9 @@ public class MessageComposerController( } private fun syncAttachments() { - state.update { it.copy(attachments = _selectedAttachments.value.values.toList()) } + state.update { + it.copy(attachments = _editModeAttachments.value + _selectedAttachments.value.values.toList()) + } handleValidationErrors() } diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt index f6556be857c..65b22f4c1fa 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt @@ -434,7 +434,7 @@ internal class MessageComposerControllerTest { } @Test - fun `Given attachments added and cleared When selectedAttachments is collected Then it emits the updated lists`() = runTest { + fun `Given attachments added and cleared When state is collected Then it reflects the updated attachments`() = runTest { // Given val controller = Fixture() .givenAppSettings() @@ -446,15 +446,15 @@ internal class MessageComposerControllerTest { val a1 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) val a2 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:2")) - controller.selectedAttachments.test { - awaitItem() // initial empty emission from the underlying StateFlow + controller.state.test { + awaitItem() // initial empty emission // When controller.addAttachments(listOf(a1, a2)) - assertEquals(2, awaitItem().size) + assertEquals(2, awaitItem().attachments.size) controller.clearAttachments() - assertTrue(awaitItem().isEmpty()) + assertTrue(awaitItem().attachments.isEmpty()) cancelAndIgnoreRemainingEvents() } @@ -482,6 +482,70 @@ internal class MessageComposerControllerTest { assertEquals("uri:1", controller.state.value.attachments.first().extraData[EXTRA_SOURCE_URI]) } + @Test + fun `Given edit mode with remote attachment When add picker attachment Then both are staged`() = runTest { + // Given + val remoteAttachment = randomAttachment() + val pickerAttachment = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:picker")) + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + controller.performMessageAction(Edit(randomMessage(cid = CID, attachments = listOf(remoteAttachment)))) + + // When + controller.addAttachments(listOf(pickerAttachment)) + + // Then + assertEquals(2, controller.state.value.attachments.size) + } + + @Test + fun `Given edit mode with remote attachment When remove remote attachment Then it is removed`() = runTest { + // Given + val remoteAttachment = randomAttachment() + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + controller.performMessageAction(Edit(randomMessage(cid = CID, attachments = listOf(remoteAttachment)))) + + // When + controller.removeAttachment(remoteAttachment) + + // Then + assertTrue(controller.state.value.attachments.isEmpty()) + } + + @Test + fun `Given edit mode When dismiss Then edit attachments are cleared and picker selections are preserved`() = runTest { + // Given + val remoteAttachment = randomAttachment() + val pickerAttachment = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:picker")) + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + controller.addAttachments(listOf(pickerAttachment)) + controller.performMessageAction(Edit(randomMessage(cid = CID, attachments = listOf(remoteAttachment)))) + + // When + controller.dismissMessageActions() + + // Then + assertEquals(1, controller.state.value.attachments.size) + assertEquals("uri:picker", controller.state.value.attachments.first().extraData[EXTRA_SOURCE_URI]) + } + @Test fun `Given a command When selectCommand called Then inputFocusEvents emits`() = runTest { // Given diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index aef47162c62..ef09e391a2e 100644 --- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api +++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api @@ -5142,6 +5142,7 @@ public final class io/getstream/chat/android/ui/viewmodel/messages/MessageListVi public fun (Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/client/setup/state/ClientState;Lio/getstream/sdk/chat/audio/recording/StreamMediaRecorder;Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/UserLookupHandler;Lkotlin/jvm/functions/Function1;IZIZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFooterVisibility;Lio/getstream/chat/android/ui/common/feature/messages/list/DateSeparatorHandler;Lio/getstream/chat/android/ui/common/feature/messages/list/DateSeparatorHandler;Lio/getstream/chat/android/ui/common/feature/messages/list/MessagePositionHandler;ZZZZ)V public synthetic fun (Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/client/setup/state/ClientState;Lio/getstream/sdk/chat/audio/recording/StreamMediaRecorder;Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/UserLookupHandler;Lkotlin/jvm/functions/Function1;IZIZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFooterVisibility;Lio/getstream/chat/android/ui/common/feature/messages/list/DateSeparatorHandler;Lio/getstream/chat/android/ui/common/feature/messages/list/DateSeparatorHandler;Lio/getstream/chat/android/ui/common/feature/messages/list/MessagePositionHandler;ZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; + public fun create (Ljava/lang/Class;Landroidx/lifecycle/viewmodel/CreationExtras;)Landroidx/lifecycle/ViewModel; } public final class io/getstream/chat/android/ui/viewmodel/messages/MessageListViewModelFactory$Builder { diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageListViewModelFactory.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageListViewModelFactory.kt index 6d791780e9f..a2b4224567d 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageListViewModelFactory.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageListViewModelFactory.kt @@ -18,8 +18,11 @@ package io.getstream.chat.android.ui.viewmodel.messages import android.content.Context import androidx.core.net.toUri +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.CreationExtras import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.state.watchChannelAsState import io.getstream.chat.android.client.channel.state.ChannelState @@ -107,10 +110,16 @@ public class MessageListViewModelFactory @JvmOverloads constructor( ) } - private val factories: Map, () -> ViewModel> = mapOf( - MessageListHeaderViewModel::class.java to { MessageListHeaderViewModel(cid, messageId = messageId) }, - MessageListViewModel::class.java to { - MessageListViewModel( + override fun create(modelClass: Class, extras: CreationExtras): T = + createInternal(modelClass, savedStateHandle = extras.createSavedStateHandle()) + + override fun create(modelClass: Class): T = + createInternal(modelClass, savedStateHandle = SavedStateHandle()) + + private fun createInternal(modelClass: Class, savedStateHandle: SavedStateHandle): T { + val viewModel: ViewModel = when (modelClass) { + MessageListHeaderViewModel::class.java -> MessageListHeaderViewModel(cid, messageId = messageId) + MessageListViewModel::class.java -> MessageListViewModel( messageListController = MessageListController( cid = cid, clipboardHandler = {}, @@ -133,9 +142,7 @@ public class MessageListViewModelFactory @JvmOverloads constructor( ), chatClient = chatClient, ) - }, - MessageComposerViewModel::class.java to { - MessageComposerViewModel( + MessageComposerViewModel::class.java -> MessageComposerViewModel( MessageComposerController( channelCid = cid, chatClient = chatClient, @@ -147,14 +154,16 @@ public class MessageListViewModelFactory @JvmOverloads constructor( maxAttachmentCount = maxAttachmentCount, isDraftMessageEnabled = isComposerDraftMessagesEnabled, ), + savedStateHandle = savedStateHandle, ), ) - }, - ) - - override fun create(modelClass: Class): T { - val viewModel: ViewModel = factories[modelClass]?.invoke() - ?: throw IllegalArgumentException("MessageListViewModelFactory can only create instances of the following classes: ${factories.keys.joinToString { it.simpleName }}") + else -> throw IllegalArgumentException( + "MessageListViewModelFactory can only create instances of the following classes: " + + "${MessageListHeaderViewModel::class.java.simpleName}, " + + "${MessageListViewModel::class.java.simpleName}, or " + + "${MessageComposerViewModel::class.java.simpleName}.", + ) + } @Suppress("UNCHECKED_CAST") return viewModel as T From 086546e094f787b302b30a68e63ddfaba5155b50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 13 Mar 2026 15:27:55 +0000 Subject: [PATCH 15/22] Improve attachment management in `MessageComposerController` - Introduce a dedicated state for audio recording attachments to ensure they are properly tracked and synced. - Implement a fallback attachment key using name, MIME type, and file size when a source URI is missing. - Update `removeAttachment` and `clearAttachments` to correctly handle picker selections, edit-mode attachments, and recording attachments. - Ensure all attachment types are preserved and correctly merged during synchronization. --- .../composer/MessageComposerController.kt | 64 ++++++---- .../composer/MessageComposerControllerTest.kt | 114 ++++++++++++++---- 2 files changed, 132 insertions(+), 46 deletions(-) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt index 17ae0bca8f8..ef5cffd6302 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt @@ -252,8 +252,9 @@ public class MessageComposerController( */ public val inputFocusEvents: SharedFlow = _inputFocusEvents.asSharedFlow() - // Insertion-ordered map keyed by EXTRA_SOURCE_URI. Tracks picker selections - // independently of edit-mode attachments, so selections survive entering and exiting edit mode. + // Insertion-ordered map keyed by attachment key (EXTRA_SOURCE_URI or fallback). + // Tracks picker selections independently of edit-mode attachments, so selections + // survive entering and exiting edit mode. private val _selectedAttachments = MutableStateFlow(linkedMapOf()) // Holds the base attachments from the message being edited. Cleared when edit mode is dismissed. @@ -262,6 +263,10 @@ public class MessageComposerController( // Holds the message being edited, or null when not in edit mode. private val _editModeMessage = MutableStateFlow(null) + // Holds the attachment produced by a completed audio recording, if any. + // Cleared when the composer is reset (e.g. after sending). + private val _recordingAttachment = MutableStateFlow(null) + private val sessionRepository = ComposerSessionRepository(savedStateHandle) /** Full message composer state holding all the required information. */ @@ -478,13 +483,11 @@ public class MessageComposerController( audioRecordingController.recordingState.onEach { recording -> logger.d { "[onRecordingState] recording: $recording" } - state.update { - if (recording is RecordingState.Complete) { - it.copy(recording = recording, attachments = it.attachments + recording.attachment) - } else { - it.copy(recording = recording) - } + if (recording is RecordingState.Complete) { + _recordingAttachment.value = recording.attachment } + state.update { it.copy(recording = recording) } + syncAttachments() }.launchIn(scope) if (config.isDraftMessageEnabled) { @@ -655,8 +658,9 @@ public class MessageComposerController( /** * Adds [attachments] to the staged list, preserving insertion order. * - * Attachments are keyed by [EXTRA_SOURCE_URI] from [Attachment.extraData], preserving insertion order. - * If a URI is already present, its value is updated in place without changing its position. + * Each attachment is keyed by its [EXTRA_SOURCE_URI] if present, or by a deterministic + * fallback derived from [Attachment.name] and [Attachment.mimeType]. If a key is already + * present, its value is updated in place without changing its position. * * @param attachments The attachments to stage. */ @@ -664,7 +668,7 @@ public class MessageComposerController( _selectedAttachments.update { current -> LinkedHashMap(current).also { updated -> attachments.forEach { attachment -> - attachment.sourceUriString()?.let { updated[it] = attachment } + updated[attachment.attachmentKey()] = attachment } } } @@ -674,18 +678,23 @@ public class MessageComposerController( /** * Removes [attachment] from the staged list. * - * If the attachment carries a [EXTRA_SOURCE_URI] in its [Attachment.extraData] it is removed from - * the picker selection ([_selectedAttachments]); otherwise it is removed from the edit-mode base - * attachments ([_editModeAttachments]). Has no effect if the attachment is not present in either list. + * Searches picker selections first (by key), then edit-mode attachments, then the recording + * attachment. Removes from the first list that contains it. * * @param attachment The attachment to remove. */ public fun removeAttachment(attachment: Attachment) { - val key = attachment.sourceUriString() - if (key != null) { - _selectedAttachments.update { LinkedHashMap(it).also { map -> map.remove(key) } } - } else { - _editModeAttachments.update { it.filterNot(attachment::equals) } + val key = attachment.attachmentKey() + when { + _selectedAttachments.value.containsKey(key) -> { + _selectedAttachments.update { LinkedHashMap(it).also { map -> map.remove(key) } } + } + _editModeAttachments.value.any(attachment::equals) -> { + _editModeAttachments.update { it.filterNot(attachment::equals) } + } + _recordingAttachment.value == attachment -> { + _recordingAttachment.value = null + } } syncAttachments() } @@ -703,11 +712,12 @@ public class MessageComposerController( /** * Removes all staged attachments and updates the composer state. - * Clears both picker selections and edit-mode base attachments. + * Clears picker selections, edit-mode base attachments, and any completed recording attachment. */ public fun clearAttachments() { _editModeAttachments.value = emptyList() _selectedAttachments.value = linkedMapOf() + _recordingAttachment.value = null syncAttachments() } @@ -931,7 +941,11 @@ public class MessageComposerController( private fun syncAttachments() { state.update { - it.copy(attachments = _editModeAttachments.value + _selectedAttachments.value.values.toList()) + it.copy( + attachments = _editModeAttachments.value + + _selectedAttachments.value.values.toList() + + listOfNotNull(_recordingAttachment.value), + ) } handleValidationErrors() } @@ -1078,8 +1092,9 @@ public class MessageComposerController( public fun sendRecording() { scope.launch { audioRecordingController.completeRecordingSync().onSuccess { recording -> - val attachments = state.value.attachments + recording - sendMessage(buildNewMessage(messageInput.value.text, attachments), callback = {}) + _recordingAttachment.value = recording + syncAttachments() + sendMessage(buildNewMessage(messageInput.value.text, state.value.attachments), callback = {}) } } } @@ -1260,3 +1275,6 @@ public class MessageComposerController( } private fun Attachment.sourceUriString(): String? = extraData[EXTRA_SOURCE_URI]?.toString() + +private fun Attachment.attachmentKey(): String = + sourceUriString() ?: "fallback:${name.orEmpty()}:${mimeType.orEmpty()}:$fileSize" diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt index 65b22f4c1fa..63fcbd2aac5 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt @@ -716,8 +716,6 @@ internal class MessageComposerControllerTest { assertEquals(null, controller.state.value.activeCommand) } - // region sendMessage tests - @Test fun `Given normal mode When sendMessage called Then chatClient sendMessage is invoked with correct message`() = runTest { // Given @@ -1032,26 +1030,6 @@ internal class MessageComposerControllerTest { assertEquals("Fixed content", messageCaptor.firstValue.text) } - // endregion - - // region loadNewestMessages tests - // - // Why we verify `inheritScope` instead of `queryChannel`: - // - // The `loadNewestMessages` extension function internally uses `logic.channel().watch()` which - // relies on the StatePlugin's LogicRegistry - a complex internal dependency that cannot be - // easily mocked in unit tests. However, `loadNewestMessages` creates a `CoroutineCall` using - // `chatClient.inheritScope()` as its very first operation: - // - // fun ChatClient.loadNewestMessages(...): Call { - // return CoroutineCall(inheritScope { Job(it) }) { ... } - // } - // - // Therefore, verifying whether `inheritScope` was called serves as a reliable proxy to confirm - // whether `loadNewestMessages` was invoked, without needing to mock the entire StatePlugin - // infrastructure. - // - @Test fun `Given endOfNewerMessages is true When sendMessage called Then loadNewestMessages is not called`() = runTest { // Given @@ -1130,7 +1108,97 @@ internal class MessageComposerControllerTest { verify(fixture.chatClient).inheritScope(any()) } - // endregion + @Test + fun `Given URI-less attachments When addAttachments called Then all attachments are staged`() = runTest { + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val a1 = randomAttachment(name = "location.pin", type = "location") + val a2 = randomAttachment(name = "card.custom", type = "custom") + + controller.addAttachments(listOf(a1, a2)) + + assertEquals(2, controller.state.value.attachments.size) + } + + @Test + fun `Given URI-less attachment staged When removeAttachment called Then it is removed by fallback key`() = runTest { + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val attachment = randomAttachment(name = "location.pin", type = "location") + controller.addAttachments(listOf(attachment)) + + controller.removeAttachment(attachment) + + assertTrue(controller.state.value.attachments.isEmpty()) + } + + @Test + fun `Given edit-mode attachment with URI When removeAttachment called Then edit-mode list is checked before picker`() = + runTest { + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val remoteAttachment = randomAttachment() + controller.performMessageAction(Edit(randomMessage(cid = CID, attachments = listOf(remoteAttachment)))) + + controller.removeAttachment(remoteAttachment) + + assertTrue(controller.state.value.attachments.isEmpty()) + } + + @Test + fun `Given picker attachments When syncAttachments called after recording completes Then recording attachment is preserved`() = + runTest { + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val pickerAttachment = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) + controller.addAttachments(listOf(pickerAttachment)) + assertEquals(1, controller.state.value.attachments.size) + + // Simulate recording completion by adding another picker attachment + // (which triggers syncAttachments) — the existing attachment should survive + val pickerAttachment2 = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:2")) + controller.addAttachments(listOf(pickerAttachment2)) + + assertEquals(2, controller.state.value.attachments.size) + } + + @Test + fun `Given clearAttachments called When recording was completed Then recording attachment is also cleared`() = + runTest { + val controller = Fixture() + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + val pickerAttachment = randomAttachment(extraData = mapOf(EXTRA_SOURCE_URI to "uri:1")) + controller.addAttachments(listOf(pickerAttachment)) + + controller.clearAttachments() + + assertTrue(controller.state.value.attachments.isEmpty()) + } /** * Custom test implementation of [Mention] for testing purposes. From c4628eaedf2ed6aa3995119a598ae1a5c0c5e2b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 13 Mar 2026 15:35:05 +0000 Subject: [PATCH 16/22] Remove unnecessary `clearAttachments` call from `MessagesScreen` --- .../getstream/chat/android/compose/ui/messages/MessagesScreen.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt index 58956a05935..357befa0dee 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/MessagesScreen.kt @@ -370,7 +370,6 @@ internal fun DefaultBottomBarContent( skipEnrichUrl = skipEnrichUrl, ), ) - composerViewModel.clearAttachments() }, ) From 34494fcab6090f16aa9b5da2156edfca1e05637e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 13 Mar 2026 16:11:22 +0000 Subject: [PATCH 17/22] Add setMessageInputInternal(message.text, MessageInput.Source.Edit) so the text field is populated after process-death restore in edit mode. --- .../feature/messages/composer/MessageComposerController.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt index ef5cffd6302..25dd4e0d1fb 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt @@ -537,6 +537,7 @@ public class MessageComposerController( } private fun restoreEditMode(message: Message, attachments: List) { + setMessageInputInternal(message.text, MessageInput.Source.Edit) _editModeMessage.value = message _editModeAttachments.value = attachments messageActions.value += Edit(message) From b811121ba26bb29521bea0656d5407b5bb8e2ccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 13 Mar 2026 16:11:54 +0000 Subject: [PATCH 18/22] Include `name` in attachment serialization in `ComposerSessionRepository` --- .../composer/ComposerSessionRepository.kt | 7 ++++-- .../composer/ComposerSessionRepositoryTest.kt | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepository.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepository.kt index 3944f47125b..388ce700745 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepository.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepository.kt @@ -104,10 +104,11 @@ internal class ComposerSessionRepository(private val savedStateHandle: SavedStat private fun jsonToAttachment(json: JSONObject): Attachment? { val uri = json.optString(KEY_URI).takeIf(String::isNotEmpty) + val name = json.optString(KEY_NAME).takeIf(String::isNotEmpty) val assetUrl = json.optString(KEY_ASSET_URL).takeIf(String::isNotEmpty) val imageUrl = json.optString(KEY_IMAGE_URL).takeIf(String::isNotEmpty) val thumbUrl = json.optString(KEY_THUMB_URL).takeIf(String::isNotEmpty) - val hasIdentifier = uri != null || assetUrl != null || imageUrl != null || thumbUrl != null + val hasIdentifier = uri != null || name != null || assetUrl != null || imageUrl != null || thumbUrl != null if (!hasIdentifier) return null val extraData = buildMap { json.optJSONObject(KEY_EXTRA_DATA)?.let { obj -> @@ -152,7 +153,9 @@ internal class ComposerSessionRepository(private val savedStateHandle: SavedStat * Serializes [extraData] to a [JSONObject], excluding [EXTRA_SOURCE_URI] (stored separately). * * Only primitive types (String, Boolean, Int, Long, Double, Float) are included. - * Non-serializable values are silently skipped. + * Non-serializable values are silently skipped. Note that [Float] values are promoted to + * [Double] during serialization because JSON has no single-precision type; callers should + * read restored values as [Number] rather than casting directly to [Float]. * * @return A [JSONObject], or `null` if there are no serializable entries. */ diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepositoryTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepositoryTest.kt index ed0c0ceb5ba..1aeba089171 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepositoryTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/ComposerSessionRepositoryTest.kt @@ -259,6 +259,29 @@ internal class ComposerSessionRepositoryTest { assertFalse(restored.containsKey("objectKey")) } + @Test + fun `Given edit-mode attachment with only name When saved Then it survives round-trip`() { + val repo = ComposerSessionRepository(SavedStateHandle()) + val attachment = Attachment( + name = randomString(), + type = randomString(), + fileSize = 2048, + mimeType = randomString(), + ) + + repo.save( + emptyList(), + editMode = ComposerSessionRepository.EditMode(randomMessage(), listOf(attachment)), + ) + val restored = repo.restoreEditMode()!!.attachments + + assertEquals(1, restored.size) + assertEquals(attachment.name, restored.first().name) + assertEquals(attachment.type, restored.first().type) + assertEquals(attachment.fileSize, restored.first().fileSize) + assertEquals(attachment.mimeType, restored.first().mimeType) + } + @Test fun `Given both selected attachments and edit mode saved When restored Then both are returned independently`() { val repo = ComposerSessionRepository(SavedStateHandle()) From 0ccd8f3a729f4b16465943ea0d2e23bda52c96dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 13 Mar 2026 16:39:36 +0000 Subject: [PATCH 19/22] Use full message from channel state in `restoreEditMode` --- .../feature/messages/composer/MessageComposerController.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt index 25dd4e0d1fb..85e39be78ab 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt @@ -537,10 +537,11 @@ public class MessageComposerController( } private fun restoreEditMode(message: Message, attachments: List) { + val fullMessage = channelState.value?.getMessageById(message.id) ?: message setMessageInputInternal(message.text, MessageInput.Source.Edit) - _editModeMessage.value = message + _editModeMessage.value = fullMessage _editModeAttachments.value = attachments - messageActions.value += Edit(message) + messageActions.value += Edit(fullMessage) syncAttachments() } From e7ef154d3be34ea5dce6f960eaaf8b523c41b02a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 13 Mar 2026 16:47:07 +0000 Subject: [PATCH 20/22] Add tests for `MessageComposerController` edit session restoration --- .../composer/MessageComposerControllerTest.kt | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt index 63fcbd2aac5..c1ed6cf67c7 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTest.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.ui.common.feature.messages.composer +import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.state.GlobalState @@ -59,6 +60,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -1200,6 +1202,72 @@ internal class MessageComposerControllerTest { assertTrue(controller.state.value.attachments.isEmpty()) } + @Test + fun `Given persisted edit session When controller restores Then edit action uses full message from channel state`() = + runTest { + val fullMessage = randomMessage( + id = "msg-1", + cid = CID, + text = "original", + createdAt = Date(), + ) + val editedText = "edited text" + val savedStateHandle = SavedStateHandle() + ComposerSessionRepository(savedStateHandle).save( + selectedAttachments = emptyList(), + editMode = ComposerSessionRepository.EditMode( + message = fullMessage.copy(text = editedText), + attachments = emptyList(), + ), + ) + + val controller = Fixture(savedStateHandle = savedStateHandle) + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .givenMessageById(fullMessage) + .get() + advanceUntilIdle() + + assertEquals(editedText, controller.messageInput.value.text) + val action = controller.state.value.action + assertTrue(action is Edit) + assertEquals(fullMessage.id, (action as Edit).message.id) + assertEquals(fullMessage.createdAt, action.message.createdAt) + } + + @Test + fun `Given persisted edit session When channel state has no message Then edit action falls back to stripped message`() = + runTest { + val messageId = "msg-2" + val editedText = "edited text" + val savedStateHandle = SavedStateHandle() + ComposerSessionRepository(savedStateHandle).save( + selectedAttachments = emptyList(), + editMode = ComposerSessionRepository.EditMode( + message = Message(id = messageId, cid = CID, text = editedText), + attachments = emptyList(), + ), + ) + + val controller = Fixture(savedStateHandle = savedStateHandle) + .givenAppSettings() + .givenAudioPlayer(mock()) + .givenClientState(randomUser()) + .givenGlobalState() + .givenChannelState() + .get() + advanceUntilIdle() + + assertEquals(editedText, controller.messageInput.value.text) + val action = controller.state.value.action + assertTrue(action is Edit) + assertEquals(messageId, (action as Edit).message.id) + assertNull(action.message.createdAt) + } + /** * Custom test implementation of [Mention] for testing purposes. */ @@ -1212,6 +1280,7 @@ internal class MessageComposerControllerTest { private class Fixture( val chatClient: ChatClient = mock(), private val cid: String = CID, + private val savedStateHandle: SavedStateHandle = SavedStateHandle(), ) { private val clientState: ClientState = mock() @@ -1287,6 +1356,10 @@ internal class MessageComposerControllerTest { whenever(chatClient.markMessageRead(any(), any(), any())) doReturn Unit.asCall() } + fun givenMessageById(message: Message) = apply { + whenever(channelState.getMessageById(eq(message.id))) doReturn message + } + fun givenEditMessage(message: Message) = apply { whenever(chatClient.editMessage(any(), any(), any())) doReturn message.asCall() } @@ -1315,6 +1388,7 @@ internal class MessageComposerControllerTest { fileToUri = mock(), config = config, globalState = MutableStateFlow(globalState), + savedStateHandle = savedStateHandle, ) } } From a2889b2eb83dc2ceaa11ee42c9777913a1e8372f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 16 Mar 2026 09:21:16 +0000 Subject: [PATCH 21/22] Add a feature flag to enable the system attachment picker in the Compose sample app. - Add string resources for the system attachment picker setting. - Extend `CustomSettings` to persist the `isSystemAttachmentPickerEnabled` preference. - Update `CustomLoginActivity` to include a UI toggle for the system attachment picker feature flag. - Configure `AttachmentPickerConfig` in `MessagesActivity` to use the preference value. --- .../android/compose/sample/data/CustomSettings.kt | 2 ++ .../android/compose/sample/ui/MessagesActivity.kt | 2 +- .../compose/sample/ui/login/CustomLoginActivity.kt | 14 ++++++++++++++ .../src/main/res/values/strings.xml | 2 ++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt index 6fbb6fe16b2..cb90952582c 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt @@ -34,6 +34,7 @@ class CustomSettings(private val context: Context) { var isAdaptiveLayoutEnabled: Boolean by booleanPref(AdaptiveLayout) var isComposerFloatingStyleEnabled: Boolean by booleanPref(ComposerFloatingStyle) + var isSystemAttachmentPickerEnabled: Boolean by booleanPref(SystemAttachmentPicker) private fun booleanPref(key: String, default: Boolean = false) = object : ReadWriteProperty { @@ -47,5 +48,6 @@ class CustomSettings(private val context: Context) { private const val AdaptiveLayout = "adaptive_layout" private const val ComposerFloatingStyle = "composer_floating_style" +private const val SystemAttachmentPicker = "system_attachment_picker" fun Context.customSettings(): CustomSettings = CustomSettings(this) diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/MessagesActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/MessagesActivity.kt index 01bcb6ff8f0..340d0039f58 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/MessagesActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/MessagesActivity.kt @@ -110,7 +110,7 @@ class MessagesActivity : ComponentActivity() { floatingStyleEnabled = settings.isComposerFloatingStyleEnabled, ), translation = TranslationConfig(enabled = ChatApp.autoTranslationEnabled), - attachmentPicker = AttachmentPickerConfig(useSystemPicker = false), + attachmentPicker = AttachmentPickerConfig(useSystemPicker = settings.isSystemAttachmentPickerEnabled), ), allowUIAutomationTest = true, messageComposerTheme = messageComposerTheme, diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt index c4e38edf640..61afe6eee61 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt @@ -127,6 +127,9 @@ class CustomLoginActivity : AppCompatActivity() { var isComposerFloatingStyleEnabled by remember { mutableStateOf(settings.isComposerFloatingStyleEnabled) } + var isSystemAttachmentPickerEnabled by remember { + mutableStateOf(settings.isSystemAttachmentPickerEnabled) + } val isLoginButtonEnabled = apiKeyText.isNotEmpty() && userIdText.isNotEmpty() && @@ -153,6 +156,17 @@ class CustomLoginActivity : AppCompatActivity() { settings.isComposerFloatingStyleEnabled = it }, ), + FeatureFlag( + label = stringResource(R.string.custom_login_flag_system_attachment_picker_label), + description = stringResource( + R.string.custom_login_flag_system_attachment_picker_description, + ), + value = isSystemAttachmentPickerEnabled, + onValueChange = { + isSystemAttachmentPickerEnabled = it + settings.isSystemAttachmentPickerEnabled = it + }, + ), ) CustomLoginInputField( diff --git a/stream-chat-android-compose-sample/src/main/res/values/strings.xml b/stream-chat-android-compose-sample/src/main/res/values/strings.xml index f840739ddf0..487a74bdf20 100644 --- a/stream-chat-android-compose-sample/src/main/res/values/strings.xml +++ b/stream-chat-android-compose-sample/src/main/res/values/strings.xml @@ -37,6 +37,8 @@ Adjust layout based on screen sizes Message composer floating style Message list scrolls behind some composer elements + System attachment picker + Use the system\'s native file/media picker Pinned Messages From 7660fe9814070464c9f769fc6347baf1a7cd2209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 16 Mar 2026 10:36:44 +0000 Subject: [PATCH 22/22] Fix Attachment type mismatch in test step --- .../chat/android/compose/tests/AttachmentsTests.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/AttachmentsTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/AttachmentsTests.kt index 1c7aacd0e75..f125cc77a9c 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/AttachmentsTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/AttachmentsTests.kt @@ -140,8 +140,8 @@ class AttachmentsTests : StreamTestCase() { step("GIVEN user opens the channel") { userRobot.login().openChannel() } - step("WHEN user sends a file") { - userRobot.attachFile(type = AttachmentType.IMAGE) + step("WHEN user attaches a file") { + userRobot.attachFile(type = AttachmentType.FILE) } step("AND user sends the file") { userRobot.tapOnSendButton() @@ -151,7 +151,7 @@ class AttachmentsTests : StreamTestCase() { } step("THEN user can see deleted message") { userRobot - .assertImage(isDisplayed = false) + .assertFile(isDisplayed = false) .assertDeletedMessage() } }