mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 19:44:05 +00:00
b31950789f
* Move synchronous image upload preparation off the main actor Signed-off-by: Ivan Magda <imagda15@gmail.com> * Make media preparation cooperative with cancellation Replace the dispatch queue with concurrent copy, image and video-thumbnail functions. Return sendable upload metadata directly, bound each batch to two preparations and discard unfinished results when cancellation occurs. Keep file metadata reads synchronous and prevent the preview from sending a result after its preparation task has been cancelled. Replace unrelated tests with cancellation and batch-order coverage requested during review. * Update MediaUploadingPreprocessor.swift * Run media preparation in a nonisolated preprocessor Make MediaUploadingPreprocessor a nonisolated struct whose copy, image processing and video thumbnail run in @concurrent instance methods, instead of a main-actor type with static helpers and an extra setting parameter. Check cancellation with plain early returns between the slow steps, name the batch limit, stop logging cancelled video exports as errors, and restore develop's structure and doc comments where they were changed without need. Replace the tests that didn't reach the new code with tests for cleanup on failure, on batch failure and on cancellation, and make the preview test set up its blocking mock before the view model starts processing. * Apply review suggestions to media preparation Docs: - Drop the doc comment on maximumConcurrentProcessingCount, the name says it. - Move the cleanup note into the main description of both processMedia functions, so the Returns lines match develop again. Tests: - Rename the test helper to copyResourceToTemporaryDirectory(_:). --------- Signed-off-by: Ivan Magda <imagda15@gmail.com> Co-authored-by: Ivan Magda <imagda15@gmail.com>
254 lines
12 KiB
Swift
254 lines
12 KiB
Swift
//
|
|
// Copyright 2025 Element Creations Ltd.
|
|
// Copyright 2022-2025 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import Combine
|
|
import MatrixRustSDK
|
|
import SwiftUI
|
|
|
|
typealias MediaUploadPreviewScreenViewModelType = StateStoreViewModelV2<MediaUploadPreviewScreenViewState, MediaUploadPreviewScreenViewAction>
|
|
|
|
class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType, MediaUploadPreviewScreenViewModelProtocol {
|
|
private let timelineController: TimelineControllerProtocol
|
|
private let userIndicatorController: UserIndicatorControllerProtocol
|
|
|
|
private let mediaUploadingPreprocessor: MediaUploadingPreprocessor
|
|
private var mediaURLs: [URL]
|
|
|
|
private var processingTask: Task<Result<[MediaInfo], MediaUploadingPreprocessorError>, Never>
|
|
private var requestHandle: SendAttachmentJoinHandleProtocol?
|
|
private let clientProxy: ClientProxyProtocol
|
|
private let galleryEnabled: Bool
|
|
|
|
private var actionsSubject: PassthroughSubject<MediaUploadPreviewScreenViewModelAction, Never> = .init()
|
|
|
|
var actions: AnyPublisher<MediaUploadPreviewScreenViewModelAction, Never> {
|
|
actionsSubject.eraseToAnyPublisher()
|
|
}
|
|
|
|
init(mediaURLs: [URL],
|
|
caption: NSAttributedString?,
|
|
title: String?,
|
|
shouldShowCaptionWarning: Bool,
|
|
galleryEnabled: Bool,
|
|
mediaUploadingPreprocessor: MediaUploadingPreprocessor,
|
|
timelineController: TimelineControllerProtocol,
|
|
clientProxy: ClientProxyProtocol,
|
|
userIndicatorController: UserIndicatorControllerProtocol) {
|
|
self.mediaURLs = mediaURLs
|
|
self.mediaUploadingPreprocessor = mediaUploadingPreprocessor
|
|
self.timelineController = timelineController
|
|
self.clientProxy = clientProxy
|
|
self.userIndicatorController = userIndicatorController
|
|
self.galleryEnabled = galleryEnabled
|
|
|
|
// Start processing the media whilst the user is reviewing it/adding a caption.
|
|
processingTask = Self.processMedia(at: mediaURLs, preprocessor: mediaUploadingPreprocessor, clientProxy: clientProxy)
|
|
|
|
super.init(initialViewState: MediaUploadPreviewScreenViewState(mediaURLs: mediaURLs,
|
|
title: title,
|
|
shouldShowCaptionWarning: shouldShowCaptionWarning,
|
|
bindings: .init(caption: caption ?? NSAttributedString())))
|
|
}
|
|
|
|
override func process(viewAction: MediaUploadPreviewScreenViewAction) {
|
|
// Get the current caption before all the processing starts.
|
|
let caption = state.bindings.caption.nonBlankString
|
|
|
|
switch viewAction {
|
|
case .send:
|
|
startLoading()
|
|
|
|
Task { [processingTask] in
|
|
defer { stopLoading() }
|
|
|
|
let result = await processingTask.value
|
|
guard !processingTask.isCancelled else { return }
|
|
switch result {
|
|
case .success(let mediaInfos):
|
|
if galleryEnabled, mediaInfos.count > 1 {
|
|
switch await sendGallery(mediaInfos: mediaInfos, caption: caption) {
|
|
case .success:
|
|
break
|
|
case .failure(let error):
|
|
MXLog.error("Failed sending gallery with error: \(error)")
|
|
showError(label: L10n.screenMediaUploadPreviewErrorFailedSending)
|
|
}
|
|
} else {
|
|
var perItemCaption = caption
|
|
for mediaInfo in mediaInfos {
|
|
switch await sendAttachment(mediaInfo: mediaInfo, caption: perItemCaption) {
|
|
case .success:
|
|
perItemCaption = nil // Set the caption only on the first uploaded file.
|
|
case .failure(let error):
|
|
MXLog.error("Failed sending media with error: \(error)")
|
|
showError(label: L10n.screenMediaUploadPreviewErrorFailedSending)
|
|
}
|
|
}
|
|
}
|
|
|
|
actionsSubject.send(.dismiss)
|
|
case .failure(.maxUploadSizeUnknown):
|
|
showAlert(.maxUploadSizeUnknown)
|
|
case .failure(.maxUploadSizeExceeded(let limit)):
|
|
showAlert(.maxUploadSizeExceeded(limit: limit))
|
|
case .failure(let error):
|
|
MXLog.error("Failed processing media to upload with error: \(error)")
|
|
showError(label: L10n.screenMediaUploadPreviewErrorFailedProcessing)
|
|
}
|
|
}
|
|
case .cancel:
|
|
requestHandle?.cancel()
|
|
actionsSubject.send(.dismiss)
|
|
case .editedMedia(let image, let index):
|
|
guard mediaURLs.indices.contains(index),
|
|
let data = image.jpegData(compressionQuality: 1.0) else {
|
|
return
|
|
}
|
|
|
|
do {
|
|
try data.write(to: mediaURLs[index], options: .atomic)
|
|
|
|
state.mediaEditVersion += 1
|
|
|
|
processingTask.cancel()
|
|
processingTask = Self.processMedia(at: mediaURLs, preprocessor: mediaUploadingPreprocessor, clientProxy: clientProxy)
|
|
} catch {
|
|
MXLog.error("Failed writing cropped image with error: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sendGallery(mediaInfos: [MediaInfo], caption: String?) async -> Result<Void, TimelineControllerError> {
|
|
let itemInfos: [GalleryItemInfo] = mediaInfos.map { mediaInfo in
|
|
switch mediaInfo {
|
|
case let .image(imageURL, thumbnailURL, imageInfo):
|
|
.image(imageInfo: imageInfo,
|
|
source: .file(filename: imageURL.path(percentEncoded: false)),
|
|
caption: nil,
|
|
formattedCaption: nil,
|
|
thumbnailSource: .file(filename: thumbnailURL.path(percentEncoded: false)))
|
|
case let .video(videoURL, thumbnailURL, videoInfo):
|
|
.video(videoInfo: videoInfo,
|
|
source: .file(filename: videoURL.path(percentEncoded: false)),
|
|
caption: nil,
|
|
formattedCaption: nil,
|
|
thumbnailSource: .file(filename: thumbnailURL.path(percentEncoded: false)))
|
|
case let .audio(audioURL, audioInfo):
|
|
.audio(audioInfo: audioInfo,
|
|
source: .file(filename: audioURL.path(percentEncoded: false)),
|
|
caption: nil,
|
|
formattedCaption: nil)
|
|
case let .file(fileURL, fileInfo):
|
|
.file(fileInfo: fileInfo,
|
|
source: .file(filename: fileURL.path(percentEncoded: false)),
|
|
caption: nil,
|
|
formattedCaption: nil)
|
|
}
|
|
}
|
|
|
|
return await timelineController.sendGallery(itemInfos: itemInfos,
|
|
caption: caption,
|
|
inReplyToEventID: nil)
|
|
}
|
|
|
|
func stopProcessing() {
|
|
processingTask.cancel()
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private static func processMedia(at urls: [URL],
|
|
preprocessor: MediaUploadingPreprocessor,
|
|
clientProxy: ClientProxyProtocol) -> Task<Result<[MediaInfo], MediaUploadingPreprocessorError>, Never> {
|
|
Task {
|
|
guard case let .success(maxUploadSize) = await clientProxy.maxMediaUploadSize else { return .failure(.maxUploadSizeUnknown) }
|
|
return await preprocessor.processMedia(at: urls, maxUploadSize: maxUploadSize)
|
|
}
|
|
}
|
|
|
|
private func sendAttachment(mediaInfo: MediaInfo, caption: String?) async -> Result<Void, TimelineControllerError> {
|
|
let requestHandle: (@MainActor @Sendable (SendAttachmentJoinHandleProtocol) -> Void) = { [weak self] handle in
|
|
self?.requestHandle = handle
|
|
}
|
|
|
|
switch mediaInfo {
|
|
case let .image(imageURL, thumbnailURL, imageInfo):
|
|
return await timelineController.sendImage(url: imageURL,
|
|
thumbnailURL: thumbnailURL,
|
|
imageInfo: imageInfo,
|
|
caption: caption,
|
|
requestHandle: requestHandle)
|
|
case let .video(videoURL, thumbnailURL, videoInfo):
|
|
return await timelineController.sendVideo(url: videoURL,
|
|
thumbnailURL: thumbnailURL,
|
|
videoInfo: videoInfo,
|
|
caption: caption,
|
|
requestHandle: requestHandle)
|
|
case let .audio(audioURL, audioInfo):
|
|
return await timelineController.sendAudio(url: audioURL,
|
|
audioInfo: audioInfo,
|
|
caption: caption,
|
|
requestHandle: requestHandle)
|
|
case let .file(fileURL, fileInfo):
|
|
return await timelineController.sendFile(url: fileURL,
|
|
fileInfo: fileInfo,
|
|
caption: caption,
|
|
requestHandle: requestHandle)
|
|
}
|
|
}
|
|
|
|
private static let loadingIndicatorIdentifier = "\(MediaUploadPreviewScreenViewModel.self)-Loading"
|
|
|
|
private func startLoading() {
|
|
userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
|
|
type: .modal(progress: .indeterminate, interactiveDismissDisabled: false, allowsInteraction: true),
|
|
title: L10n.commonPreparing,
|
|
persistent: true),
|
|
delay: .milliseconds(100))
|
|
|
|
state.shouldDisableInteraction = true
|
|
}
|
|
|
|
private func stopLoading() {
|
|
userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
|
|
state.shouldDisableInteraction = false
|
|
requestHandle = nil
|
|
}
|
|
|
|
private func showError(label: String) {
|
|
userIndicatorController.submitIndicator(UserIndicator(title: label))
|
|
}
|
|
|
|
private func showAlert(_ alertType: MediaUploadPreviewAlertType) {
|
|
switch alertType {
|
|
case .maxUploadSizeUnknown:
|
|
state.bindings.alertInfo = .init(id: alertType,
|
|
title: L10n.commonSomethingWentWrong,
|
|
message: L10n.screenMediaUploadPreviewErrorCouldNotBeUploaded,
|
|
primaryButton: .init(title: L10n.actionTryAgain) { [weak self] in
|
|
guard let self else { return }
|
|
processingTask = Self.processMedia(at: mediaURLs, preprocessor: mediaUploadingPreprocessor, clientProxy: clientProxy)
|
|
process(viewAction: .send)
|
|
},
|
|
secondaryButton: .init(title: L10n.actionCancel, role: .cancel) { })
|
|
case .maxUploadSizeExceeded(let limit):
|
|
state.bindings.alertInfo = .init(id: alertType,
|
|
title: L10n.screenMediaUploadPreviewErrorTooLargeTitle,
|
|
message: L10n.screenMediaUploadPreviewErrorTooLargeMessage(limit.formatted(.byteCount(style: .file))),
|
|
primaryButton: .init(title: L10n.actionCancel, role: .cancel) { })
|
|
}
|
|
}
|
|
}
|
|
|
|
extension NSAttributedString {
|
|
var nonBlankString: String? {
|
|
guard !string.isBlank else { return nil }
|
|
return string
|
|
}
|
|
}
|