Create initial item view (#7)
@ -42,7 +42,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
let errorReporter = CrashlyticsErrorReporter()
|
||||
#endif
|
||||
|
||||
let services = ServiceContainer()
|
||||
let services = ServiceContainer(
|
||||
application: UIApplication.shared,
|
||||
errorReporter: errorReporter
|
||||
)
|
||||
let appModule = DefaultAppModule(services: services)
|
||||
appProcessor = AppProcessor(appModule: appModule, services: services)
|
||||
return true
|
||||
|
||||
@ -1,84 +1,84 @@
|
||||
import CoreData
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
|
||||
@FetchRequest(
|
||||
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
|
||||
animation: .default
|
||||
)
|
||||
private var items: FetchedResults<Item>
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
ForEach(items) { item in
|
||||
NavigationLink {
|
||||
Text("Item at \(item.timestamp!, formatter: itemFormatter)")
|
||||
} label: {
|
||||
Text(item.timestamp!, formatter: itemFormatter)
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteItems)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
EditButton()
|
||||
}
|
||||
ToolbarItem {
|
||||
Button(action: addItem) {
|
||||
Label("Add Item", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("Select an item")
|
||||
}
|
||||
}
|
||||
|
||||
private func addItem() {
|
||||
withAnimation {
|
||||
let newItem = Item(context: viewContext)
|
||||
newItem.timestamp = Date()
|
||||
|
||||
do {
|
||||
try viewContext.save()
|
||||
} catch {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate.
|
||||
// You should not use this function in a shipping application,
|
||||
// although it may be useful during development.
|
||||
let nsError = error as NSError
|
||||
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteItems(offsets: IndexSet) {
|
||||
withAnimation {
|
||||
offsets.map { items[$0] }.forEach(viewContext.delete)
|
||||
|
||||
do {
|
||||
try viewContext.save()
|
||||
} catch {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate.
|
||||
// You should not use this function in a shipping application,
|
||||
// although it may be useful during development.
|
||||
let nsError = error as NSError
|
||||
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let itemFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .medium
|
||||
return formatter
|
||||
}()
|
||||
|
||||
#Preview {
|
||||
ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
|
||||
}
|
||||
//import CoreData
|
||||
//import SwiftUI
|
||||
//
|
||||
//struct ContentView: View {
|
||||
// @Environment(\.managedObjectContext) private var viewContext
|
||||
//
|
||||
// @FetchRequest(
|
||||
// sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
|
||||
// animation: .default
|
||||
// )
|
||||
// private var items: FetchedResults<Item>
|
||||
//
|
||||
// var body: some View {
|
||||
// NavigationView {
|
||||
// List {
|
||||
// ForEach(items) { item in
|
||||
// NavigationLink {
|
||||
// Text("Item at \(item.timestamp!, formatter: itemFormatter)")
|
||||
// } label: {
|
||||
// Text(item.timestamp!, formatter: itemFormatter)
|
||||
// }
|
||||
// }
|
||||
// .onDelete(perform: deleteItems)
|
||||
// }
|
||||
// .toolbar {
|
||||
// ToolbarItem(placement: .navigationBarTrailing) {
|
||||
// EditButton()
|
||||
// }
|
||||
// ToolbarItem {
|
||||
// Button(action: addItem) {
|
||||
// Label("Add Item", systemImage: "plus")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// Text("Select an item")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func addItem() {
|
||||
// withAnimation {
|
||||
// let newItem = Item(context: viewContext)
|
||||
// newItem.timestamp = Date()
|
||||
//
|
||||
// do {
|
||||
// try viewContext.save()
|
||||
// } catch {
|
||||
// // Replace this implementation with code to handle the error appropriately.
|
||||
// // fatalError() causes the application to generate a crash log and terminate.
|
||||
// // You should not use this function in a shipping application,
|
||||
// // although it may be useful during development.
|
||||
// let nsError = error as NSError
|
||||
// fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func deleteItems(offsets: IndexSet) {
|
||||
// withAnimation {
|
||||
// offsets.map { items[$0] }.forEach(viewContext.delete)
|
||||
//
|
||||
// do {
|
||||
// try viewContext.save()
|
||||
// } catch {
|
||||
// // Replace this implementation with code to handle the error appropriately.
|
||||
// // fatalError() causes the application to generate a crash log and terminate.
|
||||
// // You should not use this function in a shipping application,
|
||||
// // although it may be useful during development.
|
||||
// let nsError = error as NSError
|
||||
// fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//private let itemFormatter: DateFormatter = {
|
||||
// let formatter = DateFormatter()
|
||||
// formatter.dateStyle = .short
|
||||
// formatter.timeStyle = .medium
|
||||
// return formatter
|
||||
//}()
|
||||
//
|
||||
//#Preview {
|
||||
// ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
|
||||
//}
|
||||
|
||||
@ -1,55 +1,55 @@
|
||||
import CoreData
|
||||
//import CoreData
|
||||
|
||||
struct Persistence {}
|
||||
//struct Persistence {}
|
||||
|
||||
struct PersistenceController {
|
||||
static let shared = PersistenceController()
|
||||
|
||||
static var preview: PersistenceController = {
|
||||
let result = PersistenceController(inMemory: true)
|
||||
let viewContext = result.container.viewContext
|
||||
for _ in 0 ..< 10 {
|
||||
let newItem = Item(context: viewContext)
|
||||
newItem.timestamp = Date()
|
||||
}
|
||||
do {
|
||||
try viewContext.save()
|
||||
} catch {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate.
|
||||
// You should not use this function in a shipping application, although it may be useful during development.
|
||||
let nsError = error as NSError
|
||||
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||
}
|
||||
return result
|
||||
}()
|
||||
|
||||
let container: NSPersistentContainer
|
||||
|
||||
init(inMemory: Bool = false) {
|
||||
container = NSPersistentContainer(name: "Authenticator")
|
||||
if inMemory {
|
||||
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
|
||||
}
|
||||
container.loadPersistentStores(completionHandler: { _, error in
|
||||
if let error = error as NSError? {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate.
|
||||
// You should not use this function in a shipping application,
|
||||
// although it may be useful during development.
|
||||
|
||||
//
|
||||
// Typical reasons for an error here include:
|
||||
// * The parent directory does not exist, cannot be created, or disallows writing.
|
||||
// * The persistent store is not accessible,
|
||||
// due to permissions or data protection when the device is locked.
|
||||
// * The device is out of space.
|
||||
// * The store could not be migrated to the current model version.
|
||||
// Check the error message to determine what the actual problem was.
|
||||
//
|
||||
fatalError("Unresolved error \(error), \(error.userInfo)")
|
||||
}
|
||||
})
|
||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
}
|
||||
}
|
||||
//struct PersistenceController {
|
||||
// static let shared = PersistenceController()
|
||||
//
|
||||
// static var preview: PersistenceController = {
|
||||
// let result = PersistenceController(inMemory: true)
|
||||
// let viewContext = result.container.viewContext
|
||||
// for _ in 0 ..< 10 {
|
||||
// let newItem = Item(context: viewContext)
|
||||
// newItem.timestamp = Date()
|
||||
// }
|
||||
// do {
|
||||
// try viewContext.save()
|
||||
// } catch {
|
||||
// // Replace this implementation with code to handle the error appropriately.
|
||||
// // fatalError() causes the application to generate a crash log and terminate.
|
||||
// // You should not use this function in a shipping application, although it may be useful during development.
|
||||
// let nsError = error as NSError
|
||||
// fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||
// }
|
||||
// return result
|
||||
// }()
|
||||
//
|
||||
// let container: NSPersistentContainer
|
||||
//
|
||||
// init(inMemory: Bool = false) {
|
||||
// container = NSPersistentContainer(name: "Authenticator")
|
||||
// if inMemory {
|
||||
// container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
|
||||
// }
|
||||
// container.loadPersistentStores(completionHandler: { _, error in
|
||||
// if let error = error as NSError? {
|
||||
// // Replace this implementation with code to handle the error appropriately.
|
||||
// // fatalError() causes the application to generate a crash log and terminate.
|
||||
// // You should not use this function in a shipping application,
|
||||
// // although it may be useful during development.
|
||||
//
|
||||
// //
|
||||
// // Typical reasons for an error here include:
|
||||
// // * The parent directory does not exist, cannot be created, or disallows writing.
|
||||
// // * The persistent store is not accessible,
|
||||
// // due to permissions or data protection when the device is locked.
|
||||
// // * The device is out of space.
|
||||
// // * The store could not be migrated to the current model version.
|
||||
// // Check the error message to determine what the actual problem was.
|
||||
// //
|
||||
// fatalError("Unresolved error \(error), \(error.userInfo)")
|
||||
// }
|
||||
// })
|
||||
// container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
// }
|
||||
//}
|
||||
|
||||
@ -1,61 +0,0 @@
|
||||
import AuthenticatorShared
|
||||
import XCTest
|
||||
|
||||
@testable import Authenticator
|
||||
|
||||
// MARK: - SceneDelegateTests
|
||||
|
||||
class SceneDelegateTests: AuthenticatorTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var appCoordinator: MockCoordinator<AppRoute>!
|
||||
var appModule: MockAppModule!
|
||||
var subject: SceneDelegate!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
appCoordinator = MockCoordinator<AppRoute>()
|
||||
appModule = MockAppModule()
|
||||
appModule.appCoordinator = appCoordinator.asAnyCoordinator()
|
||||
subject = SceneDelegate()
|
||||
subject.appModule = appModule
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
appModule = nil
|
||||
subject = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// `scene(_:willConnectTo:options:)` with a `UIWindowScene` creates the app's UI.
|
||||
func test_sceneWillConnectTo_withWindowScene() throws {
|
||||
let session = TestInstanceFactory.create(UISceneSession.self)
|
||||
let scene = TestInstanceFactory.create(UIWindowScene.self, properties: [
|
||||
"session": session,
|
||||
])
|
||||
let options = TestInstanceFactory.create(UIScene.ConnectionOptions.self)
|
||||
subject.scene(scene, willConnectTo: session, options: options)
|
||||
|
||||
XCTAssertNotNil(subject.appCoordinator)
|
||||
XCTAssertNotNil(subject.window)
|
||||
XCTAssertTrue(appCoordinator.isStarted)
|
||||
}
|
||||
|
||||
/// `scene(_:willConnectTo:options:)` without a `UIWindowScene` fails to create the app's UI.
|
||||
func test_sceneWillConnectTo_withNonWindowScene() throws {
|
||||
let session = TestInstanceFactory.create(UISceneSession.self)
|
||||
let scene = TestInstanceFactory.create(UIScene.self, properties: [
|
||||
"session": session,
|
||||
])
|
||||
let options = TestInstanceFactory.create(UIScene.ConnectionOptions.self)
|
||||
subject.scene(scene, willConnectTo: session, options: options)
|
||||
|
||||
XCTAssertNil(subject.appCoordinator)
|
||||
XCTAssertNil(subject.window)
|
||||
XCTAssertFalse(appCoordinator.isStarted)
|
||||
}
|
||||
}
|
||||
@ -1,18 +1,12 @@
|
||||
import AuthenticatorShared
|
||||
import UIKit
|
||||
|
||||
@testable import Authenticator
|
||||
|
||||
class TestingAppDelegate: NSObject, UIApplicationDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||
window.rootViewController = UIViewController()
|
||||
window.makeKeyAndVisible()
|
||||
self.window = window
|
||||
return true
|
||||
}
|
||||
/// A replacement for `AppDelegate` that allows for checking that certain app delegate methods get called at the
|
||||
/// appropriate times during unit tests.
|
||||
///
|
||||
class TestingAppDelegate: NSObject, UIApplicationDelegate, AppDelegateType {
|
||||
var appProcessor: AppProcessor?
|
||||
var isTesting = false
|
||||
}
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
import AuthenticatorShared
|
||||
import UIKit
|
||||
|
||||
extension UIApplication: Application {}
|
||||
@ -0,0 +1,111 @@
|
||||
import BitwardenSdk
|
||||
import Foundation
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
class MockClientAuth: ClientAuthProtocol {
|
||||
var approveAuthRequestPublicKey: String?
|
||||
var approveAuthRequestResult: Result<AsymmetricEncString, Error> = .success("")
|
||||
|
||||
var hashPasswordEmail: String?
|
||||
var hashPasswordPassword: String?
|
||||
var hashPasswordKdfParams: Kdf?
|
||||
var hashPasswordPurpose: HashPurpose?
|
||||
var hashPasswordResult: Result<String, Error> = .success("hash password")
|
||||
|
||||
var makeRegisterKeysEmail: String?
|
||||
var makeRegisterKeysPassword: String?
|
||||
var makeRegisterKeysKdf: Kdf?
|
||||
var makeRegisterKeysResult: Result<RegisterKeyResponse, Error> = .success(RegisterKeyResponse(
|
||||
masterPasswordHash: "masterPasswordHash",
|
||||
encryptedUserKey: "encryptedUserKey",
|
||||
keys: RsaKeyPair(public: "public", private: "private")
|
||||
))
|
||||
|
||||
var newAuthRequestEmail: String?
|
||||
var newAuthRequestResult: Result<AuthRequestResponse, Error> = .success(
|
||||
AuthRequestResponse(
|
||||
privateKey: "private",
|
||||
publicKey: "public",
|
||||
fingerprint: "fingerprint",
|
||||
accessCode: "12345"
|
||||
)
|
||||
)
|
||||
var passwordStrengthResult = UInt8(2)
|
||||
var passwordStrengthPassword: String?
|
||||
var passwordStrengthEmail: String?
|
||||
var passwordStrengthAdditionalInputs: [String]?
|
||||
|
||||
var satisfiesPolicyPassword: String?
|
||||
var satisfiesPolicyStrength: UInt8?
|
||||
var satisfiesPolicyPolicy: MasterPasswordPolicyOptions?
|
||||
var satisfiesPolicyResult = true
|
||||
|
||||
var validatePasswordPassword: String?
|
||||
var validatePasswordPasswordHash: String?
|
||||
var validatePasswordResult: Bool = false
|
||||
|
||||
var validatePasswordUserKeyEncryptedUserKey: String?
|
||||
var validatePasswordUserKeyPassword: String?
|
||||
var validatePasswordUserKeyResult: Result<String, Error> = .success("MASTER_PASSWORD_HASH")
|
||||
|
||||
func approveAuthRequest(publicKey: String) async throws -> AsymmetricEncString {
|
||||
approveAuthRequestPublicKey = publicKey
|
||||
return try approveAuthRequestResult.get()
|
||||
}
|
||||
|
||||
func hashPassword(email: String, password: String, kdfParams: Kdf, purpose: HashPurpose) async throws -> String {
|
||||
hashPasswordEmail = email
|
||||
hashPasswordPassword = password
|
||||
hashPasswordKdfParams = kdfParams
|
||||
hashPasswordPurpose = purpose
|
||||
|
||||
return try hashPasswordResult.get()
|
||||
}
|
||||
|
||||
func makeRegisterKeys(email: String, password: String, kdf: Kdf) async throws -> RegisterKeyResponse {
|
||||
makeRegisterKeysEmail = email
|
||||
makeRegisterKeysPassword = password
|
||||
makeRegisterKeysKdf = kdf
|
||||
|
||||
return try makeRegisterKeysResult.get()
|
||||
}
|
||||
|
||||
func newAuthRequest(email: String) async throws -> AuthRequestResponse {
|
||||
newAuthRequestEmail = email
|
||||
return try newAuthRequestResult.get()
|
||||
}
|
||||
|
||||
func passwordStrength(password: String, email: String, additionalInputs: [String]) async -> UInt8 {
|
||||
passwordStrengthPassword = password
|
||||
passwordStrengthEmail = email
|
||||
passwordStrengthAdditionalInputs = additionalInputs
|
||||
|
||||
return passwordStrengthResult
|
||||
}
|
||||
|
||||
func satisfiesPolicy(password: String, strength: UInt8, policy: MasterPasswordPolicyOptions) async -> Bool {
|
||||
satisfiesPolicyPassword = password
|
||||
satisfiesPolicyStrength = strength
|
||||
satisfiesPolicyPolicy = policy
|
||||
|
||||
return satisfiesPolicyResult
|
||||
}
|
||||
|
||||
func trustDevice() async throws -> BitwardenSdk.TrustDeviceResponse {
|
||||
// Nothing yet.
|
||||
throw AuthenticatorTestError.example
|
||||
}
|
||||
|
||||
func validatePassword(password: String, passwordHash: String) async throws -> Bool {
|
||||
validatePasswordPassword = password
|
||||
validatePasswordPasswordHash = passwordHash
|
||||
return validatePasswordResult
|
||||
}
|
||||
|
||||
func validatePasswordUserKey(password: String, encryptedUserKey: String) async throws -> String {
|
||||
validatePasswordUserKeyPassword = password
|
||||
validatePasswordUserKeyEncryptedUserKey = encryptedUserKey
|
||||
return try validatePasswordUserKeyResult.get()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
import BitwardenSdk
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
class MockClientPlatform: ClientPlatformProtocol {
|
||||
var fingerprintMaterialString: String?
|
||||
var fingerprintResult: Result<String, Error> = .success("a-fingerprint-phrase-string-placeholder")
|
||||
var featureFlags: [String: Bool] = ["": false]
|
||||
var userFingerprintCalled = false
|
||||
|
||||
func fingerprint(req: BitwardenSdk.FingerprintRequest) async throws -> String {
|
||||
try fingerprintResult.get()
|
||||
}
|
||||
|
||||
func loadFlags(flags: [String: Bool]) async throws {
|
||||
featureFlags = flags
|
||||
}
|
||||
|
||||
func userFingerprint(fingerprintMaterial: String) async throws -> String {
|
||||
fingerprintMaterialString = fingerprintMaterial
|
||||
userFingerprintCalled = true
|
||||
return try fingerprintResult.get()
|
||||
}
|
||||
}
|
||||
10
AuthenticatorShared/Core/Platform/Extentions/Optional.swift
Normal file
@ -0,0 +1,10 @@
|
||||
// MARK: - Optional
|
||||
|
||||
extension Optional where Wrapped: Collection {
|
||||
// MARK: Properties
|
||||
|
||||
/// Returns true if the value is `nil` or an empty collection.
|
||||
var isEmptyOrNil: Bool {
|
||||
self?.isEmpty ?? true
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import XCTest
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
class OptionalTests: AuthenticatorTestCase {
|
||||
// MARK: Tests
|
||||
|
||||
/// `isEmptyOrNil` returns `true` if the wrapped collection is empty.
|
||||
func test_isEmptyOrNil_empty() {
|
||||
let subject: [String]? = []
|
||||
XCTAssertTrue(subject.isEmptyOrNil)
|
||||
}
|
||||
|
||||
/// `isEmptyOrNil` returns `true` if the value is `nil`.
|
||||
func test_isEmptyOrNil_nil() {
|
||||
let subject: [String]? = nil
|
||||
XCTAssertTrue(subject.isEmptyOrNil)
|
||||
}
|
||||
|
||||
/// `isEmptyOrNil` returns `false` if the wrapped collection is not empty.
|
||||
func test_isEmptyOrNil_notEmpty() {
|
||||
let subject: [String]? = ["a", "b", "c"]
|
||||
XCTAssertFalse(subject.isEmptyOrNil)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
extension Sequence {
|
||||
/// Maps the elements of an array with an async Transform.
|
||||
///
|
||||
/// - Parameter transform: An asynchronous function mapping the sequence element.
|
||||
///
|
||||
func asyncMap<T>(
|
||||
_ transform: (Element) async throws -> T
|
||||
) async rethrows -> [T] {
|
||||
var values = [T]()
|
||||
|
||||
for element in self {
|
||||
try await values.append(transform(element))
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
import XCTest
|
||||
|
||||
final class SequenceAsyncTests: AuthenticatorTestCase {
|
||||
/// `asyncMap` correctly maps each element.
|
||||
func test_asyncMap_success() async {
|
||||
let input = [1, 2, 3]
|
||||
let output = await input.asyncMap { number in
|
||||
await asyncDouble(number)
|
||||
}
|
||||
XCTAssertEqual(output, [2, 4, 6])
|
||||
}
|
||||
|
||||
/// `asyncMap` correctly propagates errors.
|
||||
func test_asyncMap_error() async {
|
||||
let input = [1, 2, 3]
|
||||
await assertAsyncThrows {
|
||||
_ = try await input.asyncMap { number in
|
||||
try await asyncDoubleWithError(number)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to double a number asynchronously.
|
||||
///
|
||||
/// - Parameter number: an `Int` to double.
|
||||
/// - Returns: The number multiplied by 2.
|
||||
///
|
||||
private func asyncDouble(_ number: Int) async -> Int {
|
||||
number * 2
|
||||
}
|
||||
|
||||
/// Helper function to double a number asynchronously and throw an error for a specific case.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - number: an `Int` to double.
|
||||
/// - errorCase: The `Int` for which to throw an error. Default is 2.
|
||||
/// - Returns: The number multiplied by 2.
|
||||
///
|
||||
private func asyncDoubleWithError(_ number: Int, errorCase: Int = 2) async throws -> Int {
|
||||
if number == errorCase {
|
||||
throw NSError(domain: "TestError", code: 0, userInfo: nil)
|
||||
}
|
||||
return number * 2
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
/// A protocol for the application instance (i.e. `UIApplication`).
|
||||
///
|
||||
public protocol Application {
|
||||
/// Registers the application to receive remote push notifications.
|
||||
///
|
||||
func registerForRemoteNotifications()
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
import BitwardenSdk
|
||||
|
||||
/// A protocol for service that handles common client functionality such as encryption and
|
||||
/// decryption.
|
||||
///
|
||||
protocol ClientService {
|
||||
/// Returns a `ClientAuthProtocol` for auth data tasks.
|
||||
///
|
||||
func clientAuth() -> ClientAuthProtocol
|
||||
|
||||
/// Returns a `ClientCryptoProtocol` for crypto data tasks.
|
||||
///
|
||||
func clientCrypto() -> ClientCryptoProtocol
|
||||
|
||||
/// Returns a `ClientExportersProtocol` for vault export data tasks.
|
||||
///
|
||||
func clientExporters() -> ClientExportersProtocol
|
||||
|
||||
/// Returns a `ClientGeneratorsProtocol` for generator data tasks.
|
||||
///
|
||||
func clientGenerator() -> ClientGeneratorsProtocol
|
||||
|
||||
/// Returns a `ClientPlatformProtocol` for client platform tasks.
|
||||
///
|
||||
func clientPlatform() -> ClientPlatformProtocol
|
||||
|
||||
/// Returns a `ClientVaultService` for vault data tasks.
|
||||
///
|
||||
func clientVault() -> ClientVaultService
|
||||
}
|
||||
|
||||
// MARK: - DefaultClientService
|
||||
|
||||
/// A default `ClientService` implementation. This is a thin wrapper around the SDK `Client` so that
|
||||
/// it can be swapped to a mock instance during tests.
|
||||
///
|
||||
class DefaultClientService: ClientService {
|
||||
// MARK: Properties
|
||||
|
||||
/// The `Client` instance used to access `BitwardenSdk`.
|
||||
private let client: Client
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Initialize a `DefaultClientService`.
|
||||
///
|
||||
/// - Parameter settings: The settings to apply to the client. Defaults to `nil`.
|
||||
///
|
||||
init(settings: ClientSettings? = nil) {
|
||||
client = Client(settings: settings)
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func clientAuth() -> ClientAuthProtocol {
|
||||
client.auth()
|
||||
}
|
||||
|
||||
func clientCrypto() -> ClientCryptoProtocol {
|
||||
client.crypto()
|
||||
}
|
||||
|
||||
func clientExporters() -> ClientExportersProtocol {
|
||||
client.exporters()
|
||||
}
|
||||
|
||||
func clientGenerator() -> ClientGeneratorsProtocol {
|
||||
client.generators()
|
||||
}
|
||||
|
||||
func clientPlatform() -> ClientPlatformProtocol {
|
||||
client.platform()
|
||||
}
|
||||
|
||||
func clientVault() -> ClientVaultService {
|
||||
client.vault()
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,6 @@
|
||||
import BitwardenSdk
|
||||
import UIKit
|
||||
|
||||
// swiftlint:disable file_length
|
||||
|
||||
/// The `ServiceContainer` contains the list of services used by the app. This can be injected into
|
||||
/// `Coordinator`s throughout the app which build processors. A `Processor` can define which
|
||||
/// services it needs access to by defining a typealias containing a list of services.
|
||||
@ -14,32 +12,83 @@ import UIKit
|
||||
/// & HasExampleRepository
|
||||
/// }
|
||||
///
|
||||
public class ServiceContainer: Services { // swiftlint:disable:this type_body_length
|
||||
public class ServiceContainer: Services {
|
||||
// MARK: Properties
|
||||
|
||||
/// The application instance (i.e. `UIApplication`), if the app isn't running in an extension.
|
||||
let application: Application?
|
||||
|
||||
/// The service used by the application to handle encryption and decryption tasks.
|
||||
let clientService: ClientService
|
||||
|
||||
/// The service used by the application to report non-fatal errors.
|
||||
let errorReporter: ErrorReporter
|
||||
|
||||
/// The repository used by the application to manage item data for the UI layer.
|
||||
let itemRepository: ItemRepository
|
||||
|
||||
/// Provides the present time for TOTP Code Calculation.
|
||||
let timeProvider: TimeProvider
|
||||
|
||||
/// The service used by the application to validate TOTP keys and produce TOTP values.
|
||||
let totpService: TOTPService
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Initialize a `ServiceContainer`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - application: The application instance.
|
||||
/// - clientService: The service used by the application to handle encryption and decryption tasks.
|
||||
/// - errorReporter: The service used by the application to report non-fatal errors.
|
||||
/// - itemRepository: The repository used by the application to manage vault data for the UI layer.
|
||||
/// - timeProvider: Provides the present time for TOTP Code Calculation.
|
||||
/// - totpService: The service used by the application to validate TOTP keys and produce TOTP values.
|
||||
///
|
||||
init(
|
||||
timeProvider: TimeProvider
|
||||
application: Application?,
|
||||
clientService: ClientService,
|
||||
errorReporter: ErrorReporter,
|
||||
itemRepository: ItemRepository,
|
||||
timeProvider: TimeProvider,
|
||||
totpService: TOTPService
|
||||
) {
|
||||
self.application = application
|
||||
self.clientService = clientService
|
||||
self.errorReporter = errorReporter
|
||||
self.itemRepository = itemRepository
|
||||
self.timeProvider = timeProvider
|
||||
self.totpService = totpService
|
||||
}
|
||||
|
||||
/// A convenience initializer to initialize the `ServiceContainer` with the default services.
|
||||
///
|
||||
public convenience init() {
|
||||
/// - Parameters:
|
||||
/// - application: The application instance.
|
||||
/// - errorReporter: The service used by the application to report non-fatal errors.
|
||||
///
|
||||
public convenience init(
|
||||
application: Application? = nil,
|
||||
errorReporter: ErrorReporter
|
||||
) {
|
||||
let clientService = DefaultClientService()
|
||||
|
||||
let timeProvider = CurrentTime()
|
||||
|
||||
let totpService = DefaultTOTPService()
|
||||
|
||||
let itemRepository = DefaultItemRepository(
|
||||
clientVault: clientService.clientVault(),
|
||||
errorReporter: errorReporter,
|
||||
timeProvider: timeProvider)
|
||||
|
||||
self.init(
|
||||
timeProvider: timeProvider
|
||||
application: application,
|
||||
clientService: clientService,
|
||||
errorReporter: errorReporter,
|
||||
itemRepository: itemRepository,
|
||||
timeProvider: timeProvider,
|
||||
totpService: totpService
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,31 @@
|
||||
import BitwardenSdk
|
||||
|
||||
/// The services provided by the `ServiceContainer`.
|
||||
typealias Services = HasTimeProvider
|
||||
typealias Services = HasErrorReporter
|
||||
& HasItemRepository
|
||||
& HasTOTPService
|
||||
& HasTimeProvider
|
||||
|
||||
/// Protocol for an object that provides an `ErrorReporter`.
|
||||
///
|
||||
protocol HasErrorReporter {
|
||||
/// The service used by the application to report non-fatal errors.
|
||||
var errorReporter: ErrorReporter { get }
|
||||
}
|
||||
|
||||
/// Protocol for an object that provides an `ItemRepository`.
|
||||
///
|
||||
protocol HasItemRepository {
|
||||
/// The repository used by the application to manage item data for the UI layer.
|
||||
var itemRepository: ItemRepository { get }
|
||||
}
|
||||
|
||||
/// Protocol for an object that provides a `TOTPService`.
|
||||
///
|
||||
protocol HasTOTPService {
|
||||
/// A service used to validate authentication keys and generate TOTP codes.
|
||||
var totpService: TOTPService { get }
|
||||
}
|
||||
|
||||
/// Protocol for an object that provides a `TimeProvider`.
|
||||
///
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
import BitwardenSdk
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
class MockClientService: ClientService {
|
||||
var clientAuthService: MockClientAuth
|
||||
var clientExportersService: MockClientExporters
|
||||
var clientPlatformService: MockClientPlatform
|
||||
var clientVaultService: MockClientVaultService
|
||||
|
||||
init(
|
||||
clientAuth: MockClientAuth = MockClientAuth(),
|
||||
clientExporters: MockClientExporters = MockClientExporters(),
|
||||
clientPlatform: MockClientPlatform = MockClientPlatform(),
|
||||
clientVault: MockClientVaultService = MockClientVaultService()
|
||||
) {
|
||||
clientAuthService = clientAuth
|
||||
clientExportersService = clientExporters
|
||||
clientPlatformService = clientPlatform
|
||||
clientVaultService = clientVault
|
||||
}
|
||||
|
||||
func clientAuth() -> ClientAuthProtocol {
|
||||
clientAuthService
|
||||
}
|
||||
|
||||
func clientCrypto() -> ClientCryptoProtocol {
|
||||
fatalError("Not implemented yet")
|
||||
}
|
||||
|
||||
func clientExporters() -> BitwardenSdk.ClientExportersProtocol {
|
||||
clientExportersService
|
||||
}
|
||||
|
||||
func clientGenerator() -> ClientGeneratorsProtocol {
|
||||
fatalError("Not implemented yet")
|
||||
}
|
||||
|
||||
func clientPlatform() -> ClientPlatformProtocol {
|
||||
clientPlatformService
|
||||
}
|
||||
|
||||
func clientVault() -> ClientVaultService {
|
||||
clientVaultService
|
||||
}
|
||||
}
|
||||
@ -5,10 +5,20 @@ import Networking
|
||||
|
||||
extension ServiceContainer {
|
||||
static func withMocks(
|
||||
timeProvider: TimeProvider = MockTimeProvider(.currentTime)
|
||||
application: Application? = nil,
|
||||
clientService: ClientService = MockClientService(),
|
||||
errorReporter: ErrorReporter = MockErrorReporter(),
|
||||
itemRepository: ItemRepository = MockItemRepository(),
|
||||
timeProvider: TimeProvider = MockTimeProvider(.currentTime),
|
||||
totpService: TOTPService = MockTOTPService()
|
||||
) -> ServiceContainer {
|
||||
ServiceContainer(
|
||||
timeProvider: timeProvider
|
||||
application: application,
|
||||
clientService: clientService,
|
||||
errorReporter: errorReporter,
|
||||
itemRepository: itemRepository,
|
||||
timeProvider: timeProvider,
|
||||
totpService: totpService
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,5 @@
|
||||
import BitwardenSdk
|
||||
import Foundation
|
||||
|
||||
// MARK: - VaultRoute
|
||||
|
||||
/// A route to a specific screen in the vault tab.
|
||||
public enum VaultRoute: Equatable, Hashable {
|
||||
case onboarding
|
||||
}
|
||||
|
||||
@propertyWrapper
|
||||
struct AlwaysEqual<Value>: Equatable {
|
||||
var wrappedValue: Value
|
||||
@ -0,0 +1,388 @@
|
||||
// swiftlint:disable:this file_name
|
||||
|
||||
import BitwardenSdk
|
||||
import Foundation
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
extension AttachmentView {
|
||||
static func fixture(
|
||||
fileName: String? = nil,
|
||||
id: String? = "1",
|
||||
key: String? = nil,
|
||||
size: String? = nil,
|
||||
sizeName: String? = nil,
|
||||
url: String? = nil
|
||||
) -> AttachmentView {
|
||||
.init(
|
||||
id: id,
|
||||
url: url,
|
||||
size: size,
|
||||
sizeName: sizeName,
|
||||
fileName: fileName,
|
||||
key: key
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Cipher {
|
||||
static func fixture(
|
||||
attachments: [Attachment]? = nil,
|
||||
card: Card? = nil,
|
||||
collectionIds: [String] = [],
|
||||
creationDate: DateTime = Date(year: 2024, month: 1, day: 1),
|
||||
deletedDate: Date? = nil,
|
||||
edit: Bool = true,
|
||||
favorite: Bool = false,
|
||||
fields: [Field]? = nil,
|
||||
folderId: String? = nil,
|
||||
id: String? = nil,
|
||||
identity: Identity? = nil,
|
||||
key: String? = nil,
|
||||
localData: LocalData? = nil,
|
||||
login: BitwardenSdk.Login? = nil,
|
||||
name: String = "Bitwarden",
|
||||
notes: String? = nil,
|
||||
organizationId: String? = nil,
|
||||
organizationUseTotp: Bool = false,
|
||||
passwordHistory: [PasswordHistory]? = nil,
|
||||
reprompt: BitwardenSdk.CipherRepromptType = .none,
|
||||
revisionDate: Date = Date(year: 2024, month: 1, day: 1),
|
||||
secureNote: SecureNote? = nil,
|
||||
type: BitwardenSdk.CipherType = .login,
|
||||
viewPassword: Bool = true
|
||||
) -> Cipher {
|
||||
Cipher(
|
||||
id: id,
|
||||
organizationId: organizationId,
|
||||
folderId: folderId,
|
||||
collectionIds: collectionIds,
|
||||
key: key,
|
||||
name: name,
|
||||
notes: notes,
|
||||
type: type,
|
||||
login: login,
|
||||
identity: identity,
|
||||
card: card,
|
||||
secureNote: secureNote,
|
||||
favorite: favorite,
|
||||
reprompt: reprompt,
|
||||
organizationUseTotp: organizationUseTotp,
|
||||
edit: edit,
|
||||
viewPassword: viewPassword,
|
||||
localData: localData,
|
||||
attachments: attachments,
|
||||
fields: fields,
|
||||
passwordHistory: passwordHistory,
|
||||
creationDate: creationDate,
|
||||
deletedDate: deletedDate,
|
||||
revisionDate: revisionDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension CipherView {
|
||||
static func fixture(
|
||||
attachments: [AttachmentView]? = nil,
|
||||
card: CardView? = nil,
|
||||
collectionIds: [String] = [],
|
||||
creationDate: DateTime = Date(year: 2023, month: 11, day: 5, hour: 9, minute: 41),
|
||||
deletedDate: Date? = nil,
|
||||
edit: Bool = true,
|
||||
favorite: Bool = false,
|
||||
fields: [FieldView]? = nil,
|
||||
folderId: String? = nil,
|
||||
id: String? = "1",
|
||||
identity: IdentityView? = nil,
|
||||
key: String? = nil,
|
||||
localData: LocalDataView? = nil,
|
||||
login: BitwardenSdk.LoginView? = nil,
|
||||
name: String = "Bitwarden",
|
||||
notes: String? = nil,
|
||||
organizationId: String? = nil,
|
||||
organizationUseTotp: Bool = false,
|
||||
passwordHistory: [PasswordHistoryView]? = nil,
|
||||
reprompt: BitwardenSdk.CipherRepromptType = .none,
|
||||
revisionDate: Date = Date(year: 2023, month: 11, day: 5, hour: 9, minute: 41),
|
||||
secureNote: SecureNoteView? = nil,
|
||||
type: BitwardenSdk.CipherType = .login,
|
||||
viewPassword: Bool = true
|
||||
) -> CipherView {
|
||||
CipherView(
|
||||
id: id,
|
||||
organizationId: organizationId,
|
||||
folderId: folderId,
|
||||
collectionIds: collectionIds,
|
||||
key: key,
|
||||
name: name,
|
||||
notes: notes,
|
||||
type: type,
|
||||
login: login,
|
||||
identity: identity,
|
||||
card: card,
|
||||
secureNote: secureNote,
|
||||
favorite: favorite,
|
||||
reprompt: reprompt,
|
||||
organizationUseTotp: organizationUseTotp,
|
||||
edit: edit,
|
||||
viewPassword: viewPassword,
|
||||
localData: localData,
|
||||
attachments: attachments,
|
||||
fields: fields,
|
||||
passwordHistory: passwordHistory,
|
||||
creationDate: creationDate,
|
||||
deletedDate: deletedDate,
|
||||
revisionDate: revisionDate
|
||||
)
|
||||
}
|
||||
|
||||
static func cardFixture(
|
||||
attachments: [AttachmentView]? = nil,
|
||||
card: CardView = CardView.fixture(),
|
||||
collectionIds: [String] = [],
|
||||
creationDate: DateTime = Date(year: 2023, month: 11, day: 5, hour: 9, minute: 41),
|
||||
deletedDate: Date? = nil,
|
||||
edit: Bool = true,
|
||||
favorite: Bool = false,
|
||||
fields: [FieldView]? = nil,
|
||||
folderId: String? = nil,
|
||||
id: String = "8675",
|
||||
key: String? = nil,
|
||||
localData: LocalDataView? = nil,
|
||||
name: String = "Bitwarden",
|
||||
notes: String? = nil,
|
||||
organizationId: String? = nil,
|
||||
organizationUseTotp: Bool = false,
|
||||
passwordHistory: [PasswordHistoryView]? = nil,
|
||||
reprompt: BitwardenSdk.CipherRepromptType = .none,
|
||||
revisionDate: Date = Date(year: 2023, month: 11, day: 5, hour: 9, minute: 41),
|
||||
viewPassword: Bool = true
|
||||
) -> CipherView {
|
||||
CipherView(
|
||||
id: id,
|
||||
organizationId: organizationId,
|
||||
folderId: folderId,
|
||||
collectionIds: collectionIds,
|
||||
key: key,
|
||||
name: name,
|
||||
notes: notes,
|
||||
type: .card,
|
||||
login: nil,
|
||||
identity: nil,
|
||||
card: card,
|
||||
secureNote: nil,
|
||||
favorite: favorite,
|
||||
reprompt: reprompt,
|
||||
organizationUseTotp: organizationUseTotp,
|
||||
edit: edit,
|
||||
viewPassword: viewPassword,
|
||||
localData: localData,
|
||||
attachments: attachments,
|
||||
fields: fields,
|
||||
passwordHistory: passwordHistory,
|
||||
creationDate: creationDate,
|
||||
deletedDate: deletedDate,
|
||||
revisionDate: revisionDate
|
||||
)
|
||||
}
|
||||
|
||||
static func loginFixture(
|
||||
attachments: [AttachmentView]? = nil,
|
||||
collectionIds: [String] = [],
|
||||
creationDate: DateTime = Date(year: 2023, month: 11, day: 5, hour: 9, minute: 41),
|
||||
deletedDate: Date? = nil,
|
||||
edit: Bool = true,
|
||||
favorite: Bool = false,
|
||||
fields: [FieldView]? = nil,
|
||||
folderId: String? = nil,
|
||||
id: String = "8675",
|
||||
key: String? = nil,
|
||||
localData: LocalDataView? = nil,
|
||||
login: BitwardenSdk.LoginView = .fixture(),
|
||||
name: String = "Bitwarden",
|
||||
notes: String? = nil,
|
||||
organizationId: String? = nil,
|
||||
organizationUseTotp: Bool = false,
|
||||
passwordHistory: [PasswordHistoryView]? = nil,
|
||||
reprompt: BitwardenSdk.CipherRepromptType = .none,
|
||||
revisionDate: Date = Date(year: 2023, month: 11, day: 5, hour: 9, minute: 41),
|
||||
viewPassword: Bool = true
|
||||
) -> CipherView {
|
||||
CipherView(
|
||||
id: id,
|
||||
organizationId: organizationId,
|
||||
folderId: folderId,
|
||||
collectionIds: collectionIds,
|
||||
key: key,
|
||||
name: name,
|
||||
notes: notes,
|
||||
type: .login,
|
||||
login: login,
|
||||
identity: nil,
|
||||
card: nil,
|
||||
secureNote: nil,
|
||||
favorite: favorite,
|
||||
reprompt: reprompt,
|
||||
organizationUseTotp: organizationUseTotp,
|
||||
edit: edit,
|
||||
viewPassword: viewPassword,
|
||||
localData: localData,
|
||||
attachments: attachments,
|
||||
fields: fields,
|
||||
passwordHistory: passwordHistory,
|
||||
creationDate: creationDate,
|
||||
deletedDate: deletedDate,
|
||||
revisionDate: revisionDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection {
|
||||
static func fixture(
|
||||
id: String? = "",
|
||||
organizationId: String = "",
|
||||
name: String = "",
|
||||
externalId: String = "",
|
||||
hidePasswords: Bool = false,
|
||||
readOnly: Bool = false
|
||||
) -> Collection {
|
||||
Collection(
|
||||
id: id,
|
||||
organizationId: organizationId,
|
||||
name: name,
|
||||
externalId: externalId,
|
||||
hidePasswords: hidePasswords,
|
||||
readOnly: readOnly
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension BitwardenSdk.CardView {
|
||||
static func fixture(
|
||||
brand: String? = nil,
|
||||
cardholderName: String? = nil,
|
||||
code: String? = nil,
|
||||
expMonth: String? = nil,
|
||||
expYear: String? = nil,
|
||||
number: String? = nil
|
||||
) -> BitwardenSdk.CardView {
|
||||
BitwardenSdk.CardView(
|
||||
cardholderName: cardholderName,
|
||||
expMonth: expMonth,
|
||||
expYear: expYear,
|
||||
code: code,
|
||||
brand: brand,
|
||||
number: number
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension CollectionView {
|
||||
static func fixture(
|
||||
externalId: String = "",
|
||||
hidePasswords: Bool = false,
|
||||
id: String = "collection-view-1",
|
||||
name: String = "",
|
||||
organizationId: String = "",
|
||||
readOnly: Bool = false
|
||||
) -> CollectionView {
|
||||
CollectionView(
|
||||
id: id,
|
||||
organizationId: organizationId,
|
||||
name: name,
|
||||
externalId: externalId,
|
||||
hidePasswords: hidePasswords,
|
||||
readOnly: readOnly
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Fido2Credential {
|
||||
static func fixture(
|
||||
counter: String = "",
|
||||
creationDate: Date = Date(year: 2024, month: 3, day: 15, hour: 9, minute: 15),
|
||||
credentialId: String = "",
|
||||
discoverable: String = "",
|
||||
keyAlgorithm: String = "",
|
||||
keyCurve: String = "",
|
||||
keyType: String = "",
|
||||
keyValue: String = "",
|
||||
rpId: String = "",
|
||||
rpName: String? = nil,
|
||||
userDisplayName: String? = nil,
|
||||
userHandle: String? = nil,
|
||||
userName: String? = nil
|
||||
) -> Fido2Credential {
|
||||
Fido2Credential(
|
||||
credentialId: credentialId,
|
||||
keyType: keyType,
|
||||
keyAlgorithm: keyAlgorithm,
|
||||
keyCurve: keyCurve,
|
||||
keyValue: keyValue,
|
||||
rpId: rpId,
|
||||
userHandle: userHandle,
|
||||
userName: userName,
|
||||
counter: counter,
|
||||
rpName: rpName,
|
||||
userDisplayName: userDisplayName,
|
||||
discoverable: discoverable,
|
||||
creationDate: creationDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension BitwardenSdk.Login {
|
||||
static func fixture(
|
||||
autofillOnPageLoad: Bool? = nil,
|
||||
fido2Credentials: [Fido2Credential]? = nil,
|
||||
password: String? = nil,
|
||||
passwordRevisionDate: Date? = nil,
|
||||
uris: [LoginUri]? = nil,
|
||||
username: String? = nil,
|
||||
totp: String? = nil
|
||||
) -> BitwardenSdk.Login {
|
||||
BitwardenSdk.Login(
|
||||
username: username,
|
||||
password: password,
|
||||
passwordRevisionDate: passwordRevisionDate,
|
||||
uris: uris,
|
||||
totp: totp,
|
||||
autofillOnPageLoad: autofillOnPageLoad,
|
||||
fido2Credentials: fido2Credentials
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension BitwardenSdk.LoginView {
|
||||
static func fixture(
|
||||
fido2Credentials: [Fido2Credential]? = nil,
|
||||
password: String? = nil,
|
||||
passwordRevisionDate: DateTime? = nil,
|
||||
uris: [LoginUriView]? = nil,
|
||||
username: String? = nil,
|
||||
totp: String? = nil,
|
||||
autofillOnPageLoad: Bool? = nil
|
||||
) -> BitwardenSdk.LoginView {
|
||||
BitwardenSdk.LoginView(
|
||||
username: username,
|
||||
password: password,
|
||||
passwordRevisionDate: passwordRevisionDate,
|
||||
uris: uris,
|
||||
totp: totp,
|
||||
autofillOnPageLoad: autofillOnPageLoad,
|
||||
fido2Credentials: fido2Credentials
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension PasswordHistoryView {
|
||||
static func fixture(
|
||||
password: String = "",
|
||||
lastUsedDate: Date = Date(year: 2024, month: 1, day: 1)
|
||||
) -> PasswordHistoryView {
|
||||
PasswordHistoryView(
|
||||
password: password,
|
||||
lastUsedDate: lastUsedDate
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
import BitwardenSdk
|
||||
import Foundation
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
extension VaultListItem {
|
||||
static func fixture(
|
||||
cipherView: CipherView = .fixture()
|
||||
) -> VaultListItem {
|
||||
VaultListItem(cipherView: cipherView)!
|
||||
}
|
||||
|
||||
static func fixtureGroup(
|
||||
id: String = "123",
|
||||
group: VaultListGroup = .card,
|
||||
count: Int = 1
|
||||
) -> VaultListItem {
|
||||
VaultListItem(
|
||||
id: id,
|
||||
itemType: .group(
|
||||
group,
|
||||
count
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
static func fixtureTOTP(
|
||||
name: String = "Name",
|
||||
totp: VaultListTOTP
|
||||
) -> VaultListItem {
|
||||
VaultListItem(
|
||||
id: totp.id,
|
||||
itemType: .totp(
|
||||
name: name,
|
||||
totpModel: totp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension VaultListTOTP {
|
||||
static func fixture(
|
||||
id: String = "123",
|
||||
loginView: BitwardenSdk.LoginView = .fixture(
|
||||
totp: .base32Key
|
||||
),
|
||||
timeProvider: TimeProvider,
|
||||
totpCode: String = "123456",
|
||||
totpPeriod: UInt32 = 30
|
||||
) -> VaultListTOTP {
|
||||
VaultListTOTP(
|
||||
id: id,
|
||||
loginView: loginView,
|
||||
totpCode: .init(
|
||||
code: totpCode,
|
||||
codeGenerationDate: timeProvider.presentTime,
|
||||
period: totpPeriod
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
static func fixture(
|
||||
id: String = "123",
|
||||
loginView: BitwardenSdk.LoginView = .fixture(
|
||||
totp: .base32Key
|
||||
),
|
||||
totpCode: TOTPCodeModel = .init(
|
||||
code: "123456",
|
||||
codeGenerationDate: Date(),
|
||||
period: 30
|
||||
)
|
||||
) -> VaultListTOTP {
|
||||
VaultListTOTP(
|
||||
id: id,
|
||||
loginView: loginView,
|
||||
totpCode: totpCode
|
||||
)
|
||||
}
|
||||
}
|
||||
161
AuthenticatorShared/Core/Vault/Repositories/ItemRepository.swift
Normal file
@ -0,0 +1,161 @@
|
||||
import BitwardenSdk
|
||||
import Combine
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
/// A protocol for an `ItemRepository` which manages acess to the data needed by the UI layer.
|
||||
///
|
||||
public protocol ItemRepository: AnyObject {
|
||||
// MARK: Data Methods
|
||||
|
||||
func addItem(_ item: CipherView) async throws
|
||||
|
||||
func deleteItem(_ id: String)
|
||||
|
||||
func fetchItem(withId id: String) async throws -> CipherView?
|
||||
|
||||
/// Regenerates the TOTP code for a given key.
|
||||
///
|
||||
/// - Parameter key: The key for a TOTP code.
|
||||
/// - Returns: An updated LoginTOTPState.
|
||||
///
|
||||
func refreshTOTPCode(for key: TOTPKeyModel) async throws -> LoginTOTPState
|
||||
|
||||
/// Regenerates the TOTP codes for a list of Vault Items.
|
||||
///
|
||||
/// - Parameter items: The list of items that need updated TOTP codes.
|
||||
/// - Returns: An updated list of items with new TOTP codes.
|
||||
///
|
||||
func refreshTOTPCodes(for items: [VaultListItem]) async throws -> [VaultListItem]
|
||||
|
||||
func updateItem(_ item: CipherView) async throws
|
||||
|
||||
// MARK: Publishers
|
||||
|
||||
func vaultListPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[VaultListItem], Never>>
|
||||
}
|
||||
|
||||
class DefaultItemRepository {
|
||||
// MARK: Properties
|
||||
|
||||
/// The client used by the application to handle vault encryption and decryption tasks.
|
||||
private let clientVault: ClientVaultService
|
||||
|
||||
/// The service used by the application to report non-fatal errors.
|
||||
private let errorReporter: ErrorReporter
|
||||
|
||||
/// The service used to get the present time.
|
||||
private let timeProvider: TimeProvider
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Initialize a `DefaultItemRepository`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - clientVault: The client used by the application to handle vault encryption and decryption tasks.
|
||||
/// - errorReporter: The service used by the application to report non-fatal errors.
|
||||
/// - timeProvider: The service used to get the present time.
|
||||
///
|
||||
init(
|
||||
clientVault: ClientVaultService,
|
||||
errorReporter: ErrorReporter,
|
||||
timeProvider: TimeProvider
|
||||
) {
|
||||
self.clientVault = clientVault
|
||||
self.errorReporter = errorReporter
|
||||
self.timeProvider = timeProvider
|
||||
}
|
||||
}
|
||||
|
||||
extension DefaultItemRepository: ItemRepository {
|
||||
// MARK: Data Methods
|
||||
|
||||
func addItem(_ item: BitwardenSdk.CipherView) async throws {}
|
||||
|
||||
func deleteItem(_ id: String) {}
|
||||
|
||||
func fetchItem(withId id: String) async throws -> BitwardenSdk.CipherView? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func refreshTOTPCode(for key: TOTPKeyModel) async throws -> LoginTOTPState {
|
||||
return .none
|
||||
}
|
||||
|
||||
func refreshTOTPCodes(for items: [VaultListItem]) async throws -> [VaultListItem] {
|
||||
await items.asyncMap { item in
|
||||
guard case let .totp(name, model) = item.itemType,
|
||||
let key = model.loginView.totp,
|
||||
let code = try? await clientVault.generateTOTPCode(for: key, date: timeProvider.presentTime)
|
||||
else {
|
||||
errorReporter.log(error: TOTPServiceError
|
||||
.unableToGenerateCode("Unable to refresh TOTP code for item: \(item.id)"))
|
||||
return item
|
||||
}
|
||||
var updatedModel = model
|
||||
updatedModel.totpCode = code
|
||||
return .init(
|
||||
id: item.id,
|
||||
itemType: .totp(name: name, totpModel: updatedModel)
|
||||
)
|
||||
}
|
||||
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
func updateItem(_ item: BitwardenSdk.CipherView) async throws {}
|
||||
|
||||
// MARK: Publishers
|
||||
|
||||
func vaultListPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[VaultListItem], Never>> {
|
||||
Just([
|
||||
VaultListItem(
|
||||
id: UUID().uuidString,
|
||||
itemType: .totp(
|
||||
name: "Amazon",
|
||||
totpModel: VaultListTOTP(
|
||||
id: UUID().uuidString,
|
||||
loginView: .init(
|
||||
username: "Username",
|
||||
password: "Password",
|
||||
passwordRevisionDate: nil,
|
||||
uris: nil,
|
||||
totp: "amazon",
|
||||
autofillOnPageLoad: false,
|
||||
fido2Credentials: nil
|
||||
),
|
||||
totpCode: TOTPCodeModel(
|
||||
code: "123456",
|
||||
codeGenerationDate: Date(),
|
||||
period: 30
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
VaultListItem(
|
||||
id: UUID().uuidString,
|
||||
itemType: .totp(
|
||||
name: "eBay",
|
||||
totpModel: VaultListTOTP(
|
||||
id: UUID().uuidString,
|
||||
loginView: .init(
|
||||
username: "Username",
|
||||
password: "Password",
|
||||
passwordRevisionDate: nil,
|
||||
uris: nil,
|
||||
totp: "ebay",
|
||||
autofillOnPageLoad: false,
|
||||
fido2Credentials: nil
|
||||
),
|
||||
totpCode: TOTPCodeModel(
|
||||
code: "123456",
|
||||
codeGenerationDate: Date(),
|
||||
period: 30
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
])
|
||||
.eraseToAnyPublisher()
|
||||
.values
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
import BitwardenSdk
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
class MockItemRepository: ItemRepository {
|
||||
var vaultListSubject = CurrentValueSubject<[VaultListItem], Never>([])
|
||||
|
||||
func addItem(_ item: BitwardenSdk.CipherView) async throws {
|
||||
|
||||
}
|
||||
|
||||
func deleteItem(_ id: String) {
|
||||
|
||||
}
|
||||
|
||||
func fetchItem(withId id: String) async throws -> BitwardenSdk.CipherView? {
|
||||
nil
|
||||
}
|
||||
|
||||
func refreshTOTPCode(for key: TOTPKeyModel) async throws -> LoginTOTPState {
|
||||
.none
|
||||
}
|
||||
|
||||
func refreshTOTPCodes(for items: [VaultListItem]) async throws -> [VaultListItem] {
|
||||
[]
|
||||
}
|
||||
|
||||
func updateItem(_ item: BitwardenSdk.CipherView) async throws {
|
||||
|
||||
}
|
||||
|
||||
func vaultListPublisher() async throws -> AsyncThrowingPublisher<AnyPublisher<[VaultListItem], Never>> {
|
||||
vaultListSubject.eraseToAnyPublisher().values
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
import BitwardenSdk
|
||||
import Foundation
|
||||
|
||||
/// A protocol for a service that handles encryption and decryption tasks for the vault. This is
|
||||
/// similar to `ClientVaultProtocol` but returns the protocols so they can be mocked for testing.
|
||||
///
|
||||
protocol ClientVaultService: AnyObject {
|
||||
/// Returns an object that handles encryption and decryption for attachments.
|
||||
///
|
||||
func attachments() -> ClientAttachmentsProtocol
|
||||
|
||||
/// Returns an object that handles encryption and decryption for ciphers.
|
||||
///
|
||||
func ciphers() -> ClientCiphersProtocol
|
||||
|
||||
/// Returns an object that handles encryption and decryption for collections.
|
||||
///
|
||||
func collections() -> ClientCollectionsProtocol
|
||||
|
||||
/// Returns an object that handles encryption and decryption for folders.
|
||||
///
|
||||
func folders() -> ClientFoldersProtocol
|
||||
|
||||
/// Returns a TOTP Code for a key.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - key: The key used to generate the code.
|
||||
/// - date: The date used to generate the code
|
||||
/// - Returns: A TOTPCodeState model.
|
||||
///
|
||||
func generateTOTPCode(for key: String, date: Date?) async throws -> TOTPCodeModel
|
||||
|
||||
/// Returns an object that handles encryption and decryption for password history.
|
||||
///
|
||||
func passwordHistory() -> ClientPasswordHistoryProtocol
|
||||
|
||||
/// Returns an object that handles encryption and decryption for sends.
|
||||
///
|
||||
func sends() -> ClientSendsProtocol
|
||||
}
|
||||
|
||||
// MARK: - ClientVault
|
||||
|
||||
extension ClientVault: ClientVaultService {
|
||||
func attachments() -> ClientAttachmentsProtocol {
|
||||
attachments() as ClientAttachments
|
||||
}
|
||||
|
||||
func ciphers() -> ClientCiphersProtocol {
|
||||
ciphers() as ClientCiphers
|
||||
}
|
||||
|
||||
func collections() -> ClientCollectionsProtocol {
|
||||
collections() as ClientCollections
|
||||
}
|
||||
|
||||
func folders() -> ClientFoldersProtocol {
|
||||
folders() as ClientFolders
|
||||
}
|
||||
|
||||
func generateTOTPCode(for key: String, date: Date? = nil) async throws -> TOTPCodeModel {
|
||||
let calculationDate: Date = date ?? Date()
|
||||
let response = try await generateTotp(key: key, time: calculationDate)
|
||||
return TOTPCodeModel(
|
||||
code: response.code,
|
||||
codeGenerationDate: calculationDate,
|
||||
period: response.period
|
||||
)
|
||||
}
|
||||
|
||||
func passwordHistory() -> ClientPasswordHistoryProtocol {
|
||||
passwordHistory() as ClientPasswordHistory
|
||||
}
|
||||
|
||||
func sends() -> ClientSendsProtocol {
|
||||
sends() as ClientSends
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
/// Extension on `String` to provide utilities for TOTP key validation.
|
||||
///
|
||||
/// Includes checks for Base32 encoded strings, OTP Auth URIs, and Steam URIs.
|
||||
///
|
||||
extension String {
|
||||
/// `true` if the String is base 32.
|
||||
var isBase32: Bool {
|
||||
let regex = "^[A-Z2-7]+=*$"
|
||||
return range(of: regex, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
/// `true` if prefixed with `steam://` and followed by a base 32 string.
|
||||
var isSteamUri: Bool {
|
||||
guard let keyIndexOffset = steamURIKeyIndexOffset else {
|
||||
return false
|
||||
}
|
||||
let key = String(suffix(from: keyIndexOffset))
|
||||
return key.isBase32
|
||||
}
|
||||
|
||||
/// `true` if the String begins with "otpauth://"
|
||||
var hasOTPAuthPrefix: Bool {
|
||||
lowercased().starts(with: "otpauth://")
|
||||
}
|
||||
|
||||
var steamURIKeyIndexOffset: String.Index? {
|
||||
guard lowercased().starts(with: "steam://") else { return nil }
|
||||
return index(startIndex, offsetBy: 8)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
|
||||
/// Model representing the components extracted from an OTP Auth URI.
|
||||
///
|
||||
/// This model includes the Base32-encoded key, the period, the number of digits, and the hashing algorithm.
|
||||
struct OTPAuthModel: Equatable {
|
||||
/// The Base32-encoded key used for generating the OTP.
|
||||
let keyB32: String
|
||||
|
||||
/// The time period in seconds for which the OTP is valid.
|
||||
let period: Int
|
||||
|
||||
/// The number of digits in the OTP.
|
||||
let digits: Int
|
||||
|
||||
/// The hashing algorithm used for generating the OTP.
|
||||
let algorithm: TOTPCryptoHashAlgorithm
|
||||
|
||||
/// Initializes a new instance of `OTPAuthModel`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - keyB32: The Base32-encoded key.
|
||||
/// - period: The time period in seconds for which the OTP is valid.
|
||||
/// - digits: The number of digits in the OTP.
|
||||
/// - algorithm: The hashing algorithm used for generating the OTP.
|
||||
init(keyB32: String, period: Int, digits: Int, algorithm: TOTPCryptoHashAlgorithm) {
|
||||
self.keyB32 = keyB32
|
||||
self.period = period
|
||||
self.digits = digits
|
||||
self.algorithm = algorithm
|
||||
}
|
||||
|
||||
/// Parses an OTP Auth URI into its components.
|
||||
///
|
||||
/// - Parameter otpAuthKey: A string representing the OTP Auth URI.
|
||||
///
|
||||
init?(otpAuthKey: String) {
|
||||
guard let urlComponents = URLComponents(string: otpAuthKey.lowercased()),
|
||||
urlComponents.scheme == "otpauth",
|
||||
let queryItems = urlComponents.queryItems,
|
||||
let secret = queryItems.first(where: { $0.name == "secret" })?.value,
|
||||
secret.uppercased().isBase32 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let period = queryItems.first { $0.name == "period" }?.value.flatMap(Int.init) ?? 30
|
||||
let digits = queryItems.first { $0.name == "digits" }?.value.flatMap(Int.init) ?? 6
|
||||
let algorithm = TOTPCryptoHashAlgorithm(from: queryItems.first { $0.name == "algorithm" }?.value)
|
||||
|
||||
self.init(keyB32: secret, period: period, digits: digits, algorithm: algorithm)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
import XCTest
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
// MARK: - OTPAuthModelTests
|
||||
|
||||
class OTPAuthModelTests: AuthenticatorTestCase {
|
||||
// MARK: Tests
|
||||
|
||||
/// Tests that a malformed string does not create a model.
|
||||
func test_init_otpAuthKey_failure_base32() {
|
||||
let subject = OTPAuthModel(otpAuthKey: .base32Key)
|
||||
XCTAssertNil(subject)
|
||||
}
|
||||
|
||||
/// Tests that a malformed string does not create a model.
|
||||
func test_init_otpAuthKey_failure_incompletePrefix() {
|
||||
let subject = OTPAuthModel(otpAuthKey: "totp/Example:eliot@livefront.com?secret=JBSWY3DPEHPK3PXP")
|
||||
XCTAssertNil(subject)
|
||||
}
|
||||
|
||||
/// Tests that a malformed string does not create a model.
|
||||
func test_init_otpAuthKey_failure_noSecret() {
|
||||
let subject = OTPAuthModel(
|
||||
otpAuthKey: "otpauth://totp/Example:eliot@livefront.com?issuer=Example&algorithm=SHA256&digits=6&period=30"
|
||||
)
|
||||
XCTAssertNil(subject)
|
||||
}
|
||||
|
||||
/// Tests that a malformed string does not create a model.
|
||||
func test_init_otpAuthKey_failure_steam() {
|
||||
let subject = OTPAuthModel(otpAuthKey: .steamUriKey)
|
||||
XCTAssertNil(subject)
|
||||
}
|
||||
|
||||
/// Tests that a fully formatted OTP Auth string creates the model.
|
||||
func test_init_otpAuthKey_success_full() {
|
||||
let subject = OTPAuthModel(otpAuthKey: .otpAuthUriKeyComplete)
|
||||
XCTAssertNotNil(subject)
|
||||
}
|
||||
|
||||
/// Tests that a partially formatted OTP Auth string creates the model.
|
||||
func test_init_otpAuthKey_success_partial() {
|
||||
let subject = OTPAuthModel(otpAuthKey: .otpAuthUriKeyPartial)
|
||||
XCTAssertNotNil(subject)
|
||||
XCTAssertEqual(subject?.digits, 6)
|
||||
XCTAssertEqual(subject?.period, 30)
|
||||
XCTAssertEqual(subject?.algorithm, .sha1)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
import XCTest
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
// MARK: - TOTPCodeConfigTests
|
||||
|
||||
final class TOTPCodeConfigTests: AuthenticatorTestCase {
|
||||
// MARK: Tests
|
||||
|
||||
/// Tests that a malformed string does not create a model.
|
||||
func test_init_totpCodeConfig_failure_incompletePrefix() {
|
||||
let subject = TOTPKeyModel(
|
||||
authenticatorKey: "totp/Example:eliot@livefront.com?secret=JBSWY3DPEHPK3PXP"
|
||||
)
|
||||
XCTAssertNil(subject)
|
||||
}
|
||||
|
||||
/// Tests that a malformed string does not create a model.
|
||||
func test_init_totpCodeConfig_failure_noSecret() {
|
||||
let subject = TOTPKeyModel(
|
||||
authenticatorKey: "otpauth://totp/Example:eliot@livefront.com?issuer=Example&algorithm=SHA256&digits=6&period=30" // swiftlint:disable:this line_length
|
||||
)
|
||||
XCTAssertNil(subject)
|
||||
}
|
||||
|
||||
/// Tests that a base32 string creates the model.
|
||||
func test_init_totpCodeConfig_base32() {
|
||||
let subject = TOTPKeyModel(
|
||||
authenticatorKey: .base32Key
|
||||
)
|
||||
XCTAssertNotNil(subject)
|
||||
XCTAssertEqual(subject?.base32Key, .base32Key)
|
||||
}
|
||||
|
||||
/// Tests that an otp auth string creates the model.
|
||||
func test_init_totpCodeConfig_success_full() {
|
||||
let subject = TOTPKeyModel(
|
||||
authenticatorKey: .otpAuthUriKeyComplete
|
||||
)
|
||||
XCTAssertNotNil(subject)
|
||||
XCTAssertEqual(
|
||||
subject?.base32Key,
|
||||
.base32Key.lowercased()
|
||||
)
|
||||
}
|
||||
|
||||
/// Tests that an otp auth string creates the model.
|
||||
func test_init_totpCodeConfig_success_partial() {
|
||||
let subject = TOTPKeyModel(
|
||||
authenticatorKey: .otpAuthUriKeyPartial
|
||||
)
|
||||
XCTAssertNotNil(subject)
|
||||
XCTAssertEqual(
|
||||
subject?.base32Key,
|
||||
.base32Key.lowercased()
|
||||
)
|
||||
}
|
||||
|
||||
/// Tests that an otp auth string creates the model.
|
||||
func test_init_totpCodeConfig_success_sha512() {
|
||||
let subject = TOTPKeyModel(
|
||||
authenticatorKey: .otpAuthUriKeySHA512
|
||||
)
|
||||
XCTAssertNotNil(subject)
|
||||
XCTAssertEqual(
|
||||
subject?.base32Key,
|
||||
.base32Key.lowercased()
|
||||
)
|
||||
}
|
||||
|
||||
/// Tests that a steam string creates the model.
|
||||
func test_init_totpCodeConfig_success_steam() {
|
||||
let subject = TOTPKeyModel(authenticatorKey: .steamUriKey)
|
||||
XCTAssertNotNil(subject)
|
||||
XCTAssertEqual(subject?.base32Key, .base32Key)
|
||||
XCTAssertEqual(subject?.digits, 5)
|
||||
XCTAssertEqual(subject?.algorithm, .sha1)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// A model representing a one time password code generated by the sdk.
|
||||
///
|
||||
public struct TOTPCodeModel: Equatable, Sendable {
|
||||
/// The code string.
|
||||
///
|
||||
var code: String
|
||||
|
||||
/// The date used to generate the code.
|
||||
///
|
||||
var codeGenerationDate: Date
|
||||
|
||||
/// The code formatted for display
|
||||
///
|
||||
var displayCode: String {
|
||||
code.enumerated().map { index, character in
|
||||
guard (index + 1) % 3 == 0 else { return "\(character)" }
|
||||
return "\(character) "
|
||||
}.joined()
|
||||
}
|
||||
|
||||
/// The period of the code.
|
||||
///
|
||||
var period: UInt32
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
/// Defines the hash algorithms supported for TOTP.
|
||||
///
|
||||
enum TOTPCryptoHashAlgorithm: String {
|
||||
case sha1 = "SHA1"
|
||||
case sha256 = "SHA256"
|
||||
case sha512 = "SHA512"
|
||||
|
||||
/// Initializes the algorithm from a given string value.
|
||||
/// - Parameter rawValue: An optional `String`.
|
||||
///
|
||||
init(from rawValue: String?) {
|
||||
switch rawValue?.uppercased() {
|
||||
case "SHA256":
|
||||
self = .sha256
|
||||
case "SHA512":
|
||||
self = .sha512
|
||||
default: // Default to SHA1 if not specified or unrecognized
|
||||
self = .sha1
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
/// A calculator to identify expired TOTP Codes.
|
||||
///
|
||||
enum TOTPExpirationCalculator {
|
||||
// MARK: Static Methods
|
||||
|
||||
/// Checks if a given `TOTPCodeModel` is expired.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - codeModel: The TOTP code model to check for expiration.
|
||||
/// - timeProvider: A data type to provide the current time.
|
||||
///
|
||||
/// - Returns: A `Bool`, `true` if the code has expired, `false` if it is still valid.
|
||||
///
|
||||
static func hasCodeExpired(
|
||||
_ codeModel: TOTPCodeModel,
|
||||
timeProvider: any TimeProvider
|
||||
) -> Bool {
|
||||
let period = codeModel.period
|
||||
let codeGenerationDate = codeModel.codeGenerationDate
|
||||
|
||||
// The time interval until a code generated at the `codeGenerationDate` would need a refresh.
|
||||
let codeGenerationDateTimeRemaining = timeRemaining(for: codeGenerationDate, using: Double(period))
|
||||
|
||||
// The date after which the codeModel would need a refresh.
|
||||
let codeRefreshCutoffDate = codeGenerationDate.addingTimeInterval(codeGenerationDateTimeRemaining)
|
||||
|
||||
// Check if the cutoff date has past.
|
||||
let hasCodeExpired = codeRefreshCutoffDate <= timeProvider.presentTime
|
||||
|
||||
return hasCodeExpired
|
||||
}
|
||||
|
||||
/// Sorts a list of `VaultListItem` by expiration state
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - items: An array of list items that may be expired.
|
||||
/// - period: The interval after which the codes are expired.
|
||||
/// - timeProvider: The provider of the current time.
|
||||
///
|
||||
/// - Returns: A dictionary with the items sorted by a `Bool` flag indicating expiration.
|
||||
///
|
||||
static func listItemsByExpiration(
|
||||
_ items: [VaultListItem],
|
||||
timeProvider: any TimeProvider
|
||||
) -> [Bool: [VaultListItem]] {
|
||||
let sortedItems: [Bool: [VaultListItem]] = Dictionary(grouping: items, by: { item in
|
||||
guard case let .totp(_, model) = item.itemType else { return false }
|
||||
return hasCodeExpired(model.totpCode, timeProvider: timeProvider)
|
||||
})
|
||||
return sortedItems
|
||||
}
|
||||
|
||||
/// Calculates the seconds remaining before an update is needed
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - date: The date used to calculate the remaining seconds.
|
||||
/// - period: The period of expiration.
|
||||
/// - Returns: The number of seconds remaining, expressed as an integer.
|
||||
///
|
||||
static func remainingSeconds(for date: Date, using period: Int) -> Int {
|
||||
Int(ceil(timeRemaining(for: date, using: Double(period))))
|
||||
}
|
||||
|
||||
/// Calculates the time interval remaining before an update is needed
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - date: The date used to calculate the remaining seconds.
|
||||
/// - period: The period of expiration.
|
||||
/// - Returns: The time remaining, expressed as a TimeInterval.
|
||||
///
|
||||
static func timeRemaining(for date: Date, using period: TimeInterval) -> TimeInterval {
|
||||
let interval = date.timeIntervalSinceReferenceDate
|
||||
let remainder = interval.truncatingRemainder(dividingBy: period)
|
||||
return Double(period) - remainder
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,121 @@
|
||||
import XCTest
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
final class TOTPExpirationCalculatorTests: AuthenticatorTestCase {
|
||||
// MARK: Tests
|
||||
|
||||
func test_hasCodeExpired_codesOlderThanPeriod() {
|
||||
XCTAssertTrue(
|
||||
TOTPExpirationCalculator.hasCodeExpired(
|
||||
.init(
|
||||
code: "",
|
||||
codeGenerationDate: .distantPast,
|
||||
period: 30
|
||||
),
|
||||
timeProvider: MockTimeProvider(.currentTime)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func test_hasCodeExpired_recentCodesPastExpiration() {
|
||||
XCTAssertTrue(
|
||||
TOTPExpirationCalculator.hasCodeExpired(
|
||||
.init(
|
||||
code: "",
|
||||
codeGenerationDate: Date(year: 2024, month: 1, day: 1, second: 29),
|
||||
period: 30
|
||||
),
|
||||
timeProvider: MockTimeProvider(.mockTime(Date(year: 2024, month: 1, day: 1, second: 30)))
|
||||
)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
TOTPExpirationCalculator.hasCodeExpired(
|
||||
.init(
|
||||
code: "",
|
||||
codeGenerationDate: Date(year: 2024, month: 1, day: 1, second: 29),
|
||||
period: 30
|
||||
),
|
||||
timeProvider: MockTimeProvider(.mockTime(Date(year: 2024, month: 1, day: 1, second: 31)))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func test_hasCodeExpired_currentCodes() {
|
||||
XCTAssertFalse(
|
||||
TOTPExpirationCalculator.hasCodeExpired(
|
||||
.init(
|
||||
code: "",
|
||||
codeGenerationDate: Date(year: 2024, month: 1, day: 1, second: 15),
|
||||
period: 30
|
||||
),
|
||||
timeProvider: MockTimeProvider(.mockTime(Date(year: 2024, month: 1, day: 1, second: 15)))
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
TOTPExpirationCalculator.hasCodeExpired(
|
||||
.init(
|
||||
code: "",
|
||||
codeGenerationDate: Date(year: 2024, month: 1, day: 1, second: 0),
|
||||
period: 30
|
||||
),
|
||||
timeProvider: MockTimeProvider(.mockTime(Date(year: 2024, month: 1, day: 1, second: 29)))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func test_listItemsByExpiration() {
|
||||
let expired = VaultListItem.fixtureTOTP(
|
||||
totp: .fixture(
|
||||
totpCode: .init(
|
||||
code: "",
|
||||
codeGenerationDate: Date(year: 2024, month: 1, day: 1, second: 29),
|
||||
period: 30
|
||||
)
|
||||
)
|
||||
)
|
||||
let current = VaultListItem.fixtureTOTP(
|
||||
totp: .fixture(
|
||||
totpCode: .init(
|
||||
code: "",
|
||||
codeGenerationDate: Date(year: 2024, month: 1, day: 1, second: 31),
|
||||
period: 30
|
||||
)
|
||||
)
|
||||
)
|
||||
let expectation = [
|
||||
true: [
|
||||
expired,
|
||||
],
|
||||
false: [
|
||||
current,
|
||||
],
|
||||
]
|
||||
XCTAssertEqual(
|
||||
expectation,
|
||||
TOTPExpirationCalculator.listItemsByExpiration(
|
||||
[current, expired],
|
||||
timeProvider: MockTimeProvider(
|
||||
.mockTime(
|
||||
Date(
|
||||
year: 2024,
|
||||
month: 1,
|
||||
day: 1,
|
||||
second: 31
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func test_remainingSecons_roundsUp() {
|
||||
XCTAssertEqual(
|
||||
TOTPExpirationCalculator.remainingSeconds(
|
||||
for: Date(year: 2024, month: 1, day: 1, second: 29, nanosecond: 90_000_000),
|
||||
using: 30
|
||||
),
|
||||
1
|
||||
)
|
||||
}
|
||||
}
|
||||
86
AuthenticatorShared/Core/Vault/Services/TOTP/TOTPKey.swift
Normal file
@ -0,0 +1,86 @@
|
||||
/// Represents different types of TOTP keys.
|
||||
///
|
||||
enum TOTPKey: Equatable {
|
||||
/// A base 32 string key
|
||||
case base32(key: String)
|
||||
|
||||
/// An OTP Auth URI
|
||||
case otpAuthUri(OTPAuthModel)
|
||||
|
||||
/// A Steam URI
|
||||
case steamUri(key: String)
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The hash algorithm used for the TOTP code.
|
||||
/// For `otpAuthUri`, it extracts the algorithm from the model.
|
||||
/// Defaults to SHA1 for other types.
|
||||
///
|
||||
var algorithm: TOTPCryptoHashAlgorithm {
|
||||
guard case let .otpAuthUri(model) = self else { return .sha1 }
|
||||
return model.algorithm
|
||||
}
|
||||
|
||||
/// The number of digits in the TOTP code.
|
||||
/// Defaults to 6 for base32 and OTP Auth URIs, and 5 for Steam URIs.
|
||||
///
|
||||
var digits: Int {
|
||||
switch self {
|
||||
case .base32:
|
||||
return 6
|
||||
case let .otpAuthUri(model):
|
||||
return model.digits
|
||||
case .steamUri:
|
||||
return 5
|
||||
}
|
||||
}
|
||||
|
||||
/// The key used for generating the TOTP code.
|
||||
/// Directly returns the key for base32 and Steam URI.
|
||||
/// For `otpAuthUri`, extracts the key from the model.
|
||||
var base32Key: String {
|
||||
switch self {
|
||||
case let .base32(key),
|
||||
let .steamUri(key):
|
||||
return key
|
||||
case let .otpAuthUri(model):
|
||||
return model.keyB32
|
||||
}
|
||||
}
|
||||
|
||||
/// The time period (in seconds) for which the TOTP code is valid.
|
||||
/// Defaults to 30 seconds for base32 and Steam URIs.
|
||||
/// For `otpAuthUri`, extracts the period from the model.
|
||||
var period: Int {
|
||||
switch self {
|
||||
case .base32,
|
||||
.steamUri:
|
||||
return 30
|
||||
case let .otpAuthUri(model):
|
||||
return model.period
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Initializes a TOTPKey from a given string.
|
||||
///
|
||||
/// This initializer supports creation of different types of TOTP keys based on the string format.
|
||||
/// It supports base32 keys, OTP Auth URIs, and Steam URIs.
|
||||
///
|
||||
/// - Parameter key: A string representing the TOTP key.
|
||||
init?(_ key: String) {
|
||||
if key.uppercased().isBase32 {
|
||||
self = .base32(key: key)
|
||||
} else if key.hasOTPAuthPrefix,
|
||||
let otpAuthModel = OTPAuthModel(otpAuthKey: key) {
|
||||
self = .otpAuthUri(otpAuthModel)
|
||||
} else if let keyIndexOffset = key.steamURIKeyIndexOffset {
|
||||
let steamKey = String(key.suffix(from: keyIndexOffset))
|
||||
guard steamKey.uppercased().isBase32 else { return nil }
|
||||
self = .steamUri(key: steamKey)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
/// A model representing a TOTP authentication key.
|
||||
///
|
||||
public struct TOTPKeyModel: Equatable, Sendable {
|
||||
// MARK: Properties
|
||||
|
||||
/// The hash algorithm used for the TOTP code.
|
||||
///
|
||||
let algorithm: TOTPCryptoHashAlgorithm
|
||||
|
||||
/// The base 32 key used to generate the TOTP code.
|
||||
var base32Key: String {
|
||||
totpKey.base32Key
|
||||
}
|
||||
|
||||
/// The number of digits in the TOTP code.
|
||||
///
|
||||
let digits: Int
|
||||
|
||||
/// The time period (in seconds) for which the TOTP code is valid.
|
||||
///
|
||||
let period: Int
|
||||
|
||||
/// The authenticatorKey used to generate the `TOTPCodeConfig`.
|
||||
let rawAuthenticatorKey: String
|
||||
|
||||
/// The key type used for generating the TOTP code.
|
||||
let totpKey: TOTPKey
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Initializes a new configuration from an authenticator key.
|
||||
///
|
||||
/// - Parameter authenticatorKey: A string representing the TOTP key.
|
||||
init?(authenticatorKey: String) {
|
||||
guard let keyType = TOTPKey(authenticatorKey) else { return nil }
|
||||
rawAuthenticatorKey = authenticatorKey
|
||||
totpKey = keyType
|
||||
period = keyType.period
|
||||
digits = keyType.digits
|
||||
algorithm = keyType.algorithm
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
/// Protocol defining the functionality of a TOTP (Time-based One-Time Password) service.
|
||||
protocol TOTPService {
|
||||
/// Retrieves the TOTP configuration for a given key.
|
||||
///
|
||||
/// - Parameter key: A string representing the TOTP key.
|
||||
/// - Throws: `TOTPServiceError.invalidKeyFormat` if the key format is invalid.
|
||||
/// - Returns: A `TOTPKeyModel` containing the configuration details.
|
||||
func getTOTPConfiguration(key: String?) throws -> TOTPKeyModel
|
||||
}
|
||||
|
||||
/// Default implementation of the `TOTPService`.
|
||||
struct DefaultTOTPService: TOTPService {
|
||||
/// Retrieves the TOTP configuration for a given key.
|
||||
///
|
||||
/// - Parameter key: A string representing the TOTP key.
|
||||
/// - Throws: `TOTPServiceError.invalidKeyFormat` if the key format is invalid.
|
||||
/// - Returns: A `TOTPKeyModel` containing the configuration details.
|
||||
func getTOTPConfiguration(key: String?) throws -> TOTPKeyModel {
|
||||
guard let key,
|
||||
let config = TOTPKeyModel(authenticatorKey: key) else {
|
||||
throw TOTPServiceError.invalidKeyFormat
|
||||
}
|
||||
return config
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
/// Enum representing errors in the TOTP Service.
|
||||
///
|
||||
enum TOTPServiceError: Error, Equatable {
|
||||
/// `invalidKeyFormat` is thrown when the TOTP key is not in a recognized or valid format.
|
||||
case invalidKeyFormat
|
||||
|
||||
/// `unableToGenerateCode` is thrown when the TOTP code cannot be generated.
|
||||
case unableToGenerateCode(_ errorDescription: String?)
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import XCTest
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
// MARK: - TOTPServiceTests
|
||||
|
||||
final class TOTPServiceTests: AuthenticatorTestCase {
|
||||
// MARK: Tests
|
||||
|
||||
func test_default_getTOTPConfiguration_base32() throws {
|
||||
let config = try DefaultTOTPService()
|
||||
.getTOTPConfiguration(key: .base32Key)
|
||||
XCTAssertNotNil(config)
|
||||
}
|
||||
|
||||
func test_default_getTOTPConfiguration_otp() throws {
|
||||
let config = try DefaultTOTPService()
|
||||
.getTOTPConfiguration(key: .otpAuthUriKeyComplete)
|
||||
XCTAssertNotNil(config)
|
||||
}
|
||||
|
||||
func test_default_getTOTPConfiguration_steam() throws {
|
||||
let config = try DefaultTOTPService()
|
||||
.getTOTPConfiguration(key: .steamUriKey)
|
||||
XCTAssertNotNil(config)
|
||||
}
|
||||
|
||||
func test_default_getTOTPConfiguration_failure() {
|
||||
XCTAssertThrowsError(
|
||||
try DefaultTOTPService().getTOTPConfiguration(key: "1234")
|
||||
) { error in
|
||||
XCTAssertEqual(
|
||||
error as? TOTPServiceError,
|
||||
.invalidKeyFormat
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
class MockTOTPService: TOTPService {
|
||||
var capturedKey: String?
|
||||
var getTOTPConfigResult: Result<TOTPKeyModel, Error> = .failure(TOTPServiceError.invalidKeyFormat)
|
||||
|
||||
func getTOTPConfiguration(key: String?) throws -> AuthenticatorShared.TOTPKeyModel {
|
||||
capturedKey = key
|
||||
return try getTOTPConfigResult.get()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
extension String {
|
||||
static let base32Key = "JBSWY3DPEHPK3PXP"
|
||||
// swiftlint:disable:next line_length
|
||||
static let otpAuthUriKeyComplete = "otpauth://totp/Example:eliot@livefront.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA256&digits=6&period=30"
|
||||
static let otpAuthUriKeyPartial = "otpauth://totp/Example:eliot@livefront.com?secret=JBSWY3DPEHPK3PXP"
|
||||
// swiftlint:disable:next line_length
|
||||
static let otpAuthUriKeySHA512 = "otpauth://totp/Example:eliot@livefront.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA512"
|
||||
static let steamUriKey = "steam://JBSWY3DPEHPK3PXP"
|
||||
}
|
||||
@ -0,0 +1,425 @@
|
||||
// swiftlint:disable:this file_name
|
||||
|
||||
import BitwardenSdk
|
||||
|
||||
extension Attachment {
|
||||
init(attachmentView: AttachmentView) {
|
||||
self.init(
|
||||
id: attachmentView.id,
|
||||
url: attachmentView.url,
|
||||
size: attachmentView.size,
|
||||
sizeName: attachmentView.sizeName,
|
||||
fileName: attachmentView.fileName,
|
||||
key: attachmentView.key
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension AttachmentView {
|
||||
init(attachment: Attachment) {
|
||||
self.init(
|
||||
id: attachment.id,
|
||||
url: attachment.url,
|
||||
size: attachment.size,
|
||||
sizeName: attachment.sizeName,
|
||||
fileName: attachment.fileName,
|
||||
key: attachment.key
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Card {
|
||||
init(cardView: CardView) {
|
||||
self.init(
|
||||
cardholderName: cardView.cardholderName,
|
||||
expMonth: cardView.expMonth,
|
||||
expYear: cardView.expYear,
|
||||
code: cardView.code,
|
||||
brand: cardView.brand,
|
||||
number: cardView.number
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension CardView {
|
||||
init(card: Card) {
|
||||
self.init(
|
||||
cardholderName: card.cardholderName,
|
||||
expMonth: card.expMonth,
|
||||
expYear: card.expYear,
|
||||
code: card.code,
|
||||
brand: card.brand,
|
||||
number: card.number
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension CipherListView {
|
||||
init(cipher: Cipher) {
|
||||
self.init(
|
||||
id: cipher.id,
|
||||
organizationId: cipher.organizationId,
|
||||
folderId: cipher.folderId,
|
||||
collectionIds: cipher.collectionIds,
|
||||
name: cipher.name,
|
||||
subTitle: "",
|
||||
type: cipher.type,
|
||||
favorite: cipher.favorite,
|
||||
reprompt: cipher.reprompt,
|
||||
edit: cipher.edit,
|
||||
viewPassword: cipher.viewPassword,
|
||||
attachments: UInt32(cipher.attachments?.count ?? 0),
|
||||
creationDate: cipher.creationDate,
|
||||
deletedDate: cipher.deletedDate,
|
||||
revisionDate: cipher.revisionDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Cipher {
|
||||
init(cipherView: CipherView) {
|
||||
self.init(
|
||||
id: cipherView.id,
|
||||
organizationId: cipherView.organizationId,
|
||||
folderId: cipherView.folderId,
|
||||
collectionIds: cipherView.collectionIds,
|
||||
key: cipherView.key,
|
||||
name: cipherView.name,
|
||||
notes: cipherView.notes,
|
||||
type: cipherView.type,
|
||||
login: cipherView.login.map(Login.init),
|
||||
identity: cipherView.identity.map(Identity.init),
|
||||
card: cipherView.card.map(Card.init),
|
||||
secureNote: cipherView.secureNote.map(SecureNote.init),
|
||||
favorite: cipherView.favorite,
|
||||
reprompt: cipherView.reprompt,
|
||||
organizationUseTotp: cipherView.organizationUseTotp,
|
||||
edit: cipherView.edit,
|
||||
viewPassword: cipherView.viewPassword,
|
||||
localData: cipherView.localData.map(LocalData.init),
|
||||
attachments: cipherView.attachments?.map(Attachment.init),
|
||||
fields: cipherView.fields?.map(Field.init),
|
||||
passwordHistory: cipherView.passwordHistory?.map(PasswordHistory.init),
|
||||
creationDate: cipherView.creationDate,
|
||||
deletedDate: cipherView.deletedDate,
|
||||
revisionDate: cipherView.revisionDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension CipherView {
|
||||
init(cipher: Cipher) {
|
||||
self.init(
|
||||
id: cipher.id,
|
||||
organizationId: cipher.organizationId,
|
||||
folderId: cipher.folderId,
|
||||
collectionIds: cipher.collectionIds,
|
||||
key: cipher.key,
|
||||
name: cipher.name,
|
||||
notes: cipher.notes,
|
||||
type: cipher.type,
|
||||
login: cipher.login.map(LoginView.init),
|
||||
identity: cipher.identity.map(IdentityView.init),
|
||||
card: cipher.card.map(CardView.init),
|
||||
secureNote: cipher.secureNote.map(SecureNoteView.init),
|
||||
favorite: cipher.favorite,
|
||||
reprompt: cipher.reprompt,
|
||||
organizationUseTotp: cipher.organizationUseTotp,
|
||||
edit: cipher.edit,
|
||||
viewPassword: cipher.viewPassword,
|
||||
localData: cipher.localData.map(LocalDataView.init),
|
||||
attachments: cipher.attachments?.map(AttachmentView.init),
|
||||
fields: cipher.fields?.map(FieldView.init),
|
||||
passwordHistory: cipher.passwordHistory?.map(PasswordHistoryView.init),
|
||||
creationDate: cipher.creationDate,
|
||||
deletedDate: cipher.deletedDate,
|
||||
revisionDate: cipher.revisionDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension CollectionView {
|
||||
init(collection: Collection) {
|
||||
self.init(
|
||||
id: collection.id,
|
||||
organizationId: collection.organizationId,
|
||||
name: collection.name,
|
||||
externalId: collection.externalId,
|
||||
hidePasswords: collection.hidePasswords,
|
||||
readOnly: collection.readOnly
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Field {
|
||||
init(fieldView: FieldView) {
|
||||
self.init(
|
||||
name: fieldView.name,
|
||||
value: fieldView.value,
|
||||
type: fieldView.type,
|
||||
linkedId: fieldView.linkedId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension FieldView {
|
||||
init(field: Field) {
|
||||
self.init(
|
||||
name: field.name,
|
||||
value: field.value,
|
||||
type: field.type,
|
||||
linkedId: field.linkedId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Folder {
|
||||
init(folderView: FolderView) {
|
||||
self.init(
|
||||
id: folderView.id,
|
||||
name: folderView.name,
|
||||
revisionDate: folderView.revisionDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension FolderView {
|
||||
init(folder: Folder) {
|
||||
self.init(
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
revisionDate: folder.revisionDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Identity {
|
||||
init(identityView: IdentityView) {
|
||||
self.init(
|
||||
title: identityView.title,
|
||||
firstName: identityView.firstName,
|
||||
middleName: identityView.middleName,
|
||||
lastName: identityView.lastName,
|
||||
address1: identityView.address1,
|
||||
address2: identityView.address2,
|
||||
address3: identityView.address3,
|
||||
city: identityView.city,
|
||||
state: identityView.state,
|
||||
postalCode: identityView.postalCode,
|
||||
country: identityView.country,
|
||||
company: identityView.company,
|
||||
email: identityView.email,
|
||||
phone: identityView.phone,
|
||||
ssn: identityView.ssn,
|
||||
username: identityView.username,
|
||||
passportNumber: identityView.passportNumber,
|
||||
licenseNumber: identityView.licenseNumber
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension IdentityView {
|
||||
init(identity: Identity) {
|
||||
self.init(
|
||||
title: identity.title,
|
||||
firstName: identity.firstName,
|
||||
middleName: identity.middleName,
|
||||
lastName: identity.lastName,
|
||||
address1: identity.address1,
|
||||
address2: identity.address2,
|
||||
address3: identity.address3,
|
||||
city: identity.city,
|
||||
state: identity.state,
|
||||
postalCode: identity.postalCode,
|
||||
country: identity.country,
|
||||
company: identity.company,
|
||||
email: identity.email,
|
||||
phone: identity.phone,
|
||||
ssn: identity.ssn,
|
||||
username: identity.username,
|
||||
passportNumber: identity.passportNumber,
|
||||
licenseNumber: identity.licenseNumber
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalData {
|
||||
init(localDataView: LocalDataView) {
|
||||
self.init(
|
||||
lastUsedDate: localDataView.lastUsedDate,
|
||||
lastLaunched: localDataView.lastLaunched
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalDataView {
|
||||
init(localData: LocalData) {
|
||||
self.init(
|
||||
lastUsedDate: localData.lastUsedDate,
|
||||
lastLaunched: localData.lastLaunched
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Login {
|
||||
init(loginView: LoginView) {
|
||||
self.init(
|
||||
username: loginView.username,
|
||||
password: loginView.password,
|
||||
passwordRevisionDate: loginView.passwordRevisionDate,
|
||||
uris: loginView.uris?.map(LoginUri.init),
|
||||
totp: loginView.totp,
|
||||
autofillOnPageLoad: loginView.autofillOnPageLoad,
|
||||
fido2Credentials: loginView.fido2Credentials
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension LoginView {
|
||||
init(login: Login) {
|
||||
self.init(
|
||||
username: login.username,
|
||||
password: login.password,
|
||||
passwordRevisionDate: login.passwordRevisionDate,
|
||||
uris: login.uris?.map(LoginUriView.init),
|
||||
totp: login.totp,
|
||||
autofillOnPageLoad: login.autofillOnPageLoad,
|
||||
fido2Credentials: login.fido2Credentials
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension LoginUri {
|
||||
init(loginUriView: LoginUriView) {
|
||||
self.init(
|
||||
uri: loginUriView.uri,
|
||||
match: loginUriView.match
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension LoginUriView {
|
||||
init(loginUri: LoginUri) {
|
||||
self.init(
|
||||
uri: loginUri.uri,
|
||||
match: loginUri.match
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension PasswordHistory {
|
||||
init(passwordHistoryView: PasswordHistoryView) {
|
||||
self.init(
|
||||
password: passwordHistoryView.password,
|
||||
lastUsedDate: passwordHistoryView.lastUsedDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension PasswordHistoryView {
|
||||
init(passwordHistory: PasswordHistory) {
|
||||
self.init(
|
||||
password: passwordHistory.password,
|
||||
lastUsedDate: passwordHistory.lastUsedDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension SecureNote {
|
||||
init(secureNoteView: SecureNoteView) {
|
||||
self.init(type: secureNoteView.type)
|
||||
}
|
||||
}
|
||||
|
||||
extension SecureNoteView {
|
||||
init(secureNote: SecureNote) {
|
||||
self.init(type: secureNote.type)
|
||||
}
|
||||
}
|
||||
|
||||
extension SendFileView {
|
||||
init(sendFile: SendFile) {
|
||||
self.init(
|
||||
id: sendFile.id,
|
||||
fileName: sendFile.fileName,
|
||||
size: sendFile.size,
|
||||
sizeName: sendFile.sizeName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension SendTextView {
|
||||
init(sendText: SendText) {
|
||||
self.init(
|
||||
text: sendText.text,
|
||||
hidden: sendText.hidden
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension SendView {
|
||||
init(send: Send) {
|
||||
self.init(
|
||||
id: send.id,
|
||||
accessId: send.accessId,
|
||||
name: send.name,
|
||||
notes: send.notes,
|
||||
key: send.key,
|
||||
newPassword: nil,
|
||||
hasPassword: !(send.password?.isEmpty ?? true),
|
||||
type: send.type,
|
||||
file: send.file.map(SendFileView.init),
|
||||
text: send.text.map(SendTextView.init),
|
||||
maxAccessCount: send.maxAccessCount,
|
||||
accessCount: send.accessCount,
|
||||
disabled: send.disabled,
|
||||
hideEmail: send.hideEmail,
|
||||
revisionDate: send.revisionDate,
|
||||
deletionDate: send.deletionDate,
|
||||
expirationDate: send.expirationDate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension SendFile {
|
||||
init(sendFileView: SendFileView) {
|
||||
self.init(
|
||||
id: sendFileView.id,
|
||||
fileName: sendFileView.fileName,
|
||||
size: sendFileView.size,
|
||||
sizeName: sendFileView.sizeName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension SendText {
|
||||
init(sendTextView: SendTextView) {
|
||||
self.init(
|
||||
text: sendTextView.text,
|
||||
hidden: sendTextView.hidden
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Send {
|
||||
init(sendView: SendView) {
|
||||
self.init(
|
||||
id: sendView.id,
|
||||
accessId: sendView.accessId,
|
||||
name: sendView.name,
|
||||
notes: sendView.notes,
|
||||
key: sendView.key ?? "",
|
||||
password: sendView.newPassword,
|
||||
type: sendView.type,
|
||||
file: sendView.file.map(SendFile.init),
|
||||
text: sendView.text.map(SendText.init),
|
||||
maxAccessCount: sendView.maxAccessCount,
|
||||
accessCount: sendView.accessCount,
|
||||
disabled: sendView.disabled,
|
||||
hideEmail: sendView.hideEmail,
|
||||
revisionDate: sendView.revisionDate,
|
||||
deletionDate: sendView.deletionDate,
|
||||
expirationDate: sendView.expirationDate
|
||||
)
|
||||
}
|
||||
} // swiftlint:disable:this file_length
|
||||
@ -0,0 +1,53 @@
|
||||
import BitwardenSdk
|
||||
|
||||
// MARK: - MockClientExporters
|
||||
|
||||
/// A mocked `ClientExportersProtocol`.
|
||||
///
|
||||
class MockClientExporters {
|
||||
// MARK: Properties
|
||||
|
||||
/// The ciphers exported in a call to `exportVault(_:)` or `exportOrganizationVault(_:)`.
|
||||
var ciphers = [BitwardenSdk.Cipher]()
|
||||
|
||||
/// The collections exported in a call to `exportOrganizationVault(_:)`.
|
||||
var collections = [BitwardenSdk.Collection]()
|
||||
|
||||
/// The result of a call to `exportOrganizationVault(_:)`
|
||||
var exportOrganizationVaultResult: Result<String, Error> = .failure(AuthenticatorTestError.example)
|
||||
|
||||
/// The result of a call to `exportVault(_:)`
|
||||
var exportVaultResult: Result<String, Error> = .failure(AuthenticatorTestError.example)
|
||||
|
||||
/// The folders exported in a call to `exportVault(_:)`.
|
||||
var folders = [BitwardenSdk.Folder]()
|
||||
|
||||
/// The format of the export in a call to `exportVault(_:)` or `exportOrganizationVault(_:)`.
|
||||
var format: BitwardenSdk.ExportFormat?
|
||||
}
|
||||
|
||||
// MARK: - ClientExportersProtocol
|
||||
|
||||
extension MockClientExporters: ClientExportersProtocol {
|
||||
func exportOrganizationVault(
|
||||
collections: [BitwardenSdk.Collection],
|
||||
ciphers: [BitwardenSdk.Cipher],
|
||||
format: BitwardenSdk.ExportFormat
|
||||
) async throws -> String {
|
||||
self.collections = collections
|
||||
self.ciphers = ciphers
|
||||
self.format = format
|
||||
return try exportOrganizationVaultResult.get()
|
||||
}
|
||||
|
||||
func exportVault(
|
||||
folders: [BitwardenSdk.Folder],
|
||||
ciphers: [BitwardenSdk.Cipher],
|
||||
format: BitwardenSdk.ExportFormat
|
||||
) async throws -> String {
|
||||
self.folders = folders
|
||||
self.ciphers = ciphers
|
||||
self.format = format
|
||||
return try exportVaultResult.get()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,203 @@
|
||||
import BitwardenSdk
|
||||
import Foundation
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
class MockClientVaultService: ClientVaultService {
|
||||
var clientAttachments = MockClientAttachments()
|
||||
var clientCiphers = MockClientCiphers()
|
||||
var clientCollections = MockClientCollections()
|
||||
var clientFolders = MockClientFolders()
|
||||
var clientPasswordHistory = MockClientPasswordHistory()
|
||||
var clientSends = MockClientSends()
|
||||
var generateTOTPCodeResult: Result<String, Error> = .success("123456")
|
||||
var timeProvider = MockTimeProvider(.currentTime)
|
||||
var totpPeriod: UInt32 = 30
|
||||
|
||||
func attachments() -> ClientAttachmentsProtocol {
|
||||
clientAttachments
|
||||
}
|
||||
|
||||
func ciphers() -> ClientCiphersProtocol {
|
||||
clientCiphers
|
||||
}
|
||||
|
||||
func collections() -> ClientCollectionsProtocol {
|
||||
clientCollections
|
||||
}
|
||||
|
||||
func folders() -> ClientFoldersProtocol {
|
||||
clientFolders
|
||||
}
|
||||
|
||||
func generateTOTPCode(for _: String, date: Date?) async throws -> AuthenticatorShared.TOTPCodeModel {
|
||||
let code = try generateTOTPCodeResult.get()
|
||||
return TOTPCodeModel(
|
||||
code: code,
|
||||
codeGenerationDate: date ?? timeProvider.presentTime,
|
||||
period: totpPeriod
|
||||
)
|
||||
}
|
||||
|
||||
func passwordHistory() -> ClientPasswordHistoryProtocol {
|
||||
clientPasswordHistory
|
||||
}
|
||||
|
||||
func sends() -> ClientSendsProtocol {
|
||||
clientSends
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MockClientAttachments
|
||||
|
||||
class MockClientAttachments: ClientAttachmentsProtocol {
|
||||
var encryptedFilePaths = [String]()
|
||||
var decryptedBuffers = [Data]()
|
||||
var encryptedBuffers = [Data]()
|
||||
|
||||
func decryptBuffer(cipher _: Cipher, attachment _: Attachment, buffer: Data) async throws -> Data {
|
||||
decryptedBuffers.append(buffer)
|
||||
return buffer
|
||||
}
|
||||
|
||||
func decryptFile(
|
||||
cipher _: Cipher,
|
||||
attachment _: Attachment,
|
||||
encryptedFilePath: String,
|
||||
decryptedFilePath _: String
|
||||
) async throws {
|
||||
encryptedFilePaths.append(encryptedFilePath)
|
||||
}
|
||||
|
||||
func encryptBuffer(
|
||||
cipher _: Cipher,
|
||||
attachment: AttachmentView,
|
||||
buffer: Data
|
||||
) async throws -> AttachmentEncryptResult {
|
||||
encryptedBuffers.append(buffer)
|
||||
return AttachmentEncryptResult(attachment: Attachment(attachmentView: attachment), contents: buffer)
|
||||
}
|
||||
|
||||
func encryptFile(
|
||||
cipher _: Cipher,
|
||||
attachment: AttachmentView,
|
||||
decryptedFilePath _: String,
|
||||
encryptedFilePath _: String
|
||||
) async throws -> Attachment {
|
||||
Attachment(attachmentView: attachment)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MockClientCiphers
|
||||
|
||||
class MockClientCiphers: ClientCiphersProtocol {
|
||||
var encryptError: Error?
|
||||
var encryptedCiphers = [CipherView]()
|
||||
|
||||
func decrypt(cipher: Cipher) async throws -> CipherView {
|
||||
CipherView(cipher: cipher)
|
||||
}
|
||||
|
||||
func decryptList(ciphers: [Cipher]) async throws -> [CipherListView] {
|
||||
ciphers.map(CipherListView.init)
|
||||
}
|
||||
|
||||
func encrypt(cipherView: CipherView) async throws -> Cipher {
|
||||
encryptedCiphers.append(cipherView)
|
||||
if let encryptError {
|
||||
throw encryptError
|
||||
}
|
||||
return Cipher(cipherView: cipherView)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MockClientCollections
|
||||
|
||||
class MockClientCollections: ClientCollectionsProtocol {
|
||||
func decrypt(collection _: Collection) async throws -> CollectionView {
|
||||
fatalError("Not implemented yet")
|
||||
}
|
||||
|
||||
func decryptList(collections: [Collection]) async throws -> [CollectionView] {
|
||||
collections.map(CollectionView.init)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MockClientFolders
|
||||
|
||||
class MockClientFolders: ClientFoldersProtocol {
|
||||
var decryptedFolders = [Folder]()
|
||||
var encryptError: Error?
|
||||
var encryptedFolders = [FolderView]()
|
||||
|
||||
func decrypt(folder: Folder) async throws -> FolderView {
|
||||
FolderView(folder: folder)
|
||||
}
|
||||
|
||||
func decryptList(folders: [Folder]) async throws -> [FolderView] {
|
||||
decryptedFolders = folders
|
||||
return folders.map(FolderView.init)
|
||||
}
|
||||
|
||||
func encrypt(folder: FolderView) async throws -> Folder {
|
||||
encryptedFolders.append(folder)
|
||||
if let encryptError {
|
||||
throw encryptError
|
||||
}
|
||||
return Folder(folderView: folder)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MockClientPasswordHistory
|
||||
|
||||
class MockClientPasswordHistory: ClientPasswordHistoryProtocol {
|
||||
var encryptedPasswordHistory = [PasswordHistoryView]()
|
||||
|
||||
func decryptList(list: [PasswordHistory]) async throws -> [PasswordHistoryView] {
|
||||
list.map(PasswordHistoryView.init)
|
||||
}
|
||||
|
||||
func encrypt(passwordHistory: PasswordHistoryView) async throws -> PasswordHistory {
|
||||
encryptedPasswordHistory.append(passwordHistory)
|
||||
return PasswordHistory(passwordHistoryView: passwordHistory)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MockClientSends
|
||||
|
||||
class MockClientSends: ClientSendsProtocol {
|
||||
var decryptedSends: [Send] = []
|
||||
var encryptedSendViews: [SendView] = []
|
||||
var encryptedBuffers: [Data] = []
|
||||
|
||||
func decrypt(send: Send) async throws -> SendView {
|
||||
decryptedSends.append(send)
|
||||
return SendView(send: send)
|
||||
}
|
||||
|
||||
func decryptBuffer(send _: Send, buffer _: Data) async throws -> Data {
|
||||
fatalError("Not implemented yet")
|
||||
}
|
||||
|
||||
func decryptFile(send _: Send, encryptedFilePath _: String, decryptedFilePath _: String) async throws {
|
||||
fatalError("Not implemented yet")
|
||||
}
|
||||
|
||||
func decryptList(sends _: [Send]) async throws -> [BitwardenSdk.SendListView] {
|
||||
fatalError("Not implemented yet")
|
||||
}
|
||||
|
||||
func encrypt(send sendView: SendView) async throws -> Send {
|
||||
encryptedSendViews.append(sendView)
|
||||
return Send(sendView: sendView)
|
||||
}
|
||||
|
||||
func encryptBuffer(send _: Send, buffer: Data) async throws -> Data {
|
||||
encryptedBuffers.append(buffer)
|
||||
return buffer
|
||||
}
|
||||
|
||||
func encryptFile(send _: Send, decryptedFilePath _: String, encryptedFilePath _: String) async throws {
|
||||
fatalError("Not implemented yet")
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,5 @@ import Foundation
|
||||
public enum AuthRoute: Equatable {
|
||||
/// Dismisses the auth flow.
|
||||
case complete
|
||||
|
||||
case onboarding
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ class AppCoordinator: Coordinator, HasRootNavigator {
|
||||
// MARK: Types
|
||||
|
||||
/// The types of modules used by this coordinator.
|
||||
typealias Module = VaultModule
|
||||
typealias Module = ItemsModule
|
||||
|
||||
// MARK: Private Properties
|
||||
|
||||
@ -59,7 +59,7 @@ class AppCoordinator: Coordinator, HasRootNavigator {
|
||||
func handleEvent(_ event: AppEvent, context: AnyObject?) async {
|
||||
switch event {
|
||||
case .didStart:
|
||||
showVault(route: .onboarding)
|
||||
showItems(route: .list)
|
||||
}
|
||||
}
|
||||
|
||||
@ -77,28 +77,22 @@ class AppCoordinator: Coordinator, HasRootNavigator {
|
||||
|
||||
// MARK: Private Methods
|
||||
|
||||
/// Shows the vault route (not in a tab). This is used within the app extensions.
|
||||
/// Shows the Token List screen.
|
||||
///
|
||||
/// - Parameter route: The vault route to show.
|
||||
/// - Parameter route: The token list route to show.
|
||||
///
|
||||
private func showVault(route: VaultRoute) {
|
||||
if let coordinator = childCoordinator as? AnyCoordinator<VaultRoute, AuthAction> {
|
||||
private func showItems(route: ItemsRoute) {
|
||||
if let coordinator = childCoordinator as? AnyCoordinator<ItemsRoute, ItemsEvent> {
|
||||
coordinator.navigate(to: route)
|
||||
} else {
|
||||
let stackNavigator = UINavigationController()
|
||||
let coordinator = module.makeVaultCoordinator(
|
||||
delegate: self,
|
||||
let coordinator = module.makeItemsCoordinator(
|
||||
stackNavigator: stackNavigator
|
||||
)
|
||||
coordinator.start()
|
||||
coordinator.navigate(to: route)
|
||||
coordinator.navigate(to: .list)
|
||||
childCoordinator = coordinator
|
||||
rootNavigator?.show(child: stackNavigator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - VaultCoordinatorDelegate
|
||||
|
||||
extension AppCoordinator: VaultCoordinatorDelegate {
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ class AppProcessorTests: AuthenticatorTestCase {
|
||||
|
||||
var appModule: MockAppModule!
|
||||
var coordinator: MockCoordinator<AppRoute, AppEvent>!
|
||||
var errorReporter: MockErrorReporter!
|
||||
var router: MockRouter<AuthEvent, AuthRoute>!
|
||||
var subject: AppProcessor!
|
||||
var timeProvider: MockTimeProvider!
|
||||
@ -20,11 +21,14 @@ class AppProcessorTests: AuthenticatorTestCase {
|
||||
router = MockRouter(routeForEvent: { _ in .onboarding })
|
||||
appModule = MockAppModule()
|
||||
coordinator = MockCoordinator()
|
||||
errorReporter = MockErrorReporter()
|
||||
timeProvider = MockTimeProvider(.currentTime)
|
||||
|
||||
subject = AppProcessor(
|
||||
appModule: appModule,
|
||||
services: ServiceContainer()
|
||||
services: ServiceContainer.withMocks(
|
||||
errorReporter: errorReporter
|
||||
)
|
||||
)
|
||||
subject.coordinator = coordinator.asAnyCoordinator()
|
||||
}
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - NavigationBarViewModifier
|
||||
|
||||
/// A modifier that customizes a navigation bar's title and title display mode.
|
||||
///
|
||||
struct NavigationBarViewModifier: ViewModifier {
|
||||
// MARK: Properties
|
||||
|
||||
/// The navigation bar title.
|
||||
var title: String
|
||||
|
||||
/// The navigation bar title display mode.
|
||||
var navigationBarTitleDisplayMode: NavigationBarItem.TitleDisplayMode
|
||||
|
||||
// MARK: View
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.navigationBarTitleDisplayMode(navigationBarTitleDisplayMode)
|
||||
.navigationTitle(title)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - ScrollViewModifier
|
||||
|
||||
/// A modifier that adds padded content to a `ScrollView`.
|
||||
///
|
||||
struct ScrollViewModifier: ViewModifier {
|
||||
// MARK: Properties
|
||||
|
||||
/// Whether or not to add the vertical padding.
|
||||
var addVerticalPadding = true
|
||||
|
||||
// MARK: View
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
ScrollView {
|
||||
content
|
||||
.padding(.horizontal, 16)
|
||||
.padding([.top, .bottom], addVerticalPadding ? 16 : 0)
|
||||
}
|
||||
.background(Color(asset: Asset.Colors.backgroundSecondary))
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 299 KiB |
|
Before Width: | Height: | Size: 331 KiB After Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 331 KiB After Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 178 KiB |
@ -0,0 +1,136 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Helper functions extended off the `View` protocol for supporting buttons and menus in toolbars.
|
||||
///
|
||||
extension View {
|
||||
// MARK: Buttons
|
||||
|
||||
/// Returns a toolbar button configured for adding an item.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - hidden: Whether to hide the toolbar item.
|
||||
/// - action: The action to perform when the button is tapped.
|
||||
/// - Returns: A `Button` configured for adding an item.
|
||||
///
|
||||
func addToolbarButton(hidden: Bool = false, action: @escaping () -> Void) -> some View {
|
||||
toolbarButton(asset: Asset.Images.plus, label: Localizations.add, action: action)
|
||||
.hidden(hidden)
|
||||
}
|
||||
|
||||
/// Returns a toolbar button configured for cancelling an operation in a view.
|
||||
///
|
||||
/// - Parameter action: The action to perform when the button is tapped.
|
||||
/// - Returns: A `Button` configured for cancelling an operation in a view.
|
||||
///
|
||||
func cancelToolbarButton(action: @escaping () -> Void) -> some View {
|
||||
toolbarButton(asset: Asset.Images.cancel, label: Localizations.cancel, action: action)
|
||||
.accessibilityIdentifier("CLOSE")
|
||||
}
|
||||
|
||||
/// Returns a toolbar button configured for closing a view.
|
||||
///
|
||||
/// - Parameter action: The action to perform when the button is tapped.
|
||||
/// - Returns: A `Button` configured for closing a view.
|
||||
///
|
||||
func closeToolbarButton(action: @escaping () -> Void) -> some View {
|
||||
toolbarButton(asset: Asset.Images.cancel, label: Localizations.close, action: action)
|
||||
.accessibilityIdentifier("CLOSE")
|
||||
}
|
||||
|
||||
/// Returns a `Button` that displays an image for use in a toolbar.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - asset: The image asset to show in the button.
|
||||
/// - label: The label associated with the image, used as an accessibility label.
|
||||
/// - action: The action to perform when the button is tapped.
|
||||
/// - Returns: A `Button` for displaying an image in a toolbar.
|
||||
///
|
||||
func toolbarButton(asset: ImageAsset, label: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(asset: asset, label: Text(label))
|
||||
.resizable()
|
||||
.foregroundColor(Asset.Colors.primaryBitwarden.swiftUIColor)
|
||||
.frame(width: 19, height: 19)
|
||||
}
|
||||
// Ideally we would set both `minHeight` and `minWidth` to 44. Setting `minWidth` causes
|
||||
// padding to be applied equally on both sides of the image. This results in extra padding
|
||||
// along the margin though.
|
||||
.frame(minHeight: 44)
|
||||
}
|
||||
|
||||
/// Returns a `Button` that displays a text label for use in a toolbar.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - label: The label to display in the button.
|
||||
/// - action: The action to perform when the button is tapped.
|
||||
/// - Returns: A `Button` for displaying a text label in a toolbar.
|
||||
///
|
||||
func toolbarButton(_ label: String, action: @escaping () async -> Void) -> some View {
|
||||
AsyncButton(label, action: action)
|
||||
.foregroundColor(Asset.Colors.primaryBitwarden.swiftUIColor)
|
||||
// Ideally we would set both `minHeight` and `minWidth` to 44. Setting `minWidth` causes
|
||||
// padding to be applied equally on both sides of the image. This results in extra padding
|
||||
// along the margin though.
|
||||
.frame(minHeight: 44)
|
||||
}
|
||||
|
||||
// MARK: Menus
|
||||
|
||||
/// Returns a `Menu` for use in a toolbar.
|
||||
///
|
||||
/// - Parameter content: The content to display in the menu when the more icon is tapped.
|
||||
/// - Returns: A `Menu` for use in a toolbar.
|
||||
///
|
||||
func optionsToolbarMenu(@ViewBuilder content: () -> some View) -> some View {
|
||||
Menu {
|
||||
content()
|
||||
} label: {
|
||||
Image(asset: Asset.Images.verticalKabob, label: Text(Localizations.options))
|
||||
.resizable()
|
||||
.frame(width: 19, height: 19)
|
||||
.foregroundColor(Asset.Colors.primaryBitwarden.swiftUIColor)
|
||||
}
|
||||
// Ideally we would set both `minHeight` and `minWidth` to 44. Setting `minWidth` causes
|
||||
// padding to be applied equally on both sides of the image. This results in extra padding
|
||||
// along the margin though.
|
||||
.frame(height: 44)
|
||||
}
|
||||
|
||||
// MARK: Toolbar Items
|
||||
|
||||
/// A `ToolbarItem` for views with an add button.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - hidden: Whether to hide the toolbar item.
|
||||
/// - action: The action to perform when the add button is tapped.
|
||||
/// - Returns: A `ToolbarItem` with an add button.
|
||||
///
|
||||
func addToolbarItem(hidden: Bool = false, _ action: @escaping () -> Void) -> some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
toolbarButton(asset: Asset.Images.plus, label: Localizations.add, action: action)
|
||||
.hidden(hidden)
|
||||
}
|
||||
}
|
||||
|
||||
/// A `ToolbarItem` for views with a dismiss button.
|
||||
///
|
||||
/// - Parameter action: The action to perform when the dismiss button is tapped.
|
||||
/// - Returns: A `ToolbarItem` with a dismiss button.
|
||||
///
|
||||
func cancelToolbarItem(_ action: @escaping () -> Void) -> some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
cancelToolbarButton(action: action)
|
||||
}
|
||||
}
|
||||
|
||||
/// A `ToolbarItem` for views with a more button.
|
||||
///
|
||||
/// - Parameter content: The content to display in the menu when the more icon is tapped.
|
||||
/// - Returns: A `ToolbarItem` with a more button that shows a menu.
|
||||
///
|
||||
func optionsToolbarItem(@ViewBuilder _ content: () -> some View) -> some ToolbarContent {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
optionsToolbarMenu(content: content)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Helper functions extended off the `View` protocol.
|
||||
///
|
||||
extension View {
|
||||
/// On iOS 16+, configures the scroll view to dismiss the keyboard immediately.
|
||||
///
|
||||
func dismissKeyboardImmediately() -> some View {
|
||||
if #available(iOSApplicationExtension 16, *) {
|
||||
return self.scrollDismissesKeyboard(.immediately)
|
||||
} else {
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
/// On iOS 16+, configures the scroll view to dismiss the keyboard interactively.
|
||||
///
|
||||
func dismissKeyboardInteractively() -> some View {
|
||||
if #available(iOSApplicationExtension 16, *) {
|
||||
return self.scrollDismissesKeyboard(.interactively)
|
||||
} else {
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
/// Focuses next field in sequence, from the given `FocusState`.
|
||||
/// Requires a currently active focus state and a next field available in the sequence.
|
||||
/// (https://stackoverflow.com/a/71531523)
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```
|
||||
/// .onSubmit { self.focusNextField($focusedField) }
|
||||
/// ```
|
||||
/// Given that `focusField` is an enum that represents the focusable fields. For example:
|
||||
/// ```
|
||||
/// @FocusState private var focusedField: Field?
|
||||
/// enum Field: Int, Hashable {
|
||||
/// case name
|
||||
/// case country
|
||||
/// case city
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// - Parameter field: next field to be focused.
|
||||
///
|
||||
func focusNextField<F: RawRepresentable>(_ field: FocusState<F?>.Binding) where F.RawValue == Int {
|
||||
guard let currentValue = field.wrappedValue else { return }
|
||||
let nextValue = currentValue.rawValue + 1
|
||||
if let newValue = F(rawValue: nextValue) {
|
||||
field.wrappedValue = newValue
|
||||
}
|
||||
}
|
||||
|
||||
/// Hides a view based on the specified value.
|
||||
///
|
||||
/// NOTE: This should only be used when the view needs to remain in the view hierarchy while hidden,
|
||||
/// which is often useful for sizing purposes (e.g. hide or swap a view without resizing the parent).
|
||||
/// Otherwise, `if condition { view }` is preferred.
|
||||
///
|
||||
/// - Parameter hidden: `true` if the view should be hidden.
|
||||
/// - Returns The original view if `hidden` is false, or the view with the hidden modifier applied.
|
||||
///
|
||||
@ViewBuilder
|
||||
func hidden(_ hidden: Bool) -> some View {
|
||||
if hidden {
|
||||
self.hidden()
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a custom navigation bar title and title display mode to a view.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - title: The navigation bar title.
|
||||
/// - titleDisplayMode: The navigation bar title display mode.
|
||||
///
|
||||
/// - Returns: A view with a custom navigation bar.
|
||||
///
|
||||
func navigationBar(
|
||||
title: String,
|
||||
titleDisplayMode: NavigationBarItem.TitleDisplayMode
|
||||
) -> some View {
|
||||
modifier(NavigationBarViewModifier(
|
||||
title: title,
|
||||
navigationBarTitleDisplayMode: titleDisplayMode
|
||||
))
|
||||
}
|
||||
|
||||
/// Applies the `ScrollViewModifier` to a view.
|
||||
///
|
||||
/// - Parameter addVerticalPadding: Whether or not to add vertical padding. Defaults to `true`.
|
||||
///
|
||||
/// - Returns: A view within a `ScrollView`.
|
||||
///
|
||||
func scrollView(addVerticalPadding: Bool = true) -> some View {
|
||||
modifier(ScrollViewModifier(addVerticalPadding: addVerticalPadding))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - LoadingState
|
||||
|
||||
/// An enumeration of the possible loading states for any screen with a loading state.
|
||||
enum LoadingState<T: Equatable>: Equatable {
|
||||
/// The data that should be displayed on screen.
|
||||
case data(T)
|
||||
|
||||
/// The view is loading.
|
||||
case loading(T?)
|
||||
|
||||
/// The data to be displayed, if the case is `data`.
|
||||
var data: T? {
|
||||
switch self {
|
||||
case let .data(data):
|
||||
return data
|
||||
case let .loading(maybeData):
|
||||
return maybeData
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - LoadingView
|
||||
|
||||
/// A view that displays either a loading indicator or the content view for a set of loaded data.
|
||||
struct LoadingView<T: Equatable, Contents: View>: View {
|
||||
/// The state of this view.
|
||||
var state: LoadingState<T>
|
||||
|
||||
/// A view builder for displaying the loaded contents of this view.
|
||||
@ViewBuilder var contents: (T) -> Contents
|
||||
|
||||
var body: some View {
|
||||
switch state {
|
||||
case .loading:
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case let .data(data):
|
||||
contents(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "cancel.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Icons/cancel.imageset/cancel.pdf
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "clock.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Icons/clock.imageset/clock.pdf
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "collections.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "copy.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Icons/copy.imageset/copy.pdf
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "credit-card.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "doc.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Icons/doc.imageset/doc.pdf
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "folder-closed.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "globe.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Icons/globe.imageset/globe.pdf
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "horizontal-kabob.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "id.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Icons/id.imageset/id.pdf
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "paperclip.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "plus.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Icons/plus.imageset/plus.pdf
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "trash.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
AuthenticatorShared/UI/Platform/Application/Support/Images.xcassets/Icons/trash.imageset/trash.pdf
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "vertical-kabob.pdf",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true,
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,881 @@
|
||||
"About" = "About";
|
||||
"Add" = "Add";
|
||||
"AddFolder" = "Add Folder";
|
||||
"AddItem" = "Add Item";
|
||||
"AnErrorHasOccurred" = "An error has occurred.";
|
||||
"Dark" = "Dark";
|
||||
"Back" = "Back";
|
||||
"Bitwarden" = "Bitwarden";
|
||||
"Cancel" = "Cancel";
|
||||
"ContinueToGiveFeedback" = "Continue to Give Feedback?";
|
||||
"ContinueToGiveFeedbackDescription" = "Select continue to provide feedback on your experience in a web form.";
|
||||
"Copy" = "Copy";
|
||||
"CopyPassword" = "Copy password";
|
||||
"CopyUsername" = "Copy username";
|
||||
"Credits" = "Credits";
|
||||
"Delete" = "Delete";
|
||||
"Deleting" = "Deleting...";
|
||||
"DoYouReallyWantToDelete" = "Do you really want to delete? This cannot be undone.";
|
||||
"Edit" = "Edit";
|
||||
"EditFolder" = "Edit folder";
|
||||
"Email" = "Email";
|
||||
"EmailAddress" = "Email address";
|
||||
"EmailUs" = "Email us";
|
||||
"EmailUsDescription" = "Email us directly to get help or leave feedback.";
|
||||
"EnterPIN" = "Enter your PIN code.";
|
||||
"Favorites" = "Favorites";
|
||||
"FileBugReport" = "File a bug report";
|
||||
"FileBugReportDescription" = "Open an issue at our GitHub repository.";
|
||||
"FingerprintDirection" = "Use your fingerprint to verify.";
|
||||
"Folder" = "Folder";
|
||||
"FolderCreated" = "New folder created.";
|
||||
"FolderDeleted" = "Folder deleted.";
|
||||
"FolderNone" = "No Folder";
|
||||
"Folders" = "Folders";
|
||||
"FolderUpdated" = "Folder saved";
|
||||
"GiveFeedback" = "Give feedback";
|
||||
"GoToWebsite" = "Go to website";
|
||||
"HelpAndFeedback" = "Help and feedback";
|
||||
"Hide" = "Hide";
|
||||
"InternetConnectionRequiredMessage" = "Please connect to the internet before continuing.";
|
||||
"InternetConnectionRequiredTitle" = "Internet connection required";
|
||||
"InvalidMasterPassword" = "Invalid master password. Try again.";
|
||||
"InvalidPIN" = "Invalid PIN. Try again.";
|
||||
"Launch" = "Launch";
|
||||
"LogIn" = "Log In";
|
||||
"LogInNoun" = "Login";
|
||||
"LogOut" = "Log out";
|
||||
"LogoutConfirmation" = "Are you sure you want to log out?";
|
||||
"RemoveAccount" = "Remove account";
|
||||
"RemoveAccountConfirmation" = "Are you sure you want to remove this account?";
|
||||
"AccountAlreadyAdded" = "Account already added";
|
||||
"SwitchToAlreadyAddedAccountConfirmation" = "Would you like to switch to it now?";
|
||||
"MasterPassword" = "Master password";
|
||||
"More" = "More";
|
||||
"MyVault" = "My vault";
|
||||
"Authenticator" = "Authenticator";
|
||||
"Name" = "Name";
|
||||
"No" = "No";
|
||||
"Notes" = "Notes";
|
||||
"Ok" = "Ok";
|
||||
"Password" = "Password";
|
||||
"Save" = "Save";
|
||||
"Move" = "Move";
|
||||
"Saving" = "Saving...";
|
||||
"Settings" = "Settings";
|
||||
"Show" = "Show";
|
||||
"ItemDeleted" = "Item deleted";
|
||||
"Submit" = "Submit";
|
||||
"Sync" = "Sync";
|
||||
"ThankYou" = "Thank you";
|
||||
"Tools" = "Tools";
|
||||
"URI" = "URI";
|
||||
"UseFingerprintToUnlock" = "Use fingerprint to unlock";
|
||||
"Username" = "Username";
|
||||
"ValidationFieldRequired" = "The %1$@ field is required.";
|
||||
"ValueHasBeenCopied" = "%1$@ copied";
|
||||
"VerifyFingerprint" = "Verify fingerprint";
|
||||
"VerifyMasterPassword" = "Verify master password";
|
||||
"VerifyPIN" = "Verify PIN";
|
||||
"Version" = "Version";
|
||||
"View" = "View";
|
||||
"VisitOurWebsite" = "Visit our website";
|
||||
"Website" = "Website";
|
||||
"Yes" = "Yes";
|
||||
"Account" = "Account";
|
||||
"AccountCreated" = "Your new account has been created! You may now log in.";
|
||||
"AddAnItem" = "Add an Item";
|
||||
"AppExtension" = "App extension";
|
||||
"AutofillAccessibilityDescription" = "Use the Bitwarden accessibility service to auto-fill your logins across apps and the web.";
|
||||
"AutofillService" = "Auto-fill service";
|
||||
"AvoidAmbiguousCharacters" = "Avoid ambiguous characters";
|
||||
"BitwardenAppExtension" = "Bitwarden app extension";
|
||||
"BitwardenAppExtensionAlert2" = "The easiest way to add new logins to your vault is from the Bitwarden app extension. Learn more about using the Bitwarden app extension by navigating to the \"Settings\" screen.";
|
||||
"BitwardenAppExtensionDescription" = "Use Bitwarden in Safari and other apps to auto-fill your logins.";
|
||||
"BitwardenAutofillService" = "Bitwarden Auto-fill Service";
|
||||
"BitwardenAutofillAccessibilityServiceDescription" = "Use the Bitwarden accessibility service to auto-fill your logins.";
|
||||
"ChangeEmail" = "Change email";
|
||||
"ChangeEmailConfirmation" = "You can change your email address on the bitwarden.com web vault. Do you want to visit the website now?";
|
||||
"ChangeMasterPassword" = "Change master password";
|
||||
"Close" = "Close";
|
||||
"Continue" = "Continue";
|
||||
"CreateAccount" = "Create account";
|
||||
"CreatingAccount" = "Creating account...";
|
||||
"EditItem" = "Edit item";
|
||||
"EnableAutomaticSyncing" = "Allow automatic syncing";
|
||||
"EnterEmailForHint" = "Enter your account email address to receive your master password hint.";
|
||||
"ExntesionReenable" = "Reactivate app extension";
|
||||
"ExtensionAlmostDone" = "Almost done!";
|
||||
"ExtensionEnable" = "Activate app extension";
|
||||
"ExtensionInSafari" = "In Safari, find Bitwarden using the share icon (hint: scroll to the right on the bottom row of the menu).";
|
||||
"ExtensionInstantAccess" = "Get instant access to your passwords!";
|
||||
"ExtensionReady" = "You're ready to log in!";
|
||||
"ExtensionSetup" = "Your logins are now easily accessible from Safari, Chrome, and other supported apps.";
|
||||
"ExtensionSetup2" = "In Safari and Chrome, find Bitwarden using the share icon (hint: scroll to the right on the bottom row of the share menu).";
|
||||
"ExtensionTapIcon" = "Tap the Bitwarden icon in the menu to launch the extension.";
|
||||
"ExtensionTurnOn" = "To turn on Bitwarden in Safari and other apps, tap the \"more\" icon on the bottom row of the menu.";
|
||||
"Favorite" = "Favorite";
|
||||
"Fingerprint" = "Fingerprint";
|
||||
"GeneratePassword" = "Generate password";
|
||||
"GetPasswordHint" = "Get your master password hint";
|
||||
"ImportItems" = "Import items";
|
||||
"ImportItemsConfirmation" = "You can bulk import items from the bitwarden.com web vault. Do you want to visit the website now?";
|
||||
"ImportItemsDescription" = "Quickly bulk import your items from other password management apps.";
|
||||
"LastSync" = "Last sync:";
|
||||
"Length" = "Length";
|
||||
"Lock" = "Lock";
|
||||
"FifteenMinutes" = "15 minutes";
|
||||
"OneHour" = "1 hour";
|
||||
"OneMinute" = "1 minute";
|
||||
"FourHours" = "4 hours";
|
||||
"Immediately" = "Immediately";
|
||||
"VaultTimeout" = "Vault timeout";
|
||||
"VaultTimeoutAction" = "Vault timeout action";
|
||||
"VaultTimeoutLogOutConfirmation" = "Logging out will remove all access to your vault and requires online authentication after the timeout period. Are you sure you want to use this setting?";
|
||||
"LoggingIn" = "Logging in...";
|
||||
"LoginOrCreateNewAccount" = "Log in or create a new account to access your secure vault.";
|
||||
"Manage" = "Manage";
|
||||
"MasterPasswordConfirmationValMessage" = "Password confirmation is not correct.";
|
||||
"MasterPasswordDescription" = "The master password is the password you use to access your vault. It is very important that you do not forget your master password. There is no way to recover the password in the event that you forget it.";
|
||||
"MasterPasswordHint" = "Master password hint (optional)";
|
||||
"MasterPasswordHintDescription" = "A master password hint can help you remember your password if you forget it.";
|
||||
"MasterPasswordLengthValMessageX" = "Master password must be at least %1$@ characters long.";
|
||||
"MinNumbers" = "Minimum numbers";
|
||||
"MinSpecial" = "Minimum special";
|
||||
"MoreSettings" = "More settings";
|
||||
"MustLogInMainApp" = "You must log into the main Bitwarden app before you can use the extension.";
|
||||
"Never" = "Never";
|
||||
"NewItemCreated" = "Item added";
|
||||
"NoFavorites" = "There are no favorites in your vault.";
|
||||
"NoItems" = "There are no items in your vault.";
|
||||
"NoItemsTap" = "There are no items in your vault for this website/app. Tap to add one.";
|
||||
"NoUsernamePasswordConfigured" = "This login does not have a username or password configured.";
|
||||
"OkGotIt" = "Ok, got it!";
|
||||
"OptionDefaults" = "Option defaults are set from the main Bitwarden app's password generator tool.";
|
||||
"Options" = "Options";
|
||||
"Other" = "Other";
|
||||
"PasswordGenerated" = "Password generated";
|
||||
"PasswordGenerator" = "Password generator";
|
||||
"PasswordHint" = "Password hint";
|
||||
"PasswordHintAlert" = "We've sent you an email with your master password hint.";
|
||||
"PasswordOverrideAlert" = "Are you sure you want to overwrite the current password?";
|
||||
"PushNotificationAlert" = "Bitwarden keeps your vault automatically synced by using push notifications. For the best possible experience, please select \"Allow\" on the following prompt when asked to allow push notifications.";
|
||||
"RateTheApp" = "Rate the app";
|
||||
"RateTheAppDescription" = "Please consider helping us out with a good review!";
|
||||
"RegeneratePassword" = "Regenerate password";
|
||||
"RetypeMasterPassword" = "Re-type master password";
|
||||
"SearchVault" = "Search vault";
|
||||
"Security" = "Security";
|
||||
"Select" = "Select";
|
||||
"SetPIN" = "Set PIN";
|
||||
"SetPINDirection" = "Enter a 4 digit PIN code to unlock the app with.";
|
||||
"ItemInformation" = "Item information";
|
||||
"ItemUpdated" = "Item saved";
|
||||
"Submitting" = "Submitting...";
|
||||
"Syncing" = "Syncing...";
|
||||
"SyncingComplete" = "Syncing complete";
|
||||
"SyncingFailed" = "Syncing failed";
|
||||
"SyncVaultNow" = "Sync vault now";
|
||||
"TouchID" = "Touch ID";
|
||||
"TwoStepLogin" = "Two-step login";
|
||||
"UnlockWith" = "Unlock with %1$@";
|
||||
"UnlockWithPIN" = "Unlock with PIN code";
|
||||
"Validating" = "Validating";
|
||||
"VerificationCode" = "Verification code";
|
||||
"ViewItem" = "View item";
|
||||
"WebVault" = "Bitwarden web vault";
|
||||
"Lost2FAApp" = "Lost authenticator app?";
|
||||
"Items" = "Items";
|
||||
"ExtensionActivated" = "Extension activated!";
|
||||
"Icons" = "Icons";
|
||||
"Translations" = "Translations";
|
||||
"ItemsForUri" = "Items for %1$@";
|
||||
"NoItemsForUri" = "There are no items in your vault for %1$@.";
|
||||
"BitwardenAutofillServiceOverlay" = "When you select an input field and see a Bitwarden auto-fill overlay, you can tap it to launch the auto-fill service.";
|
||||
"BitwardenAutofillServiceNotificationContent" = "Tap this notification to auto-fill an item from your vault.";
|
||||
"BitwardenAutofillServiceOpenAccessibilitySettings" = "Open Accessibility Settings";
|
||||
"BitwardenAutofillServiceStep1" = "1. On the Android Accessibility Settings screen, touch \"Bitwarden\" under the Services heading.";
|
||||
"BitwardenAutofillServiceStep2" = "2. Switch on the toggle and press OK to accept.";
|
||||
"Disabled" = "Disabled";
|
||||
"Enabled" = "Enabled";
|
||||
"Off" = "Off";
|
||||
"On" = "On";
|
||||
"Status" = "Status";
|
||||
"BitwardenAutofillServiceAlert2" = "The easiest way to add new logins to your vault is from the Bitwarden Auto-fill Service. Learn more about using the Bitwarden Auto-fill Service by navigating to the \"Settings\" screen.";
|
||||
"Autofill" = "Auto-fill";
|
||||
"AutofillOrView" = "Do you want to auto-fill or view this item?";
|
||||
"BitwardenAutofillServiceMatchConfirm" = "Are you sure you want to auto-fill this item? It is not a complete match for \"%1$@\".";
|
||||
"MatchingItems" = "Matching items";
|
||||
"PossibleMatchingItems" = "Possible matching items";
|
||||
"Search" = "Search";
|
||||
"BitwardenAutofillServiceSearch" = "You are searching for an auto-fill item for \"%1$@\".";
|
||||
"LearnOrg" = "Learn about organizations";
|
||||
"CannotOpenApp" = "Cannot open the app \"%1$@\".";
|
||||
"AuthenticatorAppTitle" = "Authenticator app";
|
||||
"EnterVerificationCodeApp" = "Enter the 6 digit verification code from your authenticator app.";
|
||||
"EnterVerificationCodeEmail" = "Enter the 6 digit verification code that was emailed to %1$@.";
|
||||
"LoginUnavailable" = "Login unavailable";
|
||||
"NoTwoStepAvailable" = "This account has two-step login set up, however, none of the configured two-step providers are supported on this device. Please use a supported device and/or add additional providers that are better supported across devices (such as an authenticator app).";
|
||||
"RecoveryCodeTitle" = "Recovery code";
|
||||
"RememberMe" = "Remember me";
|
||||
"SendVerificationCodeAgain" = "Send verification code email again";
|
||||
"TwoStepLoginOptions" = "Two-step login options";
|
||||
"UseAnotherTwoStepMethod" = "Use another two-step login method";
|
||||
"VerificationEmailNotSent" = "Could not send verification email. Try again.";
|
||||
"VerificationEmailSent" = "Verification email sent";
|
||||
"YubiKeyInstruction" = "To continue, hold your YubiKey NEO against the back of the device or insert your YubiKey into your device's USB port, then touch its button.";
|
||||
"YubiKeyTitle" = "YubiKey security key";
|
||||
"AddNewAttachment" = "Add new attachment";
|
||||
"Attachments" = "Attachments";
|
||||
"UnableToDownloadFile" = "Unable to download file.";
|
||||
"UnableToOpenFile" = "Your device cannot open this type of file.";
|
||||
"Downloading" = "Downloading...";
|
||||
"AttachmentLargeWarning" = "This attachment is %1$@ in size. Are you sure you want to download it onto your device?";
|
||||
"AuthenticatorKey" = "Authenticator key (TOTP)";
|
||||
"VerificationCodeTotp" = "Verification code (TOTP)";
|
||||
"AuthenticatorKeyAdded" = "Authenticator key added.";
|
||||
"AuthenticatorKeyReadError" = "Cannot read authenticator key.";
|
||||
"PointYourCameraAtTheQRCode" = "Point your camera at the QR Code.\nScanning will happen automatically.";
|
||||
"ScanQrTitle" = "Scan QR Code";
|
||||
"Camera" = "Camera";
|
||||
"Photos" = "Photos";
|
||||
"CopyTotp" = "Copy TOTP";
|
||||
"CopyTotpAutomaticallyDescription" = "If a login has an authenticator key, copy the TOTP verification code to your clipboard when you auto-fill the login.";
|
||||
"CopyTotpAutomatically" = "Copy TOTP automatically";
|
||||
"PremiumRequired" = "A premium membership is required to use this feature.";
|
||||
"AttachementAdded" = "Attachment added";
|
||||
"AttachmentDeleted" = "Attachment deleted";
|
||||
"ChooseFile" = "Choose file";
|
||||
"File" = "File";
|
||||
"NoFileChosen" = "No file chosen";
|
||||
"NoAttachments" = "There are no attachments.";
|
||||
"FileSource" = "File Source";
|
||||
"FeatureUnavailable" = "Feature unavailable";
|
||||
"MaxFileSize" = "Maximum file size is 100 MB.";
|
||||
"UpdateKey" = "You cannot use this feature until you update your encryption key.";
|
||||
"EncryptionKeyMigrationRequiredDescriptionLong" = "Encryption key migration required. Please login through the web vault to update your encryption key.";
|
||||
"LearnMore" = "Learn more";
|
||||
"ApiUrl" = "API server URL";
|
||||
"CustomEnvironment" = "Custom environment";
|
||||
"CustomEnvironmentFooter" = "For advanced users. You can specify the base URL of each service independently.";
|
||||
"EnvironmentSaved" = "The environment URLs have been saved.";
|
||||
"FormattedIncorrectly" = "%1$@ is not correctly formatted.";
|
||||
"IdentityUrl" = "Identity server URL";
|
||||
"SelfHostedEnvironment" = "Self-hosted environment";
|
||||
"SelfHostedEnvironmentFooter" = "Specify the base URL of your on-premise hosted Bitwarden installation.";
|
||||
"ServerUrl" = "Server URL";
|
||||
"WebVaultUrl" = "Web vault server URL";
|
||||
"BitwardenAutofillServiceNotificationContentOld" = "Tap this notification to view items from your vault.";
|
||||
"CustomFields" = "Custom fields";
|
||||
"CopyNumber" = "Copy number";
|
||||
"CopySecurityCode" = "Copy security code";
|
||||
"Number" = "Number";
|
||||
"SecurityCode" = "Security code";
|
||||
"TypeCard" = "Card";
|
||||
"TypeIdentity" = "Identity";
|
||||
"TypeLogin" = "Login";
|
||||
"TypeSecureNote" = "Secure note";
|
||||
"Address1" = "Address 1";
|
||||
"Address2" = "Address 2";
|
||||
"Address3" = "Address 3";
|
||||
"April" = "April";
|
||||
"August" = "August";
|
||||
"Brand" = "Brand";
|
||||
"CardholderName" = "Cardholder name";
|
||||
"CityTown" = "City / Town";
|
||||
"Company" = "Company";
|
||||
"Country" = "Country";
|
||||
"December" = "December";
|
||||
"Dr" = "Dr";
|
||||
"ExpirationMonth" = "Expiration month";
|
||||
"ExpirationYear" = "Expiration year";
|
||||
"February" = "February";
|
||||
"FirstName" = "First name";
|
||||
"January" = "January";
|
||||
"July" = "July";
|
||||
"June" = "June";
|
||||
"LastName" = "Last name";
|
||||
"FullName" = "Full name";
|
||||
"LicenseNumber" = "License number";
|
||||
"March" = "March";
|
||||
"May" = "May";
|
||||
"MiddleName" = "Middle name";
|
||||
"Mr" = "Mr";
|
||||
"Mrs" = "Mrs";
|
||||
"Ms" = "Ms";
|
||||
"Mx" = "Mx";
|
||||
"November" = "November";
|
||||
"October" = "October";
|
||||
"PassportNumber" = "Passport number";
|
||||
"Phone" = "Phone";
|
||||
"September" = "September";
|
||||
"SSN" = "Social Security number";
|
||||
"StateProvince" = "State / Province";
|
||||
"Title" = "Title";
|
||||
"ZipPostalCode" = "Zip / Postal code";
|
||||
"Address" = "Address";
|
||||
"Expiration" = "Expiration";
|
||||
"ShowWebsiteIcons" = "Show website icons";
|
||||
"ShowWebsiteIconsDescription" = "Show a recognizable image next to each login.";
|
||||
"IconsUrl" = "Icons server URL";
|
||||
"AutofillWithBitwarden" = "Auto-fill with Bitwarden";
|
||||
"VaultIsLocked" = "Vault is locked";
|
||||
"GoToMyVault" = "Go to my vault";
|
||||
"Collections" = "Collections";
|
||||
"NoItemsCollection" = "There are no items in this collection.";
|
||||
"NoItemsFolder" = "There are no items in this folder.";
|
||||
"NoItemsTrash" = "There are no items in the trash.";
|
||||
"AutofillAccessibilityService" = "Auto-fill Accessibility Service";
|
||||
"AutofillServiceDescription" = "The Bitwarden auto-fill service uses the Android Autofill Framework to assist in filling login information into other apps on your device.";
|
||||
"BitwardenAutofillServiceDescription" = "Use the Bitwarden auto-fill service to fill login information into other apps.";
|
||||
"BitwardenAutofillServiceOpenAutofillSettings" = "Open Autofill Settings";
|
||||
"FaceID" = "Face ID";
|
||||
"FaceIDDirection" = "Use Face ID to verify.";
|
||||
"UseFaceIDToUnlock" = "Use Face ID To Unlock";
|
||||
"VerifyFaceID" = "Verify Face ID";
|
||||
"WindowsHello" = "Windows Hello";
|
||||
"BitwardenAutofillGoToSettings" = "We were unable to automatically open the Android autofill settings menu for you. You can navigate to the autofill settings menu manually from Android Settings > System > Languages and input > Advanced > Autofill service.";
|
||||
"CustomFieldName" = "Custom field name";
|
||||
"FieldTypeBoolean" = "Boolean";
|
||||
"FieldTypeHidden" = "Hidden";
|
||||
"FieldTypeLinked" = "Linked";
|
||||
"FieldTypeText" = "Text";
|
||||
"NewCustomField" = "New custom field";
|
||||
"SelectTypeField" = "What type of custom field do you want to add?";
|
||||
"Remove" = "Remove";
|
||||
"NewUri" = "New URI";
|
||||
"URIPosition" = "URI %1$@";
|
||||
"BaseDomain" = "Base domain";
|
||||
"Default" = "Default";
|
||||
"Exact" = "Exact";
|
||||
"Host" = "Host";
|
||||
"RegEx" = "Regular expression";
|
||||
"StartsWith" = "Starts with";
|
||||
"URIMatchDetection" = "URI match detection";
|
||||
"MatchDetection" = "Match detection";
|
||||
"YesAndSave" = "Yes, and save";
|
||||
"AutofillAndSave" = "Auto-fill and save";
|
||||
"Organization" = "Organization";
|
||||
"HoldYubikeyNearTop" = "Hold your Yubikey near the top of the device.";
|
||||
"TryAgain" = "Try again";
|
||||
"YubiKeyInstructionIos" = "To continue, hold your YubiKey NEO against the back of the device.";
|
||||
"BitwardenAutofillAccessibilityServiceDescription2" = "The accessibility service may be helpful to use when apps do not support the standard auto-fill service.";
|
||||
"DatePasswordUpdated" = "Password updated";
|
||||
"DateUpdated" = "Updated";
|
||||
"AutofillActivated" = "AutoFill activated!";
|
||||
"MustLogInMainAppAutofill" = "You must log into the main Bitwarden app before you can use AutoFill.";
|
||||
"AutofillSetup" = "Your logins are now easily accessible right from your keyboard while logging into apps and websites.";
|
||||
"AutofillSetup2" = "We recommend disabling any other AutoFill apps under Settings if you do not plan to use them.";
|
||||
"BitwardenAutofillDescription" = "Access your vault directly from your keyboard to quickly autofill passwords.";
|
||||
"AutofillTurnOn" = "To set up password auto-fill on your device, follow these instructions:";
|
||||
"AutofillTurnOn1" = "1. Go to the iOS \"Settings\" app";
|
||||
"AutofillTurnOn2" = "2. Tap \"Passwords\"";
|
||||
"AutofillTurnOn3" = "3. Tap \"AutoFill Passwords\"";
|
||||
"AutofillTurnOn4" = "4. Turn on AutoFill";
|
||||
"AutofillTurnOn5" = "5. Select \"Bitwarden\"";
|
||||
"PasswordAutofill" = "Password auto-fill";
|
||||
"BitwardenAutofillAlert2" = "The easiest way to add new logins to your vault is by using the Bitwarden Password AutoFill extension. Learn more about using the Bitwarden Password AutoFill extension by navigating to the \"Settings\" screen.";
|
||||
"InvalidEmail" = "Invalid email address.";
|
||||
"Cards" = "Cards";
|
||||
"Identities" = "Identities";
|
||||
"Logins" = "Logins";
|
||||
"SecureNotes" = "Secure notes";
|
||||
"AllItems" = "All items";
|
||||
"URIs" = "URIs";
|
||||
"CheckingPassword" = "Checking password...";
|
||||
"CheckPassword" = "Check if password has been exposed.";
|
||||
"PasswordExposed" = "This password has been exposed %1$@ time(s) in data breaches. You should change it.";
|
||||
"PasswordSafe" = "This password was not found in any known data breaches. It should be safe to use.";
|
||||
"IdentityName" = "Identity name";
|
||||
"Value" = "Value";
|
||||
"PasswordHistory" = "Password history";
|
||||
"Types" = "Types";
|
||||
"NoPasswordsToList" = "No passwords to list.";
|
||||
"NoItemsToList" = "There are no items to list.";
|
||||
"SearchCollection" = "Search collection";
|
||||
"SearchFileSends" = "Search file Sends";
|
||||
"SearchTextSends" = "Search text Sends";
|
||||
"SearchGroup" = "Search %1$@";
|
||||
"Type" = "Type";
|
||||
"MoveDown" = "Move down";
|
||||
"MoveUp" = "Move Up";
|
||||
"Miscellaneous" = "Miscellaneous";
|
||||
"Ownership" = "Ownership";
|
||||
"WhoOwnsThisItem" = "Who owns this item?";
|
||||
"NoCollectionsToList" = "There are no collections to list.";
|
||||
"MovedItemToOrg" = "%1$@ moved to %2$@.";
|
||||
"ItemShared" = "Item has been shared.";
|
||||
"SelectOneCollection" = "You must select at least one collection.";
|
||||
"Share" = "Share";
|
||||
"ShareItem" = "Share Item";
|
||||
"MoveToOrganization" = "Move to Organization";
|
||||
"NoOrgsToList" = "No organizations to list.";
|
||||
"MoveToOrgDesc" = "Choose an organization that you wish to move this item to. Moving to an organization transfers ownership of the item to that organization. You will no longer be the direct owner of this item once it has been moved.";
|
||||
"NumberOfWords" = "Number of words";
|
||||
"Passphrase" = "Passphrase";
|
||||
"WordSeparator" = "Word separator";
|
||||
"Clear" = "Clear";
|
||||
"Generator" = "Generator";
|
||||
"NoFoldersToList" = "There are no folders to list.";
|
||||
"FingerprintPhrase" = "Fingerprint phrase";
|
||||
"YourAccountsFingerprint" = "Your account's fingerprint phrase";
|
||||
"LearnOrgConfirmation" = "Bitwarden allows you to share your vault items with others by using an organization account. Would you like to visit the bitwarden.com website to learn more?";
|
||||
"ExportVault" = "Export vault";
|
||||
"LockNow" = "Lock now";
|
||||
"PIN" = "PIN";
|
||||
"Unlock" = "Unlock";
|
||||
"UnlockVault" = "Unlock vault";
|
||||
"ThirtyMinutes" = "30 minutes";
|
||||
"SetPINDescription" = "Set your PIN code for unlocking Bitwarden. Your PIN settings will be reset if you ever fully log out of the application.";
|
||||
"LoggedInAsOn" = "Logged in as %1$@ on %2$@.";
|
||||
"VaultLockedMasterPassword" = "Your vault is locked. Verify your master password to continue.";
|
||||
"VaultLockedPIN" = "Your vault is locked. Verify your PIN code to continue.";
|
||||
"VaultLockedIdentity" = "Your vault is locked. Verify your identity to continue.";
|
||||
"Dark" = "Dark";
|
||||
"Light" = "Light";
|
||||
"FiveMinutes" = "5 minutes";
|
||||
"TenSeconds" = "10 seconds";
|
||||
"ThirtySeconds" = "30 seconds";
|
||||
"TwentySeconds" = "20 seconds";
|
||||
"TwoMinutes" = "2 minutes";
|
||||
"ClearClipboard" = "Clear clipboard";
|
||||
"ClearClipboardDescription" = "Automatically clear copied values from your clipboard.";
|
||||
"DefaultUriMatchDetection" = "Default URI match detection";
|
||||
"DefaultUriMatchDetectionDescription" = "Choose the default way that URI match detection is handled for logins when performing actions such as auto-fill.";
|
||||
"Theme" = "Theme";
|
||||
"ThemeDescription" = "Change the application's color theme.";
|
||||
"ThemeDefault" = "Default (System)";
|
||||
"DefaultDarkTheme" = "Default dark theme";
|
||||
"CopyNotes" = "Copy note";
|
||||
"Exit" = "Exit";
|
||||
"ExitConfirmation" = "Are you sure you want to exit Bitwarden?";
|
||||
"PINRequireMasterPasswordRestart" = "Do you want to require unlocking with your master password when the application is restarted?";
|
||||
"Black" = "Black";
|
||||
"Nord" = "Nord";
|
||||
"SolarizedDark" = "Solarized Dark";
|
||||
"AutofillBlockedUris" = "Auto-fill blocked URIs";
|
||||
"AskToAddLogin" = "Ask to add login";
|
||||
"AskToAddLoginDescription" = "Ask to add an item if one isn't found in your vault.";
|
||||
"OnRestart" = "On app restart";
|
||||
"AutofillServiceNotEnabled" = "Auto-fill makes it easy to securely access your Bitwarden vault from other websites and apps. It looks like you have not set up an auto-fill service for Bitwarden. Set up auto-fill for Bitwarden from the \"Settings\" screen.";
|
||||
"ThemeAppliedOnRestart" = "Your theme changes will apply when the app is restarted.";
|
||||
"Capitalize" = "Capitalize";
|
||||
"IncludeNumber" = "Include number";
|
||||
"Download" = "Download";
|
||||
"Shared" = "Shared";
|
||||
"ToggleVisibility" = "Toggle visibility";
|
||||
"LoginExpired" = "Your login session has expired.";
|
||||
"BiometricsDirection" = "Biometric verification";
|
||||
"Biometrics" = "Biometrics";
|
||||
"UseBiometricsToUnlock" = "Use biometrics to unlock";
|
||||
"AccessibilityOverlayPermissionAlert" = "Bitwarden needs attention - See \"Auto-fill Accessibility Service\" from Bitwarden settings";
|
||||
"BitwardenAutofillServiceOverlayPermission" = "3. On the Android App Settings screen for Bitwarden, go to the \"Display over other apps\" options (under Advanced) and tap the toggle to allow overlay support.";
|
||||
"OverlayPermission" = "Permission";
|
||||
"BitwardenAutofillServiceOpenOverlayPermissionSettings" = "Open Overlay Permission Settings";
|
||||
"BitwardenAutofillServiceStep3" = "3. On the Android App Settings screen for Bitwarden, select \"Display over other apps\" (under \"Advanced\") and switch on the toggle to allow the overlay.";
|
||||
"Denied" = "Denied";
|
||||
"Granted" = "Granted";
|
||||
"FileFormat" = "File format";
|
||||
"ExportVaultMasterPasswordDescription" = "Enter your master password to export your vault data.";
|
||||
"SendVerificationCodeToEmail" = "Send a verification code to your email";
|
||||
"CodeSent" = "Code sent!";
|
||||
"ConfirmYourIdentity" = "Confirm your identity to continue.";
|
||||
"ExportVaultWarning" = "This export contains your vault data in an unencrypted format. You should not store or send the exported file over unsecure channels (such as email). Delete it immediately after you are done using it.";
|
||||
"EncExportKeyWarning" = "This export encrypts your data using your account's encryption key. If you ever rotate your account's encryption key you should export again since you will not be able to decrypt this export file.";
|
||||
"EncExportAccountWarning" = "Account encryption keys are unique to each Bitwarden user account, so you can't import an encrypted export into a different account.";
|
||||
"ExportVaultConfirmationTitle" = "Confirm vault export";
|
||||
"Warning" = "Warning";
|
||||
"ExportVaultFailure" = "There was a problem exporting your vault. If the problem persists, you'll need to export from the web vault.";
|
||||
"ExportVaultSuccess" = "Vault exported successfully";
|
||||
"Clone" = "Clone";
|
||||
"PasswordGeneratorPolicyInEffect" = "One or more organization policies are affecting your generator settings";
|
||||
"Open" = "Open";
|
||||
"UnableToSaveAttachment" = "There was a problem saving this attachment. If the problem persists, you can save it from the web vault.";
|
||||
"SaveAttachmentSuccess" = "Attachment saved successfully";
|
||||
"AutofillTileAccessibilityRequired" = "Please turn on \"Auto-fill Accessibility Service\" from Bitwarden Settings to use the Auto-fill tile.";
|
||||
"AutofillTileUriNotFound" = "No password fields detected";
|
||||
"SoftDeleting" = "Sending to trash...";
|
||||
"ItemSoftDeleted" = "Item has been sent to trash.";
|
||||
"Restore" = "Restore";
|
||||
"Restoring" = "Restoring...";
|
||||
"ItemRestored" = "Item restored";
|
||||
"Trash" = "Trash";
|
||||
"SearchTrash" = "Search trash";
|
||||
"DoYouReallyWantToPermanentlyDeleteCipher" = "Do you really want to permanently delete? This cannot be undone.";
|
||||
"DoYouReallyWantToRestoreCipher" = "Do you really want to restore this item?";
|
||||
"DoYouReallyWantToSoftDeleteCipher" = "Do you really want to send to the trash?";
|
||||
"AccountBiometricInvalidated" = "Biometric unlock for this account is disabled pending verification of master password.";
|
||||
"AccountBiometricInvalidatedExtension" = "Autofill biometric unlock for this account is disabled pending verification of master password.";
|
||||
"EnableSyncOnRefresh" = "Allow sync on refresh";
|
||||
"EnableSyncOnRefreshDescription" = "Syncing vault with pull down gesture.";
|
||||
"LogInSso" = "Enterprise single sign-on";
|
||||
"LogInSsoSummary" = "Quickly log in using your organization's single sign-on portal. Please enter your organization's identifier to begin.";
|
||||
"OrgIdentifier" = "Organization identifier";
|
||||
"LoginSsoError" = "Currently unable to login with SSO";
|
||||
"SetMasterPassword" = "Set master password";
|
||||
"SetMasterPasswordSummary" = "In order to complete logging in with SSO, please set a master password to access and protect your vault.";
|
||||
"MasterPasswordPolicyInEffect" = "One or more organization policies require your master password to meet the following requirements:";
|
||||
"PolicyInEffectMinComplexity" = "Minimum complexity score of %1$@";
|
||||
"PolicyInEffectMinLength" = "Minimum length of %1$@";
|
||||
"PolicyInEffectUppercase" = "Contain one or more uppercase characters";
|
||||
"PolicyInEffectLowercase" = "Contain one or more lowercase characters";
|
||||
"PolicyInEffectNumbers" = "Contain one or more numbers";
|
||||
"PolicyInEffectSpecial" = "Contain one or more of the following special characters: %1$@";
|
||||
"MasterPasswordPolicyValidationTitle" = "Invalid password";
|
||||
"MasterPasswordPolicyValidationMessage" = "Password does not meet organization requirements. Please check the policy information and try again.";
|
||||
"Loading" = "Loading";
|
||||
"AcceptPolicies" = "By activating this switch you agree to the following:";
|
||||
"AcceptPoliciesError" = "Terms of Service and Privacy Policy have not been acknowledged.";
|
||||
"TermsOfService" = "Terms of Service";
|
||||
"PrivacyPolicy" = "Privacy Policy";
|
||||
"AccessibilityDrawOverPermissionAlert" = "Bitwarden needs attention - Turn on \"Draw-Over\" in \"Auto-fill Services\" from Bitwarden Settings";
|
||||
"AutofillServices" = "Auto-fill services";
|
||||
"InlineAutofill" = "Use inline autofill";
|
||||
"InlineAutofillDescription" = "Use inline autofill if your selected IME (keyboard) supports it. If your configuration is not supported (or this option is turned off), the default Autofill overlay will be used.";
|
||||
"Accessibility" = "Use accessibility";
|
||||
"AccessibilityDescription" = "Use the Bitwarden Accessibility Service to auto-fill your logins across apps and the web. When set up, we'll display a popup when login fields are selected.";
|
||||
"AccessibilityDescription2" = "Use the Bitwarden Accessibility Service to auto-fill your logins across apps and the web. (Requires Draw-Over to be turned on as well)";
|
||||
"AccessibilityDescription3" = "Use the Bitwarden Accessibility Service to use the Autofill Quick-Action Tile, and/or show a popup using Draw-Over (if turned on).";
|
||||
"AccessibilityDescription4" = "Required to use the Autofill Quick-Action Tile, or to augment the Autofill Service by using Draw-Over (if turned on).";
|
||||
"DrawOver" = "Use draw-over";
|
||||
"DrawOverDescription" = "Allows the Bitwarden Accessibility Service to display a popup when login fields are selected.";
|
||||
"DrawOverDescription2" = "If turned on, the Bitwarden Accessibility Service will display a popup when login fields are selected to assist with auto-filling your logins.";
|
||||
"DrawOverDescription3" = "If turned on, accessibility will show a popup to augment the Autofill Service for older apps that don't support the Android Autofill Framework.";
|
||||
"PersonalOwnershipSubmitError" = "Due to an enterprise policy, you are restricted from saving items to your individual vault. Change the ownership option to an organization and choose from available collections.";
|
||||
"PersonalOwnershipPolicyInEffect" = "An organization policy is affecting your ownership options.";
|
||||
"Send" = "Send";
|
||||
"AllSends" = "All Sends";
|
||||
"Sends" = "Sends";
|
||||
"NameInfo" = "A friendly name to describe this Send.";
|
||||
"Text" = "Text";
|
||||
"TypeText" = "Text";
|
||||
"TypeTextInfo" = "The text you want to send.";
|
||||
"HideTextByDefault" = "When accessing the Send, hide the text by default";
|
||||
"TypeFile" = "File";
|
||||
"TypeFileInfo" = "The file you want to send.";
|
||||
"FileTypeIsSelected" = "File type is selected.";
|
||||
"FileTypeIsNotSelected" = "File type is not selected, tap to select.";
|
||||
"TextTypeIsSelected" = "Text type is selected.";
|
||||
"TextTypeIsNotSelected" = "Text type is not selected, tap to select.";
|
||||
"DeletionDate" = "Deletion date";
|
||||
"DeletionTime" = "Deletion time";
|
||||
"DeletionDateInfo" = "The Send will be permanently deleted on the specified date and time.";
|
||||
"PendingDelete" = "Pending deletion";
|
||||
"ExpirationDate" = "Expiration date";
|
||||
"ExpirationTime" = "Expiration time";
|
||||
"ExpirationDateInfo" = "If set, access to this Send will expire on the specified date and time.";
|
||||
"Expired" = "Expired";
|
||||
"MaximumAccessCount" = "Maximum access count";
|
||||
"MaximumAccessCountInfo" = "If set, users will no longer be able to access this Send once the maximum access count is reached.";
|
||||
"MaximumAccessCountReached" = "Max access count reached";
|
||||
"CurrentAccessCount" = "Current access count";
|
||||
"NewPassword" = "New password";
|
||||
"PasswordInfo" = "Optionally require a password for users to access this Send.";
|
||||
"RemovePassword" = "Remove password";
|
||||
"AreYouSureRemoveSendPassword" = "Are you sure you want to remove the password?";
|
||||
"RemovingSendPassword" = "Removing password";
|
||||
"SendPasswordRemoved" = "Password has been removed.";
|
||||
"NotesInfo" = "Private notes about this Send.";
|
||||
"DisableSend" = "Deactivate this Send so that no one can access it";
|
||||
"NoSends" = "There are no Sends in your account.";
|
||||
"AddASend" = "Add a Send";
|
||||
"CopyLink" = "Copy link";
|
||||
"ShareLink" = "Share link";
|
||||
"SendLink" = "Send link";
|
||||
"SearchSends" = "Search Sends";
|
||||
"EditSend" = "Edit Send";
|
||||
"AddSend" = "New Send";
|
||||
"AreYouSureDeleteSend" = "Are you sure you want to delete this Send?";
|
||||
"SendDeleted" = "Send deleted";
|
||||
"SendUpdated" = "Send saved";
|
||||
"NewSendCreated" = "Send created";
|
||||
"OneDay" = "1 day";
|
||||
"TwoDays" = "2 days";
|
||||
"ThreeDays" = "3 days";
|
||||
"SevenDays" = "7 days";
|
||||
"ThirtyDays" = "30 days";
|
||||
"Custom" = "Custom";
|
||||
"ShareOnSave" = "Share this Send upon save";
|
||||
"SendDisabledWarning" = "Due to an enterprise policy, you are only able to delete an existing Send.";
|
||||
"AboutSend" = "About Send";
|
||||
"HideEmail" = "Hide my email address from recipients";
|
||||
"SendOptionsPolicyInEffect" = "One or more organization policies are affecting your Send options.";
|
||||
"SendFilePremiumRequired" = "Free accounts are restricted to sharing text only. A premium membership is required to use files with Send.";
|
||||
"SendFileEmailVerificationRequired" = "You must verify your email to use files with Send. You can verify your email in the web vault.";
|
||||
"PasswordPrompt" = "Master password re-prompt";
|
||||
"PasswordConfirmation" = "Master password confirmation";
|
||||
"PasswordConfirmationDesc" = "This action is protected, to continue please re-enter your master password to verify your identity.";
|
||||
"CaptchaRequired" = "Captcha required";
|
||||
"CaptchaFailed" = "Captcha failed. Please try again.";
|
||||
"UpdatedMasterPassword" = "Updated master password";
|
||||
"UpdateMasterPassword" = "Update master password";
|
||||
"UpdateMasterPasswordWarning" = "Your master password was recently changed by an administrator in your organization. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour.";
|
||||
"UpdatingPassword" = "Updating password";
|
||||
"UpdatePasswordError" = "Currently unable to update password";
|
||||
"RemoveMasterPassword" = "Remove master password";
|
||||
"RemoveMasterPasswordWarning" = "%1$@ is using SSO with customer-managed encryption. Continuing will remove your master password from your account and require SSO to login.";
|
||||
"RemoveMasterPasswordWarning2" = "If you do not want to remove your master password, you may leave this organization.";
|
||||
"LeaveOrganization" = "Leave organization";
|
||||
"LeaveOrganizationName" = "Leave %1$@?";
|
||||
"Fido2Title" = "FIDO2 WebAuthn";
|
||||
"Fido2Instruction" = "To continue, have your FIDO2 WebAuthn compatible security key ready, then follow the instructions after clicking 'Authenticate WebAuthn' on the next screen.";
|
||||
"Fido2Desc" = "Authentication using FIDO2 WebAuthn, you can authenticate using an external security key.";
|
||||
"Fido2AuthenticateWebAuthn" = "Authenticate WebAuthn";
|
||||
"Fido2ReturnToApp" = "Return to app";
|
||||
"Fido2CheckBrowser" = "Please make sure your default browser supports WebAuthn and try again.";
|
||||
"ResetPasswordAutoEnrollInviteWarning" = "This organization has an enterprise policy that will automatically enroll you in password reset. Enrollment will allow organization administrators to change your master password.";
|
||||
"VaultTimeoutPolicyInEffect" = "Your organization policies have set your maximum allowed vault timeout to %1$@ hour(s) and %2$@ minute(s).";
|
||||
"VaultTimeoutPolicyWithActionInEffect" = "Your organization policies are affecting your vault timeout. Maximum allowed vault timeout is %1$@ hour(s) and %2$@ minute(s). Your vault timeout action is set to %3$@.";
|
||||
"VaultTimeoutActionPolicyInEffect" = "Your organization policies have set your vault timeout action to %1$@.";
|
||||
"VaultTimeoutToLarge" = "Your vault timeout exceeds the restrictions set by your organization.";
|
||||
"DisablePersonalVaultExportPolicyInEffect" = "One or more organization policies prevents your from exporting your individual vault.";
|
||||
"AddAccount" = "Add account";
|
||||
"AccountUnlocked" = "Unlocked";
|
||||
"AccountLocked" = "Locked";
|
||||
"AccountLoggedOut" = "Logged out";
|
||||
"AccountSwitchedAutomatically" = "Switched to next available account";
|
||||
"AccountLockedSuccessfully" = "Account locked";
|
||||
"AccountLoggedOutSuccessfully" = "Account logged out successfully";
|
||||
"AccountRemovedSuccessfully" = "Account removed successfully";
|
||||
"DeleteAccount" = "Delete account";
|
||||
"DeletingYourAccountIsPermanent" = "Deleting your account is permanent";
|
||||
"DeleteAccountExplanation" = "Your account and all vault data will be erased and unrecoverable. Are you sure you want to continue?";
|
||||
"DeletingYourAccount" = "Deleting your account";
|
||||
"YourAccountHasBeenPermanentlyDeleted" = "Your account has been permanently deleted";
|
||||
"InvalidVerificationCode" = "Invalid verification code";
|
||||
"RequestOTP" = "Request one-time password";
|
||||
"SendCode" = "Send code";
|
||||
"Sending" = "Sending";
|
||||
"CopySendLinkOnSave" = "Copy Send link on save";
|
||||
"SendingCode" = "Sending code";
|
||||
"Verifying" = "Verifying";
|
||||
"ResendCode" = "Resend code";
|
||||
"AVerificationCodeWasSentToYourEmail" = "A verification code was sent to your email";
|
||||
"AnErrorOccurredWhileSendingAVerificationCodeToYourEmailPleaseTryAgain" = "An error occurred while sending a verification code to your email. Please try again";
|
||||
"EnterTheVerificationCodeThatWasSentToYourEmail" = "Enter the verification code that was sent to your email";
|
||||
"SubmitCrashLogs" = "Submit crash logs";
|
||||
"SubmitCrashLogsDescription" = "Help Bitwarden improve app stability by submitting crash reports.";
|
||||
"OptionsExpanded" = "Options are expanded, tap to collapse.";
|
||||
"OptionsCollapsed" = "Options are collapsed, tap to expand.";
|
||||
"UppercaseAtoZ" = "Uppercase (A to Z)";
|
||||
"LowercaseAtoZ" = "Lowercase (A to Z)";
|
||||
"NumbersZeroToNine" = "Numbers (0 to 9)";
|
||||
"SpecialCharacters" = "Special characters (!@#$%^&*)";
|
||||
"TapToGoBack" = "Tap to go back";
|
||||
"PasswordIsVisibleTapToHide" = "Password is visible, tap to hide.";
|
||||
"PasswordIsNotVisibleTapToShow" = "Password is not visible, tap to show.";
|
||||
"FilterByVault" = "Filter items by vault";
|
||||
"AllVaults" = "All vaults";
|
||||
"Vaults" = "Vaults";
|
||||
"VaultFilterDescription" = "Vault: %1$@";
|
||||
"All" = "All";
|
||||
"Totp" = "TOTP";
|
||||
"VerificationCodes" = "Verification codes";
|
||||
"PremiumSubscriptionRequired" = "Premium subscription required";
|
||||
"CannotAddAuthenticatorKey" = "Cannot add authenticator key?";
|
||||
"ScanQRCode" = "Scan QR Code";
|
||||
"CannotScanQRCode" = "Cannot scan QR Code?";
|
||||
"AuthenticatorKeyScanner" = "Authenticator key";
|
||||
"EnterKeyManually" = "Enter key manually";
|
||||
"AddTotp" = "Add TOTP";
|
||||
"SetupTotp" = "Set up TOTP";
|
||||
"OnceTheKeyIsSuccessfullyEntered" = "Once the key is successfully entered,\nselect Add TOTP to store the key safely";
|
||||
"NeverLockWarning" = "Setting your lock options to “Never” keeps your vault available to anyone with access to your device. If you use this option, you should ensure that you keep your device properly protected.";
|
||||
"EnvironmentPageUrlsError" = "One or more of the URLs entered are invalid. Please revise it and try to save again.";
|
||||
"GenericErrorMessage" = "We were unable to process your request. Please try again or contact us.";
|
||||
"AllowScreenCapture" = "Allow screen capture";
|
||||
"AreYouSureYouWantToEnableScreenCapture" = "Are you sure you want to turn on screen capture?";
|
||||
"LogInRequested" = "Login requested";
|
||||
"AreYouTryingToLogIn" = "Are you trying to log in?";
|
||||
"LogInAttemptByXOnY" = "Login attempt by %1$@ on %2$@";
|
||||
"DeviceType" = "Device type";
|
||||
"IpAddress" = "IP address";
|
||||
"Time" = "Time";
|
||||
"Near" = "Near";
|
||||
"ConfirmLogIn" = "Confirm login";
|
||||
"DenyLogIn" = "Deny login";
|
||||
"JustNow" = "Just now";
|
||||
"XMinutesAgo" = "%1$@ minutes ago";
|
||||
"LogInAccepted" = "Login confirmed";
|
||||
"LogInDenied" = "Login denied";
|
||||
"ApproveLoginRequests" = "Approve login requests";
|
||||
"UseThisDeviceToApproveLoginRequestsMadeFromOtherDevices" = "Use this device to approve login requests made from other devices";
|
||||
"AllowNotifications" = "Allow notifications";
|
||||
"ReceivePushNotificationsForNewLoginRequests" = "Receive push notifications for new login requests";
|
||||
"NoThanks" = "No thanks";
|
||||
"ConfimLogInAttempForX" = "Confirm login attempt for %1$@";
|
||||
"AllNotifications" = "All notifications";
|
||||
"PasswordType" = "Password type";
|
||||
"WhatWouldYouLikeToGenerate" = "What would you like to generate?";
|
||||
"UsernameType" = "Username type";
|
||||
"PlusAddressedEmail" = "Plus addressed email";
|
||||
"CatchAllEmail" = "Catch-all email";
|
||||
"ForwardedEmailAlias" = "Forwarded email alias";
|
||||
"RandomWord" = "Random word";
|
||||
"EmailRequiredParenthesis" = "Email (required)";
|
||||
"DomainNameRequiredParenthesis" = "Domain name (required)";
|
||||
"APIKeyRequiredParenthesis" = "API key (required)";
|
||||
"Service" = "Service";
|
||||
"AddyIo" = "addy.io";
|
||||
"FirefoxRelay" = "Firefox Relay";
|
||||
"SimpleLogin" = "SimpleLogin";
|
||||
"DuckDuckGo" = "DuckDuckGo";
|
||||
"Fastmail" = "Fastmail";
|
||||
"ForwardEmail" = "ForwardEmail";
|
||||
"APIAccessToken" = "API access token";
|
||||
"AreYouSureYouWantToOverwriteTheCurrentUsername" = "Are you sure you want to overwrite the current username?";
|
||||
"GenerateUsername" = "Generate username";
|
||||
"EmailType" = "Email Type";
|
||||
"WebsiteRequired" = "Website (required)";
|
||||
"UnknownXErrorMessage" = "Unknown %1$@ error occurred.";
|
||||
"PlusAddressedEmailDescription" = "Use your email provider's subaddress capabilities";
|
||||
"CatchAllEmailDescription" = "Use your domain's configured catch-all inbox.";
|
||||
"ForwardedEmailDescription" = "Generate an email alias with an external forwarding service.";
|
||||
"Random" = "Random";
|
||||
"ConnectToWatch" = "Connect to Watch";
|
||||
"AccessibilityServiceDisclosure" = "Accessibility Service Disclosure";
|
||||
"AccessibilityDisclosureText" = "Bitwarden uses the Accessibility Service to search for login fields in apps and websites, then establish the appropriate field IDs for entering a username & password when a match for the app or site is found. We do not store any of the information presented to us by the service, nor do we make any attempt to control any on-screen elements beyond text entry of credentials.";
|
||||
"Accept" = "Accept";
|
||||
"Decline" = "Decline";
|
||||
"LoginRequestHasAlreadyExpired" = "Login request has already expired.";
|
||||
"LoginAttemptFromXDoYouWantToSwitchToThisAccount" = "Login attempt from:\n%1$@\nDo you want to switch to this account?";
|
||||
"NewAroundHere" = "New around here?";
|
||||
"GetMasterPasswordwordHint" = "Get master password hint";
|
||||
"LoggingInAsXOnY" = "Logging in as %1$@ on %2$@";
|
||||
"NotYou" = "Not you?";
|
||||
"LogInWithMasterPassword" = "Log in with master password";
|
||||
"LogInWithAnotherDevice" = "Log in with device";
|
||||
"LogInInitiated" = "Login initiated";
|
||||
"ANotificationHasBeenSentToYourDevice" = "A notification has been sent to your device.";
|
||||
"PleaseMakeSureYourVaultIsUnlockedAndTheFingerprintPhraseMatchesOnTheOtherDevice" = "Please make sure your vault is unlocked and the Fingerprint phrase matches on the other device.";
|
||||
"ResendNotification" = "Resend notification";
|
||||
"NeedAnotherOption" = "Need another option?";
|
||||
"ViewAllLoginOptions" = "View all log in options";
|
||||
"ThisRequestIsNoLongerValid" = "This request is no longer valid";
|
||||
"PendingLogInRequests" = "Pending login requests";
|
||||
"DeclineAllRequests" = "Decline all requests";
|
||||
"AreYouSureYouWantToDeclineAllPendingLogInRequests" = "Are you sure you want to decline all pending login requests?";
|
||||
"RequestsDeclined" = "Requests declined";
|
||||
"NoPendingRequests" = "No pending requests";
|
||||
"EnableCamerPermissionToUseTheScanner" = "Enable camera permission to use the scanner";
|
||||
"Language" = "Language";
|
||||
"LanguageChangeXDescription" = "The language has been changed to %1$@. Please restart the app to see the change";
|
||||
"LanguageChangeRequiresAppRestart" = "Language change requires app restart";
|
||||
"DefaultSystem" = "Default (System)";
|
||||
"Important" = "Important";
|
||||
"Light" = "Light";
|
||||
"MasterPassword" = "Master password";
|
||||
"Ok" = "Ok";
|
||||
"ValidationFieldRequired" = "The %1$@ field is required.";
|
||||
"YourMasterPasswordCannotBeRecoveredIfYouForgetItXCharactersMinimum" = "Your master password cannot be recovered if you forget it! %1$@ characters minimum.";
|
||||
"WeakMasterPassword" = "Weak Master Password";
|
||||
"WeakPasswordIdentifiedUseAStrongPasswordToProtectYourAccount" = "Weak password identified. Use a strong password to protect your account. Are you sure you want to use a weak password?";
|
||||
"Weak" = "Weak";
|
||||
"Good" = "Good";
|
||||
"Strong" = "Strong";
|
||||
"CheckKnownDataBreachesForThisPassword" = "Check known data breaches for this password";
|
||||
"ExposedMasterPassword" = "Exposed Master Password";
|
||||
"PasswordFoundInADataBreachAlertDescription" = "Password found in a data breach. Use a unique password to protect your account. Are you sure you want to use an exposed password?";
|
||||
"WeakAndExposedMasterPassword" = "Weak and Exposed Master Password";
|
||||
"WeakPasswordIdentifiedAndFoundInADataBreachAlertDescription" = "Weak password identified and found in a data breach. Use a strong and unique password to protect your account. Are you sure you want to use this password?";
|
||||
"OrganizationSsoIdentifierRequired" = "Organization SSO identifier required.";
|
||||
"AddTheKeyToAnExistingOrNewItem" = "Add the key to an existing or new item";
|
||||
"ThereAreNoItemsInYourVaultThatMatchX" = "There are no items in your vault that match \"%1$@\"";
|
||||
"SearchForAnItemOrAddANewItem" = "Search for an item or add a new item";
|
||||
"ThereAreNoItemsThatMatchTheSearch" = "There are no items that match the search";
|
||||
"US" = "US";
|
||||
"EU" = "EU";
|
||||
"SelfHosted" = "Self-hosted";
|
||||
"DataRegion" = "Data region";
|
||||
"Region" = "Region";
|
||||
"UpdateWeakMasterPasswordWarning" = "Your master password does not meet one or more of your organization policies. In order to access the vault, you must update your master password now. Proceeding will log you out of your current session, requiring you to log back in. Active sessions on other devices may continue to remain active for up to one hour.";
|
||||
"CurrentMasterPassword" = "Current master password";
|
||||
"LoggedIn" = "Logged in!";
|
||||
"ApproveWithMyOtherDevice" = "Approve with my other device";
|
||||
"RequestAdminApproval" = "Request admin approval";
|
||||
"ApproveWithMasterPassword" = "Approve with master password";
|
||||
"TurnOffUsingPublicDevice" = "Turn off using a public device";
|
||||
"RememberThisDevice" = "Remember this device";
|
||||
"Passkey" = "Passkey";
|
||||
"Passkeys" = "Passkeys";
|
||||
"Application" = "Application";
|
||||
"YouCannotEditPasskeyApplicationBecauseItWouldInvalidateThePasskey" = "You cannot edit passkey application because it would invalidate the passkey";
|
||||
"PasskeyWillNotBeCopied" = "Passkey will not be copied";
|
||||
"ThePasskeyWillNotBeCopiedToTheClonedItemDoYouWantToContinueCloningThisItem" = "The passkey will not be copied to the cloned item. Do you want to continue cloning this item?";
|
||||
"CopyApplication" = "Copy application";
|
||||
"AvailableForTwoStepLogin" = "Available for two-step login";
|
||||
"MasterPasswordRePromptHelp" = "Master password re-prompt help";
|
||||
"UnlockingMayFailDueToInsufficientMemoryDecreaseYourKDFMemorySettingsToResolve" = "Unlocking may fail due to insufficient memory. Decrease your KDF memory settings or set up biometric unlock to resolve.";
|
||||
"InvalidAPIKey" = "Invalid API key";
|
||||
"InvalidAPIToken" = "Invalid API token";
|
||||
"AdminApprovalRequested" = "Admin approval requested";
|
||||
"YourRequestHasBeenSentToYourAdmin" = "Your request has been sent to your admin.";
|
||||
"YouWillBeNotifiedOnceApproved" = "You will be notified once approved.";
|
||||
"TroubleLoggingIn" = "Trouble logging in?";
|
||||
"LoggingInAsX" = "Logging in as %1$@";
|
||||
"VaultTimeoutActionChangedToLogOut" = "Vault timeout action changed to log out";
|
||||
"BlockAutoFill" = "Block auto-fill";
|
||||
"AutoFillWillNotBeOfferedForTheseURIs" = "Auto-fill will not be offered for these URIs.";
|
||||
"NewBlockedURI" = "New blocked URI";
|
||||
"URISaved" = "URI saved";
|
||||
"InvalidFormatUseHttpsHttpOrAndroidApp" = "Invalid format. Use https://, http://, or androidapp://";
|
||||
"EditURI" = "Edit URI";
|
||||
"EnterURI" = "Enter URI";
|
||||
"FormatXSeparateMultipleURIsWithAComma" = "Format: %1$@. Separate multiple URIs with a comma.";
|
||||
"FormatX" = "Format: %1$@";
|
||||
"InvalidURI" = "Invalid URI";
|
||||
"URIRemoved" = "URI removed";
|
||||
"ThereAreNoBlockedURIs" = "There are no blocked URIs";
|
||||
"TheURIXIsAlreadyBlocked" = "The URI %1$@ is already blocked";
|
||||
"CannotEditMultipleURIsAtOnce" = "Cannot edit multiple URIs at once";
|
||||
"LoginApproved" = "Login approved";
|
||||
"LogInWithDeviceMustBeSetUpInTheSettingsOfTheBitwardenAppNeedAnotherOption" = "Log in with device must be set up in the settings of the Bitwarden app. Need another option?";
|
||||
"LogInWithDevice" = "Log in with device";
|
||||
"LoggingInOn" = "Logging in on";
|
||||
"Vault" = "Vault";
|
||||
"Appearance" = "Appearance";
|
||||
"AccountSecurity" = "Account security";
|
||||
"BitwardenHelpCenter" = "Bitwarden Help Center";
|
||||
"ContactBitwardenSupport" = "Contact Bitwarden support";
|
||||
"CopyAppInformation" = "Copy app information";
|
||||
"SyncNow" = "Sync now";
|
||||
"UnlockOptions" = "Unlock options";
|
||||
"SessionTimeout" = "Session timeout";
|
||||
"SessionTimeoutAction" = "Session timeout action";
|
||||
"AccountFingerprintPhrase" = "Account fingerprint phrase";
|
||||
"OneHourAndOneMinute" = "One hour and one minute";
|
||||
"OneHourAndXMinute" = "One hour and %1$@ minutes";
|
||||
"XHoursAndOneMinute" = "%1$@ hours and one minute";
|
||||
"XHoursAndYMinutes" = "%1$@ hours and %2$@ minutes";
|
||||
"XHours" = "%1$@ hours";
|
||||
"AutofillServicesExplanationLong" = "The Android Autofill Framework is used to assist in filling login information into other apps on your device.";
|
||||
"UseInlineAutofillExplanationLong" = "Use inline autofill if your selected keyboard supports it. Otherwise, use the default overlay.";
|
||||
"AdditionalOptions" = "Additional options";
|
||||
"ContinueToWebApp" = "Continue to web app?";
|
||||
"ContinueToX" = "Continue to %1$@?";
|
||||
"ContinueToHelpCenter" = "Continue to Help center?";
|
||||
"ContinueToContactSupport" = "Continue to contact support?";
|
||||
"ContinueToPrivacyPolicy" = "Continue to privacy policy?";
|
||||
"ContinueToAppStore" = "Continue to app store?";
|
||||
"TwoStepLoginDescriptionLong" = "Make your account more secure by setting up two-step login in the Bitwarden web app.";
|
||||
"ChangeMasterPasswordDescriptionLong" = "You can change your master password on the Bitwarden web app.";
|
||||
"YouCanImportDataToYourVaultOnX" = "You can import data to your vault on %1$@.";
|
||||
"LearnMoreAboutHowToUseBitwardenOnTheHelpCenter" = "Learn more about how to use Bitwarden on the Help center.";
|
||||
"ContactSupportDescriptionLong" = "Can’t find what you are looking for? Reach out to Bitwarden support on bitwarden.com.";
|
||||
"PrivacyPolicyDescriptionLong" = "Check out our privacy policy on bitwarden.com.";
|
||||
"ExploreMoreFeaturesOfYourBitwardenAccountOnTheWebApp" = "Explore more features of your Bitwarden account on the web app.";
|
||||
"LearnAboutOrganizationsDescriptionLong" = "Bitwarden allows you to share your vault items with others by using an organization. Learn more on the bitwarden.com website.";
|
||||
"RateAppDescriptionLong" = "Help others find out if Bitwarden is right for them. Visit the app store and leave a rating now.";
|
||||
"DefaultDarkThemeDescriptionLong" = "Choose the dark theme to use when your device’s dark mode is in use";
|
||||
"CreatedXY" = "Created %1$@, %2$@";
|
||||
"TooManyAttempts" = "Too many attempts";
|
||||
"AccountLoggedOutBiometricExceeded" = "Account logged out.";
|
||||
"YourOrganizationPermissionsWereUpdatedRequeringYouToSetAMasterPassword" = "Your organization permissions were updated, requiring you to set a master password.";
|
||||
"YourOrganizationRequiresYouToSetAMasterPassword" = "Your organization requires you to set a master password.";
|
||||
"SetUpAnUnlockOptionToChangeYourVaultTimeoutAction" = "Set up an unlock option to change your vault timeout action.";
|
||||
"DuoTwoStepLoginIsRequiredForYourAccount" = "Duo two-step login is required for your account.";
|
||||
"FollowTheStepsFromDuoToFinishLoggingIn" = "Follow the steps from Duo to finish logging in.";
|
||||
"LaunchDuo" = "Launch Duo";
|
||||
"AppInfo" = "App info";
|
||||
"Browse" = "Browse";
|
||||
"SelectLanguage" = "Select language";
|
||||
"ContinueToCompleteWebAuthnVerfication" = "Continue to complete WebAuthn verfication.";
|
||||
"ThereWasAnErrorStartingWebAuthnTwoFactorAuthentication" = "There was an error starting WebAuthn two factor authentication";
|
||||
"LaunchWebAuthn" = "Launch WebAuthn";
|
||||
"Duo" = "Duo";
|
||||
"DuoUnsupported" = "Duo not yet supported.";
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
import SwiftUI
|
||||
|
||||
#if DEBUG
|
||||
/// A `TimeProvider` for previews.
|
||||
///
|
||||
class PreviewTimeProvider: TimeProvider {
|
||||
/// A fixed date to use for previews.
|
||||
var fixedDate: Date
|
||||
|
||||
var presentTime: Date {
|
||||
fixedDate
|
||||
}
|
||||
|
||||
init(
|
||||
fixedDate: Date = .init(
|
||||
timeIntervalSinceReferenceDate: 1_695_000_011
|
||||
)
|
||||
) {
|
||||
self.fixedDate = fixedDate
|
||||
}
|
||||
|
||||
func timeSince(_ date: Date) -> TimeInterval {
|
||||
presentTime.timeIntervalSince(date)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - AsyncButton
|
||||
|
||||
/// A wrapper around SwiftUI's `Button` that allows you to perform an action asynchronously when the user
|
||||
/// interacts with the button. This is especially helpful when the button's action instructs a `Store` to perform
|
||||
/// an `Effect`.
|
||||
///
|
||||
struct AsyncButton<Label>: View where Label: View {
|
||||
// MARK: Properties
|
||||
|
||||
/// The async action to perform when the user interacts with the button.
|
||||
let action: () async -> Void
|
||||
|
||||
// MARK: Private Properties
|
||||
|
||||
/// A view that describes the purpose of the button’s action.
|
||||
private var label: Label
|
||||
|
||||
/// An optional semantic role that describes the button.
|
||||
private let role: ButtonRole?
|
||||
|
||||
var body: some View {
|
||||
Button(
|
||||
role: role,
|
||||
action: {
|
||||
Task {
|
||||
await action()
|
||||
}
|
||||
},
|
||||
label: { label }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Creates a new `AsyncButton`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - role: An optional semantic role that describes the button. A value of nil means that the button
|
||||
/// doesn’t have an assigned role.
|
||||
/// - action: The async action to perform when the user interacts with this button.
|
||||
/// - label: A view that describes the purpose of the button’s action.
|
||||
///
|
||||
init(
|
||||
role: ButtonRole? = nil,
|
||||
action: @escaping () async -> Void,
|
||||
@ViewBuilder label: @escaping () -> Label
|
||||
) {
|
||||
self.role = role
|
||||
self.action = action
|
||||
self.label = label()
|
||||
}
|
||||
}
|
||||
|
||||
extension AsyncButton where Label == Text {
|
||||
/// Creates a new `AsyncButton`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - titleKey: The key for the button's localized title, that describes the purpose of the button's
|
||||
/// `action`.
|
||||
/// - action: The async action to perform when the user interacts with this button.
|
||||
///
|
||||
init(_ titleKey: LocalizedStringKey, action: @escaping () async -> Void) {
|
||||
self.init(action: action, label: { Text(titleKey) })
|
||||
}
|
||||
|
||||
/// Creates a new `AsyncButton`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - title: A string that describes the purpose of the button's `action`.
|
||||
/// - role: An optional semantic role describing the button. A value of `nil` means that the button doesn't
|
||||
/// have an assigned role.
|
||||
/// - action: The async action to perform when the user interacts with this button.
|
||||
///
|
||||
init<S>(_ title: S, role: ButtonRole? = nil, action: @escaping () async -> Void) where S: StringProtocol {
|
||||
self.init(role: role, action: action, label: { Text(title) })
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import ViewInspector
|
||||
import XCTest
|
||||
|
||||
@testable import AuthenticatorShared
|
||||
|
||||
// MARK: - AsyncButtonTests
|
||||
|
||||
class AsyncButtonTests: AuthenticatorTestCase {
|
||||
// MARK: Tests
|
||||
|
||||
func test_button_tap() throws {
|
||||
var didTap = false
|
||||
let subject = AsyncButton("Test") {
|
||||
didTap = true
|
||||
}
|
||||
|
||||
let button = try subject.inspect().find(button: "Test")
|
||||
try button.tap()
|
||||
|
||||
waitFor(didTap)
|
||||
XCTAssertTrue(didTap)
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
30
AuthenticatorShared/UI/Vault/Items/Items/ItemsAction.swift
Normal file
@ -0,0 +1,30 @@
|
||||
// MARK: - ItemsAction
|
||||
|
||||
/// Actions that can be processed by a `ItemsProcessor`.
|
||||
enum ItemsAction: Equatable {
|
||||
/// The add item button was pressed.
|
||||
///
|
||||
case addItemPressed
|
||||
|
||||
/// The url has been opened so clear the value in the state.
|
||||
case clearURL
|
||||
|
||||
/// The copy TOTP Code button was pressed.
|
||||
///
|
||||
case copyTOTPCode(_ code: String)
|
||||
|
||||
/// An item in the vault group was tapped.
|
||||
///
|
||||
/// - Parameter item: The item that was tapped.
|
||||
///
|
||||
case itemPressed(_ item: VaultListItem)
|
||||
|
||||
/// The more button on an item in the vault group was tapped.
|
||||
///
|
||||
/// - Parameter item: The item associated with the more button that was tapped.
|
||||
///
|
||||
case morePressed(_ item: VaultListItem)
|
||||
|
||||
/// The toast was shown or hidden.
|
||||
case toastShown(Toast?)
|
||||
}
|
||||
13
AuthenticatorShared/UI/Vault/Items/Items/ItemsEffect.swift
Normal file
@ -0,0 +1,13 @@
|
||||
// MARK: - ItemsEffect
|
||||
|
||||
/// Effects that can be handled by a `ItemsProcessor`.
|
||||
enum ItemsEffect: Equatable {
|
||||
/// The vault group view appeared on screen.
|
||||
case appeared
|
||||
|
||||
/// The refresh control was triggered.
|
||||
case refresh
|
||||
|
||||
/// Stream the vault list for the user.
|
||||
case streamVaultList
|
||||
}
|
||||
184
AuthenticatorShared/UI/Vault/Items/Items/ItemsProcessor.swift
Normal file
@ -0,0 +1,184 @@
|
||||
import BitwardenSdk
|
||||
import Foundation
|
||||
|
||||
// MARK: - ItemsProcessor
|
||||
|
||||
/// A `Processor` that can process `ItemsAction`s and `ItemsEffect`s.
|
||||
final class ItemsProcessor: StateProcessor<ItemsState, ItemsAction, ItemsEffect> {
|
||||
// MARK: Types
|
||||
|
||||
typealias Services = HasErrorReporter
|
||||
& HasItemRepository
|
||||
& HasTimeProvider
|
||||
|
||||
// MARK: Private Properties
|
||||
|
||||
/// The `Coordinator` for this processor.
|
||||
private var coordinator: any Coordinator<ItemsRoute, ItemsEvent>
|
||||
|
||||
/// The services for this processor.
|
||||
private var services: Services
|
||||
|
||||
/// An object to manage TOTP code expirations and batch refresh calls for the group.
|
||||
private var groupTotpExpirationManager: TOTPExpirationManager?
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Creates a new `ItemsProcessor`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - coordinator: The `Coordinator` for this processor.
|
||||
/// - services: The services for this processor.
|
||||
/// - state: The initial state of this processor.
|
||||
///
|
||||
init(
|
||||
coordinator: any Coordinator<ItemsRoute, ItemsEvent>,
|
||||
services: Services,
|
||||
state: ItemsState
|
||||
) {
|
||||
self.coordinator = coordinator
|
||||
self.services = services
|
||||
|
||||
super.init(state: state)
|
||||
groupTotpExpirationManager = .init(
|
||||
timeProvider: services.timeProvider,
|
||||
onExpiration: { [weak self] expiredItems in
|
||||
guard let self else { return }
|
||||
Task {
|
||||
await self.refreshTOTPCodes(for: expiredItems)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
override func perform(_ effect: ItemsEffect) async {
|
||||
switch effect {
|
||||
case .appeared:
|
||||
await streamItemList()
|
||||
case .refresh:
|
||||
await streamItemList()
|
||||
case .streamVaultList:
|
||||
await streamItemList()
|
||||
}
|
||||
}
|
||||
|
||||
override func receive(_ action: ItemsAction) {}
|
||||
|
||||
// MARK: Private Methods
|
||||
|
||||
/// Refreshes the vault group's TOTP Codes.
|
||||
///
|
||||
private func refreshTOTPCodes(for items: [VaultListItem]) async {
|
||||
guard case let .data(currentSections) = state.loadingState else { return }
|
||||
do {
|
||||
let refreshedItems = try await services.itemRepository.refreshTOTPCodes(for: items)
|
||||
groupTotpExpirationManager?.configureTOTPRefreshScheduling(for: refreshedItems)
|
||||
state.loadingState = .data(refreshedItems)
|
||||
} catch {
|
||||
services.errorReporter.log(error: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream the items list.
|
||||
private func streamItemList() async {
|
||||
do {
|
||||
for try await vaultList in try await services.itemRepository.vaultListPublisher() {
|
||||
groupTotpExpirationManager?.configureTOTPRefreshScheduling(for: vaultList)
|
||||
state.loadingState = .data(vaultList)
|
||||
}
|
||||
} catch {
|
||||
services.errorReporter.log(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A class to manage TOTP code expirations for the ItemsProcessor and batch refresh calls.
|
||||
///
|
||||
private class TOTPExpirationManager {
|
||||
// MARK: Properties
|
||||
|
||||
/// A closure to call on expiration
|
||||
///
|
||||
var onExpiration: (([VaultListItem]) -> Void)?
|
||||
|
||||
// MARK: Private Properties
|
||||
|
||||
/// All items managed by the object, grouped by TOTP period.
|
||||
///
|
||||
private(set) var itemsByInterval = [UInt32: [VaultListItem]]()
|
||||
|
||||
/// A model to provide time to calculate the countdown.
|
||||
///
|
||||
private var timeProvider: any TimeProvider
|
||||
|
||||
/// A timer that triggers `checkForExpirations` to manage code expirations.
|
||||
///
|
||||
private var updateTimer: Timer?
|
||||
|
||||
/// Initializes a new countdown timer
|
||||
///
|
||||
/// - Parameters
|
||||
/// - timeProvider: A protocol providing the present time as a `Date`.
|
||||
/// Used to calculate time remaining for a present TOTP code.
|
||||
/// - onExpiration: A closure to call on code expiration for a list of vault items.
|
||||
///
|
||||
init(
|
||||
timeProvider: any TimeProvider,
|
||||
onExpiration: (([VaultListItem]) -> Void)?
|
||||
) {
|
||||
self.timeProvider = timeProvider
|
||||
self.onExpiration = onExpiration
|
||||
updateTimer = Timer.scheduledTimer(
|
||||
withTimeInterval: 0.25,
|
||||
repeats: true,
|
||||
block: { _ in
|
||||
self.checkForExpirations()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Clear out any timers tracking TOTP code expiration
|
||||
deinit {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Configures TOTP code refresh scheduling
|
||||
///
|
||||
/// - Parameter items: The vault list items that may require code expiration tracking.
|
||||
///
|
||||
func configureTOTPRefreshScheduling(for items: [VaultListItem]) {
|
||||
var newItemsByInterval = [UInt32: [VaultListItem]]()
|
||||
items.forEach { item in
|
||||
guard case let .totp(_, model) = item.itemType else { return }
|
||||
newItemsByInterval[model.totpCode.period, default: []].append(item)
|
||||
}
|
||||
itemsByInterval = newItemsByInterval
|
||||
}
|
||||
|
||||
/// A function to remove any outstanding timers
|
||||
///
|
||||
func cleanup() {
|
||||
updateTimer?.invalidate()
|
||||
updateTimer = nil
|
||||
}
|
||||
|
||||
private func checkForExpirations() {
|
||||
var expired = [VaultListItem]()
|
||||
var notExpired = [UInt32: [VaultListItem]]()
|
||||
itemsByInterval.forEach { period, items in
|
||||
let sortedItems: [Bool: [VaultListItem]] = TOTPExpirationCalculator.listItemsByExpiration(
|
||||
items,
|
||||
timeProvider: timeProvider
|
||||
)
|
||||
expired.append(contentsOf: sortedItems[true] ?? [])
|
||||
notExpired[period] = sortedItems[false]
|
||||
}
|
||||
itemsByInterval = notExpired
|
||||
guard !expired.isEmpty else { return }
|
||||
onExpiration?(expired)
|
||||
}
|
||||
}
|
||||
45
AuthenticatorShared/UI/Vault/Items/Items/ItemsState.swift
Normal file
@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - ItemsState
|
||||
|
||||
/// The state of a `ItemsView`.
|
||||
struct ItemsState: Equatable {
|
||||
// MARK: Properties
|
||||
|
||||
/// Whether there is data for the vault group.
|
||||
var emptyData: Bool {
|
||||
loadingState.data.isEmptyOrNil
|
||||
}
|
||||
|
||||
/// The base url used to fetch icons.
|
||||
var iconBaseURL: URL?
|
||||
|
||||
/// The current loading state.
|
||||
var loadingState: LoadingState<[VaultListItem]> = .loading(nil)
|
||||
|
||||
/// The string to use in the empty view.
|
||||
var noItemsString: String {
|
||||
Localizations.noItems
|
||||
}
|
||||
|
||||
/// Whether to show the add item button in the view.
|
||||
var showAddItemButton: Bool {
|
||||
// Don't show if there is data.
|
||||
guard emptyData else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
/// Whether to show the add item button in the toolbar.
|
||||
var showAddToolbarItem: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether to show the special web icons.
|
||||
var showWebIcons = true
|
||||
|
||||
/// A toast message to show in the view.
|
||||
var toast: Toast?
|
||||
|
||||
/// The url to open in the device's web browser.
|
||||
var url: URL?
|
||||
}
|
||||
213
AuthenticatorShared/UI/Vault/Items/Items/ItemsView.swift
Normal file
@ -0,0 +1,213 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - ItemsView
|
||||
|
||||
/// A view that displays the items in a single vault group.
|
||||
struct ItemsView: View {
|
||||
// MARK: Properties
|
||||
|
||||
/// An object used to open urls from this view.
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
/// The `Store` for this view.
|
||||
@ObservedObject var store: Store<ItemsState, ItemsAction, ItemsEffect>
|
||||
|
||||
/// The `TimeProvider` used to calculate TOTP expiration.
|
||||
var timeProvider: any TimeProvider
|
||||
|
||||
// MARK: View
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.navigationTitle("Item List")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.background(Asset.Colors.backgroundSecondary.swiftUIColor.ignoresSafeArea())
|
||||
.toolbar {
|
||||
addToolbarItem(hidden: !store.state.showAddToolbarItem) {
|
||||
store.send(.addItemPressed)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await store.perform(.appeared)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
LoadingView(state: store.state.loadingState) { items in
|
||||
if items.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
groupView(with: items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A view that displays an empty state for this vault group.
|
||||
@ViewBuilder private var emptyView: some View {
|
||||
GeometryReader { reader in
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
Text(store.state.noItemsString)
|
||||
.multilineTextAlignment(.center)
|
||||
.styleGuide(.callout)
|
||||
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
|
||||
|
||||
if store.state.showAddItemButton {
|
||||
Button(Localizations.addAnItem) {
|
||||
store.send(.addItemPressed)
|
||||
}
|
||||
.buttonStyle(.tertiary())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(minWidth: reader.size.width, minHeight: reader.size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Private Methods
|
||||
|
||||
/// A view that displays a list of the sections within this vault group.
|
||||
///
|
||||
@ViewBuilder
|
||||
private func groupView(with items: [VaultListItem]) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 7) {
|
||||
ForEach(items) { item in
|
||||
Button {
|
||||
store.send(.itemPressed(item))
|
||||
} label: {
|
||||
vaultItemRow(
|
||||
for: item,
|
||||
isLastInSection: true
|
||||
)
|
||||
}
|
||||
}
|
||||
.background(Asset.Colors.backgroundPrimary.swiftUIColor)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a row in the list for the provided item.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - item: The `VaultListItem` to use when creating the view.
|
||||
/// - isLastInSection: A flag indicating if this item is the last one in the section.
|
||||
///
|
||||
@ViewBuilder
|
||||
private func vaultItemRow(for item: VaultListItem, isLastInSection: Bool = false) -> some View {
|
||||
ItemListItemRowView(
|
||||
store: store.child(
|
||||
state: { state in
|
||||
ItemListItemRowState(
|
||||
iconBaseURL: state.iconBaseURL,
|
||||
item: item,
|
||||
hasDivider: !isLastInSection,
|
||||
showWebIcons: state.showWebIcons
|
||||
)
|
||||
},
|
||||
mapAction: { action in
|
||||
switch action {
|
||||
case let .copyTOTPCode(code):
|
||||
return .copyTOTPCode(code)
|
||||
case .morePressed:
|
||||
return .morePressed(item)
|
||||
}
|
||||
},
|
||||
mapEffect: { .appeared }
|
||||
),
|
||||
timeProvider: timeProvider
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Previews
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Loading") {
|
||||
NavigationView {
|
||||
ItemsView(
|
||||
store: Store(
|
||||
processor: StateProcessor(
|
||||
state: ItemsState(
|
||||
loadingState: .loading(nil)
|
||||
)
|
||||
)
|
||||
),
|
||||
timeProvider: PreviewTimeProvider()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Tokens") {
|
||||
NavigationView {
|
||||
ItemsView(
|
||||
store: Store(
|
||||
processor: StateProcessor(
|
||||
state: ItemsState(
|
||||
loadingState: .data(
|
||||
[
|
||||
.init(
|
||||
id: "One",
|
||||
itemType: .totp(
|
||||
name: "One",
|
||||
totpModel: VaultListTOTP(
|
||||
id: UUID().uuidString,
|
||||
loginView: .init(
|
||||
username: "email@example.com",
|
||||
password: nil,
|
||||
passwordRevisionDate: nil,
|
||||
uris: nil,
|
||||
totp: "asdf",
|
||||
autofillOnPageLoad: nil,
|
||||
fido2Credentials: nil
|
||||
),
|
||||
totpCode: TOTPCodeModel(
|
||||
code: "123456",
|
||||
codeGenerationDate: Date(),
|
||||
period: 30
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
.init(
|
||||
id: "Two",
|
||||
itemType: .totp(
|
||||
name: "Two",
|
||||
totpModel: VaultListTOTP(
|
||||
id: UUID().uuidString,
|
||||
loginView: .init(
|
||||
username: "email@example.com",
|
||||
password: nil,
|
||||
passwordRevisionDate: nil,
|
||||
uris: nil,
|
||||
totp: "asdf",
|
||||
autofillOnPageLoad: nil,
|
||||
fido2Credentials: nil
|
||||
),
|
||||
totpCode: TOTPCodeModel(
|
||||
code: "123456",
|
||||
codeGenerationDate: Date(),
|
||||
period: 30
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
timeProvider: PreviewTimeProvider()
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||