Merge branch 'develop' into aleksandrs/6838_filter_sessions

# Conflicts:
#	RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift
#	RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift
This commit is contained in:
Aleksandrs Proskurins
2022-10-12 10:58:04 +03:00
70 changed files with 1869 additions and 389 deletions
@@ -19,6 +19,7 @@ import SwiftUI
struct UserSessionsOverviewCoordinatorParameters {
let session: MXSession
let service: UserSessionsOverviewService
}
final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
@@ -36,10 +37,9 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
init(parameters: UserSessionsOverviewCoordinatorParameters) {
self.parameters = parameters
service = parameters.service
let dataProvider = UserSessionsDataProvider(session: parameters.session)
service = UserSessionsOverviewService(dataProvider: dataProvider)
viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: service)
viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: parameters.service)
hostingViewController = VectorHostingController(rootView: UserSessionsOverview(viewModel: viewModel.context))
hostingViewController.vc_setLargeTitleDisplayMode(.never)
@@ -60,7 +60,7 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
case let .showOtherSessions(sessionInfos: sessionInfos, filter: filter):
self.showOtherSessions(sessionInfos: sessionInfos, filterBy: filter)
case .verifyCurrentSession:
self.startVerifyCurrentSession()
self.completion?(.verifyCurrentSession)
case .renameSession(let sessionInfo):
self.completion?(.renameSession(sessionInfo))
case .logoutOfSession(let sessionInfo):
@@ -44,6 +44,16 @@ class UserSessionsDataProvider: UserSessionsDataProviderProtocol {
session.crypto.device(withDeviceId: deviceId, ofUser: userId)
}
func verificationState(for deviceInfo: MXDeviceInfo?) -> UserSessionInfo.VerificationState {
guard let deviceInfo = deviceInfo else { return .unknown }
guard session.crypto?.crossSigning?.canCrossSign == true else {
return deviceInfo.deviceId == session.myDeviceId ? .unverified : .unknown
}
return deviceInfo.trustLevel.isVerified ? .verified : .unverified
}
func accountData(for eventType: String) -> [AnyHashable: Any]? {
session.accountData.accountData(forEventType: eventType)
}
@@ -28,6 +28,8 @@ protocol UserSessionsDataProviderProtocol {
func device(withDeviceId deviceId: String, ofUser userId: String) -> MXDeviceInfo?
func verificationState(for deviceInfo: MXDeviceInfo?) -> UserSessionInfo.VerificationState
func accountData(for eventType: String) -> [AnyHashable: Any]?
func qrLoginAvailable() async throws -> Bool
@@ -14,7 +14,7 @@
// limitations under the License.
//
import Foundation
import Combine
import MatrixSDK
class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
@@ -22,20 +22,22 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
private static let inactiveSessionDurationTreshold: TimeInterval = 90 * 86400
private let dataProvider: UserSessionsDataProviderProtocol
private var cancellables: Set<AnyCancellable> = []
private(set) var overviewData: UserSessionsOverviewData
private(set) var overviewDataPublisher: CurrentValueSubject<UserSessionsOverviewData, Never>
private(set) var sessionInfos: [UserSessionInfo]
init(dataProvider: UserSessionsDataProviderProtocol) {
self.dataProvider = dataProvider
overviewData = UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [],
linkDeviceEnabled: false)
overviewDataPublisher = .init(UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [],
linkDeviceEnabled: false))
sessionInfos = []
setupInitialOverviewData()
listenForSessionUpdates()
}
// MARK: - Public
@@ -47,9 +49,10 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
self.sessionInfos = self.sortedSessionInfos(from: devices)
Task { @MainActor in
let linkDeviceEnabled = try? await self.dataProvider.qrLoginAvailable()
self.overviewData = self.sessionsOverviewData(from: self.sessionInfos,
linkDeviceEnabled: linkDeviceEnabled ?? false)
completion(.success(self.overviewData))
let overviewData = self.sessionsOverviewData(from: self.sessionInfos,
linkDeviceEnabled: linkDeviceEnabled ?? false)
self.overviewDataPublisher.send(overviewData)
completion(.success(overviewData))
}
case .failure(let error):
completion(.failure(error))
@@ -58,25 +61,43 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
}
func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo? {
if overviewData.currentSession?.id == sessionId {
return overviewData.currentSession
if currentSession?.id == sessionId {
return currentSession
}
return overviewData.otherSessions.first(where: { $0.id == sessionId })
return otherSessions.first(where: { $0.id == sessionId })
}
// MARK: - Private
private func listenForSessionUpdates() {
NotificationCenter.default.publisher(for: .MXDeviceInfoTrustLevelDidChange)
.sink { [weak self] _ in
self?.updateOverviewData { _ in }
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .MXDeviceListDidUpdateUsersDevices)
.sink { [weak self] _ in
self?.updateOverviewData { _ in }
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .MXCrossSigningInfoTrustLevelDidChange)
.sink { [weak self] _ in
self?.updateOverviewData { _ in }
}
.store(in: &cancellables)
}
private func setupInitialOverviewData() {
guard let currentSessionInfo = getCurrentSessionInfo() else {
return
}
overviewData = UserSessionsOverviewData(currentSession: currentSessionInfo,
unverifiedSessions: currentSessionInfo.isVerified ? [] : [currentSessionInfo],
inactiveSessions: currentSessionInfo.isActive ? [] : [currentSessionInfo],
otherSessions: [],
linkDeviceEnabled: false)
overviewDataPublisher = .init(UserSessionsOverviewData(currentSession: currentSessionInfo,
unverifiedSessions: currentSessionInfo.verificationState == .verified ? [] : [currentSessionInfo],
inactiveSessions: currentSessionInfo.isActive ? [] : [currentSessionInfo],
otherSessions: [],
linkDeviceEnabled: false))
}
private func getCurrentSessionInfo() -> UserSessionInfo? {
@@ -96,14 +117,15 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
private func sessionsOverviewData(from allSessions: [UserSessionInfo],
linkDeviceEnabled: Bool) -> UserSessionsOverviewData {
UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first,
unverifiedSessions: allSessions.filter { !$0.isVerified },
unverifiedSessions: allSessions.filter { $0.verificationState != .verified },
inactiveSessions: allSessions.filter { !$0.isActive },
otherSessions: allSessions.filter { !$0.isCurrent },
linkDeviceEnabled: linkDeviceEnabled)
}
private func sessionInfo(from device: MXDevice, isCurrentSession: Bool) -> UserSessionInfo {
let isSessionVerified = deviceInfo(for: device.deviceId)?.trustLevel.isVerified ?? false
let deviceInfo = deviceInfo(for: device.deviceId)
let verificationState = dataProvider.verificationState(for: deviceInfo)
let eventType = kMXAccountDataTypeClientInformation + "." + device.deviceId
let appData = dataProvider.accountData(for: eventType)
@@ -122,7 +144,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
return UserSessionInfo(withDevice: device,
applicationData: appData as? [String: String],
userAgent: userAgent,
isSessionVerified: isSessionVerified,
verificationState: verificationState,
isActive: isSessionActive,
isCurrent: isCurrentSession)
}
@@ -140,13 +162,13 @@ extension UserSessionInfo {
init(withDevice device: MXDevice,
applicationData: [String: String]?,
userAgent: UserAgent?,
isSessionVerified: Bool,
verificationState: VerificationState,
isActive: Bool,
isCurrent: Bool) {
self.init(id: device.deviceId,
name: device.displayName,
deviceType: userAgent?.deviceType ?? .unknown,
isVerified: isSessionVerified,
verificationState: verificationState,
lastSeenIP: device.lastSeenIp,
lastSeenTimestamp: device.lastSeenTs > 0 ? TimeInterval(device.lastSeenTs / 1000) : nil,
applicationName: applicationData?["name"],
@@ -14,7 +14,7 @@
// limitations under the License.
//
import Foundation
import Combine
class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
enum Mode {
@@ -27,17 +27,17 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
private let mode: Mode
var overviewData: UserSessionsOverviewData
var overviewDataPublisher: CurrentValueSubject<UserSessionsOverviewData, Never>
var sessionInfos = [UserSessionInfo]()
init(mode: Mode = .currentSessionUnverified) {
self.mode = mode
overviewData = UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [],
linkDeviceEnabled: false)
overviewDataPublisher = .init(UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [],
linkDeviceEnabled: false))
}
func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) {
@@ -46,47 +46,47 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
switch mode {
case .noOtherSessions:
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [],
linkDeviceEnabled: false)
overviewDataPublisher.send(UserSessionsOverviewData(currentSession: mockCurrentSession,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [],
linkDeviceEnabled: false))
case .onlyUnverifiedSessions:
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: unverifiedSessions + [currentSession],
inactiveSessions: [],
otherSessions: unverifiedSessions,
linkDeviceEnabled: false)
overviewDataPublisher.send(UserSessionsOverviewData(currentSession: mockCurrentSession,
unverifiedSessions: unverifiedSessions + [mockCurrentSession],
inactiveSessions: [],
otherSessions: unverifiedSessions,
linkDeviceEnabled: false))
case .onlyInactiveSessions:
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: [],
inactiveSessions: inactiveSessions,
otherSessions: inactiveSessions,
linkDeviceEnabled: false)
overviewDataPublisher.send(UserSessionsOverviewData(currentSession: mockCurrentSession,
unverifiedSessions: [],
inactiveSessions: inactiveSessions,
otherSessions: inactiveSessions,
linkDeviceEnabled: false))
default:
let otherSessions = unverifiedSessions + inactiveSessions + buildSessions(verified: true, active: true)
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: unverifiedSessions,
inactiveSessions: inactiveSessions,
otherSessions: otherSessions,
linkDeviceEnabled: true)
overviewDataPublisher.send(UserSessionsOverviewData(currentSession: mockCurrentSession,
unverifiedSessions: unverifiedSessions,
inactiveSessions: inactiveSessions,
otherSessions: otherSessions,
linkDeviceEnabled: true))
}
completion(.success(overviewData))
completion(.success(overviewDataPublisher.value))
}
func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo? {
overviewData.otherSessions.first { $0.id == sessionId }
otherSessions.first { $0.id == sessionId }
}
// MARK: - Private
private var currentSession: UserSessionInfo {
private var mockCurrentSession: UserSessionInfo {
UserSessionInfo(id: "alice",
name: "iOS",
deviceType: .mobile,
isVerified: mode == .currentSessionVerified,
verificationState: mode == .currentSessionVerified ? .verified : .unverified,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: nil,
applicationName: "Element iOS",
@@ -105,14 +105,14 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
[UserSessionInfo(id: "1 verified: \(verified) active: \(active)",
name: "macOS verified: \(verified) active: \(active)",
deviceType: .desktop,
isVerified: verified,
verificationState: verified ? .verified : .unverified,
lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000,
applicationName: "Element MacOS",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "macOS 12.5.1",
deviceOS: "macOS",
lastSeenIPLocation: nil,
clientName: "Electron",
clientVersion: "20.0.0",
@@ -121,14 +121,14 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
UserSessionInfo(id: "2 verified: \(verified) active: \(active)",
name: "Firefox on Windows verified: \(verified) active: \(active)",
deviceType: .web,
isVerified: verified,
verificationState: verified ? .verified : .unverified,
lastSeenIP: "2.0.0.2",
lastSeenTimestamp: Date().timeIntervalSince1970 - 100,
applicationName: "Element Web",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "Windows 10",
deviceOS: "Windows",
lastSeenIPLocation: nil,
clientName: "Firefox",
clientVersion: "39.0",
@@ -137,7 +137,7 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
UserSessionInfo(id: "3 verified: \(verified) active: \(active)",
name: "Android verified: \(verified) active: \(active)",
deviceType: .mobile,
isVerified: verified,
verificationState: verified ? .verified : .unverified,
lastSeenIP: "3.0.0.3",
lastSeenTimestamp: Date().timeIntervalSince1970 - 10,
applicationName: "Element Android",
@@ -14,7 +14,7 @@
// limitations under the License.
//
import Foundation
import Combine
struct UserSessionsOverviewData {
let currentSession: UserSessionInfo?
@@ -25,10 +25,23 @@ struct UserSessionsOverviewData {
}
protocol UserSessionsOverviewServiceProtocol {
var overviewData: UserSessionsOverviewData { get }
var overviewDataPublisher: CurrentValueSubject<UserSessionsOverviewData, Never> { get }
var sessionInfos: [UserSessionInfo] { 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 }
}
@@ -76,7 +76,7 @@ class UserSessionsOverviewViewModelTests: XCTestCase {
result = action
}
guard let currentSession = service.overviewData.currentSession else {
guard let currentSession = service.currentSession else {
XCTFail("The current session should be valid at this point")
return
}
@@ -84,7 +84,7 @@ class UserSessionsOverviewViewModelTests: XCTestCase {
viewModel.process(viewAction: .viewCurrentSessionDetails)
XCTAssertEqual(result, .showCurrentSessionOverview(sessionInfo: currentSession))
guard let randomSession = service.overviewData.otherSessions.randomElement() else {
guard let randomSession = service.otherSessions.randomElement() else {
XCTFail("There should be other sessions")
return
}
@@ -19,6 +19,7 @@ import Foundation
// MARK: - Coordinator
enum UserSessionsOverviewCoordinatorResult {
case verifyCurrentSession
case renameSession(UserSessionInfo)
case logoutOfSession(UserSessionInfo)
case openSessionOverview(sessionInfo: UserSessionInfo)
@@ -28,7 +28,12 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
super.init(initialViewState: .init())
updateViewState(with: userSessionsOverviewService.overviewData)
userSessionsOverviewService.overviewDataPublisher.sink { [weak self] overviewData in
self?.updateViewState(with: overviewData)
}
.store(in: &cancellables)
updateViewState(with: userSessionsOverviewService.overviewDataPublisher.value)
}
// MARK: - Public
@@ -40,19 +45,19 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
case .verifyCurrentSession:
completion?(.verifyCurrentSession)
case .renameCurrentSession:
guard let currentSessionInfo = userSessionsOverviewService.overviewData.currentSession else {
guard let currentSessionInfo = userSessionsOverviewService.currentSession else {
assertionFailure("Missing current session")
return
}
completion?(.renameSession(currentSessionInfo))
case .logoutOfCurrentSession:
guard let currentSessionInfo = userSessionsOverviewService.overviewData.currentSession else {
guard let currentSessionInfo = userSessionsOverviewService.currentSession else {
assertionFailure("Missing current session")
return
}
completion?(.logoutOfSession(currentSessionInfo))
case .viewCurrentSessionDetails:
guard let currentSessionInfo = userSessionsOverviewService.overviewData.currentSession else {
guard let currentSessionInfo = userSessionsOverviewService.currentSession else {
assertionFailure("Missing current session")
return
}
@@ -91,19 +96,15 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
state.showLoadingIndicator = true
userSessionsOverviewService.updateOverviewData { [weak self] result in
guard let self = self else {
return
}
guard let self = self else { return }
self.state.showLoadingIndicator = false
switch result {
case .success(let overViewData):
self.updateViewState(with: overViewData)
case .failure(let error):
if case let .failure(error) = result {
// TODO:
break
}
// No need to consume .success as there's a subscription on the data.
}
}
@@ -73,7 +73,7 @@ struct UserSessionListPreview: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(userSessionsOverviewService.overviewData.otherSessions) { userSessionInfo in
ForEach(userSessionsOverviewService.otherSessions) { userSessionInfo in
let viewData = UserSessionListItemViewDataFactory().create(from: userSessionInfo)
UserSessionListItem(viewData: viewData, onBackgroundTap: { _ in
@@ -22,7 +22,7 @@ struct UserSessionListItemViewDataFactory {
sessionDisplayName: sessionInfo.name)
let sessionDetails = buildSessionDetails(sessionInfo: sessionInfo)
let deviceAvatarViewData = DeviceAvatarViewData(deviceType: sessionInfo.deviceType,
isVerified: sessionInfo.isVerified)
verificationState: sessionInfo.verificationState)
return UserSessionListItemViewData(sessionId: sessionInfo.id,
sessionName: sessionName,
sessionDetails: sessionDetails,
@@ -50,7 +50,15 @@ struct UserSessionListItemViewDataFactory {
private func activeSessionDetails(sessionInfo: UserSessionInfo) -> String {
let sessionDetailsString: String
let sessionStatusText = sessionInfo.isVerified ? VectorL10n.userSessionVerifiedShort : VectorL10n.userSessionUnverifiedShort
let sessionStatusText: String
switch sessionInfo.verificationState {
case .verified:
sessionStatusText = VectorL10n.userSessionVerifiedShort
case .unverified:
sessionStatusText = VectorL10n.userSessionUnverifiedShort
case .unknown:
sessionStatusText = VectorL10n.userSessionVerificationUnknownShort
}
var lastActivityDateString: String?
@@ -123,8 +123,10 @@ struct UserSessionsOverview: View {
private var currentSessionMenu: some View {
Menu {
Button { viewModel.send(viewAction: .renameCurrentSession) } label: {
Label(VectorL10n.manageSessionRename, systemImage: "pencil")
SwiftUI.Section {
Button { viewModel.send(viewAction: .renameCurrentSession) } label: {
Label(VectorL10n.manageSessionRename, systemImage: "pencil")
}
}
if #available(iOS 15, *) {
@@ -137,8 +139,12 @@ struct UserSessionsOverview: View {
}
}
} label: {
Image(systemName: "ellipsis.circle")
Image(systemName: "ellipsis")
.foregroundColor(theme.colors.secondaryContent)
.padding(.horizontal, 8)
.padding(.vertical, 12)
}
.offset(x: 8) // Re-align the symbol after applying padding.
}
private var otherSessionsSection: some View {