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)
🙌 Improvements

View file

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

View file

@ -419,9 +419,5 @@ final class BuildSettings: NSObject {
static let syncLocalContacts: Bool = false
// MARK: - New App Layout
static let newAppLayoutEnabled = true
// MARK: - Device manager
static let deviceManagerEnabled = false
static let newAppLayoutEnabled = true
}

View file

@ -89,6 +89,8 @@ class CommonConfiguration: NSObject, Configurable {
sdkOptions.authEnableRefreshTokens = BuildSettings.authEnableRefreshTokens
// Configure key provider delegate
MXKeyProvider.sharedInstance().delegate = EncryptionKeyManager.shared
sdkOptions.enableNewClientInformationFeature = RiotSettings.shared.enableClientInformationFeature
}
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
#
# 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 = { :branch => 'develop'}
# $matrixSDKVersion = { :specHash => { git: 'https://git.io/fork123', branch: 'fix' } }

View file

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

View file

@ -1,6 +1,6 @@
{
"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_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_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_version" = "Version %@";
@ -2193,6 +2195,7 @@ Tap the + to start adding people.";
"room_recents_recently_viewed_section" = "Recently viewed";
"all_chats_user_menu_accessibility_label" = "User menu";
"all_chats_user_menu_settings" = "User settings";
"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_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";
// First item is client name and second item is session display 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_session_section_header" = "Session";
"user_session_details_application_section_header" = "Application";
"user_session_details_device_section_header" = "Device";
"user_session_details_session_name" = "Session name";
"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_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_session_title" = "Session";
"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_block" = "Blacklist";
"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_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";

View file

@ -116,6 +116,7 @@ internal class Asset: NSObject {
internal static let e2eWarning = ImageAsset(name: "e2e_warning")
internal static let encryptionNormal = ImageAsset(name: "encryption_normal")
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 favouritesEmptyScreenArtwork = ImageAsset(name: "favourites_empty_screen_artwork")
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 {
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
public static var allChatsUserMenuSettings: String {
return VectorL10n.tr("Vector", "all_chats_user_menu_settings")
@ -5623,6 +5627,10 @@ public class VectorL10n: NSObject {
public static var roomEventEncryptionInfoEventUserId: String {
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
public static var roomEventEncryptionInfoTitle: String {
return VectorL10n.tr("Vector", "room_event_encryption_info_title")
@ -7375,6 +7383,14 @@ public class VectorL10n: NSObject {
public static var settingsLabsEnableNewAppLayout: String {
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
public static var settingsLabsEnableRingingForGroupCalls: String {
return VectorL10n.tr("Vector", "settings_labs_enable_ringing_for_group_calls")
@ -8479,10 +8495,38 @@ public class VectorL10n: NSObject {
public static var userOtherSessionSecurityRecommendationTitle: String {
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
public static var userSessionDetailsDeviceIpAddress: String {
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
public static var userSessionDetailsDeviceSectionHeader: String {
return VectorL10n.tr("Vector", "user_session_details_device_section_header")
@ -8531,6 +8575,14 @@ public class VectorL10n: NSObject {
public static var userSessionOverviewSessionTitle: String {
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
public static var userSessionUnverified: String {
return VectorL10n.tr("Vector", "user_session_unverified")

View file

@ -496,12 +496,12 @@ class CallPresenter: NSObject {
#if canImport(JitsiMeetSDK)
JMCallKitProxy.removeListener(self)
guard let session = sessions.first else {
guard let sessionInfo = sessions.first else {
return
}
if let widgetEventsListener = widgetEventsListener {
session.removeListener(widgetEventsListener)
sessionInfo.removeListener(widgetEventsListener)
}
widgetEventsListener = nil
#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

@ -163,7 +163,15 @@ final class RiotSettings: NSObject {
NotificationCenter.default.post(name: RiotSettings.didUpdateLiveLocationSharingActivation, object: self)
}
}
/// 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
/// Indicate if `allowStunServerFallback` settings has been set once.

View file

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

View file

@ -75,6 +75,23 @@ class AllChatsViewController: HomeViewController {
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
// References on the currently selected room
@ -91,6 +108,8 @@ class AllChatsViewController: HomeViewController {
// Tell whether the onboarding screen is preparing.
private(set) var isOnboardingInProgress: Bool = false
private var toolbarHeight: CGFloat = 0
// MARK: - Lifecycle
@ -107,6 +126,8 @@ class AllChatsViewController: HomeViewController {
recentsTableView.register(RecentsInvitesTableViewCell.nib, forCellReuseIdentifier: RecentsInvitesTableViewCell.reuseIdentifier)
recentsTableView.contentInsetAdjustmentBehavior = .automatic
toolbarHeight = toolbar.frame.height
updateUI()
navigationItem.largeTitleDisplayMode = .automatic
@ -122,8 +143,7 @@ class AllChatsViewController: HomeViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.navigationController?.isToolbarHidden = false
self.navigationController?.toolbar.tintColor = ThemeService.shared().theme.colors.accent
self.toolbar.tintColor = ThemeService.shared().theme.colors.accent
if self.navigationItem.searchController == nil {
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) {
super.viewWillTransition(to: size, with: coordinator)
@ -372,8 +386,8 @@ class AllChatsViewController: HomeViewController {
let scrollPosition = scrollPosition(of: scrollView)
if !self.recentsTableView.isDragging && scrollPosition == 0 && self.navigationController?.isToolbarHidden == true {
self.navigationController?.setToolbarHidden(false, animated: true)
if !self.recentsTableView.isDragging && scrollPosition == 0 && self.isToolbarHidden == true {
self.setToolbarHidden(false, animated: true)
}
guard self.recentsTableView.isDragging else {
@ -385,8 +399,8 @@ class AllChatsViewController: HomeViewController {
}
let isToolBarHidden: Bool = scrollPosition - initialScrollPosition > 0
if isToolBarHidden != self.navigationController?.isToolbarHidden {
self.navigationController?.setToolbarHidden(isToolBarHidden, animated: true)
if isToolBarHidden != self.isToolbarHidden {
self.setToolbarHidden(isToolBarHidden, animated: true)
}
}
@ -494,13 +508,21 @@ class AllChatsViewController: HomeViewController {
}
private func updateToolbar(with menu: UIMenu) {
self.navigationController?.isToolbarHidden = false
guard isViewLoaded else {
return
}
self.isToolbarHidden = false
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(image: Asset.Images.allChatsEditIcon.image, menu: menu)
], animated: true)
]
}
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">
<connections>
<outlet property="recentsTableView" destination="orV-HH-88x" id="lgA-2k-pXJ"/>
<outlet property="stickyHeadersBottomContainer" destination="EXH-mK-0eB" id="95Y-KP-bwF"/>
<outlet property="stickyHeadersBottomContainerHeightConstraint" destination="SNq-Js-N7s" id="vom-iM-s6W"/>
<outlet property="toolbar" destination="osO-1z-CZg" id="xRm-cJ-bNi"/>
<outlet property="view" destination="iN0-l3-epB" id="NUQ-LI-M61"/>
</connections>
</placeholder>
@ -25,25 +24,24 @@
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/>
</tableView>
<view clipsSubviews="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="EXH-mK-0eB">
<rect key="frame" x="0.0" y="667" width="375" height="0.0"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/>
<accessibility key="accessibilityConfiguration" identifier="RecentsVCStickyHeadersBottomContainer"/>
<toolbar opaque="NO" clearsContextBeforeDrawing="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="osO-1z-CZg">
<rect key="frame" x="0.0" y="623" width="375" height="44"/>
<constraints>
<constraint firstAttribute="height" id="SNq-Js-N7s"/>
<constraint firstAttribute="height" constant="44" id="7k8-Ot-vpH"/>
</constraints>
</view>
<items/>
</toolbar>
</subviews>
<viewLayoutGuide key="safeArea" id="4qf-KS-Fc9"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<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="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="osO-1z-CZg" firstAttribute="trailing" secondItem="4qf-KS-Fc9" secondAttribute="trailing" id="T2o-XM-BgN"/>
<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="4qf-KS-Fc9" firstAttribute="bottom" secondItem="orV-HH-88x" secondAttribute="bottom" id="zCp-Ju-dvr"/>
</constraints>
<point key="canvasLocation" x="140" y="137.18140929535232"/>
</view>

View file

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

View file

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

View file

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

View file

@ -18,6 +18,7 @@
#import "MXKEventFormatter.h"
#import "MXKURLPreviewDataProtocol.h"
#import "EventEncryptionDecoration.h"
@protocol MXThreadProtocol;
@ -101,9 +102,9 @@ typedef enum : NSUInteger {
@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.

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];
@ -116,7 +116,7 @@
andLatestRoomState:latestRoomState
error:&error];
_showEncryptionBadge = [self shouldShowWarningBadgeForEvent:event roomState:roomState session:session];
_encryptionDecoration = [self encryptionDecorationForEvent:event roomState:roomState session:session];
[self updateLinkWithRoomState:roomState];
}
@ -167,24 +167,24 @@
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
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.
if (event.eventType != MXEventTypeRoomMessage && !event.isMediaAttachment)
{
return NO;
return EventEncryptionDecorationNone;
}
// Always show a warning badge if there was a decryption error.
if (event.decryptionError)
{
return YES;
return EventEncryptionDecorationDecryptionError;
}
// Unencrypted message events should show a warning unless they're pending local echoes
@ -193,10 +193,10 @@
if (event.isLocalEvent
|| 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.
@ -208,12 +208,17 @@
if (userTrustLevel.isVerified && !deviceInfo.trustLevel.isVerified)
{
return YES;
return EventEncryptionDecorationUntrustedDevice;
}
}
if (event.isUntrusted)
{
return EventEncryptionDecorationUnsafeKey;
}
// Everything was fine
return NO;
return EventEncryptionDecorationNone;
}
@end

View file

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

View file

@ -32,7 +32,7 @@
#define TABLEVIEW_ROW_CELL_HEIGHT 46
#define TABLEVIEW_SECTION_HEADER_HEIGHT 28
@interface RoomMemberDetailsViewController () <UIGestureRecognizerDelegate, DeviceTableViewCellDelegate, RoomMemberTitleViewDelegate, KeyVerificationCoordinatorBridgePresenterDelegate>
@interface RoomMemberDetailsViewController () <UIGestureRecognizerDelegate, DeviceTableViewCellDelegate, RoomMemberTitleViewDelegate, KeyVerificationCoordinatorBridgePresenterDelegate, UserVerificationCoordinatorBridgePresenterDelegate>
{
RoomMemberTitleView* memberTitleView;
@ -449,6 +449,7 @@
session:self.mxRoom.mxSession
userId:self.mxRoomMember.userId
userDisplayName:self.mxRoomMember.displayname];
userVerificationCoordinatorBridgePresenter.delegate = self;
[userVerificationCoordinatorBridgePresenter start];
self.userVerificationCoordinatorBridgePresenter = userVerificationCoordinatorBridgePresenter;
}
@ -1345,4 +1346,11 @@
keyVerificationCoordinatorBridgePresenter = nil;
}
#pragma mark - UserVerificationCoordinatorBridgePresenterDelegate
- (void)userVerificationCoordinatorBridgePresenterDelegateDidComplete:(UserVerificationCoordinatorBridgePresenter *)coordinatorBridgePresenter
{
[self refreshUserEncryptionTrustLevel];
}
@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
{
if (!bubbleComponent.showEncryptionBadge)
{
return nil;
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 AssetImages.encryptionWarning.image;
}
+ (void)addEncryptionStatusFromBubbleData:(MXKRoomBubbleCellData *)bubbleData inContainerView:(UIView *)containerView

View file

@ -71,7 +71,15 @@ final class PinCodePreferences: NSObject {
}
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

View file

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

View file

@ -172,7 +172,9 @@ typedef NS_ENUM(NSUInteger, LABS_ENABLE)
LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX = 0,
LABS_ENABLE_THREADS_INDEX,
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)
@ -403,7 +405,7 @@ ChangePasswordCoordinatorBridgePresenterDelegate>
Section *sectionSecurity = [Section sectionWithTag:SECTION_TAG_SECURITY];
[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
[sectionSecurity addRowWithTag:DEVICE_MANAGER_INDEX];
@ -595,6 +597,8 @@ ChangePasswordCoordinatorBridgePresenterDelegate>
{
[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];
if (sectionLabs.hasAnyRows)
{
@ -2532,6 +2536,30 @@ ChangePasswordCoordinatorBridgePresenterDelegate>
{
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)
{
@ -3277,6 +3305,19 @@ ChangePasswordCoordinatorBridgePresenterDelegate>
[[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
{
RiotSettings.shared.pinRoomsWithMissedNotificationsOnHome = sender.isOn;

View file

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

View file

@ -24,6 +24,9 @@ schemes:
disableMainThreadChecker: true
targets:
- RiotTests
gatherCoverageData: true
coverageTargets:
- Riot
targets:
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
}()
func lastActivityDateString(from lastActivityTimestamp: TimeInterval) -> String {
static func lastActivityDateString(from lastActivityTimestamp: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: lastActivityTimestamp)
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

@ -35,16 +35,41 @@ struct UserSessionInfo: Identifiable {
/// Last time the session was active
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
let isActive: Bool
/// True to indicate that this is current user session
let isCurrent: Bool
}
extension UserSessionInfo: Equatable {
static func == (lhs: UserSessionInfo, rhs: UserSessionInfo) -> Bool {
return lhs.id == rhs.id
lhs.id == rhs.id
}
}

View file

@ -17,7 +17,7 @@
import Foundation
/// Enables to build last activity date string
class UserSessionLastActivityFormatter {
enum UserSessionLastActivityFormatter {
private static var lastActivityDateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale.current
@ -28,9 +28,9 @@ class UserSessionLastActivityFormatter {
}()
/// Session last activity string
func lastActivityDateString(from lastActivityTimestamp: TimeInterval) -> String {
static func lastActivityDateString(from lastActivityTimestamp: TimeInterval) -> String {
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
/// Enables to build user session name
class UserSessionNameFormatter {
enum UserSessionNameFormatter {
/// 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 clientName = deviceType.name

View file

@ -109,12 +109,14 @@ struct UserSessionCardView: View {
.buttonStyle(PrimaryActionButtonStyle())
.padding(.top, 4)
.padding(.bottom, 3)
.accessibilityIdentifier("userSessionCardVerifyButton")
}
if viewData.isCurrentSessionDisplayMode {
Text(VectorL10n.userSessionViewDetails)
.font(theme.fonts.body)
.foregroundColor(theme.colors.accent)
.accessibilityIdentifier("userSessionCardViewDetails")
}
}
.padding(24)
@ -134,18 +136,24 @@ struct UserSessionCardViewPreview: View {
@Environment(\.theme) var theme: ThemeSwiftUI
let viewData: UserSessionCardViewData
init(isCurrentSessionInfo: Bool = false) {
let session = UserSessionInfo(id: "alice",
init(isCurrent: Bool = false) {
let sessionInfo = UserSessionInfo(id: "alice",
name: "iOS",
deviceType: .mobile,
isVerified: false,
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,
isCurrent: isCurrentSessionInfo)
viewData = UserSessionCardViewData(session: session)
isCurrent: isCurrent)
viewData = UserSessionCardViewData(sessionInfo: sessionInfo)
}
var body: some View {
@ -161,8 +169,8 @@ struct UserSessionCardViewPreview: View {
struct UserSessionCardView_Previews: PreviewProvider {
static var previews: some View {
Group {
UserSessionCardViewPreview(isCurrentSessionInfo: true).theme(.light).preferredColorScheme(.light)
UserSessionCardViewPreview(isCurrentSessionInfo: true).theme(.dark).preferredColorScheme(.dark)
UserSessionCardViewPreview(isCurrent: true).theme(.light).preferredColorScheme(.light)
UserSessionCardViewPreview(isCurrent: true).theme(.dark).preferredColorScheme(.dark)
UserSessionCardViewPreview().theme(.light).preferredColorScheme(.light)
UserSessionCardViewPreview().theme(.dark).preferredColorScheme(.dark)
}

View file

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

View file

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

View file

@ -18,7 +18,7 @@ import CommonKit
import SwiftUI
struct UserOtherSessionsCoordinatorParameters {
let sessions: [UserSessionInfo]
let sessionsInfo: [UserSessionInfo]
let filter: OtherUserSessionsFilter
let title: String
}
@ -38,7 +38,7 @@ final class UserOtherSessionsCoordinator: Coordinator, Presentable {
init(parameters: UserOtherSessionsCoordinatorParameters) {
self.parameters = parameters
let viewModel = UserOtherSessionsViewModel(sessions: parameters.sessions,
let viewModel = UserOtherSessionsViewModel(sessionsInfo: parameters.sessionsInfo,
filter: parameters.filter,
title: parameters.title)
let view = UserOtherSessions(viewModel: viewModel.context)
@ -55,8 +55,8 @@ final class UserOtherSessionsCoordinator: Coordinator, Presentable {
userOtherSessionsViewModel.completion = { [weak self] result in
guard let self = self else { return }
switch result {
case let .showUserSessionOverview(session: session):
self.completion?(.openSessionDetails(session: session))
case let .showUserSessionOverview(sessionInfo: session):
self.completion?(.openSessionDetails(sessionInfo: session))
}
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.
var screenView: ([Any], AnyView) {
let viewModel = UserOtherSessionsViewModel(sessions: inactiveSessions(),
let viewModel = UserOtherSessionsViewModel(sessionsInfo: inactiveSessions(),
filter: .inactive,
title: VectorL10n.userOtherSessionSecurityRecommendationTitle)
@ -53,12 +53,19 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
}
private func inactiveSessions() -> [UserSessionInfo] {
[UserSessionInfo(id: "alice",
[UserSessionInfo(id: "0",
name: "iOS",
deviceType: .mobile,
isVerified: false,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: nil,
applicationName: nil,
applicationVersion: nil,
applicationURL: nil,
deviceModel: nil,
deviceOS: nil,
lastSeenIPLocation: nil,
deviceName: "",
isActive: false,
isCurrent: true),
UserSessionInfo(id: "1",
@ -67,6 +74,13 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
isVerified: true,
lastSeenIP: "1.0.0.1",
lastSeenTimestamp: Date().timeIntervalSince1970 - 8000000,
applicationName: nil,
applicationVersion: nil,
applicationURL: nil,
deviceModel: nil,
deviceOS: nil,
lastSeenIPLocation: nil,
deviceName: "",
isActive: false,
isCurrent: false),
UserSessionInfo(id: "2",
@ -75,6 +89,13 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
isVerified: true,
lastSeenIP: "2.0.0.2",
lastSeenTimestamp: Date().timeIntervalSince1970 - 9000000,
applicationName: nil,
applicationVersion: nil,
applicationURL: nil,
deviceModel: nil,
deviceOS: nil,
lastSeenIPLocation: nil,
deviceName: "",
isActive: false,
isCurrent: false),
UserSessionInfo(id: "3",
@ -83,6 +104,13 @@ enum MockUserOtherSessionsScreenState: MockScreenState, CaseIterable {
isVerified: false,
lastSeenIP: "3.0.0.3",
lastSeenTimestamp: Date().timeIntervalSince1970 - 10000000,
applicationName: nil,
applicationVersion: nil,
applicationURL: nil,
deviceModel: nil,
deviceOS: nil,
lastSeenIPLocation: nil,
deviceName: "",
isActive: false,
isCurrent: false)]
}

View file

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

View file

@ -20,14 +20,14 @@ typealias UserOtherSessionsViewModelType = StateStoreViewModel<UserOtherSessions
class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessionsViewModelProtocol {
var completion: ((UserOtherSessionsViewModelResult) -> Void)?
private let sessions: [UserSessionInfo]
private let sessionsInfo: [UserSessionInfo]
init(sessions: [UserSessionInfo],
init(sessionsInfo: [UserSessionInfo],
filter: OtherUserSessionsFilter,
title: String) {
self.sessions = sessions
self.sessionsInfo = sessionsInfo
super.init(initialViewState: UserOtherSessionsViewState(title: title, sections: []))
updateViewState(sessions: sessions, filter: filter)
updateViewState(sessionsInfo: sessionsInfo, filter: filter)
}
// MARK: - Public
@ -35,30 +35,30 @@ class UserOtherSessionsViewModel: UserOtherSessionsViewModelType, UserOtherSessi
override func process(viewAction: UserOtherSessionsViewAction) {
switch viewAction {
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.")
return
}
completion?(.showUserSessionOverview(session: session))
completion?(.showUserSessionOverview(sessionInfo: session))
}
}
// MARK: - Private
private func updateViewState(sessions: [UserSessionInfo], filter: OtherUserSessionsFilter) {
let sectionItems = filterSessions(sessions: sessions, by: filter).asViewData()
private func updateViewState(sessionsInfo: [UserSessionInfo], filter: OtherUserSessionsFilter) {
let sectionItems = filterSessions(sessionsInfo: sessionsInfo, by: filter).asViewData()
let sectionHeader = createHeaderData(filter: filter)
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 {
case .all:
return sessions.filter { !$0.isCurrent }
return sessionsInfo.filter { !$0.isCurrent }
case .inactive:
return sessions.filter { !$0.isActive }
return sessionsInfo.filter { !$0.isActive }
case .unverified:
return sessions.filter { !$0.isVerified }
return sessionsInfo.filter { !$0.isVerified }
}
}

View file

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

View file

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

View file

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

@ -32,8 +32,12 @@ class UserSessionDetailsViewModel: UserSessionDetailsViewModelType, UserSessionD
private func updateViewState(session: UserSessionInfo) {
var sections = [UserSessionDetailsSectionViewData]()
sections.append(sessionSection(session: session))
if let applicationSection = applicationSection(session: session) {
sections.append(applicationSection)
}
if let deviceSection = deviceSection(session: session) {
sections.append(deviceSection)
@ -43,31 +47,68 @@ class UserSessionDetailsViewModel: UserSessionDetailsViewModelType, UserSessionD
}
private func sessionSection(session: UserSessionInfo) -> UserSessionDetailsSectionViewData {
var sessionItems = [UserSessionDetailsSectionItemViewData]()
var sessionItems: [UserSessionDetailsSectionItemViewData] = []
if let sessionName = session.name {
sessionItems.append(UserSessionDetailsSectionItemViewData(title: VectorL10n.userSessionDetailsSessionName,
value: sessionName))
sessionItems.append(.init(title: VectorL10n.userSessionDetailsSessionName,
value: sessionName))
}
sessionItems.append(UserSessionDetailsSectionItemViewData(title: VectorL10n.keyVerificationManuallyVerifyDeviceIdTitle,
value: session.id))
sessionItems.append(.init(title: VectorL10n.keyVerificationManuallyVerifyDeviceIdTitle,
value: session.id))
return UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsSessionSectionHeader.uppercased(),
footer: VectorL10n.userSessionDetailsSessionSectionFooter,
items: sessionItems)
return .init(header: VectorL10n.userSessionDetailsSessionSectionHeader.uppercased(),
footer: VectorL10n.userSessionDetailsSessionSectionFooter,
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? {
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 {
deviceSectionItems.append(UserSessionDetailsSectionItemViewData(title: VectorL10n.userSessionDetailsDeviceIpAddress,
value: lastSeenIP))
deviceSectionItems.append(.init(title: VectorL10n.userSessionDetailsDeviceIpAddress,
value: lastSeenIP))
}
if let lastSeenIPLocation = session.lastSeenIPLocation {
deviceSectionItems.append(.init(title: VectorL10n.userSessionDetailsDeviceIpLocation,
value: lastSeenIPLocation))
}
if deviceSectionItems.count > 0 {
return UserSessionDetailsSectionViewData(header: VectorL10n.userSessionDetailsDeviceSectionHeader.uppercased(),
footer: nil,
items: deviceSectionItems)
return .init(header: VectorL10n.userSessionDetailsDeviceSectionHeader.uppercased(),
footer: nil,
items: deviceSectionItems)
}
return nil
}

View file

@ -18,7 +18,8 @@ import CommonKit
import SwiftUI
struct UserSessionOverviewCoordinatorParameters {
let session: UserSessionInfo
let session: MXSession
let sessionInfo: UserSessionInfo
}
final class UserSessionOverviewCoordinator: Coordinator, Presentable {
@ -40,7 +41,8 @@ final class UserSessionOverviewCoordinator: Coordinator, Presentable {
init(parameters: UserSessionOverviewCoordinatorParameters) {
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))
@ -57,8 +59,8 @@ final class UserSessionOverviewCoordinator: Coordinator, Presentable {
switch result {
case .verifyCurrentSession:
break // TODO:
case let .showSessionDetails(session: session):
self.completion?(.openSessionDetails(session: session))
case let .showSessionDetails(sessionInfo: sessionInfo):
self.completion?(.openSessionDetails(sessionInfo: sessionInfo))
}
}
}

View file

@ -25,7 +25,9 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable {
// mock that screen.
case currentSession
case otherSession
case sessionWithPushNotifications(enabled: Bool)
case remotelyTogglingPushersNotAvailable
/// The associated screen
var screenType: Any.Type {
UserSessionOverview.self
@ -33,35 +35,89 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable {
/// A list of screen state definitions
static var allCases: [MockUserSessionOverviewScreenState] {
[.currentSession, .otherSession]
[.currentSession,
.otherSession,
.sessionWithPushNotifications(enabled: true),
.sessionWithPushNotifications(enabled: false),
.remotelyTogglingPushersNotAvailable]
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let viewModel: UserSessionOverviewViewModel
let session: UserSessionInfo
let service: UserSessionOverviewServiceProtocol
switch self {
case .currentSession:
let session = UserSessionInfo(id: "session",
name: "iOS",
deviceType: .mobile,
isVerified: false,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: Date().timeIntervalSince1970 - 100,
isActive: true,
isCurrent: true)
viewModel = UserSessionOverviewViewModel(session: session)
session = UserSessionInfo(id: "alice",
name: "iOS",
deviceType: .mobile,
isVerified: false,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: nil,
applicationName: "Element iOS",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "iOS 15.5",
lastSeenIPLocation: nil,
deviceName: "My iPhone",
isActive: true,
isCurrent: true)
service = MockUserSessionOverviewService()
case .otherSession:
let session = UserSessionInfo(id: "session",
name: "Mac",
deviceType: .desktop,
isVerified: true,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: Date().timeIntervalSince1970 - 100,
isActive: true,
isCurrent: false)
viewModel = UserSessionOverviewViewModel(session: session)
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()
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.
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)
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!
func test_whenVerifyCurrentSessionProcessed_completionWithVerifyCurrentSessionCalled() {
sut = UserSessionOverviewViewModel(session: createUserSessionInfo())
sut = UserSessionOverviewViewModel(sessionInfo: createUserSessionInfo(), service: MockUserSessionOverviewService())
XCTAssertEqual(sut.state.isPusherEnabled, nil)
var modelResult: UserSessionOverviewViewModelResult?
sut.completion = { result in
modelResult = result
@ -34,17 +35,57 @@ class UserSessionOverviewViewModelTests: XCTestCase {
}
func test_whenViewSessionDetailsProcessed_completionWithShowSessionDetailsCalled() {
let session = createUserSessionInfo()
sut = UserSessionOverviewViewModel(session: session)
let sessionInfo = createUserSessionInfo()
sut = UserSessionOverviewViewModel(sessionInfo: sessionInfo, service: MockUserSessionOverviewService())
XCTAssertEqual(sut.state.isPusherEnabled, nil)
var modelResult: UserSessionOverviewViewModelResult?
sut.completion = { result in
modelResult = result
}
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 {
UserSessionInfo(id: "session",
name: "iOS",
@ -52,6 +93,13 @@ class UserSessionOverviewViewModelTests: XCTestCase {
isVerified: false,
lastSeenIP: "10.0.0.10",
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,
isCurrent: true)
}

View file

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

View file

@ -19,18 +19,45 @@ import SwiftUI
typealias UserSessionOverviewViewModelType = StateStoreViewModel<UserSessionOverviewViewState, UserSessionOverviewViewAction>
class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessionOverviewViewModelProtocol {
private let session: UserSessionInfo
private let sessionInfo: UserSessionInfo
private let service: UserSessionOverviewServiceProtocol
var completion: ((UserSessionOverviewViewModelResult) -> Void)?
init(session: UserSessionInfo) {
self.session = session
// MARK: - Setup
init(sessionInfo: UserSessionInfo, service: UserSessionOverviewServiceProtocol) {
self.sessionInfo = sessionInfo
self.service = service
let cardViewData = UserSessionCardViewData(session: session)
let state = UserSessionOverviewViewState(cardViewData: cardViewData, isCurrentSession: session.isCurrent)
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)
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
override func process(viewAction: UserSessionOverviewViewAction) {
@ -38,7 +65,10 @@ class UserSessionOverviewViewModel: UserSessionOverviewViewModelType, UserSessio
case .verifyCurrentSession:
completion?(.verifyCurrentSession)
case .viewSessionDetails:
completion?(.showSessionDetails(session: session))
completion?(.showSessionDetails(sessionInfo: sessionInfo))
case .togglePushNotifications:
self.state.showLoadingIndicator = true
service.togglePushNotifications()
}
}
}

View file

@ -26,7 +26,7 @@ struct UserSessionOverview: View {
UserSessionCardView(viewData: viewModel.viewState.cardViewData, onVerifyAction: { _ in
viewModel.send(viewAction: .verifyCurrentSession)
},
onViewDetailsAction: { _ in
onViewDetailsAction: { _ in
viewModel.send(viewAction: .viewSessionDetails)
})
.padding(16)
@ -34,13 +34,21 @@ struct UserSessionOverview: View {
UserSessionOverviewDisclosureCell(title: VectorL10n.userSessionOverviewSessionDetailsButtonTitle, onBackgroundTap: {
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())
.frame(maxHeight: .infinity)
.waitOverlay(show: viewModel.viewState.showLoadingIndicator, allowUserInteraction: false)
.navigationTitle(viewModel.viewState.isCurrentSession ?
VectorL10n.userSessionOverviewCurrentSessionTitle :
VectorL10n.userSessionOverviewSessionTitle)
VectorL10n.userSessionOverviewCurrentSessionTitle :
VectorL10n.userSessionOverviewSessionTitle)
}
}

View file

@ -33,7 +33,7 @@ struct UserSessionOverviewDisclosureCell: View {
.frame(maxWidth: .infinity, alignment: .leading)
Image(Asset.Images.chevron.name)
}
.padding(.vertical, 12)
.padding(.vertical, 15)
.padding(.horizontal, 16)
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) {
self.parameters = parameters
service = UserSessionsOverviewService(mxSession: parameters.session)
let dataProvider = UserSessionsDataProvider(session: parameters.session)
service = UserSessionsOverviewService(dataProvider: dataProvider)
viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: service)
hostingViewController = VectorHostingController(rootView: UserSessionsOverview(viewModel: viewModel.context))
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: hostingViewController)
@ -52,14 +53,14 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
MXLog.debug("[UserSessionsOverviewCoordinator] UserSessionsOverviewViewModel did complete with result: \(result).")
switch result {
case let .showOtherSessions(sessions: sessions, filter: filter):
self.showOtherSessions(sessions: sessions, filterBy: filter)
case let .showOtherSessions(sessionsInfo: sessionsInfo, filter: filter):
self.showOtherSessions(sessionsInfo: sessionsInfo, filterBy: filter)
case .verifyCurrentSession:
self.startVerifyCurrentSession()
case let .showCurrentSessionOverview(session):
self.showCurrentSessionOverview(session: session)
case let .showUserSessionOverview(session):
self.showUserSessionOverview(session: session)
case let .showCurrentSessionOverview(sessionInfo):
self.showCurrentSessionOverview(sessionInfo: sessionInfo)
case let .showUserSessionOverview(sessionInfo):
self.showUserSessionOverview(sessionInfo: sessionInfo)
}
}
}
@ -83,20 +84,20 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
loadingIndicator = nil
}
private func showOtherSessions(sessions: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter) {
completion?(.openOtherSessions(sessions: sessions, filter: filter))
private func showOtherSessions(sessionsInfo: [UserSessionInfo], filterBy filter: OtherUserSessionsFilter) {
completion?(.openOtherSessions(sessionsInfo: sessionsInfo, filter: filter))
}
private func startVerifyCurrentSession() {
// TODO:
}
private func showCurrentSessionOverview(session: UserSessionInfo) {
completion?(.openSessionOverview(session: session))
private func showCurrentSessionOverview(sessionInfo: UserSessionInfo) {
completion?(.openSessionOverview(sessionInfo: sessionInfo))
}
private func showUserSessionOverview(session: UserSessionInfo) {
completion?(.openSessionOverview(session: session))
private func showUserSessionOverview(sessionInfo: UserSessionInfo) {
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
/// the relevant associated data for each case.
enum MockUserSessionsOverviewScreenState: MockScreenState, CaseIterable {
case verifiedSession
case currentSessionUnverified
case currentSessionVerified
case onlyUnverifiedSessions
case onlyInactiveSessions
case noOtherSessions
/// The associated screen
var screenType: Any.Type {
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.
var screenView: ([Any], AnyView) {
let service = MockUserSessionsOverviewService()
var service: UserSessionsOverviewServiceProtocol?
switch self {
case .verifiedSession:
break
case .currentSessionUnverified:
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)
// can simulate service and viewModel actions here if needs be.
return (
[service, viewModel],
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
private static let inactiveSessionDurationTreshold: TimeInterval = 90 * 86400
private let mxSession: MXSession
private let dataProvider: UserSessionsDataProviderProtocol
private(set) var overviewData: UserSessionsOverviewData
init(mxSession: MXSession) {
self.mxSession = mxSession
init(dataProvider: UserSessionsDataProviderProtocol) {
self.dataProvider = dataProvider
overviewData = UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [],
@ -39,7 +39,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
// MARK: - Public
func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) {
mxSession.matrixRestClient.devices { response in
dataProvider.devices { response in
switch response {
case .success(let devices):
self.overviewData = self.sessionsOverviewData(from: devices)
@ -61,26 +61,28 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
// MARK: - Private
private func setupInitialOverviewData() {
let currentSessionInfo = currentSessionInfo()
guard let currentSessionInfo = getCurrentSessionInfo() else {
return
}
overviewData = UserSessionsOverviewData(currentSession: currentSessionInfo,
unverifiedSessions: [],
inactiveSessions: [],
unverifiedSessions: currentSessionInfo.isVerified ? [] : [currentSessionInfo],
inactiveSessions: currentSessionInfo.isActive ? [] : [currentSessionInfo],
otherSessions: [])
}
private func currentSessionInfo() -> UserSessionInfo? {
guard let mainAccount = MXKAccountManager.shared().activeAccounts.first,
private func getCurrentSessionInfo() -> UserSessionInfo? {
guard let mainAccount = dataProvider.activeAccounts.first,
let device = mainAccount.device else {
return nil
}
return sessionInfo(from: device, isCurrentSession: true)
}
private func sessionsOverviewData(from devices: [MXDevice]) -> UserSessionsOverviewData {
let allSessions = devices
.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,
unverifiedSessions: allSessions.filter { !$0.isVerified },
@ -90,33 +92,59 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
private func sessionInfo(from device: MXDevice, isCurrentSession: Bool) -> UserSessionInfo {
let isSessionVerified = deviceInfo(for: device.deviceId)?.trustLevel.isVerified ?? false
var lastSeenTs: TimeInterval?
if device.lastSeenTs > 0 {
lastSeenTs = TimeInterval(device.lastSeenTs / 1000)
}
let eventType = kMXAccountDataTypeClientInformation + "." + device.deviceId
let appData = dataProvider.accountData(for: eventType)
var userAgent: UserAgent?
var isSessionActive = true
if let lastSeenTimestamp = lastSeenTs {
let elapsedTime = Date().timeIntervalSince1970 - lastSeenTimestamp
if let lastSeenUserAgent = device.lastSeenUserAgent {
userAgent = UserAgentParser.parse(lastSeenUserAgent)
}
if device.lastSeenTs > 0 {
let elapsedTime = Date().timeIntervalSince1970 - TimeInterval(device.lastSeenTs / 1000)
isSessionActive = elapsedTime < Self.inactiveSessionDurationTreshold
}
return UserSessionInfo(id: device.deviceId,
name: device.displayName,
deviceType: .unknown,
isVerified: isSessionVerified,
lastSeenIP: device.lastSeenIp,
lastSeenTimestamp: lastSeenTs,
return UserSessionInfo(withDevice: device,
applicationData: appData as? [String: String],
userAgent: userAgent,
isSessionVerified: isSessionVerified,
isActive: isSessionActive,
isCurrent: isCurrentSession)
}
private func deviceInfo(for deviceId: String) -> MXDeviceInfo? {
guard let userId = mxSession.myUserId else {
guard let userId = dataProvider.myUserId else {
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
class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
enum Mode {
case currentSessionUnverified
case currentSessionVerified
case onlyUnverifiedSessions
case onlyInactiveSessions
case noOtherSessions
}
private let mode: Mode
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) {
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))
}
func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo? {
nil
overviewData.otherSessions.first { $0.id == sessionId }
}
// MARK: - Private
private var currentSession: UserSessionInfo {
UserSessionInfo(id: "alice",
name: "iOS",
deviceType: .mobile,
isVerified: mode == .currentSessionVerified,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: nil,
applicationName: "Element iOS",
applicationVersion: "1.0.0",
applicationURL: nil,
deviceModel: nil,
deviceOS: "iOS 15.5",
lastSeenIPLocation: nil,
deviceName: "My iPhone",
isActive: true,
isCurrent: true)
}
init() {
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] = {
[UserSessionInfo(id: "alice",
name: "iOS",
deviceType: .mobile,
isVerified: false,
lastSeenIP: "10.0.0.10",
lastSeenTimestamp: nil,
isActive: 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,
isVerified: true,
isVerified: verified,
lastSeenIP: "1.0.0.1",
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),
UserSessionInfo(id: "2",
name: "Firefox on Windows",
UserSessionInfo(id: "2 verified: \(verified) active: \(active)",
name: "Firefox on Windows verified: \(verified) active: \(active)",
deviceType: .web,
isVerified: true,
isVerified: verified,
lastSeenIP: "2.0.0.2",
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),
UserSessionInfo(id: "3",
name: "Android",
UserSessionInfo(id: "3 verified: \(verified) active: \(active)",
name: "Android verified: \(verified) active: \(active)",
deviceType: .mobile,
isVerified: false,
isVerified: verified,
lastSeenIP: "3.0.0.3",
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)]
}()
}
}

View file

@ -18,5 +18,38 @@ import RiotSwiftUI
import XCTest
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
class UserSessionsOverviewViewModelTests: XCTestCase {
var service: MockUserSessionsOverviewService!
var viewModel: UserSessionsOverviewViewModelProtocol!
var context: UserSessionsOverviewViewModelType.Context!
func testInitialStateEmpty() {
let viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: MockUserSessionsOverviewService())
XCTAssertNil(viewModel.state.currentSessionViewData)
XCTAssertTrue(viewModel.state.unverifiedSessionsViewData.isEmpty)
XCTAssertTrue(viewModel.state.inactiveSessionsViewData.isEmpty)
XCTAssertTrue(viewModel.state.otherSessionsViewData.isEmpty)
}
override func setUpWithError() throws {
service = MockUserSessionsOverviewService()
viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: service)
context = viewModel.context
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
enum UserSessionsOverviewCoordinatorResult {
case openSessionOverview(session: UserSessionInfo)
case openOtherSessions(sessions: [UserSessionInfo], filter: OtherUserSessionsFilter)
case openSessionOverview(sessionInfo: UserSessionInfo)
case openOtherSessions(sessionsInfo: [UserSessionInfo], filter: OtherUserSessionsFilter)
}
// MARK: View model
enum UserSessionsOverviewViewModelResult {
case showOtherSessions(sessions: [UserSessionInfo], filter: OtherUserSessionsFilter)
enum UserSessionsOverviewViewModelResult: Equatable {
case showOtherSessions(sessionsInfo: [UserSessionInfo], filter: OtherUserSessionsFilter)
case verifyCurrentSession
case showCurrentSessionOverview(session: UserSessionInfo)
case showUserSessionOverview(session: UserSessionInfo)
case showCurrentSessionOverview(sessionInfo: UserSessionInfo)
case showUserSessionOverview(sessionInfo: UserSessionInfo)
}
// MARK: View

View file

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

View file

@ -18,6 +18,7 @@ import Foundation
/// View data for UserSessionListItem
struct UserSessionListItemViewData: Identifiable, Hashable {
var id: String {
sessionId
}
@ -29,6 +30,6 @@ struct UserSessionListItemViewData: Identifiable, Hashable {
let sessionDetails: String
let deviceAvatarViewData: DeviceAvatarViewData
let sessionDetailsIcon: String?
}

View file

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

View file

@ -23,11 +23,13 @@ struct UserSessionsOverview: View {
var body: some View {
ScrollView {
securityRecommendationsSection
if hasSecurityRecommendations {
securityRecommendationsSection
}
currentSessionsSection
if viewModel.viewState.otherSessionsViewData.isEmpty == false {
if !viewModel.viewState.otherSessionsViewData.isEmpty {
otherSessionsSection
}
}
@ -40,41 +42,39 @@ struct UserSessionsOverview: View {
}
}
@ViewBuilder
private var securityRecommendationsSection: some View {
if hasSecurityRecommendations {
SwiftUI.Section {
if !viewModel.viewState.unverifiedSessionsViewData.isEmpty {
SecurityRecommendationCard(style: .unverified,
sessionCount: viewModel.viewState.unverifiedSessionsViewData.count) {
viewModel.send(viewAction: .viewAllUnverifiedSessions)
}
SwiftUI.Section {
if !viewModel.viewState.unverifiedSessionsViewData.isEmpty {
SecurityRecommendationCard(style: .unverified,
sessionCount: viewModel.viewState.unverifiedSessionsViewData.count) {
viewModel.send(viewAction: .viewAllUnverifiedSessions)
}
if !viewModel.viewState.inactiveSessionsViewData.isEmpty {
SecurityRecommendationCard(style: .inactive,
sessionCount: viewModel.viewState.inactiveSessionsViewData.count) {
viewModel.send(viewAction: .viewAllInactiveSessions)
}
}
} header: {
VStack(alignment: .leading) {
Text(VectorL10n.userSessionsOverviewSecurityRecommendationsSectionTitle)
.textCase(.uppercase)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
.padding(.bottom, 8.0)
Text(VectorL10n.userSessionsOverviewSecurityRecommendationsSectionInfo)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
.padding(.bottom, 12.0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 24)
}
.padding(.horizontal, 16)
if !viewModel.viewState.inactiveSessionsViewData.isEmpty {
SecurityRecommendationCard(style: .inactive,
sessionCount: viewModel.viewState.inactiveSessionsViewData.count) {
viewModel.send(viewAction: .viewAllInactiveSessions)
}
}
} header: {
VStack(alignment: .leading) {
Text(VectorL10n.userSessionsOverviewSecurityRecommendationsSectionTitle)
.textCase(.uppercase)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
.padding(.bottom, 8.0)
Text(VectorL10n.userSessionsOverviewSecurityRecommendationsSectionInfo)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
.padding(.bottom, 12.0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 24)
}
.padding(.horizontal, 16)
.accessibilityIdentifier("userSessionsOverviewSecurityRecommendationsSection")
}
var hasSecurityRecommendations: Bool {
@ -102,10 +102,9 @@ struct UserSessionsOverview: View {
.padding(.horizontal, 16)
}
}
private var otherSessionsSection: some View {
SwiftUI.Section {
// Device list
LazyVStack(spacing: 0) {
ForEach(viewModel.viewState.otherSessionsViewData) { viewData in
UserSessionListItem(viewData: viewData, onBackgroundTap: { sessionId in
@ -131,6 +130,7 @@ struct UserSessionsOverview: View {
.padding(.horizontal, 16.0)
.padding(.top, 24.0)
}
.accessibilityIdentifier("userSessionsOverviewOtherSection")
}
}

View file

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

View file

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

View file

@ -24,6 +24,9 @@ schemes:
disableMainThreadChecker: true
targets:
- RiotSwiftUnitTests
gatherCoverageData: true
coverageTargets:
- RiotSwiftUI
targets:
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
targets:
- RiotTests
gatherCoverageData: true
coverageTargets:
- Riot
targets:
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).