Skip to content

Commit 4924fd1

Browse files
authored
Handle unresolvable attachments in picker (#6285)
- Update `StorageHelper` and `AttachmentMetaDataMapper` to safely handle cases where content URIs (e.g. cloud-backed files) cannot be opened. - Introduce `hasUnresolvedAttachments` state in `AttachmentsPickerViewModel` to track failed attachment resolutions. - Show a toast message in both View-based and Compose attachment pickers when files are unavailable and need to be downloaded to the device. - Add `clearUnresolvedAttachments` to reset the error state after it has been consumed by the UI. - Add unit tests for unresolved attachment scenarios in `AttachmentsPickerViewModelTest`.
1 parent f9084ff commit 4924fd1

8 files changed

Lines changed: 173 additions & 15 deletions

File tree

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentsPicker.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package io.getstream.chat.android.compose.ui.messages.attachments
1818

19+
import android.widget.Toast
1920
import androidx.activity.compose.BackHandler
2021
import androidx.compose.animation.AnimatedContent
2122
import androidx.compose.foundation.background
@@ -35,6 +36,7 @@ import androidx.compose.material3.Icon
3536
import androidx.compose.material3.IconButton
3637
import androidx.compose.material3.Surface
3738
import androidx.compose.runtime.Composable
39+
import androidx.compose.runtime.LaunchedEffect
3840
import androidx.compose.runtime.getValue
3941
import androidx.compose.runtime.mutableIntStateOf
4042
import androidx.compose.runtime.remember
@@ -43,6 +45,7 @@ import androidx.compose.runtime.setValue
4345
import androidx.compose.ui.Alignment
4446
import androidx.compose.ui.Modifier
4547
import androidx.compose.ui.graphics.Shape
48+
import androidx.compose.ui.platform.LocalContext
4649
import androidx.compose.ui.platform.LocalLayoutDirection
4750
import androidx.compose.ui.platform.testTag
4851
import androidx.compose.ui.res.painterResource
@@ -102,6 +105,20 @@ public fun AttachmentsPicker(
102105
}
103106
}
104107
BackHandler(onBack = dismissAction)
108+
109+
val context = LocalContext.current
110+
val hasUnresolvedAttachments = attachmentsPickerViewModel.hasUnresolvedAttachments
111+
LaunchedEffect(hasUnresolvedAttachments) {
112+
if (hasUnresolvedAttachments) {
113+
Toast.makeText(
114+
context,
115+
context.getString(R.string.stream_ui_attachment_picker_error_unresolvable_attachments),
116+
Toast.LENGTH_LONG,
117+
).show()
118+
attachmentsPickerViewModel.clearUnresolvedAttachments()
119+
}
120+
}
121+
105122
// Cross-validate requested tabFactories with the allowed ones from BE
106123
val filter = remember { AttachmentsPickerTabFactoryFilter() }
107124
val allowedFactories = filter.filterAllowedFactories(tabFactories, attachmentsPickerViewModel.channel, messageMode)

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StorageHelperWrapper.kt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import io.getstream.chat.android.models.Attachment
2222
import io.getstream.chat.android.ui.common.helper.internal.AttachmentFilter
2323
import io.getstream.chat.android.ui.common.helper.internal.StorageHelper
2424
import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData
25+
import io.getstream.log.taggedLogger
2526

