Skip to content

Commit

Permalink
[BITAU-148] [BITAU-154] Add Sync Service to the PM app (#977)
Browse files Browse the repository at this point in the history
  • Loading branch information
brant-livefront authored Oct 1, 2024
1 parent 59c7e1a commit bc5a4b3
Show file tree
Hide file tree
Showing 18 changed files with 1,115 additions and 9 deletions.
43 changes: 40 additions & 3 deletions AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Combine
import Foundation

// MARK: - AuthenticatorBridgeItemService
Expand Down Expand Up @@ -27,6 +28,13 @@ public protocol AuthenticatorBridgeItemService {
func insertItems(_ items: [AuthenticatorBridgeItemDataView],
forUserId userId: String) async throws

/// Returns `true` if sync has been enabled for one or more accounts in the Bitwarden PM app, `false`
/// if there are no accounts with sync currently turned on.
///
/// - Returns: `true` if there is one or more accounts with sync turned on; `false` otherwise.
///
func isSyncOn() async -> Bool

/// Deletes all existing items for a given user and inserts new items for the list of items provided.
///
/// - Parameters:
Expand All @@ -35,6 +43,13 @@ public protocol AuthenticatorBridgeItemService {
///
func replaceAllItems(with items: [AuthenticatorBridgeItemDataView],
forUserId userId: String) async throws

/// A Publisher that returns all of the items in the shared store.
///
/// - Returns: Publisher that will publish the initial list of all items and any future data changes.
///
func sharedItemsPublisher() async throws ->
AnyPublisher<[AuthenticatorBridgeItemDataView], any Error>
}

/// A concrete implementation of the `AuthenticatorBridgeItemService` protocol.
Expand All @@ -60,9 +75,9 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi
/// - dataStore: The CoreData store for working with shared data
/// - sharedKeychainRepository: The keychain repository for working with the shared key.
///
init(cryptoService: SharedCryptographyService,
dataStore: AuthenticatorBridgeDataStore,
sharedKeychainRepository: SharedKeychainRepository) {
public init(cryptoService: SharedCryptographyService,
dataStore: AuthenticatorBridgeDataStore,
sharedKeychainRepository: SharedKeychainRepository) {
self.cryptoService = cryptoService
self.dataStore = dataStore
self.sharedKeychainRepository = sharedKeychainRepository
Expand Down Expand Up @@ -91,6 +106,11 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi
return try await cryptoService.decryptAuthenticatorItems(encryptedItems)
}

public func isSyncOn() async -> Bool {
let key = try? await sharedKeychainRepository.getAuthenticatorKey()
return key != nil
}

/// Inserts the list of items into the store for the given userId.
///
/// - Parameters:
Expand Down Expand Up @@ -124,4 +144,21 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi
insertRequest: insertRequest
)
}

public func sharedItemsPublisher() async throws ->
AnyPublisher<[AuthenticatorBridgeItemDataView], any Error> {
let fetchRequest = AuthenticatorBridgeItemData.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \AuthenticatorBridgeItemData.userId, ascending: true)]
return FetchedResultsPublisher(
context: dataStore.persistentContainer.viewContext,
request: fetchRequest
)
.tryMap { dataItems in
dataItems.compactMap(\.model)
}
.asyncTryMap { itemModel in
try await self.cryptoService.decryptAuthenticatorItems(itemModel)
}
.eraseToAnyPublisher()
}
}
145 changes: 145 additions & 0 deletions AuthenticatorBridgeKit/FetchedResultsPublisher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import Combine
import CoreData

// MARK: - FetchedResultsPublisher

/// A Combine publisher that publishes the initial result set and any future data changes for a
/// Core Data fetch request.
///
/// Adapted from https://gist.github.com/darrarski/28d2f5a28ef2c5669d199069c30d3d52
///
class FetchedResultsPublisher<ResultType>: Publisher where ResultType: NSFetchRequestResult {
// MARK: Types

typealias Output = [ResultType]

typealias Failure = Error

// MARK: Properties

/// The managed object context that the fetch request is executed against.
let context: NSManagedObjectContext

/// The fetch request used to get the objects.
let request: NSFetchRequest<ResultType>

// MARK: Initialization

/// Initialize a `FetchedResultsPublisher`.
///
/// - Parameters:
/// - context: The managed object context that the fetch request is executed against.
/// - request: The fetch request used to get the objects.
///
init(context: NSManagedObjectContext, request: NSFetchRequest<ResultType>) {
self.context = context
self.request = request
}

// MARK: Publisher

func receive<S>(subscriber: S) where S: Subscriber, S.Failure == Failure, S.Input == Output {
subscriber.receive(subscription: FetchedResultsSubscription(
context: context,
request: request,
subscriber: subscriber
))
}
}

// MARK: - FetchedResultsSubscription

/// A `Subscription` to a `FetchedResultsPublisher` which fetches results from Core Data via a
/// `NSFetchedResultsController` and notifies the subscriber of any changes to the data.
///
private final class FetchedResultsSubscription<SubscriberType, ResultType>: NSObject, Subscription,
NSFetchedResultsControllerDelegate
where SubscriberType: Subscriber,
SubscriberType.Input == [ResultType],
SubscriberType.Failure == Error,
ResultType: NSFetchRequestResult {
// MARK: Properties

/// The fetched results controller to manage the results of a Core Data fetch request.
private var controller: NSFetchedResultsController<ResultType>?

/// The current demand from the subscriber.
private var demand: Subscribers.Demand = .none

/// Whether the subscription has changes to send to the subscriber.
private var hasChangesToSend = false

/// The subscriber to the subscription that is notified of the fetched results.
private var subscriber: SubscriberType?

// MARK: Initialization

/// Initialize a `FetchedResultsSubscription`.
///
/// - Parameters:
/// - context: The managed object context that the fetch request is executed against.
/// - request: The fetch request used to get the objects.
/// - subscriber: The subscriber to the subscription that is notified of the fetched results.
///
init(
context: NSManagedObjectContext,
request: NSFetchRequest<ResultType>,
subscriber: SubscriberType
) {
controller = NSFetchedResultsController(
fetchRequest: request,
managedObjectContext: context,
sectionNameKeyPath: nil,
cacheName: nil
)
self.subscriber = subscriber

super.init()

controller?.delegate = self

do {
try controller?.performFetch()
if controller?.fetchedObjects != nil {
hasChangesToSend = true
fulfillDemand()
}
} catch {
subscriber.receive(completion: .failure(error))
}
}

// MARK: Subscription

func request(_ demand: Subscribers.Demand) {
self.demand += demand
fulfillDemand()
}

// MARK: Cancellable

func cancel() {
controller = nil
subscriber = nil
}

// MARK: NSFetchedResultsControllerDelegate

func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
hasChangesToSend = true
fulfillDemand()
}

// MARK: Private

private func fulfillDemand() {
guard demand > 0, hasChangesToSend,
let subscriber,
let fetchedObjects = controller?.fetchedObjects
else { return }

hasChangesToSend = false
demand -= 1
demand += subscriber.receive(fetchedObjects)
}
}
21 changes: 21 additions & 0 deletions AuthenticatorBridgeKit/Future+Extensions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Combine

extension Future {
/// Initialize a `Future` with an async throwing closure.
///
/// - Parameter attemptToFulfill: A closure that the publisher invokes when it emits a value or
/// an error occurs.
///
convenience init(_ attemptToFulfill: @Sendable @escaping () async throws -> Output) where Failure == Error {
self.init { promise in
Task {
do {
let result = try await attemptToFulfill()
promise(.success(result))
} catch {
promise(.failure(error))
}
}
}
}
}
59 changes: 59 additions & 0 deletions AuthenticatorBridgeKit/Publisher+Async.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Combine

extension Publisher {
/// Maps the output of a publisher to a different type, discarding any `nil` values.
///
/// - Parameters:
/// - maxPublishers: The maximum number of concurrent publisher subscriptions.
/// - transform: The transform to apply to each output.
/// - Returns: A publisher containing any non-`nil` mapped values.
///
func asyncCompactMap<T>(
maxPublishers: Subscribers.Demand = .max(1),
_ transform: @escaping (Output) async -> T?
) -> Publishers.CompactMap<Publishers.FlatMap<Future<T?, Never>, Self>, T> {
asyncMap(maxPublishers: maxPublishers, transform)
.compactMap { $0 }
}

/// Maps the output of a publisher to a different type.
///
/// - Parameters:
/// - maxPublishers: The maximum number of concurrent publisher subscriptions.
/// - transform: The transform to apply to each output.
/// - Returns: A publisher containing the mapped values.
///
func asyncMap<T>(
maxPublishers: Subscribers.Demand = .max(1),
_ transform: @escaping (Output) async -> T
) -> Publishers.FlatMap<Future<T, Never>, Self> {
flatMap(maxPublishers: maxPublishers) { value in
Future { promise in
Task {
let output = await transform(value)
promise(.success(output))
}
}
}
}
}

extension Publisher where Failure == Error {
/// Maps the output of a publisher to a different type which could throw an error.
///
/// - Parameters:
/// - maxPublishers: The maximum number of concurrent publisher subscriptions.
/// - transform: The transform to apply to each output.
/// - Returns: A publisher containing the mapped values.
///
func asyncTryMap<T>(
maxPublishers: Subscribers.Demand = .max(1),
_ transform: @escaping (Output) async throws -> T
) -> Publishers.FlatMap<Future<T, Failure>, Self> {
flatMap(maxPublishers: maxPublishers) { value in
Future {
try await transform(value)
}
}
}
}
2 changes: 1 addition & 1 deletion AuthenticatorBridgeKit/SharedCryptographyService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public protocol SharedCryptographyService: AnyObject {
_ items: [AuthenticatorBridgeItemDataModel]
) async throws -> [AuthenticatorBridgeItemDataView]

/// Takes an array of `AuthenticatorBridgeItemDataModel` with decrypted data and
/// Takes an array of `AuthenticatorBridgeItemDataView` with decrypted data and
/// returns the list with each member encrypted.
///
/// - Parameter items: The decrypted array of items to be encrypted
Expand Down
2 changes: 1 addition & 1 deletion AuthenticatorBridgeKit/SharedKeychainRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public protocol SharedKeychainRepository: AnyObject {

// MARK: - DefaultKeychainRepository

/// A concreate implementation of the `SharedKeychainRepository` protocol.
/// A concrete implementation of the `SharedKeychainRepository` protocol.
///
public class DefaultSharedKeychainRepository: SharedKeychainRepository {
// MARK: Properties
Expand Down
Loading

0 comments on commit bc5a4b3

Please sign in to comment.