diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt index e6fb406e7e1..e4bf9e25814 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessagesViewModelFactory.kt @@ -32,6 +32,9 @@ import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.chat.android.models.Message import io.getstream.chat.android.state.extensions.watchChannelAsState import io.getstream.chat.android.ui.common.feature.messages.composer.MessageComposerController +import io.getstream.chat.android.ui.common.feature.messages.composer.internal.ComposerStateSaver +import io.getstream.chat.android.ui.common.feature.messages.composer.internal.NoOpComposerStateSaver +import io.getstream.chat.android.ui.common.feature.messages.composer.internal.SavedStateComposerStateSaver import io.getstream.chat.android.ui.common.feature.messages.composer.mention.DefaultUserLookupHandler import io.getstream.chat.android.ui.common.feature.messages.composer.mention.UserLookupHandler import io.getstream.chat.android.ui.common.feature.messages.list.DateSeparatorHandler @@ -123,21 +126,7 @@ public class MessagesViewModelFactory( */ private val factories: Map, () -> ViewModel> = mapOf( MessageComposerViewModel::class.java to { - MessageComposerViewModel( - MessageComposerController( - chatClient = chatClient, - channelState = channelStateFlow, - mediaRecorder = mediaRecorder, - userLookupHandler = userLookupHandler, - fileToUri = fileToUriConverter, - channelCid = channelId, - config = MessageComposerController.Config( - maxAttachmentCount = maxAttachmentCount, - isLinkPreviewEnabled = isComposerLinkPreviewEnabled, - isDraftMessageEnabled = isComposerDraftMessageEnabled, - ), - ), - ) + createMessageComposerViewModel(NoOpComposerStateSaver) }, MessageListViewModel::class.java to { MessageListViewModel( @@ -190,6 +179,11 @@ public class MessagesViewModelFactory( * that do not require a [SavedStateHandle]. */ override fun create(modelClass: Class, extras: CreationExtras): T { + if (modelClass == MessageComposerViewModel::class.java) { + val savedStateHandle = extras.createSavedStateHandle() + @Suppress("UNCHECKED_CAST") + return createMessageComposerViewModel(SavedStateComposerStateSaver(savedStateHandle)) as T + } if (modelClass == AttachmentsPickerViewModel::class.java) { val savedStateHandle = extras.createSavedStateHandle() @Suppress("UNCHECKED_CAST") @@ -201,4 +195,23 @@ public class MessagesViewModelFactory( } return create(modelClass) } + + private fun createMessageComposerViewModel(stateSaver: ComposerStateSaver): MessageComposerViewModel { + return MessageComposerViewModel( + MessageComposerController( + chatClient = chatClient, + channelState = channelStateFlow, + mediaRecorder = mediaRecorder, + userLookupHandler = userLookupHandler, + fileToUri = fileToUriConverter, + channelCid = channelId, + config = MessageComposerController.Config( + maxAttachmentCount = maxAttachmentCount, + isLinkPreviewEnabled = isComposerLinkPreviewEnabled, + isDraftMessageEnabled = isComposerDraftMessageEnabled, + ), + stateSaver = stateSaver, + ), + ) + } } diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt index b6c3b13ebf3..3dcca8f3f79 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModelTest.kt @@ -41,6 +41,7 @@ import io.getstream.chat.android.state.plugin.state.global.GlobalState import io.getstream.chat.android.test.TestCoroutineExtension import io.getstream.chat.android.test.asCall import io.getstream.chat.android.ui.common.feature.messages.composer.MessageComposerController +import io.getstream.chat.android.ui.common.feature.messages.composer.internal.NoOpComposerStateSaver import io.getstream.chat.android.ui.common.feature.messages.composer.mention.DefaultUserLookupHandler import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention import io.getstream.chat.android.ui.common.feature.messages.composer.mention.MentionType @@ -475,6 +476,7 @@ internal class MessageComposerViewModelTest { ), channelState = MutableStateFlow(channelState), globalState = MutableStateFlow(globalState), + stateSaver = NoOpComposerStateSaver, ), ) } diff --git a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api index 52ef740895d..b07b1c8fa00 100644 --- a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api +++ b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api @@ -582,6 +582,14 @@ public final class io/getstream/chat/android/ui/common/feature/messages/composer public static final fun canUploadFile (Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;)Z } +public final class io/getstream/chat/android/ui/common/feature/messages/composer/internal/ParcelableAttachment$Creator : android/os/Parcelable$Creator { + public fun ()V + public final fun createFromParcel (Landroid/os/Parcel;)Lio/getstream/chat/android/ui/common/feature/messages/composer/internal/ParcelableAttachment; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public final fun newArray (I)[Lio/getstream/chat/android/ui/common/feature/messages/composer/internal/ParcelableAttachment; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + public abstract interface class io/getstream/chat/android/ui/common/feature/messages/composer/mention/CompatUserLookupHandler { public abstract fun handleCompatUserLookup (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0; } diff --git a/stream-chat-android-ui-common/build.gradle.kts b/stream-chat-android-ui-common/build.gradle.kts index 4a0e7ec284d..de02c6cba19 100644 --- a/stream-chat-android-ui-common/build.gradle.kts +++ b/stream-chat-android-ui-common/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.stream.android.library) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.android.junit5) alias(libs.plugins.androidx.baseline.profile) } diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt index 0f0e6c767f1..3fd2cbc2d54 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt @@ -32,6 +32,7 @@ import io.getstream.chat.android.models.PollConfig import io.getstream.chat.android.models.User import io.getstream.chat.android.state.extensions.globalStateFlow import io.getstream.chat.android.state.plugin.state.global.GlobalState +import io.getstream.chat.android.ui.common.feature.messages.composer.internal.ComposerStateSaver import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention import io.getstream.chat.android.ui.common.feature.messages.composer.mention.UserLookupHandler import io.getstream.chat.android.ui.common.feature.messages.composer.typing.TypingSuggester @@ -102,6 +103,7 @@ import java.util.regex.Pattern * @param fileToUri The function used to convert a file to a URI. * @param config The configuration for the message composer. * @param globalState A flow emitting the current [GlobalState]. + * @param stateSaver Store for persisting composer state across process death. */ @OptIn(ExperimentalCoroutinesApi::class) @InternalStreamChatApi @@ -115,6 +117,7 @@ public class MessageComposerController( fileToUri: (File) -> String, private val config: Config = Config(), private val globalState: Flow = chatClient.globalStateFlow, + private val stateSaver: ComposerStateSaver, ) { private val channelType = channelCid.cidToTypeAndId().first @@ -371,6 +374,8 @@ public class MessageComposerController( * Sets up the data loading operations such as observing the maximum allowed message length. */ init { + restoreAttachmentsFromStateSaver() + channelState .filterNotNull() .flatMapLatest { it.channelConfig } @@ -400,6 +405,14 @@ public class MessageComposerController( setupComposerState() } + private fun restoreAttachmentsFromStateSaver() { + val restoredAttachments = stateSaver.restoreAttachments() + ?.filter { it.upload == null || it.upload!!.exists() } + if (!restoredAttachments.isNullOrEmpty()) { + selectedAttachments.value = restoredAttachments + } + } + /** * Sets up the observing operations for various composer states. */ @@ -502,6 +515,10 @@ public class MessageComposerController( } }.launchIn(scope) } + + selectedAttachments + .onEach { attachments -> stateSaver.saveAttachments(attachments) } + .launchIn(scope) } /** @@ -675,6 +692,7 @@ public class MessageComposerController( selectedAttachments.value = emptyList() validationErrors.value = emptyList() alsoSendToChannel.value = false + stateSaver.clear() } private suspend fun clearDraftMessage(messageMode: MessageMode) { diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/internal/ComposerStateSaver.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/internal/ComposerStateSaver.kt new file mode 100644 index 00000000000..7437d317bb9 --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/internal/ComposerStateSaver.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.ui.common.feature.messages.composer.internal + +import io.getstream.chat.android.core.internal.InternalStreamChatApi +import io.getstream.chat.android.models.Attachment + +/** + * Abstraction for persisting and restoring message composer state across process death. + * + * The controller interacts with this interface only — no Android framework imports required. + */ +@InternalStreamChatApi +public interface ComposerStateSaver { + + /** + * Save the attachments from the composer. + * + * Implementations should be cheap to call frequently — this is invoked + * on every change to the attachment list. + */ + public fun saveAttachments(attachments: List) + + /** + * Restores the attachments to the composer. + */ + public fun restoreAttachments(): List? + + /** + * Clears the stored state. + */ + public fun clear() +} diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/internal/NoOpComposerStateSaver.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/internal/NoOpComposerStateSaver.kt new file mode 100644 index 00000000000..5b39247d726 --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/internal/NoOpComposerStateSaver.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.ui.common.feature.messages.composer.internal + +import io.getstream.chat.android.core.internal.InternalStreamChatApi +import io.getstream.chat.android.models.Attachment + +/** + * A [ComposerStateSaver] that does nothing. + * + * Used as a fallback when the ViewModel is created without [CreationExtras] + * (e.g. via the legacy [ViewModelProvider.Factory.create] overload). + * Composer state will not survive process death in this case. + */ +@InternalStreamChatApi +public object NoOpComposerStateSaver : ComposerStateSaver { + override fun saveAttachments(attachments: List): Unit = Unit + override fun restoreAttachments(): List? = null + override fun clear(): Unit = Unit +} diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/internal/ParcelableAttachment.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/internal/ParcelableAttachment.kt new file mode 100644 index 00000000000..fd5eb75b18b --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/internal/ParcelableAttachment.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.ui.common.feature.messages.composer.internal + +import android.os.Parcelable +import io.getstream.chat.android.models.Attachment +import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.RawValue +import java.io.File + +/** + * A Parcelable subset of [Attachment] containing only the fields that are populated + * at compose time (before upload). Fields like `imageUrl`, `thumbUrl`, `assetUrl`, + * `uploadState`, etc. are populated after upload and are intentionally excluded. + */ +@Parcelize +internal data class ParcelableAttachment( + val uploadPath: String?, + val type: String?, + val name: String?, + val fileSize: Int, + val mimeType: String?, + val title: String?, + val extraData: Map, +) : Parcelable + +/** + * Converts an [Attachment] to a [ParcelableAttachment] for persistence via [SavedStateHandle]. + */ +internal fun Attachment.toParcelable(): ParcelableAttachment = ParcelableAttachment( + uploadPath = upload?.absolutePath, + type = type, + name = name, + fileSize = fileSize, + mimeType = mimeType, + title = title, + extraData = extraData, +) + +/** + * Converts a [ParcelableAttachment] back to an [Attachment]. + */ +internal fun ParcelableAttachment.toAttachment(): Attachment = Attachment( + upload = uploadPath?.let { File(it) }, + type = type, + name = name, + fileSize = fileSize, + mimeType = mimeType, + title = title, + extraData = extraData, +) + +/** + * Checks whether all extra data values in the given attachments are safe to parcel. + * Returns `true` if all values can be written to a [android.os.Parcel] without crashing. + */ +internal fun List.areExtraDataParcelSafe(): Boolean = + all { attachment -> attachment.extraData.values.all { it.isParcelSafe() } } + +/** + * Recursively checks whether a value can be safely written via [android.os.Parcel.writeValue]. + */ +private fun Any.isParcelSafe(): Boolean = when (this) { + is String, is Int, is Long, is Float, is Double, is Boolean, is Byte, is Short -> true + is BooleanArray, is ByteArray, is FloatArray, is IntArray, is LongArray, is DoubleArray -> true + is List<*> -> all { it == null || it.isParcelSafe() } + is Map<*, *> -> all { (k, v) -> k is String && (v == null || v.isParcelSafe()) } + else -> false +} diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/internal/SavedStateComposerStateSaver.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/internal/SavedStateComposerStateSaver.kt new file mode 100644 index 00000000000..94f6aa09584 --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/internal/SavedStateComposerStateSaver.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.ui.common.feature.messages.composer.internal + +import androidx.lifecycle.SavedStateHandle +import io.getstream.chat.android.core.internal.InternalStreamChatApi +import io.getstream.chat.android.models.Attachment +import io.getstream.log.StreamLog + +/** + * [ComposerStateSaver] implementation backed by [SavedStateHandle]. + * + * Attachments are only saved when all extra data values are parcel-safe; otherwise + * saving is skipped entirely to avoid crashes during [android.app.Activity.onSaveInstanceState]. + */ +@InternalStreamChatApi +public class SavedStateComposerStateSaver( + private val savedStateHandle: SavedStateHandle, +) : ComposerStateSaver { + + private val logger = StreamLog.getLogger("Chat:ComposerStateSaver") + + override fun saveAttachments(attachments: List) { + if (!attachments.areExtraDataParcelSafe()) { + logger.w { + "[saveAttachments] Skipping attachment save: extraData contains non-parcelable values. " + + "Attachments will not survive process death." + } + savedStateHandle.remove>(KEY_ATTACHMENTS) + return + } + savedStateHandle[KEY_ATTACHMENTS] = ArrayList(attachments.map { it.toParcelable() }) + } + + override fun restoreAttachments(): List? { + return savedStateHandle.get>(KEY_ATTACHMENTS) + ?.map { it.toAttachment() } + } + + override fun clear() { + savedStateHandle.remove>(KEY_ATTACHMENTS) + } + + private companion object { + private const val KEY_ATTACHMENTS = "io.getstream.chat.composer.attachments" + } +} diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTests.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTests.kt index 437aa8188e7..1fd398ac487 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTests.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerControllerTests.kt @@ -20,15 +20,20 @@ import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.audio.AudioPlayer import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.models.App import io.getstream.chat.android.models.AppSettings +import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.ChannelData import io.getstream.chat.android.models.Command import io.getstream.chat.android.models.Config import io.getstream.chat.android.models.DraftMessage +import io.getstream.chat.android.models.FileUploadConfig import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.User import io.getstream.chat.android.state.plugin.state.global.GlobalState import io.getstream.chat.android.test.TestCoroutineExtension +import io.getstream.chat.android.ui.common.feature.messages.composer.internal.ComposerStateSaver +import io.getstream.chat.android.ui.common.feature.messages.composer.internal.NoOpComposerStateSaver import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention import io.getstream.chat.android.ui.common.feature.messages.composer.mention.MentionType import io.getstream.chat.android.ui.common.state.messages.MessageInput @@ -39,11 +44,14 @@ import kotlinx.coroutines.test.runTest import org.amshove.kluent.`should be` import org.amshove.kluent.`should be equal to` import org.amshove.kluent.`should contain` +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import java.io.File import java.util.Date @ExperimentalCoroutinesApi @@ -280,7 +288,10 @@ internal class MessageComposerControllerTests { whenever(globalState.threadDraftMessages) doReturn MutableStateFlow(threadDrafts) } - fun get(): MessageComposerController { + fun get( + stateSaver: ComposerStateSaver = NoOpComposerStateSaver, + config: MessageComposerController.Config = MessageComposerController.Config(), + ): MessageComposerController { return MessageComposerController( channelCid = cid, chatClient = chatClient, @@ -289,10 +300,151 @@ internal class MessageComposerControllerTests { userLookupHandler = mock(), fileToUri = mock(), globalState = MutableStateFlow(globalState), + stateSaver = stateSaver, + config = config, ) } } + // region State Store integration tests + + @Test + fun `restores attachments from state store`() = runTest { + val attachments = listOf( + Attachment(type = "file", name = "doc.pdf"), + ) + val store = FakeComposerStateSaver( + attachments = attachments, + ) + val controller = Fixture() + .givenAppSettings( + AppSettings( + app = App( + name = "test", + fileUploadConfig = FileUploadConfig( + allowedFileExtensions = emptyList(), + allowedMimeTypes = emptyList(), + blockedFileExtensions = emptyList(), + blockedMimeTypes = emptyList(), + sizeLimitInBytes = Long.MAX_VALUE, + ), + imageUploadConfig = FileUploadConfig( + allowedFileExtensions = emptyList(), + allowedMimeTypes = emptyList(), + blockedFileExtensions = emptyList(), + blockedMimeTypes = emptyList(), + sizeLimitInBytes = Long.MAX_VALUE, + ), + ), + ), + ) + .givenAudioPlayer(mock()) + .givenClientState(User("uid1")) + .givenGlobalState() + .givenChannelState() + .get(stateSaver = store) + + assertEquals(1, controller.selectedAttachments.value.size) + assertEquals("doc.pdf", controller.selectedAttachments.value[0].name) + } + + @Test + fun `does not restore when state store has only empty attachments`() = runTest { + val store = FakeComposerStateSaver( + attachments = emptyList(), + ) + val controller = Fixture() + .givenAppSettings(mock()) + .givenAudioPlayer(mock()) + .givenClientState(User("uid1")) + .givenGlobalState() + .givenChannelState() + .get(stateSaver = store) + + assertTrue(controller.selectedAttachments.value.isEmpty()) + } + + @Test + fun `restore drops attachments whose upload file no longer exists`() = runTest { + val existingFile = File.createTempFile("existing", ".jpg").apply { deleteOnExit() } + val missingFile = File("/tmp/non_existent_${System.nanoTime()}.jpg") + val store = FakeComposerStateSaver( + attachments = listOf( + Attachment(upload = existingFile, type = "image", name = "existing.jpg"), + Attachment(upload = missingFile, type = "image", name = "missing.jpg"), + Attachment(type = "image", name = "no-upload.jpg"), + ), + ) + val controller = Fixture() + .givenAppSettings( + AppSettings( + app = App( + name = "test", + fileUploadConfig = FileUploadConfig( + allowedFileExtensions = emptyList(), + allowedMimeTypes = emptyList(), + blockedFileExtensions = emptyList(), + blockedMimeTypes = emptyList(), + sizeLimitInBytes = Long.MAX_VALUE, + ), + imageUploadConfig = FileUploadConfig( + allowedFileExtensions = emptyList(), + allowedMimeTypes = emptyList(), + blockedFileExtensions = emptyList(), + blockedMimeTypes = emptyList(), + sizeLimitInBytes = Long.MAX_VALUE, + ), + ), + ), + ) + .givenAudioPlayer(mock()) + .givenClientState(User("uid1")) + .givenGlobalState() + .givenChannelState() + .get(stateSaver = store) + + assertEquals(2, controller.selectedAttachments.value.size) + assertEquals("existing.jpg", controller.selectedAttachments.value[0].name) + assertEquals("no-upload.jpg", controller.selectedAttachments.value[1].name) + } + + @Test + fun `clearData clears the state store`() = runTest { + val store = FakeComposerStateSaver() + val controller = Fixture() + .givenAppSettings(mock()) + .givenAudioPlayer(mock()) + .givenClientState(User("uid1")) + .givenGlobalState() + .givenChannelState() + .get(stateSaver = store) + + controller.setMessageInput("some text") + controller.clearData() + + assertTrue(store.cleared) + } + + // endregion + + /** + * A simple in-memory [ComposerStateSaver] for testing. + * No Android dependencies required. + */ + private class FakeComposerStateSaver( + private var attachments: List? = null, + ) : ComposerStateSaver { + var cleared = false + private set + + override fun saveAttachments(attachments: List) { this.attachments = attachments } + override fun restoreAttachments(): List? = attachments + override fun clear() { + cleared = true + attachments = null + } + } + companion object { @JvmField @RegisterExtension diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/internal/ParcelableAttachmentTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/internal/ParcelableAttachmentTest.kt new file mode 100644 index 00000000000..ca16932efc8 --- /dev/null +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/internal/ParcelableAttachmentTest.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.ui.common.feature.messages.composer.internal + +import io.getstream.chat.android.models.Attachment +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +internal class ParcelableAttachmentTest { + + @Test + fun `round-trip preserves all fields`() { + val original = Attachment( + upload = File("/tmp/test.jpg"), + type = "image", + name = "test.jpg", + fileSize = 12345, + mimeType = "image/jpeg", + title = "Test Image", + ) + + val restored = original.toParcelable().toAttachment() + + assertEquals("/tmp/test.jpg", restored.upload?.absolutePath) + assertEquals("image", restored.type) + assertEquals("test.jpg", restored.name) + assertEquals(12345, restored.fileSize) + assertEquals("image/jpeg", restored.mimeType) + assertEquals("Test Image", restored.title) + } + + @Test + fun `round-trip with null upload`() { + val original = Attachment( + type = "file", + name = "doc.pdf", + fileSize = 100, + ) + + val restored = original.toParcelable().toAttachment() + + assertNull(restored.upload) + assertEquals("file", restored.type) + assertEquals("doc.pdf", restored.name) + } + + @Test + fun `round-trip preserves extraData with primitives`() { + val original = Attachment( + type = "voicemail", + extraData = mapOf( + "duration" to 5.2f, + "label" to "recording", + "count" to 42, + "bigNumber" to 123456789L, + "ratio" to 3.14, + "active" to true, + ), + ) + + val restored = original.toParcelable().toAttachment() + + assertEquals(5.2f, restored.extraData["duration"]) + assertEquals("recording", restored.extraData["label"]) + assertEquals(42, restored.extraData["count"]) + assertEquals(123456789L, restored.extraData["bigNumber"]) + assertEquals(3.14, restored.extraData["ratio"]) + assertEquals(true, restored.extraData["active"]) + } + + @Test + fun `round-trip preserves extraData with nested list`() { + val waveform = listOf(0.1f, 0.5f, 0.9f, 0.3f) + val original = Attachment( + type = "voicemail", + extraData = mapOf("waveform_data" to waveform), + ) + + val restored = original.toParcelable().toAttachment() + + assertEquals(waveform, restored.extraData["waveform_data"]) + } + + @Test + fun `fields not in ParcelableAttachment are null or default on restore`() { + val original = Attachment( + upload = File("/tmp/test.jpg"), + type = "image", + name = "test.jpg", + imageUrl = "https://example.com/img.jpg", + thumbUrl = "https://example.com/thumb.jpg", + assetUrl = "https://example.com/asset.jpg", + authorName = "Author", + originalWidth = 1920, + originalHeight = 1080, + uploadState = Attachment.UploadState.InProgress(100, 1000), + ) + + val restored = original.toParcelable().toAttachment() + + assertNull(restored.imageUrl) + assertNull(restored.thumbUrl) + assertNull(restored.assetUrl) + assertNull(restored.authorName) + assertNull(restored.originalWidth) + assertNull(restored.originalHeight) + assertNull(restored.uploadState) + } + + // region areExtraDataParcelSafe + + @Test + fun `empty attachments list is parcel safe`() { + assertTrue(emptyList().areExtraDataParcelSafe()) + } + + @Test + fun `attachments with empty extraData are parcel safe`() { + val attachments = listOf( + Attachment(type = "image"), + Attachment(type = "file"), + ) + assertTrue(attachments.areExtraDataParcelSafe()) + } + + @Test + fun `attachments with primitive extraData are parcel safe`() { + val attachments = listOf( + Attachment( + extraData = mapOf( + "string" to "value", + "int" to 42, + "long" to 123L, + "float" to 1.5f, + "double" to 3.14, + "bool" to true, + ), + ), + ) + assertTrue(attachments.areExtraDataParcelSafe()) + } + + @Test + fun `attachments with nested list of primitives are parcel safe`() { + val attachments = listOf( + Attachment(extraData = mapOf("data" to listOf(1.0f, 2.0f, 3.0f))), + ) + assertTrue(attachments.areExtraDataParcelSafe()) + } + + @Test + fun `attachments with nested map of primitives are parcel safe`() { + val attachments = listOf( + Attachment(extraData = mapOf("nested" to mapOf("key" to "value"))), + ) + assertTrue(attachments.areExtraDataParcelSafe()) + } + + @Test + fun `attachments with non-parcelable extraData are NOT parcel safe`() { + val attachments = listOf( + Attachment(extraData = mapOf("custom" to object {})), + ) + assertFalse(attachments.areExtraDataParcelSafe()) + } + + @Test + fun `single unsafe attachment makes entire list unsafe`() { + val attachments = listOf( + Attachment(extraData = mapOf("safe" to "value")), + Attachment(extraData = mapOf("unsafe" to object {})), + ) + assertFalse(attachments.areExtraDataParcelSafe()) + } + + // endregion +} diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/internal/SavedStateComposerStateSaverTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/internal/SavedStateComposerStateSaverTest.kt new file mode 100644 index 00000000000..eb9efcef67e --- /dev/null +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/internal/SavedStateComposerStateSaverTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.ui.common.feature.messages.composer.internal + +import androidx.lifecycle.SavedStateHandle +import io.getstream.chat.android.models.Attachment +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.io.File + +internal class SavedStateComposerStateSaverTest { + + @Test + fun `save and restore attachments with parcel-safe extraData`() { + val store = SavedStateComposerStateSaver(SavedStateHandle()) + val attachments = listOf( + Attachment( + upload = File("/tmp/photo.jpg"), + type = "image", + name = "photo.jpg", + fileSize = 5000, + mimeType = "image/jpeg", + title = "Photo", + ), + Attachment( + upload = File("/tmp/recording.aac"), + type = "voicemail", + name = "recording.aac", + mimeType = "audio/aac", + extraData = mapOf("duration" to 5.2f), + ), + ) + + store.saveAttachments(attachments) + val restored = store.restoreAttachments()!! + + assertEquals(2, restored.size) + assertEquals("/tmp/photo.jpg", restored[0].upload?.absolutePath) + assertEquals("image", restored[0].type) + assertEquals("photo.jpg", restored[0].name) + assertEquals(5000, restored[0].fileSize) + assertEquals("/tmp/recording.aac", restored[1].upload?.absolutePath) + assertEquals("voicemail", restored[1].type) + assertEquals(5.2f, restored[1].extraData["duration"]) + } + + @Test + fun `restore attachments returns null when not saved`() { + val store = SavedStateComposerStateSaver(SavedStateHandle()) + + assertNull(store.restoreAttachments()) + } + + @Test + fun `save attachments skips when extraData is not parcel-safe`() { + val store = SavedStateComposerStateSaver(SavedStateHandle()) + val attachments = listOf( + Attachment(extraData = mapOf("unsafe" to object {})), + ) + + store.saveAttachments(attachments) + + assertNull(store.restoreAttachments()) + } + + @Test + fun `save attachments removes previous value when extraData becomes unsafe`() { + val store = SavedStateComposerStateSaver(SavedStateHandle()) + + // First save with safe data + store.saveAttachments(listOf(Attachment(type = "image", name = "safe.jpg"))) + assertEquals(1, store.restoreAttachments()!!.size) + + // Second save with unsafe data — should remove the key + store.saveAttachments(listOf(Attachment(extraData = mapOf("bad" to object {})))) + assertNull(store.restoreAttachments()) + } + + @Test + fun `clear removes all saved state`() { + val store = SavedStateComposerStateSaver(SavedStateHandle()) + + store.saveAttachments(listOf(Attachment(type = "image"))) + + store.clear() + + assertNull(store.restoreAttachments()) + } + + @Test + fun `save empty attachments list restores as empty list`() { + val store = SavedStateComposerStateSaver(SavedStateHandle()) + + store.saveAttachments(emptyList()) + val restored = store.restoreAttachments()!! + + assertTrue(restored.isEmpty()) + } +} diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index 28cfa3bff82..ca0708e9f2c 100644 --- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api +++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api @@ -5146,6 +5146,7 @@ public final class io/getstream/chat/android/ui/viewmodel/messages/MessageListVi public fun (Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/client/setup/state/ClientState;Lio/getstream/sdk/chat/audio/recording/StreamMediaRecorder;Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/UserLookupHandler;Lkotlin/jvm/functions/Function1;IZIZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFooterVisibility;Lio/getstream/chat/android/ui/common/feature/messages/list/DateSeparatorHandler;Lio/getstream/chat/android/ui/common/feature/messages/list/DateSeparatorHandler;Lio/getstream/chat/android/ui/common/feature/messages/list/MessagePositionHandler;ZZZZ)V public synthetic fun (Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/client/setup/state/ClientState;Lio/getstream/sdk/chat/audio/recording/StreamMediaRecorder;Lio/getstream/chat/android/ui/common/feature/messages/composer/mention/UserLookupHandler;Lkotlin/jvm/functions/Function1;IZIZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFooterVisibility;Lio/getstream/chat/android/ui/common/feature/messages/list/DateSeparatorHandler;Lio/getstream/chat/android/ui/common/feature/messages/list/DateSeparatorHandler;Lio/getstream/chat/android/ui/common/feature/messages/list/MessagePositionHandler;ZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; + public fun create (Ljava/lang/Class;Landroidx/lifecycle/viewmodel/CreationExtras;)Landroidx/lifecycle/ViewModel; } public final class io/getstream/chat/android/ui/viewmodel/messages/MessageListViewModelFactory$Builder { diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageListViewModelFactory.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageListViewModelFactory.kt index 2f8ee1d09bd..842ccb6e468 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageListViewModelFactory.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageListViewModelFactory.kt @@ -20,6 +20,8 @@ import android.content.Context import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.CreationExtras import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.setup.state.ClientState @@ -27,6 +29,9 @@ import io.getstream.chat.android.models.Message import io.getstream.chat.android.state.extensions.watchChannelAsState import io.getstream.chat.android.ui.ChatUI import io.getstream.chat.android.ui.common.feature.messages.composer.MessageComposerController +import io.getstream.chat.android.ui.common.feature.messages.composer.internal.ComposerStateSaver +import io.getstream.chat.android.ui.common.feature.messages.composer.internal.NoOpComposerStateSaver +import io.getstream.chat.android.ui.common.feature.messages.composer.internal.SavedStateComposerStateSaver import io.getstream.chat.android.ui.common.feature.messages.composer.mention.CompatUserLookupHandler import io.getstream.chat.android.ui.common.feature.messages.composer.mention.DefaultUserLookupHandler import io.getstream.chat.android.ui.common.feature.messages.composer.mention.UserLookupHandler @@ -135,20 +140,7 @@ public class MessageListViewModelFactory @JvmOverloads constructor( ) }, MessageComposerViewModel::class.java to { - MessageComposerViewModel( - MessageComposerController( - channelCid = cid, - chatClient = chatClient, - mediaRecorder = mediaRecorder, - userLookupHandler = userLookupHandler, - fileToUri = fileToUri, - channelState = channelStateFlow, - config = MessageComposerController.Config( - maxAttachmentCount = maxAttachmentCount, - isDraftMessageEnabled = isComposerDraftMessagesEnabled, - ), - ), - ) + createMessageComposerViewModel(NoOpComposerStateSaver) }, ) @@ -160,6 +152,33 @@ public class MessageListViewModelFactory @JvmOverloads constructor( return viewModel as T } + override fun create(modelClass: Class, extras: CreationExtras): T { + if (modelClass == MessageComposerViewModel::class.java) { + val savedStateHandle = extras.createSavedStateHandle() + @Suppress("UNCHECKED_CAST") + return createMessageComposerViewModel(SavedStateComposerStateSaver(savedStateHandle)) as T + } + return create(modelClass) + } + + private fun createMessageComposerViewModel(stateSaver: ComposerStateSaver): MessageComposerViewModel { + return MessageComposerViewModel( + MessageComposerController( + channelCid = cid, + chatClient = chatClient, + mediaRecorder = mediaRecorder, + userLookupHandler = userLookupHandler, + fileToUri = fileToUri, + channelState = channelStateFlow, + config = MessageComposerController.Config( + maxAttachmentCount = maxAttachmentCount, + isDraftMessageEnabled = isComposerDraftMessagesEnabled, + ), + stateSaver = stateSaver, + ), + ) + } + @Suppress("NEWER_VERSION_IN_SINCE_KOTLIN") public class Builder @SinceKotlin("99999.9") diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/messages/MessageComposerViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/messages/MessageComposerViewModelTest.kt index f8156e2fa46..1df9d3a59bc 100644 --- a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/messages/MessageComposerViewModelTest.kt +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/messages/MessageComposerViewModelTest.kt @@ -41,6 +41,7 @@ import io.getstream.chat.android.state.plugin.state.global.GlobalState import io.getstream.chat.android.test.TestCoroutineExtension import io.getstream.chat.android.test.asCall import io.getstream.chat.android.ui.common.feature.messages.composer.MessageComposerController +import io.getstream.chat.android.ui.common.feature.messages.composer.internal.NoOpComposerStateSaver import io.getstream.chat.android.ui.common.feature.messages.composer.mention.DefaultUserLookupHandler import io.getstream.chat.android.ui.common.state.messages.Edit import io.getstream.chat.android.ui.common.state.messages.MessageMode @@ -445,6 +446,7 @@ internal class MessageComposerViewModelTest { ), channelState = MutableStateFlow(channelState), globalState = MutableStateFlow(globalState), + stateSaver = NoOpComposerStateSaver, ), ) }