2627
/**
2728
* Wrapper around the [StorageHelper] class, with some extra functionality that makes it easier to
@@ -39,6 +40,8 @@ public class StorageHelperWrapper(
3940
private val attachmentFilter: AttachmentFilter = AttachmentFilter(),
4041
) {
4142

43+
private val logger by taggedLogger("Chat:StorageHelperWrapper")
44+
4245
/**
4346
* Loads a list of file metadata from the system and filters it against file types accepted by the backend.
4447
*
@@ -71,12 +74,20 @@ public class StorageHelperWrapper(
7174
/**
7275
* Loads attachment files from the provided metadata, so that we can upload them.
7376
*
77+
* Attachments whose underlying content cannot be resolved (e.g. cloud-backed URIs from
78+
* Google Drive that are not locally available) are skipped and a warning is logged.
79+
*
7480
* @param metaData The list of attachment meta data that we transform.
75-
* @return List of [Attachment]s with files prepared for uploading.
81+
* @return List of [Attachment]s with files prepared for uploading. May be smaller than
82+
* [metaData] if some entries could not be resolved.
7683
*/
7784
private fun getAttachmentsFromMetaData(metaData: List<AttachmentMetaData>): List<Attachment> {
78-
return metaData.map {
85+
return metaData.mapNotNull {
7986
val fileFromUri = storageHelper.getCachedFileFromUri(context, it)
87+
if (fileFromUri == null && it.uri != null) {
88+
logger.w { "[getAttachmentsFromMetaData] Skipping unresolvable attachment: ${it.title} (${it.uri})" }
89+
return@mapNotNull null
90+
}
8091

8192
Attachment(
8293
upload = fileFromUri,

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModel.kt

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,22 @@ public class AttachmentsPickerViewModel @JvmOverloads constructor(
131131
savedStateHandle.get<Boolean>(KEY_IS_SHOWING_ATTACHMENTS) ?: false,
132132
)
133133

134+
/**
135+
* Set to `true` when one or more selected attachments could not be resolved (e.g. the
136+
* content URI points to a cloud file that is not locally available). The UI layer should
137+
* observe this flag and show an appropriate message, then call [clearUnresolvedAttachments]
138+
* to reset it.
139+
*/
140+
internal var hasUnresolvedAttachments: Boolean by mutableStateOf(false)
141+
private set
142+
143+
/**
144+
* Resets the [hasUnresolvedAttachments] flag after the UI has consumed the event.
145+
*/
146+
internal fun clearUnresolvedAttachments() {
147+
hasUnresolvedAttachments = false
148+
}
149+
134150
/**
135151
* Loads all the items based on the current type.
136152
*/
@@ -220,9 +236,14 @@ public class AttachmentsPickerViewModel @JvmOverloads constructor(
220236
*/
221237
public fun getSelectedAttachments(): List<Attachment> {
222238
val dataSet = if (attachmentsPickerMode == Files) files else images
223-
val selectedAttachments = dataSet.filter { it.isSelected }
224-
225-
return storageHelper.getAttachmentsForUpload(selectedAttachments.map { it.attachmentMetaData })
239+
val selectedMetaData = dataSet
240+
.filter(AttachmentPickerItemState::isSelected)
241+
.map(AttachmentPickerItemState::attachmentMetaData)
242+
val attachments = storageHelper.getAttachmentsForUpload(selectedMetaData)
243+
if (attachments.size < selectedMetaData.size) {
244+
hasUnresolvedAttachments = true
245+
}
246+
return attachments
226247
}
227248

228249
/**
@@ -274,6 +295,9 @@ public class AttachmentsPickerViewModel @JvmOverloads constructor(
274295
val attachments = withContext(DispatcherProvider.IO) {
275296
getAttachmentsFromMetaData(metadata)
276297
}
298+
if (attachments.size < metadata.size) {
299+
hasUnresolvedAttachments = true
300+
}
277301
onComplete(attachments)
278302
}
279303
}

stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/AttachmentsPickerViewModelTest.kt

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,83 @@ internal class AttachmentsPickerViewModelTest {
202202
assertEquals(expectedAttachments, result)
203203
}
204204

205+
@Test
206+
fun `Given metadata with unresolvable URIs When getting attachments async Should set hasUnresolvedAttachments`() = runTest {
207+
val metadata = listOf(imageAttachment1, imageAttachment2)
208+
val storageHelper: StorageHelperWrapper = mock {
209+
whenever(it.getAttachmentsForUpload(metadata)) doReturn listOf(Attachment(type = "image", upload = mock()))
210+
}
211+
val viewModel = AttachmentsPickerViewModel(storageHelper, channelState)
212+
213+
assertFalse(viewModel.hasUnresolvedAttachments)
214+
215+
var result: List<Attachment>? = null
216+
viewModel.getAttachmentsFromMetadataAsync(metadata) { result = it }
217+
advanceUntilIdle()
218+
219+
assertEquals(1, result?.size)
220+
assertTrue(viewModel.hasUnresolvedAttachments)
221+
}
222+
223+
@Test
224+
fun `Given selected images with unresolvable URIs When getting selected async Should set hasUnresolvedAttachments`() = runTest {
225+
val storageHelper: StorageHelperWrapper = mock {
226+
whenever(it.getMedia()) doReturn listOf(imageAttachment1, imageAttachment2)
227+
whenever(it.getAttachmentsForUpload(any())) doReturn listOf(Attachment(type = "image", upload = mock()))
228+
}
229+
val viewModel = AttachmentsPickerViewModel(storageHelper, channelState)
230+
231+
viewModel.changeAttachmentState(true)
232+
viewModel.loadData()
233+
viewModel.changeSelectedAttachments(viewModel.images.first())
234+
viewModel.changeSelectedAttachments(viewModel.images.last())
235+
236+
assertFalse(viewModel.hasUnresolvedAttachments)
237+
238+
var result: List<Attachment>? = null
239+
viewModel.getSelectedAttachmentsAsync { result = it }
240+
advanceUntilIdle()
241+
242+
assertEquals(1, result?.size)
243+
assertTrue(viewModel.hasUnresolvedAttachments)
244+
}
245+
246+
@Test
247+
fun `Given hasUnresolvedAttachments is true When clearing Should reset to false`() = runTest {
248+
val metadata = listOf(imageAttachment1, imageAttachment2)
249+
val storageHelper: StorageHelperWrapper = mock {
250+
whenever(it.getAttachmentsForUpload(metadata)) doReturn listOf(Attachment(type = "image", upload = mock()))
251+
}
252+
val viewModel = AttachmentsPickerViewModel(storageHelper, channelState)
253+
254+
viewModel.getAttachmentsFromMetadataAsync(metadata) {}
255+
advanceUntilIdle()
256+
assertTrue(viewModel.hasUnresolvedAttachments)
257+
258+
viewModel.clearUnresolvedAttachments()
259+
assertFalse(viewModel.hasUnresolvedAttachments)
260+
}
261+
262+
@Test
263+
fun `Given all attachments resolved When getting attachments async Should not set hasUnresolvedAttachments`() = runTest {
264+
val metadata = listOf(imageAttachment1, imageAttachment2)
265+
val expectedAttachments = listOf(
266+
Attachment(type = "image", upload = mock()),
267+
Attachment(type = "image", upload = mock()),
268+
)
269+
val storageHelper: StorageHelperWrapper = mock {
270+
whenever(it.getAttachmentsForUpload(metadata)) doReturn expectedAttachments
271+
}
272+
val viewModel = AttachmentsPickerViewModel(storageHelper, channelState)
273+
274+
var result: List<Attachment>? = null
275+
viewModel.getAttachmentsFromMetadataAsync(metadata) { result = it }
276+
advanceUntilIdle()
277+
278+
assertEquals(2, result?.size)
279+
assertFalse(viewModel.hasUnresolvedAttachments)
280+
}
281+
205282
companion object {
206283

207284
private val imageAttachment1 = AttachmentMetaData(

stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/internal/StorageHelper.kt

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import android.webkit.MimeTypeMap
2525
import io.getstream.chat.android.client.internal.file.StreamFileManager
2626
import io.getstream.chat.android.models.AttachmentType
2727
import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData
28+
import io.getstream.log.taggedLogger
2829
import java.io.File
2930

3031
/**
@@ -43,6 +44,7 @@ import java.io.File
4344
@Suppress("TooManyFunctions")
4445
public class StorageHelper {
4546
private val fileManager = StreamFileManager()
47+
private val logger by taggedLogger("Chat:StorageHelper")
4648

4749
/**
4850
* Retrieves or creates a cached copy of a file from the given attachment metadata.
@@ -55,26 +57,33 @@ public class StorageHelper {
5557
* to prevent naming conflicts. The file name is derived from the attachment's title with
5658
* proper extension handling.
5759
*
60+
* Content URIs backed by cloud storage providers (e.g. Google Drive) may fail to provide
61+
* an input stream if the file is not locally available. In such cases this method returns
62+
* `null` instead of throwing.
63+
*
5864
* @param context The Android context used to access the content resolver and cache directory.
5965
* @param attachmentMetaData The attachment metadata containing either a file or URI reference.
60-
* @return A [File] object pointing to the cached file, or `null` if both file and URI are null.
61-
*
62-
* @throws java.io.IOException If there's an error reading from the URI or writing to cache.
66+
* @return A [File] object pointing to the cached file, or `null` if both file and URI are null
67+
* or if the content could not be read from the URI.
6368
*/
69+
@Suppress("TooGenericExceptionCaught")
6470
public fun getCachedFileFromUri(
6571
context: Context,
6672
attachmentMetaData: AttachmentMetaData,
6773
): File? {
6874
if (attachmentMetaData.file == null && attachmentMetaData.uri == null) {
6975
return null
7076
}
71-
if (attachmentMetaData.file != null) {
72-
return attachmentMetaData.file!!
73-
}
77+
attachmentMetaData.file?.let { return it }
7478

79+
val uri = attachmentMetaData.uri ?: return null
7580
val fileName = attachmentMetaData.getTitleWithExtension()
76-
val inputStream = context.contentResolver.openInputStream(attachmentMetaData.uri!!)
77-
?: return null
81+
val inputStream = try {
82+
context.contentResolver.openInputStream(uri)
83+
} catch (e: Exception) {
84+
logger.e(e) { "[getCachedFileFromUri] Failed to open input stream for URI: $uri" }
85+
null
86+
} ?: return null
7887
return fileManager.writeFileInTimestampedCache(
7988
context = context,
8089
fileName = fileName,

stream-chat-android-ui-common/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
<string name="stream_ui_message_composer_permissions_files_allow_more_visual_media">Allow access to more visual media</string>
4343

4444
<!-- Attachment system picker -->
45+
<string name="stream_ui_attachment_picker_error_unresolvable_attachments">Some files could not be loaded and were skipped.</string>
4546
<string name="stream_ui_message_composer_attachment_picker_files">Files</string>
4647
<string name="stream_ui_message_composer_attachment_picker_media">Media</string>
4748
<string name="stream_ui_message_composer_attachment_picker_capture">Capture</string>

stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/attachment/picker/AttachmentsPickerDialogFragment.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import android.view.View
2323
import android.view.ViewGroup
2424
import android.widget.CheckedTextView
2525
import android.widget.FrameLayout
26+
import android.widget.Toast
2627
import androidx.constraintlayout.widget.ConstraintLayout
2728
import androidx.core.view.descendants
2829
import androidx.viewpager2.widget.ViewPager2
@@ -62,7 +63,15 @@ public class AttachmentsPickerDialogFragment : BottomSheetDialogFragment() {
6263
* A listener that is invoked when attachment picking has been completed
6364
*/
6465
private var attachmentSelectionListener: AttachmentSelectionListener? = AttachmentSelectionListener { attachments ->
65-
attachmentsSelectionListener?.onAttachmentsSelected(attachments.map { it.toAttachment(requireContext()) })
66+
val resolved = attachments.mapNotNull { it.toAttachment(requireContext()) }
67+
if (resolved.size < attachments.size) {
68+
Toast.makeText(
69+
requireContext(),
70+
R.string.stream_ui_attachment_picker_error_unresolvable_attachments,
71+
Toast.LENGTH_LONG,
72+
).show()
73+
}
74+
attachmentsSelectionListener?.onAttachmentsSelected(resolved)
6675
}
6776

6877
/**

stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/internal/AttachmentMetaDataMapper.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,18 @@ import io.getstream.chat.android.models.Attachment
2121
import io.getstream.chat.android.ui.common.helper.internal.StorageHelper
2222
import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData
2323

24-
internal fun AttachmentMetaData.toAttachment(context: Context): Attachment {
24+
/**
25+
* Converts this metadata into an [Attachment] ready for upload.
26+
*
27+
* @param context Used to access the content resolver for caching the file.
28+
* @return The attachment, or `null` when the content URI cannot be resolved
29+
* (e.g. a cloud-backed file that is not locally available).
30+
*/
31+
internal fun AttachmentMetaData.toAttachment(context: Context): Attachment? {
2532
val fileFromUri = StorageHelper().getCachedFileFromUri(context, this)
33+
if (fileFromUri == null && uri != null) {
34+
return null
35+
}
2636
return Attachment(
2737
upload = fileFromUri,
2838
type = type,

0 commit comments

Comments
 (0)