From 9591a9925d0381aa84d4784bfce106db13ae27a8 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 3 Mar 2026 10:46:23 +0100 Subject: [PATCH 1/4] Introduce AsyncImageHeadersProvider. --- .../api/stream-chat-android-compose.api | 2 +- .../android/compose/ui/theme/ChatTheme.kt | 41 ++++++++++++++++- .../ui/util/ImageHeadersInterceptor.kt | 46 +++++++++++++++++++ .../api/stream-chat-android-ui-common.api | 4 ++ .../helper/AsyncImageHeadersProvider.kt | 43 +++++++++++++++++ 5 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageHeadersInterceptor.kt create mode 100644 stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/AsyncImageHeadersProvider.kt 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 826c04026d2..79525a37b80 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -3245,7 +3245,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 (ZZZZZLio/getstream/chat/android/ui/common/permissions/SystemAttachmentsPickerConfig;Lio/getstream/chat/android/compose/ui/theme/StreamColors;Lio/getstream/chat/android/compose/ui/theme/StreamDimens;Lio/getstream/chat/android/compose/ui/theme/StreamTypography;Lio/getstream/chat/android/compose/ui/theme/StreamShapes;Lio/getstream/chat/android/compose/ui/theme/StreamRippleConfiguration;Lio/getstream/chat/android/ui/common/model/UserPresence;Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Ljava/util/List;Lio/getstream/chat/android/compose/ui/components/messages/factory/MessageContentFactory;Ljava/util/List;Ljava/util/List;Lio/getstream/chat/android/compose/ui/util/ReactionIconFactory;Lio/getstream/chat/android/ui/common/helper/ReactionPushEmojiFactory;Lio/getstream/chat/android/compose/ui/theme/ReactionOptionsTheme;Lio/getstream/chat/android/compose/ui/util/MessagePreviewIconFactory;Lio/getstream/chat/android/compose/ui/util/PollSwitchItemFactory;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/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/state/messages/list/MessageOptionsUserReactionAlignment;Ljava/util/List;ZLio/getstream/chat/android/ui/common/images/resizing/StreamCdnImageResizing;ZLio/getstream/chat/android/compose/ui/theme/MessageTheme;Lio/getstream/chat/android/compose/ui/theme/MessageTheme;Lio/getstream/chat/android/compose/ui/theme/MessageDateSeparatorTheme;Lio/getstream/chat/android/compose/ui/theme/MessageUnreadSeparatorTheme;Lio/getstream/chat/android/compose/ui/theme/MessageComposerTheme;Lio/getstream/chat/android/compose/ui/theme/AttachmentPickerTheme;Lio/getstream/chat/android/compose/ui/util/MessageTextFormatter;Lio/getstream/chat/android/compose/ui/util/QuotedMessageTextFormatter;Lio/getstream/sdk/chat/audio/recording/StreamMediaRecorder;Lio/getstream/chat/android/compose/ui/theme/StreamKeyboardBehaviour;Lio/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryConfig;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;IIIIIIII)V + public static final fun ChatTheme (ZZZZZLio/getstream/chat/android/ui/common/permissions/SystemAttachmentsPickerConfig;Lio/getstream/chat/android/compose/ui/theme/StreamColors;Lio/getstream/chat/android/compose/ui/theme/StreamDimens;Lio/getstream/chat/android/compose/ui/theme/StreamTypography;Lio/getstream/chat/android/compose/ui/theme/StreamShapes;Lio/getstream/chat/android/compose/ui/theme/StreamRippleConfiguration;Lio/getstream/chat/android/ui/common/model/UserPresence;Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Ljava/util/List;Lio/getstream/chat/android/compose/ui/components/messages/factory/MessageContentFactory;Ljava/util/List;Ljava/util/List;Lio/getstream/chat/android/compose/ui/util/ReactionIconFactory;Lio/getstream/chat/android/ui/common/helper/ReactionPushEmojiFactory;Lio/getstream/chat/android/compose/ui/theme/ReactionOptionsTheme;Lio/getstream/chat/android/compose/ui/util/MessagePreviewIconFactory;Lio/getstream/chat/android/compose/ui/util/PollSwitchItemFactory;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/state/messages/list/MessageOptionsUserReactionAlignment;Ljava/util/List;ZLio/getstream/chat/android/ui/common/images/resizing/StreamCdnImageResizing;ZLio/getstream/chat/android/compose/ui/theme/MessageTheme;Lio/getstream/chat/android/compose/ui/theme/MessageTheme;Lio/getstream/chat/android/compose/ui/theme/MessageDateSeparatorTheme;Lio/getstream/chat/android/compose/ui/theme/MessageUnreadSeparatorTheme;Lio/getstream/chat/android/compose/ui/theme/MessageComposerTheme;Lio/getstream/chat/android/compose/ui/theme/AttachmentPickerTheme;Lio/getstream/chat/android/compose/ui/util/MessageTextFormatter;Lio/getstream/chat/android/compose/ui/util/QuotedMessageTextFormatter;Lio/getstream/sdk/chat/audio/recording/StreamMediaRecorder;Lio/getstream/chat/android/compose/ui/theme/StreamKeyboardBehaviour;Lio/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryConfig;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;IIIIIIII)V public static final fun getLocalComponentFactory ()Landroidx/compose/runtime/ProvidableCompositionLocal; } 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 b01cb248a39..c6630a82145 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 @@ -30,6 +30,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter @@ -49,6 +50,7 @@ import io.getstream.chat.android.compose.ui.messages.attachments.factory.Attachm import io.getstream.chat.android.compose.ui.messages.attachments.factory.AttachmentsPickerTabFactory import io.getstream.chat.android.compose.ui.theme.messages.attachments.FileAttachmentTheme import io.getstream.chat.android.compose.ui.util.DefaultPollSwitchItemFactory +import io.getstream.chat.android.compose.ui.util.ImageHeadersInterceptor import io.getstream.chat.android.compose.ui.util.LocalStreamImageLoader import io.getstream.chat.android.compose.ui.util.MessageAlignmentProvider import io.getstream.chat.android.compose.ui.util.MessagePreviewFormatter @@ -59,6 +61,7 @@ import io.getstream.chat.android.compose.ui.util.QuotedMessageTextFormatter import io.getstream.chat.android.compose.ui.util.ReactionIconFactory import io.getstream.chat.android.compose.ui.util.SearchResultNameFormatter import io.getstream.chat.android.compose.ui.util.StreamCoilImageLoaderFactory +import io.getstream.chat.android.ui.common.helper.AsyncImageHeadersProvider import io.getstream.chat.android.ui.common.helper.DateFormatter import io.getstream.chat.android.ui.common.helper.DefaultDownloadAttachmentUriGenerator import io.getstream.chat.android.ui.common.helper.DefaultImageAssetTransformer @@ -160,6 +163,12 @@ private val LocalQuotedMessageTextFormatter = compositionLocalOf { error("No SearchResultNameFormatter provided! Make sure to wrap all usages of Stream components in a ChatTheme.") } + +@Deprecated( + message = "ImageHeadersProvider is deprecated. Use asyncImageHeadersProvider in ChatTheme instead. " + + "Headers are now injected via Coil's interceptor pipeline, which is thread-safe and supports " + + "blocking/suspending operations.", +) private val LocalStreamImageHeadersProvider = compositionLocalOf { error("No ImageHeadersProvider provided! Make sure to wrap all usages of Stream components in a ChatTheme.") } @@ -293,7 +302,13 @@ private val LocalMediaGalleryConfig = compositionLocalOf { * @param messagePreviewFormatter [MessagePreviewFormatter] Used to generate a string preview for the given message. * @param imageLoaderFactory A factory that creates new Coil [ImageLoader] instances. * @param imageAssetTransformer [ImageAssetTransformer] Used to transform image assets. - * @param imageHeadersProvider [ImageHeadersProvider] Used to provide headers for image requests. + * @param imageHeadersProvider Deprecated. Use [asyncImageHeadersProvider] instead. Headers provided + * here are injected synchronously on the main thread, which blocks the UI for any non-trivial work. + * @param asyncImageHeadersProvider [AsyncImageHeadersProvider] Used to provide headers for image + * requests. Invoked on a background thread inside Coil's interceptor pipeline, making it safe for + * blocking or suspending operations such as reading an auth token. Prefer this over + * [imageHeadersProvider]. Only one of the two should be active at a time — if both are provided, + * headers may be applied twice for the same request. * @param downloadAttachmentUriGenerator [DownloadAttachmentUriGenerator] Used to generate download URIs for * attachments. * @param downloadRequestInterceptor [DownloadRequestInterceptor] Used to intercept download requests. @@ -361,6 +376,7 @@ public fun ChatTheme( searchResultNameFormatter: SearchResultNameFormatter = SearchResultNameFormatter.defaultFormatter(), imageLoaderFactory: StreamCoilImageLoaderFactory = StreamCoilImageLoaderFactory.defaultFactory(), imageHeadersProvider: ImageHeadersProvider = DefaultImageHeadersProvider, + asyncImageHeadersProvider: AsyncImageHeadersProvider? = null, downloadAttachmentUriGenerator: DownloadAttachmentUriGenerator = DefaultDownloadAttachmentUriGenerator, downloadRequestInterceptor: DownloadRequestInterceptor = DownloadRequestInterceptor { }, imageAssetTransformer: ImageAssetTransformer = DefaultImageAssetTransformer, @@ -430,6 +446,19 @@ public fun ChatTheme( ChatClient.VERSION_PREFIX_HEADER = VersionPrefixHeader.Compose } + val context = LocalContext.current + val imageLoader = remember(imageLoaderFactory, asyncImageHeadersProvider) { + val base = imageLoaderFactory.imageLoader(context.applicationContext) + if (asyncImageHeadersProvider != null) { + base.newBuilder() + .components { add(ImageHeadersInterceptor(asyncImageHeadersProvider)) } + .build() + } else { + base + } + } + + @Suppress("DEPRECATION") CompositionLocalProvider( LocalColors provides colors, LocalDimens provides dimens, @@ -463,7 +492,7 @@ public fun ChatTheme( LocalMessageUnreadSeparatorTheme provides messageUnreadSeparatorTheme, LocalMessageComposerTheme provides messageComposerTheme, LocalAttachmentPickerTheme provides attachmentPickerTheme, - LocalStreamImageLoader provides imageLoaderFactory.imageLoader(LocalContext.current.applicationContext), + LocalStreamImageLoader provides imageLoader, LocalStreamImageHeadersProvider provides imageHeadersProvider, LocalStreamDownloadAttachmentUriGenerator provides downloadAttachmentUriGenerator, LocalStreamDownloadRequestInterceptor provides downloadRequestInterceptor, @@ -820,7 +849,15 @@ public object ChatTheme { /** * Retrieves the current [ImageHeadersProvider] at the call site's position in the hierarchy. + * + * @deprecated Use [asyncImageHeadersProvider] in [ChatTheme] for thread-safe header injection. */ + @Deprecated( + message = "ImageHeadersProvider is deprecated. Pass asyncImageHeadersProvider to ChatTheme instead. " + + "Headers are now injected via Coil's interceptor pipeline, which is thread-safe and supports " + + "blocking/suspending operations.", + ) + @Suppress("DEPRECATION") public val streamImageHeadersProvider: ImageHeadersProvider @Composable @ReadOnlyComposable 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 new file mode 100644 index 00000000000..52e7016137a --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ImageHeadersInterceptor.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.compose.ui.util + +import coil3.intercept.Interceptor +import coil3.network.httpHeaders +import coil3.request.ImageResult +import io.getstream.chat.android.ui.common.helper.AsyncImageHeadersProvider +import io.getstream.chat.android.ui.common.images.internal.toNetworkHeaders + +/** + * A Coil [Interceptor] that injects HTTP headers provided by [AsyncImageHeadersProvider] into + * each image request. The provider is invoked as part of Coil's background pipeline, so + * blocking or suspending operations (e.g. fetching an auth token) are safe to perform inside + * [AsyncImageHeadersProvider.getImageRequestHeaders]. + * + * Registered automatically by [ChatTheme] when an [AsyncImageHeadersProvider] is supplied. + * Integrators do not need to register this interceptor manually. + */ +internal class ImageHeadersInterceptor( + private val asyncImageHeadersProvider: AsyncImageHeadersProvider, +) : Interceptor { + + override suspend fun intercept(chain: Interceptor.Chain): ImageResult { + val url = chain.request.data.toString() + val headers = asyncImageHeadersProvider.getImageRequestHeaders(url) + val newRequest = chain.request.newBuilder() + .httpHeaders(headers.toNetworkHeaders()) + .build() + return chain.withRequest(newRequest).proceed() + } +} diff --git a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api index b7b0be2abbe..d8117af1839 100644 --- a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api +++ b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api @@ -1065,6 +1065,10 @@ public final class io/getstream/chat/android/ui/common/feature/messages/translat public final class io/getstream/chat/android/ui/common/feature/threads/ThreadListController$Companion { } +public abstract interface class io/getstream/chat/android/ui/common/helper/AsyncImageHeadersProvider { + public abstract fun getImageRequestHeaders (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public abstract interface class io/getstream/chat/android/ui/common/helper/ClipboardHandler { public abstract fun copyMessage (Lio/getstream/chat/android/models/Message;)V } 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 new file mode 100644 index 00000000000..5922777236c --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/helper/AsyncImageHeadersProvider.kt @@ -0,0 +1,43 @@ +/* + * 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.helper + +/** + * Provides HTTP headers for image loading requests in a suspending, thread-safe manner. + * + * Unlike [ImageHeadersProvider], this interface is designed for async operations such as + * reading an auth token from encrypted storage or fetching one from a remote endpoint. + * Implementations are invoked on a background thread inside Coil's image loading pipeline, + * so blocking calls are safe. + * + * Prefer this over [ImageHeadersProvider] when integrating with [ChatTheme]. + * + * @see ImageHeadersProvider + */ +public interface AsyncImageHeadersProvider { + + /** + * Returns a map of headers to be used for the image loading request. + * + * This function is called on a background thread as part of Coil's interceptor chain, + * so blocking operations are safe. + * + * @param url The URL of the image to load. + * @return A map of headers to be used for the image loading request. + */ + public suspend fun getImageRequestHeaders(url: String): Map +} From 12772da0ab3a420f656203dcd2914821a69ad96c Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 3 Mar 2026 15:48:26 +0100 Subject: [PATCH 2/4] Introduce AsyncImageHeadersProvider. --- .../api/stream-chat-android-compose.api | 5 +++ .../android/compose/ui/theme/ChatTheme.kt | 32 ++++++++----- .../ui/util/ImageHeadersInterceptor.kt | 13 +++--- .../ui/util/StreamCoilImageLoaderFactory.kt | 39 ++++++++++++++-- .../api/stream-chat-android-ui-common.api | 2 + .../helper/AsyncImageHeadersProvider.kt | 7 ++- .../common/images/StreamImageLoaderFactory.kt | 45 +++++++++---------- 7 files changed, 93 insertions(+), 50 deletions(-) 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 79525a37b80..44e7788d5fb 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -4734,12 +4734,17 @@ public final class io/getstream/chat/android/compose/ui/util/StorageHelperWrappe public abstract interface class io/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory { public static final field Companion Lio/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory$Companion; public abstract fun imageLoader (Landroid/content/Context;)Lcoil3/ImageLoader; + public abstract fun imageLoader (Landroid/content/Context;Ljava/util/List;)Lcoil3/ImageLoader; } public final class io/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory$Companion { public final fun defaultFactory ()Lio/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory; } +public final class io/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory$DefaultImpls { + public static fun imageLoader (Lio/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory;Landroid/content/Context;Ljava/util/List;)Lcoil3/ImageLoader; +} + public final class io/getstream/chat/android/compose/ui/util/StreamImageLoaderProvidableCompositionLocal { public static final synthetic fun box-impl (Landroidx/compose/runtime/ProvidableCompositionLocal;)Lio/getstream/chat/android/compose/ui/util/StreamImageLoaderProvidableCompositionLocal; public static synthetic fun constructor-impl$default (Landroidx/compose/runtime/ProvidableCompositionLocal;ILkotlin/jvm/internal/DefaultConstructorMarker;)Landroidx/compose/runtime/ProvidableCompositionLocal; 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 c6630a82145..7208653d639 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 @@ -300,15 +300,25 @@ private val LocalMediaGalleryConfig = compositionLocalOf { * @param durationFormatter [DurationFormatter] Used to format durations in the app. * @param channelNameFormatter [ChannelNameFormatter] Used throughout the app for channel names. * @param messagePreviewFormatter [MessagePreviewFormatter] Used to generate a string preview for the given message. - * @param imageLoaderFactory A factory that creates new Coil [ImageLoader] instances. + * @param imageLoaderFactory A factory that creates new Coil [ImageLoader] instances. When used + * together with [asyncImageHeadersProvider], header injection is performed via + * [StreamCoilImageLoaderFactory.imageLoader] with the interceptors list. The default factory + * ([StreamCoilImageLoaderFactory.defaultFactory]) supports this out of the box. Custom SAM + * lambda factories (e.g. `StreamCoilImageLoaderFactory { ctx -> ... }`) will **not** receive + * the interceptors — the async headers will silently have no effect. Custom factories that need + * auth headers should either override [StreamCoilImageLoaderFactory.imageLoader] with the + * interceptors parameter, or inject headers directly inside their factory implementation via a + * custom OkHttp client. * @param imageAssetTransformer [ImageAssetTransformer] Used to transform image assets. * @param imageHeadersProvider Deprecated. Use [asyncImageHeadersProvider] instead. Headers provided * here are injected synchronously on the main thread, which blocks the UI for any non-trivial work. * @param asyncImageHeadersProvider [AsyncImageHeadersProvider] Used to provide headers for image - * requests. Invoked on a background thread inside Coil's interceptor pipeline, making it safe for - * blocking or suspending operations such as reading an auth token. Prefer this over - * [imageHeadersProvider]. Only one of the two should be active at a time — if both are provided, - * headers may be applied twice for the same request. + * requests. Invoked on IO Dispatcher inside Coil's interceptor pipeline, + * making it safe for blocking or suspending operations such as reading an auth token. Prefer this + * over [imageHeadersProvider]. Only one of the two should be active at a time — if both are + * provided, headers may be applied twice for the same request. Note: has no effect when a custom + * [imageLoaderFactory] is provided unless that factory overrides + * [StreamCoilImageLoaderFactory.imageLoader] with the interceptors parameter. * @param downloadAttachmentUriGenerator [DownloadAttachmentUriGenerator] Used to generate download URIs for * attachments. * @param downloadRequestInterceptor [DownloadRequestInterceptor] Used to intercept download requests. @@ -448,13 +458,13 @@ public fun ChatTheme( val context = LocalContext.current val imageLoader = remember(imageLoaderFactory, asyncImageHeadersProvider) { - val base = imageLoaderFactory.imageLoader(context.applicationContext) - if (asyncImageHeadersProvider != null) { - base.newBuilder() - .components { add(ImageHeadersInterceptor(asyncImageHeadersProvider)) } - .build() + if (asyncImageHeadersProvider == null) { + imageLoaderFactory.imageLoader(context.applicationContext) } else { - base + imageLoaderFactory.imageLoader( + context.applicationContext, + listOf(ImageHeadersInterceptor(asyncImageHeadersProvider)), + ) } } 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 52e7016137a..9acaf6db0cc 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 @@ -21,23 +21,22 @@ import coil3.network.httpHeaders import coil3.request.ImageResult import io.getstream.chat.android.ui.common.helper.AsyncImageHeadersProvider import io.getstream.chat.android.ui.common.images.internal.toNetworkHeaders +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * A Coil [Interceptor] that injects HTTP headers provided by [AsyncImageHeadersProvider] into * each image request. The provider is invoked as part of Coil's background pipeline, so * blocking or suspending operations (e.g. fetching an auth token) are safe to perform inside * [AsyncImageHeadersProvider.getImageRequestHeaders]. - * - * Registered automatically by [ChatTheme] when an [AsyncImageHeadersProvider] is supplied. - * Integrators do not need to register this interceptor manually. */ -internal class ImageHeadersInterceptor( - private val asyncImageHeadersProvider: AsyncImageHeadersProvider, -) : Interceptor { +internal class ImageHeadersInterceptor(private val headersProvider: AsyncImageHeadersProvider) : Interceptor { override suspend fun intercept(chain: Interceptor.Chain): ImageResult { val url = chain.request.data.toString() - val headers = asyncImageHeadersProvider.getImageRequestHeaders(url) + val headers = withContext(Dispatchers.IO) { + headersProvider.getImageRequestHeaders(url) + } val newRequest = chain.request.newBuilder() .httpHeaders(headers.toNetworkHeaders()) .build() diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory.kt index c6f5cf78367..30fb779a480 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.compose.ui.util import android.content.Context import coil3.ImageLoader import coil3.SingletonImageLoader +import coil3.intercept.Interceptor import io.getstream.chat.android.ui.common.images.StreamImageLoaderFactory /** @@ -31,15 +32,40 @@ public fun interface StreamCoilImageLoaderFactory { */ public fun imageLoader(context: Context): ImageLoader + /** + * Returns a new Coil [ImageLoader] with the given [interceptors] prepended to the component + * registry, ahead of all decoders and Coil's built-in EngineInterceptor. + * + * The default implementation **ignores [interceptors]** and delegates to [imageLoader]. + * This means that when a custom [StreamCoilImageLoaderFactory] is used alongside + * [ChatTheme]'s `asyncImageHeadersProvider`, the async headers will **not** be injected — + * the custom factory's loader is returned as-is. + * + * Custom class implementations that want to support interceptor injection should override this + * method, for example by forwarding [interceptors] to [StreamImageLoaderFactory]: + * ```kotlin + * override fun imageLoader(context: Context, interceptors: List): ImageLoader = + * StreamImageLoaderFactory(interceptors = interceptors, builder = myCustomBuilder) + * .newImageLoader(context) + * ``` + * + * Integrators using a custom [StreamCoilImageLoaderFactory] who also need auth headers on + * image requests should either override this method or inject the headers directly inside + * their factory's [imageLoader] implementation (e.g. via a custom OkHttp client). + * + * @param context The [Context] to build the [ImageLoader] with. + * @param interceptors Coil [Interceptor]s to prepend to the component registry. + */ + public fun imageLoader(context: Context, interceptors: List): ImageLoader = + imageLoader(context) + public companion object { /** * Returns the default singleton instance of [StreamCoilImageLoaderFactory]. * * @return The default implementation of [StreamCoilImageLoaderFactory]. */ - public fun defaultFactory(): StreamCoilImageLoaderFactory { - return DefaultStreamCoilImageLoaderFactory - } + public fun defaultFactory(): StreamCoilImageLoaderFactory = DefaultStreamCoilImageLoaderFactory } } @@ -68,6 +94,13 @@ internal object DefaultStreamCoilImageLoaderFactory : StreamCoilImageLoaderFacto */ override fun imageLoader(context: Context): ImageLoader = imageLoader ?: newImageLoader(context) + override fun imageLoader(context: Context, interceptors: List): ImageLoader = + if (interceptors.isEmpty()) { + imageLoader(context) + } else { + StreamImageLoaderFactory(interceptors = interceptors).newImageLoader(context) + } + /** * Builds a new [ImageLoader] using the given Android [Context]. If the loader already exists, we return it. * diff --git a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api index d8117af1839..52ef740895d 100644 --- a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api +++ b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api @@ -1198,6 +1198,8 @@ public final class io/getstream/chat/android/ui/common/helper/internal/StorageHe public final class io/getstream/chat/android/ui/common/images/StreamImageLoaderFactory : coil3/SingletonImageLoader$Factory { public static final field $stable I public fun ()V + public fun (Ljava/util/List;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Ljava/util/List;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Lkotlin/jvm/functions/Function1;)V public synthetic fun (Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun newImageLoader (Landroid/content/Context;)Lcoil3/ImageLoader; 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 5922777236c..8b1c07e3fa5 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 @@ -21,8 +21,8 @@ package io.getstream.chat.android.ui.common.helper * * Unlike [ImageHeadersProvider], this interface is designed for async operations such as * reading an auth token from encrypted storage or fetching one from a remote endpoint. - * Implementations are invoked on a background thread inside Coil's image loading pipeline, - * so blocking calls are safe. + * Implementations are always invoked on [kotlinx.coroutines.Dispatchers.IO], so blocking + * calls are safe. * * Prefer this over [ImageHeadersProvider] when integrating with [ChatTheme]. * @@ -33,8 +33,7 @@ public interface AsyncImageHeadersProvider { /** * Returns a map of headers to be used for the image loading request. * - * This function is called on a background thread as part of Coil's interceptor chain, - * so blocking operations are safe. + * Always called on [kotlinx.coroutines.Dispatchers.IO], so blocking operations are safe. * * @param url The URL of the image to load. * @return A map of headers to be used for the image loading request. 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 d3510042ce7..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 @@ -23,15 +23,12 @@ import coil3.SingletonImageLoader import coil3.disk.DiskCache import coil3.gif.AnimatedImageDecoder import coil3.gif.GifDecoder +import coil3.intercept.Interceptor import coil3.memory.MemoryCache -import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.allowHardware import coil3.request.crossfade import coil3.video.VideoFrameDecoder import io.getstream.chat.android.client.internal.file.StreamFileManager -import okhttp3.Dispatcher -import okhttp3.Interceptor -import okhttp3.OkHttpClient import okio.Path.Companion.toOkioPath private const val DEFAULT_MEMORY_PERCENTAGE = 0.25 @@ -52,6 +49,24 @@ public class StreamImageLoaderFactory( private val builder: ImageLoader.Builder.() -> Unit = {}, ) : SingletonImageLoader.Factory { + /** + * Creates a [StreamImageLoaderFactory] with additional [Interceptor]s prepended to the + * component registry, before any decoders and before Coil's built-in [EngineInterceptor]. + * + * This constructor preserves the existing primary constructor signature and is purely additive. + * + * @param interceptors Coil [Interceptor]s to register ahead of all other components. + * @param builder Optional lambda to further customize the [ImageLoader] configuration. + */ + public constructor( + interceptors: List, + builder: ImageLoader.Builder.() -> Unit = {}, + ) : this(builder) { + this.interceptors = interceptors + } + + private var interceptors: List = emptyList() + private val fileManager = StreamFileManager() override fun newImageLoader(context: PlatformContext): ImageLoader { @@ -59,27 +74,6 @@ public class StreamImageLoaderFactory( .memoryCache { MemoryCache.Builder().maxSizePercent(context, DEFAULT_MEMORY_PERCENTAGE).build() } .allowHardware(false) .crossfade(true) - .components { - add( - OkHttpNetworkFetcherFactory( - callFactory = { - val cacheControlInterceptor = Interceptor { chain -> - chain.proceed(chain.request()) - .newBuilder() - .header("Cache-Control", "max-age=3600,public") - .build() - } - // Don't limit concurrent network requests by host. - val dispatcher = Dispatcher().apply { maxRequestsPerHost = maxRequests } - - OkHttpClient.Builder() - .dispatcher(dispatcher) - .addNetworkInterceptor(cacheControlInterceptor) - .build() - }, - ), - ) - } .diskCache { DiskCache.Builder() .directory(fileManager.getImageCache(context).toOkioPath()) @@ -87,6 +81,7 @@ public class StreamImageLoaderFactory( .build() } .components { + interceptors.forEach { add(it) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { add(AnimatedImageDecoder.Factory(enforceMinimumFrameDelay = true)) } else { From 62b9f4898bc565b656577a9233e87fe5bd4b210b Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 3 Mar 2026 16:25:21 +0100 Subject: [PATCH 3/4] Update KDocs --- .../android/compose/ui/theme/ChatTheme.kt | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) 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 7208653d639..b73eef72968 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 @@ -300,25 +300,17 @@ private val LocalMediaGalleryConfig = compositionLocalOf { * @param durationFormatter [DurationFormatter] Used to format durations in the app. * @param channelNameFormatter [ChannelNameFormatter] Used throughout the app for channel names. * @param messagePreviewFormatter [MessagePreviewFormatter] Used to generate a string preview for the given message. - * @param imageLoaderFactory A factory that creates new Coil [ImageLoader] instances. When used - * together with [asyncImageHeadersProvider], header injection is performed via - * [StreamCoilImageLoaderFactory.imageLoader] with the interceptors list. The default factory - * ([StreamCoilImageLoaderFactory.defaultFactory]) supports this out of the box. Custom SAM - * lambda factories (e.g. `StreamCoilImageLoaderFactory { ctx -> ... }`) will **not** receive - * the interceptors — the async headers will silently have no effect. Custom factories that need - * auth headers should either override [StreamCoilImageLoaderFactory.imageLoader] with the - * interceptors parameter, or inject headers directly inside their factory implementation via a - * custom OkHttp client. + * @param imageLoaderFactory A factory that creates new Coil [ImageLoader] instances. If used in combination with + * [asyncImageHeadersProvider] you must override the [StreamCoilImageLoaderFactory.imageLoader] method accepting the + * interceptors parameter. * @param imageAssetTransformer [ImageAssetTransformer] Used to transform image assets. - * @param imageHeadersProvider Deprecated. Use [asyncImageHeadersProvider] instead. Headers provided - * here are injected synchronously on the main thread, which blocks the UI for any non-trivial work. + * @param imageHeadersProvider [ImageHeadersProvider] Deprecated. Use [asyncImageHeadersProvider] instead. Headers + * provided here are injected synchronously on the main thread, which blocks the UI for any non-trivial work. * @param asyncImageHeadersProvider [AsyncImageHeadersProvider] Used to provide headers for image - * requests. Invoked on IO Dispatcher inside Coil's interceptor pipeline, - * making it safe for blocking or suspending operations such as reading an auth token. Prefer this - * over [imageHeadersProvider]. Only one of the two should be active at a time — if both are - * provided, headers may be applied twice for the same request. Note: has no effect when a custom - * [imageLoaderFactory] is provided unless that factory overrides - * [StreamCoilImageLoaderFactory.imageLoader] with the interceptors parameter. + * requests. Invoked on IO Dispatcher inside Coil's interceptor pipeline, making it safe for blocking or suspending + * operations such as reading an auth token. Prefer this over [imageHeadersProvider]. If you are using this in + * combination with a custom [StreamCoilImageLoaderFactory] you must override the + * [StreamCoilImageLoaderFactory.imageLoader] method accepting the interceptors parameter. * @param downloadAttachmentUriGenerator [DownloadAttachmentUriGenerator] Used to generate download URIs for * attachments. * @param downloadRequestInterceptor [DownloadRequestInterceptor] Used to intercept download requests. From 8f7172c910010e780dc082c80632ad6194ae9e9f Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 4 Mar 2026 11:24:22 +0100 Subject: [PATCH 4/4] Set interceptorCoroutineContext --- .../chat/android/ui/common/images/StreamImageLoaderFactory.kt | 2 ++ 1 file changed, 2 insertions(+) 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 47eac193416..77012a3472c 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,6 +29,7 @@ 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 @@ -80,6 +81,7 @@ 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) {