Prevent multiple conversion of the same voice message audio file. (#1887)

This commit is contained in:
Nicolas Mauri
2023-10-13 11:48:11 +02:00
committed by GitHub
parent ad0b2f114f
commit 2f0b5a42f5
40 changed files with 627 additions and 91 deletions
@@ -328,14 +328,12 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
mentionBuilder: MentionBuilder(mentionsEnabled: appSettings.mentionsEnabled)),
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID),
appSettings: appSettings)
let voiceMesssageMediaManager = VoiceMessageMediaManager(mediaProvider: userSession.mediaProvider)
let timelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory,
mediaProvider: userSession.mediaProvider,
mediaPlayerProvider: mediaPlayerProvider,
voiceMessageMediaManager: voiceMesssageMediaManager)
voiceMessageMediaManager: userSession.voiceMessageMediaManager)
self.timelineController = timelineController
analytics.trackViewRoom(isDM: roomProxy.isDirect, isSpace: roomProxy.isSpace)
@@ -115,6 +115,49 @@ class AnalyticsClientMock: AnalyticsClientProtocol {
updateUserPropertiesClosure?(userProperties)
}
}
class AudioConverterMock: AudioConverterProtocol {
//MARK: - convertToOpusOgg
var convertToOpusOggSourceURLDestinationURLThrowableError: Error?
var convertToOpusOggSourceURLDestinationURLCallsCount = 0
var convertToOpusOggSourceURLDestinationURLCalled: Bool {
return convertToOpusOggSourceURLDestinationURLCallsCount > 0
}
var convertToOpusOggSourceURLDestinationURLReceivedArguments: (sourceURL: URL, destinationURL: URL)?
var convertToOpusOggSourceURLDestinationURLReceivedInvocations: [(sourceURL: URL, destinationURL: URL)] = []
var convertToOpusOggSourceURLDestinationURLClosure: ((URL, URL) throws -> Void)?
func convertToOpusOgg(sourceURL: URL, destinationURL: URL) throws {
if let error = convertToOpusOggSourceURLDestinationURLThrowableError {
throw error
}
convertToOpusOggSourceURLDestinationURLCallsCount += 1
convertToOpusOggSourceURLDestinationURLReceivedArguments = (sourceURL: sourceURL, destinationURL: destinationURL)
convertToOpusOggSourceURLDestinationURLReceivedInvocations.append((sourceURL: sourceURL, destinationURL: destinationURL))
try convertToOpusOggSourceURLDestinationURLClosure?(sourceURL, destinationURL)
}
//MARK: - convertToMPEG4AAC
var convertToMPEG4AACSourceURLDestinationURLThrowableError: Error?
var convertToMPEG4AACSourceURLDestinationURLCallsCount = 0
var convertToMPEG4AACSourceURLDestinationURLCalled: Bool {
return convertToMPEG4AACSourceURLDestinationURLCallsCount > 0
}
var convertToMPEG4AACSourceURLDestinationURLReceivedArguments: (sourceURL: URL, destinationURL: URL)?
var convertToMPEG4AACSourceURLDestinationURLReceivedInvocations: [(sourceURL: URL, destinationURL: URL)] = []
var convertToMPEG4AACSourceURLDestinationURLClosure: ((URL, URL) throws -> Void)?
func convertToMPEG4AAC(sourceURL: URL, destinationURL: URL) throws {
if let error = convertToMPEG4AACSourceURLDestinationURLThrowableError {
throw error
}
convertToMPEG4AACSourceURLDestinationURLCallsCount += 1
convertToMPEG4AACSourceURLDestinationURLReceivedArguments = (sourceURL: sourceURL, destinationURL: destinationURL)
convertToMPEG4AACSourceURLDestinationURLReceivedInvocations.append((sourceURL: sourceURL, destinationURL: destinationURL))
try convertToMPEG4AACSourceURLDestinationURLClosure?(sourceURL, destinationURL)
}
}
class AudioPlayerMock: AudioPlayerProtocol {
var actions: AnyPublisher<AudioPlayerAction, Never> {
get { return underlyingActions }
@@ -2191,6 +2234,67 @@ class UserNotificationCenterMock: UserNotificationCenterProtocol {
}
}
}
class VoiceMessageCacheMock: VoiceMessageCacheProtocol {
//MARK: - fileURL
var fileURLForCallsCount = 0
var fileURLForCalled: Bool {
return fileURLForCallsCount > 0
}
var fileURLForReceivedMediaSource: MediaSourceProxy?
var fileURLForReceivedInvocations: [MediaSourceProxy] = []
var fileURLForReturnValue: URL?
var fileURLForClosure: ((MediaSourceProxy) -> URL?)?
func fileURL(for mediaSource: MediaSourceProxy) -> URL? {
fileURLForCallsCount += 1
fileURLForReceivedMediaSource = mediaSource
fileURLForReceivedInvocations.append(mediaSource)
if let fileURLForClosure = fileURLForClosure {
return fileURLForClosure(mediaSource)
} else {
return fileURLForReturnValue
}
}
//MARK: - cache
var cacheMediaSourceUsingMoveThrowableError: Error?
var cacheMediaSourceUsingMoveCallsCount = 0
var cacheMediaSourceUsingMoveCalled: Bool {
return cacheMediaSourceUsingMoveCallsCount > 0
}
var cacheMediaSourceUsingMoveReceivedArguments: (mediaSource: MediaSourceProxy, fileURL: URL, move: Bool)?
var cacheMediaSourceUsingMoveReceivedInvocations: [(mediaSource: MediaSourceProxy, fileURL: URL, move: Bool)] = []
var cacheMediaSourceUsingMoveReturnValue: URL!
var cacheMediaSourceUsingMoveClosure: ((MediaSourceProxy, URL, Bool) throws -> URL)?
func cache(mediaSource: MediaSourceProxy, using fileURL: URL, move: Bool) throws -> URL {
if let error = cacheMediaSourceUsingMoveThrowableError {
throw error
}
cacheMediaSourceUsingMoveCallsCount += 1
cacheMediaSourceUsingMoveReceivedArguments = (mediaSource: mediaSource, fileURL: fileURL, move: move)
cacheMediaSourceUsingMoveReceivedInvocations.append((mediaSource: mediaSource, fileURL: fileURL, move: move))
if let cacheMediaSourceUsingMoveClosure = cacheMediaSourceUsingMoveClosure {
return try cacheMediaSourceUsingMoveClosure(mediaSource, fileURL, move)
} else {
return cacheMediaSourceUsingMoveReturnValue
}
}
//MARK: - clearCache
var clearCacheCallsCount = 0
var clearCacheCalled: Bool {
return clearCacheCallsCount > 0
}
var clearCacheClosure: (() -> Void)?
func clearCache() {
clearCacheCallsCount += 1
clearCacheClosure?()
}
}
class VoiceMessageMediaManagerMock: VoiceMessageMediaManagerProtocol {
//MARK: - loadVoiceMessageFromSource
@@ -77,7 +77,8 @@ extension View {
struct ShimmerOverlay_Previews: PreviewProvider, TestablePreview {
static let viewModel = HomeScreenViewModel(userSession: MockUserSession(clientProxy: MockClientProxy(userID: ""),
mediaProvider: MockMediaProvider()),
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock()),
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL,
mentionBuilder: MentionBuilder(mentionsEnabled: ServiceLocator.shared.settings.mentionsEnabled)),
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(),
@@ -75,7 +75,8 @@ struct WaitlistScreen_Previews: PreviewProvider, TestablePreview {
static let successViewModel = {
let viewModel = WaitlistScreenViewModel(homeserver: .mockMatrixDotOrg)
viewModel.update(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@alice:matrix.org"),
mediaProvider: MockMediaProvider()))
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock()))
return viewModel
}()
@@ -207,7 +207,8 @@ struct CreateRoomScreen: View {
struct CreateRoom_Previews: PreviewProvider, TestablePreview {
static let viewModel = {
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@userid:example.com"),
mediaProvider: MockMediaProvider())
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
let parameters = CreateRoomFlowParameters()
let selectedUsers: [UserProfileProxy] = [.mockAlice, .mockBob, .mockCharlie]
@@ -220,7 +221,8 @@ struct CreateRoom_Previews: PreviewProvider, TestablePreview {
static let emtpyViewModel = {
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@userid:example.com"),
mediaProvider: MockMediaProvider())
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
let parameters = CreateRoomFlowParameters()
return CreateRoomViewModel(userSession: userSession,
createRoomParameters: .init(parameters),
@@ -335,7 +335,8 @@ struct HomeScreen_Previews: PreviewProvider, TestablePreview {
static func viewModel(_ state: MockRoomSummaryProviderState) -> HomeScreenViewModel {
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@alice:example.com",
roomSummaryProvider: MockRoomSummaryProvider(state: state)),
mediaProvider: MockMediaProvider())
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
return HomeScreenViewModel(userSession: userSession,
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder(mentionsEnabled: ServiceLocator.shared.settings.mentionsEnabled)),
@@ -151,7 +151,8 @@ struct HomeScreenEmptyStateView_Previews: PreviewProvider, TestablePreview {
static let viewModel = {
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@user:example.com",
roomSummaryProvider: MockRoomSummaryProvider(state: .loaded([]))),
mediaProvider: MockMediaProvider())
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
return HomeScreenViewModel(userSession: userSession,
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder(mentionsEnabled: ServiceLocator.shared.settings.mentionsEnabled)),
@@ -185,7 +185,8 @@ struct HomeScreenRoomCell_Previews: PreviewProvider, TestablePreview {
let summaryProvider = MockRoomSummaryProvider(state: .loaded(.mockRooms))
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "John Doe", roomSummaryProvider: summaryProvider),
mediaProvider: MockMediaProvider())
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
let viewModel = HomeScreenViewModel(userSession: userSession,
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL,
@@ -80,7 +80,8 @@ struct InvitesScreen_Previews: PreviewProvider, TestablePreview {
private extension InvitesScreenViewModel {
static let noInvites: InvitesScreenViewModel = {
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@userid:example.com"),
mediaProvider: MockMediaProvider())
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
let regularViewModel = InvitesScreenViewModel(userSession: userSession,
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics,
@@ -93,7 +94,8 @@ private extension InvitesScreenViewModel {
clientProxy.inviteSummaryProvider = MockRoomSummaryProvider(state: .loaded(.mockInvites))
clientProxy.roomSummaryProvider = MockRoomSummaryProvider(state: .loaded(.mockInvites))
let userSession = MockUserSession(clientProxy: clientProxy,
mediaProvider: MockMediaProvider())
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
let regularViewModel = InvitesScreenViewModel(userSession: userSession,
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics,
@@ -76,7 +76,8 @@ struct NotificationSettingsEditScreen_Previews: PreviewProvider, TestablePreview
notificationSettingsProxy.getRoomsWithUserDefinedRulesReturnValue = [RoomSummary].mockRooms.compactMap(\.id)
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@alice:example.com",
roomSummaryProvider: MockRoomSummaryProvider(state: .loaded(.mockRooms))),
mediaProvider: MockMediaProvider())
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
var viewModel = NotificationSettingsEditScreenViewModel(chatType: .groupChat,
userSession: userSession,
notificationSettingsProxy: notificationSettingsProxy)
@@ -90,7 +91,8 @@ struct NotificationSettingsEditScreen_Previews: PreviewProvider, TestablePreview
notificationSettingsProxy.getRoomsWithUserDefinedRulesReturnValue = []
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@alice:example.com",
roomSummaryProvider: MockRoomSummaryProvider(state: .loaded(.mockRooms))),
mediaProvider: MockMediaProvider())
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
var viewModel = NotificationSettingsEditScreenViewModel(chatType: .oneToOneChat,
userSession: userSession,
notificationSettingsProxy: notificationSettingsProxy)
@@ -102,7 +104,7 @@ struct NotificationSettingsEditScreen_Previews: PreviewProvider, TestablePreview
let notificationSettingsProxy = NotificationSettingsProxyMock(with: .init())
notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneReturnValue = .mentionsAndKeywordsOnly
notificationSettingsProxy.getRoomsWithUserDefinedRulesReturnValue = []
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "John Doe"), mediaProvider: MockMediaProvider())
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "John Doe"), mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock())
var viewModel = NotificationSettingsEditScreenViewModel(chatType: .oneToOneChat,
userSession: userSession,
@@ -62,7 +62,8 @@ struct NotificationSettingsEditScreenRoomCell_Previews: PreviewProvider, Testabl
let summaryProvider = MockRoomSummaryProvider(state: .loaded(.mockRooms))
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "John Doe", roomSummaryProvider: summaryProvider),
mediaProvider: MockMediaProvider())
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
let notificationSettingsProxy = NotificationSettingsProxyMock(with: .init())
notificationSettingsProxy.getRoomsWithUserDefinedRulesReturnValue = []
@@ -199,7 +199,8 @@ struct NotificationSettingsScreen_Previews: PreviewProvider, TestablePreview {
notificationSettingsProxy.isRoomMentionEnabledReturnValue = true
notificationSettingsProxy.isCallEnabledReturnValue = false
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "John Doe"), mediaProvider: MockMediaProvider())
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "John Doe"), mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
var viewModel = NotificationSettingsScreenViewModel(userSession: userSession,
appSettings: appSettings,
@@ -228,7 +229,7 @@ struct NotificationSettingsScreen_Previews: PreviewProvider, TestablePreview {
notificationSettingsProxy.isRoomMentionEnabledReturnValue = true
notificationSettingsProxy.isCallEnabledReturnValue = false
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "John Doe"), mediaProvider: MockMediaProvider())
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "John Doe"), mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock())
var viewModel = NotificationSettingsScreenViewModel(userSession: userSession,
appSettings: appSettings,
@@ -235,7 +235,8 @@ struct SettingsScreen_Previews: PreviewProvider, TestablePreview {
let userSession = MockUserSession(sessionVerificationController: verificationController,
clientProxy: MockClientProxy(userID: "@userid:example.com",
deviceID: "AAAAAAAAAAA"),
mediaProvider: MockMediaProvider())
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
return SettingsScreenViewModel(userSession: userSession,
appSettings: ServiceLocator.shared.settings)
}()
@@ -137,7 +137,8 @@ struct StartChatScreen: View {
struct StartChatScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = {
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@userid:example.com"),
mediaProvider: MockMediaProvider())
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
let userDiscoveryService = UserDiscoveryServiceMock()
userDiscoveryService.fetchSuggestionsReturnValue = .success([.mockAlice])
userDiscoveryService.searchProfilesWithReturnValue = .success([.mockAlice])
@@ -22,7 +22,12 @@ enum AudioConverterError: Error {
case conversionFailed(Error?)
}
struct AudioConverter {
enum AudioConverterPreferredFileExtension: String {
case mpeg4aac = "m4a"
case ogg
}
struct AudioConverter: AudioConverterProtocol {
func convertToOpusOgg(sourceURL: URL, destinationURL: URL) throws {
do {
try OGGConverter.convertM4aFileToOpusOGG(src: sourceURL, dest: destinationURL)
@@ -0,0 +1,25 @@
//
// 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 AudioConverterProtocol {
func convertToOpusOgg(sourceURL: URL, destinationURL: URL) throws
func convertToMPEG4AAC(sourceURL: URL, destinationURL: URL) throws
}
// sourcery: AutoMockable
extension AudioConverterProtocol { }
@@ -63,7 +63,8 @@ class MockAuthenticationServiceProxy: AuthenticationServiceProxyProtocol {
}
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: username),
mediaProvider: MockMediaProvider())
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
return .success(userSession)
}
}
@@ -47,7 +47,11 @@ struct MockMediaProvider: MediaProviderProtocol {
return .success(data)
}
var loadFileFromSourceReturnValue: MediaFileHandleProxy?
func loadFileFromSource(_ source: MediaSourceProxy, body: String?) async -> Result<MediaFileHandleProxy, MediaProviderError> {
.failure(.failedRetrievingFile)
if let loadFileFromSourceReturnValue {
return .success(loadFileFromSourceReturnValue)
}
return .failure(.failedRetrievingFile)
}
}
@@ -24,4 +24,5 @@ struct MockUserSession: UserSessionProtocol {
var homeserver: String { clientProxy.homeserver }
let clientProxy: ClientProxyProtocol
let mediaProvider: MediaProviderProtocol
let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol
}
@@ -28,12 +28,14 @@ class UserSession: UserSessionProtocol {
let clientProxy: ClientProxyProtocol
let mediaProvider: MediaProviderProtocol
let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol
let callbacks = PassthroughSubject<UserSessionCallback, Never>()
private(set) var sessionVerificationController: SessionVerificationControllerProxyProtocol?
init(clientProxy: ClientProxyProtocol, mediaProvider: MediaProviderProtocol) {
init(clientProxy: ClientProxyProtocol, mediaProvider: MediaProviderProtocol, voiceMessageMediaManager: VoiceMessageMediaManagerProtocol) {
self.clientProxy = clientProxy
self.mediaProvider = mediaProvider
self.voiceMessageMediaManager = voiceMessageMediaManager
setupSessionVerificationWatchdog()
setupAuthErrorWatchdog()
@@ -30,6 +30,7 @@ protocol UserSessionProtocol {
var clientProxy: ClientProxyProtocol { get }
var mediaProvider: MediaProviderProtocol { get }
var voiceMessageMediaManager: VoiceMessageMediaManagerProtocol { get }
var sessionVerificationController: SessionVerificationControllerProxyProtocol? { get }
@@ -94,10 +94,16 @@ class UserSessionStore: UserSessionStoreProtocol {
let imageCache = ImageCache.onlyInMemory
imageCache.memoryStorage.config.keepWhenEnteringBackground = true
let mediaProvider = MediaProvider(mediaLoader: clientProxy,
imageCache: imageCache,
backgroundTaskService: backgroundTaskService)
let voiceMessageMediaManager = VoiceMessageMediaManager(mediaProvider: mediaProvider,
backgroundTaskService: backgroundTaskService)
return UserSession(clientProxy: clientProxy,
mediaProvider: MediaProvider(mediaLoader: clientProxy,
imageCache: imageCache,
backgroundTaskService: backgroundTaskService))
mediaProvider: mediaProvider,
voiceMessageMediaManager: voiceMessageMediaManager)
}
private func restorePreviousLogin(_ credentials: KeychainCredentials) async -> Result<ClientProxyProtocol, UserSessionStoreError> {
@@ -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
}
}
}
@@ -164,7 +164,8 @@ class MockScreen: Identifiable {
case .home:
let navigationStackCoordinator = NavigationStackCoordinator()
let session = MockUserSession(clientProxy: MockClientProxy(userID: "@mock:matrix.org"),
mediaProvider: MockMediaProvider())
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
let coordinator = HomeScreenCoordinator(parameters: .init(userSession: session,
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder(mentionsEnabled: true)),
bugReportService: BugReportServiceMock(),
@@ -177,7 +178,7 @@ class MockScreen: Identifiable {
let clientProxy = MockClientProxy(userID: "@mock:client.com")
let coordinator = SettingsScreenCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator,
userIndicatorController: nil,
userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()),
userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock()),
bugReportService: BugReportServiceMock(),
notificationSettings: NotificationSettingsProxyMock(with: .init()),
appSettings: ServiceLocator.shared.settings))
@@ -207,7 +208,8 @@ class MockScreen: Identifiable {
let userNotificationCenter = UserNotificationCenterMock()
userNotificationCenter.authorizationStatusReturnValue = .denied
let session = MockUserSession(clientProxy: MockClientProxy(userID: "@mock:matrix.org"),
mediaProvider: MockMediaProvider())
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
let parameters = NotificationSettingsScreenCoordinatorParameters(userSession: session,
userNotificationCenter: userNotificationCenter,
notificationSettings: NotificationSettingsProxyMock(with: .init()),
@@ -217,7 +219,8 @@ class MockScreen: Identifiable {
let userNotificationCenter = UserNotificationCenterMock()
userNotificationCenter.authorizationStatusReturnValue = .denied
let session = MockUserSession(clientProxy: MockClientProxy(userID: "@mock:matrix.org"),
mediaProvider: MockMediaProvider())
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
let notificationSettings = NotificationSettingsProxyMock(with: .init())
notificationSettings.getDefaultRoomNotificationModeIsEncryptedIsOneToOneClosure = { isEncrypted, isOneToOne in
switch (isEncrypted, isOneToOne) {
@@ -439,7 +442,7 @@ class MockScreen: Identifiable {
ServiceLocator.shared.settings.migratedAccounts[clientProxy.userID] = true
ServiceLocator.shared.settings.hasShownWelcomeScreen = true
let coordinator = UserSessionFlowCoordinator(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider()),
let coordinator = UserSessionFlowCoordinator(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock()),
navigationSplitCoordinator: navigationSplitCoordinator,
bugReportService: BugReportServiceMock(),
roomTimelineControllerFactory: MockRoomTimelineControllerFactory(),
@@ -614,7 +617,7 @@ class MockScreen: Identifiable {
let userDiscoveryMock = UserDiscoveryServiceMock()
userDiscoveryMock.fetchSuggestionsReturnValue = .success([.mockAlice, .mockBob, .mockCharlie])
userDiscoveryMock.searchProfilesWithReturnValue = .success([])
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), mediaProvider: MockMediaProvider())
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"), mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock())
let parameters: StartChatScreenCoordinatorParameters = .init(userSession: userSession, navigationStackCoordinator: navigationStackCoordinator, userDiscoveryService: userDiscoveryMock)
let coordinator = StartChatScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator)
@@ -625,7 +628,7 @@ class MockScreen: Identifiable {
let userDiscoveryMock = UserDiscoveryServiceMock()
userDiscoveryMock.fetchSuggestionsReturnValue = .success([])
userDiscoveryMock.searchProfilesWithReturnValue = .success([.mockBob, .mockBobby])
let userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider())
let userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock())
let coordinator = StartChatScreenCoordinator(parameters: .init(userSession: userSession, navigationStackCoordinator: navigationStackCoordinator, userDiscoveryService: userDiscoveryMock))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
@@ -662,7 +665,7 @@ class MockScreen: Identifiable {
let summaryProvider = MockRoomSummaryProvider(state: .loaded(.mockInvites))
clientProxy.inviteSummaryProvider = summaryProvider
let coordinator = InvitesScreenCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider())))
let coordinator = InvitesScreenCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock())))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .invites:
@@ -674,13 +677,13 @@ class MockScreen: Identifiable {
let summaryProvider = MockRoomSummaryProvider(state: .loaded(.mockInvites))
clientProxy.inviteSummaryProvider = summaryProvider
let coordinator = InvitesScreenCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider())))
let coordinator = InvitesScreenCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock())))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .invitesNoInvites:
let navigationStackCoordinator = NavigationStackCoordinator()
let clientProxy = MockClientProxy(userID: "@mock:client.com")
let coordinator = InvitesScreenCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider())))
let coordinator = InvitesScreenCoordinator(parameters: .init(userSession: MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock())))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .inviteUsers, .inviteUsersInRoom, .inviteUsersInRoomExistingMembers:
@@ -715,7 +718,7 @@ class MockScreen: Identifiable {
case .createRoom:
let navigationStackCoordinator = NavigationStackCoordinator()
let clientProxy = MockClientProxy(userID: "@mock:client.com")
let mockUserSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider())
let mockUserSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock())
let createRoomParameters = CreateRoomFlowParameters()
let selectedUsers: [UserProfileProxy] = [.mockAlice, .mockBob, .mockCharlie]
let parameters = CreateRoomCoordinatorParameters(userSession: mockUserSession, userIndicatorController: nil, createRoomParameters: .init(createRoomParameters), selectedUsers: .init(selectedUsers))
@@ -725,7 +728,7 @@ class MockScreen: Identifiable {
case .createRoomNoUsers:
let navigationStackCoordinator = NavigationStackCoordinator()
let clientProxy = MockClientProxy(userID: "@mock:client.com")
let mockUserSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider())
let mockUserSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider(), voiceMessageMediaManager: VoiceMessageMediaManagerMock())
let createRoomParameters = CreateRoomFlowParameters()
let parameters = CreateRoomCoordinatorParameters(userSession: mockUserSession, userIndicatorController: nil, createRoomParameters: .init(createRoomParameters), selectedUsers: .init([]))
let coordinator = CreateRoomCoordinator(parameters: parameters)