diff --git a/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift b/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift index da49bcd6..8a79450f 100644 --- a/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift +++ b/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift @@ -14,6 +14,8 @@ class Status: ObservableObject { @main struct ConfidenceDemoApp: App { + @StateObject private var lifecycleObserver = ConfidenceAppLifecycleProducer() + var body: some Scene { WindowGroup { let secret = ProcessInfo.processInfo.environment["CLIENT_SECRET"] ?? "" @@ -27,6 +29,7 @@ struct ConfidenceDemoApp: App { ContentView(confidence: confidence, status: status) .task { do { + confidence.track(producer: lifecycleObserver) try await self.setup(confidence: confidence) status.state = .ready } catch { diff --git a/Sources/Confidence/BufferedPassthroughSubject.swift b/Sources/Confidence/BufferedPassthroughSubject.swift new file mode 100644 index 00000000..556f4aa7 --- /dev/null +++ b/Sources/Confidence/BufferedPassthroughSubject.swift @@ -0,0 +1,30 @@ +import Foundation +import Combine + +class BufferedPassthrough { + private let subject = PassthroughSubject() + private var buffer: [T] = [] + private var isListening = false + private let queue = DispatchQueue(label: "com.confidence.passthrough_serial") + + func send(_ value: T) { + queue.sync { + if isListening { + subject.send(value) + } else { + buffer.append(value) + } + } + } + + func publisher() -> AnyPublisher { + return queue.sync { + isListening = true + let bufferedPublisher = buffer.publisher + buffer.removeAll() + return bufferedPublisher + .append(subject) + .eraseToAnyPublisher() + } + } +} diff --git a/Sources/Confidence/Confidence.swift b/Sources/Confidence/Confidence.swift index 18e2f206..bb6c1ea4 100644 --- a/Sources/Confidence/Confidence.swift +++ b/Sources/Confidence/Confidence.swift @@ -121,6 +121,30 @@ public class Confidence: ConfidenceEventSender { ) } + public func track(producer: ConfidenceProducer) { + if let eventProducer = producer as? ConfidenceEventProducer { + eventProducer.produceEvents() + .sink { [weak self] event in + guard let self = self else { + return + } + self.track(eventName: event.name, message: event.message) + } + .store(in: &cancellables) + } + + if let contextProducer = producer as? ConfidenceContextProducer { + contextProducer.produceContexts() + .sink { [weak self] context in + guard let self = self else { + return + } + self.putContext(context: context) + } + .store(in: &cancellables) + } + } + private func withLock(callback: @escaping (Confidence) -> Void) { confidenceQueue.sync { [weak self] in guard let self = self else { diff --git a/Sources/Confidence/ConfidenceAppLifecycleProducer.swift b/Sources/Confidence/ConfidenceAppLifecycleProducer.swift new file mode 100644 index 00000000..872aa7ba --- /dev/null +++ b/Sources/Confidence/ConfidenceAppLifecycleProducer.swift @@ -0,0 +1,109 @@ +#if os(iOS) || os(tvOS) || os(visionOS) || targetEnvironment(macCatalyst) +import Foundation +import UIKit +import Combine + +public class ConfidenceAppLifecycleProducer: ConfidenceEventProducer, ConfidenceContextProducer, ObservableObject { + public var currentProducedContext: CurrentValueSubject = CurrentValueSubject([:]) + private var events: BufferedPassthrough = BufferedPassthrough() + private let queue = DispatchQueue(label: "com.confidence.lifecycle_producer") + private var appNotifications: [NSNotification.Name] = [ + UIApplication.didEnterBackgroundNotification, + UIApplication.willEnterForegroundNotification, + UIApplication.didBecomeActiveNotification + ] + + private static var versionNameKey = "CONFIDENCE_VERSION_NAME_KEY" + private static var buildNameKey = "CONFIDENCE_VERSIONN_KEY" + private let appLaunchedEventName = "app-launched" + + public init() { + for notification in appNotifications { + NotificationCenter.default.addObserver( + self, + selector: #selector(notificationResponse), + name: notification, + object: nil + ) + } + + let currentVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + let currentBuild: String = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" + let context: ConfidenceStruct = [ + "version": .init(string: currentVersion), + "build": .init(string: currentBuild) + ] + + self.currentProducedContext.send(context) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + public func produceEvents() -> AnyPublisher { + events.publisher() + } + + public func produceContexts() -> AnyPublisher { + currentProducedContext + .filter { context in !context.isEmpty } + .eraseToAnyPublisher() + } + + private func track(eventName: String) { + let previousBuild: String? = UserDefaults.standard.string(forKey: Self.buildNameKey) + let previousVersion: String? = UserDefaults.standard.string(forKey: Self.versionNameKey) + + let currentVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + let currentBuild: String = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" + + let message: ConfidenceStruct = [ + "version": .init(string: currentVersion), + "build": .init(string: currentBuild) + ] + + if eventName == self.appLaunchedEventName { + if previousBuild == nil && previousVersion == nil { + events.send(Event(name: "app-installed", message: message)) + } else if previousBuild != currentBuild || previousVersion != currentVersion { + events.send(Event(name: "app-updated", message: message)) + } + } + events.send(Event(name: eventName, message: message)) + + UserDefaults.standard.setValue(currentVersion, forKey: Self.versionNameKey) + UserDefaults.standard.setValue(currentBuild, forKey: Self.buildNameKey) + } + + private func updateContext(isForeground: Bool) { + withLock { [weak self] in + guard let self = self else { + return + } + var context = self.currentProducedContext.value + context.updateValue(.init(boolean: isForeground), forKey: "is_foreground") + self.currentProducedContext.send(context) + } + } + + private func withLock(callback: @escaping () -> Void) { + queue.sync { + callback() + } + } + + @objc func notificationResponse(notification: NSNotification) { + switch notification.name { + case UIApplication.didEnterBackgroundNotification: + updateContext(isForeground: false) + case UIApplication.willEnterForegroundNotification: + updateContext(isForeground: true) + case UIApplication.didBecomeActiveNotification: + track(eventName: appLaunchedEventName) + default: + break + } + } +} +#endif diff --git a/Sources/Confidence/ConfidenceEventSender.swift b/Sources/Confidence/ConfidenceEventSender.swift index 2bb1c5e1..34831692 100644 --- a/Sources/Confidence/ConfidenceEventSender.swift +++ b/Sources/Confidence/ConfidenceEventSender.swift @@ -9,4 +9,8 @@ public protocol ConfidenceEventSender: Contextual { according to the configured flushing logic */ func track(eventName: String, message: ConfidenceStruct) + /** + The ConfidenceProducer can be used to push context changes or event tracking + */ + func track(producer: ConfidenceProducer) } diff --git a/Sources/Confidence/ConfidenceProducer.swift b/Sources/Confidence/ConfidenceProducer.swift new file mode 100644 index 00000000..fc55e0f4 --- /dev/null +++ b/Sources/Confidence/ConfidenceProducer.swift @@ -0,0 +1,32 @@ +import Foundation +import Combine + +/** +ConfidenceContextProducer or ConfidenceEventProducer +*/ +public protocol ConfidenceProducer { +} + +public struct Event { + let name: String + let message: ConfidenceStruct + + public init(name: String, message: ConfidenceStruct = [:]) { + self.name = name + self.message = message + } +} + +/** +ConfidenceContextProducer implementer pushses context changes in a Publisher fashion +*/ +public protocol ConfidenceContextProducer: ConfidenceProducer { + func produceContexts() -> AnyPublisher +} + +/** +ConfidenceContextProducer implementer emit events in a Publisher fashion +*/ +public protocol ConfidenceEventProducer: ConfidenceProducer { + func produceEvents() -> AnyPublisher +} diff --git a/Sources/Confidence/ConfidenceScreenTracker.swift b/Sources/Confidence/ConfidenceScreenTracker.swift new file mode 100644 index 00000000..527ef01d --- /dev/null +++ b/Sources/Confidence/ConfidenceScreenTracker.swift @@ -0,0 +1,102 @@ +#if os(iOS) || os(tvOS) || os(visionOS) || targetEnvironment(macCatalyst) +import Foundation +import UIKit +import Combine + +public class ConfidenceScreenTracker: ConfidenceEventProducer { + private var events = BufferedPassthrough() + static let notificationName = Notification.Name(rawValue: "ConfidenceScreenTracker") + static let screenName = "screen_name" + static let messageKey = "message_json" + static let controllerKey = "controller" + + public init() { + swizzle( + forClass: UIViewController.self, + original: #selector(UIViewController.viewDidAppear(_:)), + new: #selector(UIViewController.confidence__viewDidAppear) + ) + + swizzle( + forClass: UIViewController.self, + original: #selector(UIViewController.viewDidDisappear(_:)), + new: #selector(UIViewController.confidence__viewDidDisappear) + ) + + NotificationCenter.default.addObserver( + forName: Self.notificationName, + object: nil, + queue: OperationQueue.main) { [weak self] notification in + let name = notification.userInfo?[Self.screenName] as? String + let messageJson = (notification.userInfo?[Self.messageKey] as? String)?.data(using: .utf8) + var message: ConfidenceStruct = [:] + if let data = messageJson { + let decoder = JSONDecoder() + do { + message = try decoder.decode(ConfidenceStruct.self, from: data) + } catch { + } + } + + guard let self = self else { + return + } + if let name = name { + self.events.send(Event(name: name, message: message)) + } + } + } + + public func produceEvents() -> AnyPublisher { + events.publisher() + } + + private func swizzle(forClass: AnyClass, original: Selector, new: Selector) { + guard let originalMethod = class_getInstanceMethod(forClass, original) else { return } + guard let swizzledMethod = class_getInstanceMethod(forClass, new) else { return } + method_exchangeImplementations(originalMethod, swizzledMethod) + } +} + +public protocol TrackableComponent { + func trackName() -> String +} + +public protocol TrackableComponentWithMessage: TrackableComponent { + func trackMessage() -> ConfidenceStruct +} + +extension UIViewController { + private func sendNotification(event: String) { + var className = String(describing: type(of: self)) + .replacingOccurrences(of: "ViewController", with: "") + var message: [String: String] = [ConfidenceScreenTracker.screenName: className] + + if let trackable = self as? TrackableComponent { + className = trackable.trackName() + if let trackableWithMessage = self as? TrackableComponentWithMessage { + let encoder = JSONEncoder() + do { + let data = try encoder.encode(trackableWithMessage.trackMessage()) + let messageString = String(data: data, encoding: .utf8) + if let json = messageString { + message.updateValue(json, forKey: ConfidenceScreenTracker.messageKey) + } + } catch { + } + } + } + + NotificationCenter.default.post( + name: ConfidenceScreenTracker.notificationName, + object: self, + userInfo: message + ) + } + @objc internal func confidence__viewDidAppear(animated: Bool) { + } + + @objc internal func confidence__viewDidDisappear(animated: Bool) { + } +} +#endif diff --git a/Sources/Confidence/EventSenderEngine.swift b/Sources/Confidence/EventSenderEngine.swift index 11ccac5d..46bfec33 100644 --- a/Sources/Confidence/EventSenderEngine.swift +++ b/Sources/Confidence/EventSenderEngine.swift @@ -22,6 +22,7 @@ final class EventSenderEngineImpl: EventSenderEngine { private let uploader: ConfidenceClient private let clientSecret: String private let payloadMerger: PayloadMerger = PayloadMergerImpl() + private let semaphore = DispatchSemaphore(value: 1) init( clientSecret: String, @@ -52,10 +53,21 @@ final class EventSenderEngineImpl: EventSenderEngine { .store(in: &cancellables) uploadReqChannel.sink { [weak self] _ in + guard let self = self else { return } + await self.upload() + } + .store(in: &cancellables) + } + + func upload() async { + await withSemaphore { [weak self] in + guard let self = self else { return } do { - guard let self = self else { return } try self.storage.startNewBatch() let ids = try storage.batchReadyIds() + if ids.isEmpty { + return + } for id in ids { let events: [NetworkEvent] = try self.storage.eventsFrom(id: id) .compactMap { event in @@ -64,7 +76,13 @@ final class EventSenderEngineImpl: EventSenderEngine { payload: NetworkStruct(fields: TypeMapper.convert(structure: event.payload).fields), eventTime: Date.backport.toISOString(date: event.eventTime)) } - let shouldCleanup = try await self.uploader.upload(events: events) + var shouldCleanup = false + if events.isEmpty { + shouldCleanup = true + } else { + shouldCleanup = try await self.uploader.upload(events: events) + } + if shouldCleanup { try storage.remove(id: id) } @@ -72,7 +90,12 @@ final class EventSenderEngineImpl: EventSenderEngine { } catch { } } - .store(in: &cancellables) + } + + func withSemaphore(callback: @escaping () async -> Void) async { + semaphore.wait() + await callback() + semaphore.signal() } func emit(eventName: String, message: ConfidenceStruct, context: ConfidenceStruct) { diff --git a/Sources/Confidence/EventStorage.swift b/Sources/Confidence/EventStorage.swift index a5c83b4f..41d2825d 100644 --- a/Sources/Confidence/EventStorage.swift +++ b/Sources/Confidence/EventStorage.swift @@ -95,7 +95,9 @@ internal class EventStorageImpl: EventStorage { func remove(id: String) throws { try storageQueue.sync { let fileUrl = folderURL.appendingPathComponent(id) - try FileManager.default.removeItem(at: fileUrl) + if FileManager.default.fileExists(atPath: fileUrl.path) { + try FileManager.default.removeItem(at: fileUrl) + } } } @@ -114,7 +116,7 @@ internal class EventStorageImpl: EventStorage { self.currentFileHandle = try FileHandle(forWritingTo: currentFile) } else { // Create a brand new file - let fileUrl = folderURL.appendingPathComponent(String(Date().timeIntervalSince1970)) + let fileUrl = folderURL.appendingPathComponent(String(UUID().uuidString)) FileManager.default.createFile(atPath: fileUrl.path, contents: nil) self.currentFileUrl = fileUrl self.currentFileHandle = try FileHandle(forWritingTo: fileUrl)