diff --git a/Podfile b/Podfile index d89c32f61..3916eaea6 100644 --- a/Podfile +++ b/Podfile @@ -66,7 +66,7 @@ end abstract_target 'RiotPods' do - pod 'GBDeviceInfo', '~> 6.6.0' + pod 'GBDeviceInfo', '~> 7.1.0' pod 'Reusable', '~> 4.1' pod 'KeychainAccess', '~> 4.2.2' pod 'WeakDictionary', '~> 2.0' diff --git a/Podfile.lock b/Podfile.lock index 3d8bb303e..fa09a2228 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -40,9 +40,9 @@ PODS: - DTFoundation/Core - FLEX (4.5.0) - FlowCommoniOS (1.12.2) - - GBDeviceInfo (6.6.0): - - GBDeviceInfo/Core (= 6.6.0) - - GBDeviceInfo/Core (6.6.0) + - GBDeviceInfo (7.1.0): + - GBDeviceInfo/Core (= 7.1.0) + - GBDeviceInfo/Core (7.1.0) - GZIP (1.3.0) - Introspect (0.1.4) - JitsiMeetSDK (5.0.2) @@ -117,7 +117,7 @@ DEPENDENCIES: - DTCoreText (~> 1.6.25) - FLEX (~> 4.5.0) - FlowCommoniOS (~> 1.12.0) - - GBDeviceInfo (~> 6.6.0) + - GBDeviceInfo (~> 7.1.0) - Introspect (~> 0.1) - KeychainAccess (~> 4.2.2) - KTCenterFlowLayout (~> 1.3.1) @@ -209,7 +209,7 @@ SPEC CHECKSUMS: DTFoundation: a53f8cda2489208cbc71c648be177f902ee17536 FLEX: e51461dd6f0bfb00643c262acdfea5d5d12c596b FlowCommoniOS: ca92071ab526dc89905495a37844fd7e78d1a7f2 - GBDeviceInfo: ed0db16230d2fa280e1cbb39a5a7f60f6946aaec + GBDeviceInfo: 5d62fa85bdcce3ed288d83c28789adf1173e4376 GZIP: 416858efbe66b41b206895ac6dfd5493200d95b3 Introspect: b62c4dd2063072327c21d618ef2bedc3c87bc366 JitsiMeetSDK: edcac8e2b92ee0c7f3e75bd0aefefbe9faccfc93 @@ -241,6 +241,6 @@ SPEC CHECKSUMS: zxcvbn-ios: fef98b7c80f1512ff0eec47ac1fa399fc00f7e3c ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 96a971e076c61e54ae5bb7bf30ecba80563eeacf +PODFILE CHECKSUM: d4f1318b49e324a4e70b758e6b383ada67414a15 COCOAPODS: 1.11.3 diff --git a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7e3fbae34..816ccb018 100644 --- a/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -23,7 +23,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/matrix-org/matrix-wysiwyg-composer-swift", "state" : { - "revision" : "38724c7c037402372d2c42a7eb2451251f76693b" + "revision" : "2469f27b7e1e51aaa135e09f9005eb10fda686e6" } }, { diff --git a/Riot/Modules/Application/LegacyAppDelegate.m b/Riot/Modules/Application/LegacyAppDelegate.m index 1e0d7aa6d..3558a4a4d 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.m +++ b/Riot/Modules/Application/LegacyAppDelegate.m @@ -2197,6 +2197,8 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni [[[ReviewSessionAlertSnoozeController alloc] init] clearSnooze]; + [TimelinePollProvider.shared reset]; + #ifdef MX_CALL_STACK_ENDPOINT // Erase all created certificates and private keys by MXEndpointCallStack for (MXKAccount *account in MXKAccountManager.sharedManager.accounts) diff --git a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.h b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.h index fd28f9939..679e805f8 100644 --- a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.h +++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.h @@ -359,11 +359,6 @@ typedef BOOL (^MXKAccountOnCertificateChange)(MXKAccount *mxAccount, NSData *cer - (void)resetDeviceId; #pragma mark - Sync filter -/** - Check if the homeserver supports room members lazy loading. - @param completion the check result. - */ -- (void)supportLazyLoadOfRoomMembers:(void (^)(BOOL supportLazyLoadOfRoomMembers))completion; /** Call this method at an appropriate time to attempt dehydrating to a new backup device diff --git a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m index 07536a638..8b60e2fbe 100644 --- a/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m +++ b/Riot/Modules/MatrixKit/Models/Account/MXKAccount.m @@ -55,9 +55,6 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; // We will notify user only once on session failure BOOL notifyOpenSessionFailure; - // The timer used to postpone server sync on failure - NSTimer* initialServerSyncTimer; - // Reachability observer id reachabilityObserver; @@ -934,9 +931,6 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; sessionStateObserver = nil; } - [initialServerSyncTimer invalidate]; - initialServerSyncTimer = nil; - if (userUpdateListener) { [mxSession.myUser removeListener:userUpdateListener]; @@ -1136,8 +1130,6 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; // Cancel pending actions [[NSNotificationCenter defaultCenter] removeObserver:reachabilityObserver]; reachabilityObserver = nil; - [initialServerSyncTimer invalidate]; - initialServerSyncTimer = nil; MXLogDebug(@"[MXKAccount] Pause is delayed due to the session state: %@", [MXTools readableSessionState: mxSession.state]); } @@ -1627,8 +1619,6 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; // Cancel potential reachability observer and pending action [[NSNotificationCenter defaultCenter] removeObserver:reachabilityObserver]; reachabilityObserver = nil; - [initialServerSyncTimer invalidate]; - initialServerSyncTimer = nil; // Sanity check if (!mxSession || (mxSession.state != MXSessionStateStoreDataReady && mxSession.state != MXSessionStateInitialSyncFailed)) @@ -1694,9 +1684,8 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; if (networkReachabilityManager.isReachable) { - // The problem is not the network - // Postpone a new attempt in 10 sec - self->initialServerSyncTimer = [NSTimer scheduledTimerWithTimeInterval:10 target:self selector:@selector(launchInitialServerSync) userInfo:self repeats:NO]; + // If we have network, we retry immediately, otherwise the server may clear any cache it has computed thus far + [self launchInitialServerSync]; } else { @@ -2088,14 +2077,15 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; #pragma mark - Sync filter -- (void)supportLazyLoadOfRoomMembers:(void (^)(BOOL supportLazyLoadOfRoomMembers))completion +- (void)supportLazyLoadOfRoomMembersWithMatrixVersion:(MXMatrixVersions *)matrixVersions + completion:(void (^)(BOOL supportLazyLoadOfRoomMembers))completion { void(^onUnsupportedLazyLoadOfRoomMembers)(NSError *) = ^(NSError *error) { completion(NO); }; // Check if the server supports LL sync filter - MXFilterJSONModel *filter = [self syncFilterWithLazyLoadOfRoomMembers:YES]; + MXFilterJSONModel *filter = [self syncFilterWithLazyLoadOfRoomMembers:YES supportsNotificationsForThreads:NO]; [mxSession.store filterIdForFilter:filter success:^(NSString * _Nullable filterId) { if (filterId) @@ -2106,8 +2096,8 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; else { // Check the Matrix versions supported by the HS - [self.mxSession supportedMatrixVersions:^(MXMatrixVersions *matrixVersions) { - + if (matrixVersions) + { if (matrixVersions.supportLazyLoadMembers) { // The HS supports LL @@ -2117,8 +2107,11 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; { onUnsupportedLazyLoadOfRoomMembers(nil); } - - } failure:onUnsupportedLazyLoadOfRoomMembers]; + } + else + { + completion(NO); + } } } failure:onUnsupportedLazyLoadOfRoomMembers]; } @@ -2133,28 +2126,42 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; // Check settings BOOL syncWithLazyLoadOfRoomMembersSetting = [MXKAppSettings standardAppSettings].syncWithLazyLoadOfRoomMembers; - if (syncWithLazyLoadOfRoomMembersSetting) - { - // Check if the server supports LL sync filter before enabling it - [self supportLazyLoadOfRoomMembers:^(BOOL supportLazyLoadOfRoomMembers) { + void(^buildSyncFilter)(MXMatrixVersions *) = ^(MXMatrixVersions *matrixVersions) { + BOOL supportsNotificationsForThreads = matrixVersions ? matrixVersions.supportsNotificationsForThreads : NO; + + if (syncWithLazyLoadOfRoomMembersSetting) + { + // Check if the server supports LL sync filter before enabling it + [self supportLazyLoadOfRoomMembersWithMatrixVersion:matrixVersions completion:^(BOOL supportLazyLoadOfRoomMembers) { + - if (supportLazyLoadOfRoomMembers) - { - completion([self syncFilterWithLazyLoadOfRoomMembers:YES]); - } - else - { - // No support from the HS - // Disable the setting. That will avoid to make a request at every startup - [MXKAppSettings standardAppSettings].syncWithLazyLoadOfRoomMembers = NO; - completion([self syncFilterWithLazyLoadOfRoomMembers:NO]); - } - }]; - } - else - { - completion([self syncFilterWithLazyLoadOfRoomMembers:NO]); - } + if (supportLazyLoadOfRoomMembers) + { + completion([self syncFilterWithLazyLoadOfRoomMembers:YES + supportsNotificationsForThreads:supportsNotificationsForThreads]); + } + else + { + // No support from the HS + // Disable the setting. That will avoid to make a request at every startup + [MXKAppSettings standardAppSettings].syncWithLazyLoadOfRoomMembers = NO; + completion([self syncFilterWithLazyLoadOfRoomMembers:NO + supportsNotificationsForThreads:supportsNotificationsForThreads]); + } + }]; + } + else + { + completion([self syncFilterWithLazyLoadOfRoomMembers:NO supportsNotificationsForThreads:supportsNotificationsForThreads]); + } + }; + + [mxSession supportedMatrixVersions:^(MXMatrixVersions *matrixVersions) { + buildSyncFilter(matrixVersions); + } failure:^(NSError *error) { + MXLogWarning(@"[MXAccount] buildSyncFilter: failed to get supported versions: %@", error); + buildSyncFilter(nil); + }]; } /** @@ -2163,7 +2170,7 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; @param syncWithLazyLoadOfRoomMembers enable LL support. @return the sync filter to use. */ -- (MXFilterJSONModel *)syncFilterWithLazyLoadOfRoomMembers:(BOOL)syncWithLazyLoadOfRoomMembers +- (MXFilterJSONModel *)syncFilterWithLazyLoadOfRoomMembers:(BOOL)syncWithLazyLoadOfRoomMembers supportsNotificationsForThreads:(BOOL)supportsNotificationsForThreads { MXFilterJSONModel *syncFilter; NSUInteger limit = 10; @@ -2198,11 +2205,11 @@ static NSArray *initialSyncSilentErrorsHTTPStatusCodes; // Set that limit in the filter if (syncWithLazyLoadOfRoomMembers) { - syncFilter = [MXFilterJSONModel syncFilterForLazyLoadingWithMessageLimit:limit unreadThreadNotifications:YES]; + syncFilter = [MXFilterJSONModel syncFilterForLazyLoadingWithMessageLimit:limit unreadThreadNotifications:supportsNotificationsForThreads]; } else { - syncFilter = [MXFilterJSONModel syncFilterWithMessageLimit:limit unreadThreadNotifications:YES]; + syncFilter = [MXFilterJSONModel syncFilterWithMessageLimit:limit unreadThreadNotifications:supportsNotificationsForThreads]; } // TODO: We could extend the filter to match other settings (self.showAllEventsInRoomHistory, diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 9725d6c23..e911c0bcf 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -604,6 +604,7 @@ static CGSize kThreadListBarButtonItemImageSize; [VoiceMessageMediaServiceProvider.sharedProvider pauseAllServices]; [VoiceBroadcastRecorderProvider.shared pauseRecording]; + [VoiceBroadcastPlaybackProvider.shared pausePlaying]; // Stop the loading indicator even if the session is still in progress [self stopLoadingUserIndicator]; diff --git a/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioPlayer.swift b/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioPlayer.swift index 46e06cfed..231773f2b 100644 --- a/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioPlayer.swift +++ b/Riot/Modules/Room/VoiceMessages/VoiceMessageAudioPlayer.swift @@ -72,6 +72,10 @@ class VoiceMessageAudioPlayer: NSObject { return audioPlayer.items() } + var currentUrl: URL? { + return (audioPlayer?.currentItem?.asset as? AVURLAsset)?.url + } + private(set) var isStopped = true deinit { diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastBuilder.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastBuilder.swift index e27f5258a..5bd472f6f 100644 --- a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastBuilder.swift +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastBuilder.swift @@ -27,20 +27,25 @@ struct VoiceBroadcastBuilder { var voiceBroadcast = VoiceBroadcast() - voiceBroadcast.chunks = Set(events.compactMap { event in + let chunks = Set(events.compactMap { event in buildChunk(event: event, mediaManager: mediaManager, voiceBroadcastStartEventId: voiceBroadcastStartEventId) }) + voiceBroadcast.chunks = chunks + voiceBroadcast.duration = chunks.reduce(0) { $0 + $1.duration} + return voiceBroadcast } func buildChunk(event: MXEvent, mediaManager: MXMediaManager, voiceBroadcastStartEventId: String) -> VoiceBroadcastChunk? { guard let attachment = MXKAttachment(event: event, andMediaManager: mediaManager), let chunkInfo = event.content[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkType] as? [String: UInt], - let sequence = chunkInfo[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkSequence] else { + let sequence = chunkInfo[VoiceBroadcastSettings.voiceBroadcastContentKeyChunkSequence], + let audio = event.content[kMXMessageContentKeyExtensibleAudioMSC1767] as? [String: UInt], + let duration = audio[kMXMessageContentKeyExtensibleAudioDuration] else { return nil } - return VoiceBroadcastChunk(voiceBroadcastInfoEventId: voiceBroadcastStartEventId, sequence: sequence, attachment: attachment) + return VoiceBroadcastChunk(voiceBroadcastInfoEventId: voiceBroadcastStartEventId, sequence: sequence, attachment: attachment, duration: duration) } } diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastChunk.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastChunk.swift index 1d974d791..8c18fc602 100644 --- a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastChunk.swift +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastChunk.swift @@ -20,13 +20,16 @@ public class VoiceBroadcastChunk: NSObject { public private(set) var voiceBroadcastInfoEventId: String public private(set) var sequence: UInt public private(set) var attachment: MXKAttachment + public private(set) var duration: UInt public init(voiceBroadcastInfoEventId: String, sequence: UInt, - attachment: MXKAttachment) { + attachment: MXKAttachment, + duration: UInt) { self.voiceBroadcastInfoEventId = voiceBroadcastInfoEventId self.sequence = sequence self.attachment = attachment + self.duration = duration } public static func == (lhs: VoiceBroadcastChunk, rhs: VoiceBroadcastChunk) -> Bool { diff --git a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastModels.swift b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastModels.swift index 138af9e32..4b36bea73 100644 --- a/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastModels.swift +++ b/Riot/Modules/VoiceBroadcast/VoiceBroadcastSDK/VoiceBroadcastModels.swift @@ -24,4 +24,5 @@ public enum VoiceBroadcastKind { public struct VoiceBroadcast { var chunks: Set = [] var kind: VoiceBroadcastKind = .player + var duration: UInt = 0 } diff --git a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift index 951eed9d3..c21bd5ed5 100644 --- a/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift +++ b/RiotSwiftUI/Modules/Room/Composer/View/Composer.swift @@ -23,6 +23,13 @@ struct Composer: View { // MARK: Private + @ObservedObject private var viewModel: ComposerViewModelType.Context + @ObservedObject private var wysiwygViewModel: WysiwygComposerViewModel + private let resizeAnimationDuration: Double + + private let sendMessageAction: (WysiwygComposerContent) -> Void + private let showSendMediaActions: () -> Void + @Environment(\.theme) private var theme: ThemeSwiftUI @State private var isActionButtonShowing = false @@ -66,8 +73,8 @@ struct Composer: View { FormatType.allCases.map { type in FormatItem( type: type, - active: wysiwygViewModel.reversedActions.contains(type.composerAction), - disabled: wysiwygViewModel.disabledActions.contains(type.composerAction) + active: wysiwygViewModel.actionStates[type.composerAction] == .reversed, + disabled: wysiwygViewModel.actionStates[type.composerAction] == .disabled ) } } @@ -182,12 +189,18 @@ struct Composer: View { // MARK: Public - @ObservedObject var viewModel: ComposerViewModelType.Context - @ObservedObject var wysiwygViewModel: WysiwygComposerViewModel - let resizeAnimationDuration: Double - - let sendMessageAction: (WysiwygComposerContent) -> Void - let showSendMediaActions: () -> Void + init( + viewModel: ComposerViewModelType.Context, + wysiwygViewModel: WysiwygComposerViewModel, + resizeAnimationDuration: Double, + sendMessageAction: @escaping (WysiwygComposerContent) -> Void, + showSendMediaActions: @escaping () -> Void) { + self.viewModel = viewModel + self.wysiwygViewModel = wysiwygViewModel + self.resizeAnimationDuration = resizeAnimationDuration + self.sendMessageAction = sendMessageAction + self.showSendMediaActions = showSendMediaActions + } var body: some View { VStack(spacing: 8) { diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollProvider.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollProvider.swift index 31fb63849..a11cb3a2e 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollProvider.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollProvider.swift @@ -16,14 +16,22 @@ import Foundation -class TimelinePollProvider { +@objcMembers +class TimelinePollProvider: NSObject { static let shared = TimelinePollProvider() - var session: MXSession? + var session: MXSession? { + willSet { + guard let currentSession = self.session else { return } + + if currentSession != newValue { + // Clear all stored coordinators on new session + coordinatorsForEventIdentifiers.removeAll() + } + } + } var coordinatorsForEventIdentifiers = [String: TimelinePollCoordinator]() - private init() { } - /// Create or retrieve the poll timeline coordinator for this event and return /// a view to be displayed in the timeline func buildTimelinePollVCForEvent(_ event: MXEvent) -> UIViewController? { @@ -49,4 +57,8 @@ class TimelinePollProvider { func timelinePollCoordinatorForEventIdentifier(_ eventIdentifier: String) -> TimelinePollCoordinator? { coordinatorsForEventIdentifiers[eventIdentifier] } + + func reset() { + coordinatorsForEventIdentifiers.removeAll() + } } diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackCoordinator.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackCoordinator.swift index ee7b51e4e..d353e2f55 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackCoordinator.swift @@ -76,4 +76,8 @@ final class VoiceBroadcastPlaybackCoordinator: Coordinator, Presentable { } func endVoiceBroadcast() {} + + func pausePlaying() { + viewModel.context.send(viewAction: .pause) + } } diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackProvider.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackProvider.swift index 7ca72c413..29b6252df 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackProvider.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/Coordinator/VoiceBroadcastPlaybackProvider.swift @@ -16,13 +16,22 @@ import Foundation -class VoiceBroadcastPlaybackProvider { - static let shared = VoiceBroadcastPlaybackProvider() +@objc class VoiceBroadcastPlaybackProvider: NSObject { + @objc static let shared = VoiceBroadcastPlaybackProvider() - var session: MXSession? + var session: MXSession? { + willSet { + guard let currentSession = self.session else { return } + + if currentSession != newValue { + // Clear all stored coordinators on new session + coordinatorsForEventIdentifiers.removeAll() + } + } + } var coordinatorsForEventIdentifiers = [String: VoiceBroadcastPlaybackCoordinator]() - private init() { } + private override init() { } /// Create or retrieve the voiceBroadcast timeline coordinator for this event and return /// a view to be displayed in the timeline @@ -54,4 +63,11 @@ class VoiceBroadcastPlaybackProvider { func voiceBroadcastPlaybackCoordinatorForEventIdentifier(_ eventIdentifier: String) -> VoiceBroadcastPlaybackCoordinator? { coordinatorsForEventIdentifiers[eventIdentifier] } + + /// Pause current voice broadcast playback. + @objc public func pausePlaying() { + coordinatorsForEventIdentifiers.forEach { _, coordinator in + coordinator.pausePlaying() + } + } } diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/MatrixSDK/VoiceBroadcastPlaybackViewModel.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/MatrixSDK/VoiceBroadcastPlaybackViewModel.swift index c27da240e..ff237a320 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/MatrixSDK/VoiceBroadcastPlaybackViewModel.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/MatrixSDK/VoiceBroadcastPlaybackViewModel.swift @@ -26,14 +26,20 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic // MARK: - Properties // MARK: Private - private var voiceBroadcastAggregator: VoiceBroadcastAggregator private let mediaServiceProvider: VoiceMessageMediaServiceProvider private let cacheManager: VoiceMessageAttachmentCacheManager - private var audioPlayer: VoiceMessageAudioPlayer? + private var voiceBroadcastAggregator: VoiceBroadcastAggregator private var voiceBroadcastChunkQueue: [VoiceBroadcastChunk] = [] + private var voiceBroadcastAttachmentCacheManagerLoadResults: [VoiceMessageAttachmentCacheManagerLoadResult] = [] + + private var audioPlayer: VoiceMessageAudioPlayer? + private var displayLink: CADisplayLink! private var isLivePlayback = false + private var acceptProgressUpdates = true + + private var isActuallyPaused: Bool = false // MARK: Public @@ -50,9 +56,14 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic let viewState = VoiceBroadcastPlaybackViewState(details: details, broadcastState: VoiceBroadcastPlaybackViewModel.getBroadcastState(from: voiceBroadcastAggregator.voiceBroadcastState), playbackState: .stopped, - bindings: VoiceBroadcastPlaybackViewStateBindings()) + playingState: VoiceBroadcastPlayingState(duration: Float(voiceBroadcastAggregator.voiceBroadcast.duration)), + bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0)) super.init(initialViewState: viewState) + displayLink = CADisplayLink(target: WeakTarget(self, selector: #selector(handleDisplayLinkTick)), selector: WeakTarget.triggerSelector) + displayLink.isPaused = true + displayLink.add(to: .current, forMode: .common) + self.voiceBroadcastAggregator.delegate = self } @@ -74,6 +85,8 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic playLive() case .pause: pause() + case .sliderChange(let didChange): + didSliderChanged(didChange) } } @@ -83,6 +96,8 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic /// Listen voice broadcast private func play() { isLivePlayback = false + displayLink.isPaused = false + isActuallyPaused = false if voiceBroadcastAggregator.isStarted == false { // Start the streaming by fetching broadcast chunks @@ -90,16 +105,16 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic MXLog.debug("[VoiceBroadcastPlaybackViewModel] play: Start streaming") state.playbackState = .buffering voiceBroadcastAggregator.start() - } - else if let audioPlayer = audioPlayer { + + updateDuration() + } else if let audioPlayer = audioPlayer { MXLog.debug("[VoiceBroadcastPlaybackViewModel] play: resume") audioPlayer.play() - } - else { + } else { let chunks = voiceBroadcastAggregator.voiceBroadcast.chunks MXLog.debug("[VoiceBroadcastPlaybackViewModel] play: restart from the beginning: \(chunks.count) chunks") - // Reinject all the chuncks we already have and play them + // Reinject all the chunks we already have and play them voiceBroadcastChunkQueue.append(contentsOf: chunks) processPendingVoiceBroadcastChunks() } @@ -112,6 +127,8 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic } isLivePlayback = true + displayLink.isPaused = false + isActuallyPaused = false // Flush the current audio player playlist audioPlayer?.removeAllPlayerItems() @@ -122,22 +139,25 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic MXLog.debug("[VoiceBroadcastPlaybackViewModel] playLive: Start streaming") state.playbackState = .buffering voiceBroadcastAggregator.start() - } - else { + + state.playingState.duration = Float(voiceBroadcastAggregator.voiceBroadcast.duration) + } else { let chunks = voiceBroadcastAggregator.voiceBroadcast.chunks MXLog.debug("[VoiceBroadcastPlaybackViewModel] playLive: restart from the last chunk: \(chunks.count) chunks") - // Reinject all the chuncks we already have and play the last one + // Reinject all the chunks we already have and play the last one voiceBroadcastChunkQueue.append(contentsOf: chunks) processPendingVoiceBroadcastChunksForLivePlayback() } } - /// Stop voice broadcast + /// Pause voice broadcast private func pause() { MXLog.debug("[VoiceBroadcastPlaybackViewModel] pause") isLivePlayback = false + displayLink.isPaused = true + isActuallyPaused = true if let audioPlayer = audioPlayer, audioPlayer.isPlaying { audioPlayer.pause() @@ -147,15 +167,22 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic private func stopIfVoiceBroadcastOver() { MXLog.debug("[VoiceBroadcastPlaybackViewModel] stopIfVoiceBroadcastOver") - // TODO: Check if the broadcast is over before stopping everything + // Check if the broadcast is over before stopping everything // If not, the player should not stopped. The view state must be move to buffering - stop() + // TODO: Define with more accuracy the threshold to detect the end of the playback + let remainingTime = state.playingState.duration - state.bindings.progress + if remainingTime < 500 { + stop() + } else { + state.playbackState = .buffering + } } private func stop() { MXLog.debug("[VoiceBroadcastPlaybackViewModel] stop") isLivePlayback = false + displayLink.isPaused = true // Objects will be released on audioPlayerDidStopPlaying audioPlayer?.stop() @@ -165,9 +192,9 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic // MARK: - Voice broadcast chunks playback /// Start the playback from the beginning or push more chunks to it - private func processPendingVoiceBroadcastChunks() { + private func processPendingVoiceBroadcastChunks(_ time: TimeInterval? = nil) { reorderPendingVoiceBroadcastChunks() - processNextVoiceBroadcastChunk() + processNextVoiceBroadcastChunk(time) } /// Start the playback from the last known chunk @@ -188,7 +215,7 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic chunks.sorted(by: {$0.sequence < $1.sequence}) } - private func processNextVoiceBroadcastChunk() { + private func processNextVoiceBroadcastChunk(_ time: TimeInterval? = nil) { MXLog.debug("[VoiceBroadcastPlaybackViewModel] processNextVoiceBroadcastChunk: \(voiceBroadcastChunkQueue.count) chunks remaining") guard voiceBroadcastChunkQueue.count > 0 else { @@ -196,6 +223,10 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic return } + if (isActuallyPaused == false && state.playbackState == .paused) || state.playbackState == .stopped { + state.playbackState = .buffering + } + // TODO: Control the download rate to avoid to download all chunk in mass // We could synchronise it with the number of chunks in the player playlist (audioPlayer.playerItems) @@ -210,45 +241,113 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic // TODO: Make sure there has no new incoming chunk that should be before this attachment // Be careful that this new chunk is not older than the chunk being played by the audio player. Else // we will get an unexecpted rewind. - + switch result { - case .success(let result): - guard result.eventIdentifier == chunk.attachment.eventId else { - return - } + case .success(let result): + guard result.eventIdentifier == chunk.attachment.eventId else { + return + } + + self.voiceBroadcastAttachmentCacheManagerLoadResults.append(result) + + if let audioPlayer = self.audioPlayer { + // Append the chunk to the current playlist + audioPlayer.addContentFromURL(result.url) - if let audioPlayer = self.audioPlayer { - // Append the chunk to the current playlist - audioPlayer.addContentFromURL(result.url) - - // Resume the player. Needed after a pause - if audioPlayer.isPlaying == false { - MXLog.debug("[VoiceBroadcastPlaybackViewModel] processNextVoiceBroadcastChunk: Resume the player") - audioPlayer.play() + // Resume the player. Needed after a buffering + if audioPlayer.isPlaying == false && self.state.playbackState == .buffering { + MXLog.debug("[VoiceBroadcastPlaybackViewModel] processNextVoiceBroadcastChunk: Resume the player") + self.displayLink.isPaused = false + audioPlayer.play() + if let time = time { + audioPlayer.seekToTime(time) } } - else { - // Init and start the player on the first chunk - let audioPlayer = self.mediaServiceProvider.audioPlayerForIdentifier(result.eventIdentifier) - audioPlayer.registerDelegate(self) - - audioPlayer.loadContentFromURL(result.url, displayName: chunk.attachment.originalFileName) - audioPlayer.play() - self.audioPlayer = audioPlayer - } - - case .failure (let error): - MXLog.error("[VoiceBroadcastPlaybackViewModel] processVoiceBroadcastChunkQueue: loadAttachment error", context: error) - if self.voiceBroadcastChunkQueue.count == 0 { - // No more chunk to try. Go to error - self.state.playbackState = .error + } else { + // Init and start the player on the first chunk + let audioPlayer = self.mediaServiceProvider.audioPlayerForIdentifier(result.eventIdentifier) + audioPlayer.registerDelegate(self) + + audioPlayer.loadContentFromURL(result.url, displayName: chunk.attachment.originalFileName) + self.displayLink.isPaused = false + audioPlayer.play() + if let time = time { + audioPlayer.seekToTime(time) } + self.audioPlayer = audioPlayer + } + + case .failure (let error): + MXLog.error("[VoiceBroadcastPlaybackViewModel] processVoiceBroadcastChunkQueue: loadAttachment error", context: error) + if self.voiceBroadcastChunkQueue.count == 0 { + // No more chunk to try. Go to error + self.state.playbackState = .error + } } self.processNextVoiceBroadcastChunk() } } + private func updateDuration() { + let duration = voiceBroadcastAggregator.voiceBroadcast.duration + let time = TimeInterval(duration / 1000) + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .abbreviated + + state.playingState.duration = Float(duration) + state.playingState.durationLabel = formatter.string(from: time) + } + + private func didSliderChanged(_ didChange: Bool) { + acceptProgressUpdates = !didChange + if didChange { + audioPlayer?.pause() + displayLink.isPaused = true + } else { + // Flush the current audio player playlist + audioPlayer?.removeAllPlayerItems() + + let chunks = reorderVoiceBroadcastChunks(chunks: Array(voiceBroadcastAggregator.voiceBroadcast.chunks)) + + // Reinject the chunks we need and play them + let remainingTime = state.playingState.duration - state.bindings.progress + var chunksDuration: UInt = 0 + for chunk in chunks.reversed() { + chunksDuration += chunk.duration + voiceBroadcastChunkQueue.append(chunk) + if Float(chunksDuration) >= remainingTime { + break + } + } + + MXLog.debug("[VoiceBroadcastPlaybackViewModel] didSliderChanged: restart to time: \(state.bindings.progress) milliseconds") + let time = state.bindings.progress - state.playingState.duration + Float(chunksDuration) + processPendingVoiceBroadcastChunks(TimeInterval(time / 1000)) + } + } + + @objc private func handleDisplayLinkTick() { + updateUI() + } + + private func updateUI() { + guard let playingEventId = voiceBroadcastAttachmentCacheManagerLoadResults.first(where: { result in + result.url == audioPlayer?.currentUrl + })?.eventIdentifier, + let playingSequence = voiceBroadcastAggregator.voiceBroadcast.chunks.first(where: { chunk in + chunk.attachment.eventId == playingEventId + })?.sequence else { + return + } + + let progress = Double(voiceBroadcastAggregator.voiceBroadcast.chunks.filter { chunk in + chunk.sequence < playingSequence + }.reduce(0) { $0 + $1.duration}) + (audioPlayer?.currentTime.rounded() ?? 0) * 1000 + + state.bindings.progress = Float(progress) + } + private static func getBroadcastState(from state: VoiceBroadcastInfo.State) -> VoiceBroadcastState { var broadcastState: VoiceBroadcastState switch state { @@ -288,11 +387,10 @@ extension VoiceBroadcastPlaybackViewModel: VoiceBroadcastAggregatorDelegate { func voiceBroadcastAggregatorDidUpdateData(_ aggregator: VoiceBroadcastAggregator) { if isLivePlayback && state.playbackState == .buffering { - // We started directly with a live playback but there was no known chuncks at that time + // We started directly with a live playback but there was no known chunks at that time // These are the first chunks we get. Start the playback on the latest one processPendingVoiceBroadcastChunksForLivePlayback() - } - else { + } else { processPendingVoiceBroadcastChunks() } } @@ -307,8 +405,7 @@ extension VoiceBroadcastPlaybackViewModel: VoiceMessageAudioPlayerDelegate { func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) { if isLivePlayback { state.playbackState = .playingLive - } - else { + } else { state.playbackState = .playing } } diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackView.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackView.swift index a16c83471..fb2da1ddf 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackView.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/View/VoiceBroadcastPlaybackView.swift @@ -117,6 +117,16 @@ struct VoiceBroadcastPlaybackView: View { } .activityIndicator(show: viewModel.viewState.playbackState == .buffering) } + + Slider(value: $viewModel.progress, in: 0...viewModel.viewState.playingState.duration) { + Text("Slider") + } minimumValueLabel: { + Text("") + } maximumValueLabel: { + Text(viewModel.viewState.playingState.durationLabel ?? "").font(.body) + } onEditingChanged: { didChange in + viewModel.send(viewAction: .sliderChange(didChange: didChange)) + } } .padding([.horizontal, .top], 2.0) .padding([.bottom]) diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackModels.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackModels.swift index 3fed0075f..c9133f68e 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackModels.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackModels.swift @@ -21,6 +21,7 @@ enum VoiceBroadcastPlaybackViewAction { case play case playLive case pause + case sliderChange(didChange: Bool) } enum VoiceBroadcastPlaybackState { @@ -44,13 +45,20 @@ enum VoiceBroadcastState { case paused } +struct VoiceBroadcastPlayingState { + var duration: Float + var durationLabel: String? +} + struct VoiceBroadcastPlaybackViewState: BindableState { var details: VoiceBroadcastPlaybackDetails var broadcastState: VoiceBroadcastState var playbackState: VoiceBroadcastPlaybackState + var playingState: VoiceBroadcastPlayingState var bindings: VoiceBroadcastPlaybackViewStateBindings } struct VoiceBroadcastPlaybackViewStateBindings { + var progress: Float } diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackScreenState.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackScreenState.swift index f4fabadb1..4159d9aa7 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackScreenState.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastPlayback/VoiceBroadcastPlaybackScreenState.swift @@ -43,7 +43,7 @@ enum MockVoiceBroadcastPlaybackScreenState: MockScreenState, CaseIterable { var screenView: ([Any], AnyView) { let details = VoiceBroadcastPlaybackDetails(senderDisplayName: "Alice", avatarData: AvatarInput(mxContentUri: "", matrixItemId: "!fakeroomid:matrix.org", displayName: "The name of the room")) - let viewModel = MockVoiceBroadcastPlaybackViewModel(initialViewState: VoiceBroadcastPlaybackViewState(details: details, broadcastState: .live, playbackState: .stopped, bindings: VoiceBroadcastPlaybackViewStateBindings())) + let viewModel = MockVoiceBroadcastPlaybackViewModel(initialViewState: VoiceBroadcastPlaybackViewState(details: details, broadcastState: .live, playbackState: .stopped, playingState: VoiceBroadcastPlayingState(duration: 10.0), bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0))) return ( [false, viewModel], diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderProvider.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderProvider.swift index c7bc2b1a0..3db5cad54 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderProvider.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Coordinator/VoiceBroadcastRecorderProvider.swift @@ -23,7 +23,16 @@ import Foundation // MARK: - Properties // MARK: Public - var session: MXSession? + var session: MXSession? { + willSet { + guard let currentSession = self.session else { return } + + if currentSession != newValue { + // Clear all stored coordinators on new session + coordinatorsForEventIdentifiers.removeAll() + } + } + } var coordinatorsForEventIdentifiers = [String: VoiceBroadcastRecorderCoordinator]() // MARK: Private diff --git a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift index 0ad1fa682..f2e28e5da 100644 --- a/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift +++ b/RiotSwiftUI/Modules/Room/VoiceBroadcastRecorder/Service/MatrixSDK/VoiceBroadcastRecorderService.swift @@ -49,23 +49,31 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol { // MARK: - VoiceBroadcastRecorderServiceProtocol func startRecordingVoiceBroadcast() { - let inputNode = audioEngine.inputNode + do { + try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default) + try AVAudioSession.sharedInstance().setActive(true) - let inputFormat = inputNode.inputFormat(forBus: audioNodeBus) - MXLog.debug("[VoiceBroadcastRecorderService] Start recording voice broadcast for bus name : \(String(describing: inputNode.name(forInputBus: audioNodeBus)))") + let inputNode = audioEngine.inputNode - inputNode.installTap(onBus: audioNodeBus, - bufferSize: 512, - format: inputFormat) { (buffer, time) -> Void in - DispatchQueue.main.async { - self.writeBuffer(buffer) + let inputFormat = inputNode.inputFormat(forBus: audioNodeBus) + MXLog.debug("[VoiceBroadcastRecorderService] Start recording voice broadcast for bus name : \(String(describing: inputNode.name(forInputBus: audioNodeBus)))") + + inputNode.installTap(onBus: audioNodeBus, + bufferSize: 512, + format: inputFormat) { (buffer, time) -> Void in + DispatchQueue.main.async { + self.writeBuffer(buffer) + } } - } - try? audioEngine.start() - - // Disable the sleep mode during the recording until we are able to handle it - UIApplication.shared.isIdleTimerDisabled = true + try audioEngine.start() + + // Disable the sleep mode during the recording until we are able to handle it + UIApplication.shared.isIdleTimerDisabled = true + } catch { + MXLog.debug("[VoiceBroadcastRecorderService] startRecordingVoiceBroadcast error", context: error) + stopRecordingVoiceBroadcast() + } } func stopRecordingVoiceBroadcast() { @@ -141,6 +149,12 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol { private func tearDownVoiceBroadcastService() { resetValues() session.tearDownVoiceBroadcastService() + + do { + try AVAudioSession.sharedInstance().setActive(false) + } catch { + MXLog.error("[VoiceBroadcastRecorderService] tearDownVoiceBroadcastService error", context: error) + } } /// Write audio buffer to chunk file. diff --git a/RiotTests/UserAgentParserTests.swift b/RiotSwiftUI/Modules/UserSessions/Common/Test/Unit/UserAgentParserTests.swift similarity index 99% rename from RiotTests/UserAgentParserTests.swift rename to RiotSwiftUI/Modules/UserSessions/Common/Test/Unit/UserAgentParserTests.swift index e7704b3de..7d4766603 100644 --- a/RiotTests/UserAgentParserTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/Test/Unit/UserAgentParserTests.swift @@ -14,11 +14,10 @@ // limitations under the License. // +@testable import RiotSwiftUI import XCTest -@testable import Element class UserAgentParserTests: XCTestCase { - func testAndroidUserAgents() throws { let uaStrings = [ // New User Agent Implementation @@ -182,7 +181,7 @@ class UserAgentParserTests: XCTestCase { "Element/1.9.9; iOS", "Element/1.9.7 Android", "some random string", - "Element/1.9.9; iOS ", + "Element/1.9.9; iOS " ] let userAgents = uaStrings.map { UserAgentParser.parse($0) } @@ -200,5 +199,4 @@ class UserAgentParserTests: XCTestCase { XCTAssertEqual(userAgents, expected) } - } diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift index 3ea87bed1..8f9dab072 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift @@ -40,7 +40,7 @@ final class UserOtherSessionsCoordinator: Coordinator, Presentable { let viewModel = UserOtherSessionsViewModel(sessionInfos: parameters.sessionInfos, filter: parameters.filter, title: parameters.title, - settingService: RiotSettings.shared) + settingsService: RiotSettings.shared) let view = UserOtherSessions(viewModel: viewModel.context) userOtherSessionsViewModel = viewModel userOtherSessionsHostingController = VectorHostingController(rootView: view) diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift index a4b3e38ce..e81bb7f05 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift @@ -25,6 +25,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { // mock that screen. case all + case none case inactiveSessions case unverifiedSessions case verifiedSessions @@ -37,7 +38,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { /// A list of screen state definitions static var allCases: [MockUserOtherSessionsScreenState] { // Each of the presence statuses - [.all, .inactiveSessions, .unverifiedSessions, .verifiedSessions] + [.all, .none, .inactiveSessions, .unverifiedSessions, .verifiedSessions] } /// Generate the view struct for the screen state. @@ -48,22 +49,27 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { viewModel = UserOtherSessionsViewModel(sessionInfos: allSessions(), filter: .all, title: VectorL10n.userSessionsOverviewOtherSessionsSectionTitle, - settingService: MockUserSessionSettings()) + settingsService: MockUserSessionSettings()) + case .none: + viewModel = UserOtherSessionsViewModel(sessionInfos: [], + filter: .all, + title: VectorL10n.userSessionsOverviewOtherSessionsSectionTitle, + settingsService: MockUserSessionSettings()) case .inactiveSessions: viewModel = UserOtherSessionsViewModel(sessionInfos: inactiveSessions(), filter: .inactive, title: VectorL10n.userOtherSessionSecurityRecommendationTitle, - settingService: MockUserSessionSettings()) + settingsService: MockUserSessionSettings()) case .unverifiedSessions: viewModel = UserOtherSessionsViewModel(sessionInfos: unverifiedSessions(), filter: .unverified, title: VectorL10n.userOtherSessionSecurityRecommendationTitle, - settingService: MockUserSessionSettings()) + settingsService: MockUserSessionSettings()) case .verifiedSessions: viewModel = UserOtherSessionsViewModel(sessionInfos: verifiedSessions(), filter: .verified, title: VectorL10n.userOtherSessionSecurityRecommendationTitle, - settingService: MockUserSessionSettings()) + settingsService: MockUserSessionSettings()) } // can simulate service and viewModel actions here if needs be. diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift index 9a5702be3..71a651659 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift @@ -114,4 +114,12 @@ class UserOtherSessionsUITests: MockScreenTestCase { XCTAssertTrue(button.exists) XCTAssertFalse(buttonLearnMore.exists) } + + func test_whenNoSessionAreShown_theLayoutIsCorrect() { + app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.none.title) + let button = app.buttons["UserOtherSessions.clearFilter"] + let text = app.staticTexts["UserOtherSessions.noItemsText"] + XCTAssertTrue(button.exists) + XCTAssertTrue(text.exists) + } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift index 0d5aa1c9f..bce987575 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift @@ -346,7 +346,7 @@ class UserOtherSessionsViewModelTests: XCTestCase { UserOtherSessionsViewModel(sessionInfos: sessionInfos, filter: filter, title: title, - settingService: MockUserSessionSettings()) + settingsService: MockUserSessionSettings()) } private func createUserSessionInfo(sessionId: String, diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift index 741593fe3..c82532dd2 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift @@ -28,12 +28,12 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi init(sessionInfos: [UserSessionInfo], filter: UserOtherSessionsFilter, title: String, - settingService: UserSessionSettingsProtocol) { + settingsService: UserSessionSettingsProtocol) { self.sessionInfos = sessionInfos defaultTitle = title let bindings = UserOtherSessionsBindings(filter: filter, isEditModeEnabled: false) let sessionItems = filter.filterSessionInfos(sessionInfos: sessionInfos, selectedSessions: selectedSessions) - self.settingsService = settingService + self.settingsService = settingsService super.init(initialViewState: UserOtherSessionsViewState(bindings: bindings, title: title, sessionItems: sessionItems, @@ -41,7 +41,7 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi emptyItemsTitle: filter.userOtherSessionsViewEmptyResultsTitle, allItemsSelected: false, enableSignOutButton: false, - showLocationInfo: settingService.showIPAddressesInSessionsManager)) + showLocationInfo: settingsService.showIPAddressesInSessionsManager)) } // MARK: - Public diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift index 267008b88..e0f71675c 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift @@ -73,6 +73,7 @@ struct UserOtherSessions: View { .font(theme.fonts.footnote) .foregroundColor(theme.colors.primaryContent) .padding(.bottom, 20) + .accessibilityIdentifier("UserOtherSessions.noItemsText") Button { viewModel.send(viewAction: .clearFilter) } label: { @@ -87,6 +88,7 @@ struct UserOtherSessions: View { } .background(theme.colors.background) } + .accessibilityIdentifier("UserOtherSessions.clearFilter") } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift index b3efa0669..e90694fff 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift @@ -62,13 +62,13 @@ enum MockUserSessionDetailsScreenState: MockScreenState, CaseIterable { name: "Android", deviceType: .mobile, verificationState: .unverified, - lastSeenIP: "3.0.0.3", + lastSeenIP: nil, lastSeenTimestamp: Date().timeIntervalSince1970 - 10, - applicationName: "Element Android", - applicationVersion: "1.0.0", + applicationName: "", + applicationVersion: "", applicationURL: nil, deviceModel: nil, - deviceOS: "Android 4.0", + deviceOS: nil, lastSeenIPLocation: nil, clientName: "Element", clientVersion: "1.0.0", diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/UI/UserSessionDetailsUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/UI/UserSessionDetailsUITests.swift index ea2133dd8..fe930f4ca 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/UI/UserSessionDetailsUITests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/UI/UserSessionDetailsUITests.swift @@ -18,18 +18,17 @@ import RiotSwiftUI import XCTest class UserSessionDetailsUITests: MockScreenTestCase { - func disabled_broken_xcode14_test_longPressDetailsCell_CopiesValueToClipboard() throws { + func test_screenWithAllTheContent() throws { app.goToScreenWithIdentifier(MockUserSessionDetailsScreenState.allSections.title) - - UIPasteboard.general.string = "" - - let tables = app.tables - let sessionNameIosCell = tables.cells["Session name, iOS"] - sessionNameIosCell.press(forDuration: 0.5) - - app.buttons["Copy"].tap() - - let clipboard = try XCTUnwrap(UIPasteboard.general.string) - XCTAssertEqual(clipboard, "iOS") + + let rows = app.staticTexts.matching(identifier: "UserSessionDetailsItem.title") + XCTAssertEqual(rows.count, 6) + } + + func test_screenWithSessionSectionOnly() throws { + app.goToScreenWithIdentifier(MockUserSessionDetailsScreenState.sessionSectionOnly.title) + + let rows = app.staticTexts.matching(identifier: "UserSessionDetailsItem.title") + XCTAssertEqual(rows.count, 3) } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/View/UserSessionDetailsItem.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/View/UserSessionDetailsItem.swift index 80ff02dc5..b9a015560 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/View/UserSessionDetailsItem.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/View/UserSessionDetailsItem.swift @@ -34,10 +34,12 @@ struct UserSessionDetailsItem: View { .foregroundColor(theme.colors.secondaryContent) .frame(maxWidth: .infinity, alignment: .leading) .frame(maxHeight: .infinity, alignment: .top) + .accessibility(identifier: "UserSessionDetailsItem.title") Text(viewData.value) .font(theme.fonts.subheadline) .foregroundColor(theme.colors.primaryContent) .multilineTextAlignment(.trailing) + .accessibility(identifier: "UserSessionDetailsItem.value") } .contextMenu { Button { diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionName/Test/UI/UserSessionNameUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionName/Test/UI/UserSessionNameUITests.swift index 1603c9994..0dd53ce0a 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionName/Test/UI/UserSessionNameUITests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionName/Test/UI/UserSessionNameUITests.swift @@ -21,6 +21,7 @@ class UserSessionNameUITests: MockScreenTestCase { func testUserSessionNameInitialState() { app.goToScreenWithIdentifier(MockUserSessionNameScreenState.initialName.title) + assertButtonsExists() let doneButton = app.buttons[VectorL10n.done] XCTAssertTrue(doneButton.exists) XCTAssertFalse(doneButton.isEnabled) @@ -29,6 +30,7 @@ class UserSessionNameUITests: MockScreenTestCase { func testUserSessionNameEmptyState() { app.goToScreenWithIdentifier(MockUserSessionNameScreenState.empty.title) + assertButtonsExists() let doneButton = app.buttons[VectorL10n.done] XCTAssertTrue(doneButton.exists) XCTAssertFalse(doneButton.isEnabled) @@ -37,8 +39,20 @@ class UserSessionNameUITests: MockScreenTestCase { func testUserSessionNameChangedState() { app.goToScreenWithIdentifier(MockUserSessionNameScreenState.changedName.title) + assertButtonsExists() let doneButton = app.buttons[VectorL10n.done] XCTAssertTrue(doneButton.exists) XCTAssertTrue(doneButton.isEnabled) } } + +private extension UserSessionNameUITests { + func assertButtonsExists() { + let buttons = [VectorL10n.done, VectorL10n.cancel, "LearnMore"] + + for buttonId in buttons { + let button = app.buttons[buttonId] + XCTAssertTrue(button.exists) + } + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionName/Test/Unit/UserSessionNameViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionName/Test/Unit/UserSessionNameViewModelTests.swift index 5e76f4989..f0798cf3d 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionName/Test/Unit/UserSessionNameViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionName/Test/Unit/UserSessionNameViewModelTests.swift @@ -15,7 +15,6 @@ // import XCTest - @testable import RiotSwiftUI class UserSessionNameViewModelTests: XCTestCase { @@ -48,4 +47,38 @@ class UserSessionNameViewModelTests: XCTestCase { // Then the done button should be enabled. XCTAssertTrue(context.viewState.canUpdateName, "The done button should be enabled when the name has been changed.") } + + func testCancelIsCalled() { + viewModel.completion = { result in + guard case .cancel = result else { + XCTFail() + return + } + } + + viewModel.context.send(viewAction: .cancel) + } + + func testLearnMoreIsCalled() { + viewModel.completion = { result in + guard case .learnMore = result else { + XCTFail() + return + } + } + + viewModel.context.send(viewAction: .learnMore) + } + + func testUpdateNameIsCalled() { + viewModel.completion = { result in + guard case let .updateName(name) = result else { + XCTFail() + return + } + XCTAssertEqual(name, "Element Mobile: iOS") + } + + viewModel.context.send(viewAction: .done) + } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionName/View/UserSessionName.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionName/View/UserSessionName.swift index fa78292ea..8c6af4572 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionName/View/UserSessionName.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionName/View/UserSessionName.swift @@ -42,6 +42,7 @@ struct UserSessionName: View { viewModel.send(viewAction: .learnMore) } .foregroundColor(theme.colors.secondaryContent) + .accessibility(identifier: "LearnMore") } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift index 72862a37d..1340b1eb9 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift @@ -28,19 +28,12 @@ protocol UserSessionsOverviewServiceProtocol { var overviewDataPublisher: CurrentValueSubject { get } func updateOverviewData(completion: @escaping (Result) -> Void) -> Void - func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo? } extension UserSessionsOverviewServiceProtocol { /// The user's current session. var currentSession: UserSessionInfo? { overviewDataPublisher.value.currentSession } - /// Any unverified sessions on the user's account. - var unverifiedSessions: [UserSessionInfo] { overviewDataPublisher.value.unverifiedSessions } - /// Any inactive sessions on the user's account (not seen for a while). - var inactiveSessions: [UserSessionInfo] { overviewDataPublisher.value.inactiveSessions } /// Any sessions that are verified and have been seen recently. var otherSessions: [UserSessionInfo] { overviewDataPublisher.value.otherSessions } - /// Whether it is possible to link a new device via a QR code. - var linkDeviceEnabled: Bool { overviewDataPublisher.value.linkDeviceEnabled } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/UI/UserSessionsOverviewUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/UI/UserSessionsOverviewUITests.swift index 596647f52..62d841013 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/UI/UserSessionsOverviewUITests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/UI/UserSessionsOverviewUITests.swift @@ -23,8 +23,6 @@ class UserSessionsOverviewUITests: MockScreenTestCase { XCTAssertTrue(app.buttons["userSessionCardVerifyButton"].exists) XCTAssertTrue(app.staticTexts["userSessionCardViewDetails"].exists) - - verifyLinkDeviceButtonStatus(true) } func testCurrentSessionVerified() { @@ -33,7 +31,6 @@ class UserSessionsOverviewUITests: MockScreenTestCase { XCTAssertTrue(app.staticTexts["userSessionCardViewDetails"].exists) app.buttons["MoreOptionsMenu"].tap() XCTAssertTrue(app.buttons["Sign out of all other sessions"].exists) - verifyLinkDeviceButtonStatus(true) } func testOnlyUnverifiedSessions() { @@ -41,8 +38,6 @@ class UserSessionsOverviewUITests: MockScreenTestCase { XCTAssertTrue(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists) XCTAssertTrue(app.staticTexts["userSessionsOverviewOtherSection"].exists) - - verifyLinkDeviceButtonStatus(false) } func testOnlyInactiveSessions() { @@ -50,8 +45,6 @@ class UserSessionsOverviewUITests: MockScreenTestCase { XCTAssertTrue(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists) XCTAssertTrue(app.staticTexts["userSessionsOverviewOtherSection"].exists) - - verifyLinkDeviceButtonStatus(false) } func testNoOtherSessions() { @@ -61,18 +54,6 @@ class UserSessionsOverviewUITests: MockScreenTestCase { XCTAssertFalse(app.staticTexts["userSessionsOverviewOtherSection"].exists) app.buttons["MoreOptionsMenu"].tap() XCTAssertFalse(app.buttons["Sign out of all other sessions"].exists) - verifyLinkDeviceButtonStatus(false) - } - - func verifyLinkDeviceButtonStatus(_ enabled: Bool) { -// if enabled { -// let linkDeviceButton = app.buttons["linkDeviceButton"] -// XCTAssertTrue(linkDeviceButton.exists) -// XCTAssertTrue(linkDeviceButton.isEnabled) -// } else { -// let linkDeviceButton = app.buttons["linkDeviceButton"] -// XCTAssertFalse(linkDeviceButton.exists) -// } } func testWhenMoreThan5OtherSessionsThenViewAllButtonVisible() { diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift index 87631b216..f5d24c555 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItem.swift @@ -75,13 +75,14 @@ struct UserSessionListItem: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 16) } + .contentShape(Rectangle()) + .onTapGesture { + onBackgroundTap?(viewData.sessionId) + } + .onLongPressGesture { + onBackgroundLongPress?(viewData.sessionId) + } } - .simultaneousGesture(LongPressGesture().onEnded { _ in - onBackgroundLongPress?(viewData.sessionId) - }) - .simultaneousGesture(TapGesture().onEnded { - onBackgroundTap?(viewData.sessionId) - }) .frame(maxWidth: .infinity, alignment: .leading) .accessibilityIdentifier("UserSessionListItem_\(viewData.sessionId)") } diff --git a/RiotTests/UserSessionsOverviewServiceTests.swift b/RiotTests/UserSessionsOverviewServiceTests.swift index 49859bc68..86f129268 100644 --- a/RiotTests/UserSessionsOverviewServiceTests.swift +++ b/RiotTests/UserSessionsOverviewServiceTests.swift @@ -118,6 +118,12 @@ class UserSessionsOverviewServiceTests: XCTestCase { } } +private extension UserSessionsOverviewServiceProtocol { + var unverifiedSessions: [UserSessionInfo] { overviewDataPublisher.value.unverifiedSessions } + var inactiveSessions: [UserSessionInfo] { overviewDataPublisher.value.inactiveSessions } + var linkDeviceEnabled: Bool { overviewDataPublisher.value.linkDeviceEnabled } +} + private class MockUserSessionsDataProvider: UserSessionsDataProviderProtocol { enum Mode { case currentSessionUnverified diff --git a/changelog.d/6945.bugfix b/changelog.d/6945.bugfix new file mode 100644 index 000000000..7436c9933 --- /dev/null +++ b/changelog.d/6945.bugfix @@ -0,0 +1 @@ +Rich Text Composer: Voice Dictation is supported (only plain text can be dictated). diff --git a/changelog.d/7066.bugfix b/changelog.d/7066.bugfix new file mode 100644 index 000000000..94d8ed65c --- /dev/null +++ b/changelog.d/7066.bugfix @@ -0,0 +1 @@ +Threads: removed "unread_thread_notifications" from sync filters for server that doesn't support MSC3773 diff --git a/changelog.d/7070.bugfix b/changelog.d/7070.bugfix new file mode 100644 index 000000000..680f44936 --- /dev/null +++ b/changelog.d/7070.bugfix @@ -0,0 +1 @@ +Poll not usable after logging out and back in. diff --git a/changelog.d/pr-7051.change b/changelog.d/pr-7051.change new file mode 100644 index 000000000..89bb86755 --- /dev/null +++ b/changelog.d/pr-7051.change @@ -0,0 +1 @@ +Updated GBDeviceInfo pod. \ No newline at end of file diff --git a/changelog.d/pr-7065.change b/changelog.d/pr-7065.change new file mode 100644 index 000000000..d679f7ea0 --- /dev/null +++ b/changelog.d/pr-7065.change @@ -0,0 +1 @@ +Improve device manager code coverage. diff --git a/changelog.d/pr-7068.change b/changelog.d/pr-7068.change new file mode 100644 index 000000000..301145398 --- /dev/null +++ b/changelog.d/pr-7068.change @@ -0,0 +1,2 @@ +Initial sync: Remove 10s wait on failed initial sync + diff --git a/project.yml b/project.yml index 349f04be6..0fe95db19 100644 --- a/project.yml +++ b/project.yml @@ -53,7 +53,7 @@ packages: branch: main WysiwygComposer: url: https://github.com/matrix-org/matrix-wysiwyg-composer-swift - revision: 38724c7c037402372d2c42a7eb2451251f76693b + revision: 2469f27b7e1e51aaa135e09f9005eb10fda686e6 DeviceKit: url: https://github.com/devicekit/DeviceKit majorVersion: 4.7.0