diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index f8b9ded11..380ecd5c7 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -2398,12 +2398,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"; diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index 60f83c1eb..854994c3d 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -8471,10 +8471,38 @@ public class VectorL10n: NSObject { public static var userIdTitle: String { return VectorL10n.tr("Vector", "user_id_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") diff --git a/RiotSwiftUI/Modules/Common/Extensions/Collection.swift b/RiotSwiftUI/Modules/Common/Extensions/Collection.swift new file mode 100644 index 000000000..710e4dc21 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Extensions/Collection.swift @@ -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 + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/Common/UserAgentParser.swift b/RiotSwiftUI/Modules/UserSessions/Common/UserAgentParser.swift new file mode 100644 index 000000000..d4fe29466 --- /dev/null +++ b/RiotSwiftUI/Modules/UserSessions/Common/UserAgentParser.swift @@ -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.. (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) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/Common/UserSessionInfo.swift b/RiotSwiftUI/Modules/UserSessions/Common/UserSessionInfo.swift index 6fba28616..5bfbe2332 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/UserSessionInfo.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/UserSessionInfo.swift @@ -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 } } diff --git a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift index bf49cedb8..3736322ef 100644 --- a/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift +++ b/RiotSwiftUI/Modules/UserSessions/Common/View/UserSessionCardView.swift @@ -134,17 +134,23 @@ struct UserSessionCardViewPreview: View { @Environment(\.theme) var theme: ThemeSwiftUI let viewData: UserSessionCardViewData - - init(isCurrentSessionInfo: Bool = false) { + + init(isCurrent: Bool = false) { let 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: isCurrentSessionInfo) - + isCurrent: isCurrent) viewData = UserSessionCardViewData(session: session) } @@ -161,8 +167,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) } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift index 22b263543..37233e141 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/MockUserSessionDetailsScreenState.swift @@ -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) } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/Unit/UserSessionDetailsViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/Unit/UserSessionDetailsViewModelTests.swift index 5949ad6af..184725e1e 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/Unit/UserSessionDetailsViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/Test/Unit/UserSessionDetailsViewModelTests.swift @@ -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) } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModel.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModel.swift index 9fe47469d..475f3bb5f 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModel.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionDetails/UserSessionDetailsViewModel.swift @@ -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 } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/MockUserSessionOverviewScreenState.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/MockUserSessionOverviewScreenState.swift index 0cfa0b7a4..aa0d437b0 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/MockUserSessionOverviewScreenState.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/MockUserSessionOverviewScreenState.swift @@ -38,30 +38,43 @@ enum MockUserSessionOverviewScreenState: MockScreenState, CaseIterable { /// Generate the view struct for the screen state. var screenView: ([Any], AnyView) { - let viewModel: UserSessionOverviewViewModel + let session: UserSessionInfo 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) 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) } - + + let viewModel = UserSessionOverviewViewModel(session: session) // can simulate service and viewModel actions here if needs be. return ([viewModel], AnyView(UserSessionOverview(viewModel: viewModel.context))) } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift index a3fb37f5f..a89e3f96d 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/Test/Unit/UserSessionOverviewViewModelTests.swift @@ -52,6 +52,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) } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverview.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverview.swift index ec0f4dca3..8ebf47abc 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverview.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionOverview/View/UserSessionOverview.swift @@ -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) @@ -39,8 +39,8 @@ struct UserSessionOverview: View { .background(theme.colors.system.ignoresSafeArea()) .frame(maxHeight: .infinity) .navigationTitle(viewModel.viewState.isCurrentSession ? - VectorL10n.userSessionOverviewCurrentSessionTitle : - VectorL10n.userSessionOverviewSessionTitle) + VectorL10n.userSessionOverviewCurrentSessionTitle : + VectorL10n.userSessionOverviewSessionTitle) } } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift index ac7521dcb..c6316569d 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Coordinator/UserSessionsOverviewCoordinator.swift @@ -102,7 +102,7 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable { private func showCurrentSessionOverview(session: UserSessionInfo) { completion?(.openSessionOverview(session: session)) } - + private func showUserSessionOverview(session: UserSessionInfo) { completion?(.openSessionOverview(session: session)) } diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift index fc3a37162..874e5dfa1 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/MatrixSDK/UserSessionsOverviewService.swift @@ -76,7 +76,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { } return sessionInfo(from: device, isCurrentSession: true) } - + private func sessionsOverviewData(from devices: [MXDevice]) -> UserSessionsOverviewData { let allSessions = devices .sorted { $0.lastSeenTs > $1.lastSeenTs } @@ -90,24 +90,25 @@ 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 = mxSession.accountData.accountData(forEventType: 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) } @@ -120,3 +121,28 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol { return mxSession.crypto.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) + } +} diff --git a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift index d94dbe6c9..f9724f25e 100644 --- a/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift +++ b/RiotSwiftUI/Modules/UserSessions/UserSessionsOverview/Service/Mock/MockUserSessionsOverviewService.swift @@ -17,7 +17,7 @@ import Foundation class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { - var overviewData: UserSessionsOverviewData + let overviewData: UserSessionsOverviewData func updateOverviewData(completion: @escaping (Result) -> Void) { completion(.success(overviewData)) @@ -34,38 +34,66 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { 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", - deviceType: .desktop, - isVerified: true, - lastSeenIP: "1.0.0.1", - lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000, - isActive: false, - isCurrent: false), - UserSessionInfo(id: "2", - name: "Firefox on Windows", - deviceType: .web, - isVerified: true, - lastSeenIP: "2.0.0.2", - lastSeenTimestamp: Date().timeIntervalSince1970 - 100, - isActive: true, - isCurrent: false), - UserSessionInfo(id: "3", - name: "Android", - deviceType: .mobile, - isVerified: false, - lastSeenIP: "3.0.0.3", - lastSeenTimestamp: Date().timeIntervalSince1970 - 10, - isActive: true, - isCurrent: false)] - }() + static let allSessions = [ + 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), + 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), + UserSessionInfo(id: "2", + name: "Firefox on Windows", + deviceType: .web, + isVerified: true, + lastSeenIP: "2.0.0.2", + lastSeenTimestamp: Date().timeIntervalSince1970 - 100, + applicationName: "Element Web", + applicationVersion: "1.0.0", + applicationURL: nil, + deviceModel: nil, + deviceOS: "Windows 10", + lastSeenIPLocation: nil, + deviceName: "My Windows", + isActive: true, + isCurrent: false), + UserSessionInfo(id: "3", + name: "Android", + deviceType: .mobile, + isVerified: false, + lastSeenIP: "3.0.0.3", + lastSeenTimestamp: Date().timeIntervalSince1970 - 10, + applicationName: "Element Android", + applicationVersion: "1.0.0", + applicationURL: nil, + deviceModel: nil, + deviceOS: "Android 4.0", + lastSeenIPLocation: nil, + deviceName: "My Phone", + isActive: true, + isCurrent: false) + ] } diff --git a/RiotTests/UserAgentParserTests.swift b/RiotTests/UserAgentParserTests.swift new file mode 100644 index 000000000..d68306d67 --- /dev/null +++ b/RiotTests/UserAgentParserTests.swift @@ -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) + } + +} diff --git a/changelog.d/pr-6766.change b/changelog.d/pr-6766.change new file mode 100644 index 000000000..3f5ff2693 --- /dev/null +++ b/changelog.d/pr-6766.change @@ -0,0 +1 @@ +UserSessions: Extended device information (PSG-772).