Skip to content

Commit 396f29b

Browse files
nuno-vieiratestableappleStream-SDK-BotStream Bot
authored
Unified CDN & Media Loading Architecture (v5) (#1399)
* Replace old ImageCDN/FileCDN/ImageLoading with unified CDN/ImageLoader/VideoLoader Remove old protocols duplicated from StreamChatUI: - ImageCDN, StreamImageCDN, FileCDN, DefaultFileCDN - ImageLoading, NukeImageLoader, VideoPreviewLoader Replace with unified types from StreamChatCommonUI: - Utils.imageLoader now uses ImageLoader (StreamImageLoader) - Utils.videoLoader now uses VideoLoader (StreamVideoLoader) - CDN from StreamChat handles URL signing for files/images - Update StreamAsyncImage, LazyImageExtensions, and Utils * Update view models and views to use new CDN/loader APIs - FileAttachmentPreview and MediaViewer use CDN.fileRequest instead of FileCDN - MediaAttachment uses ImageLoader and VideoLoader directly - MessageComposerViewModel uses simplified ImageLoader API - MoreChannelActionsViewModel drops imageCDN dependency * Update test mocks and tests for new loader protocols - Replace ImageLoader_Mock to conform to ImageLoader - Replace VideoPreviewLoader_Mock with VideoLoader_Mock - Update test setup to use videoLoader instead of videoPreviewLoader - Remove ImageCDN_Tests (replaced by StreamCDN_Tests in stream-chat-swift) - Update VideoPreviewLoader_Tests for new VideoLoader protocol * Use local stream-chat-swift dependency for development Point Package.swift and xcodeproj to local ../stream-chat-swift path for development and testing of the unified CDN refactoring. * Add NukeImageDownloader and update Utils for ImageDownloading protocol Provide NukeImageDownloader as the ImageDownloading backend for SwiftUI, keeping Nuke vendored in this repo while sharing loader logic via CommonUI. Update Utils to pass NukeImageDownloader to StreamImageLoader. * Apply CDN transformations in LazyImage extensions Restore CDN URL transformation (headers, signing, caching key) in the LazyImage convenience initializers. The CDN completion is captured synchronously -- for StreamCDN (which completes inline) the full CDNRequest is used; async CDNs fall back to the raw URL gracefully. * Add StreamLazyImage wrappers with CDN support Replace direct LazyImage usage with StreamLazyImage and StreamLazyContentImage wrappers that resolve CDN transformations asynchronously via @injected dependencies before passing the request to Nuke's LazyImage. * Rename NukeImageDownloader and NukeImageProcessor to Stream prefix Rename to StreamImageDownloader and StreamImageProcessor to decouple public API naming from the Nuke dependency. * Unify StreamAsyncImage as the single image-loading view Merge StreamLazyContentImage, StreamLazyImage, and StreamAsyncImage into a single StreamAsyncImage view that handles all image loading. - CDN transformations resolved async via @injected dependencies - StreamAsyncImageResult carries UIImage, isAnimated flag, and raw animated data for GIF rendering without exposing Nuke types - Nuke coupling isolated to a single private method (loadWithNuke) for easy replacement when removing the Nuke dependency - Delete LazyImageExtensions.swift (no longer needed) - Update all call sites: avatars, link previews, giphy attachments - AnimatedGifView now takes raw Data instead of Nuke ImageContainer * Fix tests * Remove maxAttachmentSize from CDNStorage protocol * Introduce options parameters to the CDNStorage and CDNRequester * Add remote dependency * Fix CGSize * Re-record snapshot for date overlay test on iOS 26.2 * Re-record voice recording snapshot tests for iOS 26.2 * Fix flaky date overlay snapshot test by using same-day timestamps * Fix swiftformat * Fix attachment downloads not using CDN requester for URL signing and custom headers * Adopt unified MediaLoader protocol replacing separate ImageLoader and VideoLoader - Replace imageLoader + videoLoader with single mediaLoader in Utils - Update all views and view models to use utils.mediaLoader - Rename loadPreview to loadVideoPreview for clarity - Update test mocks and tests for MediaLoader conformance - Update Package.resolved to latest LLC commit * Adopt MediaLoader wrapper result types and async videoAsset - Update call sites to use MediaLoaderImage, MediaLoaderVideoPreview - Update all test mocks to conform to new MediaLoader signatures - Update SPM dependency to latest LLC commit * Access cdnRequester through config instead of ChatClient Aligns with the removal of cdnRequester/cdnStorage from ChatClient. All SwiftUI call sites now use chatClient.config.cdnRequester. * Minor cleanups * Use commit instead of branch * Use better task identity in StreamAsyncImage * Fix type mismatch in VideoPreviewLoader tests Change receivedImage (UIImage?) to receivedPreview (MediaLoaderVideoPreview?) to match the actual return type of result.get() from loadVideoPreview. * Address PR review comments - Merge ImageLoader_Mock and VideoLoader_Mock into a single MediaLoader_Mock, consolidating all tracking properties - Extract Nuke-specific image loading logic from StreamAsyncImage into NukeImageLoader internal helper - Update all test references to use MediaLoader_Mock * Make sure cache is always used first * Make initial phase success in StreamAsyncImage in case the image is available in cache * Remove Image Processor from SwiftUI since it is not used now * Fix MediaLoader tests naming * Fix swiftformat * Remove throwaway AVPlayer in StreamVideoPlayer The code was creating and starting an AVPlayer before asking avPlayerProvider for another player, doubling setup/network work and briefly swapping the active player mid-playback. Now only the provider-produced player is used. * Pass CDN headers to AVPlayerProvider for authenticated playback Add headers parameter to AVPlayerProvider.player(for:headers:completion:) so CDN authentication headers from cdnRequester.fileRequest() are propagated to the player. DefaultAVPlayerProvider uses AVURLAsset with the headers when available. A default extension preserves backwards compatibility for existing conformers. * Add unit tests for NukeImageLoader Cover the key paths: cachedResult returns nil when no key is stored, loadImage calls CDN requester with correct URL, onCacheMiss fires on cache miss, cache hit after CDN transform skips onCacheMiss, and cachingKeyMap persists across load + cachedResult calls. * Add unit tests for StreamImageDownloader Verify error handling for invalid URLs, and that all ImageDownloadingOptions combinations (headers, cachingKey, resize, zero resize) are accepted without crash. * Add fallback branch tests for maxAttachmentSize Cover the cases where appSettings is nil, server limit is zero, and server limit is negative — all should return the fallbackSize. * Add loadImage and loadImages tests for StreamMediaLoader Cover loadImage success, nil URL error, error propagation from downloader, loadImages returning correct count, and loadImages using placeholders on failure. * Unify video loading via MediaLoader and simplify AVPlayerProvider Replace manual CDN + AVPlayerProvider flow in StreamVideoPlayer with MediaLoader.loadVideoAsset(). Simplify AVPlayerProvider to accept MediaLoaderVideoAsset instead of raw URL + headers. Rename videoAsset to loadVideoAsset for consistency with other MediaLoader methods. * Make media loader mock the only mock * Make StreamImageDownloader final Sendable * Fix swiftformat * Update LLC dependency to latest commit * Rename LazyImageExtensions_Tests.swift to StreamAsyncImage_Tests.swift Align file name with the test class StreamAsyncImage_Tests it contains. Also rename test_mediaAttachment_generateThumbnail_callsImageLoader to reference MediaLoader instead of the removed ImageLoader. * Fix StreamAsyncImage doc comment referencing non-existent ImageLoader Update the doc comment to reference MediaLoader and CDNRequester which are the actual protocols used by this view. * Update LLC commit * Use attachment-based video preview loading Update MediaAttachment and tests to use the attachment-based loadVideoPreview(with:) instead of the removed URL variant, enabling thumbnail URL optimization for remote video previews. * Update LLC dependency and add loadVideoAsset to test conformers Update to LLC commit that removes the default loadVideoAsset implementation from MediaLoader, requiring all conformers to provide explicit implementations. * Use Utils.cdnRequester instead of ChatClientConfig Move CDN requester access from chatClient.config.cdnRequester to utils.cdnRequester, matching the UIKit pattern where it lives in Components. The download attachment flow now resolves CDN URLs at the UI layer before calling the core download function. * Fix cdnrequester tests * Remove unecessary stream image downloader tests * Add NukeImageLoader_Tests to project file * Minor fixes * Minor review fixes * Fix load preview mocks * Update LLC dependency * Remove unecessary test * Fix media viewer tests * [CI] Print verbose logs on old xcode job * Revert "[CI] Print verbose logs on old xcode job" This reverts commit 6ee5138. * Bump Xcode version for old Xcode job * Revert "Bump Xcode version for old Xcode job" This reverts commit 286c240. * Update dependency to develop * Update dependency again * [CI] Snapshots (#1420) Co-authored-by: Stream Bot <ci@getstream.io> --------- Co-authored-by: Alexey Alter-Pesotskiy <alex@testableapple.com> Co-authored-by: Stream SDK Bot <60655709+Stream-SDK-Bot@users.noreply.github.com> Co-authored-by: Stream Bot <ci@getstream.io>
1 parent 67b592c commit 396f29b

File tree

63 files changed

+1227
-1541
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

63 files changed

+1227
-1541
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
1515
- Redesign the thread replies divider in the message replies list [#1354](https://github.com/GetStream/stream-chat-swiftui/pull/1354)
1616

1717
### 🐞 Fixed
18+
- Fix attachment downloads not using CDN requester for URL signing and custom headers [#1399](https://github.com/GetStream/stream-chat-swiftui/pull/1399)
1819
- Fix empty share sheet when sharing a video from the full-screen media viewer [#1418](https://github.com/GetStream/stream-chat-swiftui/pull/1418)
1920
- Fix swipe-to-reply icon layout for outgoing messages and RTL [#1402](https://github.com/GetStream/stream-chat-swiftui/pull/1402)
2021
- Fix unwanted border on the Edit button in Channel Info [#1402](https://github.com/GetStream/stream-chat-swiftui/pull/1402)

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ let package = Package(
1616
)
1717
],
1818
dependencies: [
19-
.package(url: "https://github.com/GetStream/stream-chat-swift.git", from: "5.0.0-beta")
19+
.package(url: "https://github.com/GetStream/stream-chat-swift.git", revision: "ca835ce6621c027a6f97cf37ffe30d18d4d59be6")
2020
],
2121
targets: [
2222
.target(

Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/MediaAttachmentsViewModel.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,9 @@ public final class MediaItem: Identifiable {
129129

130130
@MainActor public var mediaAttachment: MediaAttachment? {
131131
if let videoAttachment {
132-
return MediaAttachment(url: videoAttachment.videoURL, type: .video)
132+
return MediaAttachment(from: videoAttachment)
133133
} else if let imageAttachment {
134-
return MediaAttachment(url: imageAttachment.imageURL, type: .image)
134+
return MediaAttachment(from: imageAttachment)
135135
}
136136
return nil
137137
}

Sources/StreamChatSwiftUI/ChatChannelList/MoreChannelActionsViewModel.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@ import UIKit
1717

1818
/// Private vars.
1919
private lazy var channelNameFormatter = utils.channelNameFormatter
20-
private lazy var imageLoader = utils.imageLoader
21-
private lazy var imageCDN = utils.imageCDN
20+
private lazy var mediaLoader = utils.mediaLoader
2221

2322
/// Published vars.
2423
@Published var channelActions: [ChannelAction]

Sources/StreamChatSwiftUI/ChatComposer/AttachmentPicker/AttachmentMediaPicker/PhotoAssetLoader.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import SwiftUI
1010
/// Helper class that loads assets from the photo library.
1111
@MainActor public class PhotoAssetLoader: NSObject, ObservableObject {
1212
@Injected(\.chatClient) private var chatClient
13+
@Injected(\.utils) private var utils
1314

1415
@Published var loadedImages = [String: UIImage]()
1516

@@ -57,7 +58,7 @@ import SwiftUI
5758
_ = url?.startAccessingSecurityScopedResource()
5859
if let assetURL = url,
5960
let file = try? AttachmentFile(url: assetURL),
60-
file.size >= chatClient.maxAttachmentSize(for: assetURL) {
61+
file.size >= chatClient.maxAttachmentSize(for: assetURL, fallbackSize: utils.composerConfig.maxAttachmentSize) {
6162
return true
6263
} else {
6364
return false

Sources/StreamChatSwiftUI/ChatComposer/ComposerConfig.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ public final class ComposerConfig {
2323
public var adjustMessageOnSend: (String) -> (String)
2424
public var adjustMessageOnRead: (String) -> (String)
2525

26+
/// The fallback maximum attachment size in bytes when the server does not provide one.
27+
/// The default value is 100 MB.
28+
public var maxAttachmentSize: Int64
29+
2630
public init(
2731
isVoiceRecordingEnabled: Bool = true,
2832
isVoiceRecordingAutoSendEnabled: Bool = false,
@@ -33,7 +37,8 @@ public final class ComposerConfig {
3337
gallerySupportedTypes: GallerySupportedTypes = .imagesAndVideo,
3438
maxGalleryAssetsCount: Int? = nil,
3539
adjustMessageOnSend: @escaping (String) -> (String) = { $0 },
36-
adjustMessageOnRead: @escaping (String) -> (String) = { $0 }
40+
adjustMessageOnRead: @escaping (String) -> (String) = { $0 },
41+
maxAttachmentSize: Int64 = 100 * 1024 * 1024
3742
) {
3843
self.inputViewMinHeight = inputViewMinHeight
3944
self.inputViewMaxHeight = inputViewMaxHeight
@@ -45,6 +50,7 @@ public final class ComposerConfig {
4550
self.maxGalleryAssetsCount = maxGalleryAssetsCount
4651
self.isVoiceRecordingEnabled = isVoiceRecordingEnabled
4752
self.isVoiceRecordingAutoSendEnabled = isVoiceRecordingAutoSendEnabled
53+
self.maxAttachmentSize = maxAttachmentSize
4854
}
4955
}
5056

Sources/StreamChatSwiftUI/ChatComposer/MessageComposerViewModel.swift

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -944,7 +944,7 @@ import SwiftUI
944944

945945
do {
946946
let fileSize = try AttachmentFile(url: url).size
947-
let canAdd = fileSize < chatClient.maxAttachmentSize(for: url)
947+
let canAdd = fileSize <= chatClient.maxAttachmentSize(for: url, fallbackSize: utils.composerConfig.maxAttachmentSize)
948948
attachmentSizeExceeded = !canAdd
949949
return canAdd
950950
} catch {
@@ -1019,6 +1019,7 @@ final class FileAddedAsset {
10191019

10201020
// The converter responsible to map attachments to assets and vice versa.
10211021
@MainActor class MessageAttachmentsConverter {
1022+
@Injected(\.chatClient) private var chatClient
10221023
@Injected(\.utils) var utils
10231024

10241025
/// Converts the added assets to payloads.
@@ -1200,13 +1201,11 @@ final class FileAddedAsset {
12001201
return
12011202
}
12021203

1203-
utils.imageLoader.loadImage(
1204+
utils.mediaLoader.loadImage(
12041205
url: imageAttachment.imageURL,
1205-
imageCDN: utils.imageCDN,
1206-
resize: false,
1207-
preferredSize: nil
1206+
options: ImageLoadOptions(resize: nil, cdnRequester: utils.cdnRequester)
12081207
) { result in
1209-
if let image = try? result.get() {
1208+
if let image = (try? result.get())?.image {
12101209
let imageAsset = AddedAsset(
12111210
image: image,
12121211
id: imageAttachment.id.rawValue,

Sources/StreamChatSwiftUI/ChatMessageList/FileAttachmentPreview.swift

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,7 @@ public struct FileAttachmentPreview: View {
1414
@Injected(\.images) private var images
1515
@Injected(\.utils) private var utils
1616

17-
private var fileCDN: FileCDN {
18-
utils.fileCDN
19-
}
17+
private var cdnRequester: CDNRequester { utils.cdnRequester }
2018

2119
let attachment: ChatMessageFileAttachment
2220

@@ -62,12 +60,14 @@ public struct FileAttachmentPreview: View {
6260
}
6361
}
6462
.onAppear {
65-
fileCDN.adjustedURL(for: url) { result in
66-
switch result {
67-
case let .success(url):
68-
adjustedUrl = url
69-
case let .failure(error):
70-
self.error = error
63+
cdnRequester.fileRequest(for: url, options: .init()) { result in
64+
Task { @MainActor in
65+
switch result {
66+
case let .success(cdnRequest):
67+
adjustedUrl = cdnRequest.url
68+
case let .failure(error):
69+
self.error = error
70+
}
7171
}
7272
}
7373
}

Sources/StreamChatSwiftUI/ChatMessageList/FileAttachmentView.swift

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ struct DownloadShareAttachmentView<Payload: DownloadableAttachmentPayload>: View
232232
@State private var shareButtonShown: Bool
233233

234234
var attachment: ChatMessageAttachment<Payload>
235-
235+
236236
init(attachment: ChatMessageAttachment<Payload>) {
237237
self.attachment = attachment
238238
let downloadButtonShown: Bool = (attachment.uploadingState == nil || attachment.uploadingState?.state == .uploaded) && attachment.downloadingState == nil
@@ -279,12 +279,20 @@ struct DownloadShareAttachmentView<Payload: DownloadableAttachmentPayload>: View
279279
let messageId = attachment.id.messageId
280280
let cid = attachment.id.cid
281281
let messageController = chatClient.messageController(cid: cid, messageId: messageId)
282-
messageController.downloadAttachment(attachment) { result in
283-
if case .failure(let error) = result {
284-
log.error("Error downloading attachment \(error.localizedDescription)")
285-
} else {
286-
downloadButtonShown = false
287-
shareButtonShown = true
282+
let cdnRequester = InjectedValues[\.utils].cdnRequester
283+
cdnRequester.fileRequest(for: attachment.remoteURL, options: .init()) { result in
284+
switch result {
285+
case let .success(cdnRequest):
286+
messageController.downloadAttachment(attachment, remoteURL: cdnRequest.url) { result in
287+
if case let .failure(error) = result {
288+
log.error("Error downloading attachment: \(error.localizedDescription)")
289+
} else {
290+
downloadButtonShown = false
291+
shareButtonShown = true
292+
}
293+
}
294+
case let .failure(error):
295+
log.error("Error resolving CDN URL: \(error.localizedDescription)")
288296
}
289297
}
290298
}

Sources/StreamChatSwiftUI/ChatMessageList/Gallery/MediaViewer.swift

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import AVKit
66
import StreamChat
7+
import StreamChatCommonUI
78
import SwiftUI
89

910
/// View used for displaying image attachments in a gallery.
@@ -316,19 +317,13 @@ public struct MediaViewerToolbarModifier: ViewModifier {
316317

317318
struct StreamVideoPlayer: View {
318319
@Injected(\.utils) private var utils
319-
320-
private var fileCDN: FileCDN {
321-
utils.fileCDN
322-
}
323-
324-
private var avPlayerProvider: AVPlayerProvider {
325-
utils.avPlayerProvider
326-
}
320+
@Injected(\.chatClient) private var chatClient
327321

328322
let url: URL
329323

330324
@State var avPlayer: AVPlayer?
331325
@State var error: Error?
326+
@State private var isVisible = false
332327

333328
init(url: URL) {
334329
self.url = url
@@ -342,17 +337,20 @@ struct StreamVideoPlayer: View {
342337
}
343338
}
344339
.onAppear {
340+
isVisible = true
345341
guard avPlayer == nil else {
346342
avPlayer?.play()
347343
return
348344
}
349-
fileCDN.adjustedURL(for: url) { result in
345+
utils.mediaLoader.loadVideoAsset(
346+
at: url,
347+
options: VideoLoadOptions(cdnRequester: utils.cdnRequester)
348+
) { result in
349+
guard isVisible else { return }
350350
switch result {
351-
case let .success(url):
352-
avPlayer = AVPlayer(url: url)
353-
try? AVAudioSession.sharedInstance().setCategory(.playback, options: [])
354-
avPlayer?.play()
355-
self.avPlayerProvider.player(for: url) { result in
351+
case let .success(videoAsset):
352+
utils.avPlayerProvider.player(from: videoAsset) { result in
353+
guard isVisible else { return }
356354
switch result {
357355
case let .success(player):
358356
self.avPlayer = player
@@ -368,6 +366,7 @@ struct StreamVideoPlayer: View {
368366
}
369367
}
370368
.onDisappear {
369+
isVisible = false
371370
avPlayer?.pause()
372371
}
373372
}

0 commit comments

Comments
 (0)