Skip to content

Commit b18ddb8

Browse files
authored
Introduce thread safe AsyncImageHeadersProvider for custom image headers (#6203)
* Introduce AsyncImageHeadersProvider. * Introduce AsyncImageHeadersProvider. * Update KDocs * Set interceptorCoroutineContext
1 parent 29b2d88 commit b18ddb8

7 files changed

Lines changed: 199 additions & 32 deletions

File tree

stream-chat-android-compose/api/stream-chat-android-compose.api

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3245,7 +3245,7 @@ public final class io/getstream/chat/android/compose/ui/theme/ChatTheme {
32453245
}
32463246

32473247
public final class io/getstream/chat/android/compose/ui/theme/ChatThemeKt {
3248-
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
3248+
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
32493249
public static final fun getLocalComponentFactory ()Landroidx/compose/runtime/ProvidableCompositionLocal;
32503250
}
32513251

@@ -4734,12 +4734,17 @@ public final class io/getstream/chat/android/compose/ui/util/StorageHelperWrappe
47344734
public abstract interface class io/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory {
47354735
public static final field Companion Lio/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory$Companion;
47364736
public abstract fun imageLoader (Landroid/content/Context;)Lcoil3/ImageLoader;
4737+
public abstract fun imageLoader (Landroid/content/Context;Ljava/util/List;)Lcoil3/ImageLoader;
47374738
}
47384739

47394740
public final class io/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory$Companion {
47404741
public final fun defaultFactory ()Lio/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory;
47414742
}
47424743

4744+
public final class io/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory$DefaultImpls {
4745+
public static fun imageLoader (Lio/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory;Landroid/content/Context;Ljava/util/List;)Lcoil3/ImageLoader;
4746+
}
4747+
47434748
public final class io/getstream/chat/android/compose/ui/util/StreamImageLoaderProvidableCompositionLocal {
47444749
public static final synthetic fun box-impl (Landroidx/compose/runtime/ProvidableCompositionLocal;)Lio/getstream/chat/android/compose/ui/util/StreamImageLoaderProvidableCompositionLocal;
47454750
public static synthetic fun constructor-impl$default (Landroidx/compose/runtime/ProvidableCompositionLocal;ILkotlin/jvm/internal/DefaultConstructorMarker;)Landroidx/compose/runtime/ProvidableCompositionLocal;

stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import androidx.compose.runtime.LaunchedEffect
3030
import androidx.compose.runtime.ProvidableCompositionLocal
3131
import androidx.compose.runtime.ReadOnlyComposable
3232
import androidx.compose.runtime.compositionLocalOf
33+
import androidx.compose.runtime.remember
3334
import androidx.compose.ui.ExperimentalComposeUiApi
3435
import androidx.compose.ui.Modifier
3536
import androidx.compose.ui.graphics.painter.Painter
@@ -49,6 +50,7 @@ import io.getstream.chat.android.compose.ui.messages.attachments.factory.Attachm
4950
import io.getstream.chat.android.compose.ui.messages.attachments.factory.AttachmentsPickerTabFactory
5051
import io.getstream.chat.android.compose.ui.theme.messages.attachments.FileAttachmentTheme
5152
import io.getstream.chat.android.compose.ui.util.DefaultPollSwitchItemFactory
53+
import io.getstream.chat.android.compose.ui.util.ImageHeadersInterceptor
5254
import io.getstream.chat.android.compose.ui.util.LocalStreamImageLoader
5355
import io.getstream.chat.android.compose.ui.util.MessageAlignmentProvider
5456
import io.getstream.chat.android.compose.ui.util.MessagePreviewFormatter
@@ -59,6 +61,7 @@ import io.getstream.chat.android.compose.ui.util.QuotedMessageTextFormatter
5961
import io.getstream.chat.android.compose.ui.util.ReactionIconFactory
6062
import io.getstream.chat.android.compose.ui.util.SearchResultNameFormatter
6163
import io.getstream.chat.android.compose.ui.util.StreamCoilImageLoaderFactory
64+
import io.getstream.chat.android.ui.common.helper.AsyncImageHeadersProvider
6265
import io.getstream.chat.android.ui.common.helper.DateFormatter
6366
import io.getstream.chat.android.ui.common.helper.DefaultDownloadAttachmentUriGenerator
6467
import io.getstream.chat.android.ui.common.helper.DefaultImageAssetTransformer
@@ -160,6 +163,12 @@ private val LocalQuotedMessageTextFormatter = compositionLocalOf<QuotedMessageTe
160163
private val LocalSearchResultNameFormatter = compositionLocalOf<SearchResultNameFormatter> {
161164
error("No SearchResultNameFormatter provided! Make sure to wrap all usages of Stream components in a ChatTheme.")
162165
}
166+
167+
@Deprecated(
168+
message = "ImageHeadersProvider is deprecated. Use asyncImageHeadersProvider in ChatTheme instead. " +
169+
"Headers are now injected via Coil's interceptor pipeline, which is thread-safe and supports " +
170+
"blocking/suspending operations.",
171+
)
163172
private val LocalStreamImageHeadersProvider = compositionLocalOf<ImageHeadersProvider> {
164173
error("No ImageHeadersProvider provided! Make sure to wrap all usages of Stream components in a ChatTheme.")
165174
}
@@ -291,9 +300,17 @@ private val LocalMediaGalleryConfig = compositionLocalOf<MediaGalleryConfig> {
291300
* @param durationFormatter [DurationFormatter] Used to format durations in the app.
292301
* @param channelNameFormatter [ChannelNameFormatter] Used throughout the app for channel names.
293302
* @param messagePreviewFormatter [MessagePreviewFormatter] Used to generate a string preview for the given message.
294-
* @param imageLoaderFactory A factory that creates new Coil [ImageLoader] instances.
303+
* @param imageLoaderFactory A factory that creates new Coil [ImageLoader] instances. If used in combination with
304+
* [asyncImageHeadersProvider] you must override the [StreamCoilImageLoaderFactory.imageLoader] method accepting the
305+
* interceptors parameter.
295306
* @param imageAssetTransformer [ImageAssetTransformer] Used to transform image assets.
296-
* @param imageHeadersProvider [ImageHeadersProvider] Used to provide headers for image requests.
307+
* @param imageHeadersProvider [ImageHeadersProvider] Deprecated. Use [asyncImageHeadersProvider] instead. Headers
308+
* provided here are injected synchronously on the main thread, which blocks the UI for any non-trivial work.
309+
* @param asyncImageHeadersProvider [AsyncImageHeadersProvider] Used to provide headers for image
310+
* requests. Invoked on IO Dispatcher inside Coil's interceptor pipeline, making it safe for blocking or suspending
311+
* operations such as reading an auth token. Prefer this over [imageHeadersProvider]. If you are using this in
312+
* combination with a custom [StreamCoilImageLoaderFactory] you must override the
313+
* [StreamCoilImageLoaderFactory.imageLoader] method accepting the interceptors parameter.
297314
* @param downloadAttachmentUriGenerator [DownloadAttachmentUriGenerator] Used to generate download URIs for
298315
* attachments.
299316
* @param downloadRequestInterceptor [DownloadRequestInterceptor] Used to intercept download requests.
@@ -361,6 +378,7 @@ public fun ChatTheme(
361378
searchResultNameFormatter: SearchResultNameFormatter = SearchResultNameFormatter.defaultFormatter(),
362379
imageLoaderFactory: StreamCoilImageLoaderFactory = StreamCoilImageLoaderFactory.defaultFactory(),
363380
imageHeadersProvider: ImageHeadersProvider = DefaultImageHeadersProvider,
381+
asyncImageHeadersProvider: AsyncImageHeadersProvider? = null,
364382
downloadAttachmentUriGenerator: DownloadAttachmentUriGenerator = DefaultDownloadAttachmentUriGenerator,
365383
downloadRequestInterceptor: DownloadRequestInterceptor = DownloadRequestInterceptor { },
366384
imageAssetTransformer: ImageAssetTransformer = DefaultImageAssetTransformer,
@@ -430,6 +448,19 @@ public fun ChatTheme(
430448
ChatClient.VERSION_PREFIX_HEADER = VersionPrefixHeader.Compose
431449
}
432450

451+
val context = LocalContext.current
452+
val imageLoader = remember(imageLoaderFactory, asyncImageHeadersProvider) {
453+
if (asyncImageHeadersProvider == null) {
454+
imageLoaderFactory.imageLoader(context.applicationContext)
455+
} else {
456+
imageLoaderFactory.imageLoader(
457+
context.applicationContext,
458+
listOf(ImageHeadersInterceptor(asyncImageHeadersProvider)),
459+
)
460+
}
461+
}
462+
463+
@Suppress("DEPRECATION")
433464
CompositionLocalProvider(
434465
LocalColors provides colors,
435466
LocalDimens provides dimens,
@@ -463,7 +494,7 @@ public fun ChatTheme(
463494
LocalMessageUnreadSeparatorTheme provides messageUnreadSeparatorTheme,
464495
LocalMessageComposerTheme provides messageComposerTheme,
465496
LocalAttachmentPickerTheme provides attachmentPickerTheme,
466-
LocalStreamImageLoader provides imageLoaderFactory.imageLoader(LocalContext.current.applicationContext),
497+
LocalStreamImageLoader provides imageLoader,
467498
LocalStreamImageHeadersProvider provides imageHeadersProvider,
468499
LocalStreamDownloadAttachmentUriGenerator provides downloadAttachmentUriGenerator,
469500
LocalStreamDownloadRequestInterceptor provides downloadRequestInterceptor,
@@ -820,7 +851,15 @@ public object ChatTheme {
820851

821852
/**
822853
* Retrieves the current [ImageHeadersProvider] at the call site's position in the hierarchy.
854+
*
855+
* @deprecated Use [asyncImageHeadersProvider] in [ChatTheme] for thread-safe header injection.
823856
*/
857+
@Deprecated(
858+
message = "ImageHeadersProvider is deprecated. Pass asyncImageHeadersProvider to ChatTheme instead. " +
859+
"Headers are now injected via Coil's interceptor pipeline, which is thread-safe and supports " +
860+
"blocking/suspending operations.",
861+
)
862+
@Suppress("DEPRECATION")
824863
public val streamImageHeadersProvider: ImageHeadersProvider
825864
@Composable
826865
@ReadOnlyComposable
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.chat.android.compose.ui.util
18+
19+
import coil3.intercept.Interceptor
20+
import coil3.network.httpHeaders
21+
import coil3.request.ImageResult
22+
import io.getstream.chat.android.ui.common.helper.AsyncImageHeadersProvider
23+
import io.getstream.chat.android.ui.common.images.internal.toNetworkHeaders
24+
import kotlinx.coroutines.Dispatchers
25+
import kotlinx.coroutines.withContext
26+
27+
/**
28+
* A Coil [Interceptor] that injects HTTP headers provided by [AsyncImageHeadersProvider] into
29+
* each image request. The provider is invoked as part of Coil's background pipeline, so
30+
* blocking or suspending operations (e.g. fetching an auth token) are safe to perform inside
31+
* [AsyncImageHeadersProvider.getImageRequestHeaders].
32+
*/
33+
internal class ImageHeadersInterceptor(private val headersProvider: AsyncImageHeadersProvider) : Interceptor {
34+
35+
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
36+
val url = chain.request.data.toString()
37+
val headers = withContext(Dispatchers.IO) {
38+
headersProvider.getImageRequestHeaders(url)
39+
}
40+
val newRequest = chain.request.newBuilder()
41+
.httpHeaders(headers.toNetworkHeaders())
42+
.build()
43+
return chain.withRequest(newRequest).proceed()
44+
}
45+
}

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

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ package io.getstream.chat.android.compose.ui.util
1919
import android.content.Context
2020
import coil3.ImageLoader
2121
import coil3.SingletonImageLoader
22+
import coil3.intercept.Interceptor
2223
import io.getstream.chat.android.ui.common.images.StreamImageLoaderFactory
2324

2425
/**
@@ -31,15 +32,40 @@ public fun interface StreamCoilImageLoaderFactory {
3132
*/
3233
public fun imageLoader(context: Context): ImageLoader
3334

35+
/**
36+
* Returns a new Coil [ImageLoader] with the given [interceptors] prepended to the component
37+
* registry, ahead of all decoders and Coil's built-in EngineInterceptor.
38+
*
39+
* The default implementation **ignores [interceptors]** and delegates to [imageLoader].
40+
* This means that when a custom [StreamCoilImageLoaderFactory] is used alongside
41+
* [ChatTheme]'s `asyncImageHeadersProvider`, the async headers will **not** be injected —
42+
* the custom factory's loader is returned as-is.
43+
*
44+
* Custom class implementations that want to support interceptor injection should override this
45+
* method, for example by forwarding [interceptors] to [StreamImageLoaderFactory]:
46+
* ```kotlin
47+
* override fun imageLoader(context: Context, interceptors: List<Interceptor>): ImageLoader =
48+
* StreamImageLoaderFactory(interceptors = interceptors, builder = myCustomBuilder)
49+
* .newImageLoader(context)
50+
* ```
51+
*
52+
* Integrators using a custom [StreamCoilImageLoaderFactory] who also need auth headers on
53+
* image requests should either override this method or inject the headers directly inside
54+
* their factory's [imageLoader] implementation (e.g. via a custom OkHttp client).
55+
*
56+
* @param context The [Context] to build the [ImageLoader] with.
57+
* @param interceptors Coil [Interceptor]s to prepend to the component registry.
58+
*/
59+
public fun imageLoader(context: Context, interceptors: List<Interceptor>): ImageLoader =
60+
imageLoader(context)
61+
3462
public companion object {
3563
/**
3664
* Returns the default singleton instance of [StreamCoilImageLoaderFactory].
3765
*
3866
* @return The default implementation of [StreamCoilImageLoaderFactory].
3967
*/
40-
public fun defaultFactory(): StreamCoilImageLoaderFactory {
41-
return DefaultStreamCoilImageLoaderFactory
42-
}
68+
public fun defaultFactory(): StreamCoilImageLoaderFactory = DefaultStreamCoilImageLoaderFactory
4369
}
4470
}
4571

@@ -68,6 +94,13 @@ internal object DefaultStreamCoilImageLoaderFactory : StreamCoilImageLoaderFacto
6894
*/
6995
override fun imageLoader(context: Context): ImageLoader = imageLoader ?: newImageLoader(context)
7096

97+
override fun imageLoader(context: Context, interceptors: List<Interceptor>): ImageLoader =
98+
if (interceptors.isEmpty()) {
99+
imageLoader(context)
100+
} else {
101+
StreamImageLoaderFactory(interceptors = interceptors).newImageLoader(context)
102+
}
103+
71104
/**
72105
* Builds a new [ImageLoader] using the given Android [Context]. If the loader already exists, we return it.
73106
*

0 commit comments

Comments
 (0)