Skip to content

Commit d0c2f78

Browse files
authored
Merge pull request #6377 from GetStream/bug/AND-1145_fix_attachments_lost_on_activity_recreation
Fix attachments lost on activity recreation
1 parent aa5a191 commit d0c2f78

15 files changed

Lines changed: 781 additions & 30 deletions

File tree

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

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider
3232
import io.getstream.chat.android.models.Message
3333
import io.getstream.chat.android.state.extensions.watchChannelAsState
3434
import io.getstream.chat.android.ui.common.feature.messages.composer.MessageComposerController
35+
import io.getstream.chat.android.ui.common.feature.messages.composer.internal.ComposerStateSaver
36+
import io.getstream.chat.android.ui.common.feature.messages.composer.internal.NoOpComposerStateSaver
37+
import io.getstream.chat.android.ui.common.feature.messages.composer.internal.SavedStateComposerStateSaver
3538
import io.getstream.chat.android.ui.common.feature.messages.composer.mention.DefaultUserLookupHandler
3639
import io.getstream.chat.android.ui.common.feature.messages.composer.mention.UserLookupHandler
3740
import io.getstream.chat.android.ui.common.feature.messages.list.DateSeparatorHandler
@@ -123,21 +126,7 @@ public class MessagesViewModelFactory(
123126
*/
124127
private val factories: Map<Class<*>, () -> ViewModel> = mapOf(
125128
MessageComposerViewModel::class.java to {
126-
MessageComposerViewModel(
127-
MessageComposerController(
128-
chatClient = chatClient,
129-
channelState = channelStateFlow,
130-
mediaRecorder = mediaRecorder,
131-
userLookupHandler = userLookupHandler,
132-
fileToUri = fileToUriConverter,
133-
channelCid = channelId,
134-
config = MessageComposerController.Config(
135-
maxAttachmentCount = maxAttachmentCount,
136-
isLinkPreviewEnabled = isComposerLinkPreviewEnabled,
137-
isDraftMessageEnabled = isComposerDraftMessageEnabled,
138-
),
139-
),
140-
)
129+
createMessageComposerViewModel(NoOpComposerStateSaver)
141130
},
142131
MessageListViewModel::class.java to {
143132
MessageListViewModel(
@@ -190,6 +179,11 @@ public class MessagesViewModelFactory(
190179
* that do not require a [SavedStateHandle].
191180
*/
192181
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
182+
if (modelClass == MessageComposerViewModel::class.java) {
183+
val savedStateHandle = extras.createSavedStateHandle()
184+
@Suppress("UNCHECKED_CAST")
185+
return createMessageComposerViewModel(SavedStateComposerStateSaver(savedStateHandle)) as T
186+
}
193187
if (modelClass == AttachmentsPickerViewModel::class.java) {
194188
val savedStateHandle = extras.createSavedStateHandle()
195189
@Suppress("UNCHECKED_CAST")
@@ -201,4 +195,23 @@ public class MessagesViewModelFactory(
201195
}
202196
return create(modelClass)
203197
}
198+
199+
private fun createMessageComposerViewModel(stateSaver: ComposerStateSaver): MessageComposerViewModel {
200+
return MessageComposerViewModel(
201+
MessageComposerController(
202+
chatClient = chatClient,
203+
channelState = channelStateFlow,
204+
mediaRecorder = mediaRecorder,
205+
userLookupHandler = userLookupHandler,
206+
fileToUri = fileToUriConverter,
207+
channelCid = channelId,
208+
config = MessageComposerController.Config(
209+
maxAttachmentCount = maxAttachmentCount,
210+
isLinkPreviewEnabled = isComposerLinkPreviewEnabled,
211+
isDraftMessageEnabled = isComposerDraftMessageEnabled,
212+
),
213+
stateSaver = stateSaver,
214+
),
215+
)
216+
}
204217
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import io.getstream.chat.android.state.plugin.state.global.GlobalState
4141
import io.getstream.chat.android.test.TestCoroutineExtension
4242
import io.getstream.chat.android.test.asCall
4343
import io.getstream.chat.android.ui.common.feature.messages.composer.MessageComposerController
44+
import io.getstream.chat.android.ui.common.feature.messages.composer.internal.NoOpComposerStateSaver
4445
import io.getstream.chat.android.ui.common.feature.messages.composer.mention.DefaultUserLookupHandler
4546
import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention
4647
import io.getstream.chat.android.ui.common.feature.messages.composer.mention.MentionType
@@ -475,6 +476,7 @@ internal class MessageComposerViewModelTest {
475476
),
476477
channelState = MutableStateFlow(channelState),
477478
globalState = MutableStateFlow(globalState),
479+
stateSaver = NoOpComposerStateSaver,
478480
),
479481
)
480482
}

stream-chat-android-ui-common/api/stream-chat-android-ui-common.api

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,14 @@ public final class io/getstream/chat/android/ui/common/feature/messages/composer
582582
public static final fun canUploadFile (Lio/getstream/chat/android/ui/common/state/messages/composer/MessageComposerState;)Z
583583
}
584584

585+
public final class io/getstream/chat/android/ui/common/feature/messages/composer/internal/ParcelableAttachment$Creator : android/os/Parcelable$Creator {
586+
public fun <init> ()V
587+
public final fun createFromParcel (Landroid/os/Parcel;)Lio/getstream/chat/android/ui/common/feature/messages/composer/internal/ParcelableAttachment;
588+
public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object;
589+
public final fun newArray (I)[Lio/getstream/chat/android/ui/common/feature/messages/composer/internal/ParcelableAttachment;
590+
public synthetic fun newArray (I)[Ljava/lang/Object;
591+
}
592+
585593
public abstract interface class io/getstream/chat/android/ui/common/feature/messages/composer/mention/CompatUserLookupHandler {
586594
public abstract fun handleCompatUserLookup (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Lkotlin/jvm/functions/Function0;
587595
}

stream-chat-android-ui-common/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ plugins {
44
alias(libs.plugins.stream.android.library)
55
alias(libs.plugins.kotlin.android)
66
alias(libs.plugins.kotlin.compose)
7+
alias(libs.plugins.kotlin.parcelize)
78
alias(libs.plugins.android.junit5)
89
alias(libs.plugins.androidx.baseline.profile)
910
}

stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import io.getstream.chat.android.models.PollConfig
3232
import io.getstream.chat.android.models.User
3333
import io.getstream.chat.android.state.extensions.globalStateFlow
3434
import io.getstream.chat.android.state.plugin.state.global.GlobalState
35+
import io.getstream.chat.android.ui.common.feature.messages.composer.internal.ComposerStateSaver
3536
import io.getstream.chat.android.ui.common.feature.messages.composer.mention.Mention
3637
import io.getstream.chat.android.ui.common.feature.messages.composer.mention.UserLookupHandler
3738
import io.getstream.chat.android.ui.common.feature.messages.composer.typing.TypingSuggester
@@ -102,6 +103,7 @@ import java.util.regex.Pattern
102103
* @param fileToUri The function used to convert a file to a URI.
103104
* @param config The configuration for the message composer.
104105
* @param globalState A flow emitting the current [GlobalState].
106+
* @param stateSaver Store for persisting composer state across process death.
105107
*/
106108
@OptIn(ExperimentalCoroutinesApi::class)
107109
@InternalStreamChatApi
@@ -115,6 +117,7 @@ public class MessageComposerController(
115117
fileToUri: (File) -> String,
116118
private val config: Config = Config(),
117119
private val globalState: Flow<GlobalState> = chatClient.globalStateFlow,
120+
private val stateSaver: ComposerStateSaver,
118121
) {
119122

120123
private val channelType = channelCid.cidToTypeAndId().first
@@ -371,6 +374,8 @@ public class MessageComposerController(
371374
* Sets up the data loading operations such as observing the maximum allowed message length.
372375
*/
373376
init {
377+
restoreAttachmentsFromStateSaver()
378+
374379
channelState
375380
.filterNotNull()
376381
.flatMapLatest { it.channelConfig }
@@ -400,6 +405,14 @@ public class MessageComposerController(
400405
setupComposerState()
401406
}
402407

408+
private fun restoreAttachmentsFromStateSaver() {
409+
val restoredAttachments = stateSaver.restoreAttachments()
410+
?.filter { it.upload == null || it.upload!!.exists() }
411+
if (!restoredAttachments.isNullOrEmpty()) {
412+
selectedAttachments.value = restoredAttachments
413+
}
414+
}
415+
403416
/**
404417
* Sets up the observing operations for various composer states.
405418
*/
@@ -502,6 +515,10 @@ public class MessageComposerController(
502515
}
503516
}.launchIn(scope)
504517
}
518+
519+
selectedAttachments
520+
.onEach { attachments -> stateSaver.saveAttachments(attachments) }
521+
.launchIn(scope)
505522
}
506523

507524
/**
@@ -675,6 +692,7 @@ public class MessageComposerController(
675692
selectedAttachments.value = emptyList()
676693
validationErrors.value = emptyList()
677694
alsoSendToChannel.value = false
695+
stateSaver.clear()
678696
}
679697

680698
private suspend fun clearDraftMessage(messageMode: MessageMode) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.chat.android.ui.common.feature.messages.composer.internal
18+
19+
import io.getstream.chat.android.core.internal.InternalStreamChatApi
20+
import io.getstream.chat.android.models.Attachment
21+
22+
/**
23+
* Abstraction for persisting and restoring message composer state across process death.
24+
*
25+
* The controller interacts with this interface only — no Android framework imports required.
26+
*/
27+
@InternalStreamChatApi
28+
public interface ComposerStateSaver {
29+
30+
/**
31+
* Save the attachments from the composer.
32+
*
33+
* Implementations should be cheap to call frequently — this is invoked
34+
* on every change to the attachment list.
35+
*/
36+
public fun saveAttachments(attachments: List<Attachment>)
37+
38+
/**
39+
* Restores the attachments to the composer.
40+
*/
41+
public fun restoreAttachments(): List<Attachment>?
42+
43+
/**
44+
* Clears the stored state.
45+
*/
46+
public fun clear()
47+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.chat.android.ui.common.feature.messages.composer.internal
18+
19+
import io.getstream.chat.android.core.internal.InternalStreamChatApi
20+
import io.getstream.chat.android.models.Attachment
21+
22+
/**
23+
* A [ComposerStateSaver] that does nothing.
24+
*
25+
* Used as a fallback when the ViewModel is created without [CreationExtras]
26+
* (e.g. via the legacy [ViewModelProvider.Factory.create] overload).
27+
* Composer state will not survive process death in this case.
28+
*/
29+
@InternalStreamChatApi
30+
public object NoOpComposerStateSaver : ComposerStateSaver {
31+
override fun saveAttachments(attachments: List<Attachment>): Unit = Unit
32+
override fun restoreAttachments(): List<Attachment>? = null
33+
override fun clear(): Unit = Unit
34+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.chat.android.ui.common.feature.messages.composer.internal
18+
19+
import android.os.Parcelable
20+
import io.getstream.chat.android.models.Attachment
21+
import kotlinx.parcelize.Parcelize
22+
import kotlinx.parcelize.RawValue
23+
import java.io.File
24+
25+
/**
26+
* A Parcelable subset of [Attachment] containing only the fields that are populated
27+
* at compose time (before upload). Fields like `imageUrl`, `thumbUrl`, `assetUrl`,
28+
* `uploadState`, etc. are populated after upload and are intentionally excluded.
29+
*/
30+
@Parcelize
31+
internal data class ParcelableAttachment(
32+
val uploadPath: String?,
33+
val type: String?,
34+
val name: String?,
35+
val fileSize: Int,
36+
val mimeType: String?,
37+
val title: String?,
38+
val extraData: Map<String, @RawValue Any>,
39+
) : Parcelable
40+
41+
/**
42+
* Converts an [Attachment] to a [ParcelableAttachment] for persistence via [SavedStateHandle].
43+
*/
44+
internal fun Attachment.toParcelable(): ParcelableAttachment = ParcelableAttachment(
45+
uploadPath = upload?.absolutePath,
46+
type = type,
47+
name = name,
48+
fileSize = fileSize,
49+
mimeType = mimeType,
50+
title = title,
51+
extraData = extraData,
52+
)
53+
54+
/**
55+
* Converts a [ParcelableAttachment] back to an [Attachment].
56+
*/
57+
internal fun ParcelableAttachment.toAttachment(): Attachment = Attachment(
58+
upload = uploadPath?.let { File(it) },
59+
type = type,
60+
name = name,
61+
fileSize = fileSize,
62+
mimeType = mimeType,
63+
title = title,
64+
extraData = extraData,
65+
)
66+
67+
/**
68+
* Checks whether all extra data values in the given attachments are safe to parcel.
69+
* Returns `true` if all values can be written to a [android.os.Parcel] without crashing.
70+
*/
71+
internal fun List<Attachment>.areExtraDataParcelSafe(): Boolean =
72+
all { attachment -> attachment.extraData.values.all { it.isParcelSafe() } }
73+
74+
/**
75+
* Recursively checks whether a value can be safely written via [android.os.Parcel.writeValue].
76+
*/
77+
private fun Any.isParcelSafe(): Boolean = when (this) {
78+
is String, is Int, is Long, is Float, is Double, is Boolean, is Byte, is Short -> true
79+
is BooleanArray, is ByteArray, is FloatArray, is IntArray, is LongArray, is DoubleArray -> true
80+
is List<*> -> all { it == null || it.isParcelSafe() }
81+
is Map<*, *> -> all { (k, v) -> k is String && (v == null || v.isParcelSafe()) }
82+
else -> false
83+
}

0 commit comments

Comments
 (0)