mirror of
https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios.git
synced 2026-04-25 11:02:48 +02:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -76,4 +76,8 @@ final class VoiceBroadcastPlaybackCoordinator: Coordinator, Presentable {
|
||||
}
|
||||
|
||||
func endVoiceBroadcast() {}
|
||||
|
||||
func pausePlaying() {
|
||||
viewModel.context.send(viewAction: .pause)
|
||||
}
|
||||
}
|
||||
|
||||
+20
-4
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+147
-50
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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],
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
+27
-13
@@ -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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+11
-5
@@ -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.
|
||||
|
||||
+8
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -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",
|
||||
|
||||
+11
-12
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
-1
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-7
@@ -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 }
|
||||
}
|
||||
|
||||
-19
@@ -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() {
|
||||
|
||||
+7
-6
@@ -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)")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user