diff --git a/README.md b/README.md index 5a6b8b4ae1b..5b3f340f3f4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

- +

diff --git a/docs/sdk-hero-android.png b/docs/sdk-hero-android.png deleted file mode 100644 index 272b0dd3b63..00000000000 Binary files a/docs/sdk-hero-android.png and /dev/null differ diff --git a/docs/stream-chat-android-github-cover.png b/docs/stream-chat-android-github-cover.png new file mode 100644 index 00000000000..17bea0fd30a Binary files /dev/null and b/docs/stream-chat-android-github-cover.png differ diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api index b2a944ce2d2..0c729e5ce1a 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -266,6 +266,7 @@ public final class io/getstream/chat/android/client/ChatClient$Builder : io/gets public final fun appVersion (Ljava/lang/String;)Lio/getstream/chat/android/client/ChatClient$Builder; public final fun baseUrl (Ljava/lang/String;)Lio/getstream/chat/android/client/ChatClient$Builder; public fun build ()Lio/getstream/chat/android/client/ChatClient; + public final fun cdn (Lio/getstream/chat/android/client/cdn/CDN;)Lio/getstream/chat/android/client/ChatClient$Builder; public final fun cdnUrl (Ljava/lang/String;)Lio/getstream/chat/android/client/ChatClient$Builder; public final fun clientDebugger (Lio/getstream/chat/android/client/debugger/ChatClientDebugger;)Lio/getstream/chat/android/client/ChatClient$Builder; public final fun config (Lio/getstream/chat/android/client/api/ChatClientConfig;)Lio/getstream/chat/android/client/ChatClient$Builder; @@ -909,6 +910,27 @@ public final class io/getstream/chat/android/client/audio/WaveformExtractorKt { public static final fun isEof (Landroid/media/MediaCodec$BufferInfo;)Z } +public abstract interface class io/getstream/chat/android/client/cdn/CDN { + public fun fileRequest (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun fileRequest$suspendImpl (Lio/getstream/chat/android/client/cdn/CDN;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun imageRequest (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun imageRequest$suspendImpl (Lio/getstream/chat/android/client/cdn/CDN;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class io/getstream/chat/android/client/cdn/CDNRequest { + public fun (Ljava/lang/String;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/Map; + public final fun copy (Ljava/lang/String;Ljava/util/Map;)Lio/getstream/chat/android/client/cdn/CDNRequest; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/cdn/CDNRequest;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/cdn/CDNRequest; + public fun equals (Ljava/lang/Object;)Z + public final fun getHeaders ()Ljava/util/Map; + public final fun getUrl ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/client/channel/ChannelClient { public final fun acceptInvite (Ljava/lang/String;)Lio/getstream/result/call/Call; public final fun addMembers (Lio/getstream/chat/android/client/query/AddMembersParams;)Lio/getstream/result/call/Call; diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt index c26c7043dc2..34b818a604d 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt @@ -74,6 +74,8 @@ import io.getstream.chat.android.client.attachment.prepareForUpload import io.getstream.chat.android.client.audio.AudioPlayer import io.getstream.chat.android.client.audio.NativeMediaPlayerImpl import io.getstream.chat.android.client.audio.StreamAudioPlayer +import io.getstream.chat.android.client.cdn.CDN +import io.getstream.chat.android.client.cdn.internal.StreamMediaDataSource import io.getstream.chat.android.client.channel.ChannelClient import io.getstream.chat.android.client.channel.state.ChannelStateLogicProvider import io.getstream.chat.android.client.clientstate.DisconnectCause @@ -104,6 +106,7 @@ import io.getstream.chat.android.client.extensions.ATTACHMENT_TYPE_FILE import io.getstream.chat.android.client.extensions.ATTACHMENT_TYPE_IMAGE import io.getstream.chat.android.client.extensions.cidToTypeAndId import io.getstream.chat.android.client.extensions.extractBaseUrl +import io.getstream.chat.android.client.extensions.getCreatedAtOrNull import io.getstream.chat.android.client.extensions.internal.hasPendingAttachments import io.getstream.chat.android.client.extensions.internal.isLaterThanDays import io.getstream.chat.android.client.header.VersionPrefixHeader @@ -162,6 +165,7 @@ import io.getstream.chat.android.client.user.storage.SharedPreferencesCredential import io.getstream.chat.android.client.user.storage.UserCredentialStorage import io.getstream.chat.android.client.utils.ProgressCallback import io.getstream.chat.android.client.utils.TokenUtils +import io.getstream.chat.android.client.utils.internal.ServerClockOffset import io.getstream.chat.android.client.utils.mergePartially import io.getstream.chat.android.client.utils.message.ensureId import io.getstream.chat.android.client.utils.observable.ChatEventsObservable @@ -289,9 +293,13 @@ internal constructor( @InternalStreamChatApi public val audioPlayer: AudioPlayer, private val now: () -> Date = ::Date, + @InternalStreamChatApi + public val serverClockOffset: ServerClockOffset, private val repository: ChatClientRepository, private val messageReceiptReporter: MessageReceiptReporter, internal val messageReceiptManager: MessageReceiptManager, + @InternalStreamChatApi + public val cdn: CDN? = null, ) { private val logger by taggedLogger(TAG) private val fileManager = StreamFileManager() @@ -353,6 +361,7 @@ internal constructor( * * @see [Plugin] */ + @Volatile @InternalStreamChatApi public var plugins: List = emptyList() @@ -399,12 +408,16 @@ internal constructor( @Suppress("ThrowsCount") internal inline fun resolvePluginDependency(): T { StreamLog.v(TAG) { "[resolvePluginDependency] P: ${P::class.simpleName}, T: ${T::class.simpleName}" } + // Snapshot plugins BEFORE checking initializationState to avoid a race with disconnect(). + // disconnect() sets initializationState to NOT_INITIALIZED before clearing plugins, + // so if we snapshot plugins first and then see COMPLETE, the snapshot is guaranteed valid. + val currentPlugins = plugins val initState = awaitInitializationState(RESOLVE_DEPENDENCY_TIMEOUT) if (initState != InitializationState.COMPLETE) { StreamLog.e(TAG) { "[resolvePluginDependency] failed (initializationState is not COMPLETE): $initState " } throw IllegalStateException("ChatClient::connectUser() must be called before resolving any dependency") } - val resolver = plugins.find { plugin -> + val resolver = currentPlugins.find { plugin -> plugin is P } ?: throw IllegalStateException( "Plugin '${P::class.qualifiedName}' was not found. Did you init it within ChatClient?", @@ -1569,9 +1582,9 @@ internal constructor( notifications.onLogout() // Set initializationState to NOT_INITIALIZED BEFORE clearing plugins to prevent race condition. - // This ensures the StatePlugin extension methods don't access the plugin during disconnect. + // resolvePluginDependency() snapshots plugins before checking state, so if it sees COMPLETE + // here, the snapshot is guaranteed to still contain the plugins. mutableClientState.setInitializationState(InitializationState.NOT_INITIALIZED) - plugins.forEach { it.onUserDisconnected() } plugins = emptyList() userStateService.onLogout() @@ -2534,16 +2547,34 @@ internal constructor( /** * Ensure the message has a [Message.createdLocallyAt] timestamp. - * If not, set it to the max of the channel's [Channel.lastMessageAt] + 1 millisecond and [now]. - * This ensures that the message appears in the correct order in the channel. + * If not, set it to the max of the channel's [Channel.lastMessageAt] + 1 millisecond and the + * estimated server time. Using estimated server time (instead of raw local clock) prevents + * cross-user ordering issues when the device clock is skewed. */ private suspend fun Message.ensureCreatedLocallyAt(cid: String): Message { - val lastMessageAt = repositoryFacade.selectChannel(cid = cid)?.lastMessageAt - val lastMessageAtPlusOneMillisecond = lastMessageAt?.let { - Date(it.time + 1) + val parentId = this.parentId + if (parentId != null) { + // Thread reply + val lastMessage = repositoryFacade.selectMessagesForThread(parentId, limit = 1).lastOrNull() + val lastMessageAt = lastMessage?.getCreatedAtOrNull() + val lastMessageAtPlusOneMillisecond = lastMessageAt?.let { + Date(it.time + 1) + } + val createdLocallyAt = max(lastMessageAtPlusOneMillisecond, serverClockOffset.estimatedServerTime()) + return copy(createdLocallyAt = this.createdLocallyAt ?: createdLocallyAt) + } else { + // Regular message + val (type, id) = cid.cidToTypeAndId() + // Fetch channel lastMessageAt from state, fallback to offline storage + val channelState = logicRegistry?.channelStateLogic(type, id)?.channelState() + val lastMessageAt = channelState?.channelData?.value?.lastMessageAt + ?: repositoryFacade.selectChannel(cid = cid)?.lastMessageAt + val lastMessageAtPlusOneMillisecond = lastMessageAt?.let { + Date(it.time + 1) + } + val createdLocallyAt = max(lastMessageAtPlusOneMillisecond, serverClockOffset.estimatedServerTime()) + return copy(createdLocallyAt = this.createdLocallyAt ?: createdLocallyAt) } - val createdLocallyAt = max(lastMessageAtPlusOneMillisecond, now()) - return copy(createdLocallyAt = this.createdLocallyAt ?: createdLocallyAt) } /** @@ -4608,6 +4639,7 @@ internal constructor( private var uploadAttachmentsNetworkType = UploadAttachmentsNetworkType.CONNECTED private var fileTransformer: FileTransformer = NoOpFileTransformer private var apiModelTransformers: ApiModelTransformers = ApiModelTransformers() + private var cdn: CDN? = null private var appName: String? = null private var appVersion: String? = null @@ -4736,7 +4768,11 @@ internal constructor( * * @param shareFileDownloadRequestInterceptor Your [Interceptor] implementation for the share file download * call. + * @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via + * [io.getstream.chat.android.client.ChatClient.Builder.cdn] to provide headers and transform URLs + * for all image, file, and download requests. */ + @Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().") public fun shareFileDownloadRequestInterceptor(shareFileDownloadRequestInterceptor: Interceptor): Builder { this.shareFileDownloadRequestInterceptor = shareFileDownloadRequestInterceptor return this @@ -4807,6 +4843,15 @@ internal constructor( forceWsUrl = value } + /** + * Sets a custom [CDN] implementation to be used by the client. + * + * @param cdn The custom CDN implementation. + */ + public fun cdn(cdn: CDN): Builder = apply { + this.cdn = cdn + } + /** * Sets the CDN URL to be used by the client. */ @@ -4933,6 +4978,8 @@ internal constructor( warmUpReflection() } + val serverClockOffset = ServerClockOffset() + val module = ChatModule( appContext = appContext, @@ -4945,19 +4992,22 @@ internal constructor( fileUploader = fileUploader, sendMessageInterceptor = sendMessageInterceptor, shareFileDownloadRequestInterceptor = shareFileDownloadRequestInterceptor, + cdn = cdn, tokenManager = tokenManager, customOkHttpClient = customOkHttpClient, clientDebugger = clientDebugger, lifecycle = lifecycle, appName = this.appName, appVersion = this.appVersion, + serverClockOffset = serverClockOffset, ) val api = module.api() val appSettingsManager = AppSettingManager(api) + val mediaDataSourceFactory = StreamMediaDataSource.factory(appContext, cdn) val audioPlayer: AudioPlayer = StreamAudioPlayer( - mediaPlayer = NativeMediaPlayerImpl(appContext) { + mediaPlayer = NativeMediaPlayerImpl(mediaDataSourceFactory) { ExoPlayer.Builder(appContext) .setAudioAttributes( AudioAttributes.Builder() @@ -4991,6 +5041,7 @@ internal constructor( retryPolicy = retryPolicy, appSettingsManager = appSettingsManager, chatSocket = module.chatSocket, + serverClockOffset = serverClockOffset, pluginFactories = allPluginFactories, repositoryFactoryProvider = allPluginFactories .filterIsInstance() @@ -5011,6 +5062,7 @@ internal constructor( messageReceiptRepository = repository, api = api, ), + cdn = cdn, ).apply { attachmentsSender = AttachmentsSender( context = appContext, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/ChatClientStateExtensions.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/ChatClientStateExtensions.kt index da75ee7648f..60af4f9859c 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/ChatClientStateExtensions.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/ChatClientStateExtensions.kt @@ -36,6 +36,7 @@ import io.getstream.chat.android.client.internal.state.extensions.internal.parse import io.getstream.chat.android.client.internal.state.extensions.internal.requestsAsState import io.getstream.chat.android.client.internal.state.plugin.factory.StreamStatePluginFactory import io.getstream.chat.android.client.internal.state.plugin.internal.StatePlugin +import io.getstream.chat.android.client.utils.attachment.isImage import io.getstream.chat.android.client.utils.internal.validateCidWithResult import io.getstream.chat.android.client.utils.message.isEphemeral import io.getstream.chat.android.core.internal.InternalStreamChatApi @@ -302,6 +303,12 @@ public fun ChatClient.setMessageForReply(cid: String, message: Message?): Call + addRequestHeader(key, value) + } + } .apply(interceptRequest), ) Result.Success(Unit) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/NativeMediaPlayer.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/NativeMediaPlayer.kt index 4fa17c6b418..fe0e28184b6 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/NativeMediaPlayer.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/NativeMediaPlayer.kt @@ -16,14 +16,13 @@ package io.getstream.chat.android.client.audio -import android.content.Context import androidx.annotation.OptIn import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.DataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.ProgressiveMediaSource @@ -198,12 +197,12 @@ public enum class NativeMediaPlayerState { /** * Default implementation of [NativeMediaPlayer] based on ExoPlayer. * - * @param context The context. + * @param dataSourceFactory The data source factory used for creating media sources. * @param builder A builder function to create an [ExoPlayer] instance. */ @OptIn(UnstableApi::class) internal class NativeMediaPlayerImpl( - context: Context, + dataSourceFactory: DataSource.Factory, private val builder: () -> ExoPlayer, ) : NativeMediaPlayer { @@ -232,7 +231,7 @@ internal class NativeMediaPlayerImpl( * For more info see [ExoPlayer Progressive](https://developer.android.com/media/media3/exoplayer/progressive). */ private val mediaSourceFactory: MediaSource.Factory = ProgressiveMediaSource.Factory( - DefaultDataSource.Factory(context), + dataSourceFactory, DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true), ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDN.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDN.kt new file mode 100644 index 00000000000..fec6c9b9b6c --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDN.kt @@ -0,0 +1,46 @@ +/* + * 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.client.cdn + +/** + * Class defining a CDN (Content Delivery Network) interface. + * Override to transform requests loading images/files from the custom CDN. + */ +public interface CDN { + + /** + * Transforms a request for loading an image from the CDN. + * + * Implementations that perform blocking or network I/O must use `withContext` to switch to the + * appropriate dispatcher (e.g. `Dispatchers.IO`). + * + * @param url Original CDN url for the image. + * @return A [CDNRequest] holding the modified request URL and/or custom headers to include with the request. + */ + public suspend fun imageRequest(url: String): CDNRequest = CDNRequest(url) + + /** + * Transforms a request for loading a non-image file from the CDN. + * + * Implementations that perform blocking or network I/O must use `withContext` to switch to the + * appropriate dispatcher (e.g. `Dispatchers.IO`). + * + * @param url Original CDN url for the file. + * @return A [CDNRequest] holding the modified request URL and/or custom headers to include with the request. + */ + public suspend fun fileRequest(url: String): CDNRequest = CDNRequest(url) +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDNRequest.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDNRequest.kt new file mode 100644 index 00000000000..598bdb10e5f --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDNRequest.kt @@ -0,0 +1,28 @@ +/* + * 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.client.cdn + +/** + * Model representing the request for loading a file from a CDN. + * + * @param url Url of the file to load. + * @param headers Map of headers added to the request. + */ +public data class CDNRequest( + val url: String, + val headers: Map? = null, +) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/CDNDataSourceFactory.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/CDNDataSourceFactory.kt new file mode 100644 index 00000000000..69f609ef5f0 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/CDNDataSourceFactory.kt @@ -0,0 +1,104 @@ +/* + * 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.client.cdn.internal + +import android.net.Uri +import androidx.core.net.toUri +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.datasource.TransferListener +import io.getstream.chat.android.client.cdn.CDN +import io.getstream.chat.android.client.cdn.CDNRequest +import io.getstream.log.taggedLogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking + +/** + * A [DataSource.Factory] that creates [CDNDataSource] instances which transform + * media requests through the [CDN.fileRequest] method before delegating to an upstream data source. + * + * @param cdn The CDN used to transform file request URLs and headers. + * @param upstreamFactory The factory for creating the upstream data source that performs the actual HTTP requests. + */ +@UnstableApi +internal class CDNDataSourceFactory( + private val cdn: CDN, + private val upstreamFactory: DataSource.Factory = DefaultHttpDataSource.Factory(), +) : DataSource.Factory { + override fun createDataSource(): DataSource { + return CDNDataSource(cdn, upstreamFactory.createDataSource()) + } +} + +/** + * A [DataSource] that transforms media requests through [CDN.fileRequest] before + * delegating to an upstream data source. This allows custom CDN implementations + * to rewrite URLs and inject headers for video/audio/voice recording playback via ExoPlayer. + * + * [CDN.fileRequest] is a suspend function and is called via [runBlocking] on [Dispatchers.IO]. + * This is safe because ExoPlayer always calls [open] from its loader thread, never the main thread. + */ +@UnstableApi +private class CDNDataSource( + private val cdn: CDN, + private val upstream: DataSource, +) : DataSource { + + private val logger by taggedLogger("Chat:CDNDataSource") + + override fun open(dataSpec: DataSpec): Long { + val scheme = dataSpec.uri.scheme + if (scheme != "http" && scheme != "https") { + return upstream.open(dataSpec) + } + val url = dataSpec.uri.toString() + val cdnRequest = try { + runBlocking { + cdn.fileRequest(url) + } + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + logger.e(e) { "[open] CDN.fileRequest() failed for url: $url. Falling back to original request." } + CDNRequest(url) + } + val mergedHeaders = buildMap { + putAll(dataSpec.httpRequestHeaders) + cdnRequest.headers?.let { putAll(it) } + } + val transformedSpec = dataSpec.buildUpon() + .setUri(cdnRequest.url.toUri()) + .setHttpRequestHeaders(mergedHeaders) + .build() + return upstream.open(transformedSpec) + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int = + upstream.read(buffer, offset, length) + + override fun close() { + upstream.close() + } + + override fun getUri(): Uri? = upstream.uri + + override fun getResponseHeaders(): Map> = upstream.responseHeaders + + override fun addTransferListener(transferListener: TransferListener) { + upstream.addTransferListener(transferListener) + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/CDNOkHttpInterceptor.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/CDNOkHttpInterceptor.kt new file mode 100644 index 00000000000..e8dfa6239af --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/CDNOkHttpInterceptor.kt @@ -0,0 +1,56 @@ +/* + * 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.client.cdn.internal + +import io.getstream.chat.android.client.cdn.CDN +import io.getstream.chat.android.client.cdn.CDNRequest +import io.getstream.log.taggedLogger +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Response + +/** + * OkHttp interceptor applying transformations to CDN requests. + */ +internal class CDNOkHttpInterceptor(private val cdn: CDN) : Interceptor { + + private val logger by taggedLogger("Chat:CDNOkHttpInterceptor") + + override fun intercept(chain: Interceptor.Chain): Response { + val originalUrl = chain.request().url.toString() + val (url, headers) = try { + runBlocking { + cdn.fileRequest(originalUrl) + } + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + logger.e(e) { + "[intercept] CDN.fileRequest() failed for url: $originalUrl. " + + "Falling back to original request." + } + CDNRequest(originalUrl) + } + val request = chain.request().newBuilder() + .url(url) + .apply { + headers?.forEach { + header(it.key, it.value) + } + } + .build() + return chain.proceed(request) + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/StreamMediaDataSource.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/StreamMediaDataSource.kt new file mode 100644 index 00000000000..8143bf48470 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/StreamMediaDataSource.kt @@ -0,0 +1,50 @@ +/* + * 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.client.cdn.internal + +import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSource +import io.getstream.chat.android.client.cdn.CDN +import io.getstream.chat.android.core.internal.InternalStreamChatApi + +/** + * Centralized provider for Media3 [DataSource.Factory] instances. + * + * Wraps the base [DefaultDataSource.Factory] with [CDNDataSourceFactory] when a custom [CDN] is configured, + * enabling URL rewriting and header injection for media playback (video, audio, voice recordings). + */ +@InternalStreamChatApi +public object StreamMediaDataSource { + + /** + * Creates a [DataSource.Factory] that handles both local and network media URIs. + * + * When a [CDN] is provided, HTTP/HTTPS requests are transformed through [CDN.fileRequest] + * for URL rewriting and header injection. Local URIs (file://, content://) pass through unchanged. + * + * @param context The context used to create the base data source. + * @param cdn Optional custom CDN for transforming network requests. + */ + @OptIn(UnstableApi::class) + public fun factory(context: Context, cdn: CDN?): DataSource.Factory { + val base = DefaultDataSource.Factory(context) + return cdn?.let { CDNDataSourceFactory(it, base) } ?: base + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/di/ChatModule.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/di/ChatModule.kt index 6295d9a0853..25af3e171fc 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/di/ChatModule.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/di/ChatModule.kt @@ -56,6 +56,8 @@ import io.getstream.chat.android.client.api2.endpoint.UserApi import io.getstream.chat.android.client.api2.mapping.DomainMapping import io.getstream.chat.android.client.api2.mapping.DtoMapping import io.getstream.chat.android.client.api2.mapping.EventMapping +import io.getstream.chat.android.client.cdn.CDN +import io.getstream.chat.android.client.cdn.internal.CDNOkHttpInterceptor import io.getstream.chat.android.client.clientstate.UserStateService import io.getstream.chat.android.client.debugger.ChatClientDebugger import io.getstream.chat.android.client.interceptor.SendMessageInterceptor @@ -82,6 +84,7 @@ import io.getstream.chat.android.client.uploader.FileUploader import io.getstream.chat.android.client.uploader.StreamFileUploader import io.getstream.chat.android.client.user.CurrentUserFetcher import io.getstream.chat.android.client.utils.HeadersUtil +import io.getstream.chat.android.client.utils.internal.ServerClockOffset import io.getstream.chat.android.models.UserId import io.getstream.log.StreamLog import okhttp3.Interceptor @@ -109,12 +112,14 @@ import java.util.concurrent.TimeUnit * logic. * @param shareFileDownloadRequestInterceptor Optional interceptor to customize file download requests done for the * purpose of sharing the file. + * @param cdn Optional [CDN] implementation for transforming file download URLs and injecting headers. * @param tokenManager Manager that provides and refreshes auth tokens for authenticated requests. * @param customOkHttpClient Optional base [OkHttpClient] to reuse threads/connection pools and customize networking. * @param clientDebugger Optional hooks for debugging client state, sockets, and network operations. * @param lifecycle Host [Lifecycle] used to observe app foreground/background and manage socket behavior. * @param appName Optional app name added to default headers for tracking. * @param appVersion Optional app version added to default headers for tracking. + * @param serverClockOffset Shared clock-offset tracker used by the socket layer for time synchronisation. */ @Suppress("TooManyFunctions") internal class ChatModule @@ -130,12 +135,14 @@ constructor( private val fileUploader: FileUploader?, private val sendMessageInterceptor: SendMessageInterceptor?, private val shareFileDownloadRequestInterceptor: Interceptor?, + private val cdn: CDN?, private val tokenManager: TokenManager, private val customOkHttpClient: OkHttpClient?, private val clientDebugger: ChatClientDebugger?, private val lifecycle: Lifecycle, private val appName: String?, private val appVersion: String?, + private val serverClockOffset: ServerClockOffset, ) { private val headersUtil = HeadersUtil(appContext, appName, appVersion) @@ -310,6 +317,7 @@ constructor( lifecycleObserver, networkStateProvider, clientDebugger, + serverClockOffset, ) private fun buildApi(chatConfig: ChatApiConfig): ChatApi = ProxyChatApi( @@ -384,6 +392,7 @@ constructor( private fun buildFileDownloadApi(): FileDownloadApi { val okHttpClient = baseClientBuilder(BASE_TIMEOUT) .apply { + cdn?.let { addInterceptor(CDNOkHttpInterceptor(it)) } shareFileDownloadRequestInterceptor?.let { addInterceptor(it) } } .build() diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/file/StreamFileManager.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/file/StreamFileManager.kt index 9caf62ee77e..c3fe2d2df73 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/file/StreamFileManager.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/file/StreamFileManager.kt @@ -164,6 +164,42 @@ public class StreamFileManager { } } + /** + * Evicts cached files matching [prefix] based on a time-to-live and a total size cap. + * + * 1. Deletes every file whose `lastModified` is older than [ttlMs] milliseconds. + * 2. If the remaining files exceed [maxSizeBytes] in total, deletes the oldest + * files first until the total drops below the cap. + * + * @param context Android context for cache directory access + * @param prefix Filename prefix that identifies files subject to eviction + * @param ttlMs Maximum age in milliseconds; older files are always deleted + * @param maxSizeBytes Soft size cap in bytes; exceeded only temporarily until the next eviction pass + */ + public suspend fun evictCacheFiles(context: Context, prefix: String, ttlMs: Long, maxSizeBytes: Long): Unit = + withContext(DispatcherProvider.IO) { + val now = System.currentTimeMillis() + val files = listFilesInCache(context, prefix).toMutableList() + val expired = files.filter { now - it.lastModified() >= ttlMs } + expired.forEach { file -> + if (file.delete()) { + files.remove(file) + } + } + + files.sortBy { it.lastModified() } + var totalSize = files.sumOf { it.length() } + val iterator = files.iterator() + while (totalSize > maxSizeBytes && iterator.hasNext()) { + val oldest = iterator.next() + val size = oldest.length() + if (oldest.delete()) { + totalSize -= size + iterator.remove() + } + } + } + /** * Clears the Stream cache directory. * @@ -357,6 +393,20 @@ public class StreamFileManager { } } + /** + * Lists files in the Stream cache directory whose names start with the given [prefix]. + * + * @param context Android context for cache directory access + * @param prefix Filename prefix to filter by + * @return List of matching files, or an empty list if the directory doesn't exist + */ + private fun listFilesInCache(context: Context, prefix: String): List { + val cacheDir = getStreamCacheDir(context) + return cacheDir.listFiles { file -> + file.isFile && file.name.startsWith(prefix) + }?.toList() ?: emptyList() + } + @Suppress("TooGenericExceptionCaught") private fun clearImageCache(context: Context): Result { return try { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/factory/StreamStatePluginFactory.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/factory/StreamStatePluginFactory.kt index 84aff4abaae..cca1f9ad227 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/factory/StreamStatePluginFactory.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/factory/StreamStatePluginFactory.kt @@ -139,6 +139,7 @@ public class StreamStatePluginFactory( userPresence = config.userPresence, isAutomaticSyncOnReconnectEnabled = config.isAutomaticSyncOnReconnectEnabled, syncMaxThreshold = config.syncMaxThreshold, + serverClockOffset = chatClient.serverClockOffset, now = { System.currentTimeMillis() }, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/sync/internal/SyncManager.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/sync/internal/SyncManager.kt index 61d4493a73e..7b4730181af 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/sync/internal/SyncManager.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/sync/internal/SyncManager.kt @@ -39,6 +39,7 @@ import io.getstream.chat.android.client.query.CreateChannelParams import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.client.sync.SyncState import io.getstream.chat.android.client.sync.stringify +import io.getstream.chat.android.client.utils.internal.ServerClockOffset import io.getstream.chat.android.client.utils.message.isDeleted import io.getstream.chat.android.client.utils.observable.Disposable import io.getstream.chat.android.core.internal.coroutines.Tube @@ -95,6 +96,7 @@ internal class SyncManager( private val isAutomaticSyncOnReconnectEnabled: Boolean, private val syncMaxThreshold: TimeDuration, private val now: () -> Long, + private val serverClockOffset: ServerClockOffset, scope: CoroutineScope, private val events: Tube> = Tube(), private val syncState: MutableStateFlow = MutableStateFlow(null), @@ -598,7 +600,7 @@ internal class SyncManager( repos.markMessageAsFailed(message) } else { logger.v { "[retryMessagesWithPendingAttachments] sending message($id)" } - if (message.createdLocallyAt.exceedsSyncThreshold()) { + if (message.createdLocallyAt.exceedsSyncThresholdServerTime()) { logger.w { "[retryMessagesWithPendingAttachments] outdated sending($id)" } removeMessage(message).await() } else { @@ -672,7 +674,7 @@ internal class SyncManager( channelClient: ChannelClient, ): Result { logger.v { "[retrySendingOfMessageWithSyncedAttachments] sending message(${message.id})" } - return if (message.createdLocallyAt.exceedsSyncThreshold()) { + return if (message.createdLocallyAt.exceedsSyncThresholdServerTime()) { logger.w { "[retrySendingOfMessageWithSyncedAttachments] outdated sending($id)" } removeMessage(message).await() } else { @@ -744,6 +746,15 @@ internal class SyncManager( return this == null || diff(now()) > syncMaxThreshold } + /** + * Important: Use only for local dates created with [ServerClockOffset.estimatedServerTime]. + * Use for comparing: + * - [Message.createdLocallyAt] + */ + private fun Date?.exceedsSyncThresholdServerTime(): Boolean { + return this == null || diff(serverClockOffset.estimatedServerTime()) > syncMaxThreshold + } + private enum class State { Idle, Syncing } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/socket/ChatSocket.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/socket/ChatSocket.kt index 71d80074d63..2cf9c4b7603 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/socket/ChatSocket.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/socket/ChatSocket.kt @@ -30,6 +30,7 @@ import io.getstream.chat.android.client.network.NetworkStateProvider import io.getstream.chat.android.client.scope.UserScope import io.getstream.chat.android.client.socket.ChatSocketStateService.State import io.getstream.chat.android.client.token.TokenManager +import io.getstream.chat.android.client.utils.internal.ServerClockOffset import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.chat.android.models.User import io.getstream.log.taggedLogger @@ -52,6 +53,7 @@ internal open class ChatSocket( private val lifecycleObserver: StreamLifecycleObserver, private val networkStateProvider: NetworkStateProvider, private val clientDebugger: ChatClientDebugger? = null, + private val serverClockOffset: ServerClockOffset, ) { private var streamWebSocket: StreamWebSocket? = null private val logger by taggedLogger(TAG) @@ -61,7 +63,13 @@ internal open class ChatSocket( private var socketStateObserverJob: Job? = null private val healthMonitor = HealthMonitor( userScope = userScope, - checkCallback = { (chatSocketStateService.currentState as? State.Connected)?.event?.let(::sendEvent) }, + checkCallback = { + (chatSocketStateService.currentState as? State.Connected)?.event?.let { + if (sendEvent(it)) { + serverClockOffset.onHealthCheckSent() + } + } + }, reconnectCallback = { chatSocketStateService.onWebSocketEventLost() }, ) private val lifecycleHandler = object : LifecycleHandler { @@ -84,6 +92,7 @@ internal open class ChatSocket( socketListenerJob?.cancel() when (networkStateProvider.isConnected()) { true -> { + serverClockOffset.onConnectionStarted() streamWebSocket = socketFactory.createSocket(connectionConf).apply { socketListenerJob = listen().onEach { when (it) { @@ -194,8 +203,14 @@ internal open class ChatSocket( private suspend fun handleEvent(chatEvent: ChatEvent) { when (chatEvent) { - is ConnectedEvent -> chatSocketStateService.onConnectionEstablished(chatEvent) - is HealthEvent -> healthMonitor.ack() + is ConnectedEvent -> { + serverClockOffset.onConnected(chatEvent.createdAt) + chatSocketStateService.onConnectionEstablished(chatEvent) + } + is HealthEvent -> { + serverClockOffset.onHealthCheck(chatEvent.createdAt) + healthMonitor.ack() + } else -> callListeners { listener -> listener.onEvent(chatEvent) } } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/internal/ServerClockOffset.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/internal/ServerClockOffset.kt new file mode 100644 index 00000000000..2eee99da6de --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/internal/ServerClockOffset.kt @@ -0,0 +1,177 @@ +/* + * 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.client.utils.internal + +import io.getstream.chat.android.client.events.ConnectedEvent +import io.getstream.chat.android.client.events.HealthEvent +import io.getstream.chat.android.core.internal.InternalStreamChatApi +import java.util.Date + +/** + * Tracks the offset between the local device clock and the server clock using + * NTP-style estimation from WebSocket health check round-trips. + * + * The algorithm keeps only the sample with the lowest observed RTT, since a + * smaller round-trip means less room for network asymmetry to distort the + * measurement. Under the assumption that clock skew is constant for the + * duration of a session, the estimate monotonically improves over time. + * + * Thread-safe: single-field writes use [Volatile] for visibility; compound + * read-modify-write sequences are guarded by [lock] for atomicity. + * + * @param localTimeMs Clock source for the local device time (injectable for tests). + * @param maxRttMs Upper bound on plausible RTT. Samples exceeding this are + * discarded as stale or mismatched. Defaults to the health check cycle + * interval (MONITOR_INTERVAL + HEALTH_CHECK_INTERVAL = 11 000 ms). + * @param maxOffsetMs Upper bound on the absolute value of the computed clock offset. + * If the derived offset exceeds this threshold the sample is considered unreliable + * (e.g. a stale / static server timestamp in a test environment) and the offset is + * reset to zero so that [estimatedServerTime] falls back to the raw local time. + * Defaults to 1 hour, which is already far beyond any real-world NTP drift. + */ +@InternalStreamChatApi +public class ServerClockOffset( + private val localTimeMs: () -> Long = { System.currentTimeMillis() }, + private val maxRttMs: Long = DEFAULT_MAX_RTT_MS, + private val maxOffsetMs: Long = DEFAULT_MAX_OFFSET_MS, +) { + + private val lock = Any() + + @Volatile + private var offsetMs: Long = 0L + + @Volatile + private var bestRttMs: Long = Long.MAX_VALUE + + @Volatile + private var healthCheckSentAtMs: Long = 0L + + @Volatile + private var connectionStartedAtMs: Long = 0L + + /** + * Record the local time immediately before starting a WebSocket connection. + * When the next [ConnectedEvent] arrives, [onConnected] will pair with this + * timestamp to compute the offset using the NTP midpoint formula. + */ + internal fun onConnectionStarted() { + connectionStartedAtMs = localTimeMs() + } + + /** + * Record the local time immediately before sending a health check echo. + * The next [onHealthCheck] call will pair with this timestamp to compute RTT. + */ + internal fun onHealthCheckSent() { + healthCheckSentAtMs = localTimeMs() + } + + /** + * Calibration from a [ConnectedEvent]. + * + * If [onConnectionStarted] was called before this connection (e.g. right before + * opening the WebSocket), uses the NTP midpoint of (connectionStartedAt, receivedAt) + * and serverTime for a more accurate offset. Otherwise falls back to a naive + * `localTime - serverTime` estimate. + * + * Resets health check state, since a new connection means any in-flight health + * check from the previous connection is stale. + */ + internal fun onConnected(serverTime: Date) { + synchronized(lock) { + bestRttMs = Long.MAX_VALUE + healthCheckSentAtMs = 0L + offsetMs = 0L + + val receivedAtMs = localTimeMs() + val startedAtMs = connectionStartedAtMs + connectionStartedAtMs = 0L + + if (startedAtMs > 0L) { + val rtt = receivedAtMs - startedAtMs + if (rtt in 1..maxRttMs) { + acceptOffset((startedAtMs + receivedAtMs) / 2 - serverTime.time) + bestRttMs = rtt + return + } + } + acceptOffset(receivedAtMs - serverTime.time) + } + } + + /** + * Refine the offset using a [HealthEvent] paired with [onHealthCheckSent]. + * + * Computes RTT from the stored send time and the current receive time, + * then applies the NTP midpoint formula: + * ``` + * offset = (sentAt + receivedAt) / 2 - serverTime + * ``` + * + * The sample is accepted only if: + * - There is a pending [onHealthCheckSent] timestamp. + * - RTT is positive (guards against clock anomalies). + * - RTT is below [maxRttMs] (rejects stale / mismatched pairs). + * - RTT is lower than any previous sample (min-RTT selection). + */ + internal fun onHealthCheck(serverTime: Date) { + synchronized(lock) { + val sentAtMs = healthCheckSentAtMs + if (sentAtMs <= 0L) return + healthCheckSentAtMs = 0L + + val receivedAtMs = localTimeMs() + val rtt = receivedAtMs - sentAtMs + if (rtt !in 1..maxRttMs) return + + if (rtt < bestRttMs) { + bestRttMs = rtt + acceptOffset((sentAtMs + receivedAtMs) / 2 - serverTime.time) + } + } + } + + /** + * Returns the current time adjusted to the server timescale. + * + * Before the first [onConnected] call, this returns the raw local time + * (offset = 0). + */ + @InternalStreamChatApi + public fun estimatedServerTime(): Date = + Date(localTimeMs() - offsetMs) + + /** + * Accepts [candidate] as the new [offsetMs] only when its absolute value is within + * [maxOffsetMs]. Offsets that are implausibly large (e.g. produced by a stale or + * static server timestamp) are silently discarded and [offsetMs] is left unchanged. + * + * Note: callers that want a rejected offset to reset to zero (e.g. [onConnected]) + * should set [offsetMs] = 0 before calling this function. + */ + private fun acceptOffset(candidate: Long) { + if (kotlin.math.abs(candidate) <= maxOffsetMs) { + offsetMs = candidate + } + } + + internal companion object { + internal const val DEFAULT_MAX_RTT_MS = 11_000L + internal const val DEFAULT_MAX_OFFSET_MS = 3_600_000L // 1 hour + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt index 0987343f63d..75bbd415609 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt @@ -34,6 +34,7 @@ import io.getstream.chat.android.client.token.FakeTokenManager import io.getstream.chat.android.client.user.CredentialConfig import io.getstream.chat.android.client.user.storage.UserCredentialStorage import io.getstream.chat.android.client.utils.TokenUtils +import io.getstream.chat.android.client.utils.internal.ServerClockOffset import io.getstream.chat.android.models.ConnectionData import io.getstream.chat.android.models.EventType import io.getstream.chat.android.models.GuestUser @@ -126,6 +127,7 @@ internal class ChatClientConnectionTests { retryPolicy = mock(), appSettingsManager = mock(), chatSocket = fakeChatSocket, + serverClockOffset = ServerClockOffset(), pluginFactories = emptyList(), repositoryFactoryProvider = NoOpRepositoryFactory.Provider, mutableClientState = mutableClientState, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt index 9e76db822ef..c4aa7b58797 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt @@ -37,6 +37,7 @@ import io.getstream.chat.android.client.scope.UserTestScope import io.getstream.chat.android.client.socket.FakeChatSocket import io.getstream.chat.android.client.token.FakeTokenManager import io.getstream.chat.android.client.utils.TokenUtils +import io.getstream.chat.android.client.utils.internal.ServerClockOffset import io.getstream.chat.android.client.utils.retry.NoRetryPolicy import io.getstream.chat.android.models.ConnectionState import io.getstream.chat.android.models.EventType @@ -138,6 +139,7 @@ internal class ChatClientTest { retryPolicy = NoRetryPolicy(), appSettingsManager = mock(), chatSocket = fakeChatSocket, + serverClockOffset = ServerClockOffset(), pluginFactories = emptyList(), mutableClientState = Mother.mockedClientState(), repositoryFactoryProvider = NoOpRepositoryFactory.Provider, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt index c449bf03179..847ccf5deaa 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt @@ -23,13 +23,17 @@ import io.getstream.chat.android.client.plugin.factory.PluginFactory import io.getstream.chat.android.client.scope.ClientTestScope import io.getstream.chat.android.client.scope.UserTestScope import io.getstream.chat.android.client.setup.state.internal.MutableClientState +import io.getstream.chat.android.client.utils.internal.ServerClockOffset import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.NoOpMessageTransformer import io.getstream.chat.android.models.NoOpUserTransformer import io.getstream.chat.android.models.User import io.getstream.chat.android.test.TestCoroutineExtension +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.test.TestResult import kotlinx.coroutines.test.runTest import org.amshove.kluent.invoking @@ -43,6 +47,7 @@ import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import java.util.concurrent.atomic.AtomicBoolean import kotlin.reflect.KClass public class DependencyResolverTest { @@ -128,6 +133,22 @@ public class DependencyResolverTest { fResult `should be` expectedDependency } + @Test + public fun `Should resolve dependency when plugins are cleared during resolution`(): TestResult = runTest { + val expectedDependency = SomeDependency() + val fixture = Fixture() + .with(PluginDependency(mapOf(SomeDependency::class to expectedDependency))) + + val client = fixture.get() + + val racingFlow = DisconnectSimulatingStateFlow(client) + whenever(fixture.mutableClientState.initializationState).thenReturn(racingFlow) + + val result = client.resolveDependency() + + result `should be` expectedDependency + } + public companion object { @JvmField @@ -174,6 +195,7 @@ public class DependencyResolverTest { retryPolicy = mock(), appSettingsManager = mock(), chatSocket = mock(), + serverClockOffset = ServerClockOffset(), pluginFactories = pluginFactories, repositoryFactoryProvider = mock(), mutableClientState = mutableClientState, @@ -217,4 +239,28 @@ public class DependencyResolverTest { } private class SomeDependency + + private class DisconnectSimulatingStateFlow( + private val client: ChatClient, + ) : StateFlow { + + private val disconnected = AtomicBoolean(false) + + override val value: InitializationState + get() { + if (disconnected.compareAndSet(false, true)) { + client.plugins = emptyList() + return InitializationState.COMPLETE + } + return InitializationState.NOT_INITIALIZED + } + + override val replayCache: List + get() = listOf(InitializationState.COMPLETE) + + override suspend fun collect(collector: FlowCollector): Nothing { + collector.emit(InitializationState.COMPLETE) + awaitCancellation() + } + } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt index 54c5840ac6c..328b57c25e7 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt @@ -33,6 +33,7 @@ import io.getstream.chat.android.client.setup.state.internal.MutableClientState import io.getstream.chat.android.client.token.FakeTokenManager import io.getstream.chat.android.client.uploader.FileUploader import io.getstream.chat.android.client.utils.TokenUtils +import io.getstream.chat.android.client.utils.internal.ServerClockOffset import io.getstream.chat.android.client.utils.retry.NoRetryPolicy import io.getstream.chat.android.models.EventType import io.getstream.chat.android.models.NoOpMessageTransformer @@ -121,6 +122,7 @@ internal class MockClientBuilder( retryPolicy = NoRetryPolicy(), appSettingsManager = mock(), chatSocket = mock(), + serverClockOffset = ServerClockOffset(), pluginFactories = emptyList(), repositoryFactoryProvider = NoOpRepositoryFactory.Provider, mutableClientState = mutableClientState, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cdn/internal/CDNDataSourceTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cdn/internal/CDNDataSourceTest.kt new file mode 100644 index 00000000000..f642c3cdd69 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cdn/internal/CDNDataSourceTest.kt @@ -0,0 +1,223 @@ +/* + * 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.client.cdn.internal + +import android.net.Uri +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener +import io.getstream.chat.android.client.cdn.CDN +import io.getstream.chat.android.client.cdn.CDNRequest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@UnstableApi +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +internal class CDNDataSourceTest { + + @Test + fun `open rewrites URI and headers when CDN returns new URL and headers`() { + val cdn = object : CDN { + override suspend fun fileRequest(url: String) = + CDNRequest("https://cdn.example.com/video.mp4", mapOf("Auth" to "token")) + } + val upstream = FakeDataSource() + val factory = CDNDataSourceFactory(cdn) { upstream } + val dataSource = factory.createDataSource() + val dataSpec = DataSpec(Uri.parse("https://original.com/video.mp4")) + + dataSource.open(dataSpec) + + val opened = upstream.lastOpenedDataSpec!! + assertEquals("https://cdn.example.com/video.mp4", opened.uri.toString()) + assertEquals("token", opened.httpRequestHeaders["Auth"]) + } + + @Test + fun `open merges CDN headers with existing DataSpec headers`() { + val cdn = object : CDN { + override suspend fun fileRequest(url: String) = + CDNRequest(url, mapOf("X-CDN" to "cdn-value")) + } + val upstream = FakeDataSource() + val factory = CDNDataSourceFactory(cdn) { upstream } + val dataSource = factory.createDataSource() + val dataSpec = DataSpec.Builder() + .setUri("https://original.com/video.mp4") + .setHttpRequestHeaders(mapOf("X-Existing" to "existing-value")) + .build() + + dataSource.open(dataSpec) + + val opened = upstream.lastOpenedDataSpec!! + assertEquals("existing-value", opened.httpRequestHeaders["X-Existing"]) + assertEquals("cdn-value", opened.httpRequestHeaders["X-CDN"]) + } + + @Test + fun `open CDN headers override existing headers for same key`() { + val cdn = object : CDN { + override suspend fun fileRequest(url: String) = + CDNRequest(url, mapOf("Auth" to "new-token")) + } + val upstream = FakeDataSource() + val factory = CDNDataSourceFactory(cdn) { upstream } + val dataSource = factory.createDataSource() + val dataSpec = DataSpec.Builder() + .setUri("https://original.com/video.mp4") + .setHttpRequestHeaders(mapOf("Auth" to "old-token")) + .build() + + dataSource.open(dataSpec) + + val opened = upstream.lastOpenedDataSpec!! + assertEquals("new-token", opened.httpRequestHeaders["Auth"]) + } + + @Test + @Suppress("TooGenericExceptionThrown") + fun `open falls back to original DataSpec when CDN throws`() { + val cdn = object : CDN { + override suspend fun fileRequest(url: String): CDNRequest { + throw RuntimeException("CDN error") + } + } + val upstream = FakeDataSource() + val factory = CDNDataSourceFactory(cdn) { upstream } + val dataSource = factory.createDataSource() + val originalUri = Uri.parse("https://original.com/video.mp4") + val dataSpec = DataSpec(originalUri) + + dataSource.open(dataSpec) + + val opened = upstream.lastOpenedDataSpec!! + assertEquals("https://original.com/video.mp4", opened.uri.toString()) + } + + @Test + fun `open skips CDN for non-HTTP schemes`() { + var cdnCalled = false + val cdn = object : CDN { + override suspend fun fileRequest(url: String): CDNRequest { + cdnCalled = true + return CDNRequest("https://should-not-be-used.com") + } + } + val upstream = FakeDataSource() + val factory = CDNDataSourceFactory(cdn) { upstream } + val dataSource = factory.createDataSource() + val dataSpec = DataSpec(Uri.parse("file:///local/video.mp4")) + + dataSource.open(dataSpec) + + val opened = upstream.lastOpenedDataSpec!! + assertEquals("file:///local/video.mp4", opened.uri.toString()) + assertTrue("CDN should not be called for file:// URIs", !cdnCalled) + } + + @Test + fun `delegates read to upstream`() { + val cdn = object : CDN {} + val upstream: DataSource = mock() + val factory = CDNDataSourceFactory(cdn) { upstream } + val dataSource = factory.createDataSource() + val buffer = ByteArray(1024) + whenever(upstream.read(buffer, 0, 1024)).thenReturn(512) + + val result = dataSource.read(buffer, 0, 1024) + + assertEquals(512, result) + verify(upstream).read(buffer, 0, 1024) + } + + @Test + fun `delegates close to upstream`() { + val cdn = object : CDN {} + val upstream: DataSource = mock() + val factory = CDNDataSourceFactory(cdn) { upstream } + val dataSource = factory.createDataSource() + + dataSource.close() + + verify(upstream).close() + } + + @Test + fun `delegates getUri to upstream`() { + val cdn = object : CDN {} + val expectedUri = Uri.parse("https://example.com") + val upstream: DataSource = mock() + whenever(upstream.uri).thenReturn(expectedUri) + val factory = CDNDataSourceFactory(cdn) { upstream } + val dataSource = factory.createDataSource() + + assertEquals(expectedUri, dataSource.uri) + } + + @Test + fun `delegates getResponseHeaders to upstream`() { + val cdn = object : CDN {} + val expectedHeaders = mapOf("Content-Type" to listOf("video/mp4")) + val upstream: DataSource = mock() + whenever(upstream.responseHeaders).thenReturn(expectedHeaders) + val factory = CDNDataSourceFactory(cdn) { upstream } + val dataSource = factory.createDataSource() + + assertEquals(expectedHeaders, dataSource.responseHeaders) + } + + @Test + fun `delegates addTransferListener to upstream`() { + val cdn = object : CDN {} + val upstream: DataSource = mock() + val listener: TransferListener = mock() + val factory = CDNDataSourceFactory(cdn) { upstream } + val dataSource = factory.createDataSource() + + dataSource.addTransferListener(listener) + + verify(upstream).addTransferListener(listener) + } + + /** + * A simple fake [DataSource] that records the [DataSpec] passed to [open]. + */ + @UnstableApi + private class FakeDataSource : DataSource { + var lastOpenedDataSpec: DataSpec? = null + + override fun open(dataSpec: DataSpec): Long { + lastOpenedDataSpec = dataSpec + return 0 + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int = 0 + override fun close() { /* empty on purpose */ } + override fun getUri(): Uri? = null + override fun getResponseHeaders(): Map> = emptyMap() + override fun addTransferListener(transferListener: TransferListener) { /* empty on purpose */ } + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cdn/internal/CDNOkHttpInterceptorTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cdn/internal/CDNOkHttpInterceptorTest.kt new file mode 100644 index 00000000000..db1896f81d4 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cdn/internal/CDNOkHttpInterceptorTest.kt @@ -0,0 +1,119 @@ +/* + * 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.client.cdn.internal + +import io.getstream.chat.android.client.api.FakeChain +import io.getstream.chat.android.client.api.FakeResponse +import io.getstream.chat.android.client.cdn.CDN +import io.getstream.chat.android.client.cdn.CDNRequest +import okhttp3.Request +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +internal class CDNOkHttpInterceptorTest { + + @Test + fun `intercept rewrites URL when CDN returns different URL`() { + val cdn = object : CDN { + override suspend fun fileRequest(url: String) = + CDNRequest("https://cdn.example.com/rewritten") + } + val interceptor = CDNOkHttpInterceptor(cdn) + val chain = FakeChain( + FakeResponse(200), + request = Request.Builder().url("https://original.com/file.mp4").build(), + ) + + val response = interceptor.intercept(chain) + + assertEquals("https://cdn.example.com/rewritten", response.request.url.toString()) + } + + @Test + fun `intercept adds CDN headers to the request`() { + val cdn = object : CDN { + override suspend fun fileRequest(url: String) = + CDNRequest(url, headers = mapOf("Authorization" to "Bearer token123", "X-Custom" to "value")) + } + val interceptor = CDNOkHttpInterceptor(cdn) + val chain = FakeChain( + FakeResponse(200), + request = Request.Builder().url("https://original.com/file.mp4").build(), + ) + + val response = interceptor.intercept(chain) + + assertEquals("Bearer token123", response.request.header("Authorization")) + assertEquals("value", response.request.header("X-Custom")) + } + + @Test + fun `intercept adds CDN headers without removing existing ones`() { + val cdn = object : CDN { + override suspend fun fileRequest(url: String) = + CDNRequest(url, headers = mapOf("X-CDN" to "cdn-value")) + } + val interceptor = CDNOkHttpInterceptor(cdn) + val originalRequest = Request.Builder() + .url("https://original.com/file.mp4") + .addHeader("X-Existing", "existing-value") + .build() + val chain = FakeChain(FakeResponse(200), request = originalRequest) + + val response = interceptor.intercept(chain) + + assertEquals("existing-value", response.request.header("X-Existing")) + assertEquals("cdn-value", response.request.header("X-CDN")) + } + + @Test + @Suppress("TooGenericExceptionThrown") + fun `intercept falls back to original request when CDN throws`() { + val cdn = object : CDN { + override suspend fun fileRequest(url: String): CDNRequest { + throw RuntimeException("CDN unavailable") + } + } + val interceptor = CDNOkHttpInterceptor(cdn) + val chain = FakeChain( + FakeResponse(200), + request = Request.Builder().url("https://original.com/file.mp4").build(), + ) + + val response = interceptor.intercept(chain) + + assertEquals("https://original.com/file.mp4", response.request.url.toString()) + assertNull(response.request.header("Authorization")) + } + + @Test + fun `intercept passes through unchanged when CDN returns original URL and null headers`() { + val cdn = object : CDN { + override suspend fun fileRequest(url: String) = CDNRequest(url, headers = null) + } + val interceptor = CDNOkHttpInterceptor(cdn) + val chain = FakeChain( + FakeResponse(200), + request = Request.Builder().url("https://original.com/file.mp4").build(), + ) + + val response = interceptor.intercept(chain) + + assertEquals("https://original.com/file.mp4", response.request.url.toString()) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt index 4647cd25ad1..fa1b22aad86 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt @@ -37,6 +37,7 @@ import io.getstream.chat.android.client.socket.FakeChatSocket import io.getstream.chat.android.client.token.TokenManager import io.getstream.chat.android.client.user.CurrentUserFetcher import io.getstream.chat.android.client.utils.TokenUtils +import io.getstream.chat.android.client.utils.internal.ServerClockOffset import io.getstream.chat.android.client.utils.retry.NoRetryPolicy import io.getstream.chat.android.models.NoOpMessageTransformer import io.getstream.chat.android.models.NoOpUserTransformer @@ -124,6 +125,7 @@ internal open class BaseChatClientTest { retryPolicy = NoRetryPolicy(), appSettingsManager = mock(), chatSocket = getChatSocket(), + serverClockOffset = ServerClockOffset(), pluginFactories = pluginFactories, repositoryFactoryProvider = NoOpRepositoryFactory.Provider, mutableClientState = mutableClientState, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt index 612166711ab..2b57355edc9 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt @@ -36,6 +36,7 @@ import io.getstream.chat.android.client.scope.UserTestScope import io.getstream.chat.android.client.socket.FakeChatSocket import io.getstream.chat.android.client.token.FakeTokenManager import io.getstream.chat.android.client.utils.TokenUtils +import io.getstream.chat.android.client.utils.internal.ServerClockOffset import io.getstream.chat.android.client.utils.retry.NoRetryPolicy import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.NoOpMessageTransformer @@ -142,6 +143,7 @@ internal class ChatClientDebuggerTest { retryPolicy = NoRetryPolicy(), appSettingsManager = mock(), chatSocket = fakeChatSocket, + serverClockOffset = ServerClockOffset(), pluginFactories = pluginFactories, mutableClientState = Mother.mockedClientState(), repositoryFactoryProvider = NoOpRepositoryFactory.Provider, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/file/StreamFileManagerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/file/StreamFileManagerTest.kt index 24c726196ba..784adbec4cb 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/file/StreamFileManagerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/file/StreamFileManagerTest.kt @@ -366,6 +366,75 @@ internal class StreamFileManagerTest { assertTrue(file.name.matches(Regex("STREAM_VID_\\d{8}_\\d{6}\\.mp4"))) } + @Test + fun `evictCacheFiles should delete files older than TTL`() = runTest { + val freshFile = "TMP_fresh.txt" + val expiredFile = "TMP_expired.txt" + + streamFileManager.writeFileInCache(context, freshFile, "fresh".byteInputStream()) + streamFileManager.writeFileInCache(context, expiredFile, "expired".byteInputStream()) + + val expiredResult = streamFileManager.getFileFromCache(context, expiredFile) + assertTrue(expiredResult is Result.Success) + (expiredResult as Result.Success).value.setLastModified(System.currentTimeMillis() - 10 * 60 * 1000L) + + streamFileManager.evictCacheFiles(context, "TMP", 5 * 60 * 1000L, 100L * 1024 * 1024) + + assertTrue(streamFileManager.getFileFromCache(context, freshFile) is Result.Success) + assertTrue(streamFileManager.getFileFromCache(context, expiredFile) is Result.Failure) + } + + @Test + fun `evictCacheFiles should delete oldest files when size cap exceeded`() = runTest { + val oldFile = "TMP_old.txt" + val newFile = "TMP_new.txt" + val largeContent = "x".repeat(1024) + + streamFileManager.writeFileInCache(context, oldFile, largeContent.byteInputStream()) + val oldResult = streamFileManager.getFileFromCache(context, oldFile) + assertTrue(oldResult is Result.Success) + (oldResult as Result.Success).value.setLastModified(System.currentTimeMillis() - 60_000L) + + streamFileManager.writeFileInCache(context, newFile, largeContent.byteInputStream()) + + streamFileManager.evictCacheFiles(context, "TMP", 5 * 60 * 1000L, 1500L) + + assertTrue(streamFileManager.getFileFromCache(context, oldFile) is Result.Failure) + assertTrue(streamFileManager.getFileFromCache(context, newFile) is Result.Success) + } + + @Test + fun `evictCacheFiles should keep files within TTL and under size cap`() = runTest { + val file1 = "TMP_keep1.txt" + val file2 = "TMP_keep2.txt" + + streamFileManager.writeFileInCache(context, file1, "content1".byteInputStream()) + streamFileManager.writeFileInCache(context, file2, "content2".byteInputStream()) + + streamFileManager.evictCacheFiles(context, "TMP", 5 * 60 * 1000L, 100L * 1024 * 1024) + + assertTrue(streamFileManager.getFileFromCache(context, file1) is Result.Success) + assertTrue(streamFileManager.getFileFromCache(context, file2) is Result.Success) + } + + @Test + fun `evictCacheFiles should not affect files with different prefix`() = runTest { + val matchFile = "TMP_match.txt" + val otherFile = "OTHER_keep.txt" + + streamFileManager.writeFileInCache(context, matchFile, "match".byteInputStream()) + streamFileManager.writeFileInCache(context, otherFile, "other".byteInputStream()) + + val matchResult = streamFileManager.getFileFromCache(context, matchFile) + assertTrue(matchResult is Result.Success) + (matchResult as Result.Success).value.setLastModified(System.currentTimeMillis() - 10 * 60 * 1000L) + + streamFileManager.evictCacheFiles(context, "TMP", 5 * 60 * 1000L, 100L * 1024 * 1024) + + assertTrue(streamFileManager.getFileFromCache(context, matchFile) is Result.Failure) + assertTrue(streamFileManager.getFileFromCache(context, otherFile) is Result.Success) + } + @Test fun `clearExternalStorage should delete both photos and videos`() { // Create photo and video files diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/internal/SyncManagerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/internal/SyncManagerTest.kt index 3cb33b7959c..d9fc4b01d2a 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/internal/SyncManagerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/internal/SyncManagerTest.kt @@ -32,6 +32,7 @@ import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.client.sync.SyncState import io.getstream.chat.android.client.test.randomConnectedEvent +import io.getstream.chat.android.client.utils.internal.ServerClockOffset import io.getstream.chat.android.client.utils.observable.Disposable import io.getstream.chat.android.core.internal.coroutines.Tube import io.getstream.chat.android.models.ConnectionState @@ -546,6 +547,7 @@ internal class SyncManagerTest { isAutomaticSyncOnReconnectEnabled = isAutomaticSyncOnReconnectEnabled, syncMaxThreshold = syncMaxThreshold, now = { currentTime }, + serverClockOffset = ServerClockOffset(localTimeMs = { currentTime }), ) } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/socket/FakeChatSocket.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/socket/FakeChatSocket.kt index e6178e185c3..83ebcb1f7e2 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/socket/FakeChatSocket.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/socket/FakeChatSocket.kt @@ -25,6 +25,7 @@ import io.getstream.chat.android.client.parser2.adapters.internal.StreamDateForm import io.getstream.chat.android.client.scope.UserScope import io.getstream.chat.android.client.token.FakeTokenManager import io.getstream.chat.android.client.token.TokenManager +import io.getstream.chat.android.client.utils.internal.ServerClockOffset import io.getstream.chat.android.models.EventType import io.getstream.chat.android.models.User import io.getstream.chat.android.randomString @@ -46,6 +47,7 @@ internal class FakeChatSocket private constructor( userScope: UserScope, lifecycleObserver: StreamLifecycleObserver, networkStateProvider: NetworkStateProvider, + serverClockOffset: ServerClockOffset, getWebSocketListener: () -> WebSocketListener, ) : ChatSocket( apiKey, @@ -55,6 +57,7 @@ internal class FakeChatSocket private constructor( userScope, lifecycleObserver, networkStateProvider, + serverClockOffset = serverClockOffset, ) { private val streamDateFormatter = StreamDateFormatter() private val webSocketListener: WebSocketListener by lazy { getWebSocketListener() } @@ -89,6 +92,7 @@ internal class FakeChatSocket private constructor( wssUrl: String = randomString(), tokenManager: TokenManager = FakeTokenManager(randomString()), networkStateProvider: NetworkStateProvider = mock(), + serverClockOffset: ServerClockOffset = ServerClockOffset(), ): FakeChatSocket { var webSocketListener: WebSocketListener? = null val parser: ChatParser = mock() @@ -107,6 +111,7 @@ internal class FakeChatSocket private constructor( userScope, lifecycleObserver, networkStateProvider, + serverClockOffset, ) { webSocketListener!! } } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/internal/ServerClockOffsetTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/internal/ServerClockOffsetTest.kt new file mode 100644 index 00000000000..a8c98f60c0a --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/internal/ServerClockOffsetTest.kt @@ -0,0 +1,399 @@ +/* + * 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.client.utils.internal + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.util.Date + +internal class ServerClockOffsetTest { + + // ── estimatedServerTime before any calibration ────────────────────── + + @Test + fun `estimatedServerTime equals local time before any calibration`() { + val sut = ServerClockOffset(localTimeMs = { 1_000_000L }) + + assertEquals(Date(1_000_000L), sut.estimatedServerTime()) + } + + // ── onConnected (naive one-way estimate) ──────────────────────────── + + @Test + fun `onConnected calibrates when local clock is ahead`() { + val sut = ServerClockOffset(localTimeMs = { 10_000L }) + + sut.onConnected(serverTime = Date(7_000L)) + + assertEquals(Date(7_000L), sut.estimatedServerTime()) + } + + @Test + fun `onConnected calibrates when local clock is behind`() { + val sut = ServerClockOffset(localTimeMs = { 5_000L }) + + sut.onConnected(serverTime = Date(8_000L)) + + assertEquals(Date(8_000L), sut.estimatedServerTime()) + } + + @Test + fun `onConnected resets health check state from previous connection`() { + var localTime = 10_000L + val sut = ServerClockOffset(localTimeMs = { localTime }) + + sut.onHealthCheckSent() + + localTime = 10_200L + sut.onConnected(serverTime = Date(10_100L)) + + localTime = 10_400L + sut.onHealthCheck(serverTime = Date(10_300L)) + assertEquals(Date(10_100L + (10_400L - 10_200L)), sut.estimatedServerTime()) + } + + // ── onConnectionStarted + onConnected (NTP for initial connection) ─── + + @Test + fun `onConnected uses NTP midpoint when onConnectionStarted was called`() { + val skew = 3_000L + var localTime = 10_000L + val sut = ServerClockOffset(localTimeMs = { localTime }) + + sut.onConnectionStarted() + + localTime = 10_200L + val serverTimeAtMidpoint = (10_000L + 10_200L) / 2 - skew + sut.onConnected(serverTime = Date(serverTimeAtMidpoint)) + + // offset = (10_000 + 10_200) / 2 - serverTimeAtMidpoint = 3_000 + localTime = 15_000L + assertEquals(Date(15_000L - skew), sut.estimatedServerTime()) + } + + @Test + fun `onConnected falls back to naive when onConnectionStarted was not called`() { + val localTime = 10_000L + val sut = ServerClockOffset(localTimeMs = { localTime }) + + sut.onConnected(serverTime = Date(7_000L)) + + assertEquals(Date(7_000L), sut.estimatedServerTime()) + } + + @Test + fun `onConnected rejects connection pair when RTT exceeds maxRttMs and uses naive`() { + var localTime = 0L + val sut = ServerClockOffset(localTimeMs = { localTime }, maxRttMs = 100L) + + sut.onConnectionStarted() + + localTime = 500L + sut.onConnected(serverTime = Date(250L)) + + // RTT = 500 > maxRttMs = 100 → rejected, naive used: offset = 500 - 250 = 250 + assertEquals(Date(500L - 250L), sut.estimatedServerTime()) + } + + @Test + fun `onConnectionStarted is consumed so second onConnected uses naive`() { + var localTime = 0L + val sut = ServerClockOffset(localTimeMs = { localTime }) + + sut.onConnectionStarted() + localTime = 100L + sut.onConnected(serverTime = Date(50L)) + + localTime = 1_000L + sut.onConnected(serverTime = Date(999L)) + + // No connectionStartedAtMs (consumed), so naive: offset = 1000 - 999 = 1 + assertEquals(Date(1_000L - 1L), sut.estimatedServerTime()) + } + + // ── onHealthCheck (NTP midpoint with min-RTT selection) ───────────── + + @Test + fun `onHealthCheck computes NTP midpoint offset`() { + val skew = 3_000L + var localTime = 10_000L + val sut = ServerClockOffset(localTimeMs = { localTime }) + sut.onConnected(serverTime = Date(localTime - skew)) + + localTime = 20_000L + sut.onHealthCheckSent() + + localTime = 20_200L + sut.onHealthCheck(serverTime = Date(17_100L)) + + // offset = (20_000 + 20_200) / 2 - 17_100 = 3_000 + // estimatedServerTime = 20_200 - 3_000 = 17_200 + assertEquals(Date(17_200L), sut.estimatedServerTime()) + } + + @Test + fun `onHealthCheck keeps lowest RTT sample`() { + var localTime = 0L + val sut = ServerClockOffset(localTimeMs = { localTime }) + sut.onConnected(serverTime = Date(0L)) + + // First health check: RTT = 500 + localTime = 1_000L + sut.onHealthCheckSent() + localTime = 1_500L + sut.onHealthCheck(serverTime = Date(1_250L)) + val offsetAfterFirst = (1_000L + 1_500L) / 2 - 1_250L + + // Second health check: RTT = 100 (better) + localTime = 2_000L + sut.onHealthCheckSent() + localTime = 2_100L + sut.onHealthCheck(serverTime = Date(2_050L)) + val offsetAfterSecond = (2_000L + 2_100L) / 2 - 2_050L + + localTime = 5_000L + assertEquals(Date(5_000L - offsetAfterSecond), sut.estimatedServerTime()) + } + + @Test + fun `onHealthCheck ignores higher RTT sample`() { + var localTime = 0L + val sut = ServerClockOffset(localTimeMs = { localTime }) + sut.onConnected(serverTime = Date(0L)) + + // First health check: RTT = 100 (best) + localTime = 1_000L + sut.onHealthCheckSent() + localTime = 1_100L + sut.onHealthCheck(serverTime = Date(1_050L)) + val bestOffset = (1_000L + 1_100L) / 2 - 1_050L + + // Second health check: RTT = 500 (worse -- ignored) + localTime = 2_000L + sut.onHealthCheckSent() + localTime = 2_500L + sut.onHealthCheck(serverTime = Date(2_250L)) + + localTime = 5_000L + assertEquals(Date(5_000L - bestOffset), sut.estimatedServerTime()) + } + + @Test + fun `onHealthCheck overrides naive onConnected estimate`() { + val skew = 3_000L + var localTime = 10_000L + val sut = ServerClockOffset(localTimeMs = { localTime }) + sut.onConnected(serverTime = Date(localTime - skew)) + + // Naive estimate at localTime = 10_000: offset = 3_000 + assertEquals(Date(7_000L), sut.estimatedServerTime()) + + localTime = 20_000L + sut.onHealthCheckSent() + localTime = 20_200L + sut.onHealthCheck(serverTime = Date(17_100L)) + + // NTP offset = (20_000 + 20_200) / 2 - 17_100 = 3_000 + // At localTime = 20_200: estimated = 20_200 - 3_000 = 17_200 + assertEquals(Date(17_200L), sut.estimatedServerTime()) + } + + // ── Guards: mismatched / stale / implausible pairs ────────────────── + + @Test + fun `onHealthCheck is no-op without prior onHealthCheckSent`() { + var localTime = 10_000L + val sut = ServerClockOffset(localTimeMs = { localTime }) + sut.onConnected(serverTime = Date(7_000L)) + + localTime = 20_000L + sut.onHealthCheck(serverTime = Date(17_000L)) + + // Offset unchanged from onConnected: 10_000 - 7_000 = 3_000 + assertEquals(Date(20_000L - 3_000L), sut.estimatedServerTime()) + } + + @Test + fun `onHealthCheck consumes sentAt so second call is no-op`() { + var localTime = 0L + val sut = ServerClockOffset(localTimeMs = { localTime }) + sut.onConnected(serverTime = Date(0L)) + + localTime = 1_000L + sut.onHealthCheckSent() + + localTime = 1_100L + sut.onHealthCheck(serverTime = Date(1_050L)) + val offsetAfterFirst = (1_000L + 1_100L) / 2 - 1_050L + + localTime = 50_000L + sut.onHealthCheck(serverTime = Date(99_999L)) + + // Offset unchanged -- second call was a no-op (sentAtMs consumed) + assertEquals(Date(50_000L - offsetAfterFirst), sut.estimatedServerTime()) + } + + @Test + fun `onHealthCheck rejects RTT exceeding maxRttMs`() { + var localTime = 0L + val sut = ServerClockOffset(localTimeMs = { localTime }, maxRttMs = 500L) + sut.onConnected(serverTime = Date(0L)) + + localTime = 1_000L + sut.onHealthCheckSent() + localTime = 2_000L + sut.onHealthCheck(serverTime = Date(1_500L)) + + // RTT = 1_000 > maxRttMs = 500 → rejected, offset unchanged (= 0) + assertEquals(Date(2_000L), sut.estimatedServerTime()) + } + + @Test + fun `onHealthCheck rejects non-positive RTT`() { + val localTime = 1_000L + val sut = ServerClockOffset(localTimeMs = { localTime }) + sut.onConnected(serverTime = Date(1_000L)) + + sut.onHealthCheckSent() + // localTime hasn't advanced → RTT = 0 → rejected + sut.onHealthCheck(serverTime = Date(1_000L)) + + assertEquals(Date(1_000L), sut.estimatedServerTime()) + } + + // ── Reconnect resets ──────────────────────────────────────────────── + + @Test + fun `onConnected resets bestRtt so health checks re-converge`() { + var localTime = 0L + val sut = ServerClockOffset(localTimeMs = { localTime }) + sut.onConnected(serverTime = Date(0L)) + + // Excellent RTT on first connection + localTime = 1_000L + sut.onHealthCheckSent() + localTime = 1_050L + sut.onHealthCheck(serverTime = Date(1_025L)) + + // Reconnect resets bestRtt + localTime = 50_000L + sut.onConnected(serverTime = Date(50_000L)) + + // Worse RTT on new connection should still be accepted + localTime = 51_000L + sut.onHealthCheckSent() + localTime = 51_200L + sut.onHealthCheck(serverTime = Date(51_100L)) + + val expectedOffset = (51_000L + 51_200L) / 2 - 51_100L + localTime = 60_000L + assertEquals(Date(60_000L - expectedOffset), sut.estimatedServerTime()) + } + + // ── Clock directions with health check ────────────────────────────── + + @Test + fun `clock 1 hour ahead is corrected by health check`() { + val skew = 3_600_000L + var localTime = 36_000_000L + val sut = ServerClockOffset(localTimeMs = { localTime }) + sut.onConnected(serverTime = Date(localTime - skew)) + + localTime = 36_010_000L + sut.onHealthCheckSent() + localTime = 36_010_200L + val serverTimeAtMidpoint = (36_010_000L + 36_010_200L) / 2 - skew + sut.onHealthCheck(serverTime = Date(serverTimeAtMidpoint)) + + localTime = 36_020_000L + val expected = 36_020_000L - skew + assertEquals(Date(expected), sut.estimatedServerTime()) + } + + @Test + fun `clock 1 hour behind is corrected by health check`() { + val skew = -3_600_000L + var localTime = 28_800_000L + val sut = ServerClockOffset(localTimeMs = { localTime }) + sut.onConnected(serverTime = Date(localTime - skew)) + + localTime = 28_810_000L + sut.onHealthCheckSent() + localTime = 28_810_200L + val serverTimeAtMidpoint = (28_810_000L + 28_810_200L) / 2 - skew + sut.onHealthCheck(serverTime = Date(serverTimeAtMidpoint)) + + localTime = 28_820_000L + val expected = 28_820_000L - skew + assertEquals(Date(expected), sut.estimatedServerTime()) + } + + // ── maxOffsetMs: implausibly large offsets are rejected ────────────── + + @Test + fun `onConnected naive falls back to local time when offset exceeds maxOffsetMs`() { + // Simulates a mock server with a stale static timestamp (e.g. 155 days in the past) + val localTime = 10_000L + val sut = ServerClockOffset(localTimeMs = { localTime }, maxOffsetMs = 1_000L) + + sut.onConnected(serverTime = Date(localTime - 5_000L)) // offset = 5_000 > 1_000 + + // offset rejected → estimatedServerTime == local time + assertEquals(Date(localTime), sut.estimatedServerTime()) + } + + @Test + fun `onConnected NTP falls back to local time when offset exceeds maxOffsetMs`() { + var localTime = 0L + val sut = ServerClockOffset(localTimeMs = { localTime }, maxOffsetMs = 1_000L) + + sut.onConnectionStarted() + localTime = 200L + sut.onConnected(serverTime = Date(localTime - 5_000L)) // offset = 5_000 > 1_000 + + localTime = 1_000L + assertEquals(Date(1_000L), sut.estimatedServerTime()) + } + + @Test + fun `onConnected accepts offset exactly at maxOffsetMs boundary`() { + val localTime = 10_000L + val sut = ServerClockOffset(localTimeMs = { localTime }, maxOffsetMs = 1_000L) + + sut.onConnected(serverTime = Date(localTime - 1_000L)) // offset = 1_000 == maxOffsetMs + + assertEquals(Date(9_000L), sut.estimatedServerTime()) + } + + @Test + fun `onHealthCheck falls back to prior offset when new offset exceeds maxOffsetMs`() { + var localTime = 10_000L + val skew = 500L + val sut = ServerClockOffset(localTimeMs = { localTime }, maxOffsetMs = 1_000L) + sut.onConnected(serverTime = Date(localTime - skew)) // valid offset = 500 + + // Health check producing an implausibly large offset + localTime = 20_000L + sut.onHealthCheckSent() + localTime = 20_200L + sut.onHealthCheck(serverTime = Date(localTime - 5_000L)) // would-be offset = 5_000 > 1_000 + + // Offset unchanged from onConnected (= 500) + localTime = 30_000L + assertEquals(Date(30_000L - skew), sut.estimatedServerTime()) + } +} diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/attachments/ChannelMediaAttachmentsActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/attachments/ChannelMediaAttachmentsActivity.kt index 8b69c6de05d..53efdb3ed38 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/attachments/ChannelMediaAttachmentsActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/channel/attachments/ChannelMediaAttachmentsActivity.kt @@ -34,7 +34,6 @@ import io.getstream.chat.android.compose.viewmodel.channel.ChannelAttachmentsVie import io.getstream.chat.android.compose.viewmodel.channel.ChannelAttachmentsViewModelFactory import io.getstream.chat.android.models.AttachmentType import io.getstream.chat.android.ui.common.feature.channel.attachments.ChannelAttachmentsViewEvent -import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl import kotlinx.coroutines.flow.collectLatest class ChannelMediaAttachmentsActivity : ComponentActivity() { @@ -50,7 +49,7 @@ class ChannelMediaAttachmentsActivity : ComponentActivity() { ChannelAttachmentsViewModelFactory( cid = requireNotNull(intent.getStringExtra(KEY_CID)), attachmentTypes = listOf(AttachmentType.IMAGE, AttachmentType.VIDEO), - localFilter = { !it.imagePreviewUrl.isNullOrEmpty() && it.titleLink.isNullOrEmpty() }, + localFilter = { !(it.imageUrl ?: it.thumbUrl).isNullOrEmpty() && it.titleLink.isNullOrEmpty() }, ) } diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt index 40ffa3ee400..1d1a7b01a19 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt @@ -99,7 +99,6 @@ import io.getstream.chat.android.ui.common.feature.channel.info.ChannelInfoViewE import io.getstream.chat.android.ui.common.state.channel.info.ChannelInfoViewState import io.getstream.chat.android.ui.common.state.messages.list.ChannelHeaderViewState import io.getstream.chat.android.ui.common.state.messages.list.DeletedMessageVisibility -import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest @@ -567,7 +566,7 @@ class ChatsActivity : ComponentActivity() { val viewModelFactory = ChannelAttachmentsViewModelFactory( cid = cid, attachmentTypes = listOf(AttachmentType.IMAGE, AttachmentType.VIDEO), - localFilter = { !it.imagePreviewUrl.isNullOrEmpty() && it.titleLink.isNullOrEmpty() }, + localFilter = { !(it.imageUrl ?: it.thumbUrl).isNullOrEmpty() && it.titleLink.isNullOrEmpty() }, ) val viewModel = viewModel( factory = viewModelFactory, diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index e365948a8d7..cb489e2f75b 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -466,39 +466,39 @@ public final class io/getstream/chat/android/compose/ui/attachments/content/Comp public final class io/getstream/chat/android/compose/ui/attachments/content/ComposableSingletons$FileAttachmentContentKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/attachments/content/ComposableSingletons$FileAttachmentContentKt; public fun ()V - public final fun getLambda$-717253115$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1435287419$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$181170026$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-2136389444$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-524321914$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-703565781$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/attachments/content/ComposableSingletons$GiphyAttachmentContentKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/attachments/content/ComposableSingletons$GiphyAttachmentContentKt; public fun ()V - public final fun getLambda$1003066709$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$271803750$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$870742964$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/attachments/content/ComposableSingletons$LinkAttachmentContentKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/attachments/content/ComposableSingletons$LinkAttachmentContentKt; public fun ()V - public final fun getLambda$-2127415352$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1355263169$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1049628640$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1464956105$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/attachments/content/ComposableSingletons$MediaAttachmentContentKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/attachments/content/ComposableSingletons$MediaAttachmentContentKt; public fun ()V - public final fun getLambda$1154126447$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-502879794$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$1438143150$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$1772229418$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$530952695$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$225318166$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$604805366$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/attachments/content/ComposableSingletons$UnsupportedAttachmentContentKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/attachments/content/ComposableSingletons$UnsupportedAttachmentContentKt; public fun ()V - public final fun getLambda$-523184459$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-273906058$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/attachments/content/FileAttachmentContentKt { @@ -673,12 +673,14 @@ public abstract interface class io/getstream/chat/android/compose/ui/attachments } public final class io/getstream/chat/android/compose/ui/attachments/preview/handler/AttachmentPreviewHandler$Companion { - public final fun defaultAttachmentHandlers (Landroid/content/Context;)Ljava/util/List; + public final fun defaultAttachmentHandlers (Landroid/content/Context;Z)Ljava/util/List; + public static synthetic fun defaultAttachmentHandlers$default (Lio/getstream/chat/android/compose/ui/attachments/preview/handler/AttachmentPreviewHandler$Companion;Landroid/content/Context;ZILjava/lang/Object;)Ljava/util/List; } public final class io/getstream/chat/android/compose/ui/attachments/preview/handler/DocumentAttachmentPreviewHandler : io/getstream/chat/android/compose/ui/attachments/preview/handler/AttachmentPreviewHandler { public static final field $stable I - public fun (Landroid/content/Context;)V + public fun (Landroid/content/Context;Z)V + public synthetic fun (Landroid/content/Context;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun canHandle (Lio/getstream/chat/android/models/Attachment;)Z public fun handleAttachmentPreview (Lio/getstream/chat/android/models/Attachment;)V } @@ -725,11 +727,11 @@ public final class io/getstream/chat/android/compose/ui/channel/attachments/Comp public final class io/getstream/chat/android/compose/ui/channel/attachments/ComposableSingletons$ChannelFilesAttachmentsScreenKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channel/attachments/ComposableSingletons$ChannelFilesAttachmentsScreenKt; public fun ()V - public final fun getLambda$-134612854$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-2118584231$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1391363486$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$310431298$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$581196621$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1416649638$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-393431677$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$114665547$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$2093298079$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$830475022$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/channel/attachments/ComposableSingletons$ChannelMediaAttachmentsGridKt { @@ -746,18 +748,18 @@ public final class io/getstream/chat/android/compose/ui/channel/attachments/Comp public final class io/getstream/chat/android/compose/ui/channel/attachments/ComposableSingletons$ChannelMediaAttachmentsPreviewScreenKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channel/attachments/ComposableSingletons$ChannelMediaAttachmentsPreviewScreenKt; public fun ()V - public final fun getLambda$1500264718$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$779946862$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1579521391$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$459972879$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/channel/attachments/ComposableSingletons$ChannelMediaAttachmentsScreenKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channel/attachments/ComposableSingletons$ChannelMediaAttachmentsScreenKt; public fun ()V - public final fun getLambda$-540544092$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-564123656$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1643768540$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$175265383$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$220895923$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-291265691$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$137810937$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$424543784$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$922830516$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$939905565$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/channel/info/ChannelInfoMemberOptionsKt { @@ -767,82 +769,82 @@ public final class io/getstream/chat/android/compose/ui/channel/info/ChannelInfo public final class io/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$AddMembersScreenKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$AddMembersScreenKt; public fun ()V + public final fun getLambda$-1384537946$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-1754400971$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-777726963$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1027758174$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1282843778$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1928445699$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-659334687$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$1302953779$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$1465447591$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$305242115$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$554001351$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1578701036$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1725394368$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$498173316$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$756179806$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$776904639$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$803858118$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$ChannelInfoMemberInfoModalSheetKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$ChannelInfoMemberInfoModalSheetKt; public fun ()V - public final fun getLambda$-1981812761$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1453636415$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1792442436$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$2116128524$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1972042200$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1108212131$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$2125899085$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$769406110$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$ChannelInfoOptionItemKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$ChannelInfoOptionItemKt; public fun ()V - public final fun getLambda$-1241192189$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1982117825$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-292702460$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1651259998$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$ChannelInfoOptionKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$ChannelInfoOptionKt; public fun ()V - public final fun getLambda$-1040044637$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1784400151$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$879534165$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-74481820$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1819224088$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$914358102$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$ChannelInfoScreenModalKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$ChannelInfoScreenModalKt; public fun ()V - public final fun getLambda$-1604845612$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1661849450$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-2131127038$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-796704472$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1930610801$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$662530306$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1189703417$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1946537055$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-554976397$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1228014962$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$350047555$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$819325143$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$DirectChannelInfoScreenKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$DirectChannelInfoScreenKt; public fun ()V - public final fun getLambda$-983594346$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1472962006$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-260303913$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1642105781$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$621085431$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$GroupChannelEditScreenKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$GroupChannelEditScreenKt; public fun ()V - public final fun getLambda$-1037305618$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1423921824$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1002481681$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1148071730$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1270981260$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-1600862874$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-1839460112$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-1996460951$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-486482257$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1243333156$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1435277257$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$2075496307$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1357286986$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1977110561$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$581743683$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$GroupChannelInfoScreenKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channel/info/ComposableSingletons$GroupChannelInfoScreenKt; public fun ()V public final fun getLambda$-1507130086$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1556588502$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-392871553$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-422068023$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$2015088361$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1263831912$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/channel/info/DirectChannelInfoScreenKt { @@ -873,20 +875,20 @@ public final class io/getstream/chat/android/compose/ui/channels/header/ChannelL public final class io/getstream/chat/android/compose/ui/channels/header/ComposableSingletons$ChannelListHeaderKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channels/header/ComposableSingletons$ChannelListHeaderKt; public fun ()V - public final fun getLambda$-1379122838$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1007073934$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1670798117$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1954110324$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$300968441$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-431955895$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-938269244$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1024258874$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1972636751$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$2147041525$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$423422310$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$732064403$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$924995604$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/channels/info/ComposableSingletons$SelectedChannelMenuKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channels/info/ComposableSingletons$SelectedChannelMenuKt; public fun ()V - public final fun getLambda$-2042739158$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$995491646$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1613575715$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1549632299$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/channels/info/SelectedChannelMenuKt { @@ -909,16 +911,16 @@ public final class io/getstream/chat/android/compose/ui/channels/list/ChannelsKt public final class io/getstream/chat/android/compose/ui/channels/list/ComposableSingletons$ChannelItemKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/channels/list/ComposableSingletons$ChannelItemKt; public fun ()V - public final fun getLambda$-1547886709$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1139924365$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1496206558$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$335089810$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$604714122$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$625714136$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$635950263$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$694440991$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$795071803$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$901496013$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1115462594$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1816279508$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-25639210$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-558845101$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1418216287$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$246298636$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$609110188$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$717081532$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$770123831$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$773857897$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/channels/list/ComposableSingletons$ChannelListKt { @@ -1040,7 +1042,7 @@ public final class io/getstream/chat/android/compose/ui/components/BackButtonKt public final class io/getstream/chat/android/compose/ui/components/ComposableSingletons$ComposerCancelIconKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/ComposableSingletons$ComposerCancelIconKt; public fun ()V - public final fun getLambda$-1154062986$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1426499273$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/ComposableSingletons$ContentBoxKt { @@ -1054,10 +1056,10 @@ public final class io/getstream/chat/android/compose/ui/components/ComposableSin public final class io/getstream/chat/android/compose/ui/components/ComposableSingletons$SearchInputKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/ComposableSingletons$SearchInputKt; public fun ()V - public final fun getLambda$-677311090$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-750420177$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$1909378300$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$236027516$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$609171215$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/ComposableSingletons$SimpleDialogKt { @@ -1127,14 +1129,14 @@ public final class io/getstream/chat/android/compose/ui/components/TypingIndicat public final class io/getstream/chat/android/compose/ui/components/attachments/files/ComposableSingletons$FileTypeIconKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/attachments/files/ComposableSingletons$FileTypeIconKt; public fun ()V - public final fun getLambda$-1000060634$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$79481413$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/attachments/files/ComposableSingletons$FilesPickerKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/attachments/files/ComposableSingletons$FilesPickerKt; public fun ()V - public final fun getLambda$-992464417$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1344332199$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-2003468154$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$693435518$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/attachments/files/FilesPickerItemImageKt { @@ -1148,8 +1150,8 @@ public final class io/getstream/chat/android/compose/ui/components/attachments/f public final class io/getstream/chat/android/compose/ui/components/attachments/images/ComposableSingletons$ImagesPickerKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/attachments/images/ComposableSingletons$ImagesPickerKt; public fun ()V - public final fun getLambda$-15552204$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1777258684$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-697716637$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$932937525$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/attachments/images/ImagesPickerKt { @@ -1188,7 +1190,7 @@ public final class io/getstream/chat/android/compose/ui/components/avatar/UserAv public final class io/getstream/chat/android/compose/ui/components/button/ComposableSingletons$StreamButtonKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/button/ComposableSingletons$StreamButtonKt; public fun ()V - public final fun getLambda$1332410672$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1117581519$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/channels/ChannelMembersKt { @@ -1227,37 +1229,37 @@ public final class io/getstream/chat/android/compose/ui/components/channels/Chan public final class io/getstream/chat/android/compose/ui/components/channels/ComposableSingletons$ChannelMembersItemKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/channels/ComposableSingletons$ChannelMembersItemKt; public fun ()V - public final fun getLambda$919559991$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$25625080$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/channels/ComposableSingletons$ChannelMembersKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/channels/ComposableSingletons$ChannelMembersKt; public fun ()V - public final fun getLambda$-642858332$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$512409354$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1421797045$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-720848603$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/channels/ComposableSingletons$ChannelOptionsKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/channels/ComposableSingletons$ChannelOptionsKt; public fun ()V - public final fun getLambda$-2099923584$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1955513889$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/channels/ComposableSingletons$MessageReadStatusIconKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/channels/ComposableSingletons$MessageReadStatusIconKt; public fun ()V - public final fun getLambda$-1237195220$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1450886538$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-273148368$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$492011124$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$702601599$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-2117056237$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-2121931027$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$235013397$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$450142065$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$5760$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/channels/ComposableSingletons$UnreadCountIndicatorKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/channels/ComposableSingletons$UnreadCountIndicatorKt; public fun ()V - public final fun getLambda$-174810483$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1800957362$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1831816724$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1609274127$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/channels/MessageReadStatusIconKt { @@ -1272,52 +1274,52 @@ public final class io/getstream/chat/android/compose/ui/components/channels/Unre public final class io/getstream/chat/android/compose/ui/components/common/ComposableSingletons$CheckboxKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/common/ComposableSingletons$CheckboxKt; public fun ()V - public final fun getLambda$2116611125$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-952110380$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/common/ComposableSingletons$CommandChipKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/common/ComposableSingletons$CommandChipKt; public fun ()V - public final fun getLambda$-100127303$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$373391640$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/common/ComposableSingletons$ContextualMenuKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/common/ComposableSingletons$ContextualMenuKt; public fun ()V - public final fun getLambda$-1035236663$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-677656741$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1449827048$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$1807406970$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/common/ComposableSingletons$CountBadgeKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/common/ComposableSingletons$CountBadgeKt; public fun ()V - public final fun getLambda$-1604941101$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-482661902$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/common/ComposableSingletons$MediaBadgesKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/common/ComposableSingletons$MediaBadgesKt; public fun ()V - public final fun getLambda$-1111764311$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-2020043295$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1373299400$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$465020416$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/common/ComposableSingletons$PlayButtonKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/common/ComposableSingletons$PlayButtonKt; public fun ()V - public final fun getLambda$1408560879$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1764127218$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/common/ComposableSingletons$PlaybackSpeedToggleKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/common/ComposableSingletons$PlaybackSpeedToggleKt; public fun ()V - public final fun getLambda$1741425817$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-200752648$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/common/ComposableSingletons$RadioControlsKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/common/ComposableSingletons$RadioControlsKt; public fun ()V - public final fun getLambda$-1193458187$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1642681905$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1408287340$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$151503440$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/common/MenuOptionItemKt { @@ -1327,40 +1329,40 @@ public final class io/getstream/chat/android/compose/ui/components/common/MenuOp public final class io/getstream/chat/android/compose/ui/components/composer/ComposableSingletons$ComposerLinkPreviewKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/composer/ComposableSingletons$ComposerLinkPreviewKt; public fun ()V + public final fun getLambda$-1215411874$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$1162160133$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1202286527$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/composer/ComposableSingletons$InputFieldKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/composer/ComposableSingletons$InputFieldKt; public fun ()V - public final fun getLambda$1685265783$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$1820691270$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-2000757083$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-2136182570$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; } public final class io/getstream/chat/android/compose/ui/components/composer/ComposableSingletons$MessageInputKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/composer/ComposableSingletons$MessageInputKt; public fun ()V - public final fun getLambda$-1127823687$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1208689611$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1211226700$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1012698267$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-1320161390$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-1355851382$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1430207850$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1684476750$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1820665602$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-535904363$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1067864317$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1081079533$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1146934483$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-140647522$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1508198121$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1651521827$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1847599811$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-2012559494$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-2077475695$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-23366827$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-961948299$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1272484982$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1407574737$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$1508406964$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1643578748$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$164987007$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1918811638$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1812722523$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$2016354046$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$2046642350$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$418285475$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$442011920$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$570098590$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$929480198$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$485345010$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$513964852$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/composer/ComposerLinkPreviewKt { @@ -1421,29 +1423,29 @@ public final class io/getstream/chat/android/compose/ui/components/messageoption public final class io/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$MessageAnnotationKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$MessageAnnotationKt; public fun ()V - public final fun getLambda$1760162651$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$866227740$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$MessageReactionsKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$MessageReactionsKt; public fun ()V - public final fun getLambda$1680147045$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1862356957$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$383913676$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$566123588$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1709319906$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-318682163$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1289414021$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$977551206$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$MessageTextKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$MessageTextKt; public fun ()V - public final fun getLambda$-70144582$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-284973735$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$PollMessageContentKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$PollMessageContentKt; public fun ()V public final fun getLambda$-1403107585$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-46287592$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1988466057$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-748128248$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$1104625805$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$1806560398$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; @@ -1456,8 +1458,8 @@ public final class io/getstream/chat/android/compose/ui/components/messages/Comp public final class io/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$ScrollToBottomButtonKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$ScrollToBottomButtonKt; public fun ()V - public final fun getLambda$-1041179467$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-388063089$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-567660524$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/messages/ComposableSingletons$SwipeToReplyIconKt { @@ -1532,44 +1534,44 @@ public final class io/getstream/chat/android/compose/ui/components/poll/Composab public final fun getLambda$-1437491986$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-1980307438$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$-49181804$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$1075132437$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$782746612$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/poll/ComposableSingletons$PollDialogHeaderKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/poll/ComposableSingletons$PollDialogHeaderKt; public fun ()V public final fun getLambda$-1604073059$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1225735551$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1370145246$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/poll/ComposableSingletons$PollMoreOptionsDialogKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/poll/ComposableSingletons$PollMoreOptionsDialogKt; public fun ()V - public final fun getLambda$-1521854163$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$355414732$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/poll/ComposableSingletons$PollOptionInputKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/poll/ComposableSingletons$PollOptionInputKt; public fun ()V + public final fun getLambda$-1499796468$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-1720332981$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$310107117$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/poll/ComposableSingletons$PollOptionVotesDialogKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/poll/ComposableSingletons$PollOptionVotesDialogKt; public fun ()V + public final fun getLambda$-1264306425$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-2004235384$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-625715631$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-712628466$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$1365316907$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$2065098054$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1648132431$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1735045266$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$715521557$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; } public final class io/getstream/chat/android/compose/ui/components/poll/ComposableSingletons$PollViewResultDialogKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/poll/ComposableSingletons$PollViewResultDialogKt; public fun ()V - public final fun getLambda$-1009002404$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$70539643$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/poll/PollAnswersKt { @@ -1596,8 +1598,8 @@ public final class io/getstream/chat/android/compose/ui/components/poll/PollView public final class io/getstream/chat/android/compose/ui/components/reactionoptions/ComposableSingletons$ExtendedReactionsOptionsKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/reactionoptions/ComposableSingletons$ExtendedReactionsOptionsKt; public fun ()V - public final fun getLambda$-867655729$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$665648868$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-603958704$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1715518083$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/reactionpicker/ReactionsPickerKt { @@ -1608,13 +1610,13 @@ public final class io/getstream/chat/android/compose/ui/components/reactionpicke public final class io/getstream/chat/android/compose/ui/components/reactions/ComposableSingletons$ReactionIconKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/reactions/ComposableSingletons$ReactionIconKt; public fun ()V - public final fun getLambda$2037498842$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1563472859$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/reactions/ComposableSingletons$ReactionToggleKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/reactions/ComposableSingletons$ReactionToggleKt; public fun ()V - public final fun getLambda$1494944442$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1676677691$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/reactions/ReactionIconSize : java/lang/Enum { @@ -1640,7 +1642,7 @@ public final class io/getstream/chat/android/compose/ui/components/reactions/Rea public final class io/getstream/chat/android/compose/ui/components/selectedmessage/ComposableSingletons$MessageMenuHeaderKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/selectedmessage/ComposableSingletons$MessageMenuHeaderKt; public fun ()V - public final fun getLambda$-1769208822$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-820719093$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$1869643284$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } @@ -1648,25 +1650,25 @@ public final class io/getstream/chat/android/compose/ui/components/selectedmessa public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/selectedmessage/ComposableSingletons$ReactionCountRowKt; public fun ()V public final fun getLambda$-188139460$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1295202040$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-639004359$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/selectedmessage/ComposableSingletons$ReactionsMenuKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/selectedmessage/ComposableSingletons$ReactionsMenuKt; public fun ()V public final fun getLambda$-20300968$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function4; - public final fun getLambda$1065764958$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$1440428061$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$305729368$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$2031327775$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$498660569$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$59986053$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/selectedmessage/ComposableSingletons$SelectedMessageMenuKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/selectedmessage/ComposableSingletons$SelectedMessageMenuKt; public fun ()V - public final fun getLambda$-1312161726$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1436712551$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-895544184$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1599407159$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-2016024701$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1896629530$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/components/selectedmessage/ComposableSingletons$UserReactionRowKt { @@ -1691,16 +1693,16 @@ public final class io/getstream/chat/android/compose/ui/components/selectedmessa public final class io/getstream/chat/android/compose/ui/mentions/ComposableSingletons$MentionListKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/mentions/ComposableSingletons$MentionListKt; public fun ()V - public final fun getLambda$-1190087909$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1428997360$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1092807187$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1701433647$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-538646917$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$1025684378$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$1350748755$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$1407974764$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$2116007242$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$462284$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$515697460$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$729325666$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$740142940$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$810855225$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/mentions/MentionListKt { @@ -1758,13 +1760,13 @@ public final class io/getstream/chat/android/compose/ui/messages/attachments/Att public final class io/getstream/chat/android/compose/ui/messages/attachments/ComposableSingletons$AttachmentCameraPickerKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/attachments/ComposableSingletons$AttachmentCameraPickerKt; public fun ()V - public final fun getLambda$407305438$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$600236639$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/messages/attachments/ComposableSingletons$AttachmentCommandPickerKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/attachments/ComposableSingletons$AttachmentCommandPickerKt; public fun ()V - public final fun getLambda$-1398054754$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-674764321$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/messages/attachments/ComposableSingletons$AttachmentFilePickerKt { @@ -1783,7 +1785,7 @@ public final class io/getstream/chat/android/compose/ui/messages/attachments/Com public final class io/getstream/chat/android/compose/ui/messages/attachments/ComposableSingletons$AttachmentPollPickerKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/attachments/ComposableSingletons$AttachmentPollPickerKt; public fun ()V - public final fun getLambda$-65438946$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$883050783$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/messages/attachments/ComposableSingletons$AttachmentSystemPickerKt { @@ -1799,14 +1801,14 @@ public final class io/getstream/chat/android/compose/ui/messages/attachments/Com public final class io/getstream/chat/android/compose/ui/messages/attachments/ComposableSingletons$AttachmentTypePickerKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/attachments/ComposableSingletons$AttachmentTypePickerKt; public fun ()V + public final fun getLambda$-1024452820$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-1321973654$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-1888332900$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-1970833877$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-2137611301$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-577222196$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-631453875$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1317222093$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$461983484$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$702643774$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-404808115$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$156348955$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1651133503$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$2040512526$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/messages/attachments/media/CaptureMediaLauncherKt { @@ -1816,51 +1818,51 @@ public final class io/getstream/chat/android/compose/ui/messages/attachments/med public final class io/getstream/chat/android/compose/ui/messages/attachments/permission/ComposableSingletons$RequiredPermissionKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/attachments/permission/ComposableSingletons$RequiredPermissionKt; public fun ()V - public final fun getLambda$-1297873074$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-727411697$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$694014524$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1589715858$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-168289637$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-248003859$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/messages/attachments/poll/ComposableSingletons$CreatePollScreenKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/attachments/poll/ComposableSingletons$CreatePollScreenKt; public fun ()V - public final fun getLambda$2009807121$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-407891280$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/messages/attachments/poll/ComposableSingletons$PollCreationDiscardDialogKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/attachments/poll/ComposableSingletons$PollCreationDiscardDialogKt; public fun ()V + public final fun getLambda$-1327019628$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-2072874969$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-934020683$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$311491696$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; } public final class io/getstream/chat/android/compose/ui/messages/attachments/poll/ComposableSingletons$PollCreationHeaderKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/attachments/poll/ComposableSingletons$PollCreationHeaderKt; public fun ()V - public final fun getLambda$-2022490771$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1643672280$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$1775632701$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1966841996$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$941076441$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/messages/attachments/poll/ComposableSingletons$PollOptionListKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/attachments/poll/ComposableSingletons$PollOptionListKt; public fun ()V - public final fun getLambda$-1703872507$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-328780308$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1767087466$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1064491627$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$2031980589$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$656888390$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/messages/attachments/poll/ComposableSingletons$PollQuestionInputKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/attachments/poll/ComposableSingletons$PollQuestionInputKt; public fun ()V - public final fun getLambda$1351789609$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$690200136$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/messages/attachments/poll/ComposableSingletons$PollSwitchListKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/attachments/poll/ComposableSingletons$PollSwitchListKt; public fun ()V - public final fun getLambda$-78162243$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1001379804$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/messages/attachments/poll/CreatePollScreenKt { @@ -1919,18 +1921,18 @@ public final class io/getstream/chat/android/compose/ui/messages/attachments/pol public final class io/getstream/chat/android/compose/ui/messages/composer/ComposableSingletons$MessageComposerKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/composer/ComposableSingletons$MessageComposerKt; public fun ()V - public final fun getLambda$-1053058358$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-1267828661$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-1344661276$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1442471521$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-990863537$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1737288663$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1764628314$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-2036571483$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-42373808$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-525935532$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-526098902$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$1434062186$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1659727545$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1828404389$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1891762869$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$336205259$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1990191614$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$419456890$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$65882020$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$955864570$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/messages/composer/MessageComposerKt { @@ -1999,14 +2001,14 @@ public final class io/getstream/chat/android/compose/ui/messages/composer/intern public final class io/getstream/chat/android/compose/ui/messages/composer/internal/ComposableSingletons$MessageComposerEditIndicatorKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/composer/internal/ComposableSingletons$MessageComposerEditIndicatorKt; public fun ()V - public final fun getLambda$-1330590749$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-2034453724$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/messages/composer/internal/ComposableSingletons$MessageComposerInputCenterBottomContentKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/composer/internal/ComposableSingletons$MessageComposerInputCenterBottomContentKt; public fun ()V - public final fun getLambda$-532574374$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1236707361$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$517294841$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$843708416$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/messages/composer/internal/ComposableSingletons$MessageComposerInputTrailingContentKt { @@ -2025,14 +2027,14 @@ public final class io/getstream/chat/android/compose/ui/messages/composer/intern public final class io/getstream/chat/android/compose/ui/messages/composer/internal/attachments/ComposableSingletons$MessageComposerAttachmentFileItemKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/composer/internal/attachments/ComposableSingletons$MessageComposerAttachmentFileItemKt; public fun ()V - public final fun getLambda$-1219803979$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$989320502$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/messages/composer/internal/attachments/ComposableSingletons$MessageComposerAttachmentMediaItemKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/composer/internal/attachments/ComposableSingletons$MessageComposerAttachmentMediaItemKt; public fun ()V - public final fun getLambda$-1831582376$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1146653240$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1893532039$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-576800359$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$116152749$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$1523175629$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } @@ -2040,7 +2042,7 @@ public final class io/getstream/chat/android/compose/ui/messages/composer/intern public final class io/getstream/chat/android/compose/ui/messages/composer/internal/attachments/ComposableSingletons$MessageComposerAttachmentsKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/composer/internal/attachments/ComposableSingletons$MessageComposerAttachmentsKt; public fun ()V - public final fun getLambda$113003969$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$122774530$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/messages/composer/internal/suggestions/ComposableSingletons$CommandSuggestionListKt { @@ -2059,11 +2061,11 @@ public final class io/getstream/chat/android/compose/ui/messages/composer/intern public final class io/getstream/chat/android/compose/ui/messages/header/ComposableSingletons$MessageListHeaderKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/header/ComposableSingletons$MessageListHeaderKt; public fun ()V - public final fun getLambda$-1132948089$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1365911516$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1736015426$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-315018467$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$142115861$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-145874692$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1814005697$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-963804314$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1090605590$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$994849381$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/messages/header/MessageListHeaderKt { @@ -2073,7 +2075,7 @@ public final class io/getstream/chat/android/compose/ui/messages/header/MessageL public final class io/getstream/chat/android/compose/ui/messages/list/ComposableSingletons$MessageDividerKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/messages/list/ComposableSingletons$MessageDividerKt; public fun ()V - public final fun getLambda$1611504890$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1396675737$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/messages/list/ComposableSingletons$MessageItemKt { @@ -2152,18 +2154,18 @@ public final class io/getstream/chat/android/compose/ui/pinned/ComposableSinglet public final class io/getstream/chat/android/compose/ui/pinned/ComposableSingletons$PinnedMessageListKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/pinned/ComposableSingletons$PinnedMessageListKt; public fun ()V - public final fun getLambda$-1014587980$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1142294529$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-1332077747$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$-1435677118$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1477440103$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1515049366$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1570168220$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-412897563$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-468425611$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1803884002$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-299281836$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-985680375$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1467213008$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1381287782$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1636356783$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$2063209603$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$208459832$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$279135979$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$499338964$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$671311955$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/pinned/PinnedMessageItemKt { @@ -3587,7 +3589,7 @@ public final class io/getstream/chat/android/compose/ui/theme/ChatTheme { } public final class io/getstream/chat/android/compose/ui/theme/ChatThemeKt { - public static final fun ChatTheme (ZLio/getstream/chat/android/compose/ui/theme/ChatUiConfig;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Colors;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Typography;Lio/getstream/chat/android/compose/ui/theme/StreamRippleConfiguration;Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Ljava/util/List;Lio/getstream/chat/android/compose/ui/util/ReactionResolver;Lio/getstream/chat/android/compose/ui/theme/ReactionOptionsTheme;Lio/getstream/chat/android/compose/ui/util/MessagePreviewIconFactory;ZLio/getstream/chat/android/ui/common/helper/DateFormatter;Lio/getstream/chat/android/ui/common/helper/TimeProvider;Lio/getstream/chat/android/ui/common/helper/DurationFormatter;Lio/getstream/chat/android/ui/common/utils/ChannelNameFormatter;Lio/getstream/chat/android/compose/ui/util/MessagePreviewFormatter;Lio/getstream/chat/android/compose/ui/util/SearchResultNameFormatter;Lio/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory;Lio/getstream/chat/android/ui/common/helper/ImageHeadersProvider;Lio/getstream/chat/android/ui/common/helper/AsyncImageHeadersProvider;Lio/getstream/chat/android/ui/common/helper/DownloadAttachmentUriGenerator;Lio/getstream/chat/android/ui/common/helper/DownloadRequestInterceptor;Lio/getstream/chat/android/ui/common/helper/ImageAssetTransformer;Lio/getstream/chat/android/compose/ui/util/MessageAlignmentProvider;Lio/getstream/chat/android/compose/ui/theme/MessageOptionsTheme;Lio/getstream/chat/android/compose/ui/theme/ChannelOptionsTheme;Lio/getstream/chat/android/ui/common/images/resizing/StreamCdnImageResizing;Lio/getstream/chat/android/compose/ui/theme/MessageComposerTheme;Lio/getstream/chat/android/compose/ui/util/MessageTextFormatter;Lio/getstream/sdk/chat/audio/recording/StreamMediaRecorder;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;IIIII)V + public static final fun ChatTheme (ZLio/getstream/chat/android/compose/ui/theme/ChatUiConfig;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Colors;Lio/getstream/chat/android/compose/ui/theme/StreamDesign$Typography;Lio/getstream/chat/android/compose/ui/theme/StreamRippleConfiguration;Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;ZLjava/util/List;Lio/getstream/chat/android/compose/ui/util/ReactionResolver;Lio/getstream/chat/android/compose/ui/theme/ReactionOptionsTheme;Lio/getstream/chat/android/compose/ui/util/MessagePreviewIconFactory;ZLio/getstream/chat/android/ui/common/helper/DateFormatter;Lio/getstream/chat/android/ui/common/helper/TimeProvider;Lio/getstream/chat/android/ui/common/helper/DurationFormatter;Lio/getstream/chat/android/ui/common/utils/ChannelNameFormatter;Lio/getstream/chat/android/compose/ui/util/MessagePreviewFormatter;Lio/getstream/chat/android/compose/ui/util/SearchResultNameFormatter;Lio/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory;Lio/getstream/chat/android/ui/common/helper/ImageHeadersProvider;Lio/getstream/chat/android/ui/common/helper/AsyncImageHeadersProvider;Lio/getstream/chat/android/ui/common/helper/DownloadAttachmentUriGenerator;Lio/getstream/chat/android/ui/common/helper/DownloadRequestInterceptor;Lio/getstream/chat/android/ui/common/helper/ImageAssetTransformer;Lio/getstream/chat/android/compose/ui/util/MessageAlignmentProvider;Lio/getstream/chat/android/compose/ui/theme/MessageOptionsTheme;Lio/getstream/chat/android/compose/ui/theme/ChannelOptionsTheme;Lio/getstream/chat/android/ui/common/images/resizing/StreamCdnImageResizing;Lio/getstream/chat/android/compose/ui/theme/MessageComposerTheme;Lio/getstream/chat/android/compose/ui/util/MessageTextFormatter;Lio/getstream/sdk/chat/audio/recording/StreamMediaRecorder;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;IIIIII)V public static final fun getLocalChatUiConfig ()Landroidx/compose/runtime/ProvidableCompositionLocal; public static final fun getLocalComponentFactory ()Landroidx/compose/runtime/ProvidableCompositionLocal; } @@ -6237,13 +6239,13 @@ public final class io/getstream/chat/android/compose/ui/threads/ComposableSingle public final class io/getstream/chat/android/compose/ui/threads/ComposableSingletons$ThreadListKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/threads/ComposableSingletons$ThreadListKt; public fun ()V - public final fun getLambda$-1255119598$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1275040553$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-1460834442$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-298553599$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1283968891$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-116820350$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1426010505$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1527555885$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1547476840$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-2080988921$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$1318792828$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$1876441331$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$2032245126$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$285832544$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$432214945$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function4; } @@ -6251,8 +6253,8 @@ public final class io/getstream/chat/android/compose/ui/threads/ComposableSingle public final class io/getstream/chat/android/compose/ui/threads/ComposableSingletons$ThreadListLoadingItemKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/threads/ComposableSingletons$ThreadListLoadingItemKt; public fun ()V - public final fun getLambda$-1236216518$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-476532961$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$102546905$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$862230462$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/threads/ThreadItemKt { @@ -6307,7 +6309,7 @@ public final class io/getstream/chat/android/compose/ui/util/ChannelUtilsKt { public final class io/getstream/chat/android/compose/ui/util/ComposableSingletons$MessagePreviewIconFactoryKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/util/ComposableSingletons$MessagePreviewIconFactoryKt; public fun ()V - public final fun getLambda$-31551883$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$150181366$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/util/ComposableSingletons$SnackbarPopupKt { @@ -6717,7 +6719,8 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/MessageC public final fun clearActiveCommand ()V public final fun clearAttachments ()V public final fun clearData ()V - public final fun completeRecording ()V + public final fun completeRecording (Lkotlin/jvm/functions/Function1;)V + public static synthetic fun completeRecording$default (Lio/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V public final fun createPoll (Lio/getstream/chat/android/models/CreatePollParams;)V public final fun dismissMessageActions ()V public final fun getInputFocusEvents ()Lkotlinx/coroutines/flow/SharedFlow; diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/LinkAttachmentContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/LinkAttachmentContent.kt index 7c3c95c6f03..1c2a742f343 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/LinkAttachmentContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/LinkAttachmentContent.kt @@ -69,7 +69,7 @@ import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.Message import io.getstream.chat.android.ui.common.utils.extensions.addSchemeToUrlIfNeeded import io.getstream.chat.android.ui.common.utils.extensions.hasLink -import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl +import io.getstream.chat.android.ui.common.utils.extensions.linkPreviewImageUrl /** * Builds a link attachment message, which shows the link image preview, the title of the link @@ -151,7 +151,7 @@ private fun FullSizeLinkAttachmentContent( textColor: Color, ) { Column(modifier = modifier) { - attachment.imagePreviewUrl?.let { + attachment.linkPreviewImageUrl?.let { LinkAttachmentImagePreview(it, Modifier.height(144.dp)) } @@ -207,7 +207,7 @@ private fun CompactLinkAttachmentContent( ), horizontalArrangement = Arrangement.spacedBy(StreamTokens.spacingXs), ) { - attachment.imagePreviewUrl?.let { + attachment.linkPreviewImageUrl?.let { LinkAttachmentImagePreview( imageUrl = it, modifier = Modifier diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/MediaAttachmentContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/MediaAttachmentContent.kt index f716f75243f..f6df1f14ffe 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/MediaAttachmentContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/MediaAttachmentContent.kt @@ -101,7 +101,6 @@ import io.getstream.chat.android.ui.common.helper.DownloadAttachmentUriGenerator import io.getstream.chat.android.ui.common.helper.DownloadRequestInterceptor import io.getstream.chat.android.ui.common.images.resizing.StreamCdnImageResizing import io.getstream.chat.android.ui.common.utils.extensions.hasLink -import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl /** * Displays a preview of single or multiple video or attachments. @@ -478,7 +477,7 @@ internal fun MediaAttachmentContentItem( MediaAttachmentClickData( mediaGalleryPreviewLauncher = mixedMediaPreviewLauncher, message = message, - selectedAttachmentUrl = attachment.imagePreviewUrl, + selectedAttachmentUrl = attachment.thumbUrl ?: attachment.imageUrl, videoThumbnailsEnabled = videoThumbnailsEnabled, downloadAttachmentUriGenerator = downloadAttachmentUriGenerator, downloadRequestInterceptor = downloadRequestInterceptor, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt index 71b106c2570..ffba3f68694 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt @@ -91,7 +91,6 @@ import io.getstream.chat.android.models.Constants import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User import io.getstream.chat.android.ui.common.utils.extensions.hasLink -import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl import kotlinx.coroutines.launch import java.util.Date @@ -365,7 +364,10 @@ public fun MediaGalleryPreviewScreen( 0 } else { filteredAttachments - .indexOfFirst { it.imagePreviewUrl == selectedAttachmentUrl } + .indexOfFirst { + val imagePreviewUrl = it.thumbUrl ?: it.imageUrl + imagePreviewUrl == selectedAttachmentUrl + } .coerceAtLeast(0) } @@ -628,7 +630,6 @@ internal fun MediaGalleryPager( * @param onLeadingContentClick Callback to be invoked when the leading content is clicked. * @param onTrailingContentClick Callback to be invoked when the trailing content is clicked. * @param modifier The [Modifier] to be applied to the footer. - * @param elevation The elevation of the footer. * @param backgroundColor The background color of the footer. * @param contentColor The content color of the footer. * @param config The configuration for the media gallery. diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/AttachmentPreviewHandler.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/AttachmentPreviewHandler.kt index eaa02dd8064..05195c7d52a 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/AttachmentPreviewHandler.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/AttachmentPreviewHandler.kt @@ -44,12 +44,18 @@ public interface AttachmentPreviewHandler { * Builds the default list of file preview providers. * * @param context The context to start the preview Activity with. + * @param useDocumentGView Whether to use Google Docs Viewer for document attachments. When `true` + * (default), documents are rendered via Google Docs Viewer. When `false`, text-based files are + * rendered in-app and other file types are downloaded and opened with an external application. * @return The list handlers that can be used to show a preview for an attachment. */ - public fun defaultAttachmentHandlers(context: Context): List { + public fun defaultAttachmentHandlers( + context: Context, + useDocumentGView: Boolean = true, + ): List { return listOf( MediaAttachmentPreviewHandler(context), - DocumentAttachmentPreviewHandler(context), + DocumentAttachmentPreviewHandler(context, useDocumentGView), UrlAttachmentPreviewHandler(context), ) } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/DocumentAttachmentPreviewHandler.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/DocumentAttachmentPreviewHandler.kt index f0f82f2b186..4a5a9e0cfb9 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/DocumentAttachmentPreviewHandler.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/handler/DocumentAttachmentPreviewHandler.kt @@ -19,12 +19,22 @@ package io.getstream.chat.android.compose.ui.attachments.preview.handler import android.content.Context import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.ui.common.feature.documents.AttachmentDocumentActivity +import io.getstream.chat.android.ui.common.feature.documents.DocumentAttachmentHandler import io.getstream.chat.android.ui.common.model.MimeType /** - * Shows a preview for the document in the attachment using Google Docs. + * Shows a preview for document attachments. + * + * Behavior depends on [useDocumentGView]: + * - `true` (default): documents are rendered via Google Docs Viewer. + * - `false`: text-based files (TXT, HTML) are rendered in-app, others open with an external app. + * + * Set via `ChatTheme(useDocumentGView = false)`. */ -public class DocumentAttachmentPreviewHandler(private val context: Context) : AttachmentPreviewHandler { +public class DocumentAttachmentPreviewHandler( + private val context: Context, + private val useDocumentGView: Boolean = true, +) : AttachmentPreviewHandler { override fun canHandle(attachment: Attachment): Boolean { val assetUrl = attachment.assetUrl @@ -45,6 +55,11 @@ public class DocumentAttachmentPreviewHandler(private val context: Context) : At } override fun handleAttachmentPreview(attachment: Attachment) { - context.startActivity(AttachmentDocumentActivity.getIntent(context, attachment.assetUrl)) + @Suppress("DEPRECATION") + if (useDocumentGView) { + context.startActivity(AttachmentDocumentActivity.getIntent(context, attachment.assetUrl)) + } else { + DocumentAttachmentHandler.openAttachment(context, attachment) + } } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt index 2b4db93997e..bf2f5a67eaa 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt @@ -64,7 +64,6 @@ import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.util.StreamAsyncImage import io.getstream.chat.android.compose.ui.util.clickable import io.getstream.chat.android.models.Attachment -import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl import kotlinx.coroutines.coroutineScope import kotlin.math.abs @@ -103,7 +102,7 @@ internal fun MediaGalleryImagePage( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - val data = attachment.imagePreviewUrl + val data = attachment.imageUrl val context = LocalContext.current // Ensure we have a new imageRequest in case the data changes diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt index f5a96a060c6..4c5cc84bb39 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt @@ -39,7 +39,10 @@ import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.ui.PlayerView +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.cdn.internal.StreamMediaDataSource import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.ui.components.LoadingIndicator import io.getstream.chat.android.compose.ui.components.common.PlayButton @@ -187,7 +190,11 @@ internal fun createPlayer( onPlaybackError: (error: Throwable) -> Unit, ): Player { // Setup player - val player = ExoPlayer.Builder(context).build() + val cdn = ChatClient.instance().cdn + val dataSourceFactory = StreamMediaDataSource.factory(context, cdn) + val player = ExoPlayer.Builder(context) + .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) + .build() player.addListener(object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { if (playbackState == Player.STATE_BUFFERING) { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsScreen.kt index ac11c5908c1..b820ab14364 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import io.getstream.chat.android.client.extensions.duration +import io.getstream.chat.android.client.utils.attachment.isImage import io.getstream.chat.android.client.utils.attachment.isVideo import io.getstream.chat.android.compose.ui.components.avatar.AvatarSize import io.getstream.chat.android.compose.ui.components.common.VideoBadge @@ -49,7 +50,6 @@ import io.getstream.chat.android.compose.viewmodel.channel.ChannelAttachmentsVie import io.getstream.chat.android.previewdata.PreviewMessageData import io.getstream.chat.android.ui.common.feature.channel.attachments.ChannelAttachmentsViewAction import io.getstream.chat.android.ui.common.state.channel.attachments.ChannelAttachmentsViewState -import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl import io.getstream.result.Error /** @@ -142,7 +142,8 @@ internal fun ChannelMediaAttachmentsItem( item: ChannelAttachmentsViewState.Content.Item, onClick: () -> Unit, ) { - val data = item.attachment.upload ?: item.attachment.imagePreviewUrl + val data = item.attachment.upload + ?: if (item.attachment.isImage()) item.attachment.imageUrl else item.attachment.thumbUrl Box( modifier = Modifier .clickable(onClick = onClick), diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/ComposerLinkPreview.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/ComposerLinkPreview.kt index a9d4c3875e6..63dc59ca594 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/ComposerLinkPreview.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/composer/ComposerLinkPreview.kt @@ -63,7 +63,7 @@ import io.getstream.chat.android.compose.ui.util.StreamAsyncImage import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.LinkPreview import io.getstream.chat.android.ui.common.utils.extensions.addSchemeToUrlIfNeeded -import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl +import io.getstream.chat.android.ui.common.utils.extensions.linkPreviewImageUrl import io.getstream.log.StreamLog private const val TAG = "ComposerLinkPreview" @@ -149,10 +149,10 @@ public fun ComposerLinkPreview( @Composable private fun ComposerLinkImagePreview(attachment: Attachment, colors: StreamDesign.Colors) { - val imagePreviewUrl = attachment.imagePreviewUrl ?: return + val linkPreviewUrl = attachment.linkPreviewImageUrl ?: return val shape = RoundedCornerShape(StreamTokens.radiusMd) StreamAsyncImage( - data = imagePreviewUrl, + data = linkPreviewUrl, modifier = Modifier .size(width = 40.dp, height = 40.dp) .clip(shape) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilder.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilder.kt index b6b8ce67484..bb5a034a92b 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilder.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilder.kt @@ -34,8 +34,9 @@ import io.getstream.chat.android.models.User import io.getstream.chat.android.ui.common.helper.DurationFormatter import io.getstream.chat.android.ui.common.images.resizing.StreamCdnImageResizing import io.getstream.chat.android.ui.common.images.resizing.applyStreamCdnImageResizingIfEnabled +import io.getstream.chat.android.ui.common.utils.extensions.giphyFallbackPreviewUrl import io.getstream.chat.android.ui.common.utils.extensions.hasLink -import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl +import io.getstream.chat.android.ui.common.utils.extensions.linkPreviewImageUrl internal class QuotedMessageBodyBuilder( private val resources: Resources, @@ -100,7 +101,7 @@ internal class QuotedMessageBodyBuilder( QuotedMessageBody( text = messageText.ifBlank { summary.linkAttachment.run { titleLink ?: ogUrl } }.orEmpty(), iconId = R.drawable.stream_design_ic_link, - imagePreviewData = summary.linkAttachment.imagePreviewUrl, + imagePreviewData = summary.linkAttachment.linkPreviewImageUrl, ) } @@ -111,7 +112,7 @@ internal class QuotedMessageBodyBuilder( resources.getString(R.string.stream_compose_quoted_message_giphy_tag) }, iconId = R.drawable.stream_design_ic_file, - imagePreviewData = summary.giphyAttachment.imagePreviewUrl, + imagePreviewData = summary.giphyAttachment.giphyFallbackPreviewUrl, ) } @@ -200,14 +201,14 @@ internal class QuotedMessageBodyBuilder( type == AttachmentType.IMAGE -> { imageCount++ fileCount++ - mediaPreviewData = attachment.upload ?: attachment.imagePreviewUrl + mediaPreviewData = attachment.upload ?: attachment.imageUrl ?.applyStreamCdnImageResizingIfEnabled(streamCdnImageResizing) } type == AttachmentType.VIDEO -> { videoCount++ fileCount++ - mediaPreviewData = attachment.upload ?: attachment.imagePreviewUrl + mediaPreviewData = attachment.upload ?: attachment.thumbUrl } type == AttachmentType.AUDIO_RECORDING -> { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPicker.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPicker.kt index 043de11552b..7296c5274ca 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPicker.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/attachments/AttachmentPicker.kt @@ -77,6 +77,17 @@ public fun AttachmentPicker( BackHandler(onBack = actions.onDismiss) val context = LocalContext.current + val hasUnresolvedAttachments = attachmentsPickerViewModel.hasUnresolvedAttachments + LaunchedEffect(hasUnresolvedAttachments) { + if (hasUnresolvedAttachments) { + Toast.makeText( + context, + context.getString(R.string.stream_ui_attachment_picker_error_unresolvable_attachments), + Toast.LENGTH_LONG, + ).show() + attachmentsPickerViewModel.clearUnresolvedAttachments() + } + } LaunchedEffect(Unit) { attachmentsPickerViewModel.submittedAttachments.collect { submitted -> if (submitted.hasUnsupportedFiles) { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt index 98e5b06580e..562a433dea3 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt @@ -60,6 +60,7 @@ import io.getstream.chat.android.ui.common.helper.DurationFormatter import io.getstream.chat.android.ui.common.helper.ImageAssetTransformer import io.getstream.chat.android.ui.common.helper.ImageHeadersProvider import io.getstream.chat.android.ui.common.helper.TimeProvider +import io.getstream.chat.android.ui.common.images.internal.CDNImageInterceptor import io.getstream.chat.android.ui.common.images.resizing.StreamCdnImageResizing import io.getstream.chat.android.ui.common.utils.ChannelNameFormatter import io.getstream.sdk.chat.audio.recording.DefaultStreamMediaRecorder @@ -174,6 +175,11 @@ private val LocalStreamMediaRecorder = compositionLocalOf { * @param typography The set of typography styles we provide, wrapped in [StreamDesign.Typography]. * @param rippleConfiguration Defines the appearance for ripples. * @param componentFactory Provide to customize the stateless components that are used throughout the UI + * @param attachmentFactories Attachment factories that we provide. + * @param useDocumentGView Whether to use Google Docs Viewer (gview) for document attachments. When `true` (default), + * documents are rendered via the legacy [AttachmentDocumentActivity] which loads them through Google Docs Viewer. + * When `false`, text-based files (TXT, HTML) are rendered in-app and other file types are downloaded and opened with an + * external application. * @param attachmentPreviewHandlers Attachment preview handlers we provide. * @param reactionResolver Provides available reactions and resolves reaction types to emoji codes. * @param reactionOptionsTheme [ReactionOptionsTheme] Theme for the reaction option list in the selected message menu. @@ -225,8 +231,9 @@ public fun ChatTheme( lightTheme = !isInDarkMode, ), componentFactory: ChatComponentFactory = DefaultChatComponentFactory(), + useDocumentGView: Boolean = true, attachmentPreviewHandlers: List = - AttachmentPreviewHandler.defaultAttachmentHandlers(LocalContext.current), + AttachmentPreviewHandler.defaultAttachmentHandlers(LocalContext.current, useDocumentGView), reactionResolver: ReactionResolver = ReactionResolver.defaultResolver(), reactionOptionsTheme: ReactionOptionsTheme = ReactionOptionsTheme.defaultTheme(), messagePreviewIconFactory: MessagePreviewIconFactory = MessagePreviewIconFactory.defaultFactory(), @@ -270,14 +277,16 @@ public fun ChatTheme( } val context = LocalContext.current - val imageLoader = remember(imageLoaderFactory, asyncImageHeadersProvider) { - if (asyncImageHeadersProvider == null) { + val cdn = remember { ChatClient.instance().cdn } + val imageLoader = remember(imageLoaderFactory, asyncImageHeadersProvider, cdn) { + val interceptors = buildList { + asyncImageHeadersProvider?.let { add(ImageHeadersInterceptor(it)) } + cdn?.let { add(CDNImageInterceptor(it)) } + } + if (interceptors.isEmpty()) { imageLoaderFactory.imageLoader(context.applicationContext) } else { - imageLoaderFactory.imageLoader( - context.applicationContext, - listOf(ImageHeadersInterceptor(asyncImageHeadersProvider)), - ) + imageLoaderFactory.imageLoader(context.applicationContext, interceptors) } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageHeadersInterceptor.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageHeadersInterceptor.kt index 9acaf6db0cc..13d9a07dbb6 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageHeadersInterceptor.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageHeadersInterceptor.kt @@ -37,8 +37,18 @@ internal class ImageHeadersInterceptor(private val headersProvider: AsyncImageHe val headers = withContext(Dispatchers.IO) { headersProvider.getImageRequestHeaders(url) } + // Merge: existing headers (from CDN interceptor / sync ImageHeadersProvider) as base, + // async provider headers override for same keys. + val existingHeaders = chain.request.httpHeaders + val mergedHeaders = buildMap { + existingHeaders.asMap().forEach { (name, values) -> + values.lastOrNull()?.let { put(name, it) } + } + putAll(headers) + }.toNetworkHeaders() + val newRequest = chain.request.newBuilder() - .httpHeaders(headers.toNetworkHeaders()) + .httpHeaders(mergedHeaders) .build() return chain.withRequest(newRequest).proceed() } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/extensions/internal/AttachmentExtensions.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/extensions/internal/AttachmentExtensions.kt index e43db9f6225..9bd7ae444f5 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/extensions/internal/AttachmentExtensions.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/extensions/internal/AttachmentExtensions.kt @@ -23,7 +23,6 @@ import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelper.Companion.EXTRA_SOURCE_URI import io.getstream.chat.android.ui.common.images.resizing.applyStreamCdnImageResizingIfEnabled -import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl /** * The content URI stored when the attachment was created from a device picker, @@ -44,24 +43,35 @@ internal val Attachment.stableKey: String /** * Best available data source for rendering an unsent attachment preview. * - * Prefers [Attachment.upload] (local file), then [imagePreviewUrl] (CDN URL), - * then [sourceUri] (content URI from the picker). + * Prefers [Attachment.upload] (local file), then [Attachment.imageUrl] (CDN URL for images), then [Attachment.thumbUrl] + * (CDN URL for video thumbnails), then [sourceUri] (content URI from the picker). */ internal val Attachment.localPreviewData: Any? - get() = upload ?: imagePreviewUrl ?: sourceUri + get() = upload ?: imageUrl ?: thumbUrl ?: sourceUri /** * Image preview data for a sent or received attachment. * * Returns the CDN image URL (with Stream resizing applied) or the local [Attachment.upload] file * for images and videos (when video thumbnails are enabled). Returns `null` for other types. + * This property checks if the attachment is an image or a video with enabled thumbnails. + * If so, it returns the appropriate URL (applied with Stream CDN image resizing if enabled) + * or the upload [java.io.File] object. + * Otherwise, it returns null. + * + * For image attachments, [Attachment.imageUrl] is used. + * For video attachments when thumbnails are enabled, [Attachment.thumbUrl] is used. */ @get:Composable internal val Attachment.imagePreviewData: Any? - get() = if (isImage() || (isVideo() && ChatTheme.config.messageList.videoThumbnailsEnabled)) { - imagePreviewUrl - ?.applyStreamCdnImageResizingIfEnabled(ChatTheme.streamCdnImageResizing) - ?: upload - } else { - null + get() = when { + isImage() -> + imageUrl + ?.applyStreamCdnImageResizingIfEnabled(ChatTheme.streamCdnImageResizing) + ?: upload + isVideo() && ChatTheme.config.messageList.videoThumbnailsEnabled -> + thumbUrl + ?.applyStreamCdnImageResizingIfEnabled(ChatTheme.streamCdnImageResizing) + ?: upload + else -> null } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelMediaAttachmentsPreviewViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelMediaAttachmentsPreviewViewModel.kt index 0e99ca82159..dddf51db36c 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelMediaAttachmentsPreviewViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channel/ChannelMediaAttachmentsPreviewViewModel.kt @@ -25,7 +25,6 @@ import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.ui.common.feature.channel.attachments.ChannelAttachmentsViewController import io.getstream.chat.android.ui.common.utils.AttachmentConstants import io.getstream.chat.android.ui.common.utils.extensions.getDisplayableName -import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl import io.getstream.log.taggedLogger import io.getstream.result.Error import io.getstream.result.onErrorSuspend @@ -81,7 +80,10 @@ internal class ChannelMediaAttachmentsPreviewViewModel( } private fun startSharing(attachment: Attachment) { - logger.d { "[startSharing] mimeType: ${attachment.mimeType}, attachment: ${attachment.imagePreviewUrl}" } + logger.d { + "[startSharing] mimeType: ${attachment.mimeType}, imageUrl: ${attachment.imageUrl}, " + + "thumbUrl: ${attachment.thumbUrl}" + } if (attachment.fileSize >= AttachmentConstants.MAX_SIZE_BEFORE_DOWNLOAD_WARNING_IN_BYTES) { logger.d { "[startSharing] Attachment larger than " + @@ -111,7 +113,10 @@ internal class ChannelMediaAttachmentsPreviewViewModel( } private fun share(attachment: Attachment) { - logger.d { "[share] mimeType: ${attachment.mimeType}, attachment: ${attachment.imagePreviewUrl}" } + logger.d { + "[share] mimeType: ${attachment.mimeType}, imageUrl: ${attachment.imageUrl}, " + + "thumbUrl: ${attachment.thumbUrl}" + } _state.update { currentState -> currentState.copy( isPreparingToShare = true, 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 06e2cd24ca3..362a515274e 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 @@ -18,6 +18,8 @@ package io.getstream.chat.android.compose.viewmodel.messages import android.net.Uri import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -164,6 +166,22 @@ public class AttachmentsPickerViewModel @JvmOverloads constructor( if (!visible) resetPickerState() } + /** + * Set to `true` when one or more selected attachments could not be resolved (e.g. the + * content URI points to a cloud file that is not locally available). The UI layer should + * observe this flag and show an appropriate message, then call [clearUnresolvedAttachments] + * to reset it. + */ + internal var hasUnresolvedAttachments: Boolean by mutableStateOf(false) + private set + + /** + * Resets the [hasUnresolvedAttachments] flag after the UI has consumed the event. + */ + internal fun clearUnresolvedAttachments() { + hasUnresolvedAttachments = false + } + /** * Toggles the attachment picker visibility. */ @@ -249,17 +267,28 @@ public class AttachmentsPickerViewModel @JvmOverloads constructor( * Resolves [uris] from a system picker into [Attachment]s and emits the result * via [submittedAttachments]. * + * URIs whose content is inaccessible (e.g. cloud-backed files not downloaded to + * the device) are detected via a lightweight accessibility check and excluded + * from the result. When any URI is inaccessible, [hasUnresolvedAttachments] is + * set so the UI layer can display an appropriate message. + * * @param uris Content URIs returned by the system picker. */ public fun resolveAndSubmitUris(uris: List) { if (uris.isEmpty()) return viewModelScope.launch { - val metadata = withContext(DispatcherProvider.IO) { storageHelper.resolveMetadata(uris) } - val attachments = storageHelper.toAttachments(metadata) + val (accessible, inaccessible) = withContext(DispatcherProvider.IO) { + val metadata = storageHelper.resolveMetadata(uris) + storageHelper.partitionResolvable(metadata) + } + if (inaccessible.isNotEmpty()) { + hasUnresolvedAttachments = true + } + val attachments = storageHelper.toAttachments(accessible) _submittedAttachments.trySend( SubmittedAttachments( attachments = attachments, - hasUnsupportedFiles = metadata.size < uris.size, + hasUnsupportedFiles = (accessible.size + inaccessible.size) < uris.size, ), ) } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt index 30ea3cefe25..1bd0ef71231 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel.kt @@ -32,6 +32,7 @@ import io.getstream.chat.android.ui.common.state.messages.MessageMode import io.getstream.chat.android.ui.common.state.messages.Reply import io.getstream.chat.android.ui.common.state.messages.composer.MessageComposerState import io.getstream.chat.android.ui.common.utils.typing.TypingUpdatesBuffer +import io.getstream.result.Result import io.getstream.result.call.Call import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow @@ -258,7 +259,8 @@ public class MessageComposerViewModel( public fun toggleRecordingPlayback(): Unit = messageComposerController.toggleRecordingPlayback() - public fun completeRecording(): Unit = messageComposerController.completeRecording() + public fun completeRecording(onComplete: ((Result) -> Unit)? = null): Unit = + messageComposerController.completeRecording(onComplete) public fun pauseRecording(): Unit = messageComposerController.pauseRecording() diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilderTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilderTest.kt index b5ab66da163..cbfbbd74072 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilderTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/components/messages/QuotedMessageBodyBuilderTest.kt @@ -194,7 +194,7 @@ internal class QuotedMessageBodyBuilderTest { Attachment( type = AttachmentType.GIPHY, name = "Happy Dance", - imageUrl = "https://giphy.com/image.gif", + thumbUrl = "https://giphy.com/image.gif", ), ), ), @@ -214,7 +214,7 @@ internal class QuotedMessageBodyBuilderTest { Attachment( type = AttachmentType.GIPHY, name = "Happy Dance", - imageUrl = "https://giphy.com/image.gif", + thumbUrl = "https://giphy.com/image.gif", ), ), ), @@ -304,7 +304,7 @@ internal class QuotedMessageBodyBuilderTest { Attachment( type = AttachmentType.VIDEO, assetUrl = "https://example.com/video.mp4", - imageUrl = "https://example.com/thumb.jpg", + thumbUrl = "https://example.com/thumb.jpg", ), ), ), @@ -324,7 +324,7 @@ internal class QuotedMessageBodyBuilderTest { Attachment( type = AttachmentType.VIDEO, assetUrl = "https://example.com/video.mp4", - imageUrl = "https://example.com/thumb.jpg", + thumbUrl = "https://example.com/thumb.jpg", ), ), ), 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 6b2c1870a3e..12d8861f219 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 @@ -464,6 +464,7 @@ internal class AttachmentsPickerViewModelTest { val metadata = listOf(imageAttachment1, imageAttachment2) val expectedAttachments = uris.map(::attachmentWithSourceUri) whenever(storageHelper.resolveMetadata(uris)) doReturn metadata + whenever(storageHelper.partitionResolvable(metadata)) doReturn (metadata to emptyList()) whenever(storageHelper.toAttachments(metadata)) doReturn expectedAttachments val viewModel = createViewModel() @@ -486,6 +487,7 @@ internal class AttachmentsPickerViewModelTest { val filteredMetadata = listOf(imageAttachment1) val expectedAttachments = listOf(attachmentWithSourceUri(imageUri1)) whenever(storageHelper.resolveMetadata(uris)) doReturn filteredMetadata + whenever(storageHelper.partitionResolvable(filteredMetadata)) doReturn (filteredMetadata to emptyList()) whenever(storageHelper.toAttachments(filteredMetadata)) doReturn expectedAttachments val viewModel = createViewModel() @@ -516,6 +518,103 @@ internal class AttachmentsPickerViewModelTest { assertTrue(results.isEmpty()) } + @Test + fun `Given inaccessible URIs When resolving Should set hasUnresolvedAttachments`() = runTest { + val uris = listOf(imageUri1, imageUri2) + val metadata = listOf(imageAttachment1, imageAttachment2) + val accessibleMetadata = listOf(imageAttachment1) + val inaccessibleMetadata = listOf(imageAttachment2) + val accessibleAttachments = listOf(attachmentWithSourceUri(imageUri1)) + whenever(storageHelper.resolveMetadata(uris)) doReturn metadata + whenever(storageHelper.partitionResolvable(metadata)) doReturn (accessibleMetadata to inaccessibleMetadata) + whenever(storageHelper.toAttachments(accessibleMetadata)) doReturn accessibleAttachments + val viewModel = createViewModel() + + assertFalse(viewModel.hasUnresolvedAttachments) + + val results = mutableListOf() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.submittedAttachments.collect(results::add) + } + viewModel.resolveAndSubmitUris(uris) + advanceUntilIdle() + job.cancel() + + assertEquals(1, results.first().attachments.size) + assertTrue(viewModel.hasUnresolvedAttachments) + assertFalse(results.first().hasUnsupportedFiles) + } + + @Test + fun `Given hasUnresolvedAttachments is true When clearing Should reset to false`() = runTest { + val uris = listOf(imageUri1, imageUri2) + val metadata = listOf(imageAttachment1, imageAttachment2) + val accessibleMetadata = listOf(imageAttachment1) + val inaccessibleMetadata = listOf(imageAttachment2) + whenever(storageHelper.resolveMetadata(uris)) doReturn metadata + whenever(storageHelper.partitionResolvable(metadata)) doReturn (accessibleMetadata to inaccessibleMetadata) + whenever(storageHelper.toAttachments(accessibleMetadata)) doReturn listOf(attachmentWithSourceUri(imageUri1)) + val viewModel = createViewModel() + + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.submittedAttachments.collect {} + } + viewModel.resolveAndSubmitUris(uris) + advanceUntilIdle() + assertTrue(viewModel.hasUnresolvedAttachments) + + viewModel.clearUnresolvedAttachments() + assertFalse(viewModel.hasUnresolvedAttachments) + job.cancel() + } + + @Test + fun `Given all URIs accessible When resolving Should not set hasUnresolvedAttachments`() = runTest { + val uris = listOf(imageUri1, imageUri2) + val metadata = listOf(imageAttachment1, imageAttachment2) + val expectedAttachments = uris.map(::attachmentWithSourceUri) + whenever(storageHelper.resolveMetadata(uris)) doReturn metadata + whenever(storageHelper.partitionResolvable(metadata)) doReturn (metadata to emptyList()) + whenever(storageHelper.toAttachments(metadata)) doReturn expectedAttachments + val viewModel = createViewModel() + + val results = mutableListOf() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.submittedAttachments.collect(results::add) + } + viewModel.resolveAndSubmitUris(uris) + advanceUntilIdle() + job.cancel() + + assertEquals(2, results.first().attachments.size) + assertFalse(viewModel.hasUnresolvedAttachments) + } + + @Test + fun `Given unresolvable and unsupported URIs When resolving Should set both flags correctly`() = runTest { + val extraUri = Uri.parse("content://media/external/files/999") + val uris = listOf(imageUri1, imageUri2, extraUri) + val metadata = listOf(imageAttachment1, imageAttachment2) + val accessibleMetadata = listOf(imageAttachment1) + val inaccessibleMetadata = listOf(imageAttachment2) + val accessibleAttachments = listOf(attachmentWithSourceUri(imageUri1)) + whenever(storageHelper.resolveMetadata(uris)) doReturn metadata + whenever(storageHelper.partitionResolvable(metadata)) doReturn (accessibleMetadata to inaccessibleMetadata) + whenever(storageHelper.toAttachments(accessibleMetadata)) doReturn accessibleAttachments + val viewModel = createViewModel() + + val results = mutableListOf() + val job = launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.submittedAttachments.collect(results::add) + } + viewModel.resolveAndSubmitUris(uris) + advanceUntilIdle() + job.cancel() + + assertTrue(viewModel.hasUnresolvedAttachments) + assertTrue(results.first().hasUnsupportedFiles) + } + private fun createViewModel(): AttachmentsPickerViewModel = AttachmentsPickerViewModel(storageHelper, channelState) diff --git a/stream-chat-android-ui-common/src/main/AndroidManifest.xml b/stream-chat-android-ui-common/src/main/AndroidManifest.xml index ed4a5f15850..46b41f79fec 100644 --- a/stream-chat-android-ui-common/src/main/AndroidManifest.xml +++ b/stream-chat-android-ui-common/src/main/AndroidManifest.xml @@ -33,7 +33,6 @@ android:exported="false" android:theme="@style/Theme.AppCompat.DayNight.NoActionBar" /> - (android.R.id.content) + + lifecycleOwner.lifecycleScope.launch { + var snackbar: Snackbar? = null + val snackbarJob = rootView?.let { + launch { + delay(SNACKBAR_DELAY_MS) + snackbar = Snackbar.make( + it, + context.getString( + R.string.stream_ui_message_list_attachment_downloading, + MediaStringUtil.convertFileSizeByteCount(0L), + MediaStringUtil.convertFileSizeByteCount(attachment.fileSize.toLong()), + ), + Snackbar.LENGTH_INDEFINITE, + ).also { sb -> sb.show() } + } + } + + shareFileManager.writeAttachmentToShareableFile( + context = context, + attachment = attachment, + onProgress = { bytesDownloaded, totalBytes -> + snackbar?.let { sb -> + val downloaded = MediaStringUtil.convertFileSizeByteCount(bytesDownloaded) + val total = MediaStringUtil.convertFileSizeByteCount(totalBytes) + val text = context.getString( + R.string.stream_ui_message_list_attachment_downloading, + downloaded, + total, + ) + sb.view.post { sb.setText(text) } + } + }, + ) + .onSuccess { uri -> + snackbarJob?.cancel() + snackbar?.dismiss() + openFileUri(context, uri, attachment) + } + .onError { error -> + snackbarJob?.cancel() + snackbar?.dismiss() + logger.e { "[openWithExternalApp] Failed to download file: ${error.message}" } + val msg = context.getString( + R.string.stream_ui_message_list_attachment_download_failed, + attachment.name ?: "", + ) + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + } + } + + private fun openFileUri(context: Context, uri: android.net.Uri, attachment: Attachment) { + try { + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, attachment.mimeType) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, attachment.name)) + } catch (e: ActivityNotFoundException) { + logger.e(e) { "[openFileUri] No app available to open file." } + Toast.makeText(context, R.string.stream_ui_message_list_attachment_no_app, Toast.LENGTH_SHORT).show() + } + } + + private fun Context.findLifecycleOwner(): LifecycleOwner? { + var ctx: Context? = this + while (ctx != null) { + if (ctx is LifecycleOwner) return ctx + ctx = (ctx as? ContextWrapper)?.baseContext + } + return null + } + + private fun Context.findActivity(): Activity? { + var ctx: Context? = this + while (ctx != null) { + if (ctx is Activity) return ctx + ctx = (ctx as? ContextWrapper)?.baseContext + } + return null + } +} 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 17711df30ff..d91bc0c5c50 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 @@ -1015,12 +1015,23 @@ public class MessageComposerController( } /** - * Completes audio recording and moves [MessageComposerState.recording] state to [RecordingState.Complete]. - * Also, it wil update [MessageComposerState.attachments] list. + * Completes audio recording and updates the [MessageComposerState.attachments] list. + * + * @param onComplete Optional callback invoked with the result of the recording once the recording has been + * finalized. On success, the recorded [Attachment] is added to the attachment list before the callback + * is invoked, so callers can safely build and send a message using the received attachment. */ - public fun completeRecording() { + public fun completeRecording(onComplete: ((Result) -> Unit)? = null) { scope.launch { - audioRecordingController.completeRecording() + if (onComplete != null) { + val result = audioRecordingController.completeRecordingSync() + if (result is Result.Success) { + addAttachments(listOf(result.value)) + } + onComplete(result) + } else { + audioRecordingController.completeRecording() + } } } diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/AsyncImageHeadersProvider.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/AsyncImageHeadersProvider.kt index 8b1c07e3fa5..35f452ace31 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/AsyncImageHeadersProvider.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/AsyncImageHeadersProvider.kt @@ -24,10 +24,13 @@ package io.getstream.chat.android.ui.common.helper * Implementations are always invoked on [kotlinx.coroutines.Dispatchers.IO], so blocking * calls are safe. * - * Prefer this over [ImageHeadersProvider] when integrating with [ChatTheme]. + * @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via + * [io.getstream.chat.android.client.ChatClient.Builder.cdn] to provide headers and transform URLs + * for all image, file, and download requests. * * @see ImageHeadersProvider */ +@Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().") public interface AsyncImageHeadersProvider { /** diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/DownloadAttachmentUriGenerator.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/DownloadAttachmentUriGenerator.kt index c744532364d..6a81cbc4de6 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/DownloadAttachmentUriGenerator.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/DownloadAttachmentUriGenerator.kt @@ -21,7 +21,12 @@ import io.getstream.chat.android.models.Attachment /** * Generates a download URI for the given attachment. + * + * @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via + * [io.getstream.chat.android.client.ChatClient.Builder.cdn] to transform URLs for all image, file, + * and download requests. */ +@Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().") public fun interface DownloadAttachmentUriGenerator { /** @@ -37,7 +42,12 @@ public fun interface DownloadAttachmentUriGenerator { /** * Default implementation of [DownloadAttachmentUriGenerator] that generates a download URI based on the asset URL * or image URL of the attachment. + * + * @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via + * [io.getstream.chat.android.client.ChatClient.Builder.cdn] to transform URLs for all image, file, + * and download requests. */ +@Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().") public object DefaultDownloadAttachmentUriGenerator : DownloadAttachmentUriGenerator { override fun generateDownloadUri(attachment: Attachment): Uri = Uri.parse(attachment.assetUrl ?: attachment.imageUrl) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/DownloadRequestInterceptor.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/DownloadRequestInterceptor.kt index 0bc7734e2e2..d3ba7d4ac37 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/DownloadRequestInterceptor.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/DownloadRequestInterceptor.kt @@ -20,7 +20,12 @@ import android.app.DownloadManager /** * Intercepts and modifies the download request before it is enqueued. + * + * @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via + * [io.getstream.chat.android.client.ChatClient.Builder.cdn] to provide headers and transform URLs + * for all image, file, and download requests. */ +@Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().") public fun interface DownloadRequestInterceptor { /** diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/ImageAssetTransformer.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/ImageAssetTransformer.kt index d1803e8568e..6568170c4d4 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/ImageAssetTransformer.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/ImageAssetTransformer.kt @@ -25,8 +25,13 @@ import java.io.File import java.nio.ByteBuffer /** - * Provides HTTP headers for image loading requests. + * Transforms image assets before loading. + * + * @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via + * [io.getstream.chat.android.client.ChatClient.Builder.cdn] to transform URLs for all image, file, + * and download requests. */ +@Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().") public interface ImageAssetTransformer { /** @@ -51,7 +56,12 @@ public interface ImageAssetTransformer { /** * Default implementation of [ImageAssetTransformer] that doesn't provide any headers. + * + * @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via + * [io.getstream.chat.android.client.ChatClient.Builder.cdn] to transform URLs for all image, file, + * and download requests. */ +@Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().") public object DefaultImageAssetTransformer : ImageAssetTransformer { override fun transform(asset: Any): Any = asset } diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/ImageHeadersProvider.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/ImageHeadersProvider.kt index c58bdd300f3..5147d70a86f 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/ImageHeadersProvider.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/ImageHeadersProvider.kt @@ -18,7 +18,12 @@ package io.getstream.chat.android.ui.common.helper /** * Provides HTTP headers for image loading requests. + * + * @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via + * [io.getstream.chat.android.client.ChatClient.Builder.cdn] to provide headers and transform URLs + * for all image, file, and download requests. */ +@Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().") public interface ImageHeadersProvider { /** @@ -32,7 +37,12 @@ public interface ImageHeadersProvider { /** * Default implementation of [ImageHeadersProvider] that doesn't provide any headers. + * + * @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via + * [io.getstream.chat.android.client.ChatClient.Builder.cdn] to provide headers and transform URLs + * for all image, file, and download requests. */ +@Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().") public object DefaultImageHeadersProvider : ImageHeadersProvider { override fun getImageRequestHeaders(url: String): Map = emptyMap() } diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/VideoHeadersProvider.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/VideoHeadersProvider.kt index 2504b4e13fa..6aa48591679 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/VideoHeadersProvider.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/VideoHeadersProvider.kt @@ -20,7 +20,12 @@ import io.getstream.chat.android.core.internal.InternalStreamChatApi /** * Provides HTTP headers for video loading requests. + * + * @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via + * [io.getstream.chat.android.client.ChatClient.Builder.cdn] to provide headers and transform URLs + * for all image, file, and download requests. */ +@Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().") public interface VideoHeadersProvider { /** diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper.kt index d4362b8528f..c509269940a 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelper.kt @@ -146,6 +146,24 @@ public class AttachmentStorageHelper( public fun resolveMetadata(uris: List): List = storageHelper.getAttachmentsFromUriList(context, uris).let(attachmentFilter::filterAttachments) + /** + * Partitions [metadata] into resolvable and unresolvable items. + * + * An item is considered resolvable if its content URI can be opened for reading. + * Metadata entries without a URI are treated as resolvable. + * + * @param metadata The metadata to partition. + * @return A [Pair] where the first element contains resolvable items and the + * second contains unresolvable items. + */ + @WorkerThread + public fun partitionResolvable( + metadata: List, + ): Pair, List> = + metadata.partition { meta -> + meta.uri?.let { storageHelper.isUriResolvable(context, it) } ?: true + } + @Suppress("MagicNumber") private fun resolveLocalDimensions(file: File, attachment: Attachment): Pair = when { attachment.isImage() -> { 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 0df5a3d5c1c..4bbebb2cad7 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 @@ -25,6 +25,7 @@ import android.webkit.MimeTypeMap import io.getstream.chat.android.client.internal.file.StreamFileManager import io.getstream.chat.android.models.AttachmentType import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData +import io.getstream.log.taggedLogger import java.io.File /** @@ -43,6 +44,7 @@ import java.io.File @Suppress("TooManyFunctions") public class StorageHelper { private val fileManager = StreamFileManager() + private val logger by taggedLogger("Chat:StorageHelper") /** * Retrieves or creates a cached copy of a file from the given attachment metadata. @@ -55,12 +57,16 @@ public class StorageHelper { * to prevent naming conflicts. The file name is derived from the attachment's title with * proper extension handling. * + * Content URIs backed by cloud storage providers (e.g. Google Drive) may fail to provide + * an input stream if the file is not locally available. In such cases this method returns + * `null` instead of throwing. + * * @param context The Android context used to access the content resolver and cache directory. * @param attachmentMetaData The attachment metadata containing either a file or URI reference. - * @return A [File] object pointing to the cached file, or `null` if both file and URI are null. - * - * @throws java.io.IOException If there's an error reading from the URI or writing to cache. + * @return A [File] object pointing to the cached file, or `null` if both file and URI are null + * or if the content could not be read from the URI. */ + @Suppress("TooGenericExceptionCaught") public fun getCachedFileFromUri( context: Context, attachmentMetaData: AttachmentMetaData, @@ -68,13 +74,16 @@ public class StorageHelper { if (attachmentMetaData.file == null && attachmentMetaData.uri == null) { return null } - if (attachmentMetaData.file != null) { - return attachmentMetaData.file!! - } + attachmentMetaData.file?.let { return it } + val uri = attachmentMetaData.uri ?: return null val fileName = attachmentMetaData.getTitleWithExtension() - val inputStream = context.contentResolver.openInputStream(attachmentMetaData.uri!!) - ?: return null + val inputStream = try { + context.contentResolver.openInputStream(uri) + } catch (e: Exception) { + logger.e(e) { "[getCachedFileFromUri] Failed to open input stream for URI: $uri" } + null + } ?: return null return fileManager.writeFileInTimestampedCache( context = context, fileName = fileName, @@ -310,6 +319,26 @@ public class StorageHelper { return mimeType?.startsWith("video") ?: false } + /** + * Checks whether the content behind [uri] can be resolved. + * + * Opens the input stream and immediately closes it without reading any bytes. + * Returns `false` when the content provider cannot supply the stream (e.g. + * cloud-backed files that are not downloaded to the device). + * + * Must be called from a background thread. + * + * @param context The context used to access the content resolver. + * @param uri The content URI to check. + * @return `true` if the content can be read, `false` otherwise. + */ + @Suppress("TooGenericExceptionCaught") + internal fun isUriResolvable(context: Context, uri: Uri): Boolean = try { + context.contentResolver.openInputStream(uri)?.use { true } ?: false + } catch (_: Exception) { + false + } + public companion object { public const val TIME_FORMAT: String = "HHmmssSSS" public const val FILE_NAME_PREFIX: String = "STREAM_" diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt index 77012a3472c..47eac193416 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory.kt @@ -29,7 +29,6 @@ import coil3.request.allowHardware import coil3.request.crossfade import coil3.video.VideoFrameDecoder import io.getstream.chat.android.client.internal.file.StreamFileManager -import kotlinx.coroutines.Dispatchers import okio.Path.Companion.toOkioPath private const val DEFAULT_MEMORY_PERCENTAGE = 0.25 @@ -81,7 +80,6 @@ public class StreamImageLoaderFactory( .maxSizePercent(DEFAULT_DISK_CACHE_PERCENTAGE) .build() } - .interceptorCoroutineContext(Dispatchers.IO) .components { interceptors.forEach { add(it) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/CDNImageInterceptor.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/CDNImageInterceptor.kt new file mode 100644 index 00000000000..184a2e6266b --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/CDNImageInterceptor.kt @@ -0,0 +1,77 @@ +/* + * 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.images.internal + +import coil3.intercept.Interceptor +import coil3.network.httpHeaders +import coil3.request.ImageResult +import io.getstream.chat.android.client.cdn.CDN +import io.getstream.chat.android.core.internal.InternalStreamChatApi +import io.getstream.log.taggedLogger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * A Coil [Interceptor] that intercepts image requests and applies CDN transformations. + * + * The interceptor calls [CDN.imageRequest] to obtain a potentially modified URL and additional + * headers. CDN headers take precedence over any headers already present on the request + * (e.g. from [io.getstream.chat.android.ui.common.helper.ImageHeadersProvider]), overriding + * them for the same key. + * + * Only HTTP/HTTPS URLs are intercepted; local resources, content URIs, etc. pass through unchanged. + */ +@InternalStreamChatApi +public class CDNImageInterceptor(private val cdn: CDN) : Interceptor { + + private val logger by taggedLogger("Chat:CDNImageInterceptor") + + override suspend fun intercept(chain: Interceptor.Chain): ImageResult { + val request = chain.request + val url = request.data.toString() + + // Only intercept http/https URLs + if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) { + return chain.proceed() + } + + val cdnRequest = try { + withContext(Dispatchers.IO) { + cdn.imageRequest(url) + } + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + logger.e(e) { "[intercept] CDN.imageRequest() failed for url: $url. Falling back to original request." } + return chain.proceed() + } + + // Merge headers: existing request headers as base, CDN headers override for same keys + val existingHeaders = request.httpHeaders + val mergedHeaders = buildMap { + existingHeaders.asMap().forEach { (name, values) -> + values.lastOrNull()?.let { put(name, it) } + } + cdnRequest.headers?.let { putAll(it) } + }.toNetworkHeaders() + + val newRequest = request.newBuilder() + .data(cdnRequest.url) + .httpHeaders(mergedHeaders) + .build() + + return chain.withRequest(newRequest).proceed() + } +} diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/StreamCoil.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/StreamCoil.kt index 235d2d3de85..0cdb1659bbe 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/StreamCoil.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/StreamCoil.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.ui.common.images.internal import android.content.Context import coil3.ImageLoader import coil3.SingletonImageLoader +import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.ui.common.images.StreamImageLoaderFactory @@ -47,7 +48,11 @@ public object StreamCoil { } private fun newImageLoaderFactory(): SingletonImageLoader.Factory { - return StreamImageLoaderFactory().apply { + val cdn = ChatClient.instance().cdn + val interceptors = buildList { + cdn?.let { add(CDNImageInterceptor(it)) } + } + return StreamImageLoaderFactory(interceptors).apply { imageLoaderFactory = this } } diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/internal/file/StreamShareFileManager.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/internal/file/StreamShareFileManager.kt index 20599a22754..10b2b2ad634 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/internal/file/StreamShareFileManager.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/internal/file/StreamShareFileManager.kt @@ -31,6 +31,8 @@ import kotlinx.coroutines.withContext import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File +import java.io.FilterInputStream +import java.io.InputStream /** * Class handling operations related to sharing files with external apps. @@ -39,11 +41,14 @@ import java.io.File public class StreamShareFileManager( private val fileManager: StreamFileManager = StreamFileManager(), private val uriProvider: ShareableUriProvider = ShareableUriProvider(), + private val config: ShareCacheConfig = ShareCacheConfig(), ) { /** * Writes a bitmap to a shareable file in the cache directory and returns a shareable URI. * + * Uses a fixed filename so that only the most recent shared bitmap is kept in cache. + * * @param context The Android context. * @param bitmap The bitmap to write. * @return A [Result] containing the [Uri] of the shareable file, or an error if the operation fails. @@ -53,14 +58,13 @@ public class StreamShareFileManager( context: Context, bitmap: Bitmap, ): Result = withContext(DispatcherProvider.IO) { - val fileName = "shared_image_${System.currentTimeMillis()}.png" try { val byteArrayOutputStream = ByteArrayOutputStream() - bitmap.compress(Bitmap.CompressFormat.PNG, BITMAP_QUALITY, byteArrayOutputStream) + bitmap.compress(Bitmap.CompressFormat.PNG, config.bitmapQuality, byteArrayOutputStream) val byteArray = byteArrayOutputStream.toByteArray() val inputStream = ByteArrayInputStream(byteArray) fileManager - .writeFileInCache(context, fileName, inputStream) + .writeFileInCache(context, config.bitmapShareFilename, inputStream) .map { file -> getUriForFile(context, file) } } catch (e: Exception) { Result.Failure(Error.ThrowableError("Could not write bitmap.", e)) @@ -74,20 +78,21 @@ public class StreamShareFileManager( * * @param context The Android context. * @param attachment The attachment to write. + * @param onProgress Optional callback informing the caller about the download progress. * @param chatClient Lambda providing the [ChatClient] instance for downloading. Defaults to [ChatClient.instance]. * @return A [Result] containing the [Uri] of the shareable file, or an error if the operation fails. */ public suspend fun writeAttachmentToShareableFile( context: Context, attachment: Attachment, + onProgress: ((bytesDownloaded: Long, totalBytes: Long) -> Unit)? = null, chatClient: () -> ChatClient = { ChatClient.instance() }, ): Result = withContext(DispatcherProvider.IO) { - // Check if already cached val cachedFile = getCachedFileForAttachment(context, attachment) if (cachedFile is Result.Success) { return@withContext Result.Success(getUriForFile(context, cachedFile.value)) } - // Not cached -> download and cache + fileManager.evictCacheFiles(context, config.cacheFilePrefix, config.cacheTtlMs, config.maxCacheSizeBytes) val url = attachment.assetUrl ?: attachment.imageUrl ?: return@withContext Result.Failure(Error.GenericError(message = "File URL cannot be null.")) @@ -96,7 +101,11 @@ public class StreamShareFileManager( .await() .flatMap { response -> val fileName = getCacheFileName(attachment) - val source = response.byteStream() + val source = if (onProgress != null) { + ProgressInputStream(response.byteStream(), attachment.fileSize.toLong(), onProgress) + } else { + response.byteStream() + } fileManager.writeFileInCache(context, fileName, source) } .map { file -> getUriForFile(context, file) } @@ -122,26 +131,64 @@ public class StreamShareFileManager( private suspend fun getCachedFileForAttachment(context: Context, attachment: Attachment): Result { val fileName = getCacheFileName(attachment) return fileManager.getFileFromCache(context, fileName).flatMap { file -> - // Ensure attachment was really cached - if (file.exists() && file.length() == attachment.fileSize.toLong()) { + if (isCachedFileValid(file, attachment.fileSize.toLong())) { Result.Success(file) } else { - Result.Failure(Error.GenericError("Cached file is invalid or incomplete.")) + Result.Failure(Error.GenericError("Cached file is invalid, incomplete, or expired.")) } } } + private fun isCachedFileValid(file: File, expectedSize: Long): Boolean = + file.exists() && + file.length() == expectedSize && + System.currentTimeMillis() - file.lastModified() < config.cacheTtlMs + private fun getCacheFileName(attachment: Attachment): String { val url = attachment.assetUrl ?: attachment.imageUrl val hashCode = url?.hashCode() ?: 0 - return "${CACHE_FILE_PREFIX}${hashCode}${attachment.name}" + return "${config.cacheFilePrefix}${hashCode}${attachment.name}" } private fun getUriForFile(context: Context, file: File): Uri = uriProvider.getUriForFile(context, file) +} + +/** + * Configuration for the share file cache. + * + * @param cacheFilePrefix Filename prefix for cached attachment files. + * @param bitmapShareFilename Fixed filename used for shared bitmaps. + * @param bitmapQuality Compression quality (0-100) for shared bitmap PNGs (default: 90). + * @param cacheTtlMs Maximum age in milliseconds before a cached file is considered expired (default: 5 min.). + * @param maxCacheSizeBytes Soft size cap in bytes for all cached attachment files (default: 25MB). + */ +@Suppress("MagicNumber") +@InternalStreamChatApi +public data class ShareCacheConfig( + val cacheFilePrefix: String = "TMP", + val bitmapShareFilename: String = "shared_image.png", + val bitmapQuality: Int = 90, + val cacheTtlMs: Long = 5 * 60 * 1000L, + val maxCacheSizeBytes: Long = 25L * 1024 * 1024, +) + +/** + * An [InputStream] wrapper that reports read progress via [onProgress]. + */ +private class ProgressInputStream( + delegate: InputStream, + private val totalBytes: Long, + private val onProgress: (bytesRead: Long, totalBytes: Long) -> Unit, +) : FilterInputStream(delegate) { + private var bytesRead = 0L - private companion object { - private const val CACHE_FILE_PREFIX = "TMP" - private const val BITMAP_QUALITY = 90 + override fun read(b: ByteArray, off: Int, len: Int): Int { + val count = super.read(b, off, len) + if (count > 0) { + bytesRead += count + onProgress(bytesRead, totalBytes) + } + return count } } diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/Attachment.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/Attachment.kt index 4e65215bcee..d933b65ce7c 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/Attachment.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/Attachment.kt @@ -21,6 +21,7 @@ import io.getstream.chat.android.client.utils.attachment.isAudio import io.getstream.chat.android.client.utils.attachment.isAudioRecording import io.getstream.chat.android.client.utils.attachment.isFile import io.getstream.chat.android.client.utils.attachment.isVideo +import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.ui.common.helper.internal.StorageHelper import io.getstream.chat.android.ui.common.utils.StringUtils @@ -42,9 +43,43 @@ public fun Attachment.getDisplayableName(): String? { * * It first checks for the thumbnail URL, and if not present, falls back to the main image URL. */ +@Deprecated( + message = "Use the appropriate field for your attachment type: " + + "imageUrl for image attachments, " + + "thumbUrl for video thumbnails and link/giphy previews.", + level = DeprecationLevel.WARNING, +) public val Attachment.imagePreviewUrl: String? get() = thumbUrl ?: imageUrl +/** + * The image URL to display for link attachment previews. + * + * Prefers [Attachment.thumbUrl] over [Attachment.imageUrl]. + */ +@InternalStreamChatApi +public val Attachment.linkPreviewImageUrl: String? + get() = thumbUrl ?: imageUrl + +/** + * The navigation URL for link attachments. + * + * Prefers [Attachment.titleLink] over [Attachment.ogUrl]. + */ +@InternalStreamChatApi +public val Attachment.linkUrl: String? + get() = titleLink ?: ogUrl + +/** + * The fallback preview URL for Giphy attachments when [io.getstream.chat.android.ui.common.utils.giphyInfo] + * is not available. + * + * Falls back through [Attachment.thumbUrl], [Attachment.titleLink], and [Attachment.ogUrl]. + */ +@InternalStreamChatApi +public val Attachment.giphyFallbackPreviewUrl: String? + get() = thumbUrl ?: titleLink ?: ogUrl + /** * Checks if the attachment is of any file type (file, video, audio or audio recording). */ diff --git a/stream-chat-android-ui-common/src/main/res/values/strings.xml b/stream-chat-android-ui-common/src/main/res/values/strings.xml index 2933cd9f376..038f8fa0257 100644 --- a/stream-chat-android-ui-common/src/main/res/values/strings.xml +++ b/stream-chat-android-ui-common/src/main/res/values/strings.xml @@ -43,6 +43,7 @@ Allow access to more visual media + Some files could not be loaded and were skipped. Files Media Capture @@ -53,6 +54,10 @@ The load failed due to the invalid url. Something went wrong. Unable to open attachment: %s Error. File can\'t be displayed + Opening… + Downloading… %1$s / %2$s + No app available to open this file + Failed to download file: %s Error. Video can\'t be displayed There is no app to view this url:\n%s just now diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelperTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelperTest.kt index aca4ceb571b..6ea52e88f36 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelperTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/helper/internal/AttachmentStorageHelperTest.kt @@ -307,6 +307,66 @@ internal class AttachmentStorageHelperTest { assertEquals(emptyList(), result) } + @Test + fun `partitionResolvable puts all items in resolvable when all URIs are readable`() { + val uri1 = mock() + val uri2 = mock() + val meta1 = AttachmentMetaData(uri = uri1, type = "image", mimeType = "image/jpeg", title = "a.jpg") + val meta2 = AttachmentMetaData(uri = uri2, type = "image", mimeType = "image/png", title = "b.png") + whenever(storageHelper.isUriResolvable(context, uri1)) doReturn true + whenever(storageHelper.isUriResolvable(context, uri2)) doReturn true + + val (resolvable, unresolvable) = sut.partitionResolvable(listOf(meta1, meta2)) + + assertEquals(listOf(meta1, meta2), resolvable) + assertEquals(emptyList(), unresolvable) + } + + @Test + fun `partitionResolvable separates unresolvable URIs`() { + val uri1 = mock() + val uri2 = mock() + val meta1 = AttachmentMetaData(uri = uri1, type = "image", mimeType = "image/jpeg", title = "a.jpg") + val meta2 = AttachmentMetaData(uri = uri2, type = "file", mimeType = "application/pdf", title = "b.pdf") + whenever(storageHelper.isUriResolvable(context, uri1)) doReturn true + whenever(storageHelper.isUriResolvable(context, uri2)) doReturn false + + val (resolvable, unresolvable) = sut.partitionResolvable(listOf(meta1, meta2)) + + assertEquals(listOf(meta1), resolvable) + assertEquals(listOf(meta2), unresolvable) + } + + @Test + fun `partitionResolvable puts all items in unresolvable when no URIs are readable`() { + val uri1 = mock() + val meta1 = AttachmentMetaData(uri = uri1, type = "file", mimeType = "application/pdf", title = "a.pdf") + whenever(storageHelper.isUriResolvable(context, uri1)) doReturn false + + val (resolvable, unresolvable) = sut.partitionResolvable(listOf(meta1)) + + assertEquals(emptyList(), resolvable) + assertEquals(listOf(meta1), unresolvable) + } + + @Test + fun `partitionResolvable treats metadata without URI as resolvable`() { + val meta = AttachmentMetaData(type = "file", mimeType = "application/pdf", title = "doc.pdf") + + val (resolvable, unresolvable) = sut.partitionResolvable(listOf(meta)) + + assertEquals(listOf(meta), resolvable) + assertEquals(emptyList(), unresolvable) + } + + @Test + fun `partitionResolvable returns empty pair for empty input`() { + val (resolvable, unresolvable) = sut.partitionResolvable(emptyList()) + + assertEquals(emptyList(), resolvable) + assertEquals(emptyList(), unresolvable) + } + companion object { @JvmField @RegisterExtension diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/CDNImageInterceptorTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/CDNImageInterceptorTest.kt new file mode 100644 index 00000000000..c34644fc9f4 --- /dev/null +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/CDNImageInterceptorTest.kt @@ -0,0 +1,185 @@ +/* + * 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.images.internal + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import coil3.intercept.Interceptor +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.request.ImageRequest +import coil3.request.ImageResult +import coil3.request.SuccessResult +import coil3.size.Size +import io.getstream.chat.android.client.cdn.CDN +import io.getstream.chat.android.client.cdn.CDNRequest +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33]) +internal class CDNImageInterceptorTest { + + private val context: Context get() = RuntimeEnvironment.getApplication() + + @Test + fun `intercept rewrites URL when CDN returns different URL`() = runTest { + val cdn = object : CDN { + override suspend fun imageRequest(url: String) = + CDNRequest("https://cdn.example.com/image.jpg") + } + val interceptor = CDNImageInterceptor(cdn) + val request = ImageRequest.Builder(context) + .data("https://original.com/image.jpg") + .build() + val chain = FakeCoilChain(request) + + interceptor.intercept(chain) + + val proceededRequest = chain.proceededRequest!! + assertEquals("https://cdn.example.com/image.jpg", proceededRequest.data.toString()) + } + + @Test + fun `intercept adds CDN headers to request`() = runTest { + val cdn = object : CDN { + override suspend fun imageRequest(url: String) = + CDNRequest(url, mapOf("Authorization" to "Bearer token", "X-Custom" to "value")) + } + val interceptor = CDNImageInterceptor(cdn) + val request = ImageRequest.Builder(context) + .data("https://original.com/image.jpg") + .build() + val chain = FakeCoilChain(request) + + interceptor.intercept(chain) + + val headers = chain.proceededRequest!!.httpHeaders + assertEquals("Bearer token", headers["Authorization"]) + assertEquals("value", headers["X-Custom"]) + } + + @Test + fun `intercept CDN headers override existing headers for same key`() = runTest { + val cdn = object : CDN { + override suspend fun imageRequest(url: String) = + CDNRequest(url, mapOf("Authorization" to "CDN-token")) + } + val interceptor = CDNImageInterceptor(cdn) + val existingHeaders = NetworkHeaders.Builder() + .add("Authorization", "Original-token") + .add("X-Existing", "keep-me") + .build() + val request = ImageRequest.Builder(context) + .data("https://original.com/image.jpg") + .httpHeaders(existingHeaders) + .build() + val chain = FakeCoilChain(request) + + interceptor.intercept(chain) + + val headers = chain.proceededRequest!!.httpHeaders + assertEquals("CDN-token", headers["Authorization"]) + assertEquals("keep-me", headers["X-Existing"]) + } + + @Test + fun `intercept skips non-HTTP URLs`() = runTest { + var cdnCalled = false + val cdn = object : CDN { + override suspend fun imageRequest(url: String): CDNRequest { + cdnCalled = true + return CDNRequest("https://should-not-be-used.com") + } + } + val interceptor = CDNImageInterceptor(cdn) + val request = ImageRequest.Builder(context) + .data("content://media/image.jpg") + .build() + val chain = FakeCoilChain(request) + + interceptor.intercept(chain) + + assertTrue("CDN should not be called for content:// URLs", !cdnCalled) + assertTrue("Request should pass through unchanged", chain.proceededRequest == null || chain.directProceed) + } + + @Test + @Suppress("TooGenericExceptionThrown") + fun `intercept falls back to original request when CDN throws`() = runTest { + val cdn = object : CDN { + override suspend fun imageRequest(url: String): CDNRequest { + throw RuntimeException("CDN unavailable") + } + } + val interceptor = CDNImageInterceptor(cdn) + val request = ImageRequest.Builder(context) + .data("https://original.com/image.jpg") + .build() + val chain = FakeCoilChain(request) + + interceptor.intercept(chain) + + assertTrue("Should fall back to direct proceed on CDN error", chain.directProceed) + } + + @Suppress("EmptyFunctionBlock") + private class FakeCoilChain( + override val request: ImageRequest, + ) : Interceptor.Chain { + var proceededRequest: ImageRequest? = null + var directProceed: Boolean = false + + override val size: Size get() = Size.ORIGINAL + + override suspend fun proceed(): ImageResult { + directProceed = true + return mock() + } + + override fun withRequest(request: ImageRequest): Interceptor.Chain { + return FakeCoilChainWithRequest(request, this) + } + + override fun withSize(size: Size): Interceptor.Chain = this + } + + @Suppress("EmptyFunctionBlock") + private class FakeCoilChainWithRequest( + override val request: ImageRequest, + private val parent: FakeCoilChain, + ) : Interceptor.Chain { + override val size: Size get() = Size.ORIGINAL + + override suspend fun proceed(): ImageResult { + parent.proceededRequest = request + return mock() + } + + override fun withRequest(request: ImageRequest): Interceptor.Chain { + return FakeCoilChainWithRequest(request, parent) + } + + override fun withSize(size: Size): Interceptor.Chain = this + } +} diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/internal/file/StreamShareFileManagerTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/internal/file/StreamShareFileManagerTest.kt index 40613216b88..0acf465265d 100644 --- a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/internal/file/StreamShareFileManagerTest.kt +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/internal/file/StreamShareFileManagerTest.kt @@ -120,6 +120,7 @@ internal class StreamShareFileManagerTest { .thenReturn(Result.Success(cachedFile)) whenever(cachedFile.exists()).thenReturn(true) whenever(cachedFile.length()).thenReturn(1024L) + whenever(cachedFile.lastModified()).thenReturn(System.currentTimeMillis()) // when val result = shareFileManager.writeAttachmentToShareableFile(context, attachment) @@ -287,6 +288,7 @@ internal class StreamShareFileManagerTest { .thenReturn(Result.Success(cachedFile)) whenever(cachedFile.exists()).thenReturn(true) whenever(cachedFile.length()).thenReturn(1024L) + whenever(cachedFile.lastModified()).thenReturn(System.currentTimeMillis()) // when val result = shareFileManager.getShareableUriForAttachment(context, attachment) @@ -356,6 +358,119 @@ internal class StreamShareFileManagerTest { Assert.assertTrue((result as Result.Failure).value is Error.GenericError) } + @Test + fun `writeAttachmentToShareableFile treats expired cached file as cache miss`() = runTest { + // given + val attachment = randomAttachment( + assetUrl = "https://example.com/file.pdf", + fileSize = 1024, + name = "document.pdf", + ) + val cachedFile = mock() + whenever(fileManager.getFileFromCache(any(), any())) + .thenReturn(Result.Success(cachedFile)) + whenever(cachedFile.exists()).thenReturn(true) + whenever(cachedFile.length()).thenReturn(1024L) + whenever(cachedFile.lastModified()).thenReturn(System.currentTimeMillis() - 6 * 60 * 1000L) + + val chatClient = mock() + val downloadedFile = File("path/to/downloaded/file.pdf") + val responseBody = TestResponseBody("test content") + whenever(chatClient.downloadFile(any())) doReturn TestCall(Result.Success(responseBody)) + whenever(fileManager.writeFileInCache(any(), any(), any())) + .thenReturn(Result.Success(downloadedFile)) + + // when + val result = shareFileManager.writeAttachmentToShareableFile( + context = context, + attachment = attachment, + chatClient = { chatClient }, + ) + + // then + Assert.assertTrue(result.isSuccess) + } + + @Test + fun `getShareableUriForAttachment returns Error when cached file is expired`() = runTest { + // given + val attachment = randomAttachment( + assetUrl = "https://example.com/file.pdf", + fileSize = 1024, + name = "document.pdf", + ) + val cachedFile = mock() + whenever(fileManager.getFileFromCache(any(), any())) + .thenReturn(Result.Success(cachedFile)) + whenever(cachedFile.exists()).thenReturn(true) + whenever(cachedFile.length()).thenReturn(1024L) + whenever(cachedFile.lastModified()).thenReturn(System.currentTimeMillis() - 6 * 60 * 1000L) + + // when + val result = shareFileManager.getShareableUriForAttachment(context, attachment) + + // then + Assert.assertTrue(result.isFailure) + } + + @Test + fun `writeAttachmentToShareableFile calls evictCacheFiles on cache miss`() = runTest { + // given + val attachment = randomAttachment( + assetUrl = "https://example.com/new-file.pdf", + fileSize = 512, + name = "new.pdf", + ) + whenever(fileManager.getFileFromCache(any(), any())) + .thenReturn(Result.Failure(Error.GenericError("Not cached"))) + + val chatClient = mock() + val downloadedFile = File("path/to/new.pdf") + val responseBody = TestResponseBody("new content") + whenever(chatClient.downloadFile(any())) doReturn TestCall(Result.Success(responseBody)) + whenever(fileManager.writeFileInCache(any(), any(), any())) + .thenReturn(Result.Success(downloadedFile)) + + // when + shareFileManager.writeAttachmentToShareableFile( + context = context, + attachment = attachment, + chatClient = { chatClient }, + ) + + // then + val defaults = ShareCacheConfig() + org.mockito.kotlin.verify(fileManager).evictCacheFiles( + context, + defaults.cacheFilePrefix, + defaults.cacheTtlMs, + defaults.maxCacheSizeBytes, + ) + } + + @Test + fun `writeAttachmentToShareableFile does not call evictCacheFiles on cache hit`() = runTest { + // given + val attachment = randomAttachment( + assetUrl = "https://example.com/file.pdf", + fileSize = 1024, + name = "document.pdf", + ) + val cachedFile = mock() + whenever(fileManager.getFileFromCache(any(), any())) + .thenReturn(Result.Success(cachedFile)) + whenever(cachedFile.exists()).thenReturn(true) + whenever(cachedFile.length()).thenReturn(1024L) + whenever(cachedFile.lastModified()).thenReturn(System.currentTimeMillis()) + + // when + shareFileManager.writeAttachmentToShareableFile(context, attachment) + + // then + org.mockito.kotlin.verify(fileManager, org.mockito.kotlin.never()) + .evictCacheFiles(any(), any(), any(), any()) + } + private fun createTestBitmap(width: Int = 100, height: Int = 100): Bitmap { // 1. Define the pixel colors. Here, a simple pattern of red and black. val pixels = IntArray(width * height) @@ -373,6 +488,93 @@ internal class StreamShareFileManagerTest { return bitmap } + @Test + fun `writeAttachmentToShareableFile onProgress receives incremental bytes`() = runTest { + // given + val content = "A".repeat(2048) + val attachment = randomAttachment( + assetUrl = "https://example.com/file.bin", + fileSize = content.length, + name = "file.bin", + ) + val chatClient = mock() + val responseBody = TestResponseBody(content) + whenever(fileManager.getFileFromCache(any(), any())) + .thenReturn(Result.Failure(Error.GenericError("Not cached"))) + whenever(chatClient.downloadFile(any())) doReturn TestCall(Result.Success(responseBody)) + whenever(fileManager.writeFileInCache(any(), any(), any())) + .thenAnswer { invocation -> + // Consume the stream to trigger onProgress callbacks + val inputStream = invocation.getArgument(2) + val buf = ByteArray(512) + while (inputStream.read(buf) != -1) { /* drain */ } + Result.Success(File("path/to/file.bin")) + } + + val progressValues = mutableListOf>() + + // when + shareFileManager.writeAttachmentToShareableFile( + context = context, + attachment = attachment, + onProgress = { bytesRead, totalBytes -> progressValues.add(bytesRead to totalBytes) }, + chatClient = { chatClient }, + ) + + // then + Assert.assertTrue("onProgress should have been called", progressValues.isNotEmpty()) + // Bytes should be monotonically increasing + for (i in 1 until progressValues.size) { + Assert.assertTrue( + "bytesRead should increase", + progressValues[i].first >= progressValues[i - 1].first, + ) + } + // Last bytesRead should equal total content length + Assert.assertEquals(content.length.toLong(), progressValues.last().first) + } + + @Test + fun `writeAttachmentToShareableFile onProgress receives correct totalBytes from attachment fileSize`() = runTest { + // given + val fileSize = 4096 + val content = "B".repeat(fileSize) + val attachment = randomAttachment( + assetUrl = "https://example.com/file.bin", + fileSize = fileSize, + name = "file.bin", + ) + val chatClient = mock() + val responseBody = TestResponseBody(content) + whenever(fileManager.getFileFromCache(any(), any())) + .thenReturn(Result.Failure(Error.GenericError("Not cached"))) + whenever(chatClient.downloadFile(any())) doReturn TestCall(Result.Success(responseBody)) + whenever(fileManager.writeFileInCache(any(), any(), any())) + .thenAnswer { invocation -> + val inputStream = invocation.getArgument(2) + val buf = ByteArray(1024) + while (inputStream.read(buf) != -1) { /* drain */ } + Result.Success(File("path/to/file.bin")) + } + + val progressValues = mutableListOf>() + + // when + shareFileManager.writeAttachmentToShareableFile( + context = context, + attachment = attachment, + onProgress = { bytesRead, totalBytes -> progressValues.add(bytesRead to totalBytes) }, + chatClient = { chatClient }, + ) + + // then + Assert.assertTrue("onProgress should have been called", progressValues.isNotEmpty()) + // All totalBytes values should match the attachment's fileSize + progressValues.forEach { (_, totalBytes) -> + Assert.assertEquals(fileSize.toLong(), totalBytes) + } + } + private class TestResponseBody(content: String) : ResponseBody() { private val buffer = Buffer().writeString(content, Charset.defaultCharset()) override fun contentLength(): Long = buffer.size diff --git a/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/extensions/AttachmentExtensionsTest.kt b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/extensions/AttachmentExtensionsTest.kt new file mode 100644 index 00000000000..b7e6d9dc2d1 --- /dev/null +++ b/stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/extensions/AttachmentExtensionsTest.kt @@ -0,0 +1,132 @@ +/* + * 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.utils.extensions + +import io.getstream.chat.android.models.Attachment +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +@Suppress("DEPRECATION") +internal class AttachmentExtensionsTest { + + @Test + fun `imagePreviewUrl returns thumbUrl when both thumbUrl and imageUrl are set`() { + val attachment = Attachment( + thumbUrl = "https://cdn.example.com/thumb.jpg", + imageUrl = "https://cdn.example.com/image.jpg", + ) + + assertEquals("https://cdn.example.com/thumb.jpg", attachment.imagePreviewUrl) + } + + @Test + fun `imagePreviewUrl returns thumbUrl when imageUrl is null`() { + val attachment = Attachment( + thumbUrl = "https://cdn.example.com/thumb.jpg", + imageUrl = null, + ) + + assertEquals("https://cdn.example.com/thumb.jpg", attachment.imagePreviewUrl) + } + + @Test + fun `imagePreviewUrl returns imageUrl when thumbUrl is null`() { + val attachment = Attachment( + thumbUrl = null, + imageUrl = "https://cdn.example.com/image.jpg", + ) + + assertEquals("https://cdn.example.com/image.jpg", attachment.imagePreviewUrl) + } + + @Test + fun `imagePreviewUrl returns null when both are null`() { + val attachment = Attachment( + thumbUrl = null, + imageUrl = null, + ) + + assertNull(attachment.imagePreviewUrl) + } + + // linkPreviewImageUrl tests + + @Test + fun `linkPreviewImageUrl returns thumbUrl when both thumbUrl and imageUrl are set`() { + val attachment = Attachment(thumbUrl = "thumb", imageUrl = "image") + assertEquals("thumb", attachment.linkPreviewImageUrl) + } + + @Test + fun `linkPreviewImageUrl returns imageUrl when thumbUrl is null`() { + val attachment = Attachment(thumbUrl = null, imageUrl = "image") + assertEquals("image", attachment.linkPreviewImageUrl) + } + + @Test + fun `linkPreviewImageUrl returns null when both are null`() { + val attachment = Attachment(thumbUrl = null, imageUrl = null) + assertNull(attachment.linkPreviewImageUrl) + } + + // linkUrl tests + + @Test + fun `linkUrl returns titleLink when both titleLink and ogUrl are set`() { + val attachment = Attachment(titleLink = "titleLink", ogUrl = "ogUrl") + assertEquals("titleLink", attachment.linkUrl) + } + + @Test + fun `linkUrl returns ogUrl when titleLink is null`() { + val attachment = Attachment(titleLink = null, ogUrl = "ogUrl") + assertEquals("ogUrl", attachment.linkUrl) + } + + @Test + fun `linkUrl returns null when both are null`() { + val attachment = Attachment(titleLink = null, ogUrl = null) + assertNull(attachment.linkUrl) + } + + // giphyFallbackPreviewUrl tests + + @Test + fun `giphyFallbackPreviewUrl returns thumbUrl when all are set`() { + val attachment = Attachment(thumbUrl = "thumb", titleLink = "titleLink", ogUrl = "ogUrl") + assertEquals("thumb", attachment.giphyFallbackPreviewUrl) + } + + @Test + fun `giphyFallbackPreviewUrl returns titleLink when thumbUrl is null`() { + val attachment = Attachment(thumbUrl = null, titleLink = "titleLink", ogUrl = "ogUrl") + assertEquals("titleLink", attachment.giphyFallbackPreviewUrl) + } + + @Test + fun `giphyFallbackPreviewUrl returns ogUrl when thumbUrl and titleLink are null`() { + val attachment = Attachment(thumbUrl = null, titleLink = null, ogUrl = "ogUrl") + assertEquals("ogUrl", attachment.giphyFallbackPreviewUrl) + } + + @Test + fun `giphyFallbackPreviewUrl returns null when all are null`() { + val attachment = Attachment(thumbUrl = null, titleLink = null, ogUrl = null) + assertNull(attachment.giphyFallbackPreviewUrl) + } +} diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/info/shared/media/ChatInfoSharedMediaFragment.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/info/shared/media/ChatInfoSharedMediaFragment.kt index 49607a99b2a..4538d4b59d7 100644 --- a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/info/shared/media/ChatInfoSharedMediaFragment.kt +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/chat/info/shared/media/ChatInfoSharedMediaFragment.kt @@ -33,7 +33,6 @@ import io.getstream.chat.android.models.AttachmentType import io.getstream.chat.android.ui.ChatUI import io.getstream.chat.android.ui.common.feature.channel.attachments.ChannelAttachmentsViewAction import io.getstream.chat.android.ui.common.state.channel.attachments.ChannelAttachmentsViewState -import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl import io.getstream.chat.android.ui.feature.gallery.AttachmentGalleryDestination import io.getstream.chat.android.ui.feature.gallery.AttachmentGalleryItem import io.getstream.chat.android.ui.viewmodel.channel.ChannelAttachmentsViewModel @@ -49,7 +48,7 @@ class ChatInfoSharedMediaFragment : Fragment() { ChannelAttachmentsViewModelFactory( cid = args.cid!!, attachmentTypes = listOf(AttachmentType.IMAGE, AttachmentType.VIDEO), - localFilter = { !it.imagePreviewUrl.isNullOrEmpty() && it.titleLink.isNullOrEmpty() }, + localFilter = { !(it.imageUrl ?: it.thumbUrl).isNullOrEmpty() && it.titleLink.isNullOrEmpty() }, ) } 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 25abe64c2e3..504b0e1e1d4 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 @@ -25,6 +25,7 @@ public final class io/getstream/chat/android/ui/ChatUI { public static final fun getStreamCdnImageResizing ()Lio/getstream/chat/android/ui/common/images/resizing/StreamCdnImageResizing; public static final fun getStyle ()Lio/getstream/chat/android/ui/font/ChatStyle; public static final fun getSupportedReactions ()Lio/getstream/chat/android/ui/helper/SupportedReactions; + public static final fun getUseDocumentGView ()Z public static final fun getUserAvatarRenderer ()Lio/getstream/chat/android/ui/widgets/avatar/UserAvatarRenderer; public static final fun getVideoHeadersProvider ()Lio/getstream/chat/android/ui/common/helper/VideoHeadersProvider; public static final fun getVideoThumbnailsEnabled ()Z @@ -53,6 +54,7 @@ public final class io/getstream/chat/android/ui/ChatUI { public static final fun setStreamCdnImageResizing (Lio/getstream/chat/android/ui/common/images/resizing/StreamCdnImageResizing;)V public static final fun setStyle (Lio/getstream/chat/android/ui/font/ChatStyle;)V public static final fun setSupportedReactions (Lio/getstream/chat/android/ui/helper/SupportedReactions;)V + public static final fun setUseDocumentGView (Z)V public static final fun setUserAvatarRenderer (Lio/getstream/chat/android/ui/widgets/avatar/UserAvatarRenderer;)V public static final fun setVideoHeadersProvider (Lio/getstream/chat/android/ui/common/helper/VideoHeadersProvider;)V public static final fun setVideoThumbnailsEnabled (Z)V @@ -4411,7 +4413,8 @@ public final class io/getstream/chat/android/ui/viewmodel/messages/MessageCompos public final fun cancelRecording ()V public final fun clearAttachments ()V public final fun clearData ()V - public final fun completeRecording ()V + public final fun completeRecording (Lkotlin/jvm/functions/Function1;)V + public static synthetic fun completeRecording$default (Lio/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V public final fun createPoll (Lio/getstream/chat/android/models/CreatePollParams;)V public final fun dismissMessageActions ()V public final fun dismissSuggestionsPopup ()V diff --git a/stream-chat-android-ui-components/detekt-baseline.xml b/stream-chat-android-ui-components/detekt-baseline.xml index 370e3254295..9a455d5660c 100644 --- a/stream-chat-android-ui-components/detekt-baseline.xml +++ b/stream-chat-android-ui-components/detekt-baseline.xml @@ -6,7 +6,6 @@ ComplexCondition:FileAttachmentsView.kt$GeneralFileAttachmentViewHolder$item.uploadState is Attachment.UploadState.Idle || item.uploadState is Attachment.UploadState.InProgress || (item.uploadState is Attachment.UploadState.Success && item.fileSize == 0) ComplexCondition:FileAttachmentsView.kt$RecordingFileAttachmentViewHolder$item.uploadState is Attachment.UploadState.Idle || item.uploadState is Attachment.UploadState.InProgress || (item.uploadState is Attachment.UploadState.Success && item.fileSize == 0) ComplexCondition:FootnoteDecorator.kt$FootnoteDecorator$!isGiphy && !isDeleted && userLanguage != i18nLanguage && translatedText != data.message.text - ComplexCondition:MediaAttachmentView.kt$MediaAttachmentView$attachment.isImage() || (attachment.isVideo() && ChatUI.videoThumbnailsEnabled && attachment.thumbUrl != null) ForbiddenComment:MediaAttachmentGridView.kt$MediaAttachmentGridView.SharedMediaSpaceItemDecorator$// TODO: leaves empty space after pagination IteratorNotThrowingNoSuchElementException:MessageComposerView.kt$<no name provided>$<no name provided> : Iterator LargeClass:MessageComposerViewStyle.kt$MessageComposerViewStyle$Companion diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/ChatUI.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/ChatUI.kt index 3a21037d388..74c93be70ff 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/ChatUI.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/ChatUI.kt @@ -73,31 +73,56 @@ public object ChatUI { /** * Provides a custom implementation for loading images. + * + * @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via + * [io.getstream.chat.android.client.ChatClient.Builder.cdn] to provide headers and transform URLs + * for all image, file, and download requests. */ + @Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().") @JvmStatic public var imageAssetTransformer: ImageAssetTransformer by StreamImageLoader.instance()::imageAssetTransformer /** * Generates a download URI for the given attachment. + * + * @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via + * [io.getstream.chat.android.client.ChatClient.Builder.cdn] to provide headers and transform URLs + * for all image, file, and download requests. */ + @Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().") @JvmStatic public var downloadAttachmentUriGenerator: DownloadAttachmentUriGenerator = DefaultDownloadAttachmentUriGenerator /** * Intercepts and modifies the download request before it is enqueued. + * + * @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via + * [io.getstream.chat.android.client.ChatClient.Builder.cdn] to provide headers and transform URLs + * for all image, file, and download requests. */ + @Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().") @JvmStatic public var downloadRequestInterceptor: DownloadRequestInterceptor = DownloadRequestInterceptor { } /** * Provides HTTP headers for image loading requests. + * + * @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via + * [io.getstream.chat.android.client.ChatClient.Builder.cdn] to provide headers and transform URLs + * for all image, file, and download requests. */ + @Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().") @JvmStatic public var imageHeadersProvider: ImageHeadersProvider by StreamImageLoader.instance()::imageHeadersProvider /** * Provides HTTP headers for video loading requests. + * + * @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via + * [io.getstream.chat.android.client.ChatClient.Builder.cdn] to provide headers and transform URLs + * for all image, file, and download requests. */ + @Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().") @JvmStatic public var videoHeadersProvider: VideoHeadersProvider = DefaultVideoHeadersProvider @@ -217,6 +242,16 @@ public object ChatUI { @JvmStatic public var draftMessagesEnabled: Boolean = true + /** + * Whether to use Google Docs Viewer (gview) for document attachments. + * + * When `true` (default), documents are rendered via the legacy [AttachmentDocumentActivity] + * which loads them through Google Docs Viewer. When `false`, text-based files (TXT, HTML) + * are rendered in-app and other file types are downloaded and opened with an external application. + */ + @JvmStatic + public var useDocumentGView: Boolean = true + /** * Sets the strategy for resizing images hosted on Stream's CDN. Disabled by default, * set [StreamCdnImageResizing.imageResizingEnabled] to true if you wish to enable resizing images. Note that diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/AttachmentMediaActivity.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/AttachmentMediaActivity.kt index e1facce5ad9..d68c2b5474c 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/AttachmentMediaActivity.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/AttachmentMediaActivity.kt @@ -32,8 +32,10 @@ import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.ui.PlayerView import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.cdn.internal.StreamMediaDataSource import io.getstream.chat.android.ui.R import io.getstream.chat.android.ui.databinding.StreamUiActivityAttachmentMediaBinding import io.getstream.chat.android.ui.utils.extensions.applyEdgeToEdgePadding @@ -134,8 +136,13 @@ public class AttachmentMediaActivity : AppCompatActivity() { binding.root.applyEdgeToEdgePadding(typeMask = WindowInsetsCompat.Type.systemBars()) } + @OptIn(UnstableApi::class) private fun createPlayer(): Player { - val player = ExoPlayer.Builder(this).build() + val cdn = ChatClient.instance().cdn + val dataSourceFactory = StreamMediaDataSource.factory(this, cdn) + val player = ExoPlayer.Builder(this) + .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) + .build() player.addListener(object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { val isBuffering = playbackState == Player.STATE_BUFFERING diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryImagePageFragment.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryImagePageFragment.kt index 5765da1a195..1b61a24cae8 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryImagePageFragment.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryImagePageFragment.kt @@ -21,8 +21,6 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment -import io.getstream.chat.android.models.Attachment -import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl import io.getstream.chat.android.ui.databinding.StreamUiItemAttachmentGalleryImageBinding import io.getstream.chat.android.ui.utils.load @@ -72,10 +70,10 @@ internal class AttachmentGalleryImagePageFragment : Fragment() { companion object { private const val ARG_IMAGE_URL = "image_url" - fun create(attachment: Attachment, imageClickListener: () -> Unit = {}): Fragment { + fun create(imageUrl: String?, imageClickListener: () -> Unit = {}): Fragment { return AttachmentGalleryImagePageFragment().apply { arguments = Bundle().apply { - putString(ARG_IMAGE_URL, attachment.imagePreviewUrl) + putString(ARG_IMAGE_URL, imageUrl) } this.imageClickListener = imageClickListener } diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryPagerAdapter.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryPagerAdapter.kt index 1c1b6797a4d..8de33e7d76e 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryPagerAdapter.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryPagerAdapter.kt @@ -33,8 +33,15 @@ internal class AttachmentGalleryPagerAdapter( val attachment = getItem(position) return when (attachment.type) { - AttachmentType.IMAGE -> AttachmentGalleryImagePageFragment.create(attachment, mediaClickListener) - AttachmentType.VIDEO -> AttachmentGalleryVideoPageFragment.create(attachment, mediaClickListener) + AttachmentType.IMAGE -> AttachmentGalleryImagePageFragment.create( + imageUrl = attachment.imageUrl, + imageClickListener = mediaClickListener, + ) + AttachmentType.VIDEO -> AttachmentGalleryVideoPageFragment.create( + thumbUrl = attachment.thumbUrl, + assetUrl = attachment.assetUrl, + imageClickListener = mediaClickListener, + ) else -> throw Throwable("Unsupported attachment type") } } diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryVideoPageFragment.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryVideoPageFragment.kt index 56989cddb7d..f8711eda8c6 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryVideoPageFragment.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryVideoPageFragment.kt @@ -31,13 +31,13 @@ import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.ResolvingDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.MediaSource import androidx.media3.ui.PlayerView -import io.getstream.chat.android.models.Attachment +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.cdn.internal.StreamMediaDataSource import io.getstream.chat.android.ui.ChatUI import io.getstream.chat.android.ui.R import io.getstream.chat.android.ui.databinding.StreamUiItemAttachmentGalleryVideoBinding @@ -244,8 +244,9 @@ internal class AttachmentGalleryVideoPageFragment : Fragment() { @OptIn(UnstableApi::class) private fun createMediaSourceFactory(): MediaSource.Factory { + val cdn = ChatClient.instance().cdn val headers = ChatUI.videoHeadersProvider.getVideoRequestHeaders(assetUrl ?: "") - val baseDataSourceFactory = DefaultDataSource.Factory(requireContext()) + val baseDataSourceFactory = StreamMediaDataSource.factory(requireContext(), cdn) val dataSourceFactory = ResolvingDataSource.Factory(baseDataSourceFactory) { dataSpec -> dataSpec.withAdditionalHeaders(headers) } @@ -259,11 +260,11 @@ internal class AttachmentGalleryVideoPageFragment : Fragment() { private const val CONTROLLER_SHOW_TIMEOUT = 2000 - fun create(attachment: Attachment, imageClickListener: () -> Unit = {}): Fragment { + fun create(thumbUrl: String?, assetUrl: String?, imageClickListener: () -> Unit = {}): Fragment { return AttachmentGalleryVideoPageFragment().apply { arguments = Bundle().apply { - putString(ARG_THUMB_URL, attachment.thumbUrl) - putString(ARG_ASSET_URL, attachment.assetUrl) + putString(ARG_THUMB_URL, thumbUrl) + putString(ARG_ASSET_URL, assetUrl) } this.imageClickListener = imageClickListener } diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/overview/internal/MediaAttachmentAdapter.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/overview/internal/MediaAttachmentAdapter.kt index 86f50774a96..a6224952d52 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/overview/internal/MediaAttachmentAdapter.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/overview/internal/MediaAttachmentAdapter.kt @@ -27,7 +27,6 @@ import io.getstream.chat.android.client.utils.attachment.isVideo import io.getstream.chat.android.models.AttachmentType import io.getstream.chat.android.ui.ChatUI import io.getstream.chat.android.ui.common.images.resizing.applyStreamCdnImageResizingIfEnabled -import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl import io.getstream.chat.android.ui.databinding.StreamUiItemMediaAttachmentBinding import io.getstream.chat.android.ui.feature.gallery.AttachmentGalleryItem import io.getstream.chat.android.ui.feature.gallery.MediaAttachmentGridViewStyle @@ -94,14 +93,17 @@ internal class MediaAttachmentAdapter( val shouldLoadImage = attachmentGalleryItem.attachment.isImage() || (attachmentGalleryItem.attachment.isVideo() && ChatUI.videoThumbnailsEnabled) + val imageData = if (shouldLoadImage) { + val attachment = attachmentGalleryItem.attachment + val url = if (attachment.isImage()) attachment.imageUrl else attachment.thumbUrl + url?.applyStreamCdnImageResizingIfEnabled( + streamCdnImageResizing = ChatUI.streamCdnImageResizing, + ) + } else { + null + } binding.mediaImageView.load( - data = if (shouldLoadImage) { - attachmentGalleryItem.attachment.imagePreviewUrl?.applyStreamCdnImageResizingIfEnabled( - streamCdnImageResizing = ChatUI.streamCdnImageResizing, - ) - } else { - null - }, + data = imageData, placeholderDrawable = if (!isVideoAttachment) { style.imagePlaceholder } else { @@ -186,7 +188,8 @@ internal class MediaAttachmentAdapter( private object AttachmentGalleryItemDiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: AttachmentGalleryItem, newItem: AttachmentGalleryItem): Boolean { - return oldItem.attachment.imagePreviewUrl == newItem.attachment.imagePreviewUrl && + return (oldItem.attachment.imageUrl ?: oldItem.attachment.thumbUrl) == + (newItem.attachment.imageUrl ?: newItem.attachment.thumbUrl) && oldItem.createdAt == newItem.createdAt } diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/attachment/picker/AttachmentsPickerDialogFragment.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/attachment/picker/AttachmentsPickerDialogFragment.kt index e0c0f7547fb..e5b8d5d299b 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/attachment/picker/AttachmentsPickerDialogFragment.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/attachment/picker/AttachmentsPickerDialogFragment.kt @@ -23,6 +23,7 @@ import android.view.View import android.view.ViewGroup import android.widget.CheckedTextView import android.widget.FrameLayout +import android.widget.Toast import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.descendants import androidx.viewpager2.widget.ViewPager2 @@ -93,9 +94,7 @@ public class AttachmentsPickerDialogFragment : BottomSheetDialogFragment() { override fun onDismiss(dialog: DialogInterface) { if (::style.isInitialized && style.saveAttachmentsOnDismiss) { - attachmentsSelectionListener?.onAttachmentsSelected( - selectedAttachments.map { it.toAttachment(requireContext()) }, - ) + attachmentsSelectionListener?.onAttachmentsSelected(resolveSelectedAttachments()) } super.onDismiss(dialog) } @@ -117,9 +116,7 @@ public class AttachmentsPickerDialogFragment : BottomSheetDialogFragment() { binding.attachButton.setImageDrawable(style.submitAttachmentsButtonIconDrawable) binding.attachButton.isEnabled = false binding.attachButton.setOnClickListener { - attachmentsSelectionListener?.onAttachmentsSelected( - selectedAttachments.map { it.toAttachment(requireContext()) }, - ) + attachmentsSelectionListener?.onAttachmentsSelected(resolveSelectedAttachments()) dismiss() } } @@ -162,9 +159,7 @@ public class AttachmentsPickerDialogFragment : BottomSheetDialogFragment() { } override fun onSelectedAttachmentsSubmitted() { - attachmentsSelectionListener?.onAttachmentsSelected( - selectedAttachments.map { it.toAttachment(requireContext()) }, - ) + attachmentsSelectionListener?.onAttachmentsSelected(resolveSelectedAttachments()) dismiss() } @@ -235,6 +230,18 @@ public class AttachmentsPickerDialogFragment : BottomSheetDialogFragment() { this.pollSubmissionListener = pollSubmissionListener } + private fun resolveSelectedAttachments(): List { + val resolved = selectedAttachments.mapNotNull { it.toAttachment(requireContext()) } + if (resolved.size < selectedAttachments.size) { + Toast.makeText( + requireContext(), + R.string.stream_ui_attachment_picker_error_unresolvable_attachments, + Toast.LENGTH_LONG, + ).show() + } + return resolved + } + private fun setSelectedTab(checkedTextView: CheckedTextView, pagePosition: Int) { binding.attachmentPager.setCurrentItem(pagePosition, false) binding.attachmentButtonsContainer.descendants.forEach { diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/internal/AttachmentMetaDataMapper.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/internal/AttachmentMetaDataMapper.kt index a9e980ed65a..33da026450f 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/internal/AttachmentMetaDataMapper.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/composer/internal/AttachmentMetaDataMapper.kt @@ -22,8 +22,18 @@ import io.getstream.chat.android.ui.common.helper.internal.AttachmentStorageHelp import io.getstream.chat.android.ui.common.helper.internal.StorageHelper import io.getstream.chat.android.ui.common.state.messages.composer.AttachmentMetaData -internal fun AttachmentMetaData.toAttachment(context: Context): Attachment { +/** + * Converts this metadata into an [Attachment] ready for upload. + * + * @param context Used to access the content resolver for caching the file. + * @return The attachment, or `null` when the content URI cannot be resolved + * (e.g. a cloud-backed file that is not locally available). + */ +internal fun AttachmentMetaData.toAttachment(context: Context): Attachment? { val fileFromUri = StorageHelper().getCachedFileFromUri(context, this) + if (fileFromUri == null && uri != null) { + return null + } val extra = uri?.let { mapOf(EXTRA_SOURCE_URI to it.toString()) } ?: emptyMap() return Attachment( upload = fileFromUri, diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/MessageListView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/MessageListView.kt index e5cea2051fb..75b2d61632e 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/MessageListView.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/MessageListView.kt @@ -77,7 +77,6 @@ import io.getstream.chat.android.ui.common.state.messages.list.DeletedMessageVis import io.getstream.chat.android.ui.common.state.messages.list.GiphyAction import io.getstream.chat.android.ui.common.state.messages.list.ModeratedMessageOption import io.getstream.chat.android.ui.common.utils.extensions.hasLink -import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl import io.getstream.chat.android.ui.databinding.StreamUiMessageListViewBinding import io.getstream.chat.android.ui.feature.gallery.AttachmentGalleryActivity import io.getstream.chat.android.ui.feature.gallery.AttachmentGalleryDestination @@ -496,7 +495,7 @@ public class MessageListView : ConstraintLayout { } if (attachment.isGiphy()) { - val url = attachment.imagePreviewUrl ?: attachment.titleLink ?: attachment.ogUrl + val url = attachment.thumbUrl ?: attachment.titleLink ?: attachment.ogUrl if (url != null) { ChatUI.navigator.navigate(WebLinkDestination(context, url)) @@ -507,7 +506,7 @@ public class MessageListView : ConstraintLayout { val filteredAttachments = message.attachments .filter { ( - it.isImage() && !it.imagePreviewUrl.isNullOrEmpty() || + it.isImage() && !it.imageUrl.isNullOrEmpty() || it.isVideo() && !it.assetUrl.isNullOrEmpty() ) && !it.hasLink() @@ -2244,7 +2243,7 @@ public class MessageListView : ConstraintLayout { internal companion object { fun parseValue(value: Int): NewMessagesBehaviour { - return values().find { behaviour -> behaviour.value == value } + return entries.find { behaviour -> behaviour.value == value } ?: throw IllegalArgumentException("Unknown behaviour type. It must be either SCROLL_TO_BOTTOM (int 0) or COUNT_UPDATE (int 1)") } } @@ -2255,7 +2254,7 @@ public class MessageListView : ConstraintLayout { internal companion object { fun parseValue(value: Int): MessagesStart { - return values().find { behaviour -> behaviour.value == value } + return entries.find { behaviour -> behaviour.value == value } ?: throw IllegalArgumentException("Unknown messages start type. It must be either BOTTOM (int 0) or TOP (int 1)") } } diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/DefaultQuotedAttachmentView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/DefaultQuotedAttachmentView.kt index 9234812d657..416c5429074 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/DefaultQuotedAttachmentView.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/DefaultQuotedAttachmentView.kt @@ -24,7 +24,6 @@ import io.getstream.chat.android.models.AttachmentType import io.getstream.chat.android.ui.ChatUI import io.getstream.chat.android.ui.common.images.internal.StreamImageLoader import io.getstream.chat.android.ui.common.images.resizing.applyStreamCdnImageResizingIfEnabled -import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl import io.getstream.chat.android.ui.feature.messages.list.DefaultQuotedAttachmentViewStyle import io.getstream.chat.android.ui.utils.extensions.createStreamThemeWrapper import io.getstream.chat.android.ui.utils.load @@ -82,7 +81,7 @@ internal class DefaultQuotedAttachmentView : AppCompatImageView { when (attachment.type) { AttachmentType.FILE, AttachmentType.VIDEO, AttachmentType.AUDIO_RECORDING -> loadAttachmentThumb(attachment) AttachmentType.IMAGE -> showAttachmentThumb( - attachment.imagePreviewUrl?.applyStreamCdnImageResizingIfEnabled(ChatUI.streamCdnImageResizing), + attachment.imageUrl?.applyStreamCdnImageResizingIfEnabled(ChatUI.streamCdnImageResizing), ) AttachmentType.GIPHY -> showAttachmentThumb(attachment.thumbUrl) else -> showAttachmentThumb(attachment.image) diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/FileAttachmentsView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/FileAttachmentsView.kt index 7dd86dc3ab9..20c728753d7 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/FileAttachmentsView.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/FileAttachmentsView.kt @@ -171,6 +171,7 @@ private class FileAttachmentsAdapter( style, ) } + else -> StreamUiItemFileAttachmentBinding .inflate(parent.streamThemeInflater, parent, false) diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/GiphyMediaAttachmentView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/GiphyMediaAttachmentView.kt index a98fe697b2f..a722abaed02 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/GiphyMediaAttachmentView.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/GiphyMediaAttachmentView.kt @@ -28,7 +28,7 @@ import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.ui.common.utils.GiphyInfo import io.getstream.chat.android.ui.common.utils.GiphyInfoType import io.getstream.chat.android.ui.common.utils.GiphySizingMode -import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl +import io.getstream.chat.android.ui.common.utils.extensions.giphyFallbackPreviewUrl import io.getstream.chat.android.ui.common.utils.giphyInfo import io.getstream.chat.android.ui.databinding.StreamUiGiphyMediaAttachmentViewBinding import io.getstream.chat.android.ui.feature.messages.list.adapter.view.GiphyMediaAttachmentViewStyle @@ -95,9 +95,7 @@ public class GiphyMediaAttachmentView : ConstraintLayout { ) { val giphyInfo = attachment.giphyInfo(giphyType) - val url = giphyInfo?.url ?: attachment.let { - it.imagePreviewUrl ?: it.titleLink ?: it.ogUrl - } ?: return + val url = giphyInfo?.url ?: attachment.giphyFallbackPreviewUrl ?: return if (style.sizingMode == GiphySizingMode.ADAPTIVE) { applyAdaptiveSizing( diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/LinkAttachmentView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/LinkAttachmentView.kt index 4c2f240b0e6..c784a0d5bdc 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/LinkAttachmentView.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/LinkAttachmentView.kt @@ -24,7 +24,8 @@ import androidx.core.view.isVisible import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.ui.R import io.getstream.chat.android.ui.common.images.internal.StreamImageLoader.ImageTransformation.RoundedCorners -import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl +import io.getstream.chat.android.ui.common.utils.extensions.linkPreviewImageUrl +import io.getstream.chat.android.ui.common.utils.extensions.linkUrl import io.getstream.chat.android.ui.databinding.StreamUiLinkAttachmentsViewBinding import io.getstream.chat.android.ui.feature.messages.list.MessageListItemStyle import io.getstream.chat.android.ui.font.TextStyle @@ -53,7 +54,7 @@ internal class LinkAttachmentView : FrameLayout { * @param style The style used for applying various things such as text styles. */ fun showLinkAttachment(attachment: Attachment, style: MessageListItemStyle) { - previewUrl = attachment.titleLink ?: attachment.ogUrl + previewUrl = attachment.linkUrl showTitle(attachment, style) showDescription(attachment, style) showLabel(attachment, style) @@ -121,11 +122,12 @@ internal class LinkAttachmentView : FrameLayout { * Shows the attachment preview image if it is not null. */ private fun showAttachmentImage(attachment: Attachment) { - if (attachment.imagePreviewUrl != null) { + val linkPreviewUrl = attachment.linkPreviewImageUrl + if (linkPreviewUrl != null) { binding.linkPreviewContainer.isVisible = true binding.linkPreviewImageView.load( - data = attachment.imagePreviewUrl, + data = linkPreviewUrl, placeholderResId = R.drawable.stream_ui_picture_placeholder, onStart = { binding.progressBar.isVisible = true }, onComplete = { binding.progressBar.isVisible = false }, diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/MediaAttachmentView.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/MediaAttachmentView.kt index 38db07f0203..f0e5f723846 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/MediaAttachmentView.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/view/internal/MediaAttachmentView.kt @@ -29,7 +29,6 @@ import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.ui.ChatUI import io.getstream.chat.android.ui.R import io.getstream.chat.android.ui.common.images.resizing.applyStreamCdnImageResizingIfEnabled -import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl import io.getstream.chat.android.ui.databinding.StreamUiMediaAttachmentViewBinding import io.getstream.chat.android.ui.feature.messages.list.adapter.view.MediaAttachmentViewStyle import io.getstream.chat.android.ui.font.setTextStyle @@ -132,11 +131,12 @@ internal class MediaAttachmentView : ConstraintLayout { */ fun showAttachment(attachment: Attachment, andMoreCount: Int = NO_MORE_COUNT) { val url = - if (attachment.isImage() || - (attachment.isVideo() && ChatUI.videoThumbnailsEnabled && attachment.thumbUrl != null) - ) { - attachment.imagePreviewUrl?.applyStreamCdnImageResizingIfEnabled(ChatUI.streamCdnImageResizing) - ?: attachment.titleLink ?: attachment.ogUrl ?: attachment.upload ?: return + if (attachment.isImage()) { + attachment.imageUrl?.applyStreamCdnImageResizingIfEnabled(ChatUI.streamCdnImageResizing) + ?: attachment.upload ?: return + } else if (attachment.isVideo() && ChatUI.videoThumbnailsEnabled && attachment.thumbUrl != null) { + attachment.thumbUrl?.applyStreamCdnImageResizingIfEnabled(ChatUI.streamCdnImageResizing) + ?: return } else { null } diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/viewholder/impl/GiphyViewHolder.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/viewholder/impl/GiphyViewHolder.kt index 7c8aae65893..da1f50a1a0f 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/viewholder/impl/GiphyViewHolder.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/viewholder/impl/GiphyViewHolder.kt @@ -23,7 +23,7 @@ import io.getstream.chat.android.ui.common.state.messages.list.CancelGiphy import io.getstream.chat.android.ui.common.state.messages.list.SendGiphy import io.getstream.chat.android.ui.common.state.messages.list.ShuffleGiphy import io.getstream.chat.android.ui.common.utils.GiphyInfoType -import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl +import io.getstream.chat.android.ui.common.utils.extensions.giphyFallbackPreviewUrl import io.getstream.chat.android.ui.common.utils.giphyInfo import io.getstream.chat.android.ui.databinding.StreamUiItemMessageGiphyBinding import io.getstream.chat.android.ui.feature.messages.list.GiphyViewHolderStyle @@ -73,9 +73,8 @@ public class GiphyViewHolder internal constructor( .attachments .firstOrNull() ?.let { - val url = it.giphyInfo(GiphyInfoType.FIXED_HEIGHT)?.url ?: it.let { - it.imagePreviewUrl ?: it.titleLink ?: it.ogUrl - } ?: return + val url = it.giphyInfo(GiphyInfoType.FIXED_HEIGHT)?.url + ?: it.giphyFallbackPreviewUrl ?: return binding.giphyPreview.load( data = url, diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/navigation/destinations/AttachmentDestination.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/navigation/destinations/AttachmentDestination.kt index a65aa3070dc..aa9c3957540 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/navigation/destinations/AttachmentDestination.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/navigation/destinations/AttachmentDestination.kt @@ -17,7 +17,6 @@ package io.getstream.chat.android.ui.navigation.destinations import android.content.Context -import android.content.Intent import android.widget.ImageView import android.widget.Toast import io.getstream.chat.android.client.utils.attachment.isAudio @@ -27,8 +26,10 @@ import io.getstream.chat.android.client.utils.attachment.isVideo import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.AttachmentType import io.getstream.chat.android.models.Message +import io.getstream.chat.android.ui.ChatUI import io.getstream.chat.android.ui.common.R import io.getstream.chat.android.ui.common.feature.documents.AttachmentDocumentActivity +import io.getstream.chat.android.ui.common.feature.documents.DocumentAttachmentHandler import io.getstream.chat.android.ui.common.model.MimeType import io.getstream.chat.android.ui.feature.gallery.AttachmentActivity import io.getstream.chat.android.ui.feature.gallery.AttachmentMediaActivity @@ -130,10 +131,12 @@ public open class AttachmentDestination( } docMimeType(mimeType) -> { - val intent = Intent(context, AttachmentDocumentActivity::class.java).apply { - putExtra("url", url) + @Suppress("DEPRECATION") + if (ChatUI.useDocumentGView) { + start(AttachmentDocumentActivity.getIntent(context, url)) + } else { + DocumentAttachmentHandler.openAttachment(context, attachment) } - start(intent) } else -> { diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt index 02d07e6f56c..c7cdbfbb1ff 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/messages/MessageComposerViewModel.kt @@ -30,6 +30,7 @@ import io.getstream.chat.android.ui.common.state.messages.MessageInput import io.getstream.chat.android.ui.common.state.messages.MessageMode import io.getstream.chat.android.ui.common.state.messages.Reply import io.getstream.chat.android.ui.common.state.messages.composer.MessageComposerState +import io.getstream.result.Result import io.getstream.result.call.Call import kotlinx.coroutines.flow.StateFlow @@ -204,7 +205,8 @@ public class MessageComposerViewModel( public fun toggleRecordingPlayback(): Unit = messageComposerController.toggleRecordingPlayback() - public fun completeRecording(): Unit = messageComposerController.completeRecording() + public fun completeRecording(onComplete: ((Result) -> Unit)? = null): Unit = + messageComposerController.completeRecording(onComplete) public fun pauseRecording(): Unit = messageComposerController.pauseRecording()