diff --git a/Config/Project.xcconfig b/Config/Project.xcconfig index 5772467ee..c3c50db54 100644 --- a/Config/Project.xcconfig +++ b/Config/Project.xcconfig @@ -25,7 +25,7 @@ KEYCHAIN_ACCESS_GROUP = $(AppIdentifierPrefix)$(BASE_BUNDLE_IDENTIFIER).keychain.shared // Build settings -IPHONEOS_DEPLOYMENT_TARGET = 11.0 +IPHONEOS_DEPLOYMENT_TARGET = 12.1 SDKROOT = iphoneos TARGETED_DEVICE_FAMILY = 1,2 SWIFT_VERSION = 5.3.1 diff --git a/Podfile b/Podfile index 0b963c87f..32d841a90 100644 --- a/Podfile +++ b/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '11.0' +platform :ios, '12.1' # Use frameforks to allow usage of pod written in Swift (like PiwikTracker) use_frameworks! @@ -70,6 +70,7 @@ abstract_target 'RiotPods' do pod 'SwiftJWT', '~> 3.6.200' pod 'SideMenu', '~> 6.5' pod 'DSWaveformImage', '~> 6.1.1' + pod 'ffmpeg-kit-ios-audio', '~> 4.4' pod 'FLEX', '~> 4.4.1', :configurations => ['Debug'] diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 4d0786654..5d23e8fa6 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -6187,7 +6187,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; - (void)voiceMessageController:(VoiceMessageController *)voiceMessageController didRequestSendForFileAtURL:(NSURL *)url completion:(void (^)(BOOL))completion { - [self.roomDataSource sendVoiceMessage:url mimeType:@"audio/m4a" success:^(NSString *eventId) { + [self.roomDataSource sendVoiceMessage:url mimeType:nil success:^(NSString *eventId) { MXLogDebug(@"Success with event id %@", eventId); completion(YES); } failure:^(NSError *error) { diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioConverter.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioConverter.swift new file mode 100644 index 000000000..2df5454cb --- /dev/null +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioConverter.swift @@ -0,0 +1,65 @@ +// +// 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 + +enum VoiceMessageAudioConverterError: Error { + case generic(String) + case cancelled +} + +struct VoiceMessageAudioConverter { + static func convertToOpusOgg(sourceURL: URL, destinationURL: URL, completion: @escaping (Result) -> Void) { + let command = "-hide_banner -y -i \"\(sourceURL.path)\" -c:a libopus \"\(destinationURL.path)\"" + executeCommand(command, completion: completion) + } + + static func convertToMPEG4AAC(sourceURL: URL, destinationURL: URL, completion: @escaping (Result) -> Void) { + let command = "-hide_banner -y -i \"\(sourceURL.path)\" -c:a aac_at \"\(destinationURL.path)\"" + executeCommand(command, completion: completion) + } + + static private func executeCommand(_ command: String, completion: @escaping (Result) -> Void) { + FFmpegKitConfig.setLogLevel(0) + + FFmpegKit.executeAsync(command) { session in + guard let session = session else { + completion(.failure(.generic("Invalid session"))) + return + } + + guard let returnCode = session.getReturnCode() else { + completion(.failure(.generic("Invalid return code"))) + return + } + + DispatchQueue.main.async { + if returnCode.isSuccess() { + completion(.success(())) + } else if returnCode.isCancel() { + completion(.failure(.cancelled)) + } else { + completion(.failure(.generic(String(returnCode.getValue())))) + MXLog.error(""" + Failed converting voice message with state: \(String(describing: FFmpegKitConfig.sessionState(toString: session.getState()))), \ + returnCode: \(String(describing: returnCode)), \ + stackTrace: \(String(describing: session.getFailStackTrace())) + """) + } + } + } + } +} diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageController.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageController.swift index ce96062b2..d3e044ce3 100644 --- a/Riot/Modules/Room/VoiceMessages/VoiceMessageController.swift +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageController.swift @@ -188,10 +188,23 @@ public class VoiceMessageController: NSObject, VoiceMessageToolbarViewDelegate, // MARK: - Private - private func sendRecordingAtURL(_ url: URL) { - delegate?.voiceMessageController(self, didRequestSendForFileAtURL: url) { [weak self] success in - UINotificationFeedbackGenerator().notificationOccurred( (success ? .success : .error)) - self?.deleteRecordingAtURL(url) + private func sendRecordingAtURL(_ sourceURL: URL) { + + let destinationURL = sourceURL.deletingPathExtension().appendingPathExtension("opus") + + VoiceMessageAudioConverter.convertToOpusOgg(sourceURL: sourceURL, destinationURL: destinationURL) { [weak self] result in + guard let self = self else { return } + + switch result { + case .success: + self.delegate?.voiceMessageController(self, didRequestSendForFileAtURL: destinationURL) { [weak self] success in + UINotificationFeedbackGenerator().notificationOccurred((success ? .success : .error)) + self?.deleteRecordingAtURL(sourceURL) + self?.deleteRecordingAtURL(destinationURL) + } + case .failure(let error): + MXLog.error("Failed failed encoding audio message with: \(error)") + } } } diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessagePlaybackController.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessagePlaybackController.swift index 836204029..dedbd3919 100644 --- a/Riot/Modules/Room/VoiceMessages/VoiceMessagePlaybackController.swift +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessagePlaybackController.swift @@ -147,7 +147,7 @@ class VoiceMessagePlaybackController: VoiceMessageAudioPlayerDelegate, VoiceMess if attachment.isEncrypted { attachment.decrypt(toTempFile: { [weak self] filePath in - self?.loadFileAtPath(filePath) + self?.convertAndLoadFileAtPath(filePath) }, failure: { [weak self] error in // A nil error in this case is a cancellation on the MXMediaLoader if let error = error { @@ -157,7 +157,7 @@ class VoiceMessagePlaybackController: VoiceMessageAudioPlayerDelegate, VoiceMess }) } else { attachment.prepare({ [weak self] in - self?.loadFileAtPath(attachment.cacheFilePath) + 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 { @@ -168,26 +168,28 @@ class VoiceMessagePlaybackController: VoiceMessageAudioPlayerDelegate, VoiceMess } } - private func loadFileAtPath(_ path: String?) { + private func convertAndLoadFileAtPath(_ path: String?) { guard let filePath = path else { return } - let url = URL(fileURLWithPath: filePath) + let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let newURL = temporaryDirectoryURL.appendingPathComponent(ProcessInfo().globallyUniqueString).appendingPathExtension("m4a") - // AVPlayer doesn't want to play it otherwise. https://stackoverflow.com/a/9350824 - let newURL = url.appendingPathExtension("m4a") - - do { - try? FileManager.default.removeItem(at: newURL) - try FileManager.default.moveItem(at: url, to: newURL) - } catch { - self.state = .error - MXLog.error("Failed appending voice message extension.") - return + 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(newURL) + audioPlayer.loadContentFromURL(url) let requiredNumberOfSamples = playbackView.getRequiredNumberOfSamples() @@ -195,7 +197,7 @@ class VoiceMessagePlaybackController: VoiceMessageAudioPlayerDelegate, VoiceMess return } - let analyser = WaveformAnalyzer(audioAssetURL: newURL) + let analyser = WaveformAnalyzer(audioAssetURL: url) analyser?.samples(count: requiredNumberOfSamples, completionHandler: { [weak self] samples in guard let samples = samples else { self?.state = .error diff --git a/Riot/SupportingFiles/Riot-Bridging-Header.h b/Riot/SupportingFiles/Riot-Bridging-Header.h index 3292be7d4..3333b1805 100644 --- a/Riot/SupportingFiles/Riot-Bridging-Header.h +++ b/Riot/SupportingFiles/Riot-Bridging-Header.h @@ -5,6 +5,8 @@ @import MatrixSDK; @import MatrixKit; +#include + #import "WebViewViewController.h" #import "RiotSplitViewController.h" #import "RiotNavigationController.h"