mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Prevent multiple conversion of the same voice message audio file. (#1887)
This commit is contained in:
@@ -16,32 +16,33 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
class VoiceMessageCache {
|
||||
var temporaryFilesFolderURL: URL {
|
||||
enum VoiceMessageCacheError: Error {
|
||||
case invalidFileExtension
|
||||
}
|
||||
|
||||
class VoiceMessageCache: VoiceMessageCacheProtocol {
|
||||
private let preferredFileExtension = "m4a"
|
||||
private var temporaryFilesFolderURL: URL {
|
||||
FileManager.default.temporaryDirectory.appendingPathComponent("media/voice-message")
|
||||
}
|
||||
|
||||
func cacheURL(for mediaSource: MediaSourceProxy, replacingExtension newExtension: String? = nil) -> URL {
|
||||
var newURL = temporaryFilesFolderURL.appendingPathComponent(mediaSource.url.lastPathComponent)
|
||||
if let newExtension {
|
||||
newURL = newURL.deletingPathExtension().appendingPathExtension(newExtension)
|
||||
}
|
||||
return newURL
|
||||
}
|
||||
|
||||
func fileURL(for mediaSource: MediaSourceProxy, withExtension fileExtension: String? = nil) -> URL? {
|
||||
var url = temporaryFilesFolderURL.appendingPathComponent(mediaSource.url.lastPathComponent)
|
||||
if let fileExtension {
|
||||
url = url.deletingPathExtension().appendingPathExtension(fileExtension)
|
||||
}
|
||||
|
||||
func fileURL(for mediaSource: MediaSourceProxy) -> URL? {
|
||||
let url = cacheURL(for: mediaSource)
|
||||
return FileManager.default.fileExists(atPath: url.path()) ? url : nil
|
||||
}
|
||||
|
||||
func cache(mediaSource: MediaSourceProxy, using fileURL: URL) throws -> URL {
|
||||
func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool = false) throws -> URL {
|
||||
guard fileURL.pathExtension == preferredFileExtension else {
|
||||
throw VoiceMessageCacheError.invalidFileExtension
|
||||
}
|
||||
setupTemporaryFilesFolder()
|
||||
let url = cacheURL(for: mediaSource)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
try FileManager.default.copyItem(at: fileURL, to: url)
|
||||
if move {
|
||||
try FileManager.default.moveItem(at: fileURL, to: url)
|
||||
} else {
|
||||
try FileManager.default.copyItem(at: fileURL, to: url)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
@@ -64,4 +65,8 @@ class VoiceMessageCache {
|
||||
MXLog.error("Failed to setup audio cache manager.")
|
||||
}
|
||||
}
|
||||
|
||||
private func cacheURL(for mediaSource: MediaSourceProxy) -> URL {
|
||||
temporaryFilesFolderURL.appendingPathComponent(mediaSource.url.lastPathComponent).appendingPathExtension(preferredFileExtension)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol VoiceMessageCacheProtocol {
|
||||
func fileURL(for mediaSource: MediaSourceProxy) -> URL?
|
||||
func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool) throws -> URL
|
||||
func clearCache()
|
||||
}
|
||||
|
||||
// sourcery: AutoMockable
|
||||
extension VoiceMessageCacheProtocol { }
|
||||
@@ -20,31 +20,47 @@ enum VoiceMessageMediaManagerError: Error {
|
||||
case unsupportedMimeTye
|
||||
}
|
||||
|
||||
private final class VoiceMessageConversionRequest {
|
||||
var continuations: [CheckedContinuation<URL, Error>] = []
|
||||
}
|
||||
|
||||
class VoiceMessageMediaManager: VoiceMessageMediaManagerProtocol {
|
||||
private let mediaProvider: MediaProviderProtocol
|
||||
private let cache: VoiceMessageCache
|
||||
|
||||
private let supportedVoiceMessageMimeType = "audio/ogg"
|
||||
|
||||
/// Preferred audio file extension after conversion
|
||||
private let preferredAudioExtension = "m4a"
|
||||
|
||||
init(mediaProvider: MediaProviderProtocol) {
|
||||
self.mediaProvider = mediaProvider
|
||||
cache = VoiceMessageCache()
|
||||
}
|
||||
private let voiceMessageCache: VoiceMessageCacheProtocol
|
||||
private let audioConverter: AudioConverterProtocol
|
||||
|
||||
private let backgroundTaskService: BackgroundTaskServiceProtocol?
|
||||
private let processingQueue: DispatchQueue
|
||||
private var conversionRequests = [MediaSourceProxy: VoiceMessageConversionRequest]()
|
||||
|
||||
private let supportedVoiceMessageMimeType = "audio/ogg"
|
||||
|
||||
init(mediaProvider: MediaProviderProtocol,
|
||||
voiceMessageCache: VoiceMessageCacheProtocol = VoiceMessageCache(),
|
||||
audioConverter: AudioConverterProtocol = AudioConverter(),
|
||||
processingQueue: DispatchQueue = .global(),
|
||||
backgroundTaskService: BackgroundTaskServiceProtocol?) {
|
||||
self.mediaProvider = mediaProvider
|
||||
self.voiceMessageCache = voiceMessageCache
|
||||
self.audioConverter = audioConverter
|
||||
self.processingQueue = processingQueue
|
||||
self.backgroundTaskService = backgroundTaskService
|
||||
}
|
||||
|
||||
deinit {
|
||||
cache.clearCache()
|
||||
voiceMessageCache.clearCache()
|
||||
}
|
||||
|
||||
func loadVoiceMessageFromSource(_ source: MediaSourceProxy, body: String?) async throws -> URL {
|
||||
let loadFileBgTask = await backgroundTaskService?.startBackgroundTask(withName: "LoadFile: \(source.url.hashValue)")
|
||||
defer { loadFileBgTask?.stop() }
|
||||
|
||||
guard let mimeType = source.mimeType, mimeType == supportedVoiceMessageMimeType else {
|
||||
throw VoiceMessageMediaManagerError.unsupportedMimeTye
|
||||
}
|
||||
|
||||
// Do we already have a converted version?
|
||||
if let fileURL = cache.fileURL(for: source, withExtension: preferredAudioExtension) {
|
||||
if let fileURL = voiceMessageCache.fileURL(for: source) {
|
||||
return fileURL
|
||||
}
|
||||
|
||||
@@ -52,16 +68,50 @@ class VoiceMessageMediaManager: VoiceMessageMediaManagerProtocol {
|
||||
guard case .success(let fileHandle) = await mediaProvider.loadFileFromSource(source, body: body) else {
|
||||
throw MediaProviderError.failedRetrievingFile
|
||||
}
|
||||
let fileURL = try cache.cache(mediaSource: source, using: fileHandle.url)
|
||||
|
||||
// Convert from ogg
|
||||
let audioConverter = AudioConverter()
|
||||
let convertedFileURL = cache.cacheURL(for: source, replacingExtension: preferredAudioExtension)
|
||||
try audioConverter.convertToMPEG4AAC(sourceURL: fileURL, destinationURL: convertedFileURL)
|
||||
|
||||
// we don't need the original file anymore
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
return try await enqueueVoiceMessageConversionRequest(forSource: source) { [audioConverter, voiceMessageCache] in
|
||||
// Do we already have a converted version?
|
||||
if let fileURL = voiceMessageCache.fileURL(for: source) {
|
||||
return fileURL
|
||||
}
|
||||
|
||||
// Convert from ogg
|
||||
let convertedFileURL = URL.temporaryDirectory.appendingPathComponent(fileHandle.url.deletingPathExtension().lastPathComponent).appendingPathExtension(AudioConverterPreferredFileExtension.mpeg4aac.rawValue)
|
||||
try audioConverter.convertToMPEG4AAC(sourceURL: fileHandle.url, destinationURL: convertedFileURL)
|
||||
|
||||
// Cache the file and return the url
|
||||
return try voiceMessageCache.cache(mediaSource: source, using: convertedFileURL, move: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func enqueueVoiceMessageConversionRequest(forSource source: MediaSourceProxy, operation: @escaping () throws -> URL) async throws -> URL {
|
||||
if let conversionRequests = conversionRequests[source] {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
conversionRequests.continuations.append(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
return convertedFileURL
|
||||
let conversionRequest = VoiceMessageConversionRequest()
|
||||
conversionRequests[source] = conversionRequest
|
||||
|
||||
defer {
|
||||
conversionRequests[source] = nil
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await Task.dispatch(on: processingQueue) {
|
||||
try operation()
|
||||
}
|
||||
|
||||
conversionRequest.continuations.forEach { $0.resume(returning: result) }
|
||||
|
||||
return result
|
||||
|
||||
} catch {
|
||||
conversionRequest.continuations.forEach { $0.resume(throwing: error) }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user