From aaf899c3f4ed90abc541e615f02d1866ce722d51 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 23 Mar 2026 16:39:03 +0100 Subject: [PATCH 01/11] Add new CDN contract. --- .../api/stream-chat-android-client.api | 36 ++++++ .../chat/android/client/ChatClient.kt | 18 ++- .../android/client/audio/NativeMediaPlayer.kt | 9 +- .../getstream/chat/android/client/cdn/CDN.kt | 37 +++++++ .../chat/android/client/cdn/CDNRequest.kt | 28 +++++ .../chat/android/client/cdn/CDNResponse.kt | 30 +++++ .../cdn/internal/CDNDataSourceFactory.kt | 103 ++++++++++++++++++ .../cdn/internal/CDNOkHttpInterceptor.kt | 57 ++++++++++ .../cdn/internal/StreamMediaDataSource.kt | 50 +++++++++ .../internal/StreamMediaPlayerContent.kt | 9 +- .../android/compose/ui/theme/ChatTheme.kt | 18 ++- .../ui/util/ImageHeadersInterceptor.kt | 12 +- .../images/internal/CDNImageInterceptor.kt | 77 +++++++++++++ .../ui/common/images/internal/StreamCoil.kt | 7 +- .../gallery/AttachmentMediaActivity.kt | 9 +- .../AttachmentGalleryVideoPageFragment.kt | 6 +- 16 files changed, 488 insertions(+), 18 deletions(-) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDN.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDNRequest.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDNResponse.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/CDNDataSourceFactory.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/CDNOkHttpInterceptor.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/StreamMediaDataSource.kt create mode 100644 stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/images/internal/CDNImageInterceptor.kt 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 8c58106eb11..693f2e5fc3e 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -274,6 +274,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 credentialStorage (Lio/getstream/chat/android/client/user/storage/UserCredentialStorage;)Lio/getstream/chat/android/client/ChatClient$Builder; @@ -711,6 +712,41 @@ 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 abstract fun fileRequest (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun imageRequest (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/cdn/CDNResponse { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/util/Map; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)Lio/getstream/chat/android/client/cdn/CDNResponse; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/cdn/CDNResponse;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/cdn/CDNResponse; + public fun equals (Ljava/lang/Object;)Z + public final fun getExtraData ()Ljava/util/Map; + public final fun getThumbnailUrl ()Ljava/lang/String; + 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 1048e479bf2..be489d8fa99 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 @@ -71,6 +71,8 @@ import io.getstream.chat.android.client.attachment.AttachmentsSender 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 @@ -293,6 +295,8 @@ internal constructor( 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() @@ -4726,6 +4730,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 @@ -4925,6 +4930,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. */ @@ -5085,8 +5099,9 @@ internal constructor( 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() @@ -5138,6 +5153,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/audio/NativeMediaPlayer.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/NativeMediaPlayer.kt index dfec5688c01..8cc96ac2a99 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 @@ -358,12 +357,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 { @@ -392,7 +391,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..4ba530ec20d --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDN.kt @@ -0,0 +1,37 @@ +/* + * 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. + */ +public interface CDN { + + /** + * Creates a request for loading an image from the CDN. + * + * @param url Original CDN url for the image. + */ + public suspend fun imageRequest(url: String): CDNRequest + + /** + * Creates a request for loading a non-image file from the CDN. + * + * @param url Original CDN url for the file. + */ + public suspend fun fileRequest(url: String): CDNRequest +} 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/CDNResponse.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDNResponse.kt new file mode 100644 index 00000000000..84867b86df8 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDNResponse.kt @@ -0,0 +1,30 @@ +/* + * 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 response for a successfully uploaded file to a CDN. + * + * @param url Url of the uploaded file. + * @param thumbnailUrl Url of the thumbnail for the uploaded file. + * @param extraData Additional data related to the uploaded file. + */ +public data class CDNResponse( + val url: String, + val thumbnailUrl: String? = null, + val extraData: 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..21564b0c5c5 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/CDNDataSourceFactory.kt @@ -0,0 +1,103 @@ +/* + * 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.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(Dispatchers.IO) { + 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(Uri.parse(cdnRequest.url)) + .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..d60d2729eac --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/internal/CDNOkHttpInterceptor.kt @@ -0,0 +1,57 @@ +/* + * 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.Dispatchers +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(Dispatchers.IO) { + 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 { + addHeader(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-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 6fe00f972fd..95b9a5f77ea 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 @@ -43,7 +43,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.attachments.content.PlayButton import io.getstream.chat.android.compose.ui.components.LoadingIndicator @@ -195,7 +198,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/theme/ChatTheme.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt index b73eef72968..1ab2955d1ce 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 @@ -48,6 +48,9 @@ import io.getstream.chat.android.compose.ui.attachments.preview.handler.Attachme import io.getstream.chat.android.compose.ui.components.messages.factory.MessageContentFactory import io.getstream.chat.android.compose.ui.messages.attachments.factory.AttachmentsPickerTabFactories import io.getstream.chat.android.compose.ui.messages.attachments.factory.AttachmentsPickerTabFactory +import io.getstream.chat.android.compose.ui.theme.ChatTheme.autoTranslationEnabled +import io.getstream.chat.android.compose.ui.theme.ChatTheme.isComposerLinkPreviewEnabled +import io.getstream.chat.android.compose.ui.theme.ChatTheme.showOriginalTranslationEnabled 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 @@ -73,6 +76,7 @@ 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.ReactionPushEmojiFactory 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.model.UserPresence import io.getstream.chat.android.ui.common.permissions.SystemAttachmentsPickerConfig @@ -449,14 +453,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-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-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/AttachmentGalleryVideoPageFragment.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/gallery/internal/AttachmentGalleryVideoPageFragment.kt index df93ebd267a..112acaa9236 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,12 +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.client.ChatClient +import io.getstream.chat.android.client.cdn.internal.StreamMediaDataSource import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.ui.ChatUI import io.getstream.chat.android.ui.R @@ -245,8 +246,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) } From 6b5efa8f96cd1e6541a829f552ebf6b3cb4b8120 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 24 Mar 2026 13:25:09 +0100 Subject: [PATCH 02/11] Add CDN for document files. --- .../chat/android/client/ChatClient.kt | 1 + .../chat/android/client/di/ChatModule.kt | 4 + .../DocumentAttachmentPreviewHandler.kt | 9 +- .../src/main/AndroidManifest.xml | 5 + .../documents/AttachmentDocumentActivity.java | 5 + .../documents/DocumentAttachmentHandler.kt | 102 +++++++++++ .../documents/TextFilePreviewActivity.kt | 171 ++++++++++++++++++ .../src/main/res/values/strings.xml | 3 + .../destinations/AttachmentDestination.kt | 8 +- 9 files changed, 299 insertions(+), 9 deletions(-) create mode 100644 stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/documents/DocumentAttachmentHandler.kt create mode 100644 stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/documents/TextFilePreviewActivity.kt 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 be489d8fa99..eb618b4baca 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 @@ -5087,6 +5087,7 @@ internal constructor( fileUploader = fileUploader, sendMessageInterceptor = sendMessageInterceptor, shareFileDownloadRequestInterceptor = shareFileDownloadRequestInterceptor, + cdn = cdn, tokenManager = tokenManager, customOkHttpClient = customOkHttpClient, clientDebugger = clientDebugger, 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 7575471ff5a..060438fe785 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 @@ -57,6 +57,8 @@ import io.getstream.chat.android.client.api2.endpoint.VideoCallApi 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 @@ -133,6 +135,7 @@ 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?, @@ -390,6 +393,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-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 3d06bf18df2..089e578fed8 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 @@ -18,11 +18,14 @@ 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.uiutils.model.MimeType /** - * Shows a preview for the document in the attachment using Google Docs. + * Shows a preview for document attachments. + * + * Text-based files (TXT, HTML) are displayed in-app using a WebView. + * All other document types (PDF, Office formats, etc.) are downloaded and opened with an external application. */ public class DocumentAttachmentPreviewHandler(private val context: Context) : AttachmentPreviewHandler { @@ -45,6 +48,6 @@ public class DocumentAttachmentPreviewHandler(private val context: Context) : At } override fun handleAttachmentPreview(attachment: Attachment) { - context.startActivity(AttachmentDocumentActivity.getIntent(context, attachment.assetUrl)) + DocumentAttachmentHandler.openAttachment(context, attachment) } } diff --git a/stream-chat-android-ui-common/src/main/AndroidManifest.xml b/stream-chat-android-ui-common/src/main/AndroidManifest.xml index 68b5cb14d64..c14de50de92 100644 --- a/stream-chat-android-ui-common/src/main/AndroidManifest.xml +++ b/stream-chat-android-ui-common/src/main/AndroidManifest.xml @@ -46,6 +46,11 @@ android:exported="false" android:theme="@style/Theme.AppCompat.DayNight.NoActionBar" /> + + 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) { "[openWithExternalApp] No app available to open file." } + Toast.makeText(context, R.string.stream_ui_message_list_attachment_no_app, Toast.LENGTH_SHORT) + .show() + } + } + .onError { + logger.e { "[openWithExternalApp] Failed to download file: ${it.message}" } + val msg = context.getString( + R.string.stream_ui_message_list_attachment_download_failed, + attachment.name ?: "", + ) + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + } + } + } +} diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/documents/TextFilePreviewActivity.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/documents/TextFilePreviewActivity.kt new file mode 100644 index 00000000000..c28bc0acb30 --- /dev/null +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/documents/TextFilePreviewActivity.kt @@ -0,0 +1,171 @@ +/* + * 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.feature.documents + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.webkit.WebView +import android.widget.ProgressBar +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.lifecycleScope +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.models.Attachment +import io.getstream.chat.android.ui.common.R +import io.getstream.chat.android.ui.common.internal.file.StreamShareFileManager +import io.getstream.chat.android.uiutils.model.MimeType +import io.getstream.log.taggedLogger +import io.getstream.result.onSuccessSuspend +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Activity for previewing text-based file attachments (TXT, HTML) in a WebView. + * + * The file is downloaded using [StreamShareFileManager] (which applies CDN transformations + * via [ChatClient.downloadFile]), cached locally, and then rendered in a WebView. + */ +internal class TextFilePreviewActivity : AppCompatActivity() { + + private val logger by taggedLogger("Chat:TextFilePreviewActivity") + private val shareFileManager = StreamShareFileManager() + + private lateinit var webView: WebView + private lateinit var progressBar: ProgressBar + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (!ChatClient.isInitialized) { + finish() + return + } + + setContentView(R.layout.stream_activity_text_file_preview) + + val rootView = findViewById(R.id.rootView) + webView = findViewById(R.id.webView) + progressBar = findViewById(R.id.progressBar) + + setupEdgeToEdge(rootView) + + val url = intent.getStringExtra(KEY_URL) + val mimeType = intent.getStringExtra(KEY_MIME_TYPE) + val fileName = intent.getStringExtra(KEY_FILE_NAME) + + if (url.isNullOrEmpty()) { + logger.e { "[onCreate] URL is null or empty. Finishing activity." } + finish() + return + } + + loadFile(url, mimeType, fileName) + } + + private fun setupEdgeToEdge(rootView: View) { + ViewCompat.setOnApplyWindowInsetsListener(rootView) { v, insets -> + val systemBarInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(systemBarInsets.left, systemBarInsets.top, systemBarInsets.right, systemBarInsets.bottom) + WindowInsetsCompat.CONSUMED + } + } + + @Suppress("TooGenericExceptionCaught") + private fun loadFile(url: String, mimeType: String?, fileName: String?) { + progressBar.visibility = View.VISIBLE + webView.visibility = View.GONE + + val attachment = Attachment( + assetUrl = url, + mimeType = mimeType, + name = fileName ?: "file", + ) + + lifecycleScope.launch { + val result = shareFileManager.writeAttachmentToShareableFile(this@TextFilePreviewActivity, attachment) + result + .onSuccessSuspend { uri -> + try { + val content = withContext(Dispatchers.IO) { + contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } + } + if (content == null) { + showErrorAndFinish(fileName) + return@onSuccessSuspend + } + displayContent(content, mimeType) + } catch (e: Exception) { + logger.e(e) { "[loadFile] Failed to read cached file content." } + showErrorAndFinish(fileName) + } + } + .onError { + logger.e { "[loadFile] Failed to download file: ${it.message}" } + showErrorAndFinish(fileName) + } + } + } + + private fun displayContent(content: String, mimeType: String?) { + progressBar.visibility = View.GONE + webView.visibility = View.VISIBLE + + val isHtml = mimeType == MimeType.MIME_TYPE_HTML + if (isHtml) { + webView.loadDataWithBaseURL(null, content, "text/html", "utf-8", null) + } else { + val wrappedContent = "
" +
+                "$content
" + webView.loadDataWithBaseURL(null, wrappedContent, "text/html", "utf-8", null) + } + } + + private fun showErrorAndFinish(fileName: String?) { + val text = getString( + R.string.stream_ui_message_list_attachment_download_failed, + fileName ?: "", + ) + Toast.makeText(this, text, Toast.LENGTH_SHORT).show() + finish() + } + + internal companion object { + private const val KEY_URL = "url" + private const val KEY_MIME_TYPE = "mimeType" + private const val KEY_FILE_NAME = "fileName" + + /** + * Creates an [Intent] to launch [TextFilePreviewActivity]. + * + * @param context The context to create the intent from. + * @param url The URL of the file to preview. + * @param mimeType The MIME type of the file. + * @param fileName The name of the file. + */ + fun getIntent(context: Context, url: String, mimeType: String?, fileName: String?): Intent { + return Intent(context, TextFilePreviewActivity::class.java).apply { + putExtra(KEY_URL, url) + putExtra(KEY_MIME_TYPE, mimeType) + putExtra(KEY_FILE_NAME, fileName) + } + } + } +} 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 0b90881d8a4..c9424a341ee 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 @@ -52,6 +52,9 @@ The load failed due to the invalid url. Something went wrong. Unable to open attachment: %s Error. File can\'t be displayed + Opening… + 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-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 abc2e6fa427..c904122058c 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 @@ -28,7 +27,7 @@ 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.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.feature.gallery.AttachmentActivity import io.getstream.chat.android.ui.feature.gallery.AttachmentMediaActivity import io.getstream.chat.android.ui.utils.load @@ -131,10 +130,7 @@ public open class AttachmentDestination( } docMimeType(mimeType) -> { - val intent = Intent(context, AttachmentDocumentActivity::class.java).apply { - putExtra("url", url) - } - start(intent) + DocumentAttachmentHandler.openAttachment(context, attachment) } else -> { From 2f8155014a67e9790ba64b82dead8cf613c918c7 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 24 Mar 2026 16:30:22 +0100 Subject: [PATCH 03/11] Add CDN support for downloading attachments. --- .../android/state/extensions/ChatClient.kt | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt index c10ed1caf3e..1f0f8ffb99b 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt @@ -28,6 +28,7 @@ import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.extensions.cidToTypeAndId +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,13 +303,29 @@ public fun ChatClient.downloadAttachment( val subPath = attachment.name ?: attachment.title ?: attachment.parseAttachmentNameFromUrl() ?: createAttachmentFallbackName() - logger.d { "Downloading attachment. Name: $subPath, Uri: $uri" } + // Apply CDN transformation if available + val cdnRequest = try { + val cdn = this@downloadAttachment.cdn + val url = uri.toString() + if (attachment.isImage()) cdn?.imageRequest(url) else cdn?.fileRequest(url) + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + logger.e(e) { "CDN request failed for attachment. Falling back to original URL." } + null + } + val finalUri = cdnRequest?.url?.let(Uri::parse) ?: uri + + logger.d { "Downloading attachment. Name: $subPath, Uri: $finalUri" } downloadManager.enqueue( - DownloadManager.Request(uri) + DownloadManager.Request(finalUri) .setTitle(subPath) .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, subPath) .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .apply { + cdnRequest?.headers?.forEach { (key, value) -> + addRequestHeader(key, value) + } + } .apply(interceptRequest), ) Result.Success(Unit) From 1f940a01c27b45d96b817e5567b915d27eba9405 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 25 Mar 2026 11:49:09 +0100 Subject: [PATCH 04/11] Deprecate current CDN methods. --- .../chat/android/client/ChatClient.kt | 4 +++ .../documents/DocumentAttachmentHandler.kt | 18 +++++++++++-- .../helper/AsyncImageHeadersProvider.kt | 5 +++- .../helper/DownloadAttachmentUriGenerator.kt | 10 ++++++++ .../helper/DownloadRequestInterceptor.kt | 5 ++++ .../ui/common/helper/ImageAssetTransformer.kt | 12 ++++++++- .../ui/common/helper/ImageHeadersProvider.kt | 10 ++++++++ .../ui/common/helper/VideoHeadersProvider.kt | 5 ++++ .../io/getstream/chat/android/ui/ChatUI.kt | 25 +++++++++++++++++++ 9 files changed, 90 insertions(+), 4 deletions(-) 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 eb618b4baca..c681c2f1049 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 @@ -4859,7 +4859,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 diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/documents/DocumentAttachmentHandler.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/documents/DocumentAttachmentHandler.kt index 4a230fa144f..07f7ac14082 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/documents/DocumentAttachmentHandler.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/documents/DocumentAttachmentHandler.kt @@ -18,6 +18,7 @@ package io.getstream.chat.android.ui.common.feature.documents import android.content.ActivityNotFoundException import android.content.Context +import android.content.ContextWrapper import android.content.Intent import android.widget.Toast import androidx.lifecycle.LifecycleOwner @@ -68,8 +69,8 @@ public object DocumentAttachmentHandler { } private fun openWithExternalApp(context: Context, attachment: Attachment) { - val lifecycleOwner = context as? LifecycleOwner ?: run { - logger.e { "[openWithExternalApp] Context is not a LifecycleOwner. Cannot download file." } + val lifecycleOwner = context.findLifecycleOwner() ?: run { + logger.e { "[openWithExternalApp] Could not find a LifecycleOwner from Context. Cannot download file." } return } @@ -99,4 +100,17 @@ public object DocumentAttachmentHandler { } } } + + /** + * Walks the [Context] wrapper chain to find the underlying [LifecycleOwner]. + * Handles [ContextThemeWrapper] and other [ContextWrapper] layers that may wrap an Activity. + */ + 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 + } } 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-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 4c69503ac27..8de08bc75f6 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 From d4f27af0736e1e9556060d5e705634f156c40817 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 25 Mar 2026 13:45:42 +0100 Subject: [PATCH 05/11] Add progress indicator snackbar. --- .../documents/DocumentAttachmentHandler.kt | 118 ++++++++++++++---- .../internal/file/StreamShareFileManager.kt | 30 ++++- .../src/main/res/values/strings.xml | 1 + 3 files changed, 123 insertions(+), 26 deletions(-) diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/documents/DocumentAttachmentHandler.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/documents/DocumentAttachmentHandler.kt index 07f7ac14082..1ee428e0f26 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/documents/DocumentAttachmentHandler.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/documents/DocumentAttachmentHandler.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.ui.common.feature.documents +import android.app.Activity import android.content.ActivityNotFoundException import android.content.Context import android.content.ContextWrapper @@ -23,10 +24,12 @@ import android.content.Intent import android.widget.Toast import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.ui.common.R import io.getstream.chat.android.ui.common.internal.file.StreamShareFileManager +import io.getstream.chat.android.ui.common.utils.MediaStringUtil import io.getstream.chat.android.uiutils.model.MimeType import io.getstream.log.taggedLogger import kotlinx.coroutines.launch @@ -37,6 +40,8 @@ import kotlinx.coroutines.launch * Text-based files (TXT, HTML) are displayed in-app using [TextFilePreviewActivity]. * All other document types (PDF, Office formats, etc.) are downloaded via [StreamShareFileManager] * and opened with an external application. + * + * For files larger than [SMALL_FILE_THRESHOLD], a Snackbar with download progress is shown. */ @InternalStreamChatApi public object DocumentAttachmentHandler { @@ -44,6 +49,8 @@ public object DocumentAttachmentHandler { private val logger by taggedLogger("Chat:DocumentAttachmentHandler") private val shareFileManager = StreamShareFileManager() + private const val SMALL_FILE_THRESHOLD = 2 * 1024 * 1024 // 2 MB + /** * Opens the given document [attachment]. * @@ -74,37 +81,89 @@ public object DocumentAttachmentHandler { return } + val isLargeFile = attachment.fileSize > SMALL_FILE_THRESHOLD + lifecycleOwner.lifecycleScope.launch { - Toast.makeText(context, R.string.stream_ui_message_list_attachment_opening, Toast.LENGTH_SHORT).show() - shareFileManager.writeAttachmentToShareableFile(context, attachment) - .onSuccess { uri -> - 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) { "[openWithExternalApp] No app available to open file." } - Toast.makeText(context, R.string.stream_ui_message_list_attachment_no_app, Toast.LENGTH_SHORT) - .show() - } - } - .onError { - logger.e { "[openWithExternalApp] Failed to download file: ${it.message}" } - val msg = context.getString( - R.string.stream_ui_message_list_attachment_download_failed, - attachment.name ?: "", + if (isLargeFile) { + downloadWithProgress(context, attachment) + } else { + downloadSilently(context, attachment) + } + } + } + + private suspend fun downloadSilently(context: Context, attachment: Attachment) { + shareFileManager.writeAttachmentToShareableFile(context, attachment) + .onSuccess { uri -> openFileUri(context, uri, attachment) } + .onError { error -> + logger.e { "[downloadSilently] 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 suspend fun downloadWithProgress(context: Context, attachment: Attachment) { + val snackbar = context.findActivity() + ?.findViewById(android.R.id.content) + ?.let { rootView -> + Snackbar.make( + rootView, + context.getString( + R.string.stream_ui_message_list_attachment_downloading, + MediaStringUtil.convertFileSizeByteCount(0L), + MediaStringUtil.convertFileSizeByteCount(attachment.fileSize.toLong()), + ), + Snackbar.LENGTH_INDEFINITE, + ).also { it.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, ) - Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() + sb.view.post { sb.setText(text) } } + }, + ) + .onSuccess { uri -> + snackbar?.dismiss() + openFileUri(context, uri, attachment) + } + .onError { error -> + snackbar?.dismiss() + logger.e { "[downloadWithProgress] 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() } } - /** - * Walks the [Context] wrapper chain to find the underlying [LifecycleOwner]. - * Handles [ContextThemeWrapper] and other [ContextWrapper] layers that may wrap an Activity. - */ private fun Context.findLifecycleOwner(): LifecycleOwner? { var ctx: Context? = this while (ctx != null) { @@ -113,4 +172,13 @@ public object DocumentAttachmentHandler { } 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/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..212e26fdebb 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. @@ -74,12 +76,14 @@ 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 @@ -96,7 +100,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) } @@ -145,3 +153,23 @@ public class StreamShareFileManager( private const val BITMAP_QUALITY = 90 } } + +/** + * 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 + + 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/res/values/strings.xml b/stream-chat-android-ui-common/src/main/res/values/strings.xml index 789817a3225..fa285dbd99b 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 @@ -54,6 +54,7 @@ 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 From 799a1a7817a78d5596ba696aef5ae25481e2e1de Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 25 Mar 2026 15:31:08 +0100 Subject: [PATCH 06/11] Add useDocumentGView config flag. --- .../chat/android/client/di/ChatModule.kt | 1 + .../api/stream-chat-android-compose.api | 8 +++++--- .../handler/AttachmentPreviewHandler.kt | 10 ++++++++-- .../DocumentAttachmentPreviewHandler.kt | 20 +++++++++++++++---- .../android/compose/ui/theme/ChatTheme.kt | 7 ++++++- .../android/state/extensions/ChatClient.kt | 6 ++++++ .../documents/DocumentAttachmentHandler.kt | 3 ++- .../api/stream-chat-android-ui-components.api | 2 ++ .../io/getstream/chat/android/ui/ChatUI.kt | 10 ++++++++++ .../destinations/AttachmentDestination.kt | 9 ++++++++- 10 files changed, 64 insertions(+), 12 deletions(-) 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 060438fe785..24d30ae97eb 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 @@ -113,6 +113,7 @@ 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. 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 ab16848ae3b..2bdd2034c8d 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -987,12 +987,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 } @@ -3245,7 +3247,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/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 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;ZLjava/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/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 089e578fed8..2c6c84871c5 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 @@ -18,16 +18,23 @@ 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.uiutils.model.MimeType /** * Shows a preview for document attachments. * - * Text-based files (TXT, HTML) are displayed in-app using a WebView. - * All other document types (PDF, Office formats, etc.) are downloaded and opened with an external application. + * 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 @@ -48,6 +55,11 @@ public class DocumentAttachmentPreviewHandler(private val context: Context) : At } override fun handleAttachmentPreview(attachment: Attachment) { - DocumentAttachmentHandler.openAttachment(context, attachment) + @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/theme/ChatTheme.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt index 1ab2955d1ce..d0e79da7602 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 @@ -291,6 +291,10 @@ private val LocalMediaGalleryConfig = compositionLocalOf { * @param userPresence The user presence display configuration. * @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 quotedAttachmentFactories Quoted attachment factories that we provide. * @param reactionIconFactory Used to create an icon [Painter] for the given reaction type. @@ -359,8 +363,9 @@ public fun ChatTheme( componentFactory: ChatComponentFactory = DefaultChatComponentFactory(), attachmentFactories: List = StreamAttachmentFactories.defaults(), messageContentFactory: MessageContentFactory = MessageContentFactory.Deprecated, + useDocumentGView: Boolean = true, attachmentPreviewHandlers: List = - AttachmentPreviewHandler.defaultAttachmentHandlers(LocalContext.current), + AttachmentPreviewHandler.defaultAttachmentHandlers(LocalContext.current, useDocumentGView), quotedAttachmentFactories: List = StreamAttachmentFactories.defaultQuotedFactories(), reactionIconFactory: ReactionIconFactory = ReactionIconFactory.defaultFactory(), reactionPushEmojiFactory: ReactionPushEmojiFactory = ReactionPushEmojiFactory.defaultFactory(), diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt index 1f0f8ffb99b..30bcc85bdd1 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt @@ -280,6 +280,12 @@ public fun ChatClient.setMessageForReply(cid: String, message: Message?): Call { - DocumentAttachmentHandler.openAttachment(context, attachment) + @Suppress("DEPRECATION") + if (ChatUI.useDocumentGView) { + start(AttachmentDocumentActivity.getIntent(context, url)) + } else { + DocumentAttachmentHandler.openAttachment(context, attachment) + } } else -> { From 8006ac28b5cdcf540a5449120f2b448f71b8f73d Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 25 Mar 2026 17:08:13 +0100 Subject: [PATCH 07/11] Add file sharing cache handling. --- .../client/internal/file/StreamFileManager.kt | 43 +++++++ .../internal/file/StreamFileManagerTest.kt | 69 +++++++++++ .../internal/file/StreamShareFileManager.kt | 47 ++++--- .../file/StreamShareFileManagerTest.kt | 115 ++++++++++++++++++ 4 files changed, 260 insertions(+), 14 deletions(-) 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..8761a4b49f9 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,35 @@ 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 fun evictCacheFiles(context: Context, prefix: String, ttlMs: Long, maxSizeBytes: Long) { + val now = System.currentTimeMillis() + val files = listFilesInCache(context, prefix).toMutableList() + val expired = files.filter { now - it.lastModified() >= ttlMs } + expired.forEach { it.delete() } + files.removeAll(expired) + + files.sortBy { it.lastModified() } + var totalSize = files.sumOf { it.length() } + val iterator = files.iterator() + while (totalSize > maxSizeBytes && iterator.hasNext()) { + val oldest = iterator.next() + totalSize -= oldest.length() + oldest.delete() + } + } + /** * Clears the Stream cache directory. * @@ -357,6 +386,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/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-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 212e26fdebb..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 @@ -41,11 +41,14 @@ import java.io.InputStream 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. @@ -55,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)) @@ -86,12 +88,11 @@ public class StreamShareFileManager( 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.")) @@ -130,30 +131,48 @@ 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) - - private companion object { - private const val CACHE_FILE_PREFIX = "TMP" - private const val BITMAP_QUALITY = 90 - } } +/** + * 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]. */ 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..26b854c7dc7 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) From d6e962c66cee73c4471618ca0193d36e8f501115 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 25 Mar 2026 17:14:14 +0100 Subject: [PATCH 08/11] Add file sharing cache handling. --- .../stream_activity_text_file_preview.xml | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 stream-chat-android-ui-common/src/main/res/layout/stream_activity_text_file_preview.xml diff --git a/stream-chat-android-ui-common/src/main/res/layout/stream_activity_text_file_preview.xml b/stream-chat-android-ui-common/src/main/res/layout/stream_activity_text_file_preview.xml new file mode 100644 index 00000000000..5e861029286 --- /dev/null +++ b/stream-chat-android-ui-common/src/main/res/layout/stream_activity_text_file_preview.xml @@ -0,0 +1,44 @@ + + + + + + + + From 788a8124617b5a906d7ed05db9297472a9a8b646 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 26 Mar 2026 09:03:22 +0100 Subject: [PATCH 09/11] Remove CDNResponse.kt --- .../api/stream-chat-android-client.api | 22 +++----------- .../getstream/chat/android/client/cdn/CDN.kt | 6 ++-- .../chat/android/client/cdn/CDNResponse.kt | 30 ------------------- 3 files changed, 8 insertions(+), 50 deletions(-) delete mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDNResponse.kt 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 693f2e5fc3e..4b8e6d4e130 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -713,8 +713,10 @@ public final class io/getstream/chat/android/client/audio/WaveformExtractorKt { } public abstract interface class io/getstream/chat/android/client/cdn/CDN { - public abstract fun fileRequest (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun imageRequest (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + 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 { @@ -731,22 +733,6 @@ public final class io/getstream/chat/android/client/cdn/CDNRequest { public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/client/cdn/CDNResponse { - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()Ljava/util/Map; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;)Lio/getstream/chat/android/client/cdn/CDNResponse; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/cdn/CDNResponse;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/cdn/CDNResponse; - public fun equals (Ljava/lang/Object;)Z - public final fun getExtraData ()Ljava/util/Map; - public final fun getThumbnailUrl ()Ljava/lang/String; - 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/cdn/CDN.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDN.kt index 4ba530ec20d..27f223364ad 100644 --- 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 @@ -25,13 +25,15 @@ public interface CDN { * Creates a request for loading an image from the CDN. * * @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 + public suspend fun imageRequest(url: String): CDNRequest = CDNRequest(url) /** * Creates a request for loading a non-image file from the CDN. * * @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 + 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/CDNResponse.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDNResponse.kt deleted file mode 100644 index 84867b86df8..00000000000 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/cdn/CDNResponse.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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 response for a successfully uploaded file to a CDN. - * - * @param url Url of the uploaded file. - * @param thumbnailUrl Url of the thumbnail for the uploaded file. - * @param extraData Additional data related to the uploaded file. - */ -public data class CDNResponse( - val url: String, - val thumbnailUrl: String? = null, - val extraData: Map? = null, -) From b88483853bba62061acf32160eeae320463ae417 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 26 Mar 2026 10:16:37 +0100 Subject: [PATCH 10/11] Add tests --- .../client/cdn/internal/CDNDataSourceTest.kt | 223 ++++++++++++++++++ .../cdn/internal/CDNOkHttpInterceptorTest.kt | 119 ++++++++++ .../internal/CDNImageInterceptorTest.kt | 185 +++++++++++++++ .../file/StreamShareFileManagerTest.kt | 87 +++++++ 4 files changed, 614 insertions(+) create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cdn/internal/CDNDataSourceTest.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/cdn/internal/CDNOkHttpInterceptorTest.kt create mode 100644 stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/images/internal/CDNImageInterceptorTest.kt 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-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 26b854c7dc7..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 @@ -488,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 From 2dbdd22e494ce049b329b1fd409c4b3d6f44e90c Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 27 Mar 2026 11:49:58 +0100 Subject: [PATCH 11/11] PR remarks --- .../getstream/chat/android/client/cdn/CDN.kt | 11 +- .../cdn/internal/CDNDataSourceFactory.kt | 5 +- .../cdn/internal/CDNOkHttpInterceptor.kt | 5 +- .../client/internal/file/StreamFileManager.kt | 35 ++-- .../src/main/AndroidManifest.xml | 6 - .../documents/AttachmentDocumentActivity.java | 9 +- .../documents/DocumentAttachmentHandler.kt | 136 ++++++-------- .../documents/TextFilePreviewActivity.kt | 171 ------------------ .../stream_activity_text_file_preview.xml | 44 ----- .../AttachmentGalleryVideoPageFragment.kt | 1 - 10 files changed, 90 insertions(+), 333 deletions(-) delete mode 100644 stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/documents/TextFilePreviewActivity.kt delete mode 100644 stream-chat-android-ui-common/src/main/res/layout/stream_activity_text_file_preview.xml 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 index 27f223364ad..fec6c9b9b6c 100644 --- 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 @@ -18,11 +18,15 @@ 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 { /** - * Creates a request for loading an image from the 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. @@ -30,7 +34,10 @@ public interface CDN { public suspend fun imageRequest(url: String): CDNRequest = CDNRequest(url) /** - * Creates a request for loading a non-image file from the CDN. + * 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. 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 index 21564b0c5c5..69f609ef5f0 100644 --- 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 @@ -17,6 +17,7 @@ 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 @@ -68,7 +69,7 @@ private class CDNDataSource( } val url = dataSpec.uri.toString() val cdnRequest = try { - runBlocking(Dispatchers.IO) { + runBlocking { cdn.fileRequest(url) } } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { @@ -80,7 +81,7 @@ private class CDNDataSource( cdnRequest.headers?.let { putAll(it) } } val transformedSpec = dataSpec.buildUpon() - .setUri(Uri.parse(cdnRequest.url)) + .setUri(cdnRequest.url.toUri()) .setHttpRequestHeaders(mergedHeaders) .build() return upstream.open(transformedSpec) 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 index d60d2729eac..e8dfa6239af 100644 --- 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 @@ -19,7 +19,6 @@ 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.Dispatchers import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Response @@ -34,7 +33,7 @@ internal class CDNOkHttpInterceptor(private val cdn: CDN) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val originalUrl = chain.request().url.toString() val (url, headers) = try { - runBlocking(Dispatchers.IO) { + runBlocking { cdn.fileRequest(originalUrl) } } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { @@ -48,7 +47,7 @@ internal class CDNOkHttpInterceptor(private val cdn: CDN) : Interceptor { .url(url) .apply { headers?.forEach { - addHeader(it.key, it.value) + header(it.key, it.value) } } .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 8761a4b49f9..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 @@ -176,22 +176,29 @@ public class StreamFileManager { * @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 fun evictCacheFiles(context: Context, prefix: String, ttlMs: Long, maxSizeBytes: Long) { - val now = System.currentTimeMillis() - val files = listFilesInCache(context, prefix).toMutableList() - val expired = files.filter { now - it.lastModified() >= ttlMs } - expired.forEach { it.delete() } - files.removeAll(expired) + 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() - totalSize -= oldest.length() - oldest.delete() + 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. diff --git a/stream-chat-android-ui-common/src/main/AndroidManifest.xml b/stream-chat-android-ui-common/src/main/AndroidManifest.xml index c14de50de92..e2403e729fa 100644 --- a/stream-chat-android-ui-common/src/main/AndroidManifest.xml +++ b/stream-chat-android-ui-common/src/main/AndroidManifest.xml @@ -46,12 +46,6 @@ android:exported="false" android:theme="@style/Theme.AppCompat.DayNight.NoActionBar" /> - - SMALL_FILE_THRESHOLD + val rootView = context.findActivity()?.findViewById(android.R.id.content) lifecycleOwner.lifecycleScope.launch { - if (isLargeFile) { - downloadWithProgress(context, attachment) - } else { - downloadSilently(context, attachment) - } - } - } - - private suspend fun downloadSilently(context: Context, attachment: Attachment) { - shareFileManager.writeAttachmentToShareableFile(context, attachment) - .onSuccess { uri -> openFileUri(context, uri, attachment) } - .onError { error -> - logger.e { "[downloadSilently] 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 suspend fun downloadWithProgress(context: Context, attachment: Attachment) { - val snackbar = context.findActivity() - ?.findViewById(android.R.id.content) - ?.let { rootView -> - Snackbar.make( - rootView, - context.getString( - R.string.stream_ui_message_list_attachment_downloading, - MediaStringUtil.convertFileSizeByteCount(0L), - MediaStringUtil.convertFileSizeByteCount(attachment.fileSize.toLong()), - ), - Snackbar.LENGTH_INDEFINITE, - ).also { it.show() } + 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, + 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 ?: "", ) - sb.view.post { sb.setText(text) } + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() } - }, - ) - .onSuccess { uri -> - snackbar?.dismiss() - openFileUri(context, uri, attachment) - } - .onError { error -> - snackbar?.dismiss() - logger.e { "[downloadWithProgress] 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) { diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/documents/TextFilePreviewActivity.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/documents/TextFilePreviewActivity.kt deleted file mode 100644 index c28bc0acb30..00000000000 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/documents/TextFilePreviewActivity.kt +++ /dev/null @@ -1,171 +0,0 @@ -/* - * 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.feature.documents - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.view.View -import android.webkit.WebView -import android.widget.ProgressBar -import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.lifecycle.lifecycleScope -import io.getstream.chat.android.client.ChatClient -import io.getstream.chat.android.models.Attachment -import io.getstream.chat.android.ui.common.R -import io.getstream.chat.android.ui.common.internal.file.StreamShareFileManager -import io.getstream.chat.android.uiutils.model.MimeType -import io.getstream.log.taggedLogger -import io.getstream.result.onSuccessSuspend -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * Activity for previewing text-based file attachments (TXT, HTML) in a WebView. - * - * The file is downloaded using [StreamShareFileManager] (which applies CDN transformations - * via [ChatClient.downloadFile]), cached locally, and then rendered in a WebView. - */ -internal class TextFilePreviewActivity : AppCompatActivity() { - - private val logger by taggedLogger("Chat:TextFilePreviewActivity") - private val shareFileManager = StreamShareFileManager() - - private lateinit var webView: WebView - private lateinit var progressBar: ProgressBar - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (!ChatClient.isInitialized) { - finish() - return - } - - setContentView(R.layout.stream_activity_text_file_preview) - - val rootView = findViewById(R.id.rootView) - webView = findViewById(R.id.webView) - progressBar = findViewById(R.id.progressBar) - - setupEdgeToEdge(rootView) - - val url = intent.getStringExtra(KEY_URL) - val mimeType = intent.getStringExtra(KEY_MIME_TYPE) - val fileName = intent.getStringExtra(KEY_FILE_NAME) - - if (url.isNullOrEmpty()) { - logger.e { "[onCreate] URL is null or empty. Finishing activity." } - finish() - return - } - - loadFile(url, mimeType, fileName) - } - - private fun setupEdgeToEdge(rootView: View) { - ViewCompat.setOnApplyWindowInsetsListener(rootView) { v, insets -> - val systemBarInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBarInsets.left, systemBarInsets.top, systemBarInsets.right, systemBarInsets.bottom) - WindowInsetsCompat.CONSUMED - } - } - - @Suppress("TooGenericExceptionCaught") - private fun loadFile(url: String, mimeType: String?, fileName: String?) { - progressBar.visibility = View.VISIBLE - webView.visibility = View.GONE - - val attachment = Attachment( - assetUrl = url, - mimeType = mimeType, - name = fileName ?: "file", - ) - - lifecycleScope.launch { - val result = shareFileManager.writeAttachmentToShareableFile(this@TextFilePreviewActivity, attachment) - result - .onSuccessSuspend { uri -> - try { - val content = withContext(Dispatchers.IO) { - contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } - } - if (content == null) { - showErrorAndFinish(fileName) - return@onSuccessSuspend - } - displayContent(content, mimeType) - } catch (e: Exception) { - logger.e(e) { "[loadFile] Failed to read cached file content." } - showErrorAndFinish(fileName) - } - } - .onError { - logger.e { "[loadFile] Failed to download file: ${it.message}" } - showErrorAndFinish(fileName) - } - } - } - - private fun displayContent(content: String, mimeType: String?) { - progressBar.visibility = View.GONE - webView.visibility = View.VISIBLE - - val isHtml = mimeType == MimeType.MIME_TYPE_HTML - if (isHtml) { - webView.loadDataWithBaseURL(null, content, "text/html", "utf-8", null) - } else { - val wrappedContent = "
" +
-                "$content
" - webView.loadDataWithBaseURL(null, wrappedContent, "text/html", "utf-8", null) - } - } - - private fun showErrorAndFinish(fileName: String?) { - val text = getString( - R.string.stream_ui_message_list_attachment_download_failed, - fileName ?: "", - ) - Toast.makeText(this, text, Toast.LENGTH_SHORT).show() - finish() - } - - internal companion object { - private const val KEY_URL = "url" - private const val KEY_MIME_TYPE = "mimeType" - private const val KEY_FILE_NAME = "fileName" - - /** - * Creates an [Intent] to launch [TextFilePreviewActivity]. - * - * @param context The context to create the intent from. - * @param url The URL of the file to preview. - * @param mimeType The MIME type of the file. - * @param fileName The name of the file. - */ - fun getIntent(context: Context, url: String, mimeType: String?, fileName: String?): Intent { - return Intent(context, TextFilePreviewActivity::class.java).apply { - putExtra(KEY_URL, url) - putExtra(KEY_MIME_TYPE, mimeType) - putExtra(KEY_FILE_NAME, fileName) - } - } - } -} diff --git a/stream-chat-android-ui-common/src/main/res/layout/stream_activity_text_file_preview.xml b/stream-chat-android-ui-common/src/main/res/layout/stream_activity_text_file_preview.xml deleted file mode 100644 index 5e861029286..00000000000 --- a/stream-chat-android-ui-common/src/main/res/layout/stream_activity_text_file_preview.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - 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 9f9f9b1cf49..e4a1912fdfb 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 @@ -38,7 +38,6 @@ import androidx.media3.exoplayer.source.MediaSource 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.models.Attachment import io.getstream.chat.android.ui.ChatUI import io.getstream.chat.android.ui.R import io.getstream.chat.android.ui.databinding.StreamUiItemAttachmentGalleryVideoBinding