From 2f5e04d1a757d0aa7480be6ee00857407b1e5541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Bispo?= Date: Wed, 25 Mar 2026 17:19:47 +0000 Subject: [PATCH] [PM-33981] feat: Add device models and API layer --- .../Auth/Models/Domain/DeviceListItem.swift | 111 +++++++++++++ .../Models/Enum/DeviceActivityStatus.swift | 83 ++++++++++ .../Core/Auth/Models/Enum/DeviceType.swift | 147 ++++++++++++++++++ .../Auth/Models/Response/DeviceResponse.swift | 34 ++++ .../Models/Response/DevicesListResponse.swift | 15 ++ .../API/Device/DeviceAPIService.swift | 22 +++ .../Requests/CurrentDeviceRequest.swift | 18 +++ .../Device/Requests/DevicesListRequest.swift | 13 ++ 8 files changed, 443 insertions(+) create mode 100644 BitwardenShared/Core/Auth/Models/Domain/DeviceListItem.swift create mode 100644 BitwardenShared/Core/Auth/Models/Enum/DeviceActivityStatus.swift create mode 100644 BitwardenShared/Core/Auth/Models/Enum/DeviceType.swift create mode 100644 BitwardenShared/Core/Auth/Models/Response/DeviceResponse.swift create mode 100644 BitwardenShared/Core/Auth/Models/Response/DevicesListResponse.swift create mode 100644 BitwardenShared/Core/Auth/Services/API/Device/Requests/CurrentDeviceRequest.swift create mode 100644 BitwardenShared/Core/Auth/Services/API/Device/Requests/DevicesListRequest.swift diff --git a/BitwardenShared/Core/Auth/Models/Domain/DeviceListItem.swift b/BitwardenShared/Core/Auth/Models/Domain/DeviceListItem.swift new file mode 100644 index 0000000000..f704210ee8 --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Domain/DeviceListItem.swift @@ -0,0 +1,111 @@ +import BitwardenKit +import BitwardenResources +import Foundation + +// MARK: - DeviceListItem + +/// A UI-friendly model representing a device in the device management list. +/// +struct DeviceListItem: Equatable, Identifiable, Sendable { + // MARK: Properties + + /// The unique identifier of the device. + let id: String + + /// The unique identifier for this device instance. + let identifier: String + + /// The display name of the device. + let displayName: String + + /// The type of the device. + let deviceType: DeviceType + + /// Whether the device is trusted. + let isTrusted: Bool + + /// Whether this is the current session's device. + var isCurrentSession: Bool + + /// Whether the device has a pending login request. + var hasPendingRequest: Bool + + /// The activity status of the device. + let activityStatus: DeviceActivityStatus + + /// The date of the first login on this device. + let firstLogin: Date + + /// The date of the last activity on this device. + let lastActivityDate: Date? + + /// The most recent pending login request for this device, if any. + var pendingRequest: LoginRequest? + + // MARK: Initialization + + /// Initializes a `DeviceListItem` with all properties. + /// + /// - Parameters: + /// - id: The unique identifier of the device. + /// - identifier: The unique identifier for this device instance. + /// - displayName: The display name of the device. + /// - deviceType: The type of the device. + /// - isTrusted: Whether the device is trusted. + /// - isCurrentSession: Whether this is the current session's device. + /// - hasPendingRequest: Whether the device has a pending login request. + /// - activityStatus: The activity status of the device. + /// - firstLogin: The date of the first login on this device. + /// - lastActivityDate: The date of the last activity on this device. + /// - pendingRequest: The most recent pending login request for this device. + /// + init( + id: String, + identifier: String, + displayName: String, + deviceType: DeviceType, + isTrusted: Bool, + isCurrentSession: Bool, + hasPendingRequest: Bool, + activityStatus: DeviceActivityStatus, + firstLogin: Date, + lastActivityDate: Date?, + pendingRequest: LoginRequest?, + ) { + self.id = id + self.identifier = identifier + self.displayName = displayName + self.deviceType = deviceType + self.isTrusted = isTrusted + self.isCurrentSession = isCurrentSession + self.hasPendingRequest = hasPendingRequest + self.activityStatus = activityStatus + self.firstLogin = firstLogin + self.lastActivityDate = lastActivityDate + self.pendingRequest = pendingRequest + } + + /// Initializes a `DeviceListItem` from a `DeviceResponse`. + /// + /// - Parameters: + /// - device: The device response from the API. + /// - timeProvider: The time provider to use for calculating the activity status. + /// + init( + device: DeviceResponse, + timeProvider: TimeProvider, + ) { + let type = DeviceType(device.type) + id = device.id + identifier = device.identifier + displayName = type.displayName + deviceType = type + isTrusted = device.isTrusted + isCurrentSession = false + hasPendingRequest = false + activityStatus = DeviceActivityStatus(from: device.lastActivityDate, timeProvider: timeProvider) + firstLogin = device.creationDate + lastActivityDate = device.lastActivityDate + pendingRequest = nil + } +} diff --git a/BitwardenShared/Core/Auth/Models/Enum/DeviceActivityStatus.swift b/BitwardenShared/Core/Auth/Models/Enum/DeviceActivityStatus.swift new file mode 100644 index 0000000000..53bd864613 --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Enum/DeviceActivityStatus.swift @@ -0,0 +1,83 @@ +import BitwardenKit +import BitwardenResources +import Foundation + +// MARK: - DeviceActivityStatus + +/// An enumeration representing the activity status of a device based on its last activity date. +/// +enum DeviceActivityStatus: Equatable, Sendable { + /// The device was active today. + case today + + /// The device was active this week (but not today). + case thisWeek + + /// The device was active last week. + case lastWeek + + /// The device was active this month (but not this or last week). + case thisMonth + + /// The device was active over 30 days ago. + case overThirtyDaysAgo + + /// The device's activity status is unknown. + case unknown + + // MARK: Initialization + + /// Initializes a `DeviceActivityStatus` from an optional date. + /// + /// - Parameters: + /// - date: The last activity date of the device. + /// - timeProvider: The time provider to use for calculating the status. + /// + init(from date: Date?, timeProvider: TimeProvider) { + guard let date else { + self = .unknown + return + } + + let now = timeProvider.presentTime + let calendar = Calendar.current + + guard let daysDifference = calendar.dateComponents([.day], from: date, to: now).day else { + self = .unknown + return + } + + switch daysDifference { + case ...0: + self = .today + case 1 ... 7: + self = .thisWeek + case 8 ... 14: + self = .lastWeek + case 15 ... 30: + self = .thisMonth + default: + self = .overThirtyDaysAgo + } + } + + // MARK: Properties + + /// The localized display string for the activity status. + var localizedString: String { + switch self { + case .today: + Localizations.today + case .thisWeek: + Localizations.thisWeek + case .lastWeek: + Localizations.lastWeek + case .thisMonth: + Localizations.thisMonth + case .overThirtyDaysAgo: + Localizations.overThirtyDaysAgo + case .unknown: + Localizations.unknown + } + } +} diff --git a/BitwardenShared/Core/Auth/Models/Enum/DeviceType.swift b/BitwardenShared/Core/Auth/Models/Enum/DeviceType.swift new file mode 100644 index 0000000000..bf5ff17c57 --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Enum/DeviceType.swift @@ -0,0 +1,147 @@ +import BitwardenKit +import BitwardenResources +import Foundation + +// MARK: - DeviceTypeCategory + +/// The category of a device type. +/// +enum DeviceTypeCategory: String, Sendable { + case mobile + case `extension` + case webApp + case desktop + case cli + case sdk + case server + + /// The localized display name for the category. + var displayName: String { + switch self { + case .mobile: + Localizations.mobile + case .extension: + Localizations.browserExtension + case .webApp: + Localizations.webVault + case .desktop: + Localizations.desktop + case .cli: + Localizations.cli + case .sdk: + Localizations.sdk + case .server: + Localizations.server + } + } +} + +// MARK: - DeviceType Extension + +extension DeviceType { + // MARK: Known Device Type Values + + static let android: DeviceType = 0 + static let iOS: DeviceType = 1 + static let chromeExtension: DeviceType = 2 + static let firefoxExtension: DeviceType = 3 + static let operaExtension: DeviceType = 4 + static let edgeExtension: DeviceType = 5 + static let windowsDesktop: DeviceType = 6 + static let macOsDesktop: DeviceType = 7 + static let linuxDesktop: DeviceType = 8 + static let chromeBrowser: DeviceType = 9 + static let firefoxBrowser: DeviceType = 10 + static let operaBrowser: DeviceType = 11 + static let edgeBrowser: DeviceType = 12 + static let ieBrowser: DeviceType = 13 + static let unknownBrowser: DeviceType = 14 + static let androidAmazon: DeviceType = 15 + static let uwp: DeviceType = 16 + static let safariBrowser: DeviceType = 17 + static let vivaldiBrowser: DeviceType = 18 + static let vivaldiExtension: DeviceType = 19 + static let safariExtension: DeviceType = 20 + static let sdk: DeviceType = 21 + static let server: DeviceType = 22 + static let windowsCLI: DeviceType = 23 + static let macOsCLI: DeviceType = 24 + static let linuxCLI: DeviceType = 25 + static let duckDuckGoBrowser: DeviceType = 26 + + // MARK: Properties + + /// The category of the device type. + var category: DeviceTypeCategory { + switch self { + case Self.android, Self.androidAmazon, Self.iOS: + .mobile + case Self.chromeExtension, Self.edgeExtension, Self.firefoxExtension, Self.operaExtension, + Self.safariExtension, Self.vivaldiExtension: + .extension + case Self.chromeBrowser, Self.duckDuckGoBrowser, Self.edgeBrowser, Self.firefoxBrowser, + Self.ieBrowser, Self.operaBrowser, Self.safariBrowser, Self.unknownBrowser, Self.vivaldiBrowser: + .webApp + case Self.linuxDesktop, Self.macOsDesktop, Self.uwp, Self.windowsDesktop: + .desktop + case Self.linuxCLI, Self.macOsCLI, Self.windowsCLI: + .cli + case Self.sdk: + .sdk + case Self.server: + .server + default: + .mobile + } + } + + /// The platform name for the device type. + var platform: String { + switch self { + case Self.android: + "Android" + case Self.iOS: + "iOS" + case Self.androidAmazon: + "Amazon" + case Self.chromeBrowser, Self.chromeExtension: + "Chrome" + case Self.firefoxBrowser, Self.firefoxExtension: + "Firefox" + case Self.operaBrowser, Self.operaExtension: + "Opera" + case Self.edgeBrowser, Self.edgeExtension: + "Edge" + case Self.vivaldiBrowser, Self.vivaldiExtension: + "Vivaldi" + case Self.safariBrowser, Self.safariExtension: + "Safari" + case Self.ieBrowser: + "IE" + case Self.duckDuckGoBrowser: + "DuckDuckGo" + case Self.unknownBrowser: + Localizations.unknown + case Self.windowsCLI, Self.windowsDesktop: + "Windows" + case Self.macOsCLI, Self.macOsDesktop: + "macOS" + case Self.linuxCLI, Self.linuxDesktop: + "Linux" + case Self.uwp: + "Windows UWP" + case Self.sdk, Self.server: + "" + default: + Localizations.unknown + } + } + + /// The display name for the device type, combining category and platform. + var displayName: String { + if platform.isEmpty { + return category.displayName + } + return "\(category.displayName) - \(platform)" + } +} diff --git a/BitwardenShared/Core/Auth/Models/Response/DeviceResponse.swift b/BitwardenShared/Core/Auth/Models/Response/DeviceResponse.swift new file mode 100644 index 0000000000..1f28a62b56 --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Response/DeviceResponse.swift @@ -0,0 +1,34 @@ +import BitwardenKit +import Foundation +import Networking + +// MARK: - DeviceResponse + +/// A data structure representing a device response from the API. +/// +public struct DeviceResponse: JSONResponse, Equatable, Sendable, Identifiable, Hashable { + public static let decoder = JSONDecoder.defaultDecoder + + // MARK: Properties + + /// The unique identifier of the device. + public let id: String + + /// The name of the device. + let name: String? + + /// The unique identifier for this device instance. + let identifier: String + + /// The numeric type of the device (maps to DeviceType). + let type: Int + + /// The date the device was first registered. + let creationDate: Date + + /// Whether the device is trusted. + let isTrusted: Bool + + /// The date of the last activity on this device. + let lastActivityDate: Date? +} diff --git a/BitwardenShared/Core/Auth/Models/Response/DevicesListResponse.swift b/BitwardenShared/Core/Auth/Models/Response/DevicesListResponse.swift new file mode 100644 index 0000000000..10274b17e6 --- /dev/null +++ b/BitwardenShared/Core/Auth/Models/Response/DevicesListResponse.swift @@ -0,0 +1,15 @@ +import Foundation +import Networking + +// MARK: - DevicesListResponse + +/// The response returned from the API when requesting the list of devices. +/// +struct DevicesListResponse: JSONResponse { + static let decoder = JSONDecoder.defaultDecoder + + // MARK: Properties + + /// The list of devices returned by the API request. + let data: [DeviceResponse] +} diff --git a/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIService.swift b/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIService.swift index bd83542ff6..ca000e3be4 100644 --- a/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIService.swift +++ b/BitwardenShared/Core/Auth/Services/API/Device/DeviceAPIService.swift @@ -3,6 +3,19 @@ /// A protocol for an API service used to make device requests. /// protocol DeviceAPIService { + /// Retrieves the current device by its app identifier. + /// + /// - Parameter appId: The unique app identifier for this device. + /// - Returns: The `DeviceResponse` for the current device. + /// + func getCurrentDevice(appId: String) async throws -> DeviceResponse + + /// Retrieves the list of devices for the current user. + /// + /// - Returns: An array of `DeviceResponse` representing all devices. + /// + func getDevices() async throws -> [DeviceResponse] + /// Queries the API to determine if this device was previously associated with the email address. /// /// - Parameters: @@ -17,6 +30,15 @@ protocol DeviceAPIService { // MARK: - APIService extension APIService: DeviceAPIService { + func getCurrentDevice(appId: String) async throws -> DeviceResponse { + try await apiService.send(CurrentDeviceRequest(appId: appId)) + } + + func getDevices() async throws -> [DeviceResponse] { + let response = try await apiService.send(DevicesListRequest()) + return response.data + } + func knownDevice(email: String, deviceIdentifier: String) async throws -> Bool { let request = KnownDeviceRequest(email: email, deviceIdentifier: deviceIdentifier) let response = try await apiUnauthenticatedService.send(request) diff --git a/BitwardenShared/Core/Auth/Services/API/Device/Requests/CurrentDeviceRequest.swift b/BitwardenShared/Core/Auth/Services/API/Device/Requests/CurrentDeviceRequest.swift new file mode 100644 index 0000000000..a69a879cce --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Device/Requests/CurrentDeviceRequest.swift @@ -0,0 +1,18 @@ +import Networking + +// MARK: - CurrentDeviceRequest + +/// A request for retrieving the current device by its app identifier. +/// +struct CurrentDeviceRequest: Request { + typealias Response = DeviceResponse + + // MARK: Properties + + /// The unique app identifier for this device. + let appId: String + + var method: HTTPMethod { .get } + + var path: String { "/devices/identifier/\(appId)" } +} diff --git a/BitwardenShared/Core/Auth/Services/API/Device/Requests/DevicesListRequest.swift b/BitwardenShared/Core/Auth/Services/API/Device/Requests/DevicesListRequest.swift new file mode 100644 index 0000000000..5beb223c91 --- /dev/null +++ b/BitwardenShared/Core/Auth/Services/API/Device/Requests/DevicesListRequest.swift @@ -0,0 +1,13 @@ +import Networking + +// MARK: - DevicesListRequest + +/// A request for retrieving the list of devices for the current user. +/// +struct DevicesListRequest: Request { + typealias Response = DevicesListResponse + + var method: HTTPMethod { .get } + + var path: String { "/devices" } +}