Skip to content

Commit 331237f

Browse files
authored
Add support for intercepting CDN file requests (#6295)
* Add new CDN contract. * Add CDN for document files. * Add CDN support for downloading attachments. * Deprecate current CDN methods. * Add progress indicator snackbar. * Add useDocumentGView config flag. * Add file sharing cache handling. * Add file sharing cache handling. * Remove CDNResponse.kt * Add tests * PR remarks
1 parent d33c0ef commit 331237f

40 files changed

Lines changed: 1681 additions & 49 deletions

File tree

stream-chat-android-client/api/stream-chat-android-client.api

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ public final class io/getstream/chat/android/client/ChatClient$Builder : io/gets
274274
public final fun appVersion (Ljava/lang/String;)Lio/getstream/chat/android/client/ChatClient$Builder;
275275
public final fun baseUrl (Ljava/lang/String;)Lio/getstream/chat/android/client/ChatClient$Builder;
276276
public fun build ()Lio/getstream/chat/android/client/ChatClient;
277+
public final fun cdn (Lio/getstream/chat/android/client/cdn/CDN;)Lio/getstream/chat/android/client/ChatClient$Builder;
277278
public final fun cdnUrl (Ljava/lang/String;)Lio/getstream/chat/android/client/ChatClient$Builder;
278279
public final fun clientDebugger (Lio/getstream/chat/android/client/debugger/ChatClientDebugger;)Lio/getstream/chat/android/client/ChatClient$Builder;
279280
public final fun credentialStorage (Lio/getstream/chat/android/client/user/storage/UserCredentialStorage;)Lio/getstream/chat/android/client/ChatClient$Builder;
@@ -711,6 +712,27 @@ public final class io/getstream/chat/android/client/audio/WaveformExtractorKt {
711712
public static final fun isEof (Landroid/media/MediaCodec$BufferInfo;)Z
712713
}
713714

715+
public abstract interface class io/getstream/chat/android/client/cdn/CDN {
716+
public fun fileRequest (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
717+
public static synthetic fun fileRequest$suspendImpl (Lio/getstream/chat/android/client/cdn/CDN;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
718+
public fun imageRequest (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
719+
public static synthetic fun imageRequest$suspendImpl (Lio/getstream/chat/android/client/cdn/CDN;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
720+
}
721+
722+
public final class io/getstream/chat/android/client/cdn/CDNRequest {
723+
public fun <init> (Ljava/lang/String;Ljava/util/Map;)V
724+
public synthetic fun <init> (Ljava/lang/String;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
725+
public final fun component1 ()Ljava/lang/String;
726+
public final fun component2 ()Ljava/util/Map;
727+
public final fun copy (Ljava/lang/String;Ljava/util/Map;)Lio/getstream/chat/android/client/cdn/CDNRequest;
728+
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;
729+
public fun equals (Ljava/lang/Object;)Z
730+
public final fun getHeaders ()Ljava/util/Map;
731+
public final fun getUrl ()Ljava/lang/String;
732+
public fun hashCode ()I
733+
public fun toString ()Ljava/lang/String;
734+
}
735+
714736
public final class io/getstream/chat/android/client/channel/ChannelClient {
715737
public final fun acceptInvite (Ljava/lang/String;)Lio/getstream/result/call/Call;
716738
public final fun addMembers (Lio/getstream/chat/android/client/query/AddMembersParams;)Lio/getstream/result/call/Call;

stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ import io.getstream.chat.android.client.attachment.AttachmentsSender
7171
import io.getstream.chat.android.client.audio.AudioPlayer
7272
import io.getstream.chat.android.client.audio.NativeMediaPlayerImpl
7373
import io.getstream.chat.android.client.audio.StreamAudioPlayer
74+
import io.getstream.chat.android.client.cdn.CDN
75+
import io.getstream.chat.android.client.cdn.internal.StreamMediaDataSource
7476
import io.getstream.chat.android.client.channel.ChannelClient
7577
import io.getstream.chat.android.client.channel.state.ChannelStateLogicProvider
7678
import io.getstream.chat.android.client.clientstate.DisconnectCause
@@ -293,6 +295,8 @@ internal constructor(
293295
private val repository: ChatClientRepository,
294296
private val messageReceiptReporter: MessageReceiptReporter,
295297
internal val messageReceiptManager: MessageReceiptManager,
298+
@InternalStreamChatApi
299+
public val cdn: CDN? = null,
296300
) {
297301
private val logger by taggedLogger(TAG)
298302
private val fileManager = StreamFileManager()
@@ -4731,6 +4735,7 @@ internal constructor(
47314735
private var uploadAttachmentsNetworkType = UploadAttachmentsNetworkType.CONNECTED
47324736
private var fileTransformer: FileTransformer = NoOpFileTransformer
47334737
private var apiModelTransformers: ApiModelTransformers = ApiModelTransformers()
4738+
private var cdn: CDN? = null
47344739
private var appName: String? = null
47354740
private var appVersion: String? = null
47364741

@@ -4859,7 +4864,11 @@ internal constructor(
48594864
*
48604865
* @param shareFileDownloadRequestInterceptor Your [Interceptor] implementation for the share file download
48614866
* call.
4867+
* @deprecated Use [io.getstream.chat.android.client.cdn.CDN] instead. Configure a custom CDN via
4868+
* [io.getstream.chat.android.client.ChatClient.Builder.cdn] to provide headers and transform URLs
4869+
* for all image, file, and download requests.
48624870
*/
4871+
@Deprecated("Use CDN instead. Configure via ChatClient.Builder.cdn().")
48634872
public fun shareFileDownloadRequestInterceptor(shareFileDownloadRequestInterceptor: Interceptor): Builder {
48644873
this.shareFileDownloadRequestInterceptor = shareFileDownloadRequestInterceptor
48654874
return this
@@ -4930,6 +4939,15 @@ internal constructor(
49304939
forceWsUrl = value
49314940
}
49324941

4942+
/**
4943+
* Sets a custom [CDN] implementation to be used by the client.
4944+
*
4945+
* @param cdn The custom CDN implementation.
4946+
*/
4947+
public fun cdn(cdn: CDN): Builder = apply {
4948+
this.cdn = cdn
4949+
}
4950+
49334951
/**
49344952
* Sets the CDN URL to be used by the client.
49354953
*/
@@ -5078,6 +5096,7 @@ internal constructor(
50785096
fileUploader = fileUploader,
50795097
sendMessageInterceptor = sendMessageInterceptor,
50805098
shareFileDownloadRequestInterceptor = shareFileDownloadRequestInterceptor,
5099+
cdn = cdn,
50815100
tokenManager = tokenManager,
50825101
customOkHttpClient = customOkHttpClient,
50835102
clientDebugger = clientDebugger,
@@ -5090,8 +5109,9 @@ internal constructor(
50905109
val api = module.api()
50915110
val appSettingsManager = AppSettingManager(api)
50925111

5112+
val mediaDataSourceFactory = StreamMediaDataSource.factory(appContext, cdn)
50935113
val audioPlayer: AudioPlayer = StreamAudioPlayer(
5094-
mediaPlayer = NativeMediaPlayerImpl(appContext) {
5114+
mediaPlayer = NativeMediaPlayerImpl(mediaDataSourceFactory) {
50955115
ExoPlayer.Builder(appContext)
50965116
.setAudioAttributes(
50975117
AudioAttributes.Builder()
@@ -5143,6 +5163,7 @@ internal constructor(
51435163
messageReceiptRepository = repository,
51445164
api = api,
51455165
),
5166+
cdn = cdn,
51465167
).apply {
51475168
attachmentsSender = AttachmentsSender(
51485169
context = appContext,

stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/NativeMediaPlayer.kt

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,13 @@
1616

1717
package io.getstream.chat.android.client.audio
1818

19-
import android.content.Context
2019
import androidx.annotation.OptIn
2120
import androidx.media3.common.MediaItem
2221
import androidx.media3.common.PlaybackException
2322
import androidx.media3.common.PlaybackParameters
2423
import androidx.media3.common.Player
2524
import androidx.media3.common.util.UnstableApi
26-
import androidx.media3.datasource.DefaultDataSource
25+
import androidx.media3.datasource.DataSource
2726
import androidx.media3.exoplayer.ExoPlayer
2827
import androidx.media3.exoplayer.source.MediaSource
2928
import androidx.media3.exoplayer.source.ProgressiveMediaSource
@@ -358,12 +357,12 @@ public enum class NativeMediaPlayerState {
358357
/**
359358
* Default implementation of [NativeMediaPlayer] based on ExoPlayer.
360359
*
361-
* @param context The context.
360+
* @param dataSourceFactory The data source factory used for creating media sources.
362361
* @param builder A builder function to create an [ExoPlayer] instance.
363362
*/
364363
@OptIn(UnstableApi::class)
365364
internal class NativeMediaPlayerImpl(
366-
context: Context,
365+
dataSourceFactory: DataSource.Factory,
367366
private val builder: () -> ExoPlayer,
368367
) : NativeMediaPlayer {
369368

@@ -392,7 +391,7 @@ internal class NativeMediaPlayerImpl(
392391
* For more info see [ExoPlayer Progressive](https://developer.android.com/media/media3/exoplayer/progressive).
393392
*/
394393
private val mediaSourceFactory: MediaSource.Factory = ProgressiveMediaSource.Factory(
395-
DefaultDataSource.Factory(context),
394+
dataSourceFactory,
396395
DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true),
397396
)
398397

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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.client.cdn
18+
19+
/**
20+
* Class defining a CDN (Content Delivery Network) interface.
21+
* Override to transform requests loading images/files from the custom CDN.
22+
*/
23+
public interface CDN {
24+
25+
/**
26+
* Transforms a request for loading an image from the CDN.
27+
*
28+
* Implementations that perform blocking or network I/O must use `withContext` to switch to the
29+
* appropriate dispatcher (e.g. `Dispatchers.IO`).
30+
*
31+
* @param url Original CDN url for the image.
32+
* @return A [CDNRequest] holding the modified request URL and/or custom headers to include with the request.
33+
*/
34+
public suspend fun imageRequest(url: String): CDNRequest = CDNRequest(url)
35+
36+
/**
37+
* Transforms a request for loading a non-image file from the CDN.
38+
*
39+
* Implementations that perform blocking or network I/O must use `withContext` to switch to the
40+
* appropriate dispatcher (e.g. `Dispatchers.IO`).
41+
*
42+
* @param url Original CDN url for the file.
43+
* @return A [CDNRequest] holding the modified request URL and/or custom headers to include with the request.
44+
*/
45+
public suspend fun fileRequest(url: String): CDNRequest = CDNRequest(url)
46+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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.client.cdn
18+
19+
/**
20+
* Model representing the request for loading a file from a CDN.
21+
*
22+
* @param url Url of the file to load.
23+
* @param headers Map of headers added to the request.
24+
*/
25+
public data class CDNRequest(
26+
val url: String,
27+
val headers: Map<String, String>? = null,
28+
)
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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.client.cdn.internal
18+
19+
import android.net.Uri
20+
import androidx.core.net.toUri
21+
import androidx.media3.common.util.UnstableApi
22+
import androidx.media3.datasource.DataSource
23+
import androidx.media3.datasource.DataSpec
24+
import androidx.media3.datasource.DefaultHttpDataSource
25+
import androidx.media3.datasource.TransferListener
26+
import io.getstream.chat.android.client.cdn.CDN
27+
import io.getstream.chat.android.client.cdn.CDNRequest
28+
import io.getstream.log.taggedLogger
29+
import kotlinx.coroutines.Dispatchers
30+
import kotlinx.coroutines.runBlocking
31+
32+
/**
33+
* A [DataSource.Factory] that creates [CDNDataSource] instances which transform
34+
* media requests through the [CDN.fileRequest] method before delegating to an upstream data source.
35+
*
36+
* @param cdn The CDN used to transform file request URLs and headers.
37+
* @param upstreamFactory The factory for creating the upstream data source that performs the actual HTTP requests.
38+
*/
39+
@UnstableApi
40+
internal class CDNDataSourceFactory(
41+
private val cdn: CDN,
42+
private val upstreamFactory: DataSource.Factory = DefaultHttpDataSource.Factory(),
43+
) : DataSource.Factory {
44+
override fun createDataSource(): DataSource {
45+
return CDNDataSource(cdn, upstreamFactory.createDataSource())
46+
}
47+
}
48+
49+
/**
50+
* A [DataSource] that transforms media requests through [CDN.fileRequest] before
51+
* delegating to an upstream data source. This allows custom CDN implementations
52+
* to rewrite URLs and inject headers for video/audio/voice recording playback via ExoPlayer.
53+
*
54+
* [CDN.fileRequest] is a suspend function and is called via [runBlocking] on [Dispatchers.IO].
55+
* This is safe because ExoPlayer always calls [open] from its loader thread, never the main thread.
56+
*/
57+
@UnstableApi
58+
private class CDNDataSource(
59+
private val cdn: CDN,
60+
private val upstream: DataSource,
61+
) : DataSource {
62+
63+
private val logger by taggedLogger("Chat:CDNDataSource")
64+
65+
override fun open(dataSpec: DataSpec): Long {
66+
val scheme = dataSpec.uri.scheme
67+
if (scheme != "http" && scheme != "https") {
68+
return upstream.open(dataSpec)
69+
}
70+
val url = dataSpec.uri.toString()
71+
val cdnRequest = try {
72+
runBlocking {
73+
cdn.fileRequest(url)
74+
}
75+
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
76+
logger.e(e) { "[open] CDN.fileRequest() failed for url: $url. Falling back to original request." }
77+
CDNRequest(url)
78+
}
79+
val mergedHeaders = buildMap {
80+
putAll(dataSpec.httpRequestHeaders)
81+
cdnRequest.headers?.let { putAll(it) }
82+
}
83+
val transformedSpec = dataSpec.buildUpon()
84+
.setUri(cdnRequest.url.toUri())
85+
.setHttpRequestHeaders(mergedHeaders)
86+
.build()
87+
return upstream.open(transformedSpec)
88+
}
89+
90+
override fun read(buffer: ByteArray, offset: Int, length: Int): Int =
91+
upstream.read(buffer, offset, length)
92+
93+
override fun close() {
94+
upstream.close()
95+
}
96+
97+
override fun getUri(): Uri? = upstream.uri
98+
99+
override fun getResponseHeaders(): Map<String, List<String>> = upstream.responseHeaders
100+
101+
override fun addTransferListener(transferListener: TransferListener) {
102+
upstream.addTransferListener(transferListener)
103+
}
104+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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.client.cdn.internal
18+
19+
import io.getstream.chat.android.client.cdn.CDN
20+
import io.getstream.chat.android.client.cdn.CDNRequest
21+
import io.getstream.log.taggedLogger
22+
import kotlinx.coroutines.runBlocking
23+
import okhttp3.Interceptor
24+
import okhttp3.Response
25+
26+
/**
27+
* OkHttp interceptor applying transformations to CDN requests.
28+
*/
29+
internal class CDNOkHttpInterceptor(private val cdn: CDN) : Interceptor {
30+
31+
private val logger by taggedLogger("Chat:CDNOkHttpInterceptor")
32+
33+
override fun intercept(chain: Interceptor.Chain): Response {
34+
val originalUrl = chain.request().url.toString()
35+
val (url, headers) = try {
36+
runBlocking {
37+
cdn.fileRequest(originalUrl)
38+
}
39+
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
40+
logger.e(e) {
41+
"[intercept] CDN.fileRequest() failed for url: $originalUrl. " +
42+
"Falling back to original request."
43+
}
44+
CDNRequest(originalUrl)
45+
}
46+
val request = chain.request().newBuilder()
47+
.url(url)
48+
.apply {
49+
headers?.forEach {
50+
header(it.key, it.value)
51+
}
52+
}
53+
.build()
54+
return chain.proceed(request)
55+
}
56+
}

0 commit comments

Comments
 (0)