Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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";
"ThisWeek" = "This week";
"LastWeek" = "Last week";
"ThisMonth" = "This month";
"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,94 @@
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 this calendar month (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.lastWeek
case .overThirtyDaysAgo:
Localizations.overThirtyDaysAgo
case .thisMonth:
Localizations.thisMonth
case .thisWeek:
Localizations.thisWeek
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
}

switch daysDifference {
case 0:
self = .today
case 1 ... 7:
self = .thisWeek
case 8 ... 14:
self = .lastWeek
default:
// Beyond the last two weeks, only classify as `.thisMonth` if the date actually
// falls within the current calendar month; a day count alone can't tell June 20
// apart from July 20 relative to a July 15 "now".
self = calendar.isDate(date, equalTo: now, toGranularity: .month) ? .thisMonth : .overThirtyDaysAgo

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€” I still feel like this isn't quite right. Now if it's July 15, and the last activity was June 20, it would return .overThirtyDaysAgo even though it's less than 30 days. Is it worth adding an "X days ago" label to handle the case where it isn't this month but it also isn't over 30 days ago?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just noticed that this changed to match other clients, fixing it now.

}
}
}
Loading
Loading