Extended device info (PSG-772) (#6766)

This commit is contained in:
ismailgulek 2022-09-29 15:07:10 +03:00 committed by GitHub
parent 46a975b9dc
commit 2f689f4557
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 825 additions and 156 deletions

View file

@ -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";

View file

@ -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")

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

@ -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

@ -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)
}

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

@ -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)))
}

View file

@ -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)
}

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)
@ -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)
}
}

View file

@ -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))
}

View file

@ -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)
}
}

View file

@ -17,7 +17,7 @@
import Foundation
class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
var overviewData: UserSessionsOverviewData
let overviewData: UserSessionsOverviewData
func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> 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)
]
}

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 @@
UserSessions: Extended device information (PSG-772).