diff --git a/Config/CommonConfiguration.swift b/Config/CommonConfiguration.swift index d304f7fbef..a89427c3ae 100644 --- a/Config/CommonConfiguration.swift +++ b/Config/CommonConfiguration.swift @@ -172,7 +172,7 @@ class CommonConfiguration: NSObject, Configurable { func setupSettingsWhenLoaded(for matrixSession: MXSession) { // Do not warn for unknown devices. We have cross-signing now - matrixSession.crypto.warnOnUnknowDevices = false + matrixSession.crypto?.warnOnUnknowDevices = false } } diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index 24ee63da65..415b5b52e4 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -2407,6 +2407,7 @@ To enable access, tap Settings> Location and select Always"; "user_sessions_overview_other_sessions_section_info" = "For best security, verify your sessions and sign out from any session that you don’t recognize or use anymore."; "user_sessions_overview_current_session_section_title" = "Current session"; +"user_sessions_overview_link_device" = "Link a device"; "user_sessions_view_all_action" = "View all (%d)"; diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 44fb49fa0f..0d4e4ac192 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -8747,6 +8747,10 @@ public class VectorL10n: NSObject { public static var userSessionsOverviewCurrentSessionSectionTitle: String { return VectorL10n.tr("Vector", "user_sessions_overview_current_session_section_title") } + /// Link a device + public static var userSessionsOverviewLinkDevice: String { + return VectorL10n.tr("Vector", "user_sessions_overview_link_device") + } /// For best security, verify your sessions and sign out from any session that you don’t recognize or use anymore. public static var userSessionsOverviewOtherSessionsSectionInfo: String { return VectorL10n.tr("Vector", "user_sessions_overview_other_sessions_section_info") diff --git a/RiotSwiftUI/Modules/UserSessions/Common/InactiveUserSessionLastActivityFormatter.swift b/RiotSwiftUI/Modules/UserSessions/Common/InactiveUserSessionLastActivityFormatter.swift index f44ef9204e..3faef040da 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/InactiveUserSessionLastActivityFormatter.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/InactiveUserSessionLastActivityFormatter.swift @@ -1,4 +1,4 @@ -// +// // Copyright 2022 New Vector Ltd // // Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift index 8fa03b02c1..44ec039fcb 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift @@ -139,21 +139,21 @@ struct UserSessionCardViewPreview: View { init(isCurrent: Bool = false) { let sessionInfo = UserSessionInfo(id: "alice", - name: "iOS", - deviceType: .mobile, - isVerified: false, - lastSeenIP: "10.0.0.10", - lastSeenTimestamp: nil, - applicationName: "Element iOS", - applicationVersion: "1.0.0", - applicationURL: nil, - deviceModel: nil, - deviceOS: "iOS 15.5", - lastSeenIPLocation: nil, - clientName: "Element", - clientVersion: "1.0.0", - isActive: true, - isCurrent: isCurrent) + name: "iOS", + deviceType: .mobile, + isVerified: false, + lastSeenIP: "10.0.0.10", + lastSeenTimestamp: nil, + applicationName: "Element iOS", + applicationVersion: "1.0.0", + applicationURL: nil, + deviceModel: nil, + deviceOS: "iOS 15.5", + lastSeenIPLocation: nil, + clientName: "Element", + clientVersion: "1.0.0", + isActive: true, + isCurrent: isCurrent) viewData = UserSessionCardViewData(sessionInfo: sessionInfo) } diff --git a/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift index 32453a5def..bfd30c522f 100644 --- a/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift @@ -41,7 +41,7 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { init(parameters: UserSessionsFlowCoordinatorParameters) { self.parameters = parameters - self.navigationRouter = parameters.router + navigationRouter = parameters.router errorPresenter = MXKErrorAlertPresentation() indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: parameters.router.toPresentable()) } @@ -75,6 +75,8 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { self.openOtherSessions(sessionInfos: sessionInfos, filterBy: filter, title: VectorL10n.userOtherSessionSecurityRecommendationTitle) + case .linkDevice: + self.openQRLoginScreen() } } return coordinator @@ -105,6 +107,21 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { } pushScreen(with: coordinator) } + + /// Shows the QR login screen. + private func openQRLoginScreen() { + let service = QRLoginService(client: parameters.session.matrixRestClient, + mode: .authenticated) + let parameters = AuthenticationQRLoginStartCoordinatorParameters(navigationRouter: navigationRouter, + qrLoginService: service) + let coordinator = AuthenticationQRLoginStartCoordinator(parameters: parameters) + coordinator.callback = { [weak self, weak coordinator] _ in + guard let self = self, let coordinator = coordinator else { return } + self.remove(childCoordinator: coordinator) + } + + pushScreen(with: coordinator) + } private func createUserSessionOverviewCoordinator(sessionInfo: UserSessionInfo) -> UserSessionOverviewCoordinator { let parameters = UserSessionOverviewCoordinatorParameters(session: parameters.session, @@ -135,7 +152,6 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable { return UserOtherSessionsCoordinator(parameters: parameters) } - /// Shows a confirmation dialog to the user to sign out of a session. private func showLogoutConfirmation(for sessionInfo: UserSessionInfo) { // Use a UIAlertController as we don't have confirmationDialog in SwiftUI on iOS 14. diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift index c118001d3f..607a87aa95 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Coordinator/UserOtherSessionsCoordinator.swift @@ -24,7 +24,6 @@ struct UserOtherSessionsCoordinatorParameters { } final class UserOtherSessionsCoordinator: Coordinator, Presentable { - private let parameters: UserOtherSessionsCoordinatorParameters private let userOtherSessionsHostingController: UIViewController private var userOtherSessionsViewModel: UserOtherSessionsViewModelProtocol diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift index ffcc4f003a..fd4493b62d 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/MockUserOtherSessionsScreenState.swift @@ -40,7 +40,6 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { /// Generate the view struct for the screen state. var screenView: ([Any], AnyView) { - let viewModel: UserOtherSessionsViewModel switch self { case .inactiveSessions: @@ -83,7 +82,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { deviceType: .desktop, isVerified: true, lastSeenIP: "1.0.0.1", - lastSeenTimestamp: Date().timeIntervalSince1970 - 8000000, + lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000, applicationName: nil, applicationVersion: nil, applicationURL: nil, @@ -99,7 +98,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { deviceType: .web, isVerified: true, lastSeenIP: "2.0.0.2", - lastSeenTimestamp: Date().timeIntervalSince1970 - 9000000, + lastSeenTimestamp: Date().timeIntervalSince1970 - 9_000_000, applicationName: nil, applicationVersion: nil, applicationURL: nil, @@ -115,7 +114,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { deviceType: .mobile, isVerified: false, lastSeenIP: "3.0.0.3", - lastSeenTimestamp: Date().timeIntervalSince1970 - 10000000, + lastSeenTimestamp: Date().timeIntervalSince1970 - 10_000_000, applicationName: nil, applicationVersion: nil, applicationURL: nil, @@ -150,7 +149,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { deviceType: .desktop, isVerified: false, lastSeenIP: "1.0.0.1", - lastSeenTimestamp: Date().timeIntervalSince1970 - 8000000, + lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000, applicationName: nil, applicationVersion: nil, applicationURL: nil, @@ -160,7 +159,6 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable { clientName: nil, clientVersion: nil, isActive: true, - isCurrent: false) - ] + isCurrent: false)] } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift index e9d5e28939..6f73638471 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/UI/UserOtherSessionsUITests.swift @@ -18,12 +18,11 @@ import RiotSwiftUI import XCTest class UserOtherSessionsUITests: MockScreenTestCase { - func test_whenOtherSessionsWithInactiveSessionFilterPresented_correctHeaderDisplayed() { app.goToScreenWithIdentifier(MockUserOtherSessionsScreenState.inactiveSessions.title) XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle].exists) - XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo].exists) + XCTAssertTrue(app.staticTexts[VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo].exists) } func test_whenOtherSessionsWithInactiveSessionFilterPresented_correctItemsDisplayed() { diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift index 57c2c6a7d7..fcd77020a6 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/Test/Unit/UserOtherSessionsViewModelTests.swift @@ -19,14 +19,12 @@ import XCTest @testable import RiotSwiftUI class UserOtherSessionsViewModelTests: XCTestCase { - - func test_whenUserOtherSessionSelectedProcessed_completionWithShowUserSessionOverviewCalled() { let expectedUserSessionInfo = createUserSessionInfo(sessionId: "session 2") let sut = UserOtherSessionsViewModel(sessionInfos: [createUserSessionInfo(sessionId: "session 1"), - expectedUserSessionInfo], - filter: .inactive, - title: "Title") + expectedUserSessionInfo], + filter: .inactive, + title: "Title") var modelResult: UserOtherSessionsViewModelResult? sut.completion = { result in @@ -39,8 +37,8 @@ class UserOtherSessionsViewModelTests: XCTestCase { func test_whenModelCreated_withInactiveFilter_viewStateIsCorrect() { let sessionInfos = [createUserSessionInfo(sessionId: "session 1"), createUserSessionInfo(sessionId: "session 2")] let sut = UserOtherSessionsViewModel(sessionInfos: sessionInfos, - filter: .inactive, - title: "Title") + filter: .inactive, + title: "Title") let expectedHeader = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle, subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo, @@ -51,7 +49,6 @@ class UserOtherSessionsViewModelTests: XCTestCase { XCTAssertEqual(sut.state, expectedState) } - private func createUserSessionInfo(sessionId: String) -> UserSessionInfo { UserSessionInfo(id: sessionId, name: "iOS", diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift index 3cda39e336..53679d9903 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsModels.swift @@ -17,6 +17,7 @@ import Foundation // MARK: - Coordinator + enum UserOtherSessionsCoordinatorResult { case openSessionDetails(sessionInfo: UserSessionInfo) } @@ -38,6 +39,7 @@ enum UserOtherSessionsSection: Hashable, Identifiable { var id: Self { self } + case sessionItems(header: UserOtherSessionsHeaderViewData, items: [UserSessionListItemViewData]) } diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift index 4673bb7a0d..706093f8b2 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/UserOtherSessionsViewModel.swift @@ -25,7 +25,6 @@ enum OtherUserSessionsFilter { } class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessionsViewModelProtocol { - var completion: ((UserOtherSessionsViewModelResult) -> Void)? private let sessionInfos: [UserSessionInfo] @@ -42,7 +41,7 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi override func process(viewAction: UserOtherSessionsViewAction) { switch viewAction { case let .userOtherSessionSelected(sessionId: sessionId): - guard let session = sessionInfos.first(where: {$0.id == sessionId}) else { + guard let session = sessionInfos.first(where: { $0.id == sessionId }) else { assertionFailure("Session should exist in the array.") return } diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift index 320a045987..6bcc7d0348 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessions.swift @@ -17,7 +17,6 @@ import SwiftUI struct UserOtherSessions: View { - @Environment(\.theme) private var theme @ObservedObject var viewModel: UserOtherSessionsViewModel.Context @@ -57,7 +56,6 @@ struct UserOtherSessions: View { // MARK: - Previews struct UserOtherSessions_Previews: PreviewProvider { - static let stateRenderer = MockUserOtherSessionsScreenState.stateRenderer static var previews: some View { diff --git a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift index 83ba4ce51d..c3d1e4dbf0 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserOtherSessions/View/UserOtherSessionsHeaderView.swift @@ -23,7 +23,6 @@ struct UserOtherSessionsHeaderViewData: Hashable { } struct UserOtherSessionsHeaderView: View { - private var backgroundShape: RoundedRectangle { RoundedRectangle(cornerRadius: 8) } @@ -33,7 +32,7 @@ struct UserOtherSessionsHeaderView: View { let viewData: UserOtherSessionsHeaderViewData var body: some View { - HStack (alignment: .top, spacing: 0) { + HStack(alignment: .top, spacing: 0) { if let iconName = viewData.iconName { Image(iconName) .frame(width: 40, height: 40) @@ -63,12 +62,10 @@ struct UserOtherSessionsHeaderView: View { // MARK: - Previews struct UserOtherSessionsHeaderView_Previews: PreviewProvider { - private static let inactiveSessionViewData = UserOtherSessionsHeaderViewData(title: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveTitle, subtitle: VectorL10n.userSessionsOverviewSecurityRecommendationsInactiveInfo, iconName: Asset.Images.userOtherSessionsInactive.name) - static var previews: some View { Group { UserOtherSessionsHeaderView(viewData: self.inactiveSessionViewData) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift index ea23cde617..51b8e883a4 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift @@ -42,38 +42,38 @@ enum MockUserSessionDetailsScreenState: MockScreenState, CaseIterable { switch self { case .allSections: sessionInfo = UserSessionInfo(id: "alice", - name: "iOS", - deviceType: .mobile, - isVerified: false, - lastSeenIP: "10.0.0.10", - lastSeenTimestamp: nil, - applicationName: "Element iOS", - applicationVersion: "1.0.0", - applicationURL: nil, - deviceModel: nil, - deviceOS: "iOS 15.5", - lastSeenIPLocation: nil, - clientName: "Element", - clientVersion: "1.0.0", - isActive: true, - isCurrent: true) + name: "iOS", + deviceType: .mobile, + isVerified: false, + lastSeenIP: "10.0.0.10", + lastSeenTimestamp: nil, + applicationName: "Element iOS", + applicationVersion: "1.0.0", + applicationURL: nil, + deviceModel: nil, + deviceOS: "iOS 15.5", + lastSeenIPLocation: nil, + clientName: "Element", + clientVersion: "1.0.0", + isActive: true, + isCurrent: true) case .sessionSectionOnly: sessionInfo = UserSessionInfo(id: "3", - name: "Android", - deviceType: .mobile, - isVerified: false, - lastSeenIP: "3.0.0.3", - lastSeenTimestamp: Date().timeIntervalSince1970 - 10, - applicationName: "Element Android", - applicationVersion: "1.0.0", - applicationURL: nil, - deviceModel: nil, - deviceOS: "Android 4.0", - lastSeenIPLocation: nil, - clientName: "Element", - clientVersion: "1.0.0", - isActive: true, - isCurrent: false) + name: "Android", + deviceType: .mobile, + isVerified: false, + lastSeenIP: "3.0.0.3", + lastSeenTimestamp: Date().timeIntervalSince1970 - 10, + applicationName: "Element Android", + applicationVersion: "1.0.0", + applicationURL: nil, + deviceModel: nil, + deviceOS: "Android 4.0", + lastSeenIPLocation: nil, + clientName: "Element", + clientVersion: "1.0.0", + isActive: true, + isCurrent: false) } let viewModel = UserSessionDetailsViewModel(sessionInfo: sessionInfo) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/MatrixSDK/UserSessionOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/MatrixSDK/UserSessionOverviewService.swift index 0b7e93e03f..857eef3714 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/MatrixSDK/UserSessionOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/MatrixSDK/UserSessionOverviewService.swift @@ -18,7 +18,6 @@ import Combine import MatrixSDK class UserSessionOverviewService: UserSessionOverviewServiceProtocol { - // MARK: - Members private(set) var pusherEnabledSubject: CurrentValueSubject @@ -36,10 +35,10 @@ class UserSessionOverviewService: UserSessionOverviewServiceProtocol { init(session: MXSession, sessionInfo: UserSessionInfo) { self.session = session self.sessionInfo = sessionInfo - self.pusherEnabledSubject = CurrentValueSubject(nil) - self.remotelyTogglingPushersAvailableSubject = CurrentValueSubject(false) + pusherEnabledSubject = CurrentValueSubject(nil) + remotelyTogglingPushersAvailableSubject = CurrentValueSubject(false) - self.localNotificationSettings = session.accountData.localNotificationSettingsForDevice(withId: sessionInfo.id) + localNotificationSettings = session.accountData.localNotificationSettingsForDevice(withId: sessionInfo.id) if let localNotificationSettings = localNotificationSettings, let isSilenced = localNotificationSettings[kMXAccountDataIsSilencedKey] as? Bool { remotelyTogglingPushersAvailableSubject.send(true) @@ -69,7 +68,7 @@ class UserSessionOverviewService: UserSessionOverviewServiceProtocol { // MARK: - Private private func toggle(_ pusher: MXPusher, enabled: Bool) { - guard self.remotelyTogglingPushersAvailableSubject.value else { + guard remotelyTogglingPushersAvailableSubject.value else { MXLog.warning("[UserSessionOverviewService] toggle pusher canceled: remotely toggling pushers not available") return } @@ -77,16 +76,16 @@ class UserSessionOverviewService: UserSessionOverviewServiceProtocol { MXLog.debug("[UserSessionOverviewService] remotely toggling pusher") let data = pusher.data.jsonDictionary() as? [String: Any] ?? [:] - self.session.matrixRestClient.setPusher(pushKey: pusher.pushkey, - kind: MXPusherKind(value: pusher.kind), - appId: pusher.appId, - appDisplayName:pusher.appDisplayName, - deviceDisplayName: pusher.deviceDisplayName, - profileTag: pusher.profileTag ?? "", - lang: pusher.lang, - data: data, - append: false, - enabled: enabled) { [weak self] response in + session.matrixRestClient.setPusher(pushKey: pusher.pushkey, + kind: MXPusherKind(value: pusher.kind), + appId: pusher.appId, + appDisplayName: pusher.appDisplayName, + deviceDisplayName: pusher.deviceDisplayName, + profileTag: pusher.profileTag ?? "", + lang: pusher.lang, + data: data, + append: false, + enabled: enabled) { [weak self] response in guard let self = self else { return } switch response { diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/Mock/MockUserSessionOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/Mock/MockUserSessionOverviewService.swift index ccd6f63dd6..f5447e6cb6 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/Mock/MockUserSessionOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Service/Mock/MockUserSessionOverviewService.swift @@ -18,18 +18,16 @@ import Combine import Foundation class MockUserSessionOverviewService: UserSessionOverviewServiceProtocol { - - var pusherEnabledSubject: CurrentValueSubject var remotelyTogglingPushersAvailableSubject: CurrentValueSubject init(pusherEnabled: Bool? = nil, remotelyTogglingPushersAvailable: Bool = true) { - self.pusherEnabledSubject = CurrentValueSubject(pusherEnabled) - self.remotelyTogglingPushersAvailableSubject = CurrentValueSubject(remotelyTogglingPushersAvailable) + pusherEnabledSubject = CurrentValueSubject(pusherEnabled) + remotelyTogglingPushersAvailableSubject = CurrentValueSubject(remotelyTogglingPushersAvailable) } func togglePushNotifications() { - guard let enabled = pusherEnabledSubject.value, self.remotelyTogglingPushersAvailableSubject.value else { + guard let enabled = pusherEnabledSubject.value, remotelyTogglingPushersAvailableSubject.value else { return } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift index 6f1859dd25..6a51a3a9ef 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift @@ -20,7 +20,6 @@ import XCTest @testable import RiotSwiftUI class UserSessionOverviewViewModelTests: XCTestCase { - func test_whenVerifyCurrentSessionProcessed_completionWithVerifyCurrentSessionCalled() { let sut = UserSessionOverviewViewModel(sessionInfo: createUserSessionInfo(), service: MockUserSessionOverviewService()) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewViewModel.swift index 5596c703f9..08c0218a18 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/UserSessionOverviewViewModel.swift @@ -67,7 +67,7 @@ class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessio case .viewSessionDetails: completion?(.showSessionDetails(sessionInfo: sessionInfo)) case .togglePushNotifications: - self.state.showLoadingIndicator = true + state.showLoadingIndicator = true service.togglePushNotifications() case .renameSession: completion?(.renameSession(sessionInfo)) diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift index 30cc837673..c3117f9ba0 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift @@ -69,6 +69,8 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { self.showCurrentSessionOverview(sessionInfo: sessionInfo) case let .showUserSessionOverview(sessionInfo): self.showUserSessionOverview(sessionInfo: sessionInfo) + case .linkDevice: + self.completion?(.linkDevice) } } } @@ -107,5 +109,4 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { private func showUserSessionOverview(sessionInfo: UserSessionInfo) { completion?(.openSessionOverview(sessionInfo: sessionInfo)) } - } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift index a8d36cc4e7..9b3f145fc4 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProvider.swift @@ -47,4 +47,10 @@ class UserSessionsDataProvider: UserSessionsDataProviderProtocol { func accountData(for eventType: String) -> [AnyHashable: Any]? { session.accountData.accountData(forEventType: eventType) } + + func qrLoginAvailable() async throws -> Bool { + let service = QRLoginService(client: session.matrixRestClient, + mode: .authenticated) + return try await service.isServiceAvailable() + } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProviderProtocol.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProviderProtocol.swift index e97310a40e..2f07e37943 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProviderProtocol.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsDataProviderProtocol.swift @@ -29,4 +29,6 @@ protocol UserSessionsDataProviderProtocol { func device(withDeviceId deviceId: String, ofUser userId: String) -> MXDeviceInfo? func accountData(for eventType: String) -> [AnyHashable: Any]? + + func qrLoginAvailable() async throws -> Bool } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift index 36d07f427d..a0dda32222 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift @@ -32,7 +32,8 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { overviewData = UserSessionsOverviewData(currentSession: nil, unverifiedSessions: [], inactiveSessions: [], - otherSessions: []) + otherSessions: [], + linkDeviceEnabled: false) sessionInfos = [] setupInitialOverviewData() } @@ -44,8 +45,12 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { switch response { case .success(let devices): self.sessionInfos = self.sortedSessionInfos(from: devices) - self.overviewData = self.sessionsOverviewData(from: self.sessionInfos) - completion(.success(self.overviewData)) + Task { @MainActor in + let linkDeviceEnabled = try? await self.dataProvider.qrLoginAvailable() + self.overviewData = self.sessionsOverviewData(from: self.sessionInfos, + linkDeviceEnabled: linkDeviceEnabled ?? false) + completion(.success(self.overviewData)) + } case .failure(let error): completion(.failure(error)) } @@ -59,7 +64,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { return overviewData.otherSessions.first(where: { $0.id == sessionId }) } - + // MARK: - Private private func setupInitialOverviewData() { @@ -70,7 +75,8 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { overviewData = UserSessionsOverviewData(currentSession: currentSessionInfo, unverifiedSessions: currentSessionInfo.isVerified ? [] : [currentSessionInfo], inactiveSessions: currentSessionInfo.isActive ? [] : [currentSessionInfo], - otherSessions: []) + otherSessions: [], + linkDeviceEnabled: false) } private func getCurrentSessionInfo() -> UserSessionInfo? { @@ -87,11 +93,13 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { .map { sessionInfo(from: $0, isCurrentSession: $0.deviceId == dataProvider.myDeviceId) } } - private func sessionsOverviewData(from allSessions: [UserSessionInfo]) -> UserSessionsOverviewData { + private func sessionsOverviewData(from allSessions: [UserSessionInfo], + linkDeviceEnabled: Bool) -> UserSessionsOverviewData { UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first, unverifiedSessions: allSessions.filter { !$0.isVerified }, inactiveSessions: allSessions.filter { !$0.isActive }, - otherSessions: allSessions.filter { !$0.isCurrent }) + otherSessions: allSessions.filter { !$0.isCurrent }, + linkDeviceEnabled: linkDeviceEnabled) } private func sessionInfo(from device: MXDevice, isCurrentSession: Bool) -> UserSessionInfo { diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift index 53c8df8b74..95b56511eb 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift @@ -17,7 +17,6 @@ import Foundation class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { - enum Mode { case currentSessionUnverified case currentSessionVerified @@ -37,7 +36,8 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { overviewData = UserSessionsOverviewData(currentSession: nil, unverifiedSessions: [], inactiveSessions: [], - otherSessions: []) + otherSessions: [], + linkDeviceEnabled: false) } func updateOverviewData(completion: @escaping (Result) -> Void) { @@ -49,24 +49,28 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { overviewData = UserSessionsOverviewData(currentSession: currentSession, unverifiedSessions: [], inactiveSessions: [], - otherSessions: []) + otherSessions: [], + linkDeviceEnabled: false) case .onlyUnverifiedSessions: overviewData = UserSessionsOverviewData(currentSession: currentSession, unverifiedSessions: unverifiedSessions + [currentSession], inactiveSessions: [], - otherSessions: unverifiedSessions) + otherSessions: unverifiedSessions, + linkDeviceEnabled: false) case .onlyInactiveSessions: overviewData = UserSessionsOverviewData(currentSession: currentSession, unverifiedSessions: [], inactiveSessions: inactiveSessions, - otherSessions: 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) + otherSessions: otherSessions, + linkDeviceEnabled: true) } completion(.success(overviewData)) @@ -75,7 +79,7 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo? { overviewData.otherSessions.first { $0.id == sessionId } } - + // MARK: - Private private var currentSession: UserSessionInfo { @@ -103,7 +107,7 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { deviceType: .desktop, isVerified: verified, lastSeenIP: "1.0.0.1", - lastSeenTimestamp: Date().timeIntervalSince1970 - 8000000, + lastSeenTimestamp: Date().timeIntervalSince1970 - 8_000_000, applicationName: "Element MacOS", applicationVersion: "1.0.0", applicationURL: nil, diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift index 3f69b814ba..ac7a98b872 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/UserSessionsOverviewServiceProtocol.swift @@ -21,6 +21,7 @@ struct UserSessionsOverviewData { let unverifiedSessions: [UserSessionInfo] let inactiveSessions: [UserSessionInfo] let otherSessions: [UserSessionInfo] + let linkDeviceEnabled: Bool } protocol UserSessionsOverviewServiceProtocol { diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/UI/UserSessionsOverviewUITests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/UI/UserSessionsOverviewUITests.swift index f05203cb7d..a92c3a716a 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/UI/UserSessionsOverviewUITests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/UI/UserSessionsOverviewUITests.swift @@ -23,6 +23,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase { XCTAssertTrue(app.buttons["userSessionCardVerifyButton"].exists) XCTAssertTrue(app.staticTexts["userSessionCardViewDetails"].exists) + + verifyLinkDeviceButtonStatus(true) } func testCurrentSessionVerified() { @@ -30,6 +32,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase { XCTAssertFalse(app.buttons["userSessionCardVerifyButton"].exists) XCTAssertTrue(app.staticTexts["userSessionCardViewDetails"].exists) + + verifyLinkDeviceButtonStatus(true) } func testOnlyUnverifiedSessions() { @@ -37,6 +41,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase { XCTAssertTrue(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists) XCTAssertTrue(app.staticTexts["userSessionsOverviewOtherSection"].exists) + + verifyLinkDeviceButtonStatus(false) } func testOnlyInactiveSessions() { @@ -44,6 +50,8 @@ class UserSessionsOverviewUITests: MockScreenTestCase { XCTAssertTrue(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists) XCTAssertTrue(app.staticTexts["userSessionsOverviewOtherSection"].exists) + + verifyLinkDeviceButtonStatus(false) } func testNoOtherSessions() { @@ -51,5 +59,18 @@ class UserSessionsOverviewUITests: MockScreenTestCase { XCTAssertFalse(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists) XCTAssertFalse(app.staticTexts["userSessionsOverviewOtherSection"].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) + } } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionsOverviewViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionsOverviewViewModelTests.swift index 815f2dcc5b..4a6115f11f 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionsOverviewViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Test/Unit/UserSessionsOverviewViewModelTests.swift @@ -27,6 +27,7 @@ class UserSessionsOverviewViewModelTests: XCTestCase { XCTAssertTrue(viewModel.state.unverifiedSessionsViewData.isEmpty) XCTAssertTrue(viewModel.state.inactiveSessionsViewData.isEmpty) XCTAssertTrue(viewModel.state.otherSessionsViewData.isEmpty) + XCTAssertFalse(viewModel.state.linkDeviceButtonVisible) } func testLoadOnDidAppear() { @@ -37,6 +38,7 @@ class UserSessionsOverviewViewModelTests: XCTestCase { XCTAssertFalse(viewModel.state.unverifiedSessionsViewData.isEmpty) XCTAssertFalse(viewModel.state.inactiveSessionsViewData.isEmpty) XCTAssertFalse(viewModel.state.otherSessionsViewData.isEmpty) + XCTAssertTrue(viewModel.state.linkDeviceButtonVisible) } func testSimpleActionProcessing() { @@ -52,6 +54,9 @@ class UserSessionsOverviewViewModelTests: XCTestCase { viewModel.process(viewAction: .viewAllInactiveSessions) XCTAssertEqual(result, .showOtherSessions(sessionInfos: [], filter: .inactive)) + + viewModel.process(viewAction: .linkDevice) + XCTAssertEqual(result, .linkDevice) } func testShowSessionDetails() { diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift index 6015210008..b8fadf8ee8 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift @@ -23,6 +23,7 @@ enum UserSessionsOverviewCoordinatorResult { case logoutOfSession(UserSessionInfo) case openSessionOverview(sessionInfo: UserSessionInfo) case openOtherSessions(sessionInfos: [UserSessionInfo], filter: OtherUserSessionsFilter) + case linkDevice } // MARK: View model @@ -34,6 +35,7 @@ enum UserSessionsOverviewViewModelResult: Equatable { case logoutOfSession(UserSessionInfo) case showCurrentSessionOverview(sessionInfo: UserSessionInfo) case showUserSessionOverview(sessionInfo: UserSessionInfo) + case linkDevice } // MARK: View @@ -48,6 +50,8 @@ struct UserSessionsOverviewViewState: BindableState { var otherSessionsViewData = [UserSessionListItemViewData]() var showLoadingIndicator = false + + var linkDeviceButtonVisible = false } enum UserSessionsOverviewViewAction { @@ -60,4 +64,5 @@ enum UserSessionsOverviewViewAction { case viewAllInactiveSessions case viewAllOtherSessions case tapUserSession(_ sessionId: String) + case linkDevice } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift index 4e9cb90ea2..22a530f27e 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewViewModel.swift @@ -70,6 +70,8 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess return } completion?(.showUserSessionOverview(sessionInfo: session)) + case .linkDevice: + completion?(.linkDevice) } } @@ -83,6 +85,7 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess if let currentSessionInfo = userSessionsViewData.currentSession { state.currentSessionViewData = UserSessionCardViewData(sessionInfo: currentSessionInfo) } + state.linkDeviceButtonVisible = userSessionsViewData.linkDeviceEnabled } private func loadData() { @@ -113,6 +116,6 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess extension Collection where Element == UserSessionInfo { func asViewData() -> [UserSessionListItemViewData] { - map { UserSessionListItemViewDataFactory().create(from: $0)} + map { UserSessionListItemViewDataFactory().create(from: $0) } } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift index 00c24061b8..6cddefda27 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift @@ -18,7 +18,6 @@ import Foundation /// View data for UserSessionListItem struct UserSessionListItemViewData: Identifiable, Hashable { - var id: String { sessionId } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift index 8adb72c3c6..ad1afc32fb 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewDataFactory.swift @@ -17,7 +17,6 @@ import Foundation struct UserSessionListItemViewDataFactory { - func create(from sessionInfo: UserSessionInfo, highlightSessionDetails: Bool = false) -> UserSessionListItemViewData { let sessionName = UserSessionNameFormatter.sessionName(deviceType: sessionInfo.deviceType, sessionDisplayName: sessionInfo.name) @@ -41,7 +40,7 @@ struct UserSessionListItemViewDataFactory { } private func inactiveSessionDetails(sessionInfo: UserSessionInfo) -> String { - if let lastActivityDate = sessionInfo.lastSeenTimestamp { + if let lastActivityDate = sessionInfo.lastSeenTimestamp { let lastActivityDateString = InactiveUserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate) return VectorL10n.userInactiveSessionItemWithDate(lastActivityDateString) } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift index 18f9ad91fd..66fd8b2533 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionsOverview.swift @@ -22,15 +22,25 @@ struct UserSessionsOverview: View { @ObservedObject var viewModel: UserSessionsOverviewViewModel.Context var body: some View { - ScrollView { - if hasSecurityRecommendations { - securityRecommendationsSection - } - - currentSessionsSection - - if !viewModel.viewState.otherSessionsViewData.isEmpty { - otherSessionsSection + GeometryReader { geometry in + VStack(alignment: .leading, spacing: 0) { + ScrollView { + if hasSecurityRecommendations { + securityRecommendationsSection + } + + currentSessionsSection + + if !viewModel.viewState.otherSessionsViewData.isEmpty { + otherSessionsSection + } + } + .readableFrame() + + if viewModel.viewState.linkDeviceButtonVisible { + linkDeviceView + .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) + } } } .background(theme.colors.system.ignoresSafeArea()) @@ -158,6 +168,23 @@ struct UserSessionsOverview: View { } .accessibilityIdentifier("userSessionsOverviewOtherSection") } + + /// The footer view containing link device button. + var linkDeviceView: some View { + VStack { + Button { + viewModel.send(viewAction: .linkDevice) + } label: { + Text(VectorL10n.userSessionsOverviewLinkDevice) + } + .buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB)) + .padding(.top, 28) + .padding(.bottom, 12) + .padding(.horizontal, 16) + .accessibilityIdentifier("linkDeviceButton") + } + .background(theme.colors.system.ignoresSafeArea()) + } } // MARK: - Previews diff --git a/RiotTests/UserSessionsOverviewServiceTests.swift b/RiotTests/UserSessionsOverviewServiceTests.swift index 1c3fb75163..14d5d064e7 100644 --- a/RiotTests/UserSessionsOverviewServiceTests.swift +++ b/RiotTests/UserSessionsOverviewServiceTests.swift @@ -32,6 +32,7 @@ class UserSessionsOverviewServiceTests: XCTestCase { XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false) XCTAssertFalse(service.overviewData.unverifiedSessions.isEmpty) XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty) + XCTAssertFalse(service.overviewData.linkDeviceEnabled) XCTAssertEqual(service.sessionForIdentifier(currentDeviceId), service.overviewData.currentSession) } @@ -45,6 +46,7 @@ class UserSessionsOverviewServiceTests: XCTestCase { XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false) XCTAssertTrue(service.overviewData.unverifiedSessions.isEmpty) XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty) + XCTAssertFalse(service.overviewData.linkDeviceEnabled) } func testWithAllSessionsVerified() { @@ -57,6 +59,7 @@ class UserSessionsOverviewServiceTests: XCTestCase { XCTAssertTrue(service.overviewData.unverifiedSessions.isEmpty) XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty) XCTAssertFalse(service.overviewData.otherSessions.isEmpty) + XCTAssertTrue(service.overviewData.linkDeviceEnabled) XCTAssertEqual(service.sessionInfos.count, 2) } @@ -71,6 +74,7 @@ class UserSessionsOverviewServiceTests: XCTestCase { XCTAssertFalse(service.overviewData.unverifiedSessions.isEmpty) XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty) XCTAssertFalse(service.overviewData.otherSessions.isEmpty) + XCTAssertTrue(service.overviewData.linkDeviceEnabled) XCTAssertEqual(service.sessionInfos.count, 3) } @@ -85,6 +89,7 @@ class UserSessionsOverviewServiceTests: XCTestCase { XCTAssertTrue(service.overviewData.unverifiedSessions.isEmpty) XCTAssertFalse(service.overviewData.inactiveSessions.isEmpty) XCTAssertFalse(service.overviewData.otherSessions.isEmpty) + XCTAssertTrue(service.overviewData.linkDeviceEnabled) XCTAssertEqual(service.sessionInfos.count, 3) } @@ -99,6 +104,7 @@ class UserSessionsOverviewServiceTests: XCTestCase { XCTAssertFalse(service.overviewData.unverifiedSessions.isEmpty) XCTAssertFalse(service.overviewData.inactiveSessions.isEmpty) XCTAssertFalse(service.overviewData.otherSessions.isEmpty) + XCTAssertTrue(service.overviewData.linkDeviceEnabled) XCTAssertEqual(service.sessionInfos.count, 4) } @@ -179,6 +185,10 @@ private class MockUserSessionsDataProvider: UserSessionsDataProviderProtocol { func accountData(for eventType: String) -> [AnyHashable : Any]? { [:] } + + func qrLoginAvailable() async throws -> Bool { + true + } // MARK: - Private