mirror of
https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios.git
synced 2026-05-04 23:17:43 +02:00
#4094 - Added voice message attachment decryption, transcoding and sampling caching layer.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
//
|
||||
// Copyright 2021 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
|
||||
import DSWaveformImage
|
||||
|
||||
enum VoiceMessageAttachmentCacheManagerError: Error {
|
||||
case invalidEventId
|
||||
case invalidAttachmentType
|
||||
case decryptionError(Error)
|
||||
case preparationError(Error)
|
||||
case conversionError(Error)
|
||||
case samplingError
|
||||
}
|
||||
|
||||
/**
|
||||
Swift optimizes the callbacks to be the same instance. Wrap them so we can store them in an array.
|
||||
*/
|
||||
private class CompletionWrapper {
|
||||
let completion: (Result<(URL, [Float]), Error>) -> Void
|
||||
|
||||
init(_ completion: @escaping (Result<(URL, [Float]), Error>) -> Void) {
|
||||
self.completion = completion
|
||||
}
|
||||
}
|
||||
|
||||
class VoiceMessageAttachmentCacheManager {
|
||||
|
||||
static let sharedManager = VoiceMessageAttachmentCacheManager()
|
||||
|
||||
private let workQueue: DispatchQueue
|
||||
|
||||
private var completionCallbacks = [String: [CompletionWrapper]]()
|
||||
private var samples = [String: [Int: [Float]]]()
|
||||
private var finalURLs = [String: URL]()
|
||||
|
||||
private init() {
|
||||
workQueue = DispatchQueue(label: "io.element.VoiceMessageAttachmentCacheManager.queue", qos: .userInitiated)
|
||||
}
|
||||
|
||||
func loadAttachment(_ attachment: MXKAttachment, numberOfSamples: Int, completion: @escaping (Result<(URL, [Float]), Error>) -> Void) {
|
||||
workQueue.async {
|
||||
self.enqueueLoadAttachment(attachment, numberOfSamples: numberOfSamples, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
func enqueueLoadAttachment(_ attachment: MXKAttachment, numberOfSamples: Int, completion: @escaping (Result<(URL, [Float]), Error>) -> Void) {
|
||||
guard attachment.type == MXKAttachmentTypeVoiceMessage else {
|
||||
DispatchQueue.main.async {
|
||||
completion(Result.failure(VoiceMessageAttachmentCacheManagerError.invalidAttachmentType))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let identifier = attachment.eventId else {
|
||||
DispatchQueue.main.async {
|
||||
completion(Result.failure(VoiceMessageAttachmentCacheManagerError.invalidEventId))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let finalURL = finalURLs[identifier], let samples = samples[identifier]?[numberOfSamples] {
|
||||
DispatchQueue.main.async {
|
||||
completion(Result.success((finalURL, samples)))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if var callbacks = completionCallbacks[identifier] {
|
||||
callbacks.append(CompletionWrapper(completion))
|
||||
completionCallbacks[identifier] = callbacks
|
||||
return
|
||||
} else {
|
||||
completionCallbacks[identifier] = [CompletionWrapper(completion)]
|
||||
}
|
||||
|
||||
func sampleFileAtURL(_ url: URL) {
|
||||
let analyser = WaveformAnalyzer(audioAssetURL: url)
|
||||
analyser?.samples(count: numberOfSamples, completionHandler: { samples in
|
||||
self.workQueue.async {
|
||||
guard let samples = samples else {
|
||||
self.invokeFailureCallbacksForIdentifier(identifier, error: VoiceMessageAttachmentCacheManagerError.samplingError)
|
||||
return
|
||||
}
|
||||
|
||||
if var existingSamples = self.samples[identifier] {
|
||||
existingSamples[numberOfSamples] = samples
|
||||
} else {
|
||||
self.samples[identifier] = [numberOfSamples: samples]
|
||||
}
|
||||
|
||||
self.invokeSuccessCallbacksForIdentifier(identifier, url: url, samples: samples)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if let finalURL = finalURLs[identifier] {
|
||||
sampleFileAtURL(finalURL)
|
||||
return
|
||||
}
|
||||
|
||||
func convertFileAtPath(_ path: String?) {
|
||||
guard let filePath = path else {
|
||||
return
|
||||
}
|
||||
|
||||
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
let newURL = temporaryDirectoryURL.appendingPathComponent(ProcessInfo().globallyUniqueString).appendingPathExtension("m4a")
|
||||
|
||||
VoiceMessageAudioConverter.convertToMPEG4AAC(sourceURL: URL(fileURLWithPath: filePath), destinationURL: newURL) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.finalURLs[identifier] = newURL
|
||||
sampleFileAtURL(newURL)
|
||||
case .failure(let error):
|
||||
self.invokeFailureCallbacksForIdentifier(identifier, error: VoiceMessageAttachmentCacheManagerError.conversionError(error))
|
||||
MXLog.error("Failed failed decoding audio message with: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if attachment.isEncrypted {
|
||||
attachment.decrypt(toTempFile: { filePath in
|
||||
convertFileAtPath(filePath)
|
||||
}, failure: { error in
|
||||
// A nil error in this case is a cancellation on the MXMediaLoader
|
||||
if let error = error {
|
||||
MXLog.error("Failed decrypting attachment with error: \(String(describing: error))")
|
||||
self.invokeFailureCallbacksForIdentifier(identifier, error: VoiceMessageAttachmentCacheManagerError.decryptionError(error))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
attachment.prepare({
|
||||
convertFileAtPath(attachment.cacheFilePath)
|
||||
}, failure: { error in
|
||||
// A nil error in this case is a cancellation on the MXMediaLoader
|
||||
if let error = error {
|
||||
MXLog.error("Failed preparing attachment with error: \(String(describing: error))")
|
||||
self.invokeFailureCallbacksForIdentifier(identifier, error: VoiceMessageAttachmentCacheManagerError.preparationError(error))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func invokeSuccessCallbacksForIdentifier(_ identifier: String, url: URL, samples: [Float]) {
|
||||
guard let callbacks = completionCallbacks[identifier] else {
|
||||
return
|
||||
}
|
||||
|
||||
for wrapper in callbacks {
|
||||
DispatchQueue.main.async {
|
||||
wrapper.completion(Result.success((url, samples)))
|
||||
}
|
||||
}
|
||||
|
||||
completionCallbacks[identifier] = nil
|
||||
}
|
||||
|
||||
private func invokeFailureCallbacksForIdentifier(_ identifier: String, error: Error) {
|
||||
guard let callbacks = completionCallbacks[identifier] else {
|
||||
return
|
||||
}
|
||||
|
||||
for wrapper in callbacks {
|
||||
DispatchQueue.main.async {
|
||||
wrapper.completion(Result.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
completionCallbacks[identifier] = nil
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,9 @@ class VoiceMessagePlaybackController: VoiceMessageAudioPlayerDelegate, VoiceMess
|
||||
dateFormatter.dateFormat = Constants.elapsedTimeFormat
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
|
||||
private let cacheManager: VoiceMessageAttachmentCacheManager
|
||||
|
||||
private let audioPlayer: VoiceMessageAudioPlayer
|
||||
private var displayLink: CADisplayLink!
|
||||
private var samples: [Float] = []
|
||||
@@ -49,7 +51,10 @@ class VoiceMessagePlaybackController: VoiceMessageAudioPlayerDelegate, VoiceMess
|
||||
|
||||
let playbackView: VoiceMessagePlaybackView
|
||||
|
||||
init(mediaServiceProvider: VoiceMessageMediaServiceProvider) {
|
||||
init(mediaServiceProvider: VoiceMessageMediaServiceProvider,
|
||||
cacheManager: VoiceMessageAttachmentCacheManager) {
|
||||
self.cacheManager = cacheManager
|
||||
|
||||
playbackView = VoiceMessagePlaybackView.loadFromNib()
|
||||
audioPlayer = mediaServiceProvider.audioPlayer()
|
||||
|
||||
@@ -62,22 +67,12 @@ class VoiceMessagePlaybackController: VoiceMessageAudioPlayerDelegate, VoiceMess
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(updateTheme), name: .themeServiceDidChangeTheme, object: nil)
|
||||
updateTheme()
|
||||
updateUI()
|
||||
}
|
||||
|
||||
|
||||
var attachment: MXKAttachment? {
|
||||
didSet {
|
||||
if oldValue?.contentURL == attachment?.contentURL &&
|
||||
oldValue?.eventSentState == attachment?.eventSentState {
|
||||
return
|
||||
}
|
||||
|
||||
switch attachment?.eventSentState {
|
||||
case MXEventSentStateFailed:
|
||||
state = .error
|
||||
default:
|
||||
state = .stopped
|
||||
loadAttachmentData()
|
||||
}
|
||||
loadAttachmentData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,79 +138,26 @@ class VoiceMessagePlaybackController: VoiceMessageAudioPlayerDelegate, VoiceMess
|
||||
|
||||
playbackView.configureWithDetails(details)
|
||||
}
|
||||
|
||||
|
||||
private func loadAttachmentData() {
|
||||
guard let attachment = attachment else {
|
||||
return
|
||||
}
|
||||
|
||||
if attachment.isEncrypted {
|
||||
attachment.decrypt(toTempFile: { [weak self] filePath in
|
||||
self?.convertAndLoadFileAtPath(filePath)
|
||||
}, failure: { [weak self] error in
|
||||
// A nil error in this case is a cancellation on the MXMediaLoader
|
||||
if let error = error {
|
||||
MXLog.error("Failed decrypting attachment with error: \(String(describing: error))")
|
||||
self?.state = .error
|
||||
}
|
||||
})
|
||||
} else {
|
||||
attachment.prepare({ [weak self] in
|
||||
self?.convertAndLoadFileAtPath(attachment.cacheFilePath)
|
||||
}, failure: { [weak self] error in
|
||||
// A nil error in this case is a cancellation on the MXMediaLoader
|
||||
if let error = error {
|
||||
MXLog.error("Failed preparing attachment with error: \(String(describing: error))")
|
||||
self?.state = .error
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func convertAndLoadFileAtPath(_ path: String?) {
|
||||
guard let filePath = path else {
|
||||
return
|
||||
}
|
||||
|
||||
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
let newURL = temporaryDirectoryURL.appendingPathComponent(ProcessInfo().globallyUniqueString).appendingPathExtension("m4a")
|
||||
|
||||
VoiceMessageAudioConverter.convertToMPEG4AAC(sourceURL: URL(fileURLWithPath: filePath), destinationURL: newURL) { [weak self] result in
|
||||
switch result {
|
||||
case .success:
|
||||
self?.loadFileAtURL(newURL)
|
||||
case .failure(let error):
|
||||
self?.state = .error
|
||||
MXLog.error("Failed failed decoding audio message with: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFileAtURL(_ url: URL) {
|
||||
|
||||
audioPlayer.loadContentFromURL(url)
|
||||
|
||||
let requiredNumberOfSamples = playbackView.getRequiredNumberOfSamples()
|
||||
|
||||
if requiredNumberOfSamples == 0 {
|
||||
return
|
||||
cacheManager.loadAttachment(attachment, numberOfSamples: requiredNumberOfSamples) { result in
|
||||
switch result {
|
||||
case .success(let result):
|
||||
self.audioPlayer.loadContentFromURL(result.0)
|
||||
self.samples = result.1
|
||||
self.updateUI()
|
||||
case .failure:
|
||||
self.state = .error
|
||||
}
|
||||
}
|
||||
|
||||
let analyser = WaveformAnalyzer(audioAssetURL: url)
|
||||
analyser?.samples(count: requiredNumberOfSamples, completionHandler: { [weak self] samples in
|
||||
guard let samples = samples else {
|
||||
self?.state = .error
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self?.samples = samples
|
||||
self?.updateUI()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@objc private func updateTheme() {
|
||||
playbackView.update(theme: ThemeService.shared().theme)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user