Merge branch 'develop' into mauroromito/fullscreen_mode_2

# Conflicts:
#	Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved
#	RiotSwiftUI/Modules/Room/Composer/View/Composer.swift
#	project.yml
This commit is contained in:
Mauro Romito
2022-11-15 11:03:28 +01:00
45 changed files with 457 additions and 205 deletions
@@ -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) {
@@ -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()
}
}
@@ -76,4 +76,8 @@ final class VoiceBroadcastPlaybackCoordinator: Coordinator, Presentable {
}
func endVoiceBroadcast() {}
func pausePlaying() {
viewModel.context.send(viewAction: .pause)
}
}
@@ -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()
}
}
}
@@ -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
}
}
@@ -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])
@@ -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
}
@@ -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],
@@ -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
@@ -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.
@@ -0,0 +1,202 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
@testable import RiotSwiftUI
import XCTest
class UserAgentParserTests: XCTestCase {
func testAndroidUserAgents() throws {
let uaStrings = [
// New User Agent Implementation
"Element dbg/1.5.0-dev (Xiaomi Mi 9T; Android 11; RKQ1.200826.002 test-keys; Flavour GooglePlay; MatrixAndroidSdk2 1.5.2)",
"Element/1.5.0 (Samsung SM-G960F; Android 6.0.1; RKQ1.200826.002; Flavour FDroid; MatrixAndroidSdk2 1.5.2)",
"Element/1.5.0 (Google Nexus 5; Android 7.0; RKQ1.200826.002 test test; Flavour FDroid; MatrixAndroidSdk2 1.5.2)",
// Legacy User Agent Implementation
"Element/1.0.0 (Linux; U; Android 6.0.1; SM-A510F Build/MMB29; Flavour GPlay; MatrixAndroidSdk2 1.0)",
"Element/1.0.0 (Linux; Android 7.0; SM-G610M Build/NRD90M; Flavour GPlay; MatrixAndroidSdk2 1.0)"
]
let userAgents = uaStrings.map { UserAgentParser.parse($0) }
let expected = [
UserAgent(deviceType: .mobile,
deviceModel: "Xiaomi Mi 9T",
deviceOS: "Android 11",
clientName: "Element dbg",
clientVersion: "1.5.0-dev"),
UserAgent(deviceType: .mobile,
deviceModel: "Samsung SM-G960F",
deviceOS: "Android 6.0.1",
clientName: "Element",
clientVersion: "1.5.0"),
UserAgent(deviceType: .mobile,
deviceModel: "Google Nexus 5",
deviceOS: "Android 7.0",
clientName: "Element",
clientVersion: "1.5.0"),
UserAgent(deviceType: .mobile,
deviceModel: "SM-A510F Build/MMB29",
deviceOS: "Android 6.0.1",
clientName: "Element",
clientVersion: "1.0.0"),
UserAgent(deviceType: .mobile,
deviceModel: "SM-G610M Build/NRD90M",
deviceOS: "Android 7.0",
clientName: "Element",
clientVersion: "1.0.0")
]
XCTAssertEqual(userAgents, expected)
}
func testIOSUserAgents() throws {
let uaStrings = [
// New User Agent Implementation
"Element/1.9.8 (iPhone X; iOS 15.2; Scale/3.00)",
"Element/1.9.9 (iPhone XS; iOS 15.5; Scale/3.00)",
"Element/1.9.7 (iPad Pro (12.9-inch) (3rd generation); iOS 15.5; Scale/3.00)",
// Legacy User Agent Implementation
"Element/1.8.21 (iPhone; iOS 15.0; Scale/2.00)",
"Element/1.8.19 (iPhone; iOS 15.2; Scale/3.00)",
// Simulator User Agent
"Element/1.9.7 (Simulator (iPhone 13 Pro Max); iOS 15.5; Scale/3.00)"
]
let userAgents = uaStrings.map { UserAgentParser.parse($0) }
let expected = [
UserAgent(deviceType: .mobile,
deviceModel: "iPhone X",
deviceOS: "iOS 15.2",
clientName: "Element",
clientVersion: "1.9.8"),
UserAgent(deviceType: .mobile,
deviceModel: "iPhone XS",
deviceOS: "iOS 15.5",
clientName: "Element",
clientVersion: "1.9.9"),
UserAgent(deviceType: .mobile,
deviceModel: "iPad Pro (12.9-inch) (3rd generation)",
deviceOS: "iOS 15.5",
clientName: "Element",
clientVersion: "1.9.7"),
UserAgent(deviceType: .mobile,
deviceModel: "iPhone",
deviceOS: "iOS 15.0",
clientName: "Element",
clientVersion: "1.8.21"),
UserAgent(deviceType: .mobile,
deviceModel: "iPhone",
deviceOS: "iOS 15.2",
clientName: "Element",
clientVersion: "1.8.19"),
UserAgent(deviceType: .mobile,
deviceModel: "Simulator (iPhone 13 Pro Max)",
deviceOS: "iOS 15.5",
clientName: "Element",
clientVersion: "1.9.7")
]
XCTAssertEqual(userAgents, expected)
}
func testDesktopUserAgents() {
let uaStrings = [
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ElementNightly/2022091301 Chrome/104.0.5112.102 Electron/20.1.1 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) ElementNightly/2022091301 Chrome/104.0.5112.102 Electron/20.1.1 Safari/537.36"
]
let userAgents = uaStrings.map { UserAgentParser.parse($0) }
let expected = [
UserAgent(deviceType: .desktop,
deviceModel: nil,
deviceOS: "macOS",
clientName: "Electron",
clientVersion: "20.1.1"),
UserAgent(deviceType: .desktop,
deviceModel: nil,
deviceOS: "Windows",
clientName: "Electron",
clientVersion: "20.1.1")
]
XCTAssertEqual(userAgents, expected)
}
func testWebUserAgents() throws {
let uaStrings = [
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:39.0) Gecko/20100101 Firefox/39.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/600.3.18 (KHTML, like Gecko) Version/8.0.3 Safari/600.3.18",
"Mozilla/5.0 (Linux; Android 9; SM-G973U Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Mobile Safari/537.36"
]
let userAgents = uaStrings.map { UserAgentParser.parse($0) }
let expected = [
UserAgent(deviceType: .web,
deviceModel: nil,
deviceOS: "macOS",
clientName: "Chrome",
clientVersion: "104.0.5112.102"),
UserAgent(deviceType: .web,
deviceModel: nil,
deviceOS: "Windows",
clientName: "Chrome",
clientVersion: "104.0.5112.102"),
UserAgent(deviceType: .web,
deviceModel: nil,
deviceOS: "macOS",
clientName: "Firefox",
clientVersion: "39.0"),
UserAgent(deviceType: .web,
deviceModel: nil,
deviceOS: "macOS",
clientName: "Safari",
clientVersion: "8.0.3"),
UserAgent(deviceType: .web,
deviceModel: nil,
deviceOS: "Android 9",
clientName: "Chrome",
clientVersion: "69.0.3497.100")
]
XCTAssertEqual(userAgents, expected)
}
func testInvalidUserAgents() throws {
let uaStrings = [
"Element (iPhone X; OS 15.2; 3.00)",
"Element/1.9.9; iOS",
"Element/1.9.7 Android",
"some random string",
"Element/1.9.9; iOS "
]
let userAgents = uaStrings.map { UserAgentParser.parse($0) }
let expected = [
.unknown,
.unknown,
.unknown,
.unknown,
UserAgent(deviceType: .mobile,
deviceModel: nil,
deviceOS: nil,
clientName: "Element",
clientVersion: "1.9.9;")
]
XCTAssertEqual(userAgents, expected)
}
}
@@ -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)
@@ -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.
@@ -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)
}
}
@@ -346,7 +346,7 @@ class UserOtherSessionsViewModelTests: XCTestCase {
UserOtherSessionsViewModel(sessionInfos: sessionInfos,
filter: filter,
title: title,
settingService: MockUserSessionSettings())
settingsService: MockUserSessionSettings())
}
private func createUserSessionInfo(sessionId: String,
@@ -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
@@ -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")
}
}
@@ -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",
@@ -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)
}
}
@@ -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 {
@@ -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)
}
}
}
@@ -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)
}
}
@@ -42,6 +42,7 @@ struct UserSessionName: View {
viewModel.send(viewAction: .learnMore)
}
.foregroundColor(theme.colors.secondaryContent)
.accessibility(identifier: "LearnMore")
}
}
@@ -28,19 +28,12 @@ protocol UserSessionsOverviewServiceProtocol {
var overviewDataPublisher: CurrentValueSubject<UserSessionsOverviewData, Never> { get }
func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> 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 }
}
@@ -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() {
@@ -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)")
}