From 13159437ed602ffb781d2f27d3da846df3cc3b0c Mon Sep 17 00:00:00 2001 From: Eli Gutovsky Date: Thu, 14 Dec 2023 15:17:00 +0200 Subject: [PATCH 1/3] refactor: persistent container --- ...treamPersistentContainerConfigurator.swift | 23 ++++++++ .../CoreData/PersistentContainer.swift | 59 +++++++++++++++---- .../PersistentContainerConfigurator.swift | 13 ++++ .../Classes/Factories/ComponentFactory.swift | 4 +- .../Components/OptiTrack/OptitrackTests.swift | 4 +- 5 files changed, 91 insertions(+), 12 deletions(-) create mode 100644 OptimoveSDK/Sources/Classes/CoreData/OptistreamPersistentContainerConfigurator.swift create mode 100644 OptimoveSDK/Sources/Classes/CoreData/PersistentContainerConfigurator.swift diff --git a/OptimoveSDK/Sources/Classes/CoreData/OptistreamPersistentContainerConfigurator.swift b/OptimoveSDK/Sources/Classes/CoreData/OptistreamPersistentContainerConfigurator.swift new file mode 100644 index 00000000..bc04dc87 --- /dev/null +++ b/OptimoveSDK/Sources/Classes/CoreData/OptistreamPersistentContainerConfigurator.swift @@ -0,0 +1,23 @@ +// Copyright © 2023 Optimove. All rights reserved. + +import CoreData +import Foundation + +final class OptistreamPersistentContainerConfigurator: PersistentContainerConfigurator { + enum Constants { + static let modelName = "OptistreamQueue" + static let folderName = "com.optimove.sdk.no-backup" + } + + let version: CoreDataMigrationVersion + + init(version: CoreDataMigrationVersion = .current) { + self.version = version + } + + let folderName: String = Constants.folderName + let modelName: String = Constants.modelName + var managedObjectModel: ManagedObjectModel { + CoreDataModelDescription.makeOptistreamEventModel(version: version) + } +} diff --git a/OptimoveSDK/Sources/Classes/CoreData/PersistentContainer.swift b/OptimoveSDK/Sources/Classes/CoreData/PersistentContainer.swift index d503ce5f..4eea188e 100644 --- a/OptimoveSDK/Sources/Classes/CoreData/PersistentContainer.swift +++ b/OptimoveSDK/Sources/Classes/CoreData/PersistentContainer.swift @@ -19,24 +19,22 @@ final class PersistentContainer: NSPersistentContainer { } } - private enum Constants { - static let modelName = "OptistreamQueue" - static let folderName = "com.optimove.sdk.no-backup" - } - private let migrator: CoreDataMigratorProtocol + private let persistentContainerConfigurator: PersistentContainerConfigurator private let storeType: PersistentStoreType init( - modelName: String = Constants.modelName, - version: CoreDataMigrationVersion = .current, + persistentContainerConfigurator: PersistentContainerConfigurator, migrator: CoreDataMigratorProtocol = CoreDataMigrator(), storeType: PersistentStoreType = .sql ) { - let mom = CoreDataModelDescription.makeOptistreamEventModel(version: version) self.migrator = migrator self.storeType = storeType - super.init(name: modelName, managedObjectModel: mom) + self.persistentContainerConfigurator = persistentContainerConfigurator + super.init( + name: persistentContainerConfigurator.modelName, + managedObjectModel: persistentContainerConfigurator.managedObjectModel + ) } func loadPersistentStores(storeName: String) throws { @@ -44,7 +42,7 @@ final class PersistentContainer: NSPersistentContainer { let persistentStoreDescription = NSPersistentStoreDescription() persistentStoreDescription.type = storeType.coreDataValue persistentStoreDescription.url = try FileManager.default.defineStoreURL( - folderName: Constants.folderName, + folderName: persistentContainerConfigurator.folderName, storeName: storeName ) persistentStoreDescription.shouldMigrateStoreAutomatically = false @@ -129,6 +127,10 @@ extension CoreDataModelDescription { } } + static func makeAnalyticsEventModel() -> NSManagedObjectModel { + return makeAnalyticsEventModelv1() + } + private static func makeOptistreamEventModelv1() -> NSManagedObjectModel { let modelDescription = CoreDataModelDescription( entities: [ @@ -170,4 +172,41 @@ extension CoreDataModelDescription { ) return modelDescription.makeModel() } + + private static func makeAnalyticsEventModelv1() -> NSManagedObjectModel { + let modelDescription = CoreDataModelDescription( + entities: [ + .entity( + name: KSEventModel.entityName, + managedObjectClass: KSEventModel.self, + attributes: [ + .attribute( + name: #keyPath(KSEventModel.eventType), + type: .stringAttributeType + ), + .attribute( + name: #keyPath(KSEventModel.happenedAt), + type: .integer64AttributeType, + defaultValue: 0 + ), + .attribute( + name: #keyPath(KSEventModel.properties), + type: .binaryDataAttributeType, + isOptional: true + ), + .attribute( + name: #keyPath(KSEventModel.uuid), + type: .stringAttributeType + ), + .attribute( + name: #keyPath(KSEventModel.userIdentifier), + type: .stringAttributeType, + isOptional: true + ), + ] + ), + ] + ) + return modelDescription.makeModel() + } } diff --git a/OptimoveSDK/Sources/Classes/CoreData/PersistentContainerConfigurator.swift b/OptimoveSDK/Sources/Classes/CoreData/PersistentContainerConfigurator.swift new file mode 100644 index 00000000..f5200120 --- /dev/null +++ b/OptimoveSDK/Sources/Classes/CoreData/PersistentContainerConfigurator.swift @@ -0,0 +1,13 @@ +// Copyright © 2023 Optimove. All rights reserved. + +import CoreData +import Foundation +import OptimoveCore + +typealias ManagedObjectModel = NSManagedObjectModel + +protocol PersistentContainerConfigurator { + var folderName: String { get } + var modelName: String { get } + var managedObjectModel: ManagedObjectModel { get } +} diff --git a/OptimoveSDK/Sources/Classes/Factories/ComponentFactory.swift b/OptimoveSDK/Sources/Classes/Factories/ComponentFactory.swift index e6a4cdfb..3276cc71 100644 --- a/OptimoveSDK/Sources/Classes/Factories/ComponentFactory.swift +++ b/OptimoveSDK/Sources/Classes/Factories/ComponentFactory.swift @@ -13,7 +13,9 @@ final class ComponentFactory { { self.serviceLocator = serviceLocator self.coreEventFactory = coreEventFactory - persistentContainer = PersistentContainer() + persistentContainer = PersistentContainer( + persistentContainerConfigurator: OptistreamPersistentContainerConfigurator() + ) } func createRealtimeComponent(configuration: Configuration) throws -> RealTime { diff --git a/OptimoveSDK/Tests/Sources/Components/OptiTrack/OptitrackTests.swift b/OptimoveSDK/Tests/Sources/Components/OptiTrack/OptitrackTests.swift index 7a646834..12fe4054 100644 --- a/OptimoveSDK/Tests/Sources/Components/OptiTrack/OptitrackTests.swift +++ b/OptimoveSDK/Tests/Sources/Components/OptiTrack/OptitrackTests.swift @@ -18,7 +18,9 @@ class OptitrackTests: OptimoveTestCase { networking = OptistreamNetworkingMock() let queue = try OptistreamQueueImpl( queueType: .track, - container: PersistentContainer(), + container: PersistentContainer( + persistentContainerConfigurator: OptistreamPersistentContainerConfigurator() + ), tenant: configuration.tenantID ) builder = OptistreamEventBuilder( From d10cd70aec05614c4ad151f52b344e20c1bfc96e Mon Sep 17 00:00:00 2001 From: Eli Gutovsky Date: Thu, 14 Dec 2023 16:23:04 +0200 Subject: [PATCH 2/3] feat: persistant container configuration --- ...treamPersistentContainerConfigurator.swift | 4 ++- .../CoreData/PersistentContainer.swift | 31 +++++++++++++++---- .../PersistentContainerConfigurator.swift | 8 ++++- ...yticsPersistentContainerConfigurator.swift | 20 ++++++++++++ 4 files changed, 55 insertions(+), 8 deletions(-) rename OptimoveSDK/Sources/Classes/{CoreData => Components/Queue}/OptistreamPersistentContainerConfigurator.swift (85%) create mode 100644 OptimoveSDK/Sources/Classes/Optimobile/Analytics/AnalyticsPersistentContainerConfigurator.swift diff --git a/OptimoveSDK/Sources/Classes/CoreData/OptistreamPersistentContainerConfigurator.swift b/OptimoveSDK/Sources/Classes/Components/Queue/OptistreamPersistentContainerConfigurator.swift similarity index 85% rename from OptimoveSDK/Sources/Classes/CoreData/OptistreamPersistentContainerConfigurator.swift rename to OptimoveSDK/Sources/Classes/Components/Queue/OptistreamPersistentContainerConfigurator.swift index bc04dc87..89ba4b2d 100644 --- a/OptimoveSDK/Sources/Classes/CoreData/OptistreamPersistentContainerConfigurator.swift +++ b/OptimoveSDK/Sources/Classes/Components/Queue/OptistreamPersistentContainerConfigurator.swift @@ -15,9 +15,11 @@ final class OptistreamPersistentContainerConfigurator: PersistentContainerConfig self.version = version } - let folderName: String = Constants.folderName + let folderName: String? = Constants.folderName let modelName: String = Constants.modelName var managedObjectModel: ManagedObjectModel { CoreDataModelDescription.makeOptistreamEventModel(version: version) } + + var location: FileManagerLocation = .libraryDirectory } diff --git a/OptimoveSDK/Sources/Classes/CoreData/PersistentContainer.swift b/OptimoveSDK/Sources/Classes/CoreData/PersistentContainer.swift index 4eea188e..582e369c 100644 --- a/OptimoveSDK/Sources/Classes/CoreData/PersistentContainer.swift +++ b/OptimoveSDK/Sources/Classes/CoreData/PersistentContainer.swift @@ -42,6 +42,7 @@ final class PersistentContainer: NSPersistentContainer { let persistentStoreDescription = NSPersistentStoreDescription() persistentStoreDescription.type = storeType.coreDataValue persistentStoreDescription.url = try FileManager.default.defineStoreURL( + location: persistentContainerConfigurator.location, folderName: persistentContainerConfigurator.folderName, storeName: storeName ) @@ -88,14 +89,32 @@ final class PersistentContainer: NSPersistentContainer { } extension FileManager { - func defineStoreURL(folderName: String, storeName: String) throws -> URL { - let libraryDirectory = try unwrap(urls(for: .libraryDirectory, in: .userDomainMask).first) - let libraryStoreDirectoryURL = try unwrap(libraryDirectory.appendingPathComponent(folderName)) - let storeURL = try unwrap(libraryStoreDirectoryURL.appendingPathComponent("\(storeName).sqlite")) - guard !directoryExists(atUrl: libraryStoreDirectoryURL, isDirectory: true) else { + func defineLocation(_ location: FileManagerLocation) throws -> URL { + switch location { + case let .appGroupDirectory(url): + return url + case .libraryDirectory: + return try unwrap(urls(for: .libraryDirectory, in: .userDomainMask).first) + } + } + + func defineStoreURL( + location: FileManagerLocation, + folderName: String?, + storeName: String + ) throws -> URL { + let storeFolderURL = try { + let locationURL = try defineLocation(location) + if let folderName = folderName { + return try unwrap(locationURL.appendingPathComponent(folderName)) + } + return locationURL + }() + let storeURL = try unwrap(storeFolderURL.appendingPathComponent("\(storeName).sqlite")) + guard !directoryExists(atUrl: storeFolderURL, isDirectory: true) else { return try addSkipBackupAttributeToItemAtURL(url: storeURL) } - try createDirectory(at: libraryStoreDirectoryURL, withIntermediateDirectories: true) + try createDirectory(at: storeFolderURL, withIntermediateDirectories: true) return try addSkipBackupAttributeToItemAtURL(url: storeURL) } diff --git a/OptimoveSDK/Sources/Classes/CoreData/PersistentContainerConfigurator.swift b/OptimoveSDK/Sources/Classes/CoreData/PersistentContainerConfigurator.swift index f5200120..d28f5d00 100644 --- a/OptimoveSDK/Sources/Classes/CoreData/PersistentContainerConfigurator.swift +++ b/OptimoveSDK/Sources/Classes/CoreData/PersistentContainerConfigurator.swift @@ -6,8 +6,14 @@ import OptimoveCore typealias ManagedObjectModel = NSManagedObjectModel +enum FileManagerLocation { + case libraryDirectory + case appGroupDirectory(URL) +} + protocol PersistentContainerConfigurator { - var folderName: String { get } + var folderName: String? { get } var modelName: String { get } var managedObjectModel: ManagedObjectModel { get } + var location: FileManagerLocation { get } } diff --git a/OptimoveSDK/Sources/Classes/Optimobile/Analytics/AnalyticsPersistentContainerConfigurator.swift b/OptimoveSDK/Sources/Classes/Optimobile/Analytics/AnalyticsPersistentContainerConfigurator.swift new file mode 100644 index 00000000..c96c3d08 --- /dev/null +++ b/OptimoveSDK/Sources/Classes/Optimobile/Analytics/AnalyticsPersistentContainerConfigurator.swift @@ -0,0 +1,20 @@ +// Copyright © 2023 Optimove. All rights reserved. + +import Foundation + +final class AnalyticsPersistentContainerConfigurator: PersistentContainerConfigurator { + enum Constants { + static let modelName = "AnalyticsEvents" + } + + init() throws { + let url = try FileManager.optimoveAppGroupURL() + self.location = .appGroupDirectory(url) + } + + var folderName: String? = nil + let modelName: String = Constants.modelName + var managedObjectModel: ManagedObjectModel = + CoreDataModelDescription.makeAnalyticsEventModel() + var location: FileManagerLocation +} From 8b83cd346ff6f774f6a575a4a5ad871d8cdfff75 Mon Sep 17 00:00:00 2001 From: Eli Gutovsky Date: Thu, 14 Dec 2023 16:23:54 +0200 Subject: [PATCH 3/3] refactor: analytics core data --- .../NSManagedObjectContext+Utilities.swift | 46 ++- .../Optimobile/Analytics/AnalyticModel.swift | 46 +++ .../Analytics/AnalyticsHelper.swift | 165 +++++++++ .../Classes/Optimobile/AnalyticsHelper.swift | 334 ------------------ .../Classes/Optimobile/Optimobile.swift | 11 +- 5 files changed, 262 insertions(+), 340 deletions(-) create mode 100644 OptimoveSDK/Sources/Classes/Optimobile/Analytics/AnalyticModel.swift create mode 100644 OptimoveSDK/Sources/Classes/Optimobile/Analytics/AnalyticsHelper.swift delete mode 100644 OptimoveSDK/Sources/Classes/Optimobile/AnalyticsHelper.swift diff --git a/OptimoveSDK/Sources/Classes/Extensions/NSManagedObjectContext+Utilities.swift b/OptimoveSDK/Sources/Classes/Extensions/NSManagedObjectContext+Utilities.swift index 80efeabc..5c51dbff 100644 --- a/OptimoveSDK/Sources/Classes/Extensions/NSManagedObjectContext+Utilities.swift +++ b/OptimoveSDK/Sources/Classes/Extensions/NSManagedObjectContext+Utilities.swift @@ -4,6 +4,17 @@ import CoreData import OptimoveCore extension NSManagedObjectContext { + enum Error: LocalizedError { + case unableToSaveEvent + + var errorDescription: String? { + switch self { + case .unableToSaveEvent: + return "Unable to save event" + } + } + } + /** Safe is determined by checking if the context has any persistent stores. - Returns: `False` if no persistent stores found. @@ -20,13 +31,44 @@ extension NSManagedObjectContext { - Returns: A value with generic type. */ func safeTryPerformAndWait(_ block: (Bool) throws -> T) throws -> T { - var result: Result? + var result: Result? performAndWait { result = Result { try block(isSafe) } } return try result!.get() } + // Async perform with throws error + func safeTryPerform(_ block: @escaping (NSManagedObjectContext) throws -> T, completion: @escaping (Result) -> Void) { + perform { + guard self.isSafe else { + completion(.failure(NSManagedObjectContext.Error.unableToSaveEvent)) + return + } + let result = Result { try block(self) } + completion(result) + } + } + + // Async/await perform with throws error + func safeTryPerform( + _ block: @escaping (NSManagedObjectContext) throws -> T) async throws -> T + { + guard isSafe else { + throw NSManagedObjectContext.Error.unableToSaveEvent + } + return try await withCheckedThrowingContinuation { continuation in + self.perform { + do { + let result = try block(self) + continuation.resume(returning: result) + } catch { + continuation.resume(throwing: error) + } + } + } + } + /** Performs a synchronous block with the passed in boolean indicating if it's safe to perform operations. Safe is determined by checking if the context has any persistent stores. Throws an error if occurs. @@ -34,7 +76,7 @@ extension NSManagedObjectContext { - block: A block to perform. */ func safeTryPerformAndWait(_ block: (Bool) throws -> Void) throws { - var result: Result? + var result: Result? performAndWait { result = Result { try block(isSafe) } } diff --git a/OptimoveSDK/Sources/Classes/Optimobile/Analytics/AnalyticModel.swift b/OptimoveSDK/Sources/Classes/Optimobile/Analytics/AnalyticModel.swift new file mode 100644 index 00000000..3396062b --- /dev/null +++ b/OptimoveSDK/Sources/Classes/Optimobile/Analytics/AnalyticModel.swift @@ -0,0 +1,46 @@ +// Copyright © 2023 Optimove. All rights reserved. + +import CoreData +import Foundation + +final class KSEventModel: NSManagedObject { + @discardableResult + static func insert( + into context: NSManagedObjectContext, + uuid: UUID = UUID(), + atTime: Date, + eventType: String, + userIdentifier: String, + properties: [String: Any]? = nil + ) throws -> KSEventModel { + let eventCD: KSEventModel = try context.insertObject() + eventCD.uuid = uuid.uuidString.lowercased() + eventCD.happenedAt = NSNumber(value: Int64(atTime.timeIntervalSince1970 * 1000)) + eventCD.eventType = eventType + eventCD.userIdentifier = userIdentifier + if let properties = properties { + eventCD.properties = try JSONSerialization.data(withJSONObject: properties) + } + return eventCD + } +} + +extension KSEventModel { + @NSManaged var uuid: String + @NSManaged var userIdentifier: String + @NSManaged var happenedAt: NSNumber + @NSManaged var eventType: String + @NSManaged var properties: Data? +} + +extension KSEventModel: Managed { + static var entityName: String { + return "Event" + } + + static var defaultSortDescriptors: [NSSortDescriptor] { + return [NSSortDescriptor(keyPath: \KSEventModel.happenedAt, ascending: true)] + } +} + +extension KSEventModel {} diff --git a/OptimoveSDK/Sources/Classes/Optimobile/Analytics/AnalyticsHelper.swift b/OptimoveSDK/Sources/Classes/Optimobile/Analytics/AnalyticsHelper.swift new file mode 100644 index 00000000..be3af1b5 --- /dev/null +++ b/OptimoveSDK/Sources/Classes/Optimobile/Analytics/AnalyticsHelper.swift @@ -0,0 +1,165 @@ +// Copyright © 2022 Optimove. All rights reserved. + +import CoreData +import Foundation +import OptimoveCore + +typealias SyncCompletedBlock = (Error?) -> Void + +final class AnalyticsHelper { + let eventsHttpClient: KSHttpClient + let optimobileHelper: OptimobileHelper + let container: PersistentContainer + let context: NSManagedObjectContext + var finishedInitializationToken: NSObjectProtocol? + + init( + httpClient: KSHttpClient, + optimobileHelper: OptimobileHelper, + container: PersistentContainer + ) throws { + self.container = container + try container.loadPersistentStores( + storeName: "KAnalyticsDbShared" + ) + context = container.newBackgroundContext() + context.mergePolicy = NSMergePolicy(merge: .mergeByPropertyStoreTrumpMergePolicyType) + + eventsHttpClient = httpClient + self.optimobileHelper = optimobileHelper + + finishedInitializationToken = NotificationCenter.default + .addObserver(forName: .optimobileInializationFinished, object: nil, queue: nil) { [weak self] notification in + DispatchQueue.global().async { + guard let self = self else { return } + self.flushEvents() + } + Logger.debug("Notification \(notification.name.rawValue) was processed") + } + } + + deinit { + eventsHttpClient.invalidateSessionCancellingTasks(false) + } + + func flushEvents() { + syncEvents() + } + + // MARK: Event Tracking + + func trackEvent(eventType: String, properties: [String: Any]?, immediateFlush: Bool) { + trackEvent( + eventType: eventType, + atTime: Date(), + properties: properties, + immediateFlush: immediateFlush + ) + } + + func trackEvent(eventType: String, atTime: Date, properties: [String: Any]?, immediateFlush: Bool, onSyncComplete: SyncCompletedBlock? = nil) { + if eventType == "" || (properties != nil && !JSONSerialization.isValidJSONObject(properties as Any)) { + Logger.error("Ignoring invalid event with empty type or non-serializable properties") + return + } + Task { + let currentUserIdentifier = optimobileHelper.currentUserIdentifier() + do { + try await context.safeTryPerform { context in + try KSEventModel.insert( + into: context, + atTime: atTime, + eventType: eventType, + userIdentifier: currentUserIdentifier + ) + try context.save() + } + + if immediateFlush { + syncEvents(onSyncComplete) + } + } catch { + Logger.error("Error saving event: \(error.localizedDescription)") + } + } + } + + private func syncEvents(_ onSyncComplete: SyncCompletedBlock? = nil) { + context.performAndWait { + // FIXME: Remove unnecessary performAndWait + let results = (try? fetchEventsBatch()) ?? [] + + if results.count == 0 { + onSyncComplete?(nil) + } else if results.count > 0 { + syncEventsBatch(events: results, onSyncComplete) + return + } + } + } + + private func syncEventsBatch( + events: [KSEventModel], + _ onSyncComplete: SyncCompletedBlock? = nil + ) { + var data = [] as [[String: Any?]] + var eventIds = [] as [NSManagedObjectID] + + for event in events { + var jsonProps = nil as Any? + if let props = event.properties { + jsonProps = try? JSONSerialization.jsonObject(with: props, options: JSONSerialization.ReadingOptions(rawValue: 0)) + } + + data.append([ + "type": event.eventType, + "uuid": event.uuid, + "timestamp": event.happenedAt, + "data": jsonProps, + "userId": event.userIdentifier, + ]) + eventIds.append(event.objectID) + } + + let path = "/v1/app-installs/\(optimobileHelper.installId())/events" + + eventsHttpClient.sendRequest(.POST, toPath: path, data: data, onSuccess: { _, _ in + if let err = self.pruneEventsBatch(eventIds) { + print("Failed to prune events batch: " + err.localizedDescription) + onSyncComplete?(err) + return + } + self.syncEvents(onSyncComplete) + }) { _, error, _ in + print("Failed to send events") + onSyncComplete?(error) + } + } + + private func pruneEventsBatch(_ eventIds: [NSManagedObjectID]) -> Error? { + var err: Error? + + context.performAndWait { + let request = NSBatchDeleteRequest(objectIDs: eventIds) + + do { + try context.execute(request) + } catch { + err = error + } + } + + return err + } + + private func fetchEventsBatch() throws -> [KSEventModel] { + return try context.safeTryPerformAndWait { _ in + try KSEventModel.fetch(in: context) { request in + request.fetchLimit = 100 + request.sortDescriptors = KSEventModel.defaultSortDescriptors + request.returnsObjectsAsFaults = false + request.includesPendingChanges = false + } + } + } +} diff --git a/OptimoveSDK/Sources/Classes/Optimobile/AnalyticsHelper.swift b/OptimoveSDK/Sources/Classes/Optimobile/AnalyticsHelper.swift deleted file mode 100644 index fe2d01a0..00000000 --- a/OptimoveSDK/Sources/Classes/Optimobile/AnalyticsHelper.swift +++ /dev/null @@ -1,334 +0,0 @@ -// Copyright © 2022 Optimove. All rights reserved. - -import CoreData -import Foundation -import OptimoveCore - -class KSEventModel: NSManagedObject { - @NSManaged var uuid: String - @NSManaged var userIdentifier: String - @NSManaged var happenedAt: NSNumber - @NSManaged var eventType: String - @NSManaged var properties: Data? -} - -typealias SyncCompletedBlock = (Error?) -> Void - -final class AnalyticsHelper { - let eventsHttpClient: KSHttpClient - let optimobileHelper: OptimobileHelper - private var analyticsContext: NSManagedObjectContext? - private var migrationAnalyticsContext: NSManagedObjectContext? - private var finishedInitializationToken: NSObjectProtocol? - - // MARK: Initialization - - init(httpClient: KSHttpClient, optimobileHelper: OptimobileHelper) { - analyticsContext = nil - migrationAnalyticsContext = nil - - eventsHttpClient = httpClient - self.optimobileHelper = optimobileHelper - - initContext() - - finishedInitializationToken = NotificationCenter.default - .addObserver(forName: .optimobileInializationFinished, object: nil, queue: nil) { [weak self] notification in - DispatchQueue.global().async { - guard let self = self else { return } - self.flushEvents() - } - Logger.debug("Notification \(notification.name.rawValue) was processed") - } - } - - deinit { - eventsHttpClient.invalidateSessionCancellingTasks(false) - } - - func flushEvents() { - if migrationAnalyticsContext != nil { - syncEvents(context: migrationAnalyticsContext) - } - syncEvents(context: analyticsContext) - } - - private func getMainStoreUrl(appGroupExists: Bool) -> URL? { - if !appGroupExists { - return getAppDbUrl() - } - - return getSharedDbUrl() - } - - private func getAppDbUrl() -> URL? { - let docsUrl = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).last - let appDbUrl = URL(string: "KAnalyticsDb.sqlite", relativeTo: docsUrl) - - return appDbUrl - } - - private func getSharedDbUrl() -> URL? { - let sharedContainerPath = try? FileManager.optimoveAppGroupURL() - if sharedContainerPath == nil { - return nil - } - - return URL(string: "KAnalyticsDbShared.sqlite", relativeTo: sharedContainerPath) - } - - private func initContext() { - let appDbUrl = getAppDbUrl() - let appDbExists = appDbUrl == nil ? false : FileManager.default.fileExists(atPath: appDbUrl!.path) - let appGroupExists = true - - let storeUrl = getMainStoreUrl(appGroupExists: appGroupExists) - - if appGroupExists, appDbExists { - migrationAnalyticsContext = getManagedObjectContext(storeUrl: appDbUrl) - } - - analyticsContext = getManagedObjectContext(storeUrl: storeUrl) - } - - private func getManagedObjectContext(storeUrl: URL?) -> NSManagedObjectContext? { - let objectModel = getCoreDataModel() - let storeCoordinator = NSPersistentStoreCoordinator(managedObjectModel: objectModel) - let opts = [NSMigratePersistentStoresAutomaticallyOption: true, NSInferMappingModelAutomaticallyOption: true] - - do { - try storeCoordinator.addPersistentStore(ofType: NSSQLiteStoreType, configurationName: nil, at: storeUrl, options: opts) - } catch { - print("Failed to set up persistent store: " + error.localizedDescription) - return nil - } - - let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) - context.performAndWait { - context.persistentStoreCoordinator = storeCoordinator - } - - return context - } - - // MARK: Event Tracking - - func trackEvent(eventType: String, properties: [String: Any]?, immediateFlush: Bool) { - trackEvent( - eventType: eventType, - atTime: Date(), - properties: properties, - immediateFlush: immediateFlush - ) - } - - func trackEvent(eventType: String, atTime: Date, properties: [String: Any]?, immediateFlush: Bool, onSyncComplete: SyncCompletedBlock? = nil) { - if eventType == "" || (properties != nil && !JSONSerialization.isValidJSONObject(properties as Any)) { - Logger.error("Ignoring invalid event with empty type or non-serializable properties") - return - } - - let currentUserIdentifier = optimobileHelper.currentUserIdentifier() - let work = { - guard let context = self.analyticsContext else { - print("No context, aborting") - return - } - - guard let entity = NSEntityDescription.entity(forEntityName: "Event", in: context) else { - print("Can't create entity, aborting") - return - } - - let event = KSEventModel(entity: entity, insertInto: nil) - - event.uuid = UUID().uuidString.lowercased() - event.happenedAt = NSNumber(value: Int64(atTime.timeIntervalSince1970 * 1000)) - event.eventType = eventType - event.userIdentifier = currentUserIdentifier - - if properties != nil { - let propsJson = try? JSONSerialization.data(withJSONObject: properties as Any, options: JSONSerialization.WritingOptions(rawValue: 0)) - - event.properties = propsJson - } - - context.insert(event) - do { - try context.save() - - if immediateFlush { - DispatchQueue.global().async { - self.syncEvents(context: self.analyticsContext, onSyncComplete) - } - } - } catch { - print("Failed to record event") - print(error) - } - } - - analyticsContext?.perform(work) - } - - private func syncEvents(context: NSManagedObjectContext?, _ onSyncComplete: SyncCompletedBlock? = nil) { - context?.performAndWait { - let results = fetchEventsBatch(context) - - if results.count == 0 { - onSyncComplete?(nil) - - if context === migrationAnalyticsContext { - removeAppDatabase() - } - } else if results.count > 0 { - syncEventsBatch(context, events: results, onSyncComplete) - return - } - } - } - - private func removeAppDatabase() { - if migrationAnalyticsContext == nil { - return - } - - guard let persStoreCoord = migrationAnalyticsContext!.persistentStoreCoordinator else { - return - } - - guard let store = persStoreCoord.persistentStores.last else { - return - } - - let storeUrl = persStoreCoord.url(for: store) - - migrationAnalyticsContext!.performAndWait { - migrationAnalyticsContext!.reset() - do { - try persStoreCoord.remove(store) - try FileManager.default.removeItem(at: storeUrl) - } catch {} - } - migrationAnalyticsContext = nil - } - - private func syncEventsBatch(_ context: NSManagedObjectContext?, events: [KSEventModel], _ onSyncComplete: SyncCompletedBlock? = nil) { - var data = [] as [[String: Any?]] - var eventIds = [] as [NSManagedObjectID] - - for event in events { - var jsonProps = nil as Any? - if let props = event.properties { - jsonProps = try? JSONSerialization.jsonObject(with: props, options: JSONSerialization.ReadingOptions(rawValue: 0)) - } - - data.append([ - "type": event.eventType, - "uuid": event.uuid, - "timestamp": event.happenedAt, - "data": jsonProps, - "userId": event.userIdentifier, - ]) - eventIds.append(event.objectID) - } - - let path = "/v1/app-installs/\(optimobileHelper.installId())/events" - - eventsHttpClient.sendRequest(.POST, toPath: path, data: data, onSuccess: { _, _ in - if let err = self.pruneEventsBatch(context, eventIds) { - print("Failed to prune events batch: " + err.localizedDescription) - onSyncComplete?(err) - return - } - self.syncEvents(context: context, onSyncComplete) - }) { _, error, _ in - print("Failed to send events") - onSyncComplete?(error) - } - } - - private func pruneEventsBatch(_ context: NSManagedObjectContext?, _ eventIds: [NSManagedObjectID]) -> Error? { - var err: Error? - - context?.performAndWait { - let request = NSBatchDeleteRequest(objectIDs: eventIds) - - do { - try context?.execute(request) - } catch { - err = error - } - } - - return err - } - - private func fetchEventsBatch(_ context: NSManagedObjectContext?) -> [KSEventModel] { - guard let context = context else { - return [] - } - - let request = NSFetchRequest(entityName: "Event") - request.returnsObjectsAsFaults = false - request.sortDescriptors = [NSSortDescriptor(key: "happenedAt", ascending: true)] - request.fetchLimit = 100 - request.includesPendingChanges = false - - do { - let results = try context.fetch(request) - return results - } catch { - print("Failed to fetch events batch: " + error.localizedDescription) - return [] - } - } - - // MARK: CoreData model definition - - private func getCoreDataModel() -> NSManagedObjectModel { - let model = NSManagedObjectModel() - - let eventEntity = NSEntityDescription() - eventEntity.name = "Event" - eventEntity.managedObjectClassName = NSStringFromClass(KSEventModel.self) - - var eventProps: [NSAttributeDescription] = [] - - let eventTypeProp = NSAttributeDescription() - eventTypeProp.name = "eventType" - eventTypeProp.attributeType = .stringAttributeType - eventTypeProp.isOptional = false - eventProps.append(eventTypeProp) - - let happenedAtProp = NSAttributeDescription() - happenedAtProp.name = "happenedAt" - happenedAtProp.attributeType = .integer64AttributeType - happenedAtProp.isOptional = false - happenedAtProp.defaultValue = 0 - eventProps.append(happenedAtProp) - - let propertiesProp = NSAttributeDescription() - propertiesProp.name = "properties" - propertiesProp.attributeType = .binaryDataAttributeType - propertiesProp.isOptional = true - eventProps.append(propertiesProp) - - let uuidProp = NSAttributeDescription() - uuidProp.name = "uuid" - uuidProp.attributeType = .stringAttributeType - uuidProp.isOptional = false - eventProps.append(uuidProp) - - let userIdProp = NSAttributeDescription() - userIdProp.name = "userIdentifier" - userIdProp.attributeType = .stringAttributeType - userIdProp.isOptional = true - eventProps.append(userIdProp) - - eventEntity.properties = eventProps - model.entities = [eventEntity] - - return model - } -} diff --git a/OptimoveSDK/Sources/Classes/Optimobile/Optimobile.swift b/OptimoveSDK/Sources/Classes/Optimobile/Optimobile.swift index 367df907..62c389e0 100644 --- a/OptimoveSDK/Sources/Classes/Optimobile/Optimobile.swift +++ b/OptimoveSDK/Sources/Classes/Optimobile/Optimobile.swift @@ -113,7 +113,7 @@ final class Optimobile { try writeDefaultsKeys(config: config, storage: storage) - instance = Optimobile(config: config, storage: storage) + instance = try Optimobile(config: config, storage: storage) instance!.initializeHelpers() @@ -193,7 +193,7 @@ final class Optimobile { Optimobile.associateUserWithInstall(userIdentifier: initialUserId, storage: storage) } - private init(config: OptimobileConfig, storage: OptimoveStorage) { + private init(config: OptimobileConfig, storage: OptimoveStorage) throws { self.config = config let urlBuilder = UrlBuilder(storage: storage) networkFactory = NetworkFactory( @@ -206,9 +206,12 @@ final class Optimobile { optimobileHelper = OptimobileHelper( storage: storage ) - analyticsHelper = AnalyticsHelper( + analyticsHelper = try AnalyticsHelper( httpClient: networkFactory.build(for: .events), - optimobileHelper: optimobileHelper + optimobileHelper: optimobileHelper, + container: PersistentContainer( + persistentContainerConfigurator: AnalyticsPersistentContainerConfigurator() + ) ) sessionHelper = SessionHelper(sessionIdleTimeout: config.sessionIdleTimeout)