Skip to content

Commit

Permalink
Add state and init observation to manager (#48)
Browse files Browse the repository at this point in the history
* Add state and init observation to manager

* Add a comment to RootViewModel.manager
  • Loading branch information
dcaunt authored Aug 5, 2024
1 parent b3138d3 commit b300505
Show file tree
Hide file tree
Showing 6 changed files with 262 additions and 31 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ class RootViewModel: ObservableObject {

private let apiClient = AppUID2Client()

private var cancellables = Set<AnyCancellable>()

init() {
/// `UID2Settings` must be configured prior to accessing the `UID2Manager` instance.
/// Configuring them here makes it less likely that an access occurs before configuration.
private let manager: UID2Manager = {
UID2Settings.shared.isLoggingEnabled = true
// Only the development app should use the integration environment.
// If you have copied the dev app for testing, you probably want to use the default
Expand All @@ -34,18 +34,15 @@ class RootViewModel: ObservableObject {
UID2Settings.shared.environment = .custom(url: URL(string: "https://operator-integ.uidapi.com")!)
}

return UID2Manager.shared
}()

init() {
Task {
await UID2Manager.shared.$identity
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] uid2Identity in
self?.uid2Identity = uid2Identity
}).store(in: &cancellables)

await UID2Manager.shared.$identityStatus
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] identityStatus in
self?.identityStatus = identityStatus
}).store(in: &cancellables)
for await state in await manager.stateValues() {
self.uid2Identity = state?.identity
self.identityStatus = state?.identityStatus
}
}
}

Expand Down Expand Up @@ -134,7 +131,7 @@ class RootViewModel: ObservableObject {

Task<Void, Never> {
do {
try await UID2Manager.shared.generateIdentity(
try await manager.generateIdentity(
identity,
subscriptionID: subscriptionID,
serverPublicKey: serverPublicKeyString,
Expand All @@ -152,7 +149,7 @@ class RootViewModel: ObservableObject {
guard let identity = try await apiClient.generateIdentity(requestString: identity, requestType: requestType) else {
return
}
await UID2Manager.shared.setIdentity(identity)
await manager.setIdentity(identity)
} catch {
self.error = error
}
Expand All @@ -161,13 +158,13 @@ class RootViewModel: ObservableObject {

func reset() {
Task {
await UID2Manager.shared.resetIdentity()
await manager.resetIdentity()
}
}

func refresh() {
Task {
await UID2Manager.shared.refreshIdentity()
await manager.refreshIdentity()
}
}
}
32 changes: 32 additions & 0 deletions Sources/UID2/Internal/Broadcaster.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation

/// Send a value to multiple observers
actor Broadcaster<Element: Sendable> {
typealias Identifier = UUID
private var continuations: [Identifier: AsyncStream<Element>.Continuation] = [:]

func values() -> AsyncStream<Element> {
.init { continuation in
let id = Identifier()
continuations[id] = continuation

continuation.onTermination = { _ in
Task { [weak self] in
await self?.remove(id)
}
}
}
}

func remove(_ id: Identifier) {
continuations[id] = nil
}

func send(_ value: Element) {
continuations.values.forEach { $0.yield(value) }
}

deinit {
continuations.values.forEach { $0.finish() }
}
}
29 changes: 29 additions & 0 deletions Sources/UID2/Internal/Queue.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Foundation

/// When bridging from a sync to async context using multiple `Task`s, order of execution is not guaranteed.
/// Using an `AsyncStream` we can bridge enqueued work to an async context within a single `Task`.
/// https://forums.swift.org/t/a-pitfall-when-using-didset-and-task-together-order-cant-be-guaranteed/71311/6
final class Queue {
typealias Operation = @Sendable () async -> Void
private let continuation: AsyncStream<Operation>.Continuation
private let task: Task<Void, Never>

init() {
let (stream, continuation) = AsyncStream.makeStream(of: Operation.self)

self.continuation = continuation
self.task = Task {
for await operation in stream {
await operation()
}
}
}

func enqueue(_ operation: @escaping Operation) {
continuation.yield(operation)
}

deinit {
task.cancel()
}
}
6 changes: 4 additions & 2 deletions Sources/UID2/UID2Manager.State.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ extension UID2Manager {
}

extension UID2Manager.State {
var identityStatus: IdentityStatus {
/// A 'case path' returning the current `IdentityStatus`.
public var identityStatus: IdentityStatus {
switch self {
case .optout:
return .optOut
Expand All @@ -33,7 +34,8 @@ extension UID2Manager.State {
}
}

var identity: UID2Identity? {
/// A 'case path' returning the current `UID2Identity`.
public var identity: UID2Identity? {
switch self {
case .optout,
.refreshExpired,
Expand Down
62 changes: 53 additions & 9 deletions Sources/UID2/UID2Manager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,28 @@ import Combine
import Foundation
import OSLog

// swiftlint:disable:next type_body_length
public final actor UID2Manager {

private enum InitializationState {
case pending
case complete
}

public typealias OnInitialized = @Sendable () async -> Void
private var initializationListeners: [OnInitialized] = []
private var initializationState = InitializationState.pending

public func addInitializationListener(_ listener: @escaping OnInitialized) {
guard initializationState != .complete else {
Task {
await listener()
}
return
}

initializationListeners.append(listener)
}

/// Singleton access point for UID2Manager
public static let shared = UID2Manager()

Expand All @@ -26,25 +46,36 @@ public final actor UID2Manager {

// MARK: - Publishers

private let broadcaster = Broadcaster<State?>()
private let queue = Queue()

/// Source of truth for both `identity` and `identityStatus` values.
public private(set) var state: State? {
didSet {
guard let state else {
if let state {
identity = state.identity
identityStatus = state.identityStatus
} else {
identity = nil
identityStatus = .noIdentity
return
}
identity = state.identity
identityStatus = state.identityStatus

queue.enqueue {
await self.broadcaster.send(self.state)
}
}
}

/// Current Identity data for the user
public func stateValues() async -> AsyncStream<State?> {
await broadcaster.values()
}

/// Current Identity data for the user. Derived from `state.identity`.
@Published public private(set) var identity: UID2Identity?
/// Public Identity Status Notifications

/// Public Identity Status Notifications. Derived from `state.identityStatus`.
@Published public private(set) var identityStatus: IdentityStatus = .noIdentity

// MARK: - Core Components

/// UID2 SDK Version
Expand Down Expand Up @@ -116,6 +147,7 @@ public final actor UID2Manager {
// Use case for app manually stopped and re-opened
Task {
await loadStateFromDisk()
await notifyInitializationListeners()
}
}

Expand Down Expand Up @@ -222,6 +254,18 @@ public final actor UID2Manager {
)
}

private func notifyInitializationListeners() async {
await withTaskGroup(of: Void.self) { taskGroup in
initializationListeners.forEach { listener in
taskGroup.addTask {
await listener()
}
}
}
initializationState = .complete
initializationListeners = []
}

private func hasExpired(expiry: Int64) -> Bool {
return expiry <= dateGenerator.now.millisecondsSince1970
}
Expand Down
Loading

0 comments on commit b300505

Please sign in to comment.