Merge branch 'develop' into aleksandrs/6786_inactive_sessions_screen

# Conflicts:
#	Riot/Assets/en.lproj/Vector.strings
#	Riot/Generated/Strings.swift
#	RiotSwiftUI/Modules/UserSessions/Coordinator/UserSessionsFlowCoordinator.swift
#	RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift
#	RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift
#	RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift
#	RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/UserSessionsOverviewModels.swift
#	RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/View/UserSessionListItemViewData.swift
This commit is contained in:
Aleksandrs Proskurins 2022-10-04 15:14:59 +03:00
commit 3deee90005
88 changed files with 2246 additions and 415 deletions

View file

@ -1,3 +1,15 @@
## Changes in 1.9.7 (2022-09-28)
🙌 Improvements
- Upgrade MatrixSDK version ([v0.23.19](https://github.com/matrix-org/matrix-ios-sdk/releases/tag/v0.23.19)).
🐛 Bugfixes
- Missing decoration for events decrypted with untrusted Megolm sessions ([Security advisory](https://github.com/vector-im/element-ios/security/advisories/GHSA-fm8m-99j7-323g))
- Fix crash when scrolling chat list ([#6749](https://github.com/vector-im/element-ios/issues/6749))
## Changes in 1.9.6 (2022-09-20) ## Changes in 1.9.6 (2022-09-20)
🙌 Improvements 🙌 Improvements

View file

@ -15,5 +15,5 @@
// //
// Version // Version
MARKETING_VERSION = 1.9.7 MARKETING_VERSION = 1.9.8
CURRENT_PROJECT_VERSION = 1.9.7 CURRENT_PROJECT_VERSION = 1.9.8

View file

@ -420,8 +420,4 @@ final class BuildSettings: NSObject {
// MARK: - New App Layout // MARK: - New App Layout
static let newAppLayoutEnabled = true static let newAppLayoutEnabled = true
// MARK: - Device manager
static let deviceManagerEnabled = false
} }

View file

@ -89,6 +89,8 @@ class CommonConfiguration: NSObject, Configurable {
sdkOptions.authEnableRefreshTokens = BuildSettings.authEnableRefreshTokens sdkOptions.authEnableRefreshTokens = BuildSettings.authEnableRefreshTokens
// Configure key provider delegate // Configure key provider delegate
MXKeyProvider.sharedInstance().delegate = EncryptionKeyManager.shared MXKeyProvider.sharedInstance().delegate = EncryptionKeyManager.shared
sdkOptions.enableNewClientInformationFeature = RiotSettings.shared.enableClientInformationFeature
} }
private func makeASCIIUserAgent() -> String? { private func makeASCIIUserAgent() -> String? {

View file

@ -16,7 +16,7 @@ use_frameworks!
# - `{ :specHash => {sdk spec hash}` to depend on specific pod options (:git => …, :podspec => …) for MatrixSDK repo. Used by Fastfile during CI # - `{ :specHash => {sdk spec hash}` to depend on specific pod options (:git => …, :podspec => …) for MatrixSDK repo. Used by Fastfile during CI
# #
# Warning: our internal tooling depends on the name of this variable name, so be sure not to change it # Warning: our internal tooling depends on the name of this variable name, so be sure not to change it
$matrixSDKVersion = '= 0.23.18' $matrixSDKVersion = '= 0.23.19'
# $matrixSDKVersion = :local # $matrixSDKVersion = :local
# $matrixSDKVersion = { :branch => 'develop'} # $matrixSDKVersion = { :branch => 'develop'}
# $matrixSDKVersion = { :specHash => { git: 'https://git.io/fork123', branch: 'fix' } } # $matrixSDKVersion = { :specHash => { git: 'https://git.io/fork123', branch: 'fix' } }

View file

@ -56,9 +56,9 @@ PODS:
- LoggerAPI (1.9.200): - LoggerAPI (1.9.200):
- Logging (~> 1.1) - Logging (~> 1.1)
- Logging (1.4.0) - Logging (1.4.0)
- MatrixSDK (0.23.18): - MatrixSDK (0.23.19):
- MatrixSDK/Core (= 0.23.18) - MatrixSDK/Core (= 0.23.19)
- MatrixSDK/Core (0.23.18): - MatrixSDK/Core (0.23.19):
- AFNetworking (~> 4.0.0) - AFNetworking (~> 4.0.0)
- GZIP (~> 1.3.0) - GZIP (~> 1.3.0)
- libbase58 (~> 0.1.4) - libbase58 (~> 0.1.4)
@ -66,9 +66,9 @@ PODS:
- OLMKit (~> 3.2.5) - OLMKit (~> 3.2.5)
- Realm (= 10.27.0) - Realm (= 10.27.0)
- SwiftyBeaver (= 1.9.5) - SwiftyBeaver (= 1.9.5)
- MatrixSDK/CryptoSDK (0.23.18): - MatrixSDK/CryptoSDK (0.23.19):
- MatrixSDKCrypto (= 0.1.0) - MatrixSDKCrypto (= 0.1.0)
- MatrixSDK/JingleCallStack (0.23.18): - MatrixSDK/JingleCallStack (0.23.19):
- JitsiMeetSDK (= 5.0.2) - JitsiMeetSDK (= 5.0.2)
- MatrixSDK/Core - MatrixSDK/Core
- MatrixSDKCrypto (0.1.0) - MatrixSDKCrypto (0.1.0)
@ -123,8 +123,8 @@ DEPENDENCIES:
- KeychainAccess (~> 4.2.2) - KeychainAccess (~> 4.2.2)
- KTCenterFlowLayout (~> 1.3.1) - KTCenterFlowLayout (~> 1.3.1)
- libPhoneNumber-iOS (~> 0.9.13) - libPhoneNumber-iOS (~> 0.9.13)
- MatrixSDK (= 0.23.18) - MatrixSDK (= 0.23.19)
- MatrixSDK/JingleCallStack (= 0.23.18) - MatrixSDK/JingleCallStack (= 0.23.19)
- OLMKit - OLMKit
- PostHog (~> 1.4.4) - PostHog (~> 1.4.4)
- ReadMoreTextView (~> 3.0.1) - ReadMoreTextView (~> 3.0.1)
@ -221,7 +221,7 @@ SPEC CHECKSUMS:
libPhoneNumber-iOS: 0a32a9525cf8744fe02c5206eb30d571e38f7d75 libPhoneNumber-iOS: 0a32a9525cf8744fe02c5206eb30d571e38f7d75
LoggerAPI: ad9c4a6f1e32f518fdb43a1347ac14d765ab5e3d LoggerAPI: ad9c4a6f1e32f518fdb43a1347ac14d765ab5e3d
Logging: beeb016c9c80cf77042d62e83495816847ef108b Logging: beeb016c9c80cf77042d62e83495816847ef108b
MatrixSDK: 26da2e3a9f3b02fc6ea67f5bc311d30f06f9ffba MatrixSDK: a60a00635006c539dce654253e8f0544ea996111
MatrixSDKCrypto: 4b9146d5ef484550341be056a164c6930038028e MatrixSDKCrypto: 4b9146d5ef484550341be056a164c6930038028e
OLMKit: da115f16582e47626616874e20f7bb92222c7a51 OLMKit: da115f16582e47626616874e20f7bb92222c7a51
PostHog: 4b6321b521569092d4ef3a02238d9435dbaeb99f PostHog: 4b6321b521569092d4ef3a02238d9435dbaeb99f
@ -241,6 +241,6 @@ SPEC CHECKSUMS:
zxcvbn-ios: fef98b7c80f1512ff0eec47ac1fa399fc00f7e3c zxcvbn-ios: fef98b7c80f1512ff0eec47ac1fa399fc00f7e3c
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
PODFILE CHECKSUM: 45176df406c18b0c23321a308f58535fbe425a93 PODFILE CHECKSUM: 400334cf1580361b831a632dcc025f2029e56b6e
COCOAPODS: 1.11.2 COCOAPODS: 1.11.2

View file

@ -1,6 +1,6 @@
{ {
"info" : { "info" : {
"version" : 1, "author" : "xcode",
"author" : "xcode" "version" : 1
} }
} }

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "encryption_untrusted.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "encryption_untrusted@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "encryption_untrusted@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -762,6 +762,8 @@ Tap the + to start adding people.";
"settings_labs_enable_auto_report_decryption_errors" = "Auto Report Decryption Errors"; "settings_labs_enable_auto_report_decryption_errors" = "Auto Report Decryption Errors";
"settings_labs_use_only_latest_user_avatar_and_name" = "Show latest avatar and name for users in message history"; "settings_labs_use_only_latest_user_avatar_and_name" = "Show latest avatar and name for users in message history";
"settings_labs_enable_live_location_sharing" = "Live location sharing - share current location (active development, and temporarily, locations persist in room history)"; "settings_labs_enable_live_location_sharing" = "Live location sharing - share current location (active development, and temporarily, locations persist in room history)";
"settings_labs_enable_new_session_manager" = "New session manager";
"settings_labs_enable_new_client_info_feature" = "Record the client name, version, and url to recognise sessions more easily in session manager";
"settings_labs_enable_new_app_layout" = "New Application Layout"; "settings_labs_enable_new_app_layout" = "New Application Layout";
"settings_version" = "Version %@"; "settings_version" = "Version %@";
@ -2193,6 +2195,7 @@ Tap the + to start adding people.";
"room_recents_recently_viewed_section" = "Recently viewed"; "room_recents_recently_viewed_section" = "Recently viewed";
"all_chats_user_menu_accessibility_label" = "User menu";
"all_chats_user_menu_settings" = "User settings"; "all_chats_user_menu_settings" = "User settings";
"all_chats_edit_menu_leave_space" = "Leave %@"; "all_chats_edit_menu_leave_space" = "Leave %@";
@ -2385,10 +2388,11 @@ To enable access, tap Settings> Location and select Always";
"user_session_verified_additional_info" = "Your current session is ready for secure messaging."; "user_session_verified_additional_info" = "Your current session is ready for secure messaging.";
"user_session_unverified_additional_info" = "Verify your current session for enhanced secure messaging."; "user_session_unverified_additional_info" = "Verify your current session for enhanced secure messaging.";
"user_session_push_notifications" = "Push notifications";
"user_session_push_notifications_message" = "When turned on, this session will receive push notifications.";
"user_other_session_security_recommendation_title" = "Security recommendation"; "user_other_session_security_recommendation_title" = "Security recommendation";
// First item is client name and second item is session display name // First item is client name and second item is session display name
"user_session_name" = "%@: %@"; "user_session_name" = "%@: %@";
@ -2403,12 +2407,18 @@ To enable access, tap Settings> Location and select Always";
"user_session_details_title" = "Session details"; "user_session_details_title" = "Session details";
"user_session_details_session_section_header" = "Session"; "user_session_details_session_section_header" = "Session";
"user_session_details_application_section_header" = "Application";
"user_session_details_device_section_header" = "Device"; "user_session_details_device_section_header" = "Device";
"user_session_details_session_name" = "Session name"; "user_session_details_session_name" = "Session name";
"user_session_details_session_id" = "Session ID"; "user_session_details_session_id" = "Session ID";
"user_session_details_session_section_footer" = "Copy any data by tapping on it and holding it down."; "user_session_details_session_section_footer" = "Copy any data by tapping on it and holding it down.";
"user_session_details_device_ip_address" = "IP address"; "user_session_details_device_ip_address" = "IP address";
"user_session_details_device_ip_location" = "IP location";
"user_session_details_device_model" = "Model";
"user_session_details_device_os" = "Operating System";
"user_session_details_application_name" = "Name";
"user_session_details_application_version" = "Version";
"user_session_details_application_url" = "URL";
"user_session_overview_current_session_title" = "Current session"; "user_session_overview_current_session_title" = "Current session";
"user_session_overview_session_title" = "Session"; "user_session_overview_session_title" = "Session";
"user_session_overview_session_details_button_title" = "Session details"; "user_session_overview_session_details_button_title" = "Session details";
@ -2601,6 +2611,7 @@ To enable access, tap Settings> Location and select Always";
"room_event_encryption_info_unverify" = "Unverify"; "room_event_encryption_info_unverify" = "Unverify";
"room_event_encryption_info_block" = "Blacklist"; "room_event_encryption_info_block" = "Blacklist";
"room_event_encryption_info_unblock" = "Unblacklist"; "room_event_encryption_info_unblock" = "Unblacklist";
"room_event_encryption_info_key_authenticity_not_guaranteed" = "The authenticity of this encrypted message can't be guaranteed on this device.";
"room_event_encryption_verify_title" = "Verify session\n\n"; "room_event_encryption_verify_title" = "Verify session\n\n";
"room_event_encryption_verify_message" = "To verify that this session can be trusted, please contact its owner using some other means (e.g. in person or a phone call) and ask them whether the key they see in their User Settings for this session matches the key below:\n\n\tSession name: %@\n\tSession ID: %@\n\tSession key: %@\n\nIf it matches, press the verify button below. If it doesnt, then someone else is intercepting this session and you probably want to press the blacklist button instead.\n\nIn future this verification process will be more sophisticated."; "room_event_encryption_verify_message" = "To verify that this session can be trusted, please contact its owner using some other means (e.g. in person or a phone call) and ask them whether the key they see in their User Settings for this session matches the key below:\n\n\tSession name: %@\n\tSession ID: %@\n\tSession key: %@\n\nIf it matches, press the verify button below. If it doesnt, then someone else is intercepting this session and you probably want to press the blacklist button instead.\n\nIn future this verification process will be more sophisticated.";
"room_event_encryption_verify_ok" = "Verify"; "room_event_encryption_verify_ok" = "Verify";

View file

@ -116,6 +116,7 @@ internal class Asset: NSObject {
internal static let e2eWarning = ImageAsset(name: "e2e_warning") internal static let e2eWarning = ImageAsset(name: "e2e_warning")
internal static let encryptionNormal = ImageAsset(name: "encryption_normal") internal static let encryptionNormal = ImageAsset(name: "encryption_normal")
internal static let encryptionTrusted = ImageAsset(name: "encryption_trusted") internal static let encryptionTrusted = ImageAsset(name: "encryption_trusted")
internal static let encryptionUntrusted = ImageAsset(name: "encryption_untrusted")
internal static let encryptionWarning = ImageAsset(name: "encryption_warning") internal static let encryptionWarning = ImageAsset(name: "encryption_warning")
internal static let favouritesEmptyScreenArtwork = ImageAsset(name: "favourites_empty_screen_artwork") internal static let favouritesEmptyScreenArtwork = ImageAsset(name: "favourites_empty_screen_artwork")
internal static let favouritesEmptyScreenArtworkDark = ImageAsset(name: "favourites_empty_screen_artwork_dark") internal static let favouritesEmptyScreenArtworkDark = ImageAsset(name: "favourites_empty_screen_artwork_dark")

View file

@ -247,6 +247,10 @@ public class VectorL10n: NSObject {
public static var allChatsTitle: String { public static var allChatsTitle: String {
return VectorL10n.tr("Vector", "all_chats_title") return VectorL10n.tr("Vector", "all_chats_title")
} }
/// User menu
public static var allChatsUserMenuAccessibilityLabel: String {
return VectorL10n.tr("Vector", "all_chats_user_menu_accessibility_label")
}
/// User settings /// User settings
public static var allChatsUserMenuSettings: String { public static var allChatsUserMenuSettings: String {
return VectorL10n.tr("Vector", "all_chats_user_menu_settings") return VectorL10n.tr("Vector", "all_chats_user_menu_settings")
@ -5623,6 +5627,10 @@ public class VectorL10n: NSObject {
public static var roomEventEncryptionInfoEventUserId: String { public static var roomEventEncryptionInfoEventUserId: String {
return VectorL10n.tr("Vector", "room_event_encryption_info_event_user_id") return VectorL10n.tr("Vector", "room_event_encryption_info_event_user_id")
} }
/// The authenticity of this encrypted message can't be guaranteed on this device.
public static var roomEventEncryptionInfoKeyAuthenticityNotGuaranteed: String {
return VectorL10n.tr("Vector", "room_event_encryption_info_key_authenticity_not_guaranteed")
}
/// End-to-end encryption information\n\n /// End-to-end encryption information\n\n
public static var roomEventEncryptionInfoTitle: String { public static var roomEventEncryptionInfoTitle: String {
return VectorL10n.tr("Vector", "room_event_encryption_info_title") return VectorL10n.tr("Vector", "room_event_encryption_info_title")
@ -7375,6 +7383,14 @@ public class VectorL10n: NSObject {
public static var settingsLabsEnableNewAppLayout: String { public static var settingsLabsEnableNewAppLayout: String {
return VectorL10n.tr("Vector", "settings_labs_enable_new_app_layout") return VectorL10n.tr("Vector", "settings_labs_enable_new_app_layout")
} }
/// Record the client name, version, and url to recognise sessions more easily in session manager
public static var settingsLabsEnableNewClientInfoFeature: String {
return VectorL10n.tr("Vector", "settings_labs_enable_new_client_info_feature")
}
/// New session manager
public static var settingsLabsEnableNewSessionManager: String {
return VectorL10n.tr("Vector", "settings_labs_enable_new_session_manager")
}
/// Ring for group calls /// Ring for group calls
public static var settingsLabsEnableRingingForGroupCalls: String { public static var settingsLabsEnableRingingForGroupCalls: String {
return VectorL10n.tr("Vector", "settings_labs_enable_ringing_for_group_calls") return VectorL10n.tr("Vector", "settings_labs_enable_ringing_for_group_calls")
@ -8479,10 +8495,38 @@ public class VectorL10n: NSObject {
public static var userOtherSessionSecurityRecommendationTitle: String { public static var userOtherSessionSecurityRecommendationTitle: String {
return VectorL10n.tr("Vector", "user_other_session_security_recommendation_title") return VectorL10n.tr("Vector", "user_other_session_security_recommendation_title")
} }
/// Name
public static var userSessionDetailsApplicationName: String {
return VectorL10n.tr("Vector", "user_session_details_application_name")
}
/// Application
public static var userSessionDetailsApplicationSectionHeader: String {
return VectorL10n.tr("Vector", "user_session_details_application_section_header")
}
/// URL
public static var userSessionDetailsApplicationUrl: String {
return VectorL10n.tr("Vector", "user_session_details_application_url")
}
/// Version
public static var userSessionDetailsApplicationVersion: String {
return VectorL10n.tr("Vector", "user_session_details_application_version")
}
/// IP address /// IP address
public static var userSessionDetailsDeviceIpAddress: String { public static var userSessionDetailsDeviceIpAddress: String {
return VectorL10n.tr("Vector", "user_session_details_device_ip_address") return VectorL10n.tr("Vector", "user_session_details_device_ip_address")
} }
/// IP location
public static var userSessionDetailsDeviceIpLocation: String {
return VectorL10n.tr("Vector", "user_session_details_device_ip_location")
}
/// Model
public static var userSessionDetailsDeviceModel: String {
return VectorL10n.tr("Vector", "user_session_details_device_model")
}
/// Operating System
public static var userSessionDetailsDeviceOs: String {
return VectorL10n.tr("Vector", "user_session_details_device_os")
}
/// Device /// Device
public static var userSessionDetailsDeviceSectionHeader: String { public static var userSessionDetailsDeviceSectionHeader: String {
return VectorL10n.tr("Vector", "user_session_details_device_section_header") return VectorL10n.tr("Vector", "user_session_details_device_section_header")
@ -8531,6 +8575,14 @@ public class VectorL10n: NSObject {
public static var userSessionOverviewSessionTitle: String { public static var userSessionOverviewSessionTitle: String {
return VectorL10n.tr("Vector", "user_session_overview_session_title") return VectorL10n.tr("Vector", "user_session_overview_session_title")
} }
/// Push notifications
public static var userSessionPushNotifications: String {
return VectorL10n.tr("Vector", "user_session_push_notifications")
}
/// When turned on, this session will receive push notifications.
public static var userSessionPushNotificationsMessage: String {
return VectorL10n.tr("Vector", "user_session_push_notifications_message")
}
/// Unverified session /// Unverified session
public static var userSessionUnverified: String { public static var userSessionUnverified: String {
return VectorL10n.tr("Vector", "user_session_unverified") return VectorL10n.tr("Vector", "user_session_unverified")

View file

@ -496,12 +496,12 @@ class CallPresenter: NSObject {
#if canImport(JitsiMeetSDK) #if canImport(JitsiMeetSDK)
JMCallKitProxy.removeListener(self) JMCallKitProxy.removeListener(self)
guard let session = sessions.first else { guard let sessionInfo = sessions.first else {
return return
} }
if let widgetEventsListener = widgetEventsListener { if let widgetEventsListener = widgetEventsListener {
session.removeListener(widgetEventsListener) sessionInfo.removeListener(widgetEventsListener)
} }
widgetEventsListener = nil widgetEventsListener = nil
#endif #endif
@ -872,11 +872,11 @@ extension CallPresenter: JMCallKitListener {
} }
func providerDidActivateAudioSession(session: AVAudioSession) { func providerDidActivateAudioSession(sessionInfo: AVAudioSession) {
} }
func providerDidDeactivateAudioSession(session: AVAudioSession) { func providerDidDeactivateAudioSession(sessionInfo: AVAudioSession) {
} }

View file

@ -164,6 +164,14 @@ final class RiotSettings: NSObject {
} }
} }
/// Flag indicating if the new session manager is enabled
@UserDefault(key: "enableNewSessionManager", defaultValue: false, storage: defaults)
var enableNewSessionManager
/// Flag indicating if the new client information feature is enabled
@UserDefault(key: "enableClientInformationFeature", defaultValue: false, storage: defaults)
var enableClientInformationFeature
// MARK: Calls // MARK: Calls
/// Indicate if `allowStunServerFallback` settings has been set once. /// Indicate if `allowStunServerFallback` settings has been set once.

View file

@ -370,6 +370,7 @@ class AllChatsCoordinator: NSObject, SplitViewMasterCoordinatorProtocol {
button.menu = menu button.menu = menu
button.showsMenuAsPrimaryAction = true button.showsMenuAsPrimaryAction = true
button.autoresizingMask = [.flexibleHeight, .flexibleWidth] button.autoresizingMask = [.flexibleHeight, .flexibleWidth]
button.accessibilityLabel = VectorL10n.allChatsUserMenuAccessibilityLabel
view.addSubview(button) view.addSubview(button)
self.avatarMenuButton = button self.avatarMenuButton = button

View file

@ -75,6 +75,23 @@ class AllChatsViewController: HomeViewController {
private var currentAlert: UIAlertController? private var currentAlert: UIAlertController?
@IBOutlet private var toolbar: UIToolbar!
private var isToolbarHidden: Bool = false {
didSet {
if isViewLoaded {
toolbar.transform = isToolbarHidden ? CGAffineTransform(translationX: 0, y: 2 * toolbarHeight) : .identity
self.view.layoutIfNeeded()
}
}
}
private func setToolbarHidden(_ isHidden: Bool, animated: Bool) {
UIView.animate(withDuration: animated ? 0.3 : 0) {
self.isToolbarHidden = isHidden
}
}
// MARK: - SplitViewMasterViewControllerProtocol // MARK: - SplitViewMasterViewControllerProtocol
// References on the currently selected room // References on the currently selected room
@ -92,6 +109,8 @@ class AllChatsViewController: HomeViewController {
// Tell whether the onboarding screen is preparing. // Tell whether the onboarding screen is preparing.
private(set) var isOnboardingInProgress: Bool = false private(set) var isOnboardingInProgress: Bool = false
private var toolbarHeight: CGFloat = 0
// MARK: - Lifecycle // MARK: - Lifecycle
override func viewDidLoad() { override func viewDidLoad() {
@ -107,6 +126,8 @@ class AllChatsViewController: HomeViewController {
recentsTableView.register(RecentsInvitesTableViewCell.nib, forCellReuseIdentifier: RecentsInvitesTableViewCell.reuseIdentifier) recentsTableView.register(RecentsInvitesTableViewCell.nib, forCellReuseIdentifier: RecentsInvitesTableViewCell.reuseIdentifier)
recentsTableView.contentInsetAdjustmentBehavior = .automatic recentsTableView.contentInsetAdjustmentBehavior = .automatic
toolbarHeight = toolbar.frame.height
updateUI() updateUI()
navigationItem.largeTitleDisplayMode = .automatic navigationItem.largeTitleDisplayMode = .automatic
@ -122,8 +143,7 @@ class AllChatsViewController: HomeViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
self.navigationController?.isToolbarHidden = false self.toolbar.tintColor = ThemeService.shared().theme.colors.accent
self.navigationController?.toolbar.tintColor = ThemeService.shared().theme.colors.accent
if self.navigationItem.searchController == nil { if self.navigationItem.searchController == nil {
self.navigationItem.searchController = searchController self.navigationItem.searchController = searchController
} }
@ -164,12 +184,6 @@ class AllChatsViewController: HomeViewController {
} }
} }
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
self.navigationController?.isToolbarHidden = true
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator) super.viewWillTransition(to: size, with: coordinator)
@ -372,8 +386,8 @@ class AllChatsViewController: HomeViewController {
let scrollPosition = scrollPosition(of: scrollView) let scrollPosition = scrollPosition(of: scrollView)
if !self.recentsTableView.isDragging && scrollPosition == 0 && self.navigationController?.isToolbarHidden == true { if !self.recentsTableView.isDragging && scrollPosition == 0 && self.isToolbarHidden == true {
self.navigationController?.setToolbarHidden(false, animated: true) self.setToolbarHidden(false, animated: true)
} }
guard self.recentsTableView.isDragging else { guard self.recentsTableView.isDragging else {
@ -385,8 +399,8 @@ class AllChatsViewController: HomeViewController {
} }
let isToolBarHidden: Bool = scrollPosition - initialScrollPosition > 0 let isToolBarHidden: Bool = scrollPosition - initialScrollPosition > 0
if isToolBarHidden != self.navigationController?.isToolbarHidden { if isToolBarHidden != self.isToolbarHidden {
self.navigationController?.setToolbarHidden(isToolBarHidden, animated: true) self.setToolbarHidden(isToolBarHidden, animated: true)
} }
} }
@ -494,13 +508,21 @@ class AllChatsViewController: HomeViewController {
} }
private func updateToolbar(with menu: UIMenu) { private func updateToolbar(with menu: UIMenu) {
self.navigationController?.isToolbarHidden = false guard isViewLoaded else {
return
}
self.isToolbarHidden = false
self.update(with: ThemeService.shared().theme) self.update(with: ThemeService.shared().theme)
self.setToolbarItems([
UIBarButtonItem(image: Asset.Images.allChatsSpacesIcon.image, style: .done, target: self, action: #selector(self.showSpaceSelectorAction(sender: ))), let spacesButton = UIBarButtonItem(image: Asset.Images.allChatsSpacesIcon.image, style: .done, target: self, action: #selector(self.showSpaceSelectorAction(sender: )))
spacesButton.accessibilityLabel = VectorL10n.spaceSelectorTitle
self.toolbar.items = [
spacesButton,
UIBarButtonItem.flexibleSpace(), UIBarButtonItem.flexibleSpace(),
UIBarButtonItem(image: Asset.Images.allChatsEditIcon.image, menu: menu) UIBarButtonItem(image: Asset.Images.allChatsEditIcon.image, menu: menu)
], animated: true) ]
} }
private func showCreateSpace(parentSpaceId: String?) { private func showCreateSpace(parentSpaceId: String?) {

View file

@ -11,8 +11,7 @@
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="AllChatsViewController" customModule="Element" customModuleProvider="target"> <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="AllChatsViewController" customModule="Element" customModuleProvider="target">
<connections> <connections>
<outlet property="recentsTableView" destination="orV-HH-88x" id="lgA-2k-pXJ"/> <outlet property="recentsTableView" destination="orV-HH-88x" id="lgA-2k-pXJ"/>
<outlet property="stickyHeadersBottomContainer" destination="EXH-mK-0eB" id="95Y-KP-bwF"/> <outlet property="toolbar" destination="osO-1z-CZg" id="xRm-cJ-bNi"/>
<outlet property="stickyHeadersBottomContainerHeightConstraint" destination="SNq-Js-N7s" id="vom-iM-s6W"/>
<outlet property="view" destination="iN0-l3-epB" id="NUQ-LI-M61"/> <outlet property="view" destination="iN0-l3-epB" id="NUQ-LI-M61"/>
</connections> </connections>
</placeholder> </placeholder>
@ -25,25 +24,24 @@
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/> <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/> <color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/>
</tableView> </tableView>
<view clipsSubviews="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="EXH-mK-0eB"> <toolbar opaque="NO" clearsContextBeforeDrawing="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="osO-1z-CZg">
<rect key="frame" x="0.0" y="667" width="375" height="0.0"/> <rect key="frame" x="0.0" y="623" width="375" height="44"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/>
<accessibility key="accessibilityConfiguration" identifier="RecentsVCStickyHeadersBottomContainer"/>
<constraints> <constraints>
<constraint firstAttribute="height" id="SNq-Js-N7s"/> <constraint firstAttribute="height" constant="44" id="7k8-Ot-vpH"/>
</constraints> </constraints>
</view> <items/>
</toolbar>
</subviews> </subviews>
<viewLayoutGuide key="safeArea" id="4qf-KS-Fc9"/> <viewLayoutGuide key="safeArea" id="4qf-KS-Fc9"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints> <constraints>
<constraint firstAttribute="trailing" secondItem="orV-HH-88x" secondAttribute="trailing" id="3Np-64-AUe"/> <constraint firstAttribute="trailing" secondItem="orV-HH-88x" secondAttribute="trailing" id="3Np-64-AUe"/>
<constraint firstItem="4qf-KS-Fc9" firstAttribute="bottom" secondItem="orV-HH-88x" secondAttribute="bottom" id="Bka-Zz-CEr"/> <constraint firstItem="osO-1z-CZg" firstAttribute="trailing" secondItem="4qf-KS-Fc9" secondAttribute="trailing" id="T2o-XM-BgN"/>
<constraint firstItem="EXH-mK-0eB" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="bottom" id="Kmg-aC-GOO"/>
<constraint firstItem="EXH-mK-0eB" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="S3i-DW-PUB"/>
<constraint firstAttribute="trailing" secondItem="EXH-mK-0eB" secondAttribute="trailing" id="bPP-yu-FTa"/>
<constraint firstItem="orV-HH-88x" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="dTn-zC-Axs"/> <constraint firstItem="orV-HH-88x" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="dTn-zC-Axs"/>
<constraint firstItem="osO-1z-CZg" firstAttribute="leading" secondItem="4qf-KS-Fc9" secondAttribute="leading" id="mq4-BS-JFN"/>
<constraint firstItem="osO-1z-CZg" firstAttribute="bottom" secondItem="4qf-KS-Fc9" secondAttribute="bottom" id="ups-Ek-9Zs"/>
<constraint firstItem="orV-HH-88x" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="top" id="xMc-15-1wS"/> <constraint firstItem="orV-HH-88x" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="top" id="xMc-15-1wS"/>
<constraint firstItem="4qf-KS-Fc9" firstAttribute="bottom" secondItem="orV-HH-88x" secondAttribute="bottom" id="zCp-Ju-dvr"/>
</constraints> </constraints>
<point key="canvasLocation" x="140" y="137.18140929535232"/> <point key="canvasLocation" x="140" y="137.18140929535232"/>
</view> </view>

View file

@ -209,6 +209,7 @@ extension UserVerificationCoordinator: KeyVerificationManuallyVerifyCoordinatorD
self.presenter.toPresentable().dismiss(animated: true) { self.presenter.toPresentable().dismiss(animated: true) {
self.remove(childCoordinator: coordinator) self.remove(childCoordinator: coordinator)
} }
delegate?.userVerificationCoordinatorDidComplete(self)
} }
func keyVerificationManuallyVerifyCoordinatorDidCancel(_ coordinator: KeyVerificationManuallyVerifyCoordinatorType) { func keyVerificationManuallyVerifyCoordinatorDidCancel(_ coordinator: KeyVerificationManuallyVerifyCoordinatorType) {

View file

@ -77,8 +77,14 @@ final class UserVerificationCoordinatorBridgePresenter: NSObject {
} else { } else {
userVerificationCoordinator = UserVerificationCoordinator(presenter: self.presenter, session: self.session, userId: self.userId, userDisplayName: self.userDisplayName) userVerificationCoordinator = UserVerificationCoordinator(presenter: self.presenter, session: self.session, userId: self.userId, userDisplayName: self.userDisplayName)
} }
userVerificationCoordinator.delegate = self
userVerificationCoordinator.start() userVerificationCoordinator.start()
self.coordinator = userVerificationCoordinator self.coordinator = userVerificationCoordinator
} }
} }
extension UserVerificationCoordinatorBridgePresenter: UserVerificationCoordinatorDelegate {
func userVerificationCoordinatorDidComplete(_ coordinator: UserVerificationCoordinatorType) {
delegate?.userVerificationCoordinatorBridgePresenterDelegateDidComplete(self)
}
}

View file

@ -920,7 +920,7 @@
{ {
for (MXKRoomBubbleComponent *component in bubbleComponents) for (MXKRoomBubbleComponent *component in bubbleComponents)
{ {
if (component.showEncryptionBadge) if (component.encryptionDecoration != EventEncryptionDecorationNone)
{ {
containsBubbleComponentWithEncryptionBadge = YES; containsBubbleComponentWithEncryptionBadge = YES;
break; break;

View file

@ -18,6 +18,7 @@
#import "MXKEventFormatter.h" #import "MXKEventFormatter.h"
#import "MXKURLPreviewDataProtocol.h" #import "MXKURLPreviewDataProtocol.h"
#import "EventEncryptionDecoration.h"
@protocol MXThreadProtocol; @protocol MXThreadProtocol;
@ -101,9 +102,9 @@ typedef enum : NSUInteger {
@property (nonatomic) MXEventScan *eventScan; @property (nonatomic) MXEventScan *eventScan;
/** /**
Indicate if an encryption badge should be shown. Type of encryption decoration (if any) for this event
*/ */
@property (nonatomic, readonly) BOOL showEncryptionBadge; @property (nonatomic, readonly) EventEncryptionDecoration encryptionDecoration;
/** /**
Thread for the bubble component. Should only exist for thread root events. Thread for the bubble component. Should only exist for thread root events.

View file

@ -73,7 +73,7 @@
} }
} }
_showEncryptionBadge = [self shouldShowWarningBadgeForEvent:event roomState:(MXRoomState*)roomState session:session]; _encryptionDecoration = [self encryptionDecorationForEvent:event roomState:(MXRoomState*)roomState session:session];
[self updateLinkWithRoomState:roomState]; [self updateLinkWithRoomState:roomState];
@ -116,7 +116,7 @@
andLatestRoomState:latestRoomState andLatestRoomState:latestRoomState
error:&error]; error:&error];
_showEncryptionBadge = [self shouldShowWarningBadgeForEvent:event roomState:roomState session:session]; _encryptionDecoration = [self encryptionDecorationForEvent:event roomState:roomState session:session];
[self updateLinkWithRoomState:roomState]; [self updateLinkWithRoomState:roomState];
} }
@ -167,24 +167,24 @@
self.link = url; self.link = url;
} }
- (BOOL)shouldShowWarningBadgeForEvent:(MXEvent*)event roomState:(MXRoomState*)roomState session:(MXSession*)session - (EventEncryptionDecoration)encryptionDecorationForEvent:(MXEvent*)event roomState:(MXRoomState*)roomState session:(MXSession*)session
{ {
// Warning badges are unnecessary in unencrypted rooms // Warning badges are unnecessary in unencrypted rooms
if (!roomState.isEncrypted) if (!roomState.isEncrypted)
{ {
return NO; return EventEncryptionDecorationNone;
} }
// Not all events are encrypted (e.g. state/reactions/redactions) and we only have encrypted cell subclasses for messages and attachments. // Not all events are encrypted (e.g. state/reactions/redactions) and we only have encrypted cell subclasses for messages and attachments.
if (event.eventType != MXEventTypeRoomMessage && !event.isMediaAttachment) if (event.eventType != MXEventTypeRoomMessage && !event.isMediaAttachment)
{ {
return NO; return EventEncryptionDecorationNone;
} }
// Always show a warning badge if there was a decryption error. // Always show a warning badge if there was a decryption error.
if (event.decryptionError) if (event.decryptionError)
{ {
return YES; return EventEncryptionDecorationDecryptionError;
} }
// Unencrypted message events should show a warning unless they're pending local echoes // Unencrypted message events should show a warning unless they're pending local echoes
@ -193,10 +193,10 @@
if (event.isLocalEvent if (event.isLocalEvent
|| event.contentHasBeenEdited) // Local echo for an edit is clear but uses a true event id, the one of the edited event || event.contentHasBeenEdited) // Local echo for an edit is clear but uses a true event id, the one of the edited event
{ {
return NO; return EventEncryptionDecorationNone;
} }
return YES; return EventEncryptionDecorationNotEncrypted;
} }
// The encryption is in a good state. // The encryption is in a good state.
@ -208,12 +208,17 @@
if (userTrustLevel.isVerified && !deviceInfo.trustLevel.isVerified) if (userTrustLevel.isVerified && !deviceInfo.trustLevel.isVerified)
{ {
return YES; return EventEncryptionDecorationUntrustedDevice;
} }
} }
if (event.isUntrusted)
{
return EventEncryptionDecorationUnsafeKey;
}
// Everything was fine // Everything was fine
return NO; return EventEncryptionDecorationNone;
} }
@end @end

View file

@ -192,7 +192,7 @@ static NSAttributedString *verticalWhitespace = nil;
NSString *claimedKey = _mxEvent.keysClaimed[@"ed25519"]; NSString *claimedKey = _mxEvent.keysClaimed[@"ed25519"];
NSString *algorithm = _mxEvent.wireContent[@"algorithm"]; NSString *algorithm = _mxEvent.wireContent[@"algorithm"];
NSString *sessionId = _mxEvent.wireContent[@"session_id"]; NSString *sessionId = _mxEvent.wireContent[@"session_id"];
NSString *untrusted = _mxEvent.isUntrusted ? [VectorL10n userVerificationSessionsListSessionUntrusted] : [VectorL10n userVerificationSessionsListSessionTrusted]; NSString *untrusted = _mxEvent.isUntrusted ? [VectorL10n roomEventEncryptionInfoKeyAuthenticityNotGuaranteed] : [VectorL10n userVerificationSessionsListSessionTrusted];
NSString *decryptionError; NSString *decryptionError;
if (_mxEvent.decryptionError) if (_mxEvent.decryptionError)

View file

@ -32,7 +32,7 @@
#define TABLEVIEW_ROW_CELL_HEIGHT 46 #define TABLEVIEW_ROW_CELL_HEIGHT 46
#define TABLEVIEW_SECTION_HEADER_HEIGHT 28 #define TABLEVIEW_SECTION_HEADER_HEIGHT 28
@interface RoomMemberDetailsViewController () <UIGestureRecognizerDelegate, DeviceTableViewCellDelegate, RoomMemberTitleViewDelegate, KeyVerificationCoordinatorBridgePresenterDelegate> @interface RoomMemberDetailsViewController () <UIGestureRecognizerDelegate, DeviceTableViewCellDelegate, RoomMemberTitleViewDelegate, KeyVerificationCoordinatorBridgePresenterDelegate, UserVerificationCoordinatorBridgePresenterDelegate>
{ {
RoomMemberTitleView* memberTitleView; RoomMemberTitleView* memberTitleView;
@ -449,6 +449,7 @@
session:self.mxRoom.mxSession session:self.mxRoom.mxSession
userId:self.mxRoomMember.userId userId:self.mxRoomMember.userId
userDisplayName:self.mxRoomMember.displayname]; userDisplayName:self.mxRoomMember.displayname];
userVerificationCoordinatorBridgePresenter.delegate = self;
[userVerificationCoordinatorBridgePresenter start]; [userVerificationCoordinatorBridgePresenter start];
self.userVerificationCoordinatorBridgePresenter = userVerificationCoordinatorBridgePresenter; self.userVerificationCoordinatorBridgePresenter = userVerificationCoordinatorBridgePresenter;
} }
@ -1345,4 +1346,11 @@
keyVerificationCoordinatorBridgePresenter = nil; keyVerificationCoordinatorBridgePresenter = nil;
} }
#pragma mark - UserVerificationCoordinatorBridgePresenterDelegate
- (void)userVerificationCoordinatorBridgePresenterDelegateDidComplete:(UserVerificationCoordinatorBridgePresenter *)coordinatorBridgePresenter
{
[self refreshUserEncryptionTrustLevel];
}
@end @end

View file

@ -0,0 +1,30 @@
//
// 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.
//
#ifndef EventEncryptionDecoration_h
#define EventEncryptionDecoration_h
typedef NS_ENUM(NSUInteger, EventEncryptionDecoration)
{
EventEncryptionDecorationNone,
EventEncryptionDecorationUnsafeKey,
EventEncryptionDecorationDecryptionError,
EventEncryptionDecorationNotEncrypted,
EventEncryptionDecorationUntrustedDevice
};
#endif /* EventEncryptionDecoration_h */

View file

@ -24,12 +24,18 @@ NSString *const kRoomEncryptedDataBubbleCellTapOnEncryptionIcon = @"kRoomEncrypt
+ (UIImage*)encryptionIconForBubbleComponent:(MXKRoomBubbleComponent *)bubbleComponent + (UIImage*)encryptionIconForBubbleComponent:(MXKRoomBubbleComponent *)bubbleComponent
{ {
if (!bubbleComponent.showEncryptionBadge) switch (bubbleComponent.encryptionDecoration) {
{ case EventEncryptionDecorationNone:
return nil;
case EventEncryptionDecorationUnsafeKey:
return AssetImages.encryptionUntrusted.image;
case EventEncryptionDecorationDecryptionError:
case EventEncryptionDecorationNotEncrypted:
case EventEncryptionDecorationUntrustedDevice:
return AssetImages.encryptionWarning.image;
default:
return nil; return nil;
} }
return AssetImages.encryptionWarning.image;
} }
+ (void)addEncryptionStatusFromBubbleData:(MXKRoomBubbleCellData *)bubbleData inContainerView:(UIView *)containerView + (void)addEncryptionStatusFromBubbleData:(MXKRoomBubbleCellData *)bubbleData inContainerView:(UIView *)containerView

View file

@ -71,7 +71,15 @@ final class PinCodePreferences: NSObject {
} }
var isBiometricsAvailable: Bool { var isBiometricsAvailable: Bool {
return LAContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) var error: NSError?
let result = LAContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
// While in lockout they're still techincally available
if error?.code == LAError.Code.biometryLockout.rawValue {
return true
}
return result
} }
/// Allowed number of PIN trials before showing forgot help alert /// Allowed number of PIN trials before showing forgot help alert

View file

@ -45,7 +45,7 @@ enum {
}; };
@interface ManageSessionViewController () @interface ManageSessionViewController () <UserVerificationCoordinatorBridgePresenterDelegate>
{ {
// The device to display // The device to display
MXDevice *device; MXDevice *device;
@ -649,6 +649,7 @@ enum {
userId:self.mainSession.myUser.userId userId:self.mainSession.myUser.userId
userDisplayName:nil userDisplayName:nil
deviceId:device.deviceId]; deviceId:device.deviceId];
userVerificationCoordinatorBridgePresenter.delegate = self;
[userVerificationCoordinatorBridgePresenter start]; [userVerificationCoordinatorBridgePresenter start];
self.userVerificationCoordinatorBridgePresenter = userVerificationCoordinatorBridgePresenter; self.userVerificationCoordinatorBridgePresenter = userVerificationCoordinatorBridgePresenter;
} }
@ -701,4 +702,11 @@ enum {
self.reauthenticationCoordinatorBridgePresenter = reauthenticationPresenter; self.reauthenticationCoordinatorBridgePresenter = reauthenticationPresenter;
} }
#pragma mark - UserVerificationCoordinatorBridgePresenterDelegate
- (void)userVerificationCoordinatorBridgePresenterDelegateDidComplete:(UserVerificationCoordinatorBridgePresenter *)coordinatorBridgePresenter
{
[self reloadDeviceWithCompletion:^{}];
}
@end @end

View file

@ -172,7 +172,9 @@ typedef NS_ENUM(NSUInteger, LABS_ENABLE)
LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX = 0, LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX = 0,
LABS_ENABLE_THREADS_INDEX, LABS_ENABLE_THREADS_INDEX,
LABS_ENABLE_AUTO_REPORT_DECRYPTION_ERRORS, LABS_ENABLE_AUTO_REPORT_DECRYPTION_ERRORS,
LABS_ENABLE_LIVE_LOCATION_SHARING LABS_ENABLE_LIVE_LOCATION_SHARING,
LABS_ENABLE_NEW_SESSION_MANAGER,
LABS_ENABLE_NEW_CLIENT_INFO_FEATURE
}; };
typedef NS_ENUM(NSUInteger, SECURITY) typedef NS_ENUM(NSUInteger, SECURITY)
@ -403,7 +405,7 @@ ChangePasswordCoordinatorBridgePresenterDelegate>
Section *sectionSecurity = [Section sectionWithTag:SECTION_TAG_SECURITY]; Section *sectionSecurity = [Section sectionWithTag:SECTION_TAG_SECURITY];
[sectionSecurity addRowWithTag:SECURITY_BUTTON_INDEX]; [sectionSecurity addRowWithTag:SECURITY_BUTTON_INDEX];
if (BuildSettings.deviceManagerEnabled) if (RiotSettings.shared.enableNewSessionManager)
{ {
// NOTE: Add device manager entry point in the security section atm for debug purpose // NOTE: Add device manager entry point in the security section atm for debug purpose
[sectionSecurity addRowWithTag:DEVICE_MANAGER_INDEX]; [sectionSecurity addRowWithTag:DEVICE_MANAGER_INDEX];
@ -595,6 +597,8 @@ ChangePasswordCoordinatorBridgePresenterDelegate>
{ {
[sectionLabs addRowWithTag:LABS_ENABLE_LIVE_LOCATION_SHARING]; [sectionLabs addRowWithTag:LABS_ENABLE_LIVE_LOCATION_SHARING];
} }
[sectionLabs addRowWithTag:LABS_ENABLE_NEW_SESSION_MANAGER];
[sectionLabs addRowWithTag:LABS_ENABLE_NEW_CLIENT_INFO_FEATURE];
sectionLabs.headerTitle = [VectorL10n settingsLabs]; sectionLabs.headerTitle = [VectorL10n settingsLabs];
if (sectionLabs.hasAnyRows) if (sectionLabs.hasAnyRows)
{ {
@ -2532,6 +2536,30 @@ ChangePasswordCoordinatorBridgePresenterDelegate>
{ {
cell = [self buildLiveLocationSharingCellForTableView:tableView atIndexPath:indexPath]; cell = [self buildLiveLocationSharingCellForTableView:tableView atIndexPath:indexPath];
} }
else if (row == LABS_ENABLE_NEW_SESSION_MANAGER)
{
MXKTableViewCellWithLabelAndSwitch *labelAndSwitchCell = [self getLabelAndSwitchCell:tableView forIndexPath:indexPath];
labelAndSwitchCell.mxkLabel.text = [VectorL10n settingsLabsEnableNewSessionManager];
labelAndSwitchCell.mxkSwitch.on = RiotSettings.shared.enableNewSessionManager;
labelAndSwitchCell.mxkSwitch.onTintColor = ThemeService.shared.theme.tintColor;
[labelAndSwitchCell.mxkSwitch addTarget:self action:@selector(toggleEnableNewSessionManager:) forControlEvents:UIControlEventTouchUpInside];
cell = labelAndSwitchCell;
}
else if (row == LABS_ENABLE_NEW_CLIENT_INFO_FEATURE)
{
MXKTableViewCellWithLabelAndSwitch *labelAndSwitchCell = [self getLabelAndSwitchCell:tableView forIndexPath:indexPath];
labelAndSwitchCell.mxkLabel.text = [VectorL10n settingsLabsEnableNewClientInfoFeature];
labelAndSwitchCell.mxkSwitch.on = RiotSettings.shared.enableClientInformationFeature;
labelAndSwitchCell.mxkSwitch.onTintColor = ThemeService.shared.theme.tintColor;
[labelAndSwitchCell.mxkSwitch addTarget:self action:@selector(toggleEnableNewClientInfoFeature:) forControlEvents:UIControlEventTouchUpInside];
cell = labelAndSwitchCell;
}
} }
else if (section == SECTION_TAG_SECURITY) else if (section == SECTION_TAG_SECURITY)
{ {
@ -3277,6 +3305,19 @@ ChangePasswordCoordinatorBridgePresenterDelegate>
[[AppDelegate theDelegate] restoreEmptyDetailsViewController]; [[AppDelegate theDelegate] restoreEmptyDetailsViewController];
} }
- (void)toggleEnableNewSessionManager:(UISwitch *)sender
{
RiotSettings.shared.enableNewSessionManager = sender.isOn;
[self updateSections];
}
- (void)toggleEnableNewClientInfoFeature:(UISwitch *)sender
{
BOOL isEnabled = sender.isOn;
RiotSettings.shared.enableClientInformationFeature = isEnabled;
MXSDKOptions.sharedInstance.enableNewClientInformationFeature = isEnabled;
}
- (void)togglePinRoomsWithMissedNotif:(UISwitch *)sender - (void)togglePinRoomsWithMissedNotif:(UISwitch *)sender
{ {
RiotSettings.shared.pinRoomsWithMissedNotificationsOnHome = sender.isOn; RiotSettings.shared.pinRoomsWithMissedNotificationsOnHome = sender.isOn;

View file

@ -461,8 +461,8 @@ static NSString *const kEventFormatterTimeFormat = @"HH:mm";
{ {
calendar = [[NSCalendar alloc] initWithCalendarIdentifier:NSCalendarIdentifierGregorian]; calendar = [[NSCalendar alloc] initWithCalendarIdentifier:NSCalendarIdentifierGregorian];
// Use the secondary bg color to set the background color in the default CSS. // Use the selected bg color to set the code block background color in the default CSS.
NSUInteger bgColor = [MXKTools rgbValueWithColor:ThemeService.shared.theme.headerBackgroundColor]; NSUInteger bgColor = [MXKTools rgbValueWithColor:ThemeService.shared.theme.selectedBackgroundColor];
self.defaultCSS = [NSString stringWithFormat:@" \ self.defaultCSS = [NSString stringWithFormat:@" \
pre,code { \ pre,code { \
background-color: #%06lX; \ background-color: #%06lX; \

View file

@ -24,6 +24,9 @@ schemes:
disableMainThreadChecker: true disableMainThreadChecker: true
targets: targets:
- RiotTests - RiotTests
gatherCoverageData: true
coverageTargets:
- Riot
targets: targets:
Riot: Riot:

View file

@ -0,0 +1,24 @@
//
// 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.
//
import Foundation
extension Collection {
/// Returns the element at the specified index if it is within bounds, otherwise nil.
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}

View file

@ -25,7 +25,7 @@ class InactiveUserSessionLastActivityFormatter {
return dateFormatter return dateFormatter
}() }()
func lastActivityDateString(from lastActivityTimestamp: TimeInterval) -> String { static func lastActivityDateString(from lastActivityTimestamp: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: lastActivityTimestamp) let date = Date(timeIntervalSince1970: lastActivityTimestamp)
return InactiveUserSessionLastActivityFormatter.dateFormatter.string(from: date) return InactiveUserSessionLastActivityFormatter.dateFormatter.string(from: date)

View file

@ -0,0 +1,201 @@
//
// 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.
//
import Foundation
struct UserAgent {
let deviceType: DeviceType
let deviceModel: String?
let deviceOS: String?
let clientName: String?
let clientVersion: String?
static let unknown = UserAgent(deviceType: .unknown,
deviceModel: nil,
deviceOS: nil,
clientName: nil,
clientVersion: nil)
}
extension UserAgent: Equatable { }
enum UserAgentParser {
private enum Constants {
static let deviceInfoRegexPattern = "\\((?:[^)(]+|\\((?:[^)(]+|\\([^)(]*\\))*\\))*\\)"
static let androidKeyword = "; MatrixAndroidSdk2"
static let iosKeyword = "; iOS "
static let desktopKeyword = " Electron/"
static let webKeyword = "Mozilla/"
}
static func parse(_ userAgent: String) -> UserAgent {
if userAgent.vc_caseInsensitiveContains(Constants.androidKeyword) {
return parseAndroid(userAgent)
} else if userAgent.vc_caseInsensitiveContains(Constants.iosKeyword) {
return parseIOS(userAgent)
} else if userAgent.vc_caseInsensitiveContains(Constants.desktopKeyword) {
return parseDesktop(userAgent)
} else if userAgent.vc_caseInsensitiveContains(Constants.webKeyword) {
return parseWeb(userAgent)
}
return .unknown
}
// Legacy: Element/1.0.0 (Linux; U; Android 6.0.1; SM-A510F Build/MMB29; Flavour GPlay; MatrixAndroidSdk2 1.0)
// New: Element dbg/1.5.0-dev (Xiaomi Mi 9T; Android 11; RKQ1.200826.002 test-keys; Flavour GooglePlay; MatrixAndroidSdk2 1.5.0)
private static func parseAndroid(_ userAgent: String) -> UserAgent {
var deviceModel: String?
var deviceOS: String?
var clientName: String?
var clientVersion: String?
let (beforeSlash, afterSlash) = userAgent.splitByFirst("/")
clientName = beforeSlash
if let afterSlash = afterSlash {
let (beforeSpace, afterSpace) = afterSlash.splitByFirst(" ")
clientVersion = beforeSpace
if let afterSpace = afterSpace {
if let deviceInfo = findFirstDeviceInfo(in: afterSpace) {
let deviceInfoComponents = deviceInfo.components(separatedBy: "; ")
let isLegacy = deviceInfoComponents[safe: 0] == "Linux"
if isLegacy {
// find the segment starting with "Android"
if let osSegmentIndex = deviceInfoComponents.firstIndex(where: { $0.hasPrefix("Android") }) {
deviceOS = deviceInfoComponents[safe: osSegmentIndex]
deviceModel = deviceInfoComponents[safe: osSegmentIndex + 1]
}
} else {
deviceModel = deviceInfoComponents[safe: 0]
deviceOS = deviceInfoComponents[safe: 1]
}
}
}
}
return UserAgent(deviceType: .mobile,
deviceModel: deviceModel,
deviceOS: deviceOS,
clientName: clientName,
clientVersion: clientVersion)
}
// Legacy: Riot/1.8.21 (iPhone; iOS 15.2; Scale/3.00)
// New: Riot/1.8.21 (iPhone X; iOS 15.2; Scale/3.00)
private static func parseIOS(_ userAgent: String) -> UserAgent {
var deviceModel: String?
var deviceOS: String?
var clientName: String?
var clientVersion: String?
let (beforeSlash, afterSlash) = userAgent.splitByFirst("/")
clientName = beforeSlash
if let afterSlash = afterSlash {
let (beforeSpace, afterSpace) = afterSlash.splitByFirst(" ")
clientVersion = beforeSpace
if let afterSpace = afterSpace {
if let deviceInfo = findFirstDeviceInfo(in: afterSpace) {
let deviceInfoComponents = deviceInfo.components(separatedBy: "; ")
deviceModel = deviceInfoComponents[safe: 0]
deviceOS = deviceInfoComponents[safe: 1]
}
}
}
return UserAgent(deviceType: .mobile,
deviceModel: deviceModel,
deviceOS: deviceOS,
clientName: clientName,
clientVersion: clientVersion)
}
// 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
private static func parseDesktop(_ userAgent: String) -> UserAgent {
var deviceOS: String?
let browserName = browserName(for: userAgent)
if let deviceInfo = findFirstDeviceInfo(in: userAgent) {
let deviceInfoComponents = deviceInfo.components(separatedBy: "; ")
deviceOS = deviceInfoComponents[safe: 1]?.hasPrefix("Android") == true ? deviceInfoComponents[safe: 1] : deviceInfoComponents.first
}
return UserAgent(deviceType: .desktop,
deviceModel: browserName,
deviceOS: deviceOS,
clientName: nil,
clientVersion: nil)
}
// Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36
private static func parseWeb(_ userAgent: String) -> UserAgent {
let desktopUserAgent = parseDesktop(userAgent)
return UserAgent(deviceType: .web,
deviceModel: desktopUserAgent.deviceModel,
deviceOS: desktopUserAgent.deviceOS,
clientName: desktopUserAgent.clientName,
clientVersion: desktopUserAgent.clientVersion)
}
private static func findFirstDeviceInfo(in string: String) -> String? {
guard let regex = try? NSRegularExpression(pattern: Constants.deviceInfoRegexPattern,
options: .caseInsensitive) else {
return nil
}
var range = regex.rangeOfFirstMatch(in: string, range: NSRange(string.startIndex..., in: string))
if range.location != NSNotFound {
range.location += 1
range.length -= 2
return string[range]
}
return nil
}
private static func browserName(for userAgent: String) -> String? {
let components = userAgent.components(separatedBy: " ")
if components.last?.hasPrefix("Firefox") == true {
return "Firefox"
} else if components.last?.hasPrefix("Safari") == true
&& components[safe:components.count - 2]?.hasPrefix("Mobile") == true {
// mobile browser
let possibleBrowserName = components[safe:components.count - 3]?.components(separatedBy: "/").first
return possibleBrowserName == "Version" ? "Safari" : possibleBrowserName
} else if components.last?.hasPrefix("Safari") == true && components[safe:components.count - 2]?.hasPrefix("Version") == true {
return "Safari"
} else {
// regular browser
return components[safe:components.count - 2]?.components(separatedBy: "/").first
}
}
}
private extension String {
subscript(_ range: NSRange) -> String {
let start = index(startIndex, offsetBy: range.lowerBound)
let end = index(startIndex, offsetBy: range.upperBound)
let subString = self[start..<end]
return String(subString)
}
func splitByFirst(_ delimiter: Character) -> (String?, String?) {
guard let delimiterIndex = firstIndex(of: delimiter) else {
return (nil, nil)
}
let before = String(prefix(upTo: delimiterIndex))
let after = String(suffix(from: index(after: delimiterIndex)))
return (before, after)
}
}

View file

@ -36,6 +36,31 @@ struct UserSessionInfo: Identifiable {
/// Last time the session was active /// Last time the session was active
let lastSeenTimestamp: TimeInterval? let lastSeenTimestamp: TimeInterval?
// MARK: - Application Properties
/// Application name used by the session
let applicationName: String?
/// Application version used by the session
let applicationVersion: String?
/// Application URL used by the session. Only applicable for web sessions.
let applicationURL: String?
// MARK: - Device Properties
/// Device model
let deviceModel: String?
/// Device OS
let deviceOS: String?
/// Last seen IP location
let lastSeenIPLocation: String?
/// Device name
let deviceName: String?
/// True to indicate that session has been used under `inactiveSessionDurationTreshold` value /// True to indicate that session has been used under `inactiveSessionDurationTreshold` value
let isActive: Bool let isActive: Bool
@ -45,6 +70,6 @@ struct UserSessionInfo: Identifiable {
extension UserSessionInfo: Equatable { extension UserSessionInfo: Equatable {
static func == (lhs: UserSessionInfo, rhs: UserSessionInfo) -> Bool { static func == (lhs: UserSessionInfo, rhs: UserSessionInfo) -> Bool {
return lhs.id == rhs.id lhs.id == rhs.id
} }
} }

View file

@ -17,7 +17,7 @@
import Foundation import Foundation
/// Enables to build last activity date string /// Enables to build last activity date string
class UserSessionLastActivityFormatter { enum UserSessionLastActivityFormatter {
private static var lastActivityDateFormatter: DateFormatter = { private static var lastActivityDateFormatter: DateFormatter = {
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.locale = Locale.current dateFormatter.locale = Locale.current
@ -28,9 +28,9 @@ class UserSessionLastActivityFormatter {
}() }()
/// Session last activity string /// Session last activity string
func lastActivityDateString(from lastActivityTimestamp: TimeInterval) -> String { static func lastActivityDateString(from lastActivityTimestamp: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: lastActivityTimestamp) let date = Date(timeIntervalSince1970: lastActivityTimestamp)
return UserSessionLastActivityFormatter.lastActivityDateFormatter.string(from: date) return Self.lastActivityDateFormatter.string(from: date)
} }
} }

View file

@ -17,9 +17,9 @@
import Foundation import Foundation
/// Enables to build user session name /// Enables to build user session name
class UserSessionNameFormatter { enum UserSessionNameFormatter {
/// Session name with client name and session display name /// Session name with client name and session display name
func sessionName(deviceType: DeviceType, sessionDisplayName: String?) -> String { static func sessionName(deviceType: DeviceType, sessionDisplayName: String?) -> String {
let sessionName: String let sessionName: String
let clientName = deviceType.name let clientName = deviceType.name

View file

@ -109,12 +109,14 @@ struct UserSessionCardView: View {
.buttonStyle(PrimaryActionButtonStyle()) .buttonStyle(PrimaryActionButtonStyle())
.padding(.top, 4) .padding(.top, 4)
.padding(.bottom, 3) .padding(.bottom, 3)
.accessibilityIdentifier("userSessionCardVerifyButton")
} }
if viewData.isCurrentSessionDisplayMode { if viewData.isCurrentSessionDisplayMode {
Text(VectorL10n.userSessionViewDetails) Text(VectorL10n.userSessionViewDetails)
.font(theme.fonts.body) .font(theme.fonts.body)
.foregroundColor(theme.colors.accent) .foregroundColor(theme.colors.accent)
.accessibilityIdentifier("userSessionCardViewDetails")
} }
} }
.padding(24) .padding(24)
@ -135,17 +137,23 @@ struct UserSessionCardViewPreview: View {
let viewData: UserSessionCardViewData let viewData: UserSessionCardViewData
init(isCurrentSessionInfo: Bool = false) { init(isCurrent: Bool = false) {
let session = UserSessionInfo(id: "alice", let sessionInfo = UserSessionInfo(id: "alice",
name: "iOS", name: "iOS",
deviceType: .mobile, deviceType: .mobile,
isVerified: false, isVerified: false,
lastSeenIP: "10.0.0.10", lastSeenIP: "10.0.0.10",
lastSeenTimestamp: Date().timeIntervalSince1970 - 100, lastSeenTimestamp: nil,
applicationName: "Element iOS",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "iOS 15.5",
lastSeenIPLocation: nil,
deviceName: "My iPhone",
isActive: true, isActive: true,
isCurrent: isCurrentSessionInfo) isCurrent: isCurrent)
viewData = UserSessionCardViewData(sessionInfo: sessionInfo)
viewData = UserSessionCardViewData(session: session)
} }
var body: some View { var body: some View {
@ -161,8 +169,8 @@ struct UserSessionCardViewPreview: View {
struct UserSessionCardView_Previews: PreviewProvider { struct UserSessionCardView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group { Group {
UserSessionCardViewPreview(isCurrentSessionInfo: true).theme(.light).preferredColorScheme(.light) UserSessionCardViewPreview(isCurrent: true).theme(.light).preferredColorScheme(.light)
UserSessionCardViewPreview(isCurrentSessionInfo: true).theme(.dark).preferredColorScheme(.dark) UserSessionCardViewPreview(isCurrent: true).theme(.dark).preferredColorScheme(.dark)
UserSessionCardViewPreview().theme(.light).preferredColorScheme(.light) UserSessionCardViewPreview().theme(.light).preferredColorScheme(.light)
UserSessionCardViewPreview().theme(.dark).preferredColorScheme(.dark) UserSessionCardViewPreview().theme(.dark).preferredColorScheme(.dark)
} }

View file

@ -18,9 +18,6 @@ import Foundation
/// View data for UserSessionCardView /// View data for UserSessionCardView
struct UserSessionCardViewData { struct UserSessionCardViewData {
private static let userSessionNameFormatter = UserSessionNameFormatter()
private static let lastActivityDateFormatter = UserSessionLastActivityFormatter()
var id: String { var id: String {
sessionId sessionId
} }
@ -48,13 +45,13 @@ struct UserSessionCardViewData {
lastSeenIP: String?, lastSeenIP: String?,
isCurrentSessionDisplayMode: Bool = false) { isCurrentSessionDisplayMode: Bool = false) {
self.sessionId = sessionId self.sessionId = sessionId
sessionName = Self.userSessionNameFormatter.sessionName(deviceType: deviceType, sessionDisplayName: sessionDisplayName) sessionName = UserSessionNameFormatter.sessionName(deviceType: deviceType, sessionDisplayName: sessionDisplayName)
self.isVerified = isVerified self.isVerified = isVerified
var lastActivityDateString: String? var lastActivityDateString: String?
if let lastActivityTimestamp = lastActivityTimestamp { if let lastActivityTimestamp = lastActivityTimestamp {
lastActivityDateString = Self.lastActivityDateFormatter.lastActivityDateString(from: lastActivityTimestamp) lastActivityDateString = UserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityTimestamp)
} }
self.lastActivityDateString = lastActivityDateString self.lastActivityDateString = lastActivityDateString
@ -66,13 +63,13 @@ struct UserSessionCardViewData {
} }
extension UserSessionCardViewData { extension UserSessionCardViewData {
init(session: UserSessionInfo) { init(sessionInfo: UserSessionInfo) {
self.init(sessionId: session.id, self.init(sessionId: sessionInfo.id,
sessionDisplayName: session.name, sessionDisplayName: sessionInfo.name,
deviceType: session.deviceType, deviceType: sessionInfo.deviceType,
isVerified: session.isVerified, isVerified: sessionInfo.isVerified,
lastActivityTimestamp: session.lastSeenTimestamp, lastActivityTimestamp: sessionInfo.lastSeenTimestamp,
lastSeenIP: session.lastSeenIP, lastSeenIP: sessionInfo.lastSeenIP,
isCurrentSessionDisplayMode: session.isCurrent) isCurrentSessionDisplayMode: sessionInfo.isCurrent)
} }
} }

View file

@ -53,58 +53,58 @@ final class UserSessionsFlowCoordinator: Coordinator, Presentable {
coordinator.completion = { [weak self] result in coordinator.completion = { [weak self] result in
guard let self = self else { return } guard let self = self else { return }
switch result { switch result {
case let .openSessionOverview(session: session): case let .openSessionOverview(sessionInfo: sessionInfo):
self.openSessionOverview(session: session) self.openSessionOverview(sessionsInfo: sessionInfo)
case let .openOtherSessions(sessions: sessions, filter: filter): case let .openOtherSessions(sessionsInfo: sessionsInfo, filter: filter):
self.openOtherSessions(sessions: sessions, filterBy: filter, title: VectorL10n.userOtherSessionSecurityRecommendationTitle) self.openOtherSessions(sessionsInfo: sessionsInfo, filterBy: filter, title: VectorL10n.userOtherSessionSecurityRecommendationTitle)
} }
} }
return coordinator return coordinator
} }
private func openSessionDetails(session: UserSessionInfo) { private func openSessionDetails(sessionInfo: UserSessionInfo) {
let coordinator = createUserSessionDetailsCoordinator(session: session) let coordinator = createUserSessionDetailsCoordinator(sessionInfo: sessionInfo)
pushScreen(with: coordinator) pushScreen(with: coordinator)
} }
private func createUserSessionDetailsCoordinator(session: UserSessionInfo) -> UserSessionDetailsCoordinator { private func createUserSessionDetailsCoordinator(sessionInfo: UserSessionInfo) -> UserSessionDetailsCoordinator {
let parameters = UserSessionDetailsCoordinatorParameters(session: session) let parameters = UserSessionDetailsCoordinatorParameters(sessionInfo: sessionInfo)
return UserSessionDetailsCoordinator(parameters: parameters) return UserSessionDetailsCoordinator(parameters: parameters)
} }
private func openSessionOverview(session: UserSessionInfo) { private func openSessionOverview(sessionsInfo: UserSessionInfo) {
let coordinator = createUserSessionOverviewCoordinator(session: session) let coordinator = createUserSessionOverviewCoordinator(sessionsInfo: sessionsInfo)
coordinator.completion = { [weak self] result in coordinator.completion = { [weak self] result in
guard let self = self else { return } guard let self = self else { return }
switch result { switch result {
case let .openSessionDetails(session: session): case let .openSessionDetails(sessionInfo: session):
self.openSessionDetails(session: session) self.openSessionDetails(sessionInfo: session)
} }
} }
pushScreen(with: coordinator) pushScreen(with: coordinator)
} }
private func createUserSessionOverviewCoordinator(session: UserSessionInfo) -> UserSessionOverviewCoordinator { private func createUserSessionOverviewCoordinator(sessionsInfo: UserSessionInfo) -> UserSessionOverviewCoordinator {
let parameters = UserSessionOverviewCoordinatorParameters(session: session) let parameters = UserSessionOverviewCoordinatorParameters(session: parameters.session, sessionInfo: sessionsInfo)
return UserSessionOverviewCoordinator(parameters: parameters) return UserSessionOverviewCoordinator(parameters: parameters)
} }
private func openOtherSessions(sessions: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter, title: String) { private func openOtherSessions(sessionsInfo: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter, title: String) {
let coordinator = createOtherSessionsCoordinator(sessions: sessions, let coordinator = createOtherSessionsCoordinator(sessionsInfo: sessionsInfo,
filterBy: filter, filterBy: filter,
title: title) title: title)
coordinator.completion = { [weak self] result in coordinator.completion = { [weak self] result in
guard let self = self else { return } guard let self = self else { return }
switch result { switch result {
case let .openSessionDetails(session: session): case let .openSessionDetails(sessionInfo: session):
self.openSessionDetails(session: session) self.openSessionDetails(sessionInfo: session)
} }
} }
pushScreen(with: coordinator) pushScreen(with: coordinator)
} }
private func createOtherSessionsCoordinator(sessions: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter, title: String) -> UserOtherSessionsCoordinator { private func createOtherSessionsCoordinator(sessionsInfo: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter, title: String) -> UserOtherSessionsCoordinator {
let parameters = UserOtherSessionsCoordinatorParameters(sessions: sessions, let parameters = UserOtherSessionsCoordinatorParameters(sessionsInfo: sessionsInfo,
filter: filter, filter: filter,
title: title) title: title)
return UserOtherSessionsCoordinator(parameters: parameters) return UserOtherSessionsCoordinator(parameters: parameters)

View file

@ -18,7 +18,7 @@ import CommonKit
import SwiftUI import SwiftUI
struct UserOtherSessionsCoordinatorParameters { struct UserOtherSessionsCoordinatorParameters {
let sessions: [UserSessionInfo] let sessionsInfo: [UserSessionInfo]
let filter: OtherUserSessionsFilter let filter: OtherUserSessionsFilter
let title: String let title: String
} }
@ -38,7 +38,7 @@ final class UserOtherSessionsCoordinator: Coordinator, Presentable {
init(parameters: UserOtherSessionsCoordinatorParameters) { init(parameters: UserOtherSessionsCoordinatorParameters) {
self.parameters = parameters self.parameters = parameters
let viewModel = UserOtherSessionsViewModel(sessions: parameters.sessions, let viewModel = UserOtherSessionsViewModel(sessionsInfo: parameters.sessionsInfo,
filter: parameters.filter, filter: parameters.filter,
title: parameters.title) title: parameters.title)
let view = UserOtherSessions(viewModel: viewModel.context) let view = UserOtherSessions(viewModel: viewModel.context)
@ -55,8 +55,8 @@ final class UserOtherSessionsCoordinator: Coordinator, Presentable {
userOtherSessionsViewModel.completion = { [weak self] result in userOtherSessionsViewModel.completion = { [weak self] result in
guard let self = self else { return } guard let self = self else { return }
switch result { switch result {
case let .showUserSessionOverview(session: session): case let .showUserSessionOverview(sessionInfo: session):
self.completion?(.openSessionDetails(session: session)) self.completion?(.openSessionDetails(sessionInfo: session))
} }
MXLog.debug("[UserOtherSessionsCoordinator] UserOtherSessionsViewModel did complete with result: \(result).") MXLog.debug("[UserOtherSessionsCoordinator] UserOtherSessionsViewModel did complete with result: \(result).")
} }

View file

@ -40,7 +40,7 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
/// Generate the view struct for the screen state. /// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) { var screenView: ([Any], AnyView) {
let viewModel = UserOtherSessionsViewModel(sessions: inactiveSessions(), let viewModel = UserOtherSessionsViewModel(sessionsInfo: inactiveSessions(),
filter: .inactive, filter: .inactive,
title: VectorL10n.userOtherSessionSecurityRecommendationTitle) title: VectorL10n.userOtherSessionSecurityRecommendationTitle)
@ -53,12 +53,19 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
} }
private func inactiveSessions() -> [UserSessionInfo] { private func inactiveSessions() -> [UserSessionInfo] {
[UserSessionInfo(id: "alice", [UserSessionInfo(id: "0",
name: "iOS", name: "iOS",
deviceType: .mobile, deviceType: .mobile,
isVerified: false, isVerified: false,
lastSeenIP: "10.0.0.10", lastSeenIP: "10.0.0.10",
lastSeenTimestamp: nil, lastSeenTimestamp: nil,
applicationName: nil,
applicationVersion: nil,
applicationURL: nil,
deviceModel: nil,
deviceOS: nil,
lastSeenIPLocation: nil,
deviceName: "",
isActive: false, isActive: false,
isCurrent: true), isCurrent: true),
UserSessionInfo(id: "1", UserSessionInfo(id: "1",
@ -67,6 +74,13 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
isVerified: true, isVerified: true,
lastSeenIP: "1.0.0.1", lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 8000000, lastSeenTimestamp: Date().timeIntervalSince1970 - 8000000,
applicationName: nil,
applicationVersion: nil,
applicationURL: nil,
deviceModel: nil,
deviceOS: nil,
lastSeenIPLocation: nil,
deviceName: "",
isActive: false, isActive: false,
isCurrent: false), isCurrent: false),
UserSessionInfo(id: "2", UserSessionInfo(id: "2",
@ -75,6 +89,13 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
isVerified: true, isVerified: true,
lastSeenIP: "2.0.0.2", lastSeenIP: "2.0.0.2",
lastSeenTimestamp: Date().timeIntervalSince1970 - 9000000, lastSeenTimestamp: Date().timeIntervalSince1970 - 9000000,
applicationName: nil,
applicationVersion: nil,
applicationURL: nil,
deviceModel: nil,
deviceOS: nil,
lastSeenIPLocation: nil,
deviceName: "",
isActive: false, isActive: false,
isCurrent: false), isCurrent: false),
UserSessionInfo(id: "3", UserSessionInfo(id: "3",
@ -83,6 +104,13 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
isVerified: false, isVerified: false,
lastSeenIP: "3.0.0.3", lastSeenIP: "3.0.0.3",
lastSeenTimestamp: Date().timeIntervalSince1970 - 10000000, lastSeenTimestamp: Date().timeIntervalSince1970 - 10000000,
applicationName: nil,
applicationVersion: nil,
applicationURL: nil,
deviceModel: nil,
deviceOS: nil,
lastSeenIPLocation: nil,
deviceName: "",
isActive: false, isActive: false,
isCurrent: false)] isCurrent: false)]
} }

View file

@ -18,13 +18,13 @@ import Foundation
// MARK: - Coordinator // MARK: - Coordinator
enum UserOtherSessionsCoordinatorResult { enum UserOtherSessionsCoordinatorResult {
case openSessionDetails(session: UserSessionInfo) case openSessionDetails(sessionInfo: UserSessionInfo)
} }
// MARK: View model // MARK: View model
enum UserOtherSessionsViewModelResult { enum UserOtherSessionsViewModelResult {
case showUserSessionOverview(session: UserSessionInfo) case showUserSessionOverview(sessionInfo: UserSessionInfo)
} }
// MARK: View // MARK: View

View file

@ -20,14 +20,14 @@ typealias UserOtherSessionsViewModelType = StateStoreViewModel<UserOtherSessions
class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessionsViewModelProtocol { class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessionsViewModelProtocol {
var completion: ((UserOtherSessionsViewModelResult) -> Void)? var completion: ((UserOtherSessionsViewModelResult) -> Void)?
private let sessions: [UserSessionInfo] private let sessionsInfo: [UserSessionInfo]
init(sessions: [UserSessionInfo], init(sessionsInfo: [UserSessionInfo],
filter: OtherUserSessionsFilter, filter: OtherUserSessionsFilter,
title: String) { title: String) {
self.sessions = sessions self.sessionsInfo = sessionsInfo
super.init(initialViewState: UserOtherSessionsViewState(title: title, sections: [])) super.init(initialViewState: UserOtherSessionsViewState(title: title, sections: []))
updateViewState(sessions: sessions, filter: filter) updateViewState(sessionsInfo: sessionsInfo, filter: filter)
} }
// MARK: - Public // MARK: - Public
@ -35,30 +35,30 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi
override func process(viewAction: UserOtherSessionsViewAction) { override func process(viewAction: UserOtherSessionsViewAction) {
switch viewAction { switch viewAction {
case let .userOtherSessionSelected(sessionId: sessionId): case let .userOtherSessionSelected(sessionId: sessionId):
guard let session = sessions.first(where: {$0.id == sessionId}) else { guard let session = sessionsInfo.first(where: {$0.id == sessionId}) else {
assertionFailure("Session should exist in the array.") assertionFailure("Session should exist in the array.")
return return
} }
completion?(.showUserSessionOverview(session: session)) completion?(.showUserSessionOverview(sessionInfo: session))
} }
} }
// MARK: - Private // MARK: - Private
private func updateViewState(sessions: [UserSessionInfo], filter: OtherUserSessionsFilter) { private func updateViewState(sessionsInfo: [UserSessionInfo], filter: OtherUserSessionsFilter) {
let sectionItems = filterSessions(sessions: sessions, by: filter).asViewData() let sectionItems = filterSessions(sessionsInfo: sessionsInfo, by: filter).asViewData()
let sectionHeader = createHeaderData(filter: filter) let sectionHeader = createHeaderData(filter: filter)
state.sections = [.sessionItems(header: sectionHeader, items: sectionItems)] state.sections = [.sessionItems(header: sectionHeader, items: sectionItems)]
} }
private func filterSessions(sessions: [UserSessionInfo], by filter: OtherUserSessionsFilter) -> [UserSessionInfo] { private func filterSessions(sessionsInfo: [UserSessionInfo], by filter: OtherUserSessionsFilter) -> [UserSessionInfo] {
switch filter { switch filter {
case .all: case .all:
return sessions.filter { !$0.isCurrent } return sessionsInfo.filter { !$0.isCurrent }
case .inactive: case .inactive:
return sessions.filter { !$0.isActive } return sessionsInfo.filter { !$0.isActive }
case .unverified: case .unverified:
return sessions.filter { !$0.isVerified } return sessionsInfo.filter { !$0.isVerified }
} }
} }

View file

@ -18,7 +18,7 @@ import CommonKit
import SwiftUI import SwiftUI
struct UserSessionDetailsCoordinatorParameters { struct UserSessionDetailsCoordinatorParameters {
let session: UserSessionInfo let sessionInfo: UserSessionInfo
} }
final class UserSessionDetailsCoordinator: Coordinator, Presentable { final class UserSessionDetailsCoordinator: Coordinator, Presentable {
@ -40,7 +40,7 @@ final class UserSessionDetailsCoordinator: Coordinator, Presentable {
init(parameters: UserSessionDetailsCoordinatorParameters) { init(parameters: UserSessionDetailsCoordinatorParameters) {
self.parameters = parameters self.parameters = parameters
let viewModel = UserSessionDetailsViewModel(session: parameters.session) let viewModel = UserSessionDetailsViewModel(session: parameters.sessionInfo)
let view = UserSessionDetails(viewModel: viewModel.context) let view = UserSessionDetails(viewModel: viewModel.context)
userSessionDetailsViewModel = viewModel userSessionDetailsViewModel = viewModel
userSessionDetailsHostingController = VectorHostingController(rootView: view) userSessionDetailsHostingController = VectorHostingController(rootView: view)

View file

@ -41,21 +41,35 @@ enum MockUserSessionDetailsScreenState: MockScreenState, CaseIterable {
let session: UserSessionInfo let session: UserSessionInfo
switch self { switch self {
case .allSections: case .allSections:
session = UserSessionInfo(id: "session", session = UserSessionInfo(id: "alice",
name: "iOS", name: "iOS",
deviceType: .mobile, deviceType: .mobile,
isVerified: false, isVerified: false,
lastSeenIP: "10.0.0.10", lastSeenIP: "10.0.0.10",
lastSeenTimestamp: Date().timeIntervalSince1970 - 100, lastSeenTimestamp: nil,
applicationName: "Element iOS",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "iOS 15.5",
lastSeenIPLocation: nil,
deviceName: "My iPhone",
isActive: true, isActive: true,
isCurrent: true) isCurrent: true)
case .sessionSectionOnly: case .sessionSectionOnly:
session = UserSessionInfo(id: "session", session = UserSessionInfo(id: "3",
name: "iOS", name: "Android",
deviceType: .mobile, deviceType: .mobile,
isVerified: false, isVerified: false,
lastSeenIP: nil, lastSeenIP: "3.0.0.3",
lastSeenTimestamp: Date().timeIntervalSince1970 - 100, lastSeenTimestamp: Date().timeIntervalSince1970 - 10,
applicationName: "Element Android",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "Android 4.0",
lastSeenIPLocation: nil,
deviceName: "My Phone",
isActive: true, isActive: true,
isCurrent: false) isCurrent: false)
} }

View file

@ -20,17 +20,20 @@ import XCTest
class UserSessionDetailsViewModelTests: XCTestCase { class UserSessionDetailsViewModelTests: XCTestCase {
func test_whenSessionNameAndLastSeenIPNil_viewStateCorrect() { func test_whenSessionNameAndLastSeenIPNil_viewStateCorrect() {
let userSessionInfo = createUserSessionInfo(sessionId: "session", let userSessionInfo = createUserSessionInfo(id: "session",
sessionName: nil, name: nil,
lastSeenIP: nil) lastSeenIP: nil)
var sessionItems = [UserSessionDetailsSectionItemViewData]() let sessionItems = [
sessionItems.append(sessionIdItem(sessionId: userSessionInfo.id)) sessionIdItem(sessionId: "session")
]
var sections = [UserSessionDetailsSectionViewData]() let sections = [
sections.append(UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsSessionSectionHeader.uppercased(), UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsSessionSectionHeader.uppercased(),
footer: VectorL10n.userSessionDetailsSessionSectionFooter, footer: VectorL10n.userSessionDetailsSessionSectionFooter,
items: sessionItems)) items: sessionItems)
]
let expectedModel = UserSessionDetailsViewState(sections: sections) let expectedModel = UserSessionDetailsViewState(sections: sections)
let sut = UserSessionDetailsViewModel(session: userSessionInfo) let sut = UserSessionDetailsViewModel(session: userSessionInfo)
@ -38,18 +41,20 @@ class UserSessionDetailsViewModelTests: XCTestCase {
} }
func test_whenSessionNameNotNilLastSeenIPNil_viewStateCorrect() { func test_whenSessionNameNotNilLastSeenIPNil_viewStateCorrect() {
let userSessionInfo = createUserSessionInfo(sessionId: "session", let userSessionInfo = createUserSessionInfo(id: "session",
sessionName: "session name", name: "session name",
lastSeenIP: nil) lastSeenIP: nil)
var sessionItems = [UserSessionDetailsSectionItemViewData]() let sessionItems = [
sessionItems.append(sessionNameItem(sessionName: "session name")) sessionNameItem(sessionName: "session name"),
sessionItems.append(sessionIdItem(sessionId: userSessionInfo.id)) sessionIdItem(sessionId: "session")
]
var sections = [UserSessionDetailsSectionViewData]() let sections = [
sections.append(UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsSessionSectionHeader.uppercased(), UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsSessionSectionHeader.uppercased(),
footer: VectorL10n.userSessionDetailsSessionSectionFooter, footer: VectorL10n.userSessionDetailsSessionSectionFooter,
items: sessionItems)) items: sessionItems)
]
let expectedModel = UserSessionDetailsViewState(sections: sections) let expectedModel = UserSessionDetailsViewState(sections: sections)
let sut = UserSessionDetailsViewModel(session: userSessionInfo) let sut = UserSessionDetailsViewModel(session: userSessionInfo)
@ -58,25 +63,35 @@ class UserSessionDetailsViewModelTests: XCTestCase {
} }
func test_whenUserSessionInfoContainsAllValues_viewStateCorrect() { func test_whenUserSessionInfoContainsAllValues_viewStateCorrect() {
let userSessionInfo = createUserSessionInfo(sessionId: "session", let userSessionInfo = createUserSessionInfo(id: "session",
sessionName: "session name", name: "session name",
lastSeenIP: "0.0.0.0") lastSeenIP: "0.0.0.0",
applicationName: "Element iOS",
applicationVersion: "1.0.0")
var sessionItems = [UserSessionDetailsSectionItemViewData]() let sessionItems = [
sessionItems.append(sessionNameItem(sessionName: "session name")) sessionNameItem(sessionName: "session name"),
sessionItems.append(sessionIdItem(sessionId: userSessionInfo.id)) sessionIdItem(sessionId: "session")
]
let appItems = [
appNameItem(appName: "Element iOS"),
appVersionItem(appVersion: "1.0.0")
]
let deviceItems = [
ipAddressItem(ipAddress: "0.0.0.0")
]
var sections = [UserSessionDetailsSectionViewData]() let sections = [
sections.append(UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsSessionSectionHeader.uppercased(), UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsSessionSectionHeader.uppercased(),
footer: VectorL10n.userSessionDetailsSessionSectionFooter, footer: VectorL10n.userSessionDetailsSessionSectionFooter,
items: sessionItems)) items: sessionItems),
UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsApplicationSectionHeader.uppercased(),
var deviceSectionItems = [UserSessionDetailsSectionItemViewData]()
deviceSectionItems.append(UserSessionDetailsSectionItemViewData(title: VectorL10n.userSessionDetailsDeviceIpAddress,
value: "0.0.0.0"))
sections.append(UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsDeviceSectionHeader.uppercased(),
footer: nil, footer: nil,
items: deviceSectionItems)) items: appItems),
UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsDeviceSectionHeader.uppercased(),
footer: nil,
items: deviceItems)
]
let expectedModel = UserSessionDetailsViewState(sections: sections) let expectedModel = UserSessionDetailsViewState(sections: sections)
let sut = UserSessionDetailsViewModel(session: userSessionInfo) let sut = UserSessionDetailsViewModel(session: userSessionInfo)
@ -84,30 +99,62 @@ class UserSessionDetailsViewModelTests: XCTestCase {
XCTAssertEqual(sut.state, expectedModel) XCTAssertEqual(sut.state, expectedModel)
} }
private func createUserSessionInfo(sessionId: String, // MARK: - Private
sessionName: String?,
private func createUserSessionInfo(id: String,
name: String?,
deviceType: DeviceType = .mobile, deviceType: DeviceType = .mobile,
isVerified: Bool = false, isVerified: Bool = false,
lastSeenIP: String?, lastSeenIP: String?,
lastSeenTimestamp: TimeInterval = Date().timeIntervalSince1970, lastSeenTimestamp: TimeInterval = Date().timeIntervalSince1970,
isCurrentSession: Bool = true) -> UserSessionInfo { applicationName: String? = nil,
UserSessionInfo(id: sessionId, applicationVersion: String? = nil,
name: sessionName, applicationURL: String? = nil,
deviceModel: String? = nil,
deviceOS: String? = nil,
lastSeenIPLocation: String? = nil,
deviceName: String? = nil,
isActive: Bool = true,
isCurrent: Bool = true) -> UserSessionInfo {
UserSessionInfo(id: id,
name: name,
deviceType: deviceType, deviceType: deviceType,
isVerified: isVerified, isVerified: isVerified,
lastSeenIP: lastSeenIP, lastSeenIP: lastSeenIP,
lastSeenTimestamp: lastSeenTimestamp, lastSeenTimestamp: lastSeenTimestamp,
isActive: true, applicationName: applicationName,
isCurrent: isCurrentSession) applicationVersion: applicationVersion,
applicationURL: applicationURL,
deviceModel: deviceModel,
deviceOS: deviceOS,
lastSeenIPLocation: lastSeenIPLocation,
deviceName: deviceName,
isActive: isActive,
isCurrent: isCurrent)
} }
private func sessionNameItem(sessionName: String) -> UserSessionDetailsSectionItemViewData { private func sessionNameItem(sessionName: String) -> UserSessionDetailsSectionItemViewData {
UserSessionDetailsSectionItemViewData(title: VectorL10n.userSessionDetailsSessionName, .init(title: VectorL10n.userSessionDetailsSessionName,
value: sessionName) value: sessionName)
} }
private func sessionIdItem(sessionId: String) -> UserSessionDetailsSectionItemViewData { private func sessionIdItem(sessionId: String) -> UserSessionDetailsSectionItemViewData {
UserSessionDetailsSectionItemViewData(title: VectorL10n.keyVerificationManuallyVerifyDeviceIdTitle, .init(title: VectorL10n.keyVerificationManuallyVerifyDeviceIdTitle,
value: sessionId) value: sessionId)
} }
private func appNameItem(appName: String) -> UserSessionDetailsSectionItemViewData {
.init(title: VectorL10n.userSessionDetailsApplicationName,
value: appName)
}
private func appVersionItem(appVersion: String) -> UserSessionDetailsSectionItemViewData {
.init(title: VectorL10n.userSessionDetailsApplicationVersion,
value: appVersion)
}
private func ipAddressItem(ipAddress: String) -> UserSessionDetailsSectionItemViewData {
.init(title: VectorL10n.userSessionDetailsDeviceIpAddress,
value: ipAddress)
}
} }

View file

@ -35,6 +35,10 @@ class UserSessionDetailsViewModel: UserSessionDetailsViewModelType, UserSessionD
sections.append(sessionSection(session: session)) sections.append(sessionSection(session: session))
if let applicationSection = applicationSection(session: session) {
sections.append(applicationSection)
}
if let deviceSection = deviceSection(session: session) { if let deviceSection = deviceSection(session: session) {
sections.append(deviceSection) sections.append(deviceSection)
} }
@ -43,29 +47,66 @@ class UserSessionDetailsViewModel: UserSessionDetailsViewModelType, UserSessionD
} }
private func sessionSection(session: UserSessionInfo) -> UserSessionDetailsSectionViewData { private func sessionSection(session: UserSessionInfo) -> UserSessionDetailsSectionViewData {
var sessionItems = [UserSessionDetailsSectionItemViewData]() var sessionItems: [UserSessionDetailsSectionItemViewData] = []
if let sessionName = session.name { if let sessionName = session.name {
sessionItems.append(UserSessionDetailsSectionItemViewData(title: VectorL10n.userSessionDetailsSessionName, sessionItems.append(.init(title: VectorL10n.userSessionDetailsSessionName,
value: sessionName)) value: sessionName))
} }
sessionItems.append(UserSessionDetailsSectionItemViewData(title: VectorL10n.keyVerificationManuallyVerifyDeviceIdTitle, sessionItems.append(.init(title: VectorL10n.keyVerificationManuallyVerifyDeviceIdTitle,
value: session.id)) value: session.id))
return UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsSessionSectionHeader.uppercased(), return .init(header: VectorL10n.userSessionDetailsSessionSectionHeader.uppercased(),
footer: VectorL10n.userSessionDetailsSessionSectionFooter, footer: VectorL10n.userSessionDetailsSessionSectionFooter,
items: sessionItems) items: sessionItems)
} }
private func applicationSection(session: UserSessionInfo) -> UserSessionDetailsSectionViewData? {
var sessionItems: [UserSessionDetailsSectionItemViewData] = []
if let name = session.applicationName {
sessionItems.append(.init(title: VectorL10n.userSessionDetailsApplicationName,
value: name))
}
if let version = session.applicationVersion {
sessionItems.append(.init(title: VectorL10n.userSessionDetailsApplicationVersion,
value: version))
}
if let url = session.applicationURL {
sessionItems.append(.init(title: VectorL10n.userSessionDetailsApplicationUrl,
value: url))
}
guard !sessionItems.isEmpty else {
return nil
}
return .init(header: VectorL10n.userSessionDetailsApplicationSectionHeader.uppercased(),
footer: nil,
items: sessionItems)
}
private func deviceSection(session: UserSessionInfo) -> UserSessionDetailsSectionViewData? { private func deviceSection(session: UserSessionInfo) -> UserSessionDetailsSectionViewData? {
var deviceSectionItems = [UserSessionDetailsSectionItemViewData]() var deviceSectionItems = [UserSessionDetailsSectionItemViewData]()
if let model = session.deviceModel {
deviceSectionItems.append(.init(title: VectorL10n.userSessionDetailsDeviceModel,
value: model))
}
if let deviceOS = session.deviceOS {
deviceSectionItems.append(.init(title: VectorL10n.userSessionDetailsDeviceOs,
value: deviceOS))
}
if let lastSeenIP = session.lastSeenIP { if let lastSeenIP = session.lastSeenIP {
deviceSectionItems.append(UserSessionDetailsSectionItemViewData(title: VectorL10n.userSessionDetailsDeviceIpAddress, deviceSectionItems.append(.init(title: VectorL10n.userSessionDetailsDeviceIpAddress,
value: lastSeenIP)) value: lastSeenIP))
} }
if let lastSeenIPLocation = session.lastSeenIPLocation {
deviceSectionItems.append(.init(title: VectorL10n.userSessionDetailsDeviceIpLocation,
value: lastSeenIPLocation))
}
if deviceSectionItems.count > 0 { if deviceSectionItems.count > 0 {
return UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsDeviceSectionHeader.uppercased(), return .init(header: VectorL10n.userSessionDetailsDeviceSectionHeader.uppercased(),
footer: nil, footer: nil,
items: deviceSectionItems) items: deviceSectionItems)
} }

View file

@ -18,7 +18,8 @@ import CommonKit
import SwiftUI import SwiftUI
struct UserSessionOverviewCoordinatorParameters { struct UserSessionOverviewCoordinatorParameters {
let session: UserSessionInfo let session: MXSession
let sessionInfo: UserSessionInfo
} }
final class UserSessionOverviewCoordinator: Coordinator, Presentable { final class UserSessionOverviewCoordinator: Coordinator, Presentable {
@ -40,7 +41,8 @@ final class UserSessionOverviewCoordinator: Coordinator, Presentable {
init(parameters: UserSessionOverviewCoordinatorParameters) { init(parameters: UserSessionOverviewCoordinatorParameters) {
self.parameters = parameters self.parameters = parameters
viewModel = UserSessionOverviewViewModel(session: parameters.session) let service = UserSessionOverviewService(session: parameters.session, sessionInfo: parameters.sessionInfo)
viewModel = UserSessionOverviewViewModel(sessionInfo: parameters.sessionInfo, service: service)
hostingController = VectorHostingController(rootView: UserSessionOverview(viewModel: viewModel.context)) hostingController = VectorHostingController(rootView: UserSessionOverview(viewModel: viewModel.context))
@ -57,8 +59,8 @@ final class UserSessionOverviewCoordinator: Coordinator, Presentable {
switch result { switch result {
case .verifyCurrentSession: case .verifyCurrentSession:
break // TODO: break // TODO:
case let .showSessionDetails(session: session): case let .showSessionDetails(sessionInfo: sessionInfo):
self.completion?(.openSessionDetails(session: session)) self.completion?(.openSessionDetails(sessionInfo: sessionInfo))
} }
} }
} }

View file

@ -25,6 +25,8 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable {
// mock that screen. // mock that screen.
case currentSession case currentSession
case otherSession case otherSession
case sessionWithPushNotifications(enabled: Bool)
case remotelyTogglingPushersNotAvailable
/// The associated screen /// The associated screen
var screenType: Any.Type { var screenType: Any.Type {
@ -33,35 +35,89 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable {
/// A list of screen state definitions /// A list of screen state definitions
static var allCases: [MockUserSessionOverviewScreenState] { static var allCases: [MockUserSessionOverviewScreenState] {
[.currentSession, .otherSession] [.currentSession,
.otherSession,
.sessionWithPushNotifications(enabled: true),
.sessionWithPushNotifications(enabled: false),
.remotelyTogglingPushersNotAvailable]
} }
/// Generate the view struct for the screen state. /// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) { var screenView: ([Any], AnyView) {
let viewModel: UserSessionOverviewViewModel let session: UserSessionInfo
let service: UserSessionOverviewServiceProtocol
switch self { switch self {
case .currentSession: case .currentSession:
let session = UserSessionInfo(id: "session", session = UserSessionInfo(id: "alice",
name: "iOS", name: "iOS",
deviceType: .mobile, deviceType: .mobile,
isVerified: false, isVerified: false,
lastSeenIP: "10.0.0.10", lastSeenIP: "10.0.0.10",
lastSeenTimestamp: Date().timeIntervalSince1970 - 100, lastSeenTimestamp: nil,
applicationName: "Element iOS",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "iOS 15.5",
lastSeenIPLocation: nil,
deviceName: "My iPhone",
isActive: true, isActive: true,
isCurrent: true) isCurrent: true)
viewModel = UserSessionOverviewViewModel(session: session) service = MockUserSessionOverviewService()
case .otherSession: case .otherSession:
let session = UserSessionInfo(id: "session", session = UserSessionInfo(id: "1",
name: "Mac", name: "macOS",
deviceType: .desktop, deviceType: .desktop,
isVerified: true, isVerified: true,
lastSeenIP: "10.0.0.10", lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 100, lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000,
isActive: true, applicationName: "Element MacOS",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "macOS 12.5.1",
lastSeenIPLocation: nil,
deviceName: "My Mac",
isActive: false,
isCurrent: false) isCurrent: false)
viewModel = UserSessionOverviewViewModel(session: session) service = MockUserSessionOverviewService()
case .sessionWithPushNotifications(let enabled):
session = UserSessionInfo(id: "1",
name: "macOS",
deviceType: .desktop,
isVerified: true,
lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000,
applicationName: "Element MacOS",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "macOS 12.5.1",
lastSeenIPLocation: nil,
deviceName: "My Mac",
isActive: false,
isCurrent: false)
service = MockUserSessionOverviewService(pusherEnabled: enabled)
case .remotelyTogglingPushersNotAvailable:
session = UserSessionInfo(id: "1",
name: "macOS",
deviceType: .desktop,
isVerified: true,
lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000,
applicationName: "Element MacOS",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "macOS 12.5.1",
lastSeenIPLocation: nil,
deviceName: "My Mac",
isActive: false,
isCurrent: false)
service = MockUserSessionOverviewService(pusherEnabled: true, remotelyTogglingPushersAvailable: false)
} }
let viewModel = UserSessionOverviewViewModel(sessionInfo: session, service: service)
// can simulate service and viewModel actions here if needs be. // can simulate service and viewModel actions here if needs be.
return ([viewModel], AnyView(UserSessionOverview(viewModel: viewModel.context))) return ([viewModel], AnyView(UserSessionOverview(viewModel: viewModel.context)))
} }

View file

@ -0,0 +1,115 @@
//
// 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.
//
import Combine
import MatrixSDK
class UserSessionOverviewService: UserSessionOverviewServiceProtocol {
// MARK: - Members
private(set) var pusherEnabledSubject: CurrentValueSubject<Bool?, Never>
private(set) var remotelyTogglingPushersAvailableSubject: CurrentValueSubject<Bool, Never>
// MARK: - Private
private let session: MXSession
private let sessionInfo: UserSessionInfo
private var pusher: MXPusher?
// MARK: - Setup
init(session: MXSession, sessionInfo: UserSessionInfo) {
self.session = session
self.sessionInfo = sessionInfo
self.pusherEnabledSubject = CurrentValueSubject(nil)
self.remotelyTogglingPushersAvailableSubject = CurrentValueSubject(false)
checkServerVersions { [weak self] in
self?.checkPusher()
}
}
// MARK: - UserSessionOverviewServiceProtocol
func togglePushNotifications() {
guard let pusher = pusher, let enabled = pusher.enabled?.boolValue, self.remotelyTogglingPushersAvailableSubject.value else {
return
}
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
guard let self = self else { return }
switch response {
case .success:
self.checkPusher()
case .failure(let error):
MXLog.warning("[UserSessionOverviewService] togglePushNotifications failed due to error: \(error)")
self.pusherEnabledSubject.send(enabled)
}
}
}
// MARK: - Private
private func checkServerVersions(_ completion: @escaping () -> Void) {
session.supportedMatrixVersions { [weak self] response in
switch response {
case .success(let versions):
self?.remotelyTogglingPushersAvailableSubject.send(versions.supportsRemotelyTogglingPushNotifications)
case .failure(let error):
MXLog.warning("[UserSessionOverviewService] checkServerVersions failed due to error: \(error)")
}
completion()
}
}
private func checkPusher() {
session.matrixRestClient.pushers { [weak self] response in
switch response {
case .success(let pushers):
self?.check(pushers: pushers)
case .failure(let error):
MXLog.warning("[UserSessionOverviewService] checkPusher failed due to error: \(error)")
}
}
}
private func check(pushers: [MXPusher]) {
for pusher in pushers where pusher.deviceId == sessionInfo.id {
self.pusher = pusher
guard let enabled = pusher.enabled else {
// For backwards compatibility, any pusher without an enabled field should be treated as if enabled is false
pusherEnabledSubject.send(false)
return
}
pusherEnabledSubject.send(enabled.boolValue)
}
}
}

View file

@ -0,0 +1,38 @@
//
// 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.
//
import Combine
import Foundation
class MockUserSessionOverviewService: UserSessionOverviewServiceProtocol {
var pusherEnabledSubject: CurrentValueSubject<Bool?, Never>
var remotelyTogglingPushersAvailableSubject: CurrentValueSubject<Bool, Never>
init(pusherEnabled: Bool? = nil, remotelyTogglingPushersAvailable: Bool = true) {
self.pusherEnabledSubject = CurrentValueSubject(pusherEnabled)
self.remotelyTogglingPushersAvailableSubject = CurrentValueSubject(remotelyTogglingPushersAvailable)
}
func togglePushNotifications() {
guard let enabled = pusherEnabledSubject.value, self.remotelyTogglingPushersAvailableSubject.value else {
return
}
pusherEnabledSubject.send(!enabled)
}
}

View file

@ -0,0 +1,25 @@
//
// 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.
//
import Combine
import Foundation
protocol UserSessionOverviewServiceProtocol {
var pusherEnabledSubject: CurrentValueSubject<Bool?, Never> { get }
var remotelyTogglingPushersAvailableSubject: CurrentValueSubject<Bool, Never> { get }
func togglePushNotifications()
}

View file

@ -34,4 +34,31 @@ class UserSessionOverviewUITests: MockScreenTestCase {
app.goToScreenWithIdentifier(MockUserSessionOverviewScreenState.currentSession.title) app.goToScreenWithIdentifier(MockUserSessionOverviewScreenState.currentSession.title)
XCTAssertTrue(app.buttons[VectorL10n.userSessionOverviewSessionDetailsButtonTitle].exists) XCTAssertTrue(app.buttons[VectorL10n.userSessionOverviewSessionDetailsButtonTitle].exists)
} }
func test_whenSessionOverviewPresented_pusherEnabledToggleExists() {
app.goToScreenWithIdentifier(MockUserSessionOverviewScreenState.sessionWithPushNotifications(enabled: true).title)
XCTAssertTrue(app.switches["UserSessionOverviewToggleCell"].exists)
XCTAssertTrue(app.switches["UserSessionOverviewToggleCell"].isOn)
XCTAssertTrue(app.switches["UserSessionOverviewToggleCell"].isEnabled)
XCTAssertTrue(app.staticTexts[VectorL10n.userSessionPushNotifications].exists)
XCTAssertTrue(app.staticTexts[VectorL10n.userSessionPushNotificationsMessage].exists)
}
func test_whenSessionOverviewPresented_pusherDisabledToggleExists() {
app.goToScreenWithIdentifier(MockUserSessionOverviewScreenState.sessionWithPushNotifications(enabled: false).title)
XCTAssertTrue(app.switches["UserSessionOverviewToggleCell"].exists)
XCTAssertFalse(app.switches["UserSessionOverviewToggleCell"].isOn)
XCTAssertTrue(app.switches["UserSessionOverviewToggleCell"].isEnabled)
XCTAssertTrue(app.staticTexts[VectorL10n.userSessionPushNotifications].exists)
XCTAssertTrue(app.staticTexts[VectorL10n.userSessionPushNotificationsMessage].exists)
}
func test_whenSessionOverviewPresented_pusherEnabledToggleExists_remotelyTogglingPushersAvailable() {
app.goToScreenWithIdentifier(MockUserSessionOverviewScreenState.remotelyTogglingPushersNotAvailable.title)
XCTAssertTrue(app.switches["UserSessionOverviewToggleCell"].exists)
XCTAssertTrue(app.switches["UserSessionOverviewToggleCell"].isOn)
XCTAssertFalse(app.switches["UserSessionOverviewToggleCell"].isEnabled)
XCTAssertTrue(app.staticTexts[VectorL10n.userSessionPushNotifications].exists)
XCTAssertTrue(app.staticTexts[VectorL10n.userSessionPushNotificationsMessage].exists)
}
} }

View file

@ -23,8 +23,9 @@ class UserSessionOverviewViewModelTests: XCTestCase {
var sut: UserSessionOverviewViewModel! var sut: UserSessionOverviewViewModel!
func test_whenVerifyCurrentSessionProcessed_completionWithVerifyCurrentSessionCalled() { func test_whenVerifyCurrentSessionProcessed_completionWithVerifyCurrentSessionCalled() {
sut = UserSessionOverviewViewModel(session: createUserSessionInfo()) sut = UserSessionOverviewViewModel(sessionInfo: createUserSessionInfo(), service: MockUserSessionOverviewService())
XCTAssertEqual(sut.state.isPusherEnabled, nil)
var modelResult: UserSessionOverviewViewModelResult? var modelResult: UserSessionOverviewViewModelResult?
sut.completion = { result in sut.completion = { result in
modelResult = result modelResult = result
@ -34,15 +35,55 @@ class UserSessionOverviewViewModelTests: XCTestCase {
} }
func test_whenViewSessionDetailsProcessed_completionWithShowSessionDetailsCalled() { func test_whenViewSessionDetailsProcessed_completionWithShowSessionDetailsCalled() {
let session = createUserSessionInfo() let sessionInfo = createUserSessionInfo()
sut = UserSessionOverviewViewModel(session: session) sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: MockUserSessionOverviewService())
XCTAssertEqual(sut.state.isPusherEnabled, nil)
var modelResult: UserSessionOverviewViewModelResult? var modelResult: UserSessionOverviewViewModelResult?
sut.completion = { result in sut.completion = { result in
modelResult = result modelResult = result
} }
sut.process(viewAction: .viewSessionDetails) sut.process(viewAction: .viewSessionDetails)
XCTAssertEqual(modelResult, .showSessionDetails(session: session)) XCTAssertEqual(modelResult, .showSessionDetails(sessionInfo: sessionInfo))
}
func test_whenViewSessionDetailsProcessed_toggleAvailablePusher() {
let sessionInfo = createUserSessionInfo()
let service = MockUserSessionOverviewService(pusherEnabled: true)
sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: service)
XCTAssertTrue(sut.state.remotelyTogglingPushersAvailable)
XCTAssertEqual(sut.state.isPusherEnabled, true)
sut.process(viewAction: .togglePushNotifications)
XCTAssertEqual(sut.state.isPusherEnabled, false)
sut.process(viewAction: .togglePushNotifications)
XCTAssertEqual(sut.state.isPusherEnabled, true)
}
func test_whenViewSessionDetailsProcessed_toggleNoPusher() {
let sessionInfo = createUserSessionInfo()
let service = MockUserSessionOverviewService(pusherEnabled: nil)
sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: service)
XCTAssertTrue(sut.state.remotelyTogglingPushersAvailable)
XCTAssertEqual(sut.state.isPusherEnabled, nil)
sut.process(viewAction: .togglePushNotifications)
XCTAssertEqual(sut.state.isPusherEnabled, nil)
sut.process(viewAction: .togglePushNotifications)
XCTAssertEqual(sut.state.isPusherEnabled, nil)
}
func test_whenViewSessionDetailsProcessed_remotelyTogglingPushersNotAvailable() {
let sessionInfo = createUserSessionInfo()
let service = MockUserSessionOverviewService(pusherEnabled: true, remotelyTogglingPushersAvailable: false)
sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: service)
XCTAssertFalse(sut.state.remotelyTogglingPushersAvailable)
XCTAssertEqual(sut.state.isPusherEnabled, true)
sut.process(viewAction: .togglePushNotifications)
XCTAssertEqual(sut.state.isPusherEnabled, true)
sut.process(viewAction: .togglePushNotifications)
XCTAssertEqual(sut.state.isPusherEnabled, true)
} }
private func createUserSessionInfo() -> UserSessionInfo { private func createUserSessionInfo() -> UserSessionInfo {
@ -52,6 +93,13 @@ class UserSessionOverviewViewModelTests: XCTestCase {
isVerified: false, isVerified: false,
lastSeenIP: "10.0.0.10", lastSeenIP: "10.0.0.10",
lastSeenTimestamp: Date().timeIntervalSince1970 - 100, lastSeenTimestamp: Date().timeIntervalSince1970 - 100,
applicationName: "Element",
applicationVersion: "1.9.7",
applicationURL: nil,
deviceModel: "iPhone XS",
deviceOS: "iOS 15.5",
lastSeenIPLocation: nil,
deviceName: "Mobile",
isActive: true, isActive: true,
isCurrent: true) isCurrent: true)
} }

View file

@ -19,13 +19,13 @@ import Foundation
// MARK: - Coordinator // MARK: - Coordinator
enum UserSessionOverviewCoordinatorResult { enum UserSessionOverviewCoordinatorResult {
case openSessionDetails(session: UserSessionInfo) case openSessionDetails(sessionInfo: UserSessionInfo)
} }
// MARK: View model // MARK: View model
enum UserSessionOverviewViewModelResult: Equatable { enum UserSessionOverviewViewModelResult: Equatable {
case showSessionDetails(session: UserSessionInfo) case showSessionDetails(sessionInfo: UserSessionInfo)
case verifyCurrentSession case verifyCurrentSession
} }
@ -34,9 +34,13 @@ enum UserSessionOverviewViewModelResult: Equatable {
struct UserSessionOverviewViewState: BindableState { struct UserSessionOverviewViewState: BindableState {
let cardViewData: UserSessionCardViewData let cardViewData: UserSessionCardViewData
let isCurrentSession: Bool let isCurrentSession: Bool
var isPusherEnabled: Bool?
var remotelyTogglingPushersAvailable: Bool
var showLoadingIndicator: Bool
} }
enum UserSessionOverviewViewAction { enum UserSessionOverviewViewAction {
case verifyCurrentSession case verifyCurrentSession
case viewSessionDetails case viewSessionDetails
case togglePushNotifications
} }

View file

@ -19,16 +19,43 @@ import SwiftUI
typealias UserSessionOverviewViewModelType = StateStoreViewModel<UserSessionOverviewViewState, UserSessionOverviewViewAction> typealias UserSessionOverviewViewModelType = StateStoreViewModel<UserSessionOverviewViewState, UserSessionOverviewViewAction>
class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessionOverviewViewModelProtocol { class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessionOverviewViewModelProtocol {
private let session: UserSessionInfo private let sessionInfo: UserSessionInfo
private let service: UserSessionOverviewServiceProtocol
var completion: ((UserSessionOverviewViewModelResult) -> Void)? var completion: ((UserSessionOverviewViewModelResult) -> Void)?
init(session: UserSessionInfo) { // MARK: - Setup
self.session = session
let cardViewData = UserSessionCardViewData(session: session) init(sessionInfo: UserSessionInfo, service: UserSessionOverviewServiceProtocol) {
let state = UserSessionOverviewViewState(cardViewData: cardViewData, isCurrentSession: session.isCurrent) self.sessionInfo = sessionInfo
self.service = service
let cardViewData = UserSessionCardViewData(sessionInfo: sessionInfo)
let state = UserSessionOverviewViewState(cardViewData: cardViewData,
isCurrentSession: sessionInfo.isCurrent,
isPusherEnabled: service.pusherEnabledSubject.value,
remotelyTogglingPushersAvailable: service.remotelyTogglingPushersAvailableSubject.value,
showLoadingIndicator: false)
super.init(initialViewState: state) super.init(initialViewState: state)
startObservingService()
}
private func startObservingService() {
service
.pusherEnabledSubject
.sink(receiveValue: { [weak self] pushEnabled in
self?.state.showLoadingIndicator = false
self?.state.isPusherEnabled = pushEnabled
})
.store(in: &cancellables)
service
.remotelyTogglingPushersAvailableSubject
.sink(receiveValue: { [weak self] remotelyTogglingPushersAvailable in
self?.state.remotelyTogglingPushersAvailable = remotelyTogglingPushersAvailable
})
.store(in: &cancellables)
} }
// MARK: - Public // MARK: - Public
@ -38,7 +65,10 @@ class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessio
case .verifyCurrentSession: case .verifyCurrentSession:
completion?(.verifyCurrentSession) completion?(.verifyCurrentSession)
case .viewSessionDetails: case .viewSessionDetails:
completion?(.showSessionDetails(session: session)) completion?(.showSessionDetails(sessionInfo: sessionInfo))
case .togglePushNotifications:
self.state.showLoadingIndicator = true
service.togglePushNotifications()
} }
} }
} }

View file

@ -34,10 +34,18 @@ struct UserSessionOverview: View {
UserSessionOverviewDisclosureCell(title: VectorL10n.userSessionOverviewSessionDetailsButtonTitle, onBackgroundTap: { UserSessionOverviewDisclosureCell(title: VectorL10n.userSessionOverviewSessionDetailsButtonTitle, onBackgroundTap: {
viewModel.send(viewAction: .viewSessionDetails) viewModel.send(viewAction: .viewSessionDetails)
}) })
if let enabled = viewModel.viewState.isPusherEnabled {
UserSessionOverviewToggleCell(title: VectorL10n.userSessionPushNotifications,
message: VectorL10n.userSessionPushNotificationsMessage,
isOn: enabled, isEnabled: viewModel.viewState.remotelyTogglingPushersAvailable) {
viewModel.send(viewAction: .togglePushNotifications)
}
}
} }
} }
.background(theme.colors.system.ignoresSafeArea()) .background(theme.colors.system.ignoresSafeArea())
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
.waitOverlay(show: viewModel.viewState.showLoadingIndicator, allowUserInteraction: false)
.navigationTitle(viewModel.viewState.isCurrentSession ? .navigationTitle(viewModel.viewState.isCurrentSession ?
VectorL10n.userSessionOverviewCurrentSessionTitle : VectorL10n.userSessionOverviewCurrentSessionTitle :
VectorL10n.userSessionOverviewSessionTitle) VectorL10n.userSessionOverviewSessionTitle)

View file

@ -33,7 +33,7 @@ struct UserSessionOverviewDisclosureCell: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
Image(Asset.Images.chevron.name) Image(Asset.Images.chevron.name)
} }
.padding(.vertical, 12) .padding(.vertical, 15)
.padding(.horizontal, 16) .padding(.horizontal, 16)
SeparatorLine() SeparatorLine()
} }

View file

@ -0,0 +1,86 @@
//
// 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.
//
import SwiftUI
struct UserSessionOverviewToggleCell: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
let title: String
let message: String?
let isOn: Bool
let isEnabled: Bool
var onBackgroundTap: (() -> Void)?
var body: some View {
VStack(spacing: 8) {
Button(action: {
guard isEnabled else { return }
onBackgroundTap?()
}) {
VStack(spacing: 0) {
SeparatorLine()
Toggle(isOn: .constant(isOn)) {
Text(title)
.font(theme.fonts.body)
.foregroundColor(theme.colors.primaryContent)
.opacity(isEnabled ? 1 : 0.3)
}
.disabled(!isEnabled)
.allowsHitTesting(false)
.padding(.vertical, 10)
.padding(.horizontal, 16)
.accessibilityIdentifier("UserSessionOverviewToggleCell")
SeparatorLine()
}
.background(theme.colors.background)
}
.disabled(!isEnabled)
if let message = message {
Text(message)
.multilineTextAlignment(.leading)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
}
}
}
}
struct UserSessionOverviewToggleCell_Previews: PreviewProvider {
static var previews: some View {
Group {
preview
.theme(.light)
.preferredColorScheme(.light)
preview
.theme(.dark)
.preferredColorScheme(.dark)
}
}
static var preview: some View {
VStack {
UserSessionOverviewToggleCell(title: "Title", message: nil, isOn: true, isEnabled: true)
UserSessionOverviewToggleCell(title: "Title", message: nil, isOn: false, isEnabled: true)
UserSessionOverviewToggleCell(title: "Title", message: "some very long message text in order to test the multine alignment", isOn: true, isEnabled: true)
UserSessionOverviewToggleCell(title: "Title", message: nil, isOn: true, isEnabled: false)
UserSessionOverviewToggleCell(title: "Title", message: nil, isOn: false, isEnabled: false)
UserSessionOverviewToggleCell(title: "Title", message: "some very long message text in order to test the multine alignment", isOn: true, isEnabled: false)
}
}
}

View file

@ -37,7 +37,8 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
init(parameters: UserSessionsOverviewCoordinatorParameters) { init(parameters: UserSessionsOverviewCoordinatorParameters) {
self.parameters = parameters self.parameters = parameters
service = UserSessionsOverviewService(mxSession: parameters.session) let dataProvider = UserSessionsDataProvider(session: parameters.session)
service = UserSessionsOverviewService(dataProvider: dataProvider)
viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: service) viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: service)
hostingViewController = VectorHostingController(rootView: UserSessionsOverview(viewModel: viewModel.context)) hostingViewController = VectorHostingController(rootView: UserSessionsOverview(viewModel: viewModel.context))
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: hostingViewController) indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: hostingViewController)
@ -52,14 +53,14 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
MXLog.debug("[UserSessionsOverviewCoordinator] UserSessionsOverviewViewModel did complete with result: \(result).") MXLog.debug("[UserSessionsOverviewCoordinator] UserSessionsOverviewViewModel did complete with result: \(result).")
switch result { switch result {
case let .showOtherSessions(sessions: sessions, filter: filter): case let .showOtherSessions(sessionsInfo: sessionsInfo, filter: filter):
self.showOtherSessions(sessions: sessions, filterBy: filter) self.showOtherSessions(sessionsInfo: sessionsInfo, filterBy: filter)
case .verifyCurrentSession: case .verifyCurrentSession:
self.startVerifyCurrentSession() self.startVerifyCurrentSession()
case let .showCurrentSessionOverview(session): case let .showCurrentSessionOverview(sessionInfo):
self.showCurrentSessionOverview(session: session) self.showCurrentSessionOverview(sessionInfo: sessionInfo)
case let .showUserSessionOverview(session): case let .showUserSessionOverview(sessionInfo):
self.showUserSessionOverview(session: session) self.showUserSessionOverview(sessionInfo: sessionInfo)
} }
} }
} }
@ -83,20 +84,20 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
loadingIndicator = nil loadingIndicator = nil
} }
private func showOtherSessions(sessions: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter) { private func showOtherSessions(sessionsInfo: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter) {
completion?(.openOtherSessions(sessions: sessions, filter: filter)) completion?(.openOtherSessions(sessionsInfo: sessionsInfo, filter: filter))
} }
private func startVerifyCurrentSession() { private func startVerifyCurrentSession() {
// TODO: // TODO:
} }
private func showCurrentSessionOverview(session: UserSessionInfo) { private func showCurrentSessionOverview(sessionInfo: UserSessionInfo) {
completion?(.openSessionOverview(session: session)) completion?(.openSessionOverview(sessionInfo: sessionInfo))
} }
private func showUserSessionOverview(session: UserSessionInfo) { private func showUserSessionOverview(sessionInfo: UserSessionInfo) {
completion?(.openSessionOverview(session: session)) completion?(.openSessionOverview(sessionInfo: sessionInfo))
} }
} }

View file

@ -20,31 +20,39 @@ import SwiftUI
/// Using an enum for the screen allows you define the different state cases with /// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case. /// the relevant associated data for each case.
enum MockUserSessionsOverviewScreenState: MockScreenState, CaseIterable { enum MockUserSessionsOverviewScreenState: MockScreenState, CaseIterable {
case verifiedSession case currentSessionUnverified
case currentSessionVerified
case onlyUnverifiedSessions
case onlyInactiveSessions
case noOtherSessions
/// The associated screen /// The associated screen
var screenType: Any.Type { var screenType: Any.Type {
UserSessionsOverview.self UserSessionsOverview.self
} }
/// A list of screen state definitions
static var allCases: [MockUserSessionsOverviewScreenState] {
// Each of the presence statuses
[.verifiedSession]
}
/// Generate the view struct for the screen state. /// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) { var screenView: ([Any], AnyView) {
let service = MockUserSessionsOverviewService() var service: UserSessionsOverviewServiceProtocol?
switch self { switch self {
case .verifiedSession: case .currentSessionUnverified:
break service = MockUserSessionsOverviewService(mode: .currentSessionUnverified)
case .currentSessionVerified:
service = MockUserSessionsOverviewService(mode: .currentSessionVerified)
case .onlyUnverifiedSessions:
service = MockUserSessionsOverviewService(mode: .onlyUnverifiedSessions)
case .onlyInactiveSessions:
service = MockUserSessionsOverviewService(mode: .onlyInactiveSessions)
case .noOtherSessions:
service = MockUserSessionsOverviewService(mode: .noOtherSessions)
}
guard let service = service else {
fatalError()
} }
let viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: service) let viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: service)
// can simulate service and viewModel actions here if needs be.
return ( return (
[service, viewModel], [service, viewModel],
AnyView(UserSessionsOverview(viewModel: viewModel.context) AnyView(UserSessionsOverview(viewModel: viewModel.context)

View file

@ -0,0 +1,50 @@
//
// 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.
//
import Foundation
import MatrixSDK
class UserSessionsDataProvider: UserSessionsDataProviderProtocol {
private let session: MXSession
init(session: MXSession) {
self.session = session
}
var myDeviceId: String {
session.myDeviceId
}
var myUserId: String? {
session.myUserId
}
var activeAccounts: [MXKAccount] {
MXKAccountManager.shared().activeAccounts
}
func devices(completion: @escaping (MXResponse<[MXDevice]>) -> Void) {
session.matrixRestClient.devices(completion: completion)
}
func device(withDeviceId deviceId: String, ofUser userId: String) -> MXDeviceInfo? {
session.crypto.device(withDeviceId: deviceId, ofUser: userId)
}
func accountData(for eventType: String) -> [AnyHashable : Any]? {
session.accountData.accountData(forEventType: eventType)
}
}

View file

@ -0,0 +1,32 @@
//
// 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.
//
import Foundation
import MatrixSDK
protocol UserSessionsDataProviderProtocol {
var myDeviceId: String { get }
var myUserId: String? { get }
var activeAccounts: [MXKAccount] { get }
func devices(completion: @escaping (MXResponse<[MXDevice]>) -> Void)
func device(withDeviceId deviceId: String, ofUser userId: String) -> MXDeviceInfo?
func accountData(for eventType: String) -> [AnyHashable: Any]?
}

View file

@ -21,12 +21,12 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
/// Delay after which session is considered inactive, 90 days /// Delay after which session is considered inactive, 90 days
private static let inactiveSessionDurationTreshold: TimeInterval = 90 * 86400 private static let inactiveSessionDurationTreshold: TimeInterval = 90 * 86400
private let mxSession: MXSession private let dataProvider: UserSessionsDataProviderProtocol
private(set) var overviewData: UserSessionsOverviewData private(set) var overviewData: UserSessionsOverviewData
init(mxSession: MXSession) { init(dataProvider: UserSessionsDataProviderProtocol) {
self.mxSession = mxSession self.dataProvider = dataProvider
overviewData = UserSessionsOverviewData(currentSession: nil, overviewData = UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [], unverifiedSessions: [],
@ -39,7 +39,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
// MARK: - Public // MARK: - Public
func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) { func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) {
mxSession.matrixRestClient.devices { response in dataProvider.devices { response in
switch response { switch response {
case .success(let devices): case .success(let devices):
self.overviewData = self.sessionsOverviewData(from: devices) self.overviewData = self.sessionsOverviewData(from: devices)
@ -61,16 +61,18 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
// MARK: - Private // MARK: - Private
private func setupInitialOverviewData() { private func setupInitialOverviewData() {
let currentSessionInfo = currentSessionInfo() guard let currentSessionInfo = getCurrentSessionInfo() else {
return
}
overviewData = UserSessionsOverviewData(currentSession: currentSessionInfo, overviewData = UserSessionsOverviewData(currentSession: currentSessionInfo,
unverifiedSessions: [], unverifiedSessions: currentSessionInfo.isVerified ? [] : [currentSessionInfo],
inactiveSessions: [], inactiveSessions: currentSessionInfo.isActive ? [] : [currentSessionInfo],
otherSessions: []) otherSessions: [])
} }
private func currentSessionInfo() -> UserSessionInfo? { private func getCurrentSessionInfo() -> UserSessionInfo? {
guard let mainAccount = MXKAccountManager.shared().activeAccounts.first, guard let mainAccount = dataProvider.activeAccounts.first,
let device = mainAccount.device else { let device = mainAccount.device else {
return nil return nil
} }
@ -80,7 +82,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
private func sessionsOverviewData(from devices: [MXDevice]) -> UserSessionsOverviewData { private func sessionsOverviewData(from devices: [MXDevice]) -> UserSessionsOverviewData {
let allSessions = devices let allSessions = devices
.sorted { $0.lastSeenTs > $1.lastSeenTs } .sorted { $0.lastSeenTs > $1.lastSeenTs }
.map { sessionInfo(from: $0, isCurrentSession: $0.deviceId == mxSession.myDeviceId) } .map { sessionInfo(from: $0, isCurrentSession: $0.deviceId == dataProvider.myDeviceId) }
return UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first, return UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first,
unverifiedSessions: allSessions.filter { !$0.isVerified }, unverifiedSessions: allSessions.filter { !$0.isVerified },
@ -91,32 +93,58 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
private func sessionInfo(from device: MXDevice, isCurrentSession: Bool) -> UserSessionInfo { private func sessionInfo(from device: MXDevice, isCurrentSession: Bool) -> UserSessionInfo {
let isSessionVerified = deviceInfo(for: device.deviceId)?.trustLevel.isVerified ?? false let isSessionVerified = deviceInfo(for: device.deviceId)?.trustLevel.isVerified ?? false
var lastSeenTs: TimeInterval? let eventType = kMXAccountDataTypeClientInformation + "." + device.deviceId
if device.lastSeenTs > 0 { let appData = dataProvider.accountData(for: eventType)
lastSeenTs = TimeInterval(device.lastSeenTs / 1000) var userAgent: UserAgent?
var isSessionActive = true
if let lastSeenUserAgent = device.lastSeenUserAgent {
userAgent = UserAgentParser.parse(lastSeenUserAgent)
} }
var isSessionActive = true if device.lastSeenTs > 0 {
if let lastSeenTimestamp = lastSeenTs { let elapsedTime = Date().timeIntervalSince1970 - TimeInterval(device.lastSeenTs / 1000)
let elapsedTime = Date().timeIntervalSince1970 - lastSeenTimestamp
isSessionActive = elapsedTime < Self.inactiveSessionDurationTreshold isSessionActive = elapsedTime < Self.inactiveSessionDurationTreshold
} }
return UserSessionInfo(id: device.deviceId, return UserSessionInfo(withDevice: device,
name: device.displayName, applicationData: appData as? [String: String],
deviceType: .unknown, userAgent: userAgent,
isVerified: isSessionVerified, isSessionVerified: isSessionVerified,
lastSeenIP: device.lastSeenIp,
lastSeenTimestamp: lastSeenTs,
isActive: isSessionActive, isActive: isSessionActive,
isCurrent: isCurrentSession) isCurrent: isCurrentSession)
} }
private func deviceInfo(for deviceId: String) -> MXDeviceInfo? { private func deviceInfo(for deviceId: String) -> MXDeviceInfo? {
guard let userId = mxSession.myUserId else { guard let userId = dataProvider.myUserId else {
return nil return nil
} }
return mxSession.crypto.device(withDeviceId: deviceId, ofUser: userId) return dataProvider.device(withDeviceId: deviceId, ofUser: userId)
}
}
extension UserSessionInfo {
init(withDevice device: MXDevice,
applicationData: [String: String]?,
userAgent: UserAgent?,
isSessionVerified: Bool,
isActive: Bool,
isCurrent: Bool) {
self.init(id: device.deviceId,
name: device.displayName,
deviceType: userAgent?.deviceType ?? .unknown,
isVerified: isSessionVerified,
lastSeenIP: device.lastSeenIp,
lastSeenTimestamp: device.lastSeenTs > 0 ? TimeInterval(device.lastSeenTs / 1000) : nil,
applicationName: applicationData?["name"],
applicationVersion: applicationData?["version"],
applicationURL: applicationData?["url"],
deviceModel: userAgent?.deviceModel,
deviceOS: userAgent?.deviceOS,
lastSeenIPLocation: nil,
deviceName: userAgent?.clientName,
isActive: isActive,
isCurrent: isCurrent)
} }
} }

View file

@ -17,55 +17,128 @@
import Foundation import Foundation
class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
enum Mode {
case currentSessionUnverified
case currentSessionVerified
case onlyUnverifiedSessions
case onlyInactiveSessions
case noOtherSessions
}
private let mode: Mode
var overviewData: UserSessionsOverviewData var overviewData: UserSessionsOverviewData
init(mode: Mode = .currentSessionUnverified) {
self.mode = mode
overviewData = UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [])
}
func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) { func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) {
let unverifiedSessions = buildSessions(verified: false, active: true)
let inactiveSessions = buildSessions(verified: true, active: false)
switch mode {
case .noOtherSessions:
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [])
case .onlyUnverifiedSessions:
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: unverifiedSessions + [currentSession],
inactiveSessions: [],
otherSessions: unverifiedSessions)
case .onlyInactiveSessions:
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: [],
inactiveSessions: inactiveSessions,
otherSessions: inactiveSessions)
default:
let otherSessions = unverifiedSessions + inactiveSessions + buildSessions(verified: true, active: true)
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: unverifiedSessions,
inactiveSessions: inactiveSessions,
otherSessions: otherSessions)
}
completion(.success(overviewData)) completion(.success(overviewData))
} }
func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo? { func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo? {
nil overviewData.otherSessions.first { $0.id == sessionId }
} }
init() { // MARK: - Private
overviewData = UserSessionsOverviewData(currentSession: Self.allSessions.filter(\.isCurrent).first,
unverifiedSessions: Self.allSessions.filter { !$0.isVerified },
inactiveSessions: Self.allSessions.filter { !$0.isActive },
otherSessions: Self.allSessions.filter { !$0.isCurrent })
}
static var allSessions: [UserSessionInfo] = { private var currentSession: UserSessionInfo {
[UserSessionInfo(id: "alice", UserSessionInfo(id: "alice",
name: "iOS", name: "iOS",
deviceType: .mobile, deviceType: .mobile,
isVerified: false, isVerified: mode == .currentSessionVerified,
lastSeenIP: "10.0.0.10", lastSeenIP: "10.0.0.10",
lastSeenTimestamp: nil, lastSeenTimestamp: nil,
applicationName: "Element iOS",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "iOS 15.5",
lastSeenIPLocation: nil,
deviceName: "My iPhone",
isActive: true, isActive: true,
isCurrent: true), isCurrent: true)
UserSessionInfo(id: "1", }
name: "macOS",
private func buildSessions(verified: Bool, active: Bool) -> [UserSessionInfo] {
[UserSessionInfo(id: "1 verified: \(verified) active: \(active)",
name: "macOS verified: \(verified) active: \(active)",
deviceType: .desktop, deviceType: .desktop,
isVerified: true, isVerified: verified,
lastSeenIP: "1.0.0.1", lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 8000000, lastSeenTimestamp: Date().timeIntervalSince1970 - 8000000,
isActive: false, applicationName: "Element MacOS",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "macOS 12.5.1",
lastSeenIPLocation: nil,
deviceName: "My Mac",
isActive: active,
isCurrent: false), isCurrent: false),
UserSessionInfo(id: "2", UserSessionInfo(id: "2 verified: \(verified) active: \(active)",
name: "Firefox on Windows", name: "Firefox on Windows verified: \(verified) active: \(active)",
deviceType: .web, deviceType: .web,
isVerified: true, isVerified: verified,
lastSeenIP: "2.0.0.2", lastSeenIP: "2.0.0.2",
lastSeenTimestamp: Date().timeIntervalSince1970 - 100, lastSeenTimestamp: Date().timeIntervalSince1970 - 100,
isActive: true, applicationName: "Element Web",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "Windows 10",
lastSeenIPLocation: nil,
deviceName: "My Windows",
isActive: active,
isCurrent: false), isCurrent: false),
UserSessionInfo(id: "3", UserSessionInfo(id: "3 verified: \(verified) active: \(active)",
name: "Android", name: "Android verified: \(verified) active: \(active)",
deviceType: .mobile, deviceType: .mobile,
isVerified: false, isVerified: verified,
lastSeenIP: "3.0.0.3", lastSeenIP: "3.0.0.3",
lastSeenTimestamp: Date().timeIntervalSince1970 - 10, lastSeenTimestamp: Date().timeIntervalSince1970 - 10,
isActive: true, applicationName: "Element Android",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "Android 4.0",
lastSeenIPLocation: nil,
deviceName: "My Phone",
isActive: active,
isCurrent: false)] isCurrent: false)]
}() }
} }

View file

@ -18,5 +18,38 @@ import RiotSwiftUI
import XCTest import XCTest
class UserSessionsOverviewUITests: MockScreenTestCase { class UserSessionsOverviewUITests: MockScreenTestCase {
// TODO: func testCurrentSessionUnverified() {
app.goToScreenWithIdentifier(MockUserSessionsOverviewScreenState.currentSessionUnverified.title)
XCTAssertTrue(app.buttons["userSessionCardVerifyButton"].exists)
XCTAssertTrue(app.staticTexts["userSessionCardViewDetails"].exists)
}
func testCurrentSessionVerified() {
app.goToScreenWithIdentifier(MockUserSessionsOverviewScreenState.currentSessionVerified.title)
XCTAssertFalse(app.buttons["userSessionCardVerifyButton"].exists)
XCTAssertTrue(app.staticTexts["userSessionCardViewDetails"].exists)
}
func testOnlyUnverifiedSessions() {
app.goToScreenWithIdentifier(MockUserSessionsOverviewScreenState.onlyUnverifiedSessions.title)
XCTAssertTrue(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists)
XCTAssertTrue(app.staticTexts["userSessionsOverviewOtherSection"].exists)
}
func testOnlyInactiveSessions() {
app.goToScreenWithIdentifier(MockUserSessionsOverviewScreenState.onlyInactiveSessions.title)
XCTAssertTrue(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists)
XCTAssertTrue(app.staticTexts["userSessionsOverviewOtherSection"].exists)
}
func testNoOtherSessions() {
app.goToScreenWithIdentifier(MockUserSessionsOverviewScreenState.noOtherSessions.title)
XCTAssertFalse(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists)
XCTAssertFalse(app.staticTexts["userSessionsOverviewOtherSection"].exists)
}
} }

View file

@ -20,13 +20,65 @@ import XCTest
@testable import RiotSwiftUI @testable import RiotSwiftUI
class UserSessionsOverviewViewModelTests: XCTestCase { class UserSessionsOverviewViewModelTests: XCTestCase {
var service: MockUserSessionsOverviewService! func testInitialStateEmpty() {
var viewModel: UserSessionsOverviewViewModelProtocol! let viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: MockUserSessionsOverviewService())
var context: UserSessionsOverviewViewModelType.Context!
override func setUpWithError() throws { XCTAssertNil(viewModel.state.currentSessionViewData)
service = MockUserSessionsOverviewService() XCTAssertTrue(viewModel.state.unverifiedSessionsViewData.isEmpty)
viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: service) XCTAssertTrue(viewModel.state.inactiveSessionsViewData.isEmpty)
context = viewModel.context XCTAssertTrue(viewModel.state.otherSessionsViewData.isEmpty)
}
func testLoadOnDidAppear() {
let viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: MockUserSessionsOverviewService())
viewModel.process(viewAction: .viewAppeared)
XCTAssertNotNil(viewModel.state.currentSessionViewData)
XCTAssertFalse(viewModel.state.unverifiedSessionsViewData.isEmpty)
XCTAssertFalse(viewModel.state.inactiveSessionsViewData.isEmpty)
XCTAssertFalse(viewModel.state.otherSessionsViewData.isEmpty)
}
func testSimpleActionProcessing() {
let viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: MockUserSessionsOverviewService())
var result: UserSessionsOverviewViewModelResult?
viewModel.completion = { action in
result = action
}
viewModel.process(viewAction: .verifyCurrentSession)
XCTAssertEqual(result, .verifyCurrentSession)
viewModel.process(viewAction: .viewAllInactiveSessions)
XCTAssertEqual(result, .showOtherSessions(sessionsInfo: [], filter: .inactive))
}
func testShowSessionDetails() {
let service = MockUserSessionsOverviewService()
service.updateOverviewData { _ in }
let viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: service)
var result: UserSessionsOverviewViewModelResult?
viewModel.completion = { action in
result = action
}
guard let currentSession = service.overviewData.currentSession else {
XCTFail("The current session should be valid at this point")
return
}
viewModel.process(viewAction: .viewCurrentSessionDetails)
XCTAssertEqual(result, .showCurrentSessionOverview(sessionInfo: currentSession))
guard let randomSession = service.overviewData.otherSessions.randomElement() else {
XCTFail("There should be other sessions")
return
}
viewModel.process(viewAction: .tapUserSession(randomSession.id))
XCTAssertEqual(result, .showUserSessionOverview(sessionInfo: randomSession))
} }
} }

View file

@ -19,17 +19,17 @@ import Foundation
// MARK: - Coordinator // MARK: - Coordinator
enum UserSessionsOverviewCoordinatorResult { enum UserSessionsOverviewCoordinatorResult {
case openSessionOverview(session: UserSessionInfo) case openSessionOverview(sessionInfo: UserSessionInfo)
case openOtherSessions(sessions: [UserSessionInfo], filter: OtherUserSessionsFilter) case openOtherSessions(sessionsInfo: [UserSessionInfo], filter: OtherUserSessionsFilter)
} }
// MARK: View model // MARK: View model
enum UserSessionsOverviewViewModelResult { enum UserSessionsOverviewViewModelResult: Equatable {
case showOtherSessions(sessions: [UserSessionInfo], filter: OtherUserSessionsFilter) case showOtherSessions(sessionsInfo: [UserSessionInfo], filter: OtherUserSessionsFilter)
case verifyCurrentSession case verifyCurrentSession
case showCurrentSessionOverview(session: UserSessionInfo) case showCurrentSessionOverview(sessionInfo: UserSessionInfo)
case showUserSessionOverview(session: UserSessionInfo) case showUserSessionOverview(sessionInfo: UserSessionInfo)
} }
// MARK: View // MARK: View

View file

@ -44,19 +44,21 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
assertionFailure("Missing current session") assertionFailure("Missing current session")
return return
} }
completion?(.showCurrentSessionOverview(session: currentSessionInfo)) completion?(.showCurrentSessionOverview(sessionInfo: currentSessionInfo))
case .viewAllUnverifiedSessions: case .viewAllUnverifiedSessions:
showSessions(filteredBy: .unverified) // TODO: showSessions(filteredBy: .unverified)
break
case .viewAllInactiveSessions: case .viewAllInactiveSessions:
showSessions(filteredBy: .inactive) showSessions(filteredBy: .inactive)
case .viewAllOtherSessions: case .viewAllOtherSessions:
showSessions(filteredBy: .all) // TODO: showSessions(filteredBy: .all)
break
case .tapUserSession(let sessionId): case .tapUserSession(let sessionId):
guard let session = userSessionsOverviewService.sessionForIdentifier(sessionId) else { guard let session = userSessionsOverviewService.sessionForIdentifier(sessionId) else {
assertionFailure("Missing session info") assertionFailure("Missing session info")
return return
} }
completion?(.showUserSessionOverview(session: session)) completion?(.showUserSessionOverview(sessionInfo: session))
} }
} }
@ -68,7 +70,7 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
state.otherSessionsViewData = userSessionsViewData.otherSessions.asViewData() state.otherSessionsViewData = userSessionsViewData.otherSessions.asViewData()
if let currentSessionInfo = userSessionsViewData.currentSession { if let currentSessionInfo = userSessionsViewData.currentSession {
state.currentSessionViewData = UserSessionCardViewData(session: currentSessionInfo) state.currentSessionViewData = UserSessionCardViewData(sessionInfo: currentSessionInfo)
} }
} }
@ -93,7 +95,7 @@ class UserSessionsOverviewViewModel: UserSessionsOverviewViewModelType, UserSess
} }
private func showSessions(filteredBy filter: OtherUserSessionsFilter) { private func showSessions(filteredBy filter: OtherUserSessionsFilter) {
completion?(.showOtherSessions(sessions: userSessionsOverviewService.overviewData.otherSessions, completion?(.showOtherSessions(sessionsInfo: userSessionsOverviewService.overviewData.otherSessions,
filter: filter)) filter: filter))
} }
} }

View file

@ -18,6 +18,7 @@ import Foundation
/// View data for UserSessionListItem /// View data for UserSessionListItem
struct UserSessionListItemViewData: Identifiable, Hashable { struct UserSessionListItemViewData: Identifiable, Hashable {
var id: String { var id: String {
sessionId sessionId
} }

View file

@ -18,12 +18,8 @@ import Foundation
struct UserSessionListItemViewDataFactory { struct UserSessionListItemViewDataFactory {
private static let userSessionNameFormatter = UserSessionNameFormatter()
private static let lastActivityDateFormatter = UserSessionLastActivityFormatter()
private static let inactiveSessionDateFormatter = InactiveUserSessionLastActivityFormatter()
func create(from session: UserSessionInfo) -> UserSessionListItemViewData { func create(from session: UserSessionInfo) -> UserSessionListItemViewData {
let sessionName = UserSessionListItemViewDataFactory.userSessionNameFormatter.sessionName(deviceType: session.deviceType, let sessionName = UserSessionNameFormatter.sessionName(deviceType: session.deviceType,
sessionDisplayName: session.name) sessionDisplayName: session.name)
let sessionDetails = buildSessionDetails(isVerified: session.isVerified, let sessionDetails = buildSessionDetails(isVerified: session.isVerified,
lastActivityDate: session.lastSeenTimestamp, lastActivityDate: session.lastSeenTimestamp,
@ -47,7 +43,7 @@ struct UserSessionListItemViewDataFactory {
private func inactiveSessionDetails(lastActivityDate: TimeInterval?) -> String { private func inactiveSessionDetails(lastActivityDate: TimeInterval?) -> String {
if let lastActivityDate = lastActivityDate { if let lastActivityDate = lastActivityDate {
let lastActivityDateString = Self.inactiveSessionDateFormatter.lastActivityDateString(from: lastActivityDate) let lastActivityDateString = InactiveUserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate)
return VectorL10n.userInactiveSessionItemWithDate(lastActivityDateString) return VectorL10n.userInactiveSessionItemWithDate(lastActivityDateString)
} }
return VectorL10n.userInactiveSessionItem return VectorL10n.userInactiveSessionItem
@ -61,7 +57,7 @@ struct UserSessionListItemViewDataFactory {
var lastActivityDateString: String? var lastActivityDateString: String?
if let lastActivityDate = lastActivityDate { if let lastActivityDate = lastActivityDate {
lastActivityDateString = Self.lastActivityDateFormatter.lastActivityDateString(from: lastActivityDate) lastActivityDateString = UserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate)
} }
if let lastActivityDateString = lastActivityDateString, lastActivityDateString.isEmpty == false { if let lastActivityDateString = lastActivityDateString, lastActivityDateString.isEmpty == false {

View file

@ -23,11 +23,13 @@ struct UserSessionsOverview: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
if hasSecurityRecommendations {
securityRecommendationsSection securityRecommendationsSection
}
currentSessionsSection currentSessionsSection
if viewModel.viewState.otherSessionsViewData.isEmpty == false { if !viewModel.viewState.otherSessionsViewData.isEmpty {
otherSessionsSection otherSessionsSection
} }
} }
@ -40,9 +42,7 @@ struct UserSessionsOverview: View {
} }
} }
@ViewBuilder
private var securityRecommendationsSection: some View { private var securityRecommendationsSection: some View {
if hasSecurityRecommendations {
SwiftUI.Section { SwiftUI.Section {
if !viewModel.viewState.unverifiedSessionsViewData.isEmpty { if !viewModel.viewState.unverifiedSessionsViewData.isEmpty {
SecurityRecommendationCard(style: .unverified, SecurityRecommendationCard(style: .unverified,
@ -74,7 +74,7 @@ struct UserSessionsOverview: View {
.padding(.top, 24) .padding(.top, 24)
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
} .accessibilityIdentifier("userSessionsOverviewSecurityRecommendationsSection")
} }
var hasSecurityRecommendations: Bool { var hasSecurityRecommendations: Bool {
@ -105,7 +105,6 @@ struct UserSessionsOverview: View {
private var otherSessionsSection: some View { private var otherSessionsSection: some View {
SwiftUI.Section { SwiftUI.Section {
// Device list
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
ForEach(viewModel.viewState.otherSessionsViewData) { viewData in ForEach(viewModel.viewState.otherSessionsViewData) { viewData in
UserSessionListItem(viewData: viewData, onBackgroundTap: { sessionId in UserSessionListItem(viewData: viewData, onBackgroundTap: { sessionId in
@ -131,6 +130,7 @@ struct UserSessionsOverview: View {
.padding(.horizontal, 16.0) .padding(.horizontal, 16.0)
.padding(.top, 24.0) .padding(.top, 24.0)
} }
.accessibilityIdentifier("userSessionsOverviewOtherSection")
} }
} }

View file

@ -24,6 +24,9 @@ schemes:
disableMainThreadChecker: true disableMainThreadChecker: true
targets: targets:
- RiotSwiftUnitTests - RiotSwiftUnitTests
gatherCoverageData: true
coverageTargets:
- RiotSwiftUI
targets: targets:
RiotSwiftUI: RiotSwiftUI:

View file

@ -24,6 +24,9 @@ schemes:
disableMainThreadChecker: true disableMainThreadChecker: true
targets: targets:
- RiotSwiftUITests - RiotSwiftUITests
gatherCoverageData: true
coverageTargets:
- RiotSwiftUI
targets: targets:
RiotSwiftUITests: RiotSwiftUITests:

View file

@ -24,6 +24,9 @@ schemes:
disableMainThreadChecker: true disableMainThreadChecker: true
targets: targets:
- RiotSwiftUnitTests - RiotSwiftUnitTests
gatherCoverageData: true
coverageTargets:
- RiotSwiftUI
targets: targets:
RiotSwiftUnitTests: RiotSwiftUnitTests:

View 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.
//
import XCTest
@testable import Element
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: "Electron",
deviceOS: "Macintosh",
clientName: nil,
clientVersion: nil),
UserAgent(deviceType: .desktop,
deviceModel: "Electron",
deviceOS: "Windows NT 10.0",
clientName: nil,
clientVersion: nil)
]
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: "Chrome",
deviceOS: "Macintosh",
clientName: nil,
clientVersion: nil),
UserAgent(deviceType: .web,
deviceModel: "Chrome",
deviceOS: "Windows NT 10.0",
clientName: nil,
clientVersion: nil),
UserAgent(deviceType: .web,
deviceModel: "Firefox",
deviceOS: "Macintosh",
clientName: nil,
clientVersion: nil),
UserAgent(deviceType: .web,
deviceModel: "Safari",
deviceOS: "Macintosh",
clientName: nil,
clientVersion: nil),
UserAgent(deviceType: .web,
deviceModel: "Chrome",
deviceOS: "Android 9",
clientName: nil,
clientVersion: nil)
]
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",
"Element/1.9.9; iOS "
]
let userAgents = uaStrings.map { UserAgentParser.parse($0) }
let expected = [
.unknown,
.unknown,
.unknown,
UserAgent(deviceType: .mobile,
deviceModel: nil,
deviceOS: nil,
clientName: "Element",
clientVersion: "1.9.9;")
]
XCTAssertEqual(userAgents, expected)
}
}

View file

@ -0,0 +1,267 @@
//
// 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.
//
import XCTest
@testable import Element
@testable import MatrixSDK
private let currentDeviceId = "deviceId"
private let unverifiedDeviceId = "unverifiedDeviceId"
private let currentUserId = "userId"
class UserSessionsOverviewServiceTests: XCTestCase {
func testInitialSessionUnverified() {
let dataProvider = MockUserSessionsDataProvider(mode: .currentSessionUnverified)
let service = UserSessionsOverviewService(dataProvider: dataProvider)
XCTAssertNotNil(service.overviewData.currentSession)
XCTAssertFalse(service.overviewData.currentSession?.isVerified ?? false)
XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false)
XCTAssertFalse(service.overviewData.unverifiedSessions.isEmpty)
XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty)
XCTAssertEqual(service.sessionForIdentifier(currentDeviceId), service.overviewData.currentSession)
}
func testInitialSessionVerified() {
let dataProvider = MockUserSessionsDataProvider(mode: .currentSessionVerified)
let service = UserSessionsOverviewService(dataProvider: dataProvider)
XCTAssertNotNil(service.overviewData.currentSession)
XCTAssertTrue(service.overviewData.currentSession?.isVerified ?? false)
XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false)
XCTAssertTrue(service.overviewData.unverifiedSessions.isEmpty)
XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty)
}
func testWithAllSessionsVerified() {
let service = setupServiceWithMode(.allOtherSessionsValid)
XCTAssertNotNil(service.overviewData.currentSession)
XCTAssertTrue(service.overviewData.currentSession?.isVerified ?? false)
XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false)
XCTAssertTrue(service.overviewData.unverifiedSessions.isEmpty)
XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty)
XCTAssertFalse(service.overviewData.otherSessions.isEmpty)
}
func testWithSomeUnverifiedSessions() {
let service = setupServiceWithMode(.someUnverifiedSessions)
XCTAssertNotNil(service.overviewData.currentSession)
XCTAssertTrue(service.overviewData.currentSession?.isVerified ?? false)
XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false)
XCTAssertFalse(service.overviewData.unverifiedSessions.isEmpty)
XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty)
XCTAssertFalse(service.overviewData.otherSessions.isEmpty)
}
func testWithSomeInactiveSessions() {
let service = setupServiceWithMode(.someInactiveSessions)
XCTAssertNotNil(service.overviewData.currentSession)
XCTAssertTrue(service.overviewData.currentSession?.isVerified ?? false)
XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false)
XCTAssertTrue(service.overviewData.unverifiedSessions.isEmpty)
XCTAssertFalse(service.overviewData.inactiveSessions.isEmpty)
XCTAssertFalse(service.overviewData.otherSessions.isEmpty)
}
func testWithSomeUnverifiedAndInactiveSessions() {
let service = setupServiceWithMode(.someUnverifiedAndInactiveSessions)
XCTAssertNotNil(service.overviewData.currentSession)
XCTAssertTrue(service.overviewData.currentSession?.isVerified ?? false)
XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false)
XCTAssertFalse(service.overviewData.unverifiedSessions.isEmpty)
XCTAssertFalse(service.overviewData.inactiveSessions.isEmpty)
XCTAssertFalse(service.overviewData.otherSessions.isEmpty)
}
// MARK: - Private
private func setupServiceWithMode(_ mode: MockUserSessionsDataProvider.Mode) -> UserSessionsOverviewServiceProtocol {
let dataProvider = MockUserSessionsDataProvider(mode: mode)
let service = UserSessionsOverviewService(dataProvider: dataProvider)
let expectation = expectation(description: "Wait for service update")
service.updateOverviewData { _ in
expectation.fulfill()
}
waitForExpectations(timeout: 1.0)
return service
}
}
private class MockUserSessionsDataProvider: UserSessionsDataProviderProtocol {
enum Mode {
case currentSessionUnverified
case currentSessionVerified
case allOtherSessionsValid
case someUnverifiedSessions
case someInactiveSessions
case someUnverifiedAndInactiveSessions
}
private let mode: Mode
var myDeviceId = currentDeviceId
var myUserId: String? = currentUserId
var activeAccounts: [MXKAccount] {
[MockAccount()]
}
init(mode: Mode) {
self.mode = mode
}
func devices(completion: @escaping (MXResponse<[MXDevice]>) -> Void) {
DispatchQueue.main.async {
switch self.mode {
case .currentSessionUnverified:
return
case .currentSessionVerified:
return
case .allOtherSessionsValid:
completion(.success(self.verifiedSessions))
case .someUnverifiedSessions:
completion(.success(self.verifiedSessions + self.unverifiedSessions))
case .someInactiveSessions:
completion(.success(self.verifiedSessions + self.inactiveSessions))
case .someUnverifiedAndInactiveSessions:
completion(.success(self.verifiedSessions + self.unverifiedSessions + self.inactiveSessions))
}
}
}
func device(withDeviceId deviceId: String, ofUser userId: String) -> MXDeviceInfo? {
guard deviceId == currentDeviceId else {
return MockDeviceInfo(verified: deviceId != unverifiedDeviceId)
}
switch mode {
case .currentSessionUnverified:
return MockDeviceInfo(verified: false)
default:
return MockDeviceInfo(verified: true)
}
}
func accountData(for eventType: String) -> [AnyHashable : Any]? {
[:]
}
// MARK: - Private
var verifiedSessions: [MXDevice] {
[MockDevice(identifier: currentDeviceId, sessionActive: true),
MockDevice(identifier: UUID().uuidString, sessionActive: true)]
}
var unverifiedSessions: [MXDevice] {
[MockDevice(identifier: unverifiedDeviceId, sessionActive: true)]
}
var inactiveSessions: [MXDevice] {
[MockDevice(identifier: UUID().uuidString, sessionActive: false)]
}
}
private class MockAccount: MXKAccount {
override var device: MXDevice? {
MockDevice(identifier: currentDeviceId, sessionActive: true)
}
}
private class MockDevice: MXDevice {
private let identifier: String
private let sessionActive: Bool
init(identifier: String, sessionActive: Bool) {
self.identifier = identifier
self.sessionActive = sessionActive
super.init()
}
required init?(coder: NSCoder) {
fatalError()
}
override var deviceId: String {
get {
identifier
}
set {
}
}
override var lastSeenTs: UInt64 {
get {
if sessionActive {
return UInt64(Date().timeIntervalSince1970 * 1000)
} else {
let ninetyDays: Double = 90 * 86400
return UInt64((Date().timeIntervalSince1970 - ninetyDays) * 1000)
}
}
set {
}
}
}
private class MockDeviceInfo: MXDeviceInfo {
private let verified: Bool
init(verified: Bool) {
self.verified = verified
super.init()
}
required init?(coder: NSCoder) {
fatalError()
}
override var trustLevel: MXDeviceTrustLevel! {
MockDeviceTrustLevel(verified: verified)
}
}
private class MockDeviceTrustLevel: MXDeviceTrustLevel {
private let verified: Bool
init(verified: Bool) {
self.verified = verified
super.init()
}
required init?(coder: NSCoder) {
fatalError()
}
override var isVerified: Bool {
verified
}
}

View file

@ -24,6 +24,9 @@ schemes:
disableMainThreadChecker: true disableMainThreadChecker: true
targets: targets:
- RiotTests - RiotTests
gatherCoverageData: true
coverageTargets:
- Riot
targets: targets:
RiotTests: RiotTests:

1
changelog.d/6580.bugfix Normal file
View file

@ -0,0 +1 @@
Voiceover: Add labels to User Menu and My Spaces buttons on the All Chats view.

1
changelog.d/6755.bugfix Normal file
View file

@ -0,0 +1 @@
App Layout: Unable to send message after filtering for room

1
changelog.d/6778.bugfix Normal file
View file

@ -0,0 +1 @@
Fix code block background colour

1
changelog.d/6781.change Normal file
View file

@ -0,0 +1 @@
CryptoV2: Manual device verification

1
changelog.d/6787.change Normal file
View file

@ -0,0 +1 @@
User sessions: Add support for MSC3881

View file

@ -0,0 +1 @@
UserSessions: Extended device information (PSG-772).

View file

@ -0,0 +1 @@
Settings: Add labs flags for new session manager (PSG-792, PSG-799).