From 029b2cb54e2aace6a520ea29e99759db8b0fe270 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 1 Dec 2025 21:03:07 +0100 Subject: [PATCH 1/4] AttachmentsPickerSystemTabFactory strict mode. --- .../messages/attachments/AttachmentsPicker.kt | 11 +- .../AttachmentsPickerSystemTabFactory.kt | 34 +++-- .../factory/AttachmentsProcessingViewModel.kt | 138 ++++++++++++++++++ .../messages/AttachmentsPickerViewModel.kt | 14 ++ .../AttachmentsProcessingViewModelTest.kt | 103 +++++++++++++ 5 files changed, 286 insertions(+), 14 deletions(-) create mode 100644 stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsProcessingViewModel.kt create mode 100644 stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsProcessingViewModelTest.kt 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..ec760e11cea 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 @@ -158,6 +160,13 @@ public fun AttachmentsPicker( }, color = ChatTheme.attachmentPickerTheme.backgroundPrimary, ) { + // Listen for attachments to be ready for upload + LaunchedEffect(attachmentsPickerViewModel) { + attachmentsPickerViewModel.attachmentsForUpload.collectLatest { + onAttachmentsSelected(it) + } + } + // Tab content AnimatedContent(targetState = selectedTabIndex, label = "") { allowedFactories.getOrNull(it) ?.PickerTabContent( @@ -171,7 +180,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/AttachmentsPickerSystemTabFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsPickerSystemTabFactory.kt index 1b80fd91666..00190a011ea 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)), + ) + + LaunchedEffect(processingViewModel) { + processingViewModel.result.collectLatest { event -> + // Check if some of the files were filtered out due to upload config + if (event.uris.size != event.processedAttachments.size) { + Toast.makeText( + context, + R.string.stream_compose_message_composer_file_not_supported, + Toast.LENGTH_SHORT, + ).show() + } + onAttachmentsSubmitted(event.processedAttachments) + } } 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.processAttachmentsFromUris(uris) } val imagePickerLauncher = rememberVisualMediaPickerLauncher(config.visualMediaAllowMultiple) { uris -> - onAttachmentsSubmitted(storageHelper.getAttachmentsMetadataFromUris(uris)) + processingViewModel.processAttachmentsFromUris(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..ecbf65619ad --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsProcessingViewModel.kt @@ -0,0 +1,138 @@ +/* + * 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.ui.common.state.messages.composer.AttachmentMetaData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +/** + * ViewModel responsible for processing attachment URIs on a background thread. + * + * This ViewModel handles the conversion of URIs to [AttachmentMetaData] by performing + * disk I/O operations on [Dispatchers.IO] to avoid blocking the main thread and causing ANRs. + * It processes attachments asynchronously and emits results through a [SharedFlow] that the UI can observe. + * + * The processing is triggered by calling [processAttachmentsFromUris], which performs the following: + * - Reads file metadata from the system storage on a background thread + * - Converts URIs to [AttachmentMetaData] objects + * - Emits an [AttachmentsProcessingResult] event with both the original URIs and processed metadata + * + * @param storageHelper The helper used to access file metadata from storage. + * + * @see AttachmentsProcessingResult + * @see AttachmentsProcessingViewModelFactory + */ +internal class AttachmentsProcessingViewModel( + private val storageHelper: StorageHelperWrapper, +) : ViewModel() { + + private val _result = MutableSharedFlow(extraBufferCapacity = 1) + + /** + * Flow of events emitted when attachment processing completes. + * + * This [SharedFlow] emits [AttachmentsProcessingResult] events that contain both the original URIs + * and the processed [AttachmentMetaData]. The UI can collect from this flow to react to processing + * completion and update the attachment state accordingly. + * + * The flow has a buffer capacity of 1, allowing for one event to be buffered if no collectors are active. + */ + val result: SharedFlow = _result.asSharedFlow() + + /** + * Processes a list of attachment URIs in the background and emits the result. + * + * This method launches a coroutine on [Dispatchers.IO] to perform disk I/O operations without + * blocking the main thread. Once processing completes, it emits an [AttachmentsProcessingResult] + * event through the [result] flow. + * + * The processing is fire-and-forget; multiple calls to this method will queue up separate + * processing jobs that execute independently. + * + * @param uris The list of URIs to process. Can be empty, in which case an empty result is emitted. + */ + fun processAttachmentsFromUris(uris: List) { + viewModelScope.launch(Dispatchers.IO) { + val metadata = storageHelper.getAttachmentsMetadataFromUris(uris) + val attachmentsProcessingResult = AttachmentsProcessingResult( + uris = uris, + processedAttachments = metadata, + ) + _result.emit(attachmentsProcessingResult) + } + } +} + +/** + * Result of attachment URI processing, containing both the original URIs and the processed metadata. + * + * This data class is emitted by [AttachmentsProcessingViewModel] when attachment processing completes. + * It pairs the original URIs with their corresponding [AttachmentMetaData], allowing consumers to + * correlate the input with the output and handle the processed attachments appropriately. + * + * @property uris The original list of URIs that were submitted for processing. + * @property processedAttachments The list of processed [AttachmentMetaData] extracted from the URIs. + * May be smaller than [uris] if some URIs could not be processed or + * were filtered out by attachment filters. + * + * @see AttachmentsProcessingViewModel + */ +internal data class AttachmentsProcessingResult( + val uris: List, + val processedAttachments: 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/viewmodel/messages/AttachmentsPickerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt index 8697c0210be..27fd6ea5892 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 @@ -33,9 +33,14 @@ import io.getstream.chat.android.compose.util.extensions.asState 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.Dispatchers +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. */ @@ -219,6 +227,12 @@ public class AttachmentsPickerViewModel( return storageHelper.getAttachmentsForUpload(metaData) } + internal fun getAttachmentsFromMetadataAsync(metaData: List) { + viewModelScope.launch(Dispatchers.IO) { + _attachmentsForUpload.emit(storageHelper.getAttachmentsForUpload(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..ebeb5d515a7 --- /dev/null +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/messages/attachments/factory/AttachmentsProcessingViewModelTest.kt @@ -0,0 +1,103 @@ +/* + * 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.result.test { + viewModel.processAttachmentsFromUris(uris) + advanceUntilIdle() + + val result = awaitItem() + assertEquals(uris, result.uris) + assertEquals(expectedMetadata, result.processedAttachments) + assertEquals(2, result.processedAttachments.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, + ) + } +} From bd6c92ef6c6f659cf4aadf360496f4950bee3927 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 2 Dec 2025 17:14:04 +0100 Subject: [PATCH 2/4] Fix StrictMode violations in the AttachmentsPicker. --- .../messages/attachments/AttachmentsPicker.kt | 16 +- .../AttachmentsPickerFilesTabFactory.kt | 42 ++-- .../AttachmentsPickerImagesTabFactory.kt | 17 +- .../AttachmentsPickerSystemTabFactory.kt | 10 +- .../factory/AttachmentsProcessingViewModel.kt | 187 ++++++++++++++---- .../compose/ui/util/StorageHelperWrapper.kt | 5 +- .../messages/AttachmentsPickerViewModel.kt | 29 ++- .../AttachmentsProcessingViewModelTest.kt | 71 ++++++- .../AttachmentsPickerViewModelTest.kt | 139 ++++++++++--- .../common/helper/internal/StorageHelper.kt | 3 + 10 files changed, 407 insertions(+), 112 deletions(-) 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 ec760e11cea..71148019590 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 @@ -92,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() } @@ -146,7 +152,7 @@ public fun AttachmentsPicker( attachmentsPickerViewModel.changeAttachmentPickerMode(attachmentPickerMode) { false } }, onSendAttachmentsClick = { - onAttachmentsSelected(attachmentsPickerViewModel.getSelectedAttachments()) + attachmentsPickerViewModel.getSelectedAttachmentsAsync() }, ) } @@ -160,12 +166,6 @@ public fun AttachmentsPicker( }, color = ChatTheme.attachmentPickerTheme.backgroundPrimary, ) { - // Listen for attachments to be ready for upload - LaunchedEffect(attachmentsPickerViewModel) { - attachmentsPickerViewModel.attachmentsForUpload.collectLatest { - onAttachmentsSelected(it) - } - } // Tab content AnimatedContent(targetState = selectedTabIndex, label = "") { allowedFactories.getOrNull(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..f230a25e922 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)), + ) + 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..5ed1f505cd2 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)), + ) + 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 00190a011ea..41f4ad0a224 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 @@ -216,27 +216,27 @@ public class AttachmentsPickerSystemTabFactory( ) LaunchedEffect(processingViewModel) { - processingViewModel.result.collectLatest { event -> + processingViewModel.attachmentsMetadataFromUris.collectLatest { metadata -> // Check if some of the files were filtered out due to upload config - if (event.uris.size != event.processedAttachments.size) { + if (metadata.uris.size != metadata.attachmentsMetadata.size) { Toast.makeText( context, R.string.stream_compose_message_composer_file_not_supported, Toast.LENGTH_SHORT, ).show() } - onAttachmentsSubmitted(event.processedAttachments) + onAttachmentsSubmitted(metadata.attachmentsMetadata) } } val filePickerLauncher = rememberFilePickerLauncher { uri -> val uris = listOf(uri) - processingViewModel.processAttachmentsFromUris(uris) + processingViewModel.getAttachmentsMetadataFromUrisAsync(uris) } val imagePickerLauncher = rememberVisualMediaPickerLauncher(config.visualMediaAllowMultiple) { uris -> - processingViewModel.processAttachmentsFromUris(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 index ecbf65619ad..8b94dcfa9bb 100644 --- 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 @@ -21,88 +21,199 @@ 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.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch /** - * ViewModel responsible for processing attachment URIs on a background thread. + * Internal ViewModel responsible for asynchronous processing of attachment metadata. * - * This ViewModel handles the conversion of URIs to [AttachmentMetaData] by performing - * disk I/O operations on [Dispatchers.IO] to avoid blocking the main thread and causing ANRs. - * It processes attachments asynchronously and emits results through a [SharedFlow] that the UI can observe. + * 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. * - * The processing is triggered by calling [processAttachmentsFromUris], which performs the following: - * - Reads file metadata from the system storage on a background thread - * - Converts URIs to [AttachmentMetaData] objects - * - Emits an [AttachmentsProcessingResult] event with both the original URIs and processed metadata + * All processing operations run on [DispatcherProvider.IO] to avoid blocking the main thread during + * disk I/O operations. The ViewModel provides three main capabilities: * - * @param storageHelper The helper used to access file metadata from storage. + * 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 + * } + * } * - * @see AttachmentsProcessingResult + * // 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 _result = MutableSharedFlow(extraBufferCapacity = 1) + private val _attachmentsMetadataFromUris = + MutableSharedFlow(extraBufferCapacity = 1) + private val _filesMetadata = + MutableSharedFlow>(extraBufferCapacity = 1) + private val _mediaMetadata = + MutableSharedFlow>(extraBufferCapacity = 1) /** - * Flow of events emitted when attachment processing completes. + * Flow of events emitted when attachments metadata is retrieved from URIs. * - * This [SharedFlow] emits [AttachmentsProcessingResult] events that contain both the original URIs - * and the processed [AttachmentMetaData]. The UI can collect from this flow to react to processing - * completion and update the attachment state accordingly. + * 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. * - * The flow has a buffer capacity of 1, allowing for one event to be buffered if no collectors are active. + * 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 result: SharedFlow = _result.asSharedFlow() + val mediaMetadata: SharedFlow> = + _mediaMetadata.asSharedFlow() /** * Processes a list of attachment URIs in the background and emits the result. * - * This method launches a coroutine on [Dispatchers.IO] to perform disk I/O operations without - * blocking the main thread. Once processing completes, it emits an [AttachmentsProcessingResult] - * event through the [result] flow. + * 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. + * 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 processAttachmentsFromUris(uris: List) { - viewModelScope.launch(Dispatchers.IO) { + fun getAttachmentsMetadataFromUrisAsync(uris: List) { + viewModelScope.launch(DispatcherProvider.IO) { val metadata = storageHelper.getAttachmentsMetadataFromUris(uris) - val attachmentsProcessingResult = AttachmentsProcessingResult( + val attachmentsMetadataFromUris = AttachmentsMetadataFromUris( uris = uris, - processedAttachments = metadata, + attachmentsMetadata = metadata, ) - _result.emit(attachmentsProcessingResult) + _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) } } } /** - * Result of attachment URI processing, containing both the original URIs and the processed metadata. + * Data class representing the result of processing attachment URIs into metadata. * - * This data class is emitted by [AttachmentsProcessingViewModel] when attachment processing completes. - * It pairs the original URIs with their corresponding [AttachmentMetaData], allowing consumers to - * correlate the input with the output and handle the processed attachments appropriately. + * 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. * - * @property uris The original list of URIs that were submitted for processing. - * @property processedAttachments The list of processed [AttachmentMetaData] extracted from the URIs. - * May be smaller than [uris] if some URIs could not be processed or - * were filtered out by attachment filters. + * 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 * - * @see AttachmentsProcessingViewModel + * @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 AttachmentsProcessingResult( +internal data class AttachmentsMetadataFromUris( val uris: List, - val processedAttachments: List, + val attachmentsMetadata: List, ) /** 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..6a4951963ee 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 @@ -97,12 +97,15 @@ public class StorageHelperWrapper( * @return List of [Attachment]s with files prepared for uploading. */ public fun getAttachmentsFromUris(uris: List): List { - return getAttachmentsMetadataFromUris(uris).let(::getAttachmentsFromMetaData) + return getAttachmentsMetadataFromUris(uris).let(::getAttachmentsFromMetaData) // not used } /** * 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 27fd6ea5892..0ea0fb9a839 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,10 +30,10 @@ 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.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow @@ -207,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. * @@ -214,7 +226,7 @@ public class AttachmentsPickerViewModel( * @return List of [Attachment]s ready for uploading. */ public fun getAttachmentsFromUris(uris: List): List { - return storageHelper.getAttachmentsFromUris(uris) + return storageHelper.getAttachmentsFromUris(uris) // not used } /** @@ -227,9 +239,16 @@ public class AttachmentsPickerViewModel( return storageHelper.getAttachmentsForUpload(metaData) } - internal fun getAttachmentsFromMetadataAsync(metaData: List) { - viewModelScope.launch(Dispatchers.IO) { - _attachmentsForUpload.emit(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)) } } 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 index ebeb5d515a7..eb107b5959e 100644 --- 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 @@ -60,14 +60,77 @@ internal class AttachmentsProcessingViewModelTest { } val viewModel = AttachmentsProcessingViewModel(storageHelper) - viewModel.result.test { - viewModel.processAttachmentsFromUris(uris) + viewModel.attachmentsMetadataFromUris.test { + viewModel.getAttachmentsMetadataFromUrisAsync(uris) advanceUntilIdle() val result = awaitItem() assertEquals(uris, result.uris) - assertEquals(expectedMetadata, result.processedAttachments) - assertEquals(2, result.processedAttachments.size) + 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) } } } 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 From 88c2f796ab477c3c542b14bf634647ffb8f316ea Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 2 Dec 2025 17:15:51 +0100 Subject: [PATCH 3/4] Update CHANGELOG.md. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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 From 45d5cc374c86d46d9823b8c1214d97e7b40a23ec Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 2 Dec 2025 18:08:06 +0100 Subject: [PATCH 4/4] Pass application context to StorageHelperWrapper. --- .../compose/ui/messages/attachments/AttachmentsPicker.kt | 1 - .../attachments/factory/AttachmentsPickerFilesTabFactory.kt | 2 +- .../attachments/factory/AttachmentsPickerImagesTabFactory.kt | 2 +- .../attachments/factory/AttachmentsPickerSystemTabFactory.kt | 2 +- .../chat/android/compose/ui/util/StorageHelperWrapper.kt | 2 +- .../compose/viewmodel/messages/AttachmentsPickerViewModel.kt | 2 +- 6 files changed, 5 insertions(+), 6 deletions(-) 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 71148019590..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 @@ -166,7 +166,6 @@ public fun AttachmentsPicker( }, color = ChatTheme.attachmentPickerTheme.backgroundPrimary, ) { - // Tab content AnimatedContent(targetState = selectedTabIndex, label = "") { allowedFactories.getOrNull(it) ?.PickerTabContent( 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 f230a25e922..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 @@ -110,7 +110,7 @@ public class AttachmentsPickerFilesTabFactory : AttachmentsPickerTabFactory { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val processingViewModel = viewModel( - factory = AttachmentsProcessingViewModelFactory(StorageHelperWrapper(context)), + factory = AttachmentsProcessingViewModelFactory(StorageHelperWrapper(context.applicationContext)), ) LaunchedEffect(processingViewModel) { processingViewModel.attachmentsMetadataFromUris.collectLatest { metadata -> 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 5ed1f505cd2..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 @@ -101,7 +101,7 @@ public class AttachmentsPickerImagesTabFactory : AttachmentsPickerTabFactory { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val processingViewModel = viewModel( - factory = AttachmentsProcessingViewModelFactory(StorageHelperWrapper(context)), + factory = AttachmentsProcessingViewModelFactory(StorageHelperWrapper(context.applicationContext)), ) LaunchedEffect(processingViewModel) { processingViewModel.mediaMetadata.collectLatest { metaData -> 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 41f4ad0a224..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 @@ -212,7 +212,7 @@ public class AttachmentsPickerSystemTabFactory( val context = LocalContext.current val processingViewModel = viewModel( - factory = AttachmentsProcessingViewModelFactory(StorageHelperWrapper(context)), + factory = AttachmentsProcessingViewModelFactory(StorageHelperWrapper(context.applicationContext)), ) LaunchedEffect(processingViewModel) { 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 6a4951963ee..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 @@ -97,7 +97,7 @@ public class StorageHelperWrapper( * @return List of [Attachment]s with files prepared for uploading. */ public fun getAttachmentsFromUris(uris: List): List { - return getAttachmentsMetadataFromUris(uris).let(::getAttachmentsFromMetaData) // not used + return getAttachmentsMetadataFromUris(uris).let(::getAttachmentsFromMetaData) } /** 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 0ea0fb9a839..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 @@ -226,7 +226,7 @@ public class AttachmentsPickerViewModel( * @return List of [Attachment]s ready for uploading. */ public fun getAttachmentsFromUris(uris: List): List { - return storageHelper.getAttachmentsFromUris(uris) // not used + return storageHelper.getAttachmentsFromUris(uris) } /**