From 86bc4d54b6638a8b67d6f844d99483280c5b7274 Mon Sep 17 00:00:00 2001 From: Mauro <34335419+Velin92@users.noreply.github.com> Date: Tue, 1 Sep 2026 18:00:14 +0200 Subject: [PATCH] Autoplay the next voice message if it is the next timeline item. (#6089) --- ElementX.xcodeproj/project.pbxproj | 4 + .../Mocks/Generated/GeneratedMocks.swift | 31 +++ ElementX/Sources/Other/Extensions/Array.swift | 14 ++ ElementX/Sources/Other/Extensions/URL.swift | 10 + .../Timeline/TimelineInteractionHandler.swift | 50 ++++ .../AlertTones/NotificationToneManager.swift | 10 +- .../Services/Audio/Player/AudioPlayer.swift | 17 +- .../Audio/Player/AudioPlayerState.swift | 1 - .../MediaPlayer/MediaPlayerProvider.swift | 14 ++ .../MediaPlayerProviderProtocol.swift | 15 ++ UnitTests/Sources/ArrayTests.swift | 81 +++++++ UnitTests/Sources/AudioPlayerStateTests.swift | 17 ++ .../TimelineInteractionHandlerTests.swift | 215 ++++++++++++++++++ 13 files changed, 463 insertions(+), 16 deletions(-) create mode 100644 UnitTests/Sources/TimelineInteractionHandlerTests.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index d1423b900..015799a9d 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -1475,6 +1475,7 @@ EC1A0E85CEE50BF0C64EEFA5 /* LabsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4D9DF4F2DF3507F99B5B97B /* LabsScreenViewModel.swift */; }; EC3320639828BED8B3E5F2C6 /* EncryptionResetScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5875F7C0A2398E9F134B1284 /* EncryptionResetScreenViewModel.swift */; }; EC5B6D717560B9E275A0EA2F /* landscape_test_image.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 028CAFDBB60E2DBB24C3F621 /* landscape_test_image.jpg */; }; + ECE4E03D17E309724013F825 /* TimelineInteractionHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53C65A839BC897F6C236E8A5 /* TimelineInteractionHandlerTests.swift */; }; ED2BEDA7B074542A4D217ADF /* StaticLocationData.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECFB21A2FF41CF0E118790DD /* StaticLocationData.swift */; }; ED3E91E6166E4923791ACA84 /* ResolveVerifiedUserSendFailureScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56852036214ABA9D7D305768 /* ResolveVerifiedUserSendFailureScreenViewModelProtocol.swift */; }; ED635D7F00FA07E94D3CE1E8 /* PreviewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B796D347E53631576F631C /* PreviewTests.swift */; }; @@ -2238,6 +2239,7 @@ 53482ECA4B6633961EC224F5 /* ScrollViewAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewAdapter.swift; sourceTree = ""; }; 5351EBD7A0B9610548E4B7B2 /* EncryptedRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptedRoomTimelineItem.swift; sourceTree = ""; }; 536E72DCBEEC4A1FE66CFDCE /* target.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = target.yml; sourceTree = ""; }; + 53C65A839BC897F6C236E8A5 /* TimelineInteractionHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineInteractionHandlerTests.swift; sourceTree = ""; }; 53F41CEAAE2BB4E74CDC2278 /* TimelineMediaPreviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewViewModel.swift; sourceTree = ""; }; 5413213B56FB76C1071E2BFB /* SearchScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreenViewModelTests.swift; sourceTree = ""; }; 5445FCE0CE15E634FDC1A2E2 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; @@ -5276,6 +5278,7 @@ 18B223FA339BF53085328DEE /* SpaceScreenViewModelTests.swift */, 6DF438EAFC732D2D95D34BF6 /* StartChatViewModelTests.swift */, 2CEBCB9676FCD1D0F13188DD /* StringTests.swift */, + 53C65A839BC897F6C236E8A5 /* TimelineInteractionHandlerTests.swift */, 9AA3AF94A06D319BB37E52DA /* TimelineItemFactoryTests.swift */, C98C243A90369929D669873D /* TimelineItemMenuActionProviderTests.swift */, ED0AD0C652385F69FA90FAF5 /* TimelineMediaPreviewDataSourceTests.swift */, @@ -8374,6 +8377,7 @@ 6189B4ABD535CE526FA1107B /* StartChatViewModelTests.swift in Sources */, 1FEC0A4EC6E6DF693C16B32A /* StringTests.swift in Sources */, E75CE800B3E64D0F7F8E228D /* TemplateScreenViewModelTests.swift in Sources */, + ECE4E03D17E309724013F825 /* TimelineInteractionHandlerTests.swift in Sources */, 0D4EB2ABAA5FE8CB10FDBCB8 /* TimelineItemFactoryTests.swift in Sources */, 0668BECCF11E8EE6936CDF16 /* TimelineItemMenuActionProviderTests.swift in Sources */, C02DE5F62C81FB9E173C3D2F /* TimelineMediaPreviewDataSourceTests.swift in Sources */, diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index 1e2dfbd92..a738736b2 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -8081,6 +8081,37 @@ nonisolated class MediaPlayerProviderMock: MediaPlayerProviderProtocol, @uncheck detachAllStatesExceptReceivedInvocationsLock.withLock { detachAllStatesExceptUnderlyingReceivedInvocations.append(exception) } await detachAllStatesExceptClosure?(exception) } + //MARK: - play + + private let playSoundEffectCallsCountLock = NSLock() + private nonisolated(unsafe) var playSoundEffectUnderlyingCallsCount = 0 + var playSoundEffectCallsCount: Int { + get { playSoundEffectCallsCountLock.withLock { playSoundEffectUnderlyingCallsCount } } + set { playSoundEffectCallsCountLock.withLock { playSoundEffectUnderlyingCallsCount = newValue } } + } + var playSoundEffectCalled: Bool { + return playSoundEffectCallsCount > 0 + } + private let playSoundEffectReceivedSoundEffectLock = NSLock() + private nonisolated(unsafe) var playSoundEffectUnderlyingReceivedSoundEffect: SoundEffect? + var playSoundEffectReceivedSoundEffect: SoundEffect? { + get { playSoundEffectReceivedSoundEffectLock.withLock { playSoundEffectUnderlyingReceivedSoundEffect } } + set { playSoundEffectReceivedSoundEffectLock.withLock { playSoundEffectUnderlyingReceivedSoundEffect = newValue } } + } + private let playSoundEffectReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var playSoundEffectUnderlyingReceivedInvocations: [SoundEffect] = [] + var playSoundEffectReceivedInvocations: [SoundEffect] { + get { playSoundEffectReceivedInvocationsLock.withLock { playSoundEffectUnderlyingReceivedInvocations } } + set { playSoundEffectReceivedInvocationsLock.withLock { playSoundEffectUnderlyingReceivedInvocations = newValue } } + } + nonisolated(unsafe) var playSoundEffectClosure: ((SoundEffect) -> Void)? + + func play(soundEffect: SoundEffect) { + playSoundEffectCallsCountLock.withLock { playSoundEffectUnderlyingCallsCount += 1 } + playSoundEffectReceivedSoundEffect = soundEffect + playSoundEffectReceivedInvocationsLock.withLock { playSoundEffectUnderlyingReceivedInvocations.append(soundEffect) } + playSoundEffectClosure?(soundEffect) + } } nonisolated class MediaProviderMock: MediaProviderProtocol, @unchecked Sendable { diff --git a/ElementX/Sources/Other/Extensions/Array.swift b/ElementX/Sources/Other/Extensions/Array.swift index ba2996842..a7fee5450 100644 --- a/ElementX/Sources/Other/Extensions/Array.swift +++ b/ElementX/Sources/Other/Extensions/Array.swift @@ -64,4 +64,18 @@ nonisolated extension Array where Element == RoomTimelineItemProtocol { func firstUsingStableID(_ id: TimelineItemIdentifier) -> Element? { first { $0.id.uniqueID == id.uniqueID } } + + /// The voice message that directly follows the item with the given ID, if there is one. + /// + /// Decorations such as date separators and the read marker are ignored, but any other + /// kind of item in between means that no voice message directly follows. + func voiceMessageDirectlyFollowing(_ id: TimelineItemIdentifier) -> VoiceMessageRoomTimelineItem? { + guard let index = firstIndex(where: { $0.id.uniqueID == id.uniqueID }) else { return nil } + + for item in self[index...].dropFirst() where !(item is DecorationTimelineItemProtocol) { + return item as? VoiceMessageRoomTimelineItem + } + + return nil + } } diff --git a/ElementX/Sources/Other/Extensions/URL.swift b/ElementX/Sources/Other/Extensions/URL.swift index 14336ce8b..d8e2cbfdd 100644 --- a/ElementX/Sources/Other/Extensions/URL.swift +++ b/ElementX/Sources/Other/Extensions/URL.swift @@ -93,6 +93,16 @@ nonisolated extension URL { return url } + /// The directory holding the sounds that iOS uses for its own interface. + static let systemSoundsDirectory: URL = { + let systemRoot = if let simulatorRoot = ProcessInfo.processInfo.environment["SIMULATOR_ROOT"] { + URL(filePath: simulatorRoot) + } else { + URL(filePath: "/") + } + return systemRoot.appending(components: "System", "Library", "Audio", "UISounds", directoryHint: .isDirectory) + }() + var globalProxy: String? { guard let proxySettings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() else { MXLog.error("Failed retrieving proxy settings") diff --git a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift index 3bb361619..da660e98f 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift @@ -59,6 +59,15 @@ class TimelineInteractionHandler { private var resumeVoiceMessagePlaybackAfterScrubbing = false + /// The voice message playback that was last asked for, and whether the player has begun it. + private enum VoiceMessagePlayback { + case requested(TimelineItemIdentifier) + case playing(TimelineItemIdentifier) + } + + private var voiceMessagePlayback: VoiceMessagePlayback? + private var audioPlayerActionsCancellable: AnyCancellable? + private var emojiPickerCancellable: AnyCancellable? init(roomProxy: JoinedRoomProxyProtocol, @@ -449,8 +458,19 @@ class TimelineInteractionHandler { return } + // Loading a new message tears the player down, which cancels any autoplay that was pending for the previous one. + voiceMessagePlayback = .requested(itemID) + let audioPlayer = mediaPlayerProvider.player + // Observe the player lazily so that the mocks used by the previews and the UI tests don't need one. + if audioPlayerActionsCancellable == nil { + audioPlayerActionsCancellable = audioPlayer.actions + .sink { [weak self] action in + Task { await self?.handleAudioPlayerAction(action) } + } + } + // Stop any recording in progress if voiceMessageRecorder.isRecording { await voiceMessageRecorder.stopRecording() @@ -493,6 +513,36 @@ class TimelineInteractionHandler { } } + private func handleAudioPlayerAction(_ action: AudioPlayerAction) async { + switch action { + case .didStartPlaying: + if case .requested(let itemID) = voiceMessagePlayback { + voiceMessagePlayback = .playing(itemID) + } + case .didFinishPlaying: + // Anything else means the player was torn down to play something different + // rather than reaching the end of the message it had started. + guard case .playing(let finishedItemID) = voiceMessagePlayback else { return } + voiceMessagePlayback = nil + await autoplayVoiceMessage(following: finishedItemID) + case .didStartLoading, .didFinishLoading, .didPausePlaying, .didStopPlaying, .didFailWithError: + break + } + } + + /// Plays the voice message directly following the given one. + private func autoplayVoiceMessage(following finishedItemID: TimelineItemIdentifier) async { + // The playback may have been taken over by another player state, such as the recorder's preview. + guard audioPlayerState(for: finishedItemID)?.isAttached == true, + let nextVoiceMessage = timelineController.timelineItems.voiceMessageDirectlyFollowing(finishedItemID) else { + return + } + + MXLog.info("Autoplaying the voice message following itemID \(finishedItemID)") + mediaPlayerProvider.play(soundEffect: .tink) + await playPauseAudio(for: nextVoiceMessage.id) + } + func seekAudio(for itemID: TimelineItemIdentifier, progress: Double) async { guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(itemID)) else { return diff --git a/ElementX/Sources/Services/AlertTones/NotificationToneManager.swift b/ElementX/Sources/Services/AlertTones/NotificationToneManager.swift index 27f8735c7..6421740ff 100644 --- a/ElementX/Sources/Services/AlertTones/NotificationToneManager.swift +++ b/ElementX/Sources/Services/AlertTones/NotificationToneManager.swift @@ -263,15 +263,7 @@ nonisolated struct NotificationToneManager: NotificationToneManagerProtocol { filename: "sound_01.caf") ].sorted() - private static let systemLocation = { - let systemRoot: URL - if let simulatorRoot = ProcessInfo.processInfo.environment["SIMULATOR_ROOT"] { - systemRoot = URL(filePath: simulatorRoot) - } else { - systemRoot = URL(filePath: "/") - } - return systemRoot.appending(components: "System", "Library", "Audio", "UISounds", directoryHint: .isDirectory) - }() + private static let systemLocation = URL.systemSoundsDirectory private static let bundledLocation: URL = { guard let url = Bundle.app.resourceURL else { diff --git a/ElementX/Sources/Services/Audio/Player/AudioPlayer.swift b/ElementX/Sources/Services/Audio/Player/AudioPlayer.swift index c624e0326..b1a2902e7 100644 --- a/ElementX/Sources/Services/Audio/Player/AudioPlayer.swift +++ b/ElementX/Sources/Services/Audio/Player/AudioPlayer.swift @@ -179,16 +179,19 @@ class AudioPlayer: NSObject, AudioPlayerProtocol { } private func addObservers() { - guard let internalAudioPlayer, let playerItem else { + // Deliberately not shadowing the properties, the observers compare against them to detect stale updates. + guard let currentAudioPlayer = internalAudioPlayer, let currentItem = playerItem else { return } - statusObserver = playerItem.observe(\.status, options: [.old, .new]) { [weak self] item, _ in + statusObserver = currentItem.observe(\.status, options: [.old, .new]) { [weak self] item, _ in // KVO fires on an arbitrary thread, hop to the main actor with the values we need. let status = item.status let error = item.error Task { @MainActor [weak self] in - guard let self else { return } + // Ignore an update that was queued before the content was replaced, it would leave + // the state out of sync with the item that is loaded now. + guard let self, item === playerItem else { return } switch status { case .failed: @@ -202,11 +205,11 @@ class AudioPlayer: NSObject, AudioPlayerProtocol { } } - rateObserver = internalAudioPlayer.observe(\.rate, options: [.old, .new]) { [weak self] player, _ in + rateObserver = currentAudioPlayer.observe(\.rate, options: [.old, .new]) { [weak self] player, _ in // KVO fires on an arbitrary thread, hop to the main actor with the values we need. let rate = player.rate Task { @MainActor [weak self] in - guard let self else { return } + guard let self, player === internalAudioPlayer else { return } if rate == 0 { if isStopped { @@ -220,7 +223,9 @@ class AudioPlayer: NSObject, AudioPlayerProtocol { } } - NotificationCenter.default.publisher(for: Notification.Name.AVPlayerItemDidPlayToEndTime) + NotificationCenter.default.publisher(for: Notification.Name.AVPlayerItemDidPlayToEndTime, object: currentItem) + // The notification isn't posted on the main thread, unlike the state updates it triggers. + .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self else { return } setInternalState(.finishedPlaying) diff --git a/ElementX/Sources/Services/Audio/Player/AudioPlayerState.swift b/ElementX/Sources/Services/Audio/Player/AudioPlayerState.swift index 6b3c278bd..d86e113c0 100644 --- a/ElementX/Sources/Services/Audio/Player/AudioPlayerState.swift +++ b/ElementX/Sources/Services/Audio/Player/AudioPlayerState.swift @@ -128,7 +128,6 @@ class AudioPlayerState: ObservableObject, Identifiable { private func subscribeToAudioPlayer(audioPlayer: AudioPlayerProtocol) { audioPlayerSubscription = audioPlayer.actions - .receive(on: DispatchQueue.main) .sink { [weak self] action in guard let self else { return diff --git a/ElementX/Sources/Services/MediaPlayer/MediaPlayerProvider.swift b/ElementX/Sources/Services/MediaPlayer/MediaPlayerProvider.swift index 80253b783..195558ed0 100644 --- a/ElementX/Sources/Services/MediaPlayer/MediaPlayerProvider.swift +++ b/ElementX/Sources/Services/MediaPlayer/MediaPlayerProvider.swift @@ -6,11 +6,14 @@ // Please see LICENSE files in the repository root for full details. // +import AVFoundation import Foundation class MediaPlayerProvider: MediaPlayerProviderProtocol { private lazy var audioPlayer = AudioPlayer() private var audioPlayerStates: [String: AudioPlayerState] = [:] + /// Held onto for the duration of the playback, an unowned player is silent. + private var soundEffectPlayer: AVAudioPlayer? var player: AudioPlayerProtocol { audioPlayer @@ -55,6 +58,17 @@ class MediaPlayerProvider: MediaPlayerProviderProtocol { } } + /// Doesn't configure an audio session, so that it can be used alongside any existing player or session. + func play(soundEffect: SoundEffect) { + do { + let player = try AVAudioPlayer(contentsOf: soundEffect.fileURL) + soundEffectPlayer = player + player.play() + } catch { + MXLog.error("Failed playing the sound effect: \(error)") + } + } + // MARK: - Private private func audioPlayerStateID(for identifier: AudioPlayerStateIdentifier) -> String? { diff --git a/ElementX/Sources/Services/MediaPlayer/MediaPlayerProviderProtocol.swift b/ElementX/Sources/Services/MediaPlayer/MediaPlayerProviderProtocol.swift index 09bb81bdf..78a94874a 100644 --- a/ElementX/Sources/Services/MediaPlayer/MediaPlayerProviderProtocol.swift +++ b/ElementX/Sources/Services/MediaPlayer/MediaPlayerProviderProtocol.swift @@ -8,6 +8,17 @@ import Foundation +enum SoundEffect { + /// A short, light blip. + case tink + + var fileURL: URL { + switch self { + case .tink: URL.systemSoundsDirectory.appending(component: "Tink.caf") + } + } +} + protocol MediaPlayerProviderProtocol { var player: AudioPlayerProtocol { get } @@ -15,6 +26,10 @@ protocol MediaPlayerProviderProtocol { func register(audioPlayerState: AudioPlayerState) func unregister(audioPlayerState: AudioPlayerState) func detachAllStates(except exception: AudioPlayerState?) async + + /// Plays a short sound on top of whatever is already playing. It goes through the app's own + /// audio session, so unlike the sounds built into iOS it is heard in silent mode too. + func play(soundEffect: SoundEffect) } // sourcery: AutoMockable diff --git a/UnitTests/Sources/ArrayTests.swift b/UnitTests/Sources/ArrayTests.swift index 6d3d825ff..0dc306e19 100644 --- a/UnitTests/Sources/ArrayTests.swift +++ b/UnitTests/Sources/ArrayTests.swift @@ -10,6 +10,7 @@ import Foundation import Testing +@MainActor struct ArrayTests { @Test func grouping() { @@ -33,4 +34,84 @@ struct ArrayTests { #expect([0, 0, 0, 1, 0, 2, 3, 0, 0].groupBy { $0 == 0 } == [[0, 0, 0], [1], [0], [2], [3], [0, 0]]) } + + // MARK: - Voice message autoplay + + @Test + func voiceMessageFollowsVoiceMessage() { + let items: [RoomTimelineItemProtocol] = [makeVoiceMessage(uniqueID: "voice1"), + makeVoiceMessage(uniqueID: "voice2")] + + #expect(items.voiceMessageDirectlyFollowing(items[0].id)?.id.uniqueID == .init("voice2")) + } + + @Test + func decorationsAreIgnored() { + let items: [RoomTimelineItemProtocol] = [makeVoiceMessage(uniqueID: "voice1"), + SeparatorRoomTimelineItem(id: .virtual(uniqueID: .init("separator")), timestamp: .mock), + ReadMarkerRoomTimelineItem(id: .virtual(uniqueID: .init("readMarker"))), + makeVoiceMessage(uniqueID: "voice2")] + + #expect(items.voiceMessageDirectlyFollowing(items[0].id)?.id.uniqueID == .init("voice2")) + } + + @Test + func otherMessagesBreakTheChain() { + let items: [RoomTimelineItemProtocol] = [makeVoiceMessage(uniqueID: "voice1"), + makeTextMessage(uniqueID: "text"), + makeVoiceMessage(uniqueID: "voice2")] + + #expect(items.voiceMessageDirectlyFollowing(items[0].id) == nil) + } + + @Test + func lastItemHasNoFollowingVoiceMessage() { + let items: [RoomTimelineItemProtocol] = [makeVoiceMessage(uniqueID: "voice1"), + SeparatorRoomTimelineItem(id: .virtual(uniqueID: .init("separator")), timestamp: .mock)] + + #expect(items.voiceMessageDirectlyFollowing(items[0].id) == nil) + } + + @Test + func precedingVoiceMessageIsIgnored() { + let items: [RoomTimelineItemProtocol] = [makeVoiceMessage(uniqueID: "voice1"), + makeVoiceMessage(uniqueID: "voice2")] + + #expect(items.voiceMessageDirectlyFollowing(items[1].id) == nil) + } + + @Test + func unknownItemHasNoFollowingVoiceMessage() { + let items: [RoomTimelineItemProtocol] = [makeVoiceMessage(uniqueID: "voice1"), + makeVoiceMessage(uniqueID: "voice2")] + + #expect(items.voiceMessageDirectlyFollowing(.virtual(uniqueID: .init("unknown"))) == nil) + } + + // MARK: - Helpers + + private func makeVoiceMessage(uniqueID: String) -> VoiceMessageRoomTimelineItem { + VoiceMessageRoomTimelineItem(id: .virtual(uniqueID: .init(uniqueID)), + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + sender: .init(id: "@sender:example.com"), + content: .init(filename: "audio.ogg", + duration: 10, + waveform: .mockWaveform, + source: try? MediaSourceProxy(url: .mockMXCAudio, mimeType: nil), + fileSize: nil, + contentType: nil)) + } + + private func makeTextMessage(uniqueID: String) -> TextRoomTimelineItem { + TextRoomTimelineItem(id: .virtual(uniqueID: .init(uniqueID)), + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + sender: .init(id: "@sender:example.com"), + content: .init(body: "Test message")) + } } diff --git a/UnitTests/Sources/AudioPlayerStateTests.swift b/UnitTests/Sources/AudioPlayerStateTests.swift index e3eeb98d1..0b1905022 100644 --- a/UnitTests/Sources/AudioPlayerStateTests.swift +++ b/UnitTests/Sources/AudioPlayerStateTests.swift @@ -273,6 +273,23 @@ struct AudioPlayerStateTests { #expect(!audioPlayerState.showProgressIndicator) } + @Test + mutating func audioPlayerActionsDidFinishPlayingWhenDetachedStraightAway() async throws { + await audioPlayerState.updateState(progress: 0.4) + audioPlayerState.attachAudioPlayer(audioPlayerMock) + + let deferred = deferFulfillment(audioPlayerState.$progress) { $0 == 0.0 } + + // Detaching before the action has been handled is what happens when the following + // voice message takes the player over as soon as this one reaches its end. + audioPlayerActionsSubject.send(.didFinishPlaying) + audioPlayerState.detachAudioPlayer() + + try await deferred.fulfill() + #expect(audioPlayerState.progress == 0.0) + #expect(!audioPlayerState.showProgressIndicator) + } + @Test func setPlaybackSpeed() { audioPlayerState.attachAudioPlayer(audioPlayerMock) diff --git a/UnitTests/Sources/TimelineInteractionHandlerTests.swift b/UnitTests/Sources/TimelineInteractionHandlerTests.swift new file mode 100644 index 000000000..b12d640ca --- /dev/null +++ b/UnitTests/Sources/TimelineInteractionHandlerTests.swift @@ -0,0 +1,215 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Combine +@testable import ElementX +import Foundation +import Testing + +@MainActor +struct TimelineInteractionHandlerTests { + private static let firstSourceURL = URL("mxc://matrix.org/voice1") + private static let secondSourceURL = URL("mxc://matrix.org/voice2") + + private let audioPlayer: AudioPlayerMock + private let mediaPlayerProvider: MediaPlayerProviderMock + private let actionsSubject: PassthroughSubject + private let loadedSourceURLsSubject: PassthroughSubject + + private static let firstVoiceMessage = makeVoiceMessage(id: "voice1", sourceURL: firstSourceURL) + private static let secondVoiceMessage = makeVoiceMessage(id: "voice2", sourceURL: secondSourceURL) + private static let textMessage = TextRoomTimelineItem(id: .event(uniqueID: .init("text"), eventOrTransactionID: .eventID("text")), + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + sender: .init(id: "@sender:example.com"), + content: .init(body: "Test message")) + + init() { + let player = AudioPlayerMock() + let actionsSubject = PassthroughSubject() + let loadedSourceURLsSubject = PassthroughSubject() + + player.actions = actionsSubject.eraseToAnyPublisher() + player.state = .stopped + player.currentTime = 0 + player.duration = 10 + player.loadSourceURLPlaybackURLAutoplayClosure = { sourceURL, _, _ in + player.sourceURL = sourceURL + player.state = .playing + loadedSourceURLsSubject.send(sourceURL) + } + player.playClosure = { player.state = .playing } + player.pauseClosure = { player.state = .paused } + player.stopClosure = { player.state = .stopped } + + let states = [Self.firstVoiceMessage, Self.secondVoiceMessage] + .reduce(into: [TimelineItemIdentifier.UniqueID: AudioPlayerState]()) { states, item in + states[item.id.uniqueID] = AudioPlayerState(id: .timelineItemIdentifier(item.id), + title: L10n.commonVoiceMessage, + duration: item.content.duration) + } + + // Hands out one stable player state per voice message, as the real provider does. + let mediaPlayerProvider = MediaPlayerProviderMock() + mediaPlayerProvider.player = player + mediaPlayerProvider.playerStateForClosure = { identifier in + guard case .timelineItemIdentifier(let itemID) = identifier else { return nil } + return states[itemID.uniqueID] + } + + audioPlayer = player + self.mediaPlayerProvider = mediaPlayerProvider + self.actionsSubject = actionsSubject + self.loadedSourceURLsSubject = loadedSourceURLsSubject + } + + // MARK: - Voice message autoplay + + @Test + func autoplaysTheFollowingVoiceMessage() async throws { + let handler = makeHandler(timelineItems: [Self.firstVoiceMessage, Self.secondVoiceMessage]) + + await handler.playPauseAudio(for: Self.firstVoiceMessage.id) + #expect(loadedSourceURLs == [Self.firstSourceURL]) + + let deferred = deferFulfillment(loadedSourceURLsSubject) { $0 == Self.secondSourceURL } + actionsSubject.send(.didStartPlaying) + actionsSubject.send(.didFinishPlaying) + try await deferred.fulfill() + + #expect(loadedSourceURLs == [Self.firstSourceURL, Self.secondSourceURL]) + #expect(mediaPlayerProvider.playSoundEffectReceivedSoundEffect == .tink) + } + + @Test + func doesNotAutoplayAcrossAnotherMessage() async { + let handler = makeHandler(timelineItems: [Self.firstVoiceMessage, Self.textMessage, Self.secondVoiceMessage]) + + await handler.playPauseAudio(for: Self.firstVoiceMessage.id) + actionsSubject.send(.didStartPlaying) + actionsSubject.send(.didFinishPlaying) + await allowPlayerActionsToBeHandled() + + #expect(loadedSourceURLs == [Self.firstSourceURL]) + #expect(!mediaPlayerProvider.playSoundEffectCalled) + } + + @Test + func doesNotAutoplayAfterTheLastVoiceMessage() async { + let handler = makeHandler(timelineItems: [Self.firstVoiceMessage]) + + await handler.playPauseAudio(for: Self.firstVoiceMessage.id) + actionsSubject.send(.didStartPlaying) + actionsSubject.send(.didFinishPlaying) + await allowPlayerActionsToBeHandled() + + #expect(loadedSourceURLs == [Self.firstSourceURL]) + #expect(!mediaPlayerProvider.playSoundEffectCalled) + } + + @Test + func doesNotAutoplayAfterPausing() async { + let handler = makeHandler(timelineItems: [Self.firstVoiceMessage, Self.secondVoiceMessage]) + + await handler.playPauseAudio(for: Self.firstVoiceMessage.id) + actionsSubject.send(.didStartPlaying) + await allowPlayerActionsToBeHandled() + + await handler.playPauseAudio(for: Self.firstVoiceMessage.id) + #expect(audioPlayer.pauseCalled) + + actionsSubject.send(.didFinishPlaying) + await allowPlayerActionsToBeHandled() + + #expect(loadedSourceURLs == [Self.firstSourceURL]) + } + + @Test + func replayingAVoiceMessageDoesNotJumpToTheFollowingOne() async throws { + let handler = makeHandler(timelineItems: [Self.firstVoiceMessage, Self.secondVoiceMessage]) + + // Play the first message all the way through so that the second one takes over. + await handler.playPauseAudio(for: Self.firstVoiceMessage.id) + let autoplay = deferFulfillment(loadedSourceURLsSubject) { $0 == Self.secondSourceURL } + actionsSubject.send(.didStartPlaying) + actionsSubject.send(.didFinishPlaying) + try await autoplay.fulfill() + actionsSubject.send(.didStartPlaying) + await allowPlayerActionsToBeHandled() + + // Loading tears the player down, which is where the spurious end of playback came from. + audioPlayer.loadSourceURLPlaybackURLAutoplayClosure = { [audioPlayer, actionsSubject, loadedSourceURLsSubject] sourceURL, _, _ in + actionsSubject.send(.didFinishPlaying) + audioPlayer.sourceURL = sourceURL + audioPlayer.state = .playing + loadedSourceURLsSubject.send(sourceURL) + } + + let replay = deferFulfillment(loadedSourceURLsSubject) { $0 == Self.firstSourceURL } + await handler.playPauseAudio(for: Self.firstVoiceMessage.id) + try await replay.fulfill() + await allowPlayerActionsToBeHandled() + + #expect(loadedSourceURLs == [Self.firstSourceURL, Self.secondSourceURL, Self.firstSourceURL]) + } + + // MARK: - Helpers + + private var loadedSourceURLs: [URL] { + audioPlayer.loadSourceURLPlaybackURLAutoplayReceivedInvocations.map(\.sourceURL) + } + + /// The handler reacts to the player's actions in detached tasks, this gives them a chance to run. + private func allowPlayerActionsToBeHandled() async { + for _ in 0..<10 { + await Task.yield() + } + } + + private func makeHandler(timelineItems: [RoomTimelineItemProtocol]) -> TimelineInteractionHandler { + let voiceMessageRecorder = VoiceMessageRecorderMock() + voiceMessageRecorder.isRecording = false + + let voiceMessageMediaManager = VoiceMessageMediaManagerMock() + voiceMessageMediaManager.loadVoiceMessageFromSourceBodyReturnValue = URL("file:///voice-message.m4a") + + let userSession = UserSessionMock(.init()) + userSession.voiceMessageMediaManager = voiceMessageMediaManager + + let appSettings = AppSettings.volatile() + + return TimelineInteractionHandler(roomProxy: JoinedRoomProxyMock(.init()), + timelineController: TimelineControllerMock(.init(timelineItems: timelineItems)), + userSession: userSession, + mediaPlayerProvider: mediaPlayerProvider, + voiceMessageRecorder: voiceMessageRecorder, + userIndicatorController: UserIndicatorControllerMock(), + appMediator: AppMediatorMock(.init()), + appSettings: appSettings, + analyticsService: AnalyticsServiceMock(.init()), + emojiProvider: EmojiProvider(appSettings: appSettings), + linkMetadataProvider: LinkMetadataProvider(), + timelineControllerFactory: TimelineControllerFactoryMock(.init())) + } + + private static func makeVoiceMessage(id: String, sourceURL: URL) -> VoiceMessageRoomTimelineItem { + VoiceMessageRoomTimelineItem(id: .event(uniqueID: .init(id), eventOrTransactionID: .eventID(id)), + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + sender: .init(id: "@sender:example.com"), + content: .init(filename: "audio.ogg", + duration: 10, + waveform: .mockWaveform, + source: try? MediaSourceProxy(url: sourceURL, mimeType: "audio/ogg"), + fileSize: nil, + contentType: nil)) + } +}