Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions BitwardenKit/Core/Platform/Models/Enum/DeviceType.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// MARK: - DeviceType

/// The type of device used to access the vault.
///
public enum DeviceType: Int, Codable, Hashable, Sendable {
case android = 0
case iOS = 1
case chromeExtension = 2
case firefoxExtension = 3
case operaExtension = 4
case edgeExtension = 5
case windowsDesktop = 6
case macOsDesktop = 7
case linuxDesktop = 8
case chromeBrowser = 9
case firefoxBrowser = 10
case operaBrowser = 11
case edgeBrowser = 12
case ieBrowser = 13
case unknownBrowser = 14
case androidAmazon = 15
case uwp = 16
case safariBrowser = 17
case vivaldiBrowser = 18
case vivaldiExtension = 19
case safariExtension = 20
case sdk = 21
case server = 22
case windowsCLI = 23
case macOsCLI = 24
case linuxCLI = 25
case duckDuckGoBrowser = 26
}

// MARK: - DefaultValueProvider

extension DeviceType: DefaultValueProvider {
public static var defaultValue: DeviceType { .unknownBrowser }
}
12 changes: 12 additions & 0 deletions BitwardenKit/Core/Platform/Models/Enum/DeviceTypeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import BitwardenKit
import XCTest

class DeviceTypeTests: BitwardenTestCase {
// MARK: Tests

/// `defaultValue` returns the default value for the type if an invalid or missing value is
/// received when decoding the type.
func test_defaultValue() {
XCTAssertEqual(DeviceType.defaultValue, .unknownBrowser)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public final class DefaultHeadersRequestHandler: RequestHandler {
public func handle(_ request: inout HTTPRequest) async throws -> HTTPRequest {
request.headers["Bitwarden-Client-Name"] = Constants.clientType
request.headers["Bitwarden-Client-Version"] = appVersion
request.headers["Device-Type"] = String(Constants.deviceType)
request.headers["Device-Type"] = String(Constants.deviceType.rawValue)
request.headers["User-Agent"] = userAgentBuilder.value

return request
Expand Down
7 changes: 2 additions & 5 deletions BitwardenKit/Core/Platform/Utilities/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@ import Foundation
/// A type alias for the client type.
public typealias ClientType = String

/// A type alias for the device type.
public typealias DeviceType = Int

// MARK: - Constants

/// Constant values reused throughout the app.
Expand All @@ -14,8 +11,8 @@ public enum Constants {
/// The client type corresponding to the app.
public static let clientType: ClientType = "mobile"

/// The device type, iOS = 1.
public static let deviceType: DeviceType = 1
/// The device type for this app.
public static let deviceType: DeviceType = .iOS

/// The number of days that a flight recorder log will remain on the device after the end date
/// before being automatically deleted.
Expand Down
4 changes: 4 additions & 0 deletions BitwardenKit/Core/Platform/Utilities/DefaultValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ extension DefaultValue: Equatable where T: Equatable {}

extension DefaultValue: Hashable where T: Hashable {}

// MARK: - Sendable

extension DefaultValue: Sendable where T: Sendable {}

// MARK: - KeyedDecodingContainer

public extension KeyedDecodingContainer {
Expand Down
15 changes: 15 additions & 0 deletions BitwardenResources/Localizations/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1169,3 +1169,18 @@
"LineOfCredit" = "Line of credit";
"MoneyMarket" = "Money market";
"Savings" = "Savings";
"Today" = "Today";
"PastSevenDays" = "Past 7 days";
"PastFourteenDays" = "Past 14 days";
"PastThirtyDays" = "Past 30 days";
"OverThirtyDaysAgo" = "Over 30 days ago";
"Unknown" = "Unknown";
/* Device display name. %1$@ = category (e.g. "Mobile"), %2$@ = platform (e.g. "iOS"). */
"DeviceDisplayName" = "%1$@ - %2$@";
"Mobile" = "Mobile";
"BrowserExtension" = "Browser extension";
"WebVaultDeviceType" = "Web vault";
"Desktop" = "Desktop";
"Cli" = "CLI";
"Sdk" = "SDK";
"Server" = "Server";
74 changes: 74 additions & 0 deletions BitwardenShared/Core/Auth/Models/Domain/DeviceListItem.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import BitwardenKit
import Foundation

// MARK: - DeviceListItem

/// A UI-friendly model representing a device in the device management list.
///
struct DeviceListItem: Equatable, Identifiable, Sendable {
// MARK: Properties

/// The activity status of the device.
let activityStatus: DeviceActivityStatus

/// The type of the device.
let deviceType: DeviceType

/// The display name of the device.
let displayName: String

/// The date of the first login on this device.
let firstLogin: Date

/// Whether the device has a pending login request.
///
/// Computed from `pendingRequest` β€” `true` when a pending request is present.
var hasPendingRequest: Bool { pendingRequest != nil }

/// The server-assigned UUID that uniquely identifies this device record across all users.
let id: String

/// The client-generated UUID embedded in the app on this device, used for push notifications
/// and device recognition.
let identifier: String

/// Whether this is the current session's device.
var isCurrentSession: Bool

/// Whether the device is trusted.
let isTrusted: Bool

/// 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: - DeviceListItem Extension

extension DeviceListItem {
// MARK: Initialization

/// 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,
) {
activityStatus = DeviceActivityStatus(from: device.lastActivityDate, timeProvider: timeProvider)
deviceType = device.type
displayName = device.type.displayName
firstLogin = device.creationDate
id = device.id
identifier = device.identifier
isCurrentSession = false
isTrusted = device.isTrusted
lastActivityDate = device.lastActivityDate
pendingRequest = nil
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import BitwardenKit
import Foundation

@testable import BitwardenShared

extension DeviceListItem {
static func fixture(
activityStatus: DeviceActivityStatus = .today,
deviceType: DeviceType = .iOS,
displayName: String = "Mobile - iOS",
firstLogin: Date = Date(timeIntervalSince1970: 1_704_067_200),
id: String = "device-id-1",
identifier: String = "device-identifier-1",
isCurrentSession: Bool = false,
isTrusted: Bool = true,
lastActivityDate: Date? = Date(timeIntervalSince1970: 1_718_452_200),
pendingRequest: LoginRequest? = nil,
) -> DeviceListItem {
DeviceListItem(
activityStatus: activityStatus,
deviceType: deviceType,
displayName: displayName,
firstLogin: firstLogin,
id: id,
identifier: identifier,
isCurrentSession: isCurrentSession,
isTrusted: isTrusted,
lastActivityDate: lastActivityDate,
pendingRequest: pendingRequest,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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 last week.
case lastWeek

/// The device was active over 30 days ago.
case overThirtyDaysAgo

/// The device was active 14 to 29 days ago (but not this or last week).
case thisMonth

/// The device was active this week (but not today).
case thisWeek

/// The device was active today.
case today

/// The device's activity status is unknown.
case unknown

// MARK: Properties

/// The localized display string for the activity status.
var localizedString: String {
switch self {
case .lastWeek:
Localizations.pastFourteenDays
case .overThirtyDaysAgo:
Localizations.overThirtyDaysAgo
case .thisMonth:
Localizations.pastThirtyDays
case .thisWeek:
Localizations.pastSevenDays
case .today:
Localizations.today
case .unknown:
Localizations.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

guard date <= now else {
self = .unknown
return
}

let calendar = Calendar.current
let startOfDate = calendar.startOfDay(for: date)
let startOfToday = calendar.startOfDay(for: now)

// `.day` is always non-nil when explicitly requested via `dateComponents([.day]:)`;
// the guard is defensive.
guard let daysDifference = calendar.dateComponents([.day], from: startOfDate, to: startOfToday).day else {
self = .unknown
return
}

// Bucket boundaries mirror the Android app's day-count logic exactly, so both platforms
// show identical "last active" labels for the same date.
switch daysDifference {
case 0:
self = .today
case 1 ..< 7:
self = .thisWeek
case 7 ..< 14:
self = .lastWeek
case 14 ..< 30:
self = .thisMonth
default:
self = .overThirtyDaysAgo
}
}
}
Loading
Loading