diff --git a/CHANGELOG.md b/CHANGELOG.md index 65ba7d52ed6..14373628c40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ ### 🐞 Fixed ### ⬆️ Improved +- Fix `StrictMode` violations in the `AttachmentsPicker`. [#6029](https://github.com/GetStream/stream-chat-android/pull/6029) ### ✅ Added diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentsPicker.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentsPicker.kt index 2490f3401f0..ffe57b5c781 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentsPicker.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentsPicker.kt @@ -35,6 +35,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember @@ -61,6 +62,7 @@ import io.getstream.chat.android.compose.viewmodel.messages.AttachmentsPickerVie import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.Channel import io.getstream.chat.android.ui.common.state.messages.MessageMode +import kotlinx.coroutines.flow.collectLatest /** * Represents the bottom bar UI that allows users to pick attachments. The picker renders its @@ -90,10 +92,16 @@ public fun AttachmentsPicker( shape: Shape = ChatTheme.shapes.bottomSheet, messageMode: MessageMode = MessageMode.Normal, ) { + // Listen for attachments to be ready for upload + LaunchedEffect(attachmentsPickerViewModel) { + attachmentsPickerViewModel.attachmentsForUpload.collectLatest { + onAttachmentsSelected(it) + } + } val saveAttachmentsOnDismiss = ChatTheme.attachmentPickerTheme.saveAttachmentsOnDismiss val dismissAction = { if (saveAttachmentsOnDismiss) { - onAttachmentsSelected(attachmentsPickerViewModel.getSelectedAttachments()) + attachmentsPickerViewModel.getSelectedAttachmentsAsync() } onDismiss() } @@ -144,7 +152,7 @@ public fun AttachmentsPicker( attachmentsPickerViewModel.changeAttachmentPickerMode(attachmentPickerMode) { false } }, onSendAttachmentsClick = { - onAttachmentsSelected(attachmentsPickerViewModel.getSelectedAttachments()) + attachmentsPickerViewModel.getSelectedAttachmentsAsync() }, ) } @@ -171,7 +179,7 @@ public fun AttachmentsPicker( onAttachmentItemSelected = attachmentsPickerViewModel::changeSelectedAttachments, onAttachmentsChanged = { attachmentsPickerViewModel.attachments = it }, onAttachmentsSubmitted = { - onAttachmentsSelected(attachmentsPickerViewModel.getAttachmentsFromMetaData(it)) + attachmentsPickerViewModel.getAttachmentsFromMetadataAsync(it) }, ) } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerFilesTabFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerFilesTabFactory.kt index 81b914ddbe9..ecf3658897b 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerFilesTabFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerFilesTabFactory.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.state.messages.attachments.AttachmentPickerItemState import io.getstream.chat.android.compose.state.messages.attachments.AttachmentsPickerMode @@ -55,6 +56,7 @@ import io.getstream.chat.android.ui.common.permissions.FilesAccess import io.getstream.chat.android.ui.common.permissions.Permissions import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData import io.getstream.chat.android.uiutils.util.openSystemSettings +import kotlinx.coroutines.flow.collectLatest /** * Holds the information required to add support for "files" tab in the attachment picker. @@ -96,6 +98,7 @@ public class AttachmentsPickerFilesTabFactory : AttachmentsPickerTabFactory { * @param onAttachmentItemSelected Handler when the item selection state changes. * @param onAttachmentsSubmitted Handler to submit the selected attachments to the message composer. */ + @Suppress("LongMethod") @Composable override fun PickerTabContent( onAttachmentPickerAction: (AttachmentPickerAction) -> Unit, @@ -106,8 +109,27 @@ public class AttachmentsPickerFilesTabFactory : AttachmentsPickerTabFactory { ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current - val storageHelper: StorageHelperWrapper = remember { - StorageHelperWrapper(context) + val processingViewModel = viewModel( + factory = AttachmentsProcessingViewModelFactory(StorageHelperWrapper(context.applicationContext)), + ) + LaunchedEffect(processingViewModel) { + processingViewModel.attachmentsMetadataFromUris.collectLatest { metadata -> + // Check if some of the files were filtered out due to upload config + if (metadata.uris.size != metadata.attachmentsMetadata.size) { + Toast.makeText( + context, + R.string.stream_compose_message_composer_file_not_supported, + Toast.LENGTH_SHORT, + ).show() + } + onAttachmentsSubmitted(metadata.attachmentsMetadata) + } + } + LaunchedEffect(processingViewModel) { + processingViewModel.filesMetadata.collectLatest { metaData -> + val items = metaData.map { AttachmentPickerItemState(it, false) } + onAttachmentsChanged(items) + } } var showPermanentlyDeniedSnackBar by remember { mutableStateOf(false) } val permissionLauncher = @@ -118,9 +140,7 @@ public class AttachmentsPickerFilesTabFactory : AttachmentsPickerTabFactory { } val filesAccess by filesAccessAsState(context, lifecycleOwner) { value -> if (value != FilesAccess.DENIED) { - onAttachmentsChanged( - storageHelper.getFiles().map { AttachmentPickerItemState(it, false) }, - ) + processingViewModel.getFilesAsync() } } @@ -135,17 +155,7 @@ public class AttachmentsPickerFilesTabFactory : AttachmentsPickerTabFactory { files = attachments, onItemSelected = onAttachmentItemSelected, onBrowseFilesResult = { uris -> - val attachments = storageHelper.getAttachmentsMetadataFromUris(uris) - // Check if some of the files were filtered out due to upload config - if (uris.size != attachments.size) { - Toast.makeText( - context, - R.string.stream_compose_message_composer_file_not_supported, - Toast.LENGTH_SHORT, - ).show() - } - - onAttachmentsSubmitted(attachments) + processingViewModel.getAttachmentsMetadataFromUrisAsync(uris) }, ) }, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerImagesTabFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerImagesTabFactory.kt index 3a596777630..b0e0c68ea6f 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerImagesTabFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerImagesTabFactory.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.state.messages.attachments.AttachmentPickerItemState import io.getstream.chat.android.compose.state.messages.attachments.AttachmentsPickerMode @@ -46,6 +47,7 @@ import io.getstream.chat.android.ui.common.permissions.Permissions import io.getstream.chat.android.ui.common.permissions.VisualMediaAccess import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData import io.getstream.chat.android.uiutils.util.openSystemSettings +import kotlinx.coroutines.flow.collectLatest /** * Holds the information required to add support for "images" tab in the attachment picker. @@ -98,13 +100,18 @@ public class AttachmentsPickerImagesTabFactory : AttachmentsPickerTabFactory { val permissions = Permissions.visualMediaPermissions() val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current - val storageHelper: StorageHelperWrapper = - remember { StorageHelperWrapper(context) } + val processingViewModel = viewModel( + factory = AttachmentsProcessingViewModelFactory(StorageHelperWrapper(context.applicationContext)), + ) + LaunchedEffect(processingViewModel) { + processingViewModel.mediaMetadata.collectLatest { metaData -> + val items = metaData.map { AttachmentPickerItemState(it, false) } + onAttachmentsChanged(items) + } + } val mediaAccess by visualMediaAccessAsState(context, lifecycleOwner) { value -> if (value != VisualMediaAccess.DENIED) { - val media = storageHelper.getMedia() - val mediaAttachments = media.map { AttachmentPickerItemState(it, false) } - onAttachmentsChanged(mediaAttachments) + processingViewModel.getMediaAsync() } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerSystemTabFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerSystemTabFactory.kt index 1b80fd91666..a83c56a7b99 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerSystemTabFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerSystemTabFactory.kt @@ -61,6 +61,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.viewmodel.compose.viewModel import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState import com.google.accompanist.permissions.isGranted @@ -81,6 +82,7 @@ import io.getstream.chat.android.ui.common.permissions.SystemAttachmentsPickerCo import io.getstream.chat.android.ui.common.permissions.toContractVisualMediaType import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData import io.getstream.chat.android.ui.common.utils.isPermissionDeclared +import kotlinx.coroutines.flow.collectLatest /** * Holds the information required to add support for "files" tab in the attachment picker. @@ -208,27 +210,33 @@ public class AttachmentsPickerSystemTabFactory( onAttachmentsSubmitted: (List) -> Unit, ) { val context = LocalContext.current - val storageHelper: StorageHelperWrapper = remember { - StorageHelperWrapper(context) + + val processingViewModel = viewModel( + factory = AttachmentsProcessingViewModelFactory(StorageHelperWrapper(context.applicationContext)), + ) + + LaunchedEffect(processingViewModel) { + processingViewModel.attachmentsMetadataFromUris.collectLatest { metadata -> + // Check if some of the files were filtered out due to upload config + if (metadata.uris.size != metadata.attachmentsMetadata.size) { + Toast.makeText( + context, + R.string.stream_compose_message_composer_file_not_supported, + Toast.LENGTH_SHORT, + ).show() + } + onAttachmentsSubmitted(metadata.attachmentsMetadata) + } } val filePickerLauncher = rememberFilePickerLauncher { uri -> val uris = listOf(uri) - val attachments = storageHelper.getAttachmentsMetadataFromUris(uris) - // Check if some of the files were filtered out due to upload config - if (uris.size != attachments.size) { - Toast.makeText( - context, - R.string.stream_compose_message_composer_file_not_supported, - Toast.LENGTH_SHORT, - ).show() - } - onAttachmentsSubmitted(attachments) + processingViewModel.getAttachmentsMetadataFromUrisAsync(uris) } val imagePickerLauncher = rememberVisualMediaPickerLauncher(config.visualMediaAllowMultiple) { uris -> - onAttachmentsSubmitted(storageHelper.getAttachmentsMetadataFromUris(uris)) + processingViewModel.getAttachmentsMetadataFromUrisAsync(uris) } val captureLauncher = rememberCaptureMediaLauncher( diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsProcessingViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsProcessingViewModel.kt new file mode 100644 index 00000000000..8b94dcfa9bb --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsProcessingViewModel.kt @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2014-2025 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.compose.ui.messages.attachments.factory + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import io.getstream.chat.android.compose.ui.util.StorageHelperWrapper +import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider +import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +/** + * Internal ViewModel responsible for asynchronous processing of attachment metadata. + * + * This ViewModel handles the background retrieval and processing of attachment metadata from various + * sources (URIs, files, media) without blocking the main thread. It uses [StorageHelperWrapper] to + * interact with the device's storage and emits results through [SharedFlow]s that the UI can collect. + * + * All processing operations run on [DispatcherProvider.IO] to avoid blocking the main thread during + * disk I/O operations. The ViewModel provides three main capabilities: + * + * 1. **URI Processing**: Converts a list of URIs to attachment metadata via [getAttachmentsMetadataFromUrisAsync] + * 2. **File Retrieval**: Fetches file metadata from storage via [getFilesAsync] + * 3. **Media Retrieval**: Fetches media metadata from storage via [getMediaAsync] + * + * ## Threading Model + * All operations are launched in the [viewModelScope] with [DispatcherProvider.IO] to ensure: + * - Non-blocking execution on the main thread + * - Automatic cancellation when the ViewModel is cleared + * - Sequential emission of results through SharedFlows + * + * ## Usage + * This ViewModel is typically used within the message composer to handle attachment selection + * and processing: + * + * ```kotlin + * val viewModel = viewModel( + * factory = AttachmentsProcessingViewModelFactory(storageHelper) + * ) + * + * // Collect metadata updates + * LaunchedEffect(Unit) { + * viewModel.attachmentsMetadataFromUris.collect { result -> + * // Handle attachment metadata + * } + * } + * + * // Trigger processing + * viewModel.getAttachmentsMetadataFromUrisAsync(selectedUris) + * ``` + * + * @param storageHelper The wrapper around storage helper functionality used to retrieve attachment + * metadata from the device's storage system. + * + * @see AttachmentsMetadataFromUris + * @see AttachmentsProcessingViewModelFactory + */ +internal class AttachmentsProcessingViewModel( + private val storageHelper: StorageHelperWrapper, +) : ViewModel() { + + private val _attachmentsMetadataFromUris = + MutableSharedFlow(extraBufferCapacity = 1) + private val _filesMetadata = + MutableSharedFlow>(extraBufferCapacity = 1) + private val _mediaMetadata = + MutableSharedFlow>(extraBufferCapacity = 1) + + /** + * Flow of events emitted when attachments metadata is retrieved from URIs. + * + * This [SharedFlow] emits [AttachmentsMetadataFromUris] events that contain both the original URIs + * and the retrieved [AttachmentMetaData]. The UI can collect from this flow to react to metadata + * retrieval and update the attachment state accordingly. + */ + val attachmentsMetadataFromUris: SharedFlow = + _attachmentsMetadataFromUris.asSharedFlow() + + /** + * Flow of events emitted when files metadata is retrieved. + * + * This [SharedFlow] emits lists of [AttachmentMetaData] representing the files retrieved from storage. + * The UI can collect from this flow to react to file metadata retrieval and update the attachment state + * accordingly. + */ + val filesMetadata: SharedFlow> = + _filesMetadata.asSharedFlow() + + /** + * Flow of events emitted when media metadata is retrieved. + * + * This [SharedFlow] emits lists of [AttachmentMetaData] representing the media retrieved from storage. + * The UI can collect from this flow to react to media metadata retrieval and update the attachment state + * accordingly. + */ + val mediaMetadata: SharedFlow> = + _mediaMetadata.asSharedFlow() + + /** + * Processes a list of attachment URIs in the background and emits the result. + * + * This method launches a coroutine on [DispatcherProvider.IO] to perform disk I/O operations without + * blocking the main thread. Once processing completes, it emits an [AttachmentsMetadataFromUris] + * event through the [attachmentsMetadataFromUris] flow. + * + * The processing is fire-and-forget; multiple calls to this method will queue up separate + * processing jobs that execute independently. Each job runs in the [viewModelScope] and will + * be cancelled automatically if the ViewModel is cleared before completion. + * + * ## Threading + * - **Caller thread**: Any (method returns immediately) + * - **Execution thread**: [DispatcherProvider.IO] (coroutine context) + * - **Emission thread**: Depends on the collector's coroutine context + * + * @param uris The list of URIs to process. Can be empty, in which case an empty result is emitted. + */ + fun getAttachmentsMetadataFromUrisAsync(uris: List) { + viewModelScope.launch(DispatcherProvider.IO) { + val metadata = storageHelper.getAttachmentsMetadataFromUris(uris) + val attachmentsMetadataFromUris = AttachmentsMetadataFromUris( + uris = uris, + attachmentsMetadata = metadata, + ) + _attachmentsMetadataFromUris.emit(attachmentsMetadataFromUris) + } + } + + /** + * Retrieves files metadata asynchronously and emits the result. + * + * This method launches a coroutine on [DispatcherProvider.IO] to perform disk I/O operations without + * blocking the main thread. Once retrieval completes, it emits a list of [AttachmentMetaData] + * through the [filesMetadata] flow. + * + * The retrieval is fire-and-forget; multiple calls to this method will queue up separate + * retrieval jobs that execute independently. Each job runs in the [viewModelScope] and will + * be cancelled automatically if the ViewModel is cleared before completion. + * + * ## Threading + * - **Caller thread**: Any (method returns immediately) + * - **Execution thread**: [DispatcherProvider.IO] (coroutine context) + * - **Emission thread**: Depends on the collector's coroutine context + */ + fun getFilesAsync() { + viewModelScope.launch(DispatcherProvider.IO) { + val metadata = storageHelper.getFiles() + _filesMetadata.emit(metadata) + } + } + + /** + * Retrieves media metadata asynchronously and emits the result. + * + * This method launches a coroutine on [DispatcherProvider.IO] to perform disk I/O operations without + * blocking the main thread. Once retrieval completes, it emits a list of [AttachmentMetaData] + * through the [mediaMetadata] flow. + * + * The retrieval is fire-and-forget; multiple calls to this method will queue up separate + * retrieval jobs that execute independently. Each job runs in the [viewModelScope] and will + * be cancelled automatically if the ViewModel is cleared before completion. + * + * ## Threading + * - **Caller thread**: Any (method returns immediately) + * - **Execution thread**: [DispatcherProvider.IO] (coroutine context) + * - **Emission thread**: Depends on the collector's coroutine context + */ + fun getMediaAsync() { + viewModelScope.launch(DispatcherProvider.IO) { + val metadata = storageHelper.getMedia() + _mediaMetadata.emit(metadata) + } + } +} + +/** + * Data class representing the result of processing attachment URIs into metadata. + * + * This class pairs the original list of [Uri]s with their corresponding [AttachmentMetaData] that + * was retrieved from storage. It's emitted through [AttachmentsProcessingViewModel.attachmentsMetadataFromUris] + * after the async processing completes. + * + * The presence of both the original URIs and the metadata allows consumers to: + * - Match results back to the original request + * - Handle cases where some URIs may not produce valid metadata + * - Track processing progress across multiple async operations + * + * @property uris The original list of URIs that were submitted for processing. This list maintains + * the order in which URIs were provided to [AttachmentsProcessingViewModel.getAttachmentsMetadataFromUrisAsync]. + * @property attachmentsMetadata The list of successfully retrieved attachment metadata. May contain + * fewer entries than [uris] if some URIs could not be processed. + * + * @see AttachmentsProcessingViewModel.attachmentsMetadataFromUris + * @see AttachmentsProcessingViewModel.getAttachmentsMetadataFromUrisAsync + */ +internal data class AttachmentsMetadataFromUris( + val uris: List, + val attachmentsMetadata: List, +) + +/** + * A [ViewModelProvider.Factory] for creating [AttachmentsProcessingViewModel] instances. + * + * This factory is used to construct [AttachmentsProcessingViewModel] with the required [StorageHelperWrapper] + * dependency. It ensures that only [AttachmentsProcessingViewModel] instances can be created and throws an + * [IllegalArgumentException] if an unsupported ViewModel class is requested. + * + * @param storageHelper The helper used to access file metadata from storage. + * + * @see AttachmentsProcessingViewModel + */ +internal class AttachmentsProcessingViewModelFactory( + private val storageHelper: StorageHelperWrapper, +) : ViewModelProvider.Factory { + + /** + * Creates a new instance of the given [ViewModel] class. + * + * @param modelClass The class of the ViewModel to create. Must be [AttachmentsProcessingViewModel]. + * @return A new instance of [AttachmentsProcessingViewModel]. + * @throws IllegalArgumentException if [modelClass] is not [AttachmentsProcessingViewModel]. + */ + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + require(modelClass == AttachmentsProcessingViewModel::class.java) { + "AttachmentsProcessingViewModelFactory can only create instances of AttachmentsProcessingViewModel" + } + + return AttachmentsProcessingViewModel(storageHelper) as T + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StorageHelperWrapper.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StorageHelperWrapper.kt index 110d2c58424..52dcc2f92b8 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StorageHelperWrapper.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StorageHelperWrapper.kt @@ -103,6 +103,9 @@ public class StorageHelperWrapper( /** * Takes a list of file Uris and transforms them into a list of [AttachmentMetaData]. * + * IMPORTANT: This method performs a potentially expensive query operation and should be called from a + * background thread to avoid blocking the UI. + * * @param uris Selected file Uris, to be transformed. * @return List of [AttachmentMetaData] that describe the files. */ 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 8697c0210be..45168c78119 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 @@ -30,12 +30,17 @@ import io.getstream.chat.android.compose.state.messages.attachments.Images import io.getstream.chat.android.compose.state.messages.attachments.MediaCapture import io.getstream.chat.android.compose.ui.util.StorageHelperWrapper import io.getstream.chat.android.compose.util.extensions.asState +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.state.messages.composer.AttachmentMetaData +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch /** * ViewModel responsible for handling the state and business logic of attachments. @@ -106,6 +111,9 @@ public class AttachmentsPickerViewModel( public var isShowingAttachments: Boolean by mutableStateOf(false) private set + private val _attachmentsForUpload: MutableSharedFlow> = MutableSharedFlow(extraBufferCapacity = 1) + internal val attachmentsForUpload: SharedFlow> = _attachmentsForUpload.asSharedFlow() + /** * Loads all the items based on the current type. */ @@ -199,6 +207,18 @@ public class AttachmentsPickerViewModel( return storageHelper.getAttachmentsForUpload(selectedAttachments.map { it.attachmentMetaData }) } + /** + * Loads up the currently selected attachments. It uses the [attachmentsPickerMode] to know which + * attachments to use - files or images. + * Runs the [getSelectedAttachments] method on [DispatcherProvider.IO] and emits the result + * via the [attachmentsForUpload] flow. + */ + internal fun getSelectedAttachmentsAsync() { + viewModelScope.launch(DispatcherProvider.IO) { + _attachmentsForUpload.emit(getSelectedAttachments()) + } + } + /** * Transforms selected file Uris to a list of [Attachment]s we can upload. * @@ -219,6 +239,19 @@ public class AttachmentsPickerViewModel( return storageHelper.getAttachmentsForUpload(metaData) } + /** + * Transforms the selected meta data into a list of [Attachment]s we can upload. + * Runs the [getAttachmentsFromMetadataAsync] method on [DispatcherProvider.IO] and emits the result + * via the [_attachmentsForUpload] flow. + * + * @param metadata List of attachment meta data items. + */ + internal fun getAttachmentsFromMetadataAsync(metadata: List) { + viewModelScope.launch(DispatcherProvider.IO) { + _attachmentsForUpload.emit(getAttachmentsFromMetaData(metadata)) + } + } + /** * Triggered when we dismiss the attachments picker. We reset the state to show images and clear * the items for now, until the user needs them again. diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsProcessingViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsProcessingViewModelTest.kt new file mode 100644 index 00000000000..eb107b5959e --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsProcessingViewModelTest.kt @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2014-2025 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.compose.ui.messages.attachments.factory + +import android.net.Uri +import androidx.lifecycle.ViewModel +import app.cash.turbine.test +import io.getstream.chat.android.compose.ui.util.StorageHelperWrapper +import io.getstream.chat.android.test.TestCoroutineExtension +import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@ExperimentalCoroutinesApi +@ExtendWith(TestCoroutineExtension::class) +internal class AttachmentsProcessingViewModelTest { + + @Test + fun `Given URIs When processing attachments Should emit result with processed metadata`() = runTest { + val uri1 = mock() + val uri2 = mock() + val uris = listOf(uri1, uri2) + val expectedMetadata = listOf( + AttachmentMetaData( + type = "image", + mimeType = "image/jpeg", + title = "photo.jpg", + ), + AttachmentMetaData( + type = "image", + mimeType = "image/png", + title = "screenshot.png", + ), + ) + val storageHelper: StorageHelperWrapper = mock { + whenever(it.getAttachmentsMetadataFromUris(uris)) doReturn expectedMetadata + } + val viewModel = AttachmentsProcessingViewModel(storageHelper) + + viewModel.attachmentsMetadataFromUris.test { + viewModel.getAttachmentsMetadataFromUrisAsync(uris) + advanceUntilIdle() + + val result = awaitItem() + assertEquals(uris, result.uris) + assertEquals(expectedMetadata, result.attachmentsMetadata) + assertEquals(2, result.attachmentsMetadata.size) + } + } + + @Test + fun `Given files When getting files async Should emit files metadata`() = runTest { + val expectedFilesMetadata = listOf( + AttachmentMetaData( + type = "file", + mimeType = "application/pdf", + title = "document.pdf", + ), + AttachmentMetaData( + type = "file", + mimeType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + title = "report.docx", + ), + ) + val storageHelper: StorageHelperWrapper = mock { + whenever(it.getFiles()) doReturn expectedFilesMetadata + } + val viewModel = AttachmentsProcessingViewModel(storageHelper) + + viewModel.filesMetadata.test { + viewModel.getFilesAsync() + advanceUntilIdle() + + val result = awaitItem() + assertEquals(expectedFilesMetadata, result) + assertEquals(2, result.size) + } + } + + @Test + fun `Given media When getting media async Should emit media metadata`() = runTest { + val expectedMediaMetadata = listOf( + AttachmentMetaData( + type = "image", + mimeType = "image/jpeg", + title = "photo1.jpg", + ), + AttachmentMetaData( + type = "video", + mimeType = "video/mp4", + title = "video1.mp4", + ), + AttachmentMetaData( + type = "image", + mimeType = "image/png", + title = "screenshot.png", + ), + ) + val storageHelper: StorageHelperWrapper = mock { + whenever(it.getMedia()) doReturn expectedMediaMetadata + } + val viewModel = AttachmentsProcessingViewModel(storageHelper) + + viewModel.mediaMetadata.test { + viewModel.getMediaAsync() + advanceUntilIdle() + + val result = awaitItem() + assertEquals(expectedMediaMetadata, result) + assertEquals(3, result.size) + } + } +} + +@ExperimentalCoroutinesApi +@ExtendWith(TestCoroutineExtension::class) +internal class AttachmentsProcessingViewModelFactoryTest { + + @Test + fun `create should return correct AttachmentsProcessingViewModel instance`() { + val storageHelper: StorageHelperWrapper = mock() + val factory = AttachmentsProcessingViewModelFactory(storageHelper) + + val viewModel = factory.create(AttachmentsProcessingViewModel::class.java) + + assertInstanceOf(AttachmentsProcessingViewModel::class.java, viewModel) + } + + @Test + fun `create should throw IllegalArgumentException for unsupported ViewModel class`() { + val storageHelper: StorageHelperWrapper = mock() + val factory = AttachmentsProcessingViewModelFactory(storageHelper) + + val exception = assertThrows { + factory.create(ViewModel::class.java) + } + + assertEquals( + "AttachmentsProcessingViewModelFactory can only create instances of AttachmentsProcessingViewModel", + exception.message, + ) + } +} 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 83b6cf74868..779f86e1198 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 @@ -16,6 +16,7 @@ package io.getstream.chat.android.compose.viewmodel.messages +import app.cash.turbine.test import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.compose.state.messages.attachments.Files import io.getstream.chat.android.compose.state.messages.attachments.Images @@ -25,7 +26,11 @@ import io.getstream.chat.android.test.TestCoroutineExtension import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow -import org.amshove.kluent.`should be equal to` +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.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.mockito.kotlin.any @@ -51,13 +56,13 @@ internal class AttachmentsPickerViewModelTest { viewModel.changeAttachmentState(true) viewModel.loadData() - viewModel.isShowingAttachments `should be equal to` true - viewModel.attachmentsPickerMode `should be equal to` Images - viewModel.images.size `should be equal to` 2 - viewModel.files.size `should be equal to` 0 - viewModel.hasPickedImages `should be equal to` false - viewModel.hasPickedFiles `should be equal to` false - viewModel.getSelectedAttachments().size `should be equal to` 0 + assertTrue(viewModel.isShowingAttachments) + assertEquals(Images, viewModel.attachmentsPickerMode) + assertEquals(2, viewModel.images.size) + assertEquals(0, viewModel.files.size) + assertFalse(viewModel.hasPickedImages) + assertFalse(viewModel.hasPickedFiles) + assertEquals(0, viewModel.getSelectedAttachments().size) } @Test @@ -70,13 +75,13 @@ internal class AttachmentsPickerViewModelTest { viewModel.changeAttachmentState(true) viewModel.changeAttachmentPickerMode(Files) - viewModel.isShowingAttachments `should be equal to` true - viewModel.attachmentsPickerMode `should be equal to` Files - viewModel.images.size `should be equal to` 0 - viewModel.files.size `should be equal to` 2 - viewModel.hasPickedImages `should be equal to` false - viewModel.hasPickedFiles `should be equal to` false - viewModel.getSelectedAttachments().size `should be equal to` 0 + assertTrue(viewModel.isShowingAttachments) + assertEquals(Files, viewModel.attachmentsPickerMode) + assertEquals(0, viewModel.images.size) + assertEquals(2, viewModel.files.size) + assertFalse(viewModel.hasPickedImages) + assertFalse(viewModel.hasPickedFiles) + assertEquals(0, viewModel.getSelectedAttachments().size) } @Test @@ -91,13 +96,13 @@ internal class AttachmentsPickerViewModelTest { viewModel.loadData() viewModel.changeSelectedAttachments(viewModel.images.first()) - viewModel.isShowingAttachments `should be equal to` true - viewModel.attachmentsPickerMode `should be equal to` Images - viewModel.images.size `should be equal to` 2 - viewModel.files.size `should be equal to` 0 - viewModel.hasPickedImages `should be equal to` true - viewModel.hasPickedFiles `should be equal to` false - viewModel.getSelectedAttachments().size `should be equal to` 1 + assertTrue(viewModel.isShowingAttachments) + assertEquals(Images, viewModel.attachmentsPickerMode) + assertEquals(2, viewModel.images.size) + assertEquals(0, viewModel.files.size) + assertTrue(viewModel.hasPickedImages) + assertFalse(viewModel.hasPickedFiles) + assertEquals(1, viewModel.getSelectedAttachments().size) } @Test @@ -111,13 +116,13 @@ internal class AttachmentsPickerViewModelTest { viewModel.changeAttachmentPickerMode(Files) viewModel.changeAttachmentState(false) - viewModel.isShowingAttachments `should be equal to` false - viewModel.attachmentsPickerMode `should be equal to` Images - viewModel.images.size `should be equal to` 0 - viewModel.files.size `should be equal to` 0 - viewModel.hasPickedImages `should be equal to` false - viewModel.hasPickedFiles `should be equal to` false - viewModel.getSelectedAttachments().size `should be equal to` 0 + assertFalse(viewModel.isShowingAttachments) + assertEquals(Images, viewModel.attachmentsPickerMode) + assertEquals(0, viewModel.images.size) + assertEquals(0, viewModel.files.size) + assertFalse(viewModel.hasPickedImages) + assertFalse(viewModel.hasPickedFiles) + assertEquals(0, viewModel.getSelectedAttachments().size) } @Test @@ -125,11 +130,85 @@ internal class AttachmentsPickerViewModelTest { val storageHelper: StorageHelperWrapper = mock() val viewModel = AttachmentsPickerViewModel(storageHelper, channelState) - viewModel.isShowingAttachments `should be equal to` false + assertFalse(viewModel.isShowingAttachments) verify(storageHelper, never()).getFiles() verify(storageHelper, never()).getMedia() } + @Test + fun `Given selected images When getting selected attachments async Should emit attachments for upload`() = runTest { + val expectedAttachments = listOf( + Attachment(type = "image", upload = mock()), + Attachment(type = "image", upload = mock()), + ) + val storageHelper: StorageHelperWrapper = mock { + whenever(it.getMedia()) doReturn listOf(imageAttachment1, imageAttachment2) + whenever(it.getAttachmentsForUpload(any())) doReturn expectedAttachments + } + val viewModel = AttachmentsPickerViewModel(storageHelper, channelState) + + viewModel.attachmentsForUpload.test { + viewModel.changeAttachmentState(true) + viewModel.loadData() + viewModel.changeSelectedAttachments(viewModel.images.first()) + viewModel.changeSelectedAttachments(viewModel.images.last()) + + viewModel.getSelectedAttachmentsAsync() + advanceUntilIdle() + + val result = awaitItem() + assertEquals(2, result.size) + assertEquals(expectedAttachments, result) + } + } + + @Test + fun `Given selected files When getting selected attachments async Should emit attachments for upload`() = runTest { + val expectedAttachments = listOf( + Attachment(type = "file", upload = mock()), + ) + val storageHelper: StorageHelperWrapper = mock { + whenever(it.getFiles()) doReturn listOf(fileAttachment1, fileAttachment2) + whenever(it.getAttachmentsForUpload(any())) doReturn expectedAttachments + } + val viewModel = AttachmentsPickerViewModel(storageHelper, channelState) + + viewModel.attachmentsForUpload.test { + viewModel.changeAttachmentState(true) + viewModel.changeAttachmentPickerMode(Files) + viewModel.changeSelectedAttachments(viewModel.files.first()) + + viewModel.getSelectedAttachmentsAsync() + advanceUntilIdle() + + val result = awaitItem() + assertEquals(1, result.size) + assertEquals(expectedAttachments, result) + } + } + + @Test + fun `Given attachment metadata When getting attachments from metadata async Should emit attachments for upload`() = runTest { + val metadata = listOf(imageAttachment1, imageAttachment2) + val expectedAttachments = listOf( + Attachment(type = "image", upload = mock()), + Attachment(type = "image", upload = mock()), + ) + val storageHelper: StorageHelperWrapper = mock { + whenever(it.getAttachmentsForUpload(metadata)) doReturn expectedAttachments + } + val viewModel = AttachmentsPickerViewModel(storageHelper, channelState) + + viewModel.attachmentsForUpload.test { + viewModel.getAttachmentsFromMetadataAsync(metadata) + advanceUntilIdle() + + val result = awaitItem() + assertEquals(2, result.size) + assertEquals(expectedAttachments, result) + } + } + companion object { private val imageAttachment1 = AttachmentMetaData( diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/internal/StorageHelper.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/internal/StorageHelper.kt index ca37e061936..97f096c8b2a 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/internal/StorageHelper.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/internal/StorageHelper.kt @@ -152,6 +152,9 @@ public class StorageHelper { * The attachment type (image, video, or file) is automatically determined based on * the MIME type. * + * IMPORTANT: This method performs a potentially expensive query operation and should be called from a + * background thread to avoid blocking the UI. + * * @param context The Android context used to access the content resolver. * @param uriList The list of content URIs (using the `content://` scheme) to query. * @return A list of [AttachmentMetaData] objects with parsed metadata. URIs that fail