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
@@ -35,7 +35,9 @@ class CreateRoomScreenViewModelTests: XCTestCase {
override func setUpWithError() throws {
cancellables.removeAll()
clientProxy = MockClientProxy(userID: "@a:b.com")
userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider())
userSession = MockUserSession(clientProxy: clientProxy,
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
let parameters = CreateRoomFlowParameters()
usersSubject.send([.mockAlice, .mockBob, .mockCharlie])
let viewModel = CreateRoomViewModel(userSession: userSession,
@@ -30,7 +30,8 @@ class HomeScreenViewModelTests: XCTestCase {
cancellables.removeAll()
clientProxy = MockClientProxy(userID: "@mock:client.com")
viewModel = HomeScreenViewModel(userSession: MockUserSession(clientProxy: clientProxy,
mediaProvider: MockMediaProvider()),
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock()),
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL, mentionBuilder: MentionBuilder(mentionsEnabled: true)),
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(),
appSettings: ServiceLocator.shared.settings,
@@ -30,7 +30,9 @@ class InvitesScreenViewModelTests: XCTestCase {
override func setUpWithError() throws {
clientProxy = MockClientProxy(userID: "@a:b.com")
userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider())
userSession = MockUserSession(clientProxy: clientProxy,
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
mockNotificationCenter = NotificationCenterMock()
}
@@ -24,7 +24,9 @@ import XCTest
final class NotificationManagerTests: XCTestCase {
var notificationManager: NotificationManager!
private let clientProxy = MockClientProxy(userID: "@test:user.net")
private lazy var mockUserSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider())
private lazy var mockUserSession = MockUserSession(clientProxy: clientProxy,
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
private var notificationCenter: UserNotificationCenterMock!
private var authorizationStatusWasGranted = false
private var shouldDisplayInAppNotificationReturnValue = false
@@ -31,7 +31,9 @@ class NotificationSettingsEditScreenViewModelTests: XCTestCase {
@MainActor override func setUpWithError() throws {
let clientProxy = MockClientProxy(userID: "@a:b.com")
userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider())
userSession = MockUserSession(clientProxy: clientProxy,
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
notificationSettingsProxy = NotificationSettingsProxyMock(with: NotificationSettingsProxyMockConfiguration())
notificationSettingsProxy.getDefaultRoomNotificationModeIsEncryptedIsOneToOneReturnValue = .allMessages
}
@@ -40,7 +40,9 @@ class NotificationSettingsScreenViewModelTests: XCTestCase {
notificationSettingsProxy.isCallEnabledReturnValue = true
let clientProxy = MockClientProxy(userID: "@a:b.com")
userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider())
userSession = MockUserSession(clientProxy: clientProxy,
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
viewModel = NotificationSettingsScreenViewModel(userSession: userSession,
appSettings: appSettings,
@@ -29,7 +29,10 @@ class RoomFlowCoordinatorTests: XCTestCase {
cancellables.removeAll()
let clientProxy = MockClientProxy(userID: "hi@bob", roomSummaryProvider: MockRoomSummaryProvider(state: .loaded(.mockRooms)))
let mediaProvider = MockMediaProvider()
let userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: mediaProvider)
let voiceMessageMediaManager = VoiceMessageMediaManagerMock()
let userSession = MockUserSession(clientProxy: clientProxy,
mediaProvider: mediaProvider,
voiceMessageMediaManager: voiceMessageMediaManager)
let navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: PlaceholderScreenCoordinator())
navigationStackCoordinator = NavigationStackCoordinator()
@@ -28,7 +28,8 @@ class SettingsScreenViewModelTests: XCTestCase {
@MainActor override func setUpWithError() throws {
cancellables.removeAll()
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: ""),
mediaProvider: MockMediaProvider())
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
viewModel = SettingsScreenViewModel(userSession: userSession, appSettings: ServiceLocator.shared.settings)
context = viewModel.context
}
@@ -33,7 +33,9 @@ class StartChatScreenViewModelTests: XCTestCase {
userDiscoveryService = UserDiscoveryServiceMock()
userDiscoveryService.fetchSuggestionsReturnValue = .success([])
userDiscoveryService.searchProfilesWithReturnValue = .success([])
let userSession = MockUserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider())
let userSession = MockUserSession(clientProxy: clientProxy,
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
viewModel = StartChatScreenViewModel(userSession: userSession,
appSettings: ServiceLocator.shared.settings,
analytics: ServiceLocator.shared.analytics,
@@ -25,7 +25,9 @@ final class UserSessionTests: XCTestCase {
override func setUpWithError() throws {
cancellables.removeAll()
userSession = UserSession(clientProxy: clientProxy, mediaProvider: MockMediaProvider())
userSession = UserSession(clientProxy: clientProxy,
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock())
}
func test_whenUserSessionReceivesSyncUpdateAndSessionControllerRetrievedAndSessionNotVerified_sessionVerificationNeededEventReceived() throws {
@@ -0,0 +1,104 @@
//
// 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 Combine
@testable import ElementX
import Foundation
import XCTest
@MainActor
class VoiceMessageCacheTests: XCTestCase {
private var voiceMessageCache: VoiceMessageCache!
private var mediaSource: MediaSourceProxy!
private var fileManager: FileManager!
private let someURL = URL("/some/url")
private let cachedFileURL = URL("/cache/file/url")
private let audioOGGMimeType = "audio/ogg"
private let testFilename = "test-file"
private let mpeg4aacFileExtension = "m4a"
private let testTemporaryDirectory = URL.temporaryDirectory.appendingPathComponent("test-voice-messsage-cache")
override func setUp() async throws {
voiceMessageCache = VoiceMessageCache()
voiceMessageCache.clearCache()
fileManager = FileManager.default
mediaSource = MediaSourceProxy(url: someURL, mimeType: "audio/ogg")
// Create the temporary directory we will use
try fileManager.createDirectory(at: testTemporaryDirectory, withIntermediateDirectories: true)
}
override func tearDown() async throws {
voiceMessageCache.clearCache()
// clear the test temporary directory
try fileManager.removeItem(at: testTemporaryDirectory)
}
func testFileURL() async throws {
// If the file is not already in the cache, no URL is expected
XCTAssertNil(voiceMessageCache.fileURL(for: mediaSource))
// If the file is present in the cache, its URL must be returned
let temporaryFileURL = try createTemporaryFile(named: testFilename, withExtension: mpeg4aacFileExtension)
let cachedURL = try voiceMessageCache.cache(mediaSource: mediaSource, using: temporaryFileURL, move: true)
XCTAssertEqual(cachedURL, voiceMessageCache.fileURL(for: mediaSource))
}
func testCacheInvalidFileExtension() async throws {
// An error should be raised if the file extension is not "m4a"
let mpegFileURL = try createTemporaryFile(named: testFilename, withExtension: "mpg")
do {
_ = try voiceMessageCache.cache(mediaSource: mediaSource, using: mpegFileURL, move: true)
XCTFail("An error is expected")
} catch {
switch error as? VoiceMessageCacheError {
case .invalidFileExtension:
break
default:
XCTFail("A VoiceMessageCacheError.invalidFileExtension is expected")
}
}
}
func testCacheCopy() async throws {
let fileURL = try createTemporaryFile(named: testFilename, withExtension: mpeg4aacFileExtension)
let cacheURL = try voiceMessageCache.cache(mediaSource: mediaSource, using: fileURL, move: false)
// The source file must remain in its original location
XCTAssertTrue(fileManager.fileExists(atPath: fileURL.path()))
// A copy must be present in the cache
XCTAssertTrue(fileManager.fileExists(atPath: cacheURL.path()))
}
func testCacheMove() async throws {
let fileURL = try createTemporaryFile(named: testFilename, withExtension: mpeg4aacFileExtension)
let cacheURL = try voiceMessageCache.cache(mediaSource: mediaSource, using: fileURL, move: true)
// The file must have been moved
XCTAssertFalse(fileManager.fileExists(atPath: fileURL.path()))
XCTAssertTrue(fileManager.fileExists(atPath: cacheURL.path()))
}
private func createTemporaryFile(named filename: String, withExtension fileExtension: String) throws -> URL {
let temporaryFileURL = testTemporaryDirectory.appendingPathComponent(filename).appendingPathExtension(fileExtension)
try Data().write(to: temporaryFileURL)
return temporaryFileURL
}
}
@@ -0,0 +1,150 @@
//
// 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 Combine
@testable import ElementX
import Foundation
import XCTest
@MainActor
class VoiceMessageMediaManagerTests: XCTestCase {
private var voiceMessageMediaManager: VoiceMessageMediaManager!
private var voiceMessageCache: VoiceMessageCacheMock!
private var mediaProvider: MockMediaProvider!
private let someURL = URL("/some/url")
private let audioOGGMimeType = "audio/ogg"
override func setUp() async throws {
voiceMessageCache = VoiceMessageCacheMock()
mediaProvider = MockMediaProvider()
voiceMessageMediaManager = VoiceMessageMediaManager(mediaProvider: mediaProvider,
voiceMessageCache: voiceMessageCache,
backgroundTaskService: MockBackgroundTaskService())
}
func testLoadVoiceMessageFromSourceUnsupportedMedia() async throws {
// Only "audio/ogg" file are supported
let unsupportedMediaSource = MediaSourceProxy(url: someURL, mimeType: "audio/wav")
do {
_ = try await voiceMessageMediaManager.loadVoiceMessageFromSource(unsupportedMediaSource, body: nil)
XCTFail("A `VoiceMessageMediaManagerError.unsupportedMimeTye` error is expected")
} catch {
switch error as? VoiceMessageMediaManagerError {
case .unsupportedMimeTye:
break
default:
XCTFail("A `VoiceMessageMediaManagerError.unsupportedMimeTye` error is expected")
}
}
}
func testLoadVoiceMessageFromSourceAlreadyCached() async throws {
// Check if the file is already present in cache
voiceMessageCache.fileURLForReturnValue = URL("/converted_file/url")
let mediaSource = MediaSourceProxy(url: someURL, mimeType: audioOGGMimeType)
let url = try await voiceMessageMediaManager.loadVoiceMessageFromSource(mediaSource, body: nil)
XCTAssertEqual(url, URL("/converted_file/url"))
// The file must have be search in the cache
XCTAssertTrue(voiceMessageCache.fileURLForCalled)
XCTAssertEqual(voiceMessageCache.fileURLForReceivedMediaSource, mediaSource)
// The file must not have been cached again
XCTAssertFalse(voiceMessageCache.cacheMediaSourceUsingMoveCalled)
}
func testLoadVoiceMessageFromSourceMediaProviderError() async throws {
// An error must be reported if the file cannot be retrieved
do {
voiceMessageCache.fileURLForReturnValue = nil
let mediaSource = MediaSourceProxy(url: someURL, mimeType: audioOGGMimeType)
_ = try await voiceMessageMediaManager.loadVoiceMessageFromSource(mediaSource, body: nil)
XCTFail("A `MediaProviderError.failedRetrievingFile` error is expected")
} catch {
switch error as? MediaProviderError {
case .failedRetrievingFile:
break
default:
XCTFail("A `MediaProviderError.failedRetrievingFile` error is expected")
}
}
}
func testLoadVoiceMessageFromSourceSingleCall() async throws {
// URL representing the file loaded by the media provider
let loadedFile = URL("/some/url/loaded_file")
// URL representing the final cached file
let cachedConvertedFileURL = URL("/some/url/cached_converted_file")
// Check if the file is not already present in cache
voiceMessageCache.fileURLForReturnValue = nil
let mediaSource = MediaSourceProxy(url: someURL, mimeType: audioOGGMimeType)
mediaProvider.loadFileFromSourceReturnValue = MediaFileHandleProxy.unmanaged(url: loadedFile)
let audioConverter = AudioConverterMock()
voiceMessageCache.cacheMediaSourceUsingMoveReturnValue = cachedConvertedFileURL
voiceMessageMediaManager = VoiceMessageMediaManager(mediaProvider: mediaProvider,
voiceMessageCache: voiceMessageCache,
audioConverter: audioConverter,
backgroundTaskService: MockBackgroundTaskService())
let url = try await voiceMessageMediaManager.loadVoiceMessageFromSource(mediaSource, body: nil)
// The file must have been converted
XCTAssertTrue(audioConverter.convertToMPEG4AACSourceURLDestinationURLCalled)
// The converted file must have been cached
XCTAssert(voiceMessageCache.cacheMediaSourceUsingMoveCalled)
XCTAssertEqual(voiceMessageCache.cacheMediaSourceUsingMoveReceivedArguments?.mediaSource, mediaSource)
XCTAssertEqual(voiceMessageCache.cacheMediaSourceUsingMoveReceivedArguments?.fileURL.pathExtension, "m4a")
XCTAssertTrue(voiceMessageCache.cacheMediaSourceUsingMoveReceivedArguments?.move ?? false)
// The returned URL must point to the cached converted file
XCTAssertEqual(url, cachedConvertedFileURL)
}
func testLoadVoiceMessageFromSourceMultipleCalls() async throws {
// URL representing the file loaded by the media provider
let loadedFile = URL("/some/url/loaded_file")
// URL representing the final cached file
let cachedConvertedFileURL = URL("/some/url/cached_converted_file")
// Multiple calls
var cachedURL: URL?
voiceMessageCache.fileURLForClosure = { _ in
cachedURL
}
voiceMessageCache.cacheMediaSourceUsingMoveClosure = { _, _, _ in
cachedURL = cachedConvertedFileURL
return cachedConvertedFileURL
}
let audioConverter = AudioConverterMock()
mediaProvider.loadFileFromSourceReturnValue = MediaFileHandleProxy.unmanaged(url: loadedFile)
voiceMessageMediaManager = VoiceMessageMediaManager(mediaProvider: mediaProvider,
voiceMessageCache: voiceMessageCache,
audioConverter: audioConverter,
backgroundTaskService: MockBackgroundTaskService())
let mediaSource = MediaSourceProxy(url: someURL, mimeType: audioOGGMimeType)
for _ in 0..<10 {
let url = try await voiceMessageMediaManager.loadVoiceMessageFromSource(mediaSource, body: nil)
XCTAssertEqual(url, cachedConvertedFileURL)
}
// The file must have been converted only once
XCTAssertEqual(audioConverter.convertToMPEG4AACSourceURLDestinationURLCallsCount, 1)
// The converted file must have been cached only once
XCTAssertEqual(voiceMessageCache.cacheMediaSourceUsingMoveCallsCount, 1)
}
}
@@ -32,7 +32,8 @@ class WaitlistScreenViewModelTests: XCTestCase {
XCTAssertTrue(context.viewState.isWaiting, "The view should start off in the waiting state.")
viewModel.update(userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@alice:matrix.org"),
mediaProvider: MockMediaProvider()))
mediaProvider: MockMediaProvider(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock()))
XCTAssertNotNil(context.viewState.userSession, "The user session should have been updated.")
XCTAssertFalse(context.viewState.isWaiting, "The view should not be in the waiting state after setting a user session.")