Skip to content

Commit

Permalink
Download and share file attachments
Browse files Browse the repository at this point in the history
  • Loading branch information
laevandus committed Aug 23, 2024
1 parent 29ff68b commit ee766d8
Show file tree
Hide file tree
Showing 43 changed files with 464 additions and 45 deletions.
8 changes: 8 additions & 0 deletions DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,14 @@ final class DemoChatChannelListRouter: ChatChannelListRouter {
self?.showChannel(for: cid, at: message?.id)
}
}
}),
.init(title: "Delete Downloaded Attachments", handler: { _ in
guard FileManager.default.fileExists(atPath: URL.streamAttachmentDownloadsDirectory.path) else { return }
do {
try FileManager.default.removeItem(at: .streamAttachmentDownloadsDirectory)
} catch {
log.debug("Failed to delete downloaded attachments with error: \(error.localizedDescription)")
}
})
])
}
Expand Down
29 changes: 28 additions & 1 deletion Sources/StreamChat/APIClient/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ class APIClient {
/// Used to queue requests that happen while we are offline
var queueOfflineRequest: QueueOfflineRequestBlock?

/// The attachment downloader.
let attachmentDownloader: AttachmentDownloader

/// The attachment uploader.
let attachmentUploader: AttachmentUploader

Expand Down Expand Up @@ -59,11 +62,13 @@ class APIClient {
sessionConfiguration: URLSessionConfiguration,
requestEncoder: RequestEncoder,
requestDecoder: RequestDecoder,
attachmentDownloader: AttachmentDownloader,
attachmentUploader: AttachmentUploader
) {
encoder = requestEncoder
decoder = requestDecoder
session = URLSession(configuration: sessionConfiguration)
self.attachmentDownloader = attachmentDownloader
self.attachmentUploader = attachmentUploader
}

Expand Down Expand Up @@ -288,7 +293,29 @@ class APIClient {
// We only retry transient errors like connectivity stuff or HTTP 5xx errors
ClientError.isEphemeral(error: error)
}


func downloadAttachment(_ attachment: ChatMessageFileAttachment, to localURL: URL, progress: ((Double) -> Void)?) async throws {
try await withCheckedThrowingContinuation { continuation in
let downloadOperation = AsyncOperation(maxRetries: maximumRequestRetries) { [weak self] operation, done in
self?.attachmentDownloader.download(attachment, to: localURL, progress: progress) { error in
if let error, self?.isConnectionError(error) == true {
// Do not retry unless its a connection problem and we still have retries left
if operation.canRetry {
done(.retry)
} else {
continuation.resume(with: error)
done(.continue)
}
} else {
continuation.resume(with: error)
done(.continue)
}
}
}
operationQueue.addOperation(downloadOperation)
}
}

func uploadAttachment(
_ attachment: AnyChatMessageAttachment,
progress: ((Double) -> Void)?,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// Copyright © 2024 Stream.io Inc. All rights reserved.
//

import Foundation

/// The component responsible for downloading files.
public protocol AttachmentDownloader {
/// Downloads a file attachment, and returns the local URL.
/// - Parameters:
/// - attachment: A file attachment.
/// - localURL: The destination URL of the download.
/// - progress: The progress of the download.
/// - completion: The callback with an error if failure occured.
func download(
_ attachment: ChatMessageFileAttachment,
to localURL: URL,
progress: ((Double) -> Void)?,
completion: @escaping (Error?) -> Void
)
}

final class StreamAttachmentDownloader: AttachmentDownloader {
private let session: URLSession
@Atomic private var taskProgressObservers: [Int: NSKeyValueObservation] = [:]

init(sessionConfiguration: URLSessionConfiguration) {
session = URLSession(configuration: sessionConfiguration)
}

func download(
_ attachment: ChatMessageFileAttachment,
to localURL: URL,
progress: ((Double) -> Void)?,
completion: @escaping (Error?) -> Void
) {
let request = URLRequest(url: attachment.assetURL)
let task = session.downloadTask(with: request) { temporaryURL, _, downloadError in
if let downloadError {
completion(downloadError)
} else if let temporaryURL {
do {
try FileManager.default.createDirectory(at: localURL.deletingLastPathComponent(), withIntermediateDirectories: true)
if FileManager.default.fileExists(atPath: localURL.path) {
try FileManager.default.removeItem(at: localURL)
}
try FileManager.default.moveItem(at: temporaryURL, to: localURL)
completion(nil)
} catch {
let clientError = ClientError.AttachmentDownloading(
id: attachment.id,
reason: error.localizedDescription
)
completion(clientError)
}
}
}
if let progressHandler = progress {
let taskID = task.taskIdentifier
_taskProgressObservers.mutate { observers in
observers[taskID] = task.progress.observe(\.fractionCompleted, options: [.initial]) { [weak self] progress, _ in
progressHandler(progress.fractionCompleted)
if progress.isFinished || progress.isCancelled {
self?._taskProgressObservers.mutate { observers in
observers[taskID]?.invalidate()
observers[taskID] = nil
}
}
}
}
}
task.resume()
}
}
10 changes: 2 additions & 8 deletions Sources/StreamChat/ChatClient+Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,9 @@ extension ChatClient {
_ sessionConfiguration: URLSessionConfiguration,
_ requestEncoder: RequestEncoder,
_ requestDecoder: RequestDecoder,
_ attachmentDownloader: AttachmentDownloader,
_ attachmentUploader: AttachmentUploader
) -> APIClient = {
APIClient(
sessionConfiguration: $0,
requestEncoder: $1,
requestDecoder: $2,
attachmentUploader: $3
)
}
) -> APIClient = APIClient.init

var webSocketClientBuilder: ((
_ sessionConfiguration: URLSessionConfiguration,
Expand Down
2 changes: 2 additions & 0 deletions Sources/StreamChat/ChatClientFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class ChatClientFactory {
encoder: RequestEncoder,
urlSessionConfiguration: URLSessionConfiguration
) -> APIClient {
let attachmentDownloader = StreamAttachmentDownloader(sessionConfiguration: urlSessionConfiguration)
let decoder = environment.requestDecoderBuilder()
let attachmentUploader = config.customAttachmentUploader ?? StreamAttachmentUploader(
cdnClient: config.customCDNClient ?? StreamCDNClient(
Expand All @@ -57,6 +58,7 @@ class ChatClientFactory {
urlSessionConfiguration,
encoder,
decoder,
attachmentDownloader,
attachmentUploader
)
return apiClient
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,19 @@ public class ChatMessageController: DataController, DelegateCallable, DataStoreP
}
}
}


/// Downloads the attachment for the id and stores it on the device.
/// - Parameters:
/// - id: The id of the file attachment.
/// - completion: A completion block with a local URL of the downloaded file.
public func downloadAttachment(with id: AttachmentId, completion: @escaping (Error?) -> Void) {
Task.run({ [messageUpdater] in
try await messageUpdater.downloadAttachment(with: id)
}, completion: { error in
self.callback { completion(error) }
})
}

/// Updates local state of attachment with provided `id` to be enqueued by attachment uploader.
/// - Parameters:
/// - id: The attachment identifier.
Expand Down
63 changes: 61 additions & 2 deletions Sources/StreamChat/Database/DTOs/AttachmentDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ class AttachmentDTO: NSManagedObject {

/// An attachment local url.
@NSManaged var localURL: URL?

/// An attachment local relative path used for storing downloaded attachments.
@NSManaged var localRelativePath: String?

/// An attachment raw `Data`.
@NSManaged var data: Data

Expand Down Expand Up @@ -91,6 +95,19 @@ class AttachmentDTO: NSManagedObject {
}
}

extension AttachmentDTO: EphemeralValuesContainer {
func resetEphemeralValues() {
switch localState {
case .downloading, .downloadingFailed:
localState = nil
localURL = nil
localRelativePath = nil
default:
break
}
}
}

extension NSManagedObjectContext: AttachmentDatabaseSession {
func attachment(id: AttachmentId) -> AttachmentDTO? {
AttachmentDTO.load(id: id, context: self)
Expand All @@ -110,8 +127,12 @@ extension NSManagedObjectContext: AttachmentDatabaseSession {
dto.data = try JSONEncoder.default.encode(payload.payload)
dto.message = messageDTO

dto.localURL = nil
dto.localState = nil
// Keep local state for downloaded attachments
if dto.localState?.isUploading == true {
dto.localURL = nil
dto.localRelativePath = nil
dto.localState = nil
}

return dto
}
Expand Down Expand Up @@ -143,11 +164,26 @@ extension NSManagedObjectContext: AttachmentDatabaseSession {
}

private extension AttachmentDTO {
var downloadingState: AttachmentDownloadingState? {
guard let localRelativePath, !localRelativePath.isEmpty else { return nil }
guard let localState, localState.isDownloading else { return nil }
// Only file attachments can be downloaded.
guard let filePayload = try? JSONDecoder.stream.decode(FileAttachmentPayload.self, from: data) else { return nil }
// Local URL exists only when the state is downloaded
let localURL = ChatMessageFileAttachment.localStorageURL(forRelativePath: localRelativePath)
return AttachmentDownloadingState(
localFileURL: localURL,
state: localState,
file: filePayload.file
)
}

var uploadingState: AttachmentUploadingState? {
guard
let localURL = localURL,
let localState = localState
else { return nil }
guard localState.isUploading else { return nil }

do {
return .init(
Expand Down Expand Up @@ -177,6 +213,7 @@ extension AttachmentDTO {
id: id,
type: attachmentType,
payload: data,
downloadingState: downloadingState,
uploadingState: uploadingState
)
}
Expand Down Expand Up @@ -209,13 +246,21 @@ extension LocalAttachmentState {
return "uploadingFailed"
case .uploaded:
return "uploaded"
case .downloading:
return "downloading"
case .downloadingFailed:
return "downloadingFailed"
case .downloaded:
return "downloaded"
}
}

var progress: Double {
switch self {
case let .uploading(progress):
return progress
case let .downloading(progress):
return progress
default:
return 0
}
Expand All @@ -233,6 +278,12 @@ extension LocalAttachmentState {
self = .uploaded
case LocalAttachmentState.unknown.rawValue:
self = .unknown
case LocalAttachmentState.downloaded.rawValue:
self = .downloaded
case LocalAttachmentState.downloadingFailed.rawValue:
self = .downloadingFailed
case LocalAttachmentState.downloading(progress: 0).rawValue:
self = .downloading(progress: progress)
default:
self = .unknown
}
Expand All @@ -254,6 +305,14 @@ extension ClientError {

final class AttachmentDecoding: ClientError {}

final class AttachmentDownloading: ClientError {
init(id: AttachmentId, reason: String) {
super.init(
"Failed to download attachment with id: \(id): \(reason)"
)
}
}

final class AttachmentUploading: ClientError {
init(id: AttachmentId) {
super.init(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23F79" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23231" systemVersion="23G93" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="AttachmentDTO" representedClassName="AttachmentDTO" syncable="YES">
<attribute name="data" attributeType="Binary"/>
<attribute name="id" attributeType="String"/>
<attribute name="localProgress" attributeType="Double" minValueString="0" maxValueString="1" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="localRelativePath" optional="YES" attributeType="String"/>
<attribute name="localStateRaw" optional="YES" attributeType="String"/>
<attribute name="localURL" optional="YES" attributeType="URI"/>
<attribute name="type" optional="YES" attributeType="String"/>
Expand Down
13 changes: 13 additions & 0 deletions Sources/StreamChat/Extensions/Task+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,16 @@ extension Task {
}
}
}

extension Task {
static func run(_ operation: @escaping () async throws -> Void, completion: @escaping (Error?) -> Void) where Success == Void, Failure == any Error {
Task {
do {
try await operation()
completion(nil)
} catch {
completion(error)
}
}
}
}
19 changes: 19 additions & 0 deletions Sources/StreamChat/Models/Attachments/AttachmentTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,25 @@ public enum LocalAttachmentState: Hashable {
case uploadingFailed
/// The attachment is successfully uploaded.
case uploaded
/// The attachment is being downloaded.
case downloading(progress: Double)
/// The attachment download failed.
case downloadingFailed
/// The attachment has been downloaded.
case downloaded

var isUploading: Bool {
switch self {
case .pendingUpload, .uploading, .uploadingFailed, .uploaded:
return true
case .unknown, .downloading, .downloadingFailed, .downloaded:
return false
}
}

var isDownloading: Bool {
!isUploading
}
}

/// An attachment action, e.g. send, shuffle.
Expand Down
Loading

0 comments on commit ee766d8

Please sign in to comment.