diff --git a/Runtime/GuruAnalytics/Editor/Dependencies.xml b/Runtime/GuruAnalytics/Editor/Dependencies.xml index f4fce54..0828d2a 100644 --- a/Runtime/GuruAnalytics/Editor/Dependencies.xml +++ b/Runtime/GuruAnalytics/Editor/Dependencies.xml @@ -20,16 +20,11 @@ Sample Dependencies.xml: - - - - git@github.com:castbox/GuruSpecs.git - - + diff --git a/Runtime/GuruAnalytics/Plugins/Android/guru-analytics-1.0.1.aar b/Runtime/GuruAnalytics/Plugins/Android/guru-analytics-1.0.1.aar deleted file mode 100644 index e7494c5..0000000 Binary files a/Runtime/GuruAnalytics/Plugins/Android/guru-analytics-1.0.1.aar and /dev/null differ diff --git a/Runtime/GuruAnalytics/Plugins/Android/guru-analytics-1.0.3.aar b/Runtime/GuruAnalytics/Plugins/Android/guru-analytics-1.0.3.aar new file mode 100644 index 0000000..8901499 Binary files /dev/null and b/Runtime/GuruAnalytics/Plugins/Android/guru-analytics-1.0.3.aar differ diff --git a/Runtime/GuruAnalytics/Plugins/Android/guru-analytics-1.0.3.aar.meta b/Runtime/GuruAnalytics/Plugins/Android/guru-analytics-1.0.3.aar.meta new file mode 100644 index 0000000..7e8e4af --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/Android/guru-analytics-1.0.3.aar.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: caf6ff09835a4a75bff1b4b068f664ef +timeCreated: 1717117895 \ No newline at end of file diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics.meta new file mode 100644 index 0000000..3626b3c --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e53e2bfca0fd949559d383674081f737 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Assets.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Assets.meta new file mode 100644 index 0000000..572bb53 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Assets.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 502f707bde2a24fadb6ec09ac5a3593f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Assets/.gitkeep b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Assets/PrivacyInfo.xcprivacy b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Assets/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..458bfd4 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Assets/PrivacyInfo.xcprivacy @@ -0,0 +1,41 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + + + NSPrivacyAccessedAPITypeReasons + + C617.1 + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + + + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + 8FFB.1 + 3D61.1 + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + + + + diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Assets/PrivacyInfo.xcprivacy.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Assets/PrivacyInfo.xcprivacy.meta new file mode 100644 index 0000000..0bc60de --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Assets/PrivacyInfo.xcprivacy.meta @@ -0,0 +1,85 @@ +fileFormatVersion: 2 +guid: 63885139be48c43ae8cd3b1c403a686f +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Android: Android + second: + enabled: 0 + settings: + CPU: ARMv7 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + CPU: AnyCPU + DefaultValueInitialized: true + OS: AnyOS + - first: + Standalone: Linux64 + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + Standalone: OSXUniversal + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + Standalone: Win + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + Standalone: Win64 + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + iPhone: iOS + second: + enabled: 0 + settings: + AddToEmbeddedBinaries: false + CPU: AnyCPU + CompileFlags: + FrameworkDependencies: + - first: + tvOS: tvOS + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes.meta new file mode 100644 index 0000000..41c20e1 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d30316515c87a4421bc7032194f888e1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/.gitkeep b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Interface.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Interface.meta new file mode 100644 index 0000000..cec3357 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Interface.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e0086576c1ac64707b788bef25dc9316 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Interface/GuruAnalytics.swift b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Interface/GuruAnalytics.swift new file mode 100644 index 0000000..15b4435 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Interface/GuruAnalytics.swift @@ -0,0 +1,158 @@ +// +// GuruAnalytics.swift +// GuruAnalytics_iOS +// +// Created by mayue on 2022/11/4. +// + +import Foundation + +public class GuruAnalytics: NSObject { + + internal static var uploadPeriodInSecond: Double = 60 + internal static var batchLimit: Int = 25 + internal static var eventExpiredSeconds: Double = 7 * 24 * 60 * 60 + internal static var initializeTimeout: Double = 5 + internal static var saasXAPPID = "" + internal static var saasXDEVICEINFO = "" + internal static var loggerDebug = true + internal static var enableUpload = true + + /// 初始化设置 + /// - Parameters: + /// - uploadPeriodInSecond: 批量上传周期,单位秒 + /// - batchLimit: 批量条数 + /// - eventExpiredSeconds: 数据过期时间,单位秒 + /// - initializeTimeout: 初始化后等待user id/device id/firebase pseudo id等属性超时时间,单位秒 + /// - saasXAPPID: 中台接口header中的X-APP-ID + /// - saasXDEVICEINFO: 中台接口header中的X-DEVICE-INFO + /// - loggerDebug: 开启控制台输出debug信息 + @objc + public class func initializeLib(uploadPeriodInSecond: Double = 60, + batchLimit: Int = 25, + eventExpiredSeconds: Double = 7 * 24 * 60 * 60, + initializeTimeout: Double = 5, + saasXAPPID: String, + saasXDEVICEINFO: String, + loggerDebug: Bool = true) { + Self.uploadPeriodInSecond = uploadPeriodInSecond + Self.batchLimit = batchLimit + Self.eventExpiredSeconds = eventExpiredSeconds + Self.initializeTimeout = initializeTimeout + Self.saasXAPPID = saasXAPPID + Self.saasXDEVICEINFO = saasXDEVICEINFO + Self.loggerDebug = loggerDebug + _ = Manager.shared + } + + /// 记录event + @objc + public class func logEvent(_ name: String, parameters: [String : Any]?) { + Manager.shared.logEvent(name, parameters: parameters) + } + + /// 中台ID。只在未获取到uid时可以为空 + @objc + public class func setUserID(_ userID: String?) { + setUserProperty(userID, forName: .uid) + } + + /// 设备ID(用户的设备ID,iOS取用户的IDFV或UUID,Android取androidID) + @objc + public class func setDeviceId(_ deviceId: String?) { + setUserProperty(deviceId, forName: .deviceId) + } + + /// adjust_id。只在未获取到adjust时可以为空 + @objc + public class func setAdjustId(_ adjustId: String?) { + setUserProperty(adjustId, forName: .adjustId) + } + + /// 广告 ID/广告标识符 (IDFA) + @objc + public class func setAdId(_ adId: String?) { + setUserProperty(adId, forName: .adId) + } + + /// 用户的pseudo_id + @objc + public class func setFirebaseId(_ firebaseId: String?) { + setUserProperty(firebaseId, forName: .firebaseId) + } + + /// screen name + @objc + public class func setScreen(_ name: String) { + Manager.shared.setScreen(name) + } + + /// 设置userproperty + @objc + public class func setUserProperty(_ value: String?, forName name: String) { + Manager.shared.setUserProperty(value ?? "", forName: name) + } + + /// 移除userproperty + @objc + public class func removeUserProperties(forNames names: [String]) { + Manager.shared.removeUserProperties(forNames: names) + } + + /// 获取events相关日志文件zip包 + /// zip解压密码:Castbox123 + @available(*, deprecated, renamed: "eventsLogsDirectory", message: "废弃,使用eventsLogsDirectory方法获取日志文件目录URL") + @objc + public class func eventsLogsArchive(_ callback: @escaping (_ url: URL?) -> Void) { + Manager.shared.eventsLogsArchive(callback) + } + + /// 获取events相关日志文件目录 + @objc + public class func eventsLogsDirectory(_ callback: @escaping (_ url: URL?) -> Void) { + Manager.shared.eventsLogsDirURL(callback) + } + + /// 更新events上报服务器域名 + /// host: 服务器域名,例如:“abc.bbb.com”, "https://abc.bbb.com", "http://abc.bbb.com" + @objc + public class func setEventsUploadEndPoint(host: String?) { + UserDefaults.eventsServerHost = host + } + + /// 获取events统计数据 + /// - Parameter callback: 数据回调 + /// - callback parameters: + /// - uploadedEventsCount: 上传后端成功event条数 + /// - loggedEventsCount: 已记录event总条数 + @objc + @available(*, deprecated, message: "used for debug, will be removed on any future released versions") + public class func debug_eventsStatistics(_ callback: @escaping (_ uploadedEventsCount: Int, _ loggedEventsCount: Int) -> Void) { + Manager.shared.debug_eventsStatistics(callback) + } + + /// 将内部事件信息上报给应用层 + /// - Parameter reportCallback: 数据回调 + /// - callback parameters: + /// - eventCode: 事件代码 + /// - info: 事件相关信息 + @objc + public class func registerInternalEventObserver(reportCallback: @escaping (_ eventCode: Int, _ info: String) -> Void) { + Manager.shared.registerInternalEventObserver(reportCallback: reportCallback) + } + + /// 获取当前user property + @objc + public class func getUserProperties() -> [String : String] { + return Manager.shared.getUserProperties() + } + + /// 设置上传开关,默认为true + /// true - 开启上传 + /// false - 关闭上传 + @objc + public class func setEnableUpload(isOn: Bool = true) -> Void { + enableUpload = isOn + } + +} diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Interface/GuruAnalytics.swift.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Interface/GuruAnalytics.swift.meta new file mode 100644 index 0000000..aa99e55 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Interface/GuruAnalytics.swift.meta @@ -0,0 +1,85 @@ +fileFormatVersion: 2 +guid: 669b744f21d994fd3b6fb7aeb95b0669 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Android: Android + second: + enabled: 0 + settings: + CPU: ARMv7 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + CPU: AnyCPU + DefaultValueInitialized: true + OS: AnyOS + - first: + Standalone: Linux64 + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + Standalone: OSXUniversal + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + Standalone: Win + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + Standalone: Win64 + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + iPhone: iOS + second: + enabled: 0 + settings: + AddToEmbeddedBinaries: false + CPU: AnyCPU + CompileFlags: + FrameworkDependencies: + - first: + tvOS: tvOS + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal.meta new file mode 100644 index 0000000..d23b566 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c6cbae57da78c46c7918b2bfd24d7335 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/DataModel.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/DataModel.meta new file mode 100644 index 0000000..22afaea --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/DataModel.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a9b9cc55c438041a7ae3ce46bd896d8d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/DataModel/Models.swift b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/DataModel/Models.swift new file mode 100644 index 0000000..e97322f --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/DataModel/Models.swift @@ -0,0 +1,228 @@ +// +// DBEntities.swift +// Alamofire +// +// Created by mayue on 2022/11/4. +// + +import Foundation +import CryptoSwift + +internal enum Entity { + +} + +extension Entity { + struct EventRecord: Codable { + + enum Priority: Int, Codable { + case EMERGENCE = 0 + case HIGH = 5 + case DEFAULT = 10 + case LOW = 15 + } + + enum TransitionStatus: Int, Codable { + case idle = 0 + case instransition = 1 + } + + let recordId: String + let eventName: String + let eventJson: String + ///单位毫秒 + let timestamp: Int64 + let priority: Priority + let transitionStatus: TransitionStatus + + init(eventName: String, event: Entity.Event, priority: Priority = .DEFAULT, transitionStatus: TransitionStatus = .idle) { + let now = Date() + let eventJson = event.asString ?? "" + if eventJson.isEmpty { + cdPrint("[WARNING] error for convert event to json") + } + self.recordId = "\(eventName)\(eventJson)\(now.timeIntervalSince1970)\(Int.random(in: Int.min...Int.max))".md5() + self.eventName = eventName + self.eventJson = eventJson + self.timestamp = event.timestamp + self.priority = priority + self.transitionStatus = transitionStatus + } + + init(recordId: String, eventName: String, eventJson: String, timestamp: Int64, priority: Int, transitionStatus: Int) { + self.recordId = recordId + self.eventName = eventName + self.eventJson = eventJson + self.timestamp = timestamp + self.priority = .init(rawValue: priority) ?? .DEFAULT + self.transitionStatus = .init(rawValue: transitionStatus) ?? .idle + } + + enum CodingKeys: String, CodingKey { + case recordId + case eventName + case eventJson + case timestamp + case priority + case transitionStatus + } + + static func createTableSql(with name: String) -> String { + return """ + CREATE TABLE IF NOT EXISTS \(name)( + \(CodingKeys.recordId.rawValue) TEXT UNIQUE NOT NULL PRIMARY KEY, + \(CodingKeys.eventName.rawValue) TEXT NOT NULL, + \(CodingKeys.eventJson.rawValue) TEXT NOT NULL, + \(CodingKeys.timestamp.rawValue) INTEGER, + \(CodingKeys.priority.rawValue) INTEGER, + \(CodingKeys.transitionStatus.rawValue) INTEGER); + """ + } + + func insertSql(to tableName: String) -> String { + return "INSERT INTO \(tableName) VALUES ('\(recordId)', '\(eventName)', '\(eventJson)', '\(timestamp)', '\(priority.rawValue)', '\(transitionStatus.rawValue)')" + } + } + +} + +extension Entity { + struct Event: Codable { + ///客户端中记录此事件的时间(采用世界协调时间,毫秒为单位) + let timestamp: Int64 + let event: String + let userInfo: UserInfo + let param: [String: EventValue] + let properties: [String: String] + let eventId: String + + enum CodingKeys: String, CodingKey { + case timestamp + case userInfo = "info" + case event, param, properties + case eventId + } + + init(timestamp: Int64, event: String, userInfo: UserInfo, parameters: [String : Any], properties: [String : String]) throws { + guard let normalizedEvent = Self.normalizeKey(event), + normalizedEvent == event else { + cdPrint("drop event because of illegal event name: \(event)") + cdPrint("standard: https://developers.google.com/android/reference/com/google/firebase/analytics/FirebaseAnalytics.Event") + throw NSError(domain: "cunstrcting event error", code: 0, userInfo: [NSLocalizedDescriptionKey : "illegal event name: \(event)"]) + } + self.eventId = UUID().uuidString.lowercased() + self.timestamp = timestamp + self.event = normalizedEvent + self.userInfo = userInfo + self.param = Self.normalizeParameters(parameters) + self.properties = properties + } + + static let maxParametersCount = 25 + static let maxKeyLength = 40 + static let maxParameterStringValueLength = 128 + + static func normalizeParameters(_ parameters: [String : Any]) -> [String : EventValue] { + var params = [String : EventValue]() + var count = 0 + parameters.sorted(by: { $0.key < $1.key }).forEach({ key, value in + + guard count < maxParametersCount else { + cdPrint("too many parameters") + cdPrint("standard: https://developers.google.com/android/reference/com/google/firebase/analytics/FirebaseAnalytics.Event") + return + } + + guard let normalizedKey = normalizeKey(key), + normalizedKey == key else { + cdPrint("drop event parameter because of illegal key: \(key)") + cdPrint("standard: https://developers.google.com/android/reference/com/google/firebase/analytics/FirebaseAnalytics.Event") + return + } + + if let value = value as? String { + params[normalizedKey] = Entity.EventValue(stringValue: String(value.prefix(maxParameterStringValueLength))) + } else if let value = value as? Int { + params[normalizedKey] = Entity.EventValue(longValue: Int64(value)) + } else if let value = value as? Int64 { + params[normalizedKey] = Entity.EventValue(longValue: value) + } else if let value = value as? Double { + params[normalizedKey] = Entity.EventValue(doubleValue: value) + } else { + params[normalizedKey] = Entity.EventValue(stringValue: String("\(value)".prefix(maxParameterStringValueLength))) + } + + count += 1 + }) + + return params + } + + static func normalizeKey(_ key: String) -> String? { + var mutableKey = key + + while let first = mutableKey.first, + !first.isLetter { + _ = mutableKey.removeFirst() + } + + var normalizedKey = "" + var count = 0 + mutableKey.forEach { c in + guard count < maxKeyLength, + c.isAlphabetic || c.isDigit || c == "_" else { return } + normalizedKey.append(c) + count += 1 + } + + return normalizedKey.isEmpty ? nil : normalizedKey + } + } + + ///用户信息 + struct UserInfo: Codable { + ///中台ID。只在未获取到uid时可以为空 + let uid: String? + ///设备ID(用户的设备ID,iOS取用户的IDFV或UUID,Android取androidID) + let deviceId: String? + ///adjust_id。只在未获取到adjust时可以为空 + let adjustId: String? + ///广告 ID/广告标识符 (IDFA) + let adId: String? + ///用户的pseudo_id + let firebaseId: String? + + enum CodingKeys: String, CodingKey { + case deviceId + case uid + case adjustId + case adId + case firebaseId + } + } + + // 参数对应的值 + struct EventValue: Codable { + let stringValue: String? // 事件参数的字符串值 + let longValue: Int64? // 事件参数的整数值 + let doubleValue: Double? // 事件参数的小数值。注意:APP序列化成JSON时,注意不要序列化成科学计数法 + + init(stringValue: String? = nil, longValue: Int64? = nil, doubleValue: Double? = nil) { + self.stringValue = stringValue + self.longValue = longValue + self.doubleValue = doubleValue + } + + enum CodingKeys: String, CodingKey { + case stringValue = "s" + case longValue = "i" + case doubleValue = "d" + } + } +} + +extension Entity { + struct SystemTimeResult: Codable { + let data: Int64 + } +} diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/DataModel/Models.swift.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/DataModel/Models.swift.meta new file mode 100644 index 0000000..b9cdcfa --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/DataModel/Models.swift.meta @@ -0,0 +1,85 @@ +fileFormatVersion: 2 +guid: f63b0ff90afd0409781c1266dc618875 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Android: Android + second: + enabled: 0 + settings: + CPU: ARMv7 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + CPU: AnyCPU + DefaultValueInitialized: true + OS: AnyOS + - first: + Standalone: Linux64 + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + Standalone: OSXUniversal + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + Standalone: Win + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + Standalone: Win64 + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + iPhone: iOS + second: + enabled: 0 + settings: + AddToEmbeddedBinaries: false + CPU: AnyCPU + CompileFlags: + FrameworkDependencies: + - first: + tvOS: tvOS + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database.meta new file mode 100644 index 0000000..653b7c0 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bb7dde11f0ad6496ca231330136b7b61 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database/Database.swift b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database/Database.swift new file mode 100644 index 0000000..0ef7c10 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database/Database.swift @@ -0,0 +1,391 @@ +// +// Database.swift +// GuruAnalytics_iOS +// +// Created by mayue on 2022/11/4. +// Copyright © 2022 Guru Network Limited. All rights reserved. +// + +import Foundation +import RxSwift +import RxCocoa +import FMDB + +internal class Database { + + typealias PropertyName = GuruAnalytics.PropertyName + + enum TableName: String, CaseIterable { + case event = "event" + } + + private let dbIOQueue = DispatchQueue.init(label: "com.guru.analytics.db.io.queue", qos: .userInitiated) + + private let dbQueueRelay = BehaviorRelay(value: nil) + private let bag = DisposeBag() + + /// 更新数据库表结构后,需要更新数据库版本 + private let currentDBVersion = DBVersionHistory.v_3 + + private var dbVersion: Database.DBVersionHistory { + get { + if let v = UserDefaults.defaults?.value(forKey: UserDefaults.dbVersionKey) as? String, + let dbV = Database.DBVersionHistory.init(rawValue: v) { + return dbV + } else { + return .initialVersion + } + + } + set { + UserDefaults.defaults?.set(newValue.rawValue, forKey: UserDefaults.dbVersionKey) + } + } + + internal init() { + + dbIOQueue.async { [weak self] in + + guard let `self` = self else { return } + + let applicationSupportPath = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, + .userDomainMask, + true).last! + "/GuruAnalytics" + + if !FileManager.default.fileExists(atPath: applicationSupportPath) { + do { + try FileManager.default.createDirectory(atPath: applicationSupportPath, withIntermediateDirectories: true) + } catch { + assertionFailure("create db path error: \(error)") + } + } + + let dbPath = applicationSupportPath + "/analytics.db" + let queue = FMDatabaseQueue(url: URL(fileURLWithPath: dbPath))! + cdPrint("database path: \(queue.path ?? "")") + + self.createEventTable(in: queue) + .filter { $0 } + .flatMap { _ in + self.migrateDB(in: queue).asMaybe() + } + .flatMap({ _ in + self.resetAllTransitionStatus(in: queue).asMaybe() + }) + .subscribe(onSuccess: { _ in + self.dbQueueRelay.accept(queue) + }) + .disposed(by: self.bag) + } + + } +} + +internal extension Database { + + func addEventRecords(_ events: Entity.EventRecord) -> Single { + cdPrint(#function) + return mapTransactionToSingle { (db) in + try db.executeUpdate(events.insertSql(to: TableName.event.rawValue), values: nil) + } + .do(onSuccess: { [weak self] (_) in + guard let `self` = self else { return } + NotificationCenter.default.post(name: self.tableUpdateNotification(TableName.event.rawValue), object: nil) + }) + } + + func fetchEventRecordsToUpload(limit: Int) -> Single<[Entity.EventRecord]> { + return mapTransactionToSingle { (db) in + let querySQL: String = +""" +SELECT * FROM \(TableName.event.rawValue) +WHERE \(Entity.EventRecord.CodingKeys.transitionStatus.rawValue) IS NULL +OR \(Entity.EventRecord.CodingKeys.transitionStatus.rawValue) != \(Entity.EventRecord.TransitionStatus.instransition.rawValue) +ORDER BY \(Entity.EventRecord.CodingKeys.priority.rawValue) ASC, \(Entity.EventRecord.CodingKeys.timestamp.rawValue) ASC +LIMIT \(limit) +""" + cdPrint(#function + "query sql: \(querySQL)") + let results = try db.executeQuery(querySQL, values: nil) //[ASC | DESC] + var t: [Entity.EventRecord] = [] + while results.next() { + guard let recordId = results.string(forColumnIndex: 0), + let eventName = results.string(forColumnIndex: 1), + let eventJson = results.string(forColumnIndex: 2) else { + continue + } + + let priority: Int = results.columnIsNull(Entity.EventRecord.CodingKeys.priority.rawValue) ? + Entity.EventRecord.Priority.DEFAULT.rawValue : Int(results.int(forColumn: Entity.EventRecord.CodingKeys.priority.rawValue)) + + let ts: Int = results.columnIsNull(Entity.EventRecord.CodingKeys.transitionStatus.rawValue) ? + Entity.EventRecord.TransitionStatus.idle.rawValue : Int(results.int(forColumn: Entity.EventRecord.CodingKeys.transitionStatus.rawValue)) + + let record = Entity.EventRecord(recordId: recordId, eventName: eventName, eventJson: eventJson, + timestamp: results.longLongInt(forColumn: Entity.EventRecord.CodingKeys.timestamp.rawValue), + priority: priority, transitionStatus: ts) + t.append(record) + } + + results.close() + + try t.forEach { record in + let updateSQL = +""" +UPDATE \(TableName.event.rawValue) +SET \(Entity.EventRecord.CodingKeys.transitionStatus.rawValue) = \(Entity.EventRecord.TransitionStatus.instransition.rawValue) +WHERE \(Entity.EventRecord.CodingKeys.recordId.rawValue) = '\(record.recordId)' +""" + try db.executeUpdate(updateSQL, values: nil) + } + + return t + } + } + + func deleteEventRecords(_ recordIds: [String]) -> Single { + guard !recordIds.isEmpty else { + return .just(()) + } + cdPrint(#function + "\(recordIds)") + return mapTransactionToSingle { db in + try recordIds.forEach { item in + try db.executeUpdate("DELETE FROM \(TableName.event.rawValue) WHERE \(Entity.EventRecord.CodingKeys.recordId.rawValue) = '\(item)'", values: nil) + } + } + .do(onSuccess: { [weak self] (_) in + guard let `self` = self else { return } + NotificationCenter.default.post(name: self.tableUpdateNotification(TableName.event.rawValue), object: nil) + }, onError: { error in + cdPrint("\(#function) error: \(error)") + }) + } + + func removeOutdatedEventRecords(earlierThan: Int64) -> Single { + return mapTransactionToSingle { db in + let sql = """ +DELETE FROM \(TableName.event.rawValue) +WHERE \(Entity.EventRecord.CodingKeys.timestamp.rawValue) < \(earlierThan) +""" + try db.executeUpdate(sql, values: nil) + } + .do(onSuccess: { [weak self] (_) in + guard let `self` = self else { return } + NotificationCenter.default.post(name: self.tableUpdateNotification(TableName.event.rawValue), object: nil) + }, onError: { error in + cdPrint("\(#function) error: \(error)") + }) + } + + func resetTransitionStatus(for recordIds: [String]) -> Single { + guard !recordIds.isEmpty else { + return .just(()) + } + cdPrint(#function + "\(recordIds)") + return mapTransactionToSingle { db in + try recordIds.forEach { item in + let updateSQL = +""" +UPDATE \(TableName.event.rawValue) +SET \(Entity.EventRecord.CodingKeys.transitionStatus.rawValue) = \(Entity.EventRecord.TransitionStatus.idle.rawValue) +WHERE \(Entity.EventRecord.CodingKeys.recordId.rawValue) = '\(item)' +""" + try db.executeUpdate(updateSQL, values: nil) + } + } + .do(onSuccess: { [weak self] (_) in + guard let `self` = self else { return } + NotificationCenter.default.post(name: self.tableUpdateNotification(TableName.event.rawValue), object: nil) + }, onError: { error in + cdPrint("\(#function) error: \(error)") + }) + } + + func uploadableEventRecordCount() -> Single { + return mapTransactionToSingle { db in + let querySQL = +""" +SELECT count(*) as Count FROM \(TableName.event.rawValue) +WHERE \(Entity.EventRecord.CodingKeys.transitionStatus.rawValue) IS NULL +OR \(Entity.EventRecord.CodingKeys.transitionStatus.rawValue) != \(Entity.EventRecord.TransitionStatus.instransition.rawValue) +""" + let result = try db.executeQuery(querySQL, values: nil) + var count = 0 + while result.next() { + count = Int(result.int(forColumn: "Count")) + } + result.parentDB = nil + result.close() + return count + } + } + + func uploadableEventRecordCountOb() -> Observable { + return NotificationCenter.default.rx.notification(tableUpdateNotification(TableName.event.rawValue)) + .startWith(Notification(name: tableUpdateNotification(TableName.event.rawValue))) + .flatMap({ [weak self] (_) -> Observable in + guard let `self` = self else { + return Observable.empty() + } + return self.uploadableEventRecordCount().asObservable() + }) + } + + func hasFgEventRecord() -> Single { + return mapTransactionToSingle { db in + let querySQL = +""" +SELECT count(*) as Count FROM \(TableName.event.rawValue) +WHERE \(Entity.EventRecord.CodingKeys.eventName.rawValue) == '\(GuruAnalytics.fgEvent.name)' +""" + let result = try db.executeQuery(querySQL, values: nil) + var count = 0 + while result.next() { + count = Int(result.int(forColumn: "Count")) + } + result.parentDB = nil + result.close() + return count > 0 + } + } + +} + +private extension Database { + func createEventTable(in queue: FMDatabaseQueue) -> Single { + return mapTransactionToSingle(queue: queue) { db in + db.executeStatements(Entity.EventRecord.createTableSql(with: TableName.event.rawValue)) + } + .do(onSuccess: { result in + cdPrint("createEventTable result: \(result)") + }, onError: { error in + cdPrint("createEventTable error: \(error)") + }) + } + + func mapTransactionToSingle(_ transaction: @escaping ((FMDatabase) throws -> T)) -> Single { + return dbQueueRelay.compactMap({ $0 }) + .take(1) + .asSingle() + .flatMap { [unowned self] queue -> Single in + return self.mapTransactionToSingle(queue: queue, transaction) + } + } + + func mapTransactionToSingle(queue: FMDatabaseQueue, _ transaction: @escaping ((FMDatabase) throws -> T)) -> Single { + return Single.create { [weak self] (subscriber) -> Disposable in + self?.dbIOQueue.async { + queue.inDeferredTransaction { (db, rollback) in + do { + let data = try transaction(db) + subscriber(.success(data)) + } catch { + rollback.pointee = true + cdPrint("inDeferredTransaction failed: \(error.localizedDescription)") + subscriber(.failure(error)) + } + } + } + return Disposables.create() + } + } + + func tableUpdateNotification(_ tableName: String) -> Notification.Name { + return Notification.Name("Guru.Analytics.DB.Table.update-\(tableName)") + } + + func migrateDB(in queue: FMDatabaseQueue) -> Single { + + return mapTransactionToSingle(queue: queue) { [weak self] db in + + guard let `self` = self else { return } + + while let nextVersion = self.dbVersion.nextVersion, + self.dbVersion < self.currentDBVersion { + switch nextVersion { + case .v_1: + () + case .v_2: + /// v_1 -> v_2 + /// event表增加priority列 + if !db.columnExists(Entity.EventRecord.CodingKeys.priority.rawValue, inTableWithName: TableName.event.rawValue) { + db.executeStatements(""" +ALTER TABLE \(TableName.event.rawValue) +ADD \(Entity.EventRecord.CodingKeys.priority.rawValue) Integer DEFAULT \(Entity.EventRecord.Priority.DEFAULT.rawValue) +""") + } + + case .v_3: + /// v_2 -> v_3 + /// event表增加transitionStatus列 + if !db.columnExists(Entity.EventRecord.CodingKeys.transitionStatus.rawValue, inTableWithName: TableName.event.rawValue) { + db.executeStatements(""" +ALTER TABLE \(TableName.event.rawValue) +ADD \(Entity.EventRecord.CodingKeys.transitionStatus.rawValue) Integer DEFAULT \(Entity.EventRecord.TransitionStatus.idle.rawValue) +""") + } + + } + self.dbVersion = nextVersion + + } + + } + .do(onError: { error in + cdPrint("migrate db error: \(error)") + }) + + } + + func resetAllTransitionStatus(in queue: FMDatabaseQueue) -> Single { + return mapTransactionToSingle(queue: queue) { db in + let updateSQL = +""" +UPDATE \(TableName.event.rawValue) +SET \(Entity.EventRecord.CodingKeys.transitionStatus.rawValue) = \(Entity.EventRecord.TransitionStatus.idle.rawValue) +""" + try db.executeUpdate(updateSQL, values: nil) + } + .do(onSuccess: { [weak self] (_) in + guard let `self` = self else { return } + NotificationCenter.default.post(name: self.tableUpdateNotification(TableName.event.rawValue), object: nil) + }, onError: { error in + cdPrint("\(#function) error: \(error)") + }) + } + +} + +fileprivate extension Array where Element == String { + + var joinedStringForSQL: String { + return self.map { "'\($0)'" }.joined(separator: ",") + } + +} + +private extension Database { + + enum DBVersionHistory: String, Comparable { + case v_1 + case v_2 + case v_3 + } +} + +extension Database.DBVersionHistory { + + static func < (lhs: Database.DBVersionHistory, rhs: Database.DBVersionHistory) -> Bool { + return lhs.versionNumber < rhs.versionNumber + } + + + var versionNumber: Int { + return Int(String(self.rawValue.split(separator: "_")[1])) ?? 1 + } + + var nextVersion: Self? { + return .init(rawValue: "v_\(versionNumber + 1)") + } + + static let initialVersion: Self = .v_1 +} diff --git a/Runtime/GuruAnalytics/Plugins/Android/guru-analytics-1.0.1.aar.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database/Database.swift.meta similarity index 58% rename from Runtime/GuruAnalytics/Plugins/Android/guru-analytics-1.0.1.aar.meta rename to Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database/Database.swift.meta index 723f7d9..eef265a 100644 --- a/Runtime/GuruAnalytics/Plugins/Android/guru-analytics-1.0.1.aar.meta +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database/Database.swift.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: b0e58ca75957d470cbc4951b34f31bf8 +guid: cc72ba03590e04e4db8e3d9eb675d0d2 PluginImporter: externalObjects: {} serializedVersion: 2 @@ -12,10 +12,17 @@ PluginImporter: validateReferences: 1 platformData: - first: - Android: Android + : Any second: - enabled: 1 - settings: {} + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 - first: Any: second: @@ -27,6 +34,16 @@ PluginImporter: enabled: 0 settings: DefaultValueInitialized: true + - first: + iPhone: iOS + second: + enabled: 0 + settings: {} + - first: + tvOS: tvOS + second: + enabled: 1 + settings: {} userData: assetBundleName: assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database/Manager.swift b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database/Manager.swift new file mode 100644 index 0000000..ef35122 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database/Manager.swift @@ -0,0 +1,681 @@ +// +// Manager.swift +// GuruAnalytics_iOS +// +// Created by 袁仕崇 on 16/11/22. +// + +import Foundation +import RxCocoa +import RxSwift + +internal class Manager { + + // MARK: - temporary, will be removed soon + @available(*, deprecated, message: "used for debug, will be removed on any future released versions") + private var loggedEventsCount: Int = 0 + + @available(*, deprecated, message: "used for debug, will be removed on any future released versions") + private func accumulateLoggedEventsCount(_ count: Int) { + loggedEventsCount += count + } + + @available(*, deprecated, message: "used for debug, will be removed on any future released versions") + private var uploadedEventsCount: Int = 0 + + @available(*, deprecated, message: "used for debug, will be removed on any future released versions") + private func accumulateUploadedEventsCount(_ count: Int) { + uploadedEventsCount += count + } + + @available(*, deprecated, message: "used for debug, will be removed on any future released versions") + internal func debug_eventsStatistics(_ callback: @escaping (_ uploadedEventsCount: Int, _ loggedEventsCount: Int) -> Void) { + callback(uploadedEventsCount, loggedEventsCount) + } + + // MARK: - internal members + + internal static let shared = Manager() + + /// 时间维度,默认每1分钟后批量上传1次 + private var scheduleInterval: TimeInterval = GuruAnalytics.uploadPeriodInSecond + + /// 数量维度,默认满25条批量上传1次 + private var numberOfCountPerConsume: Int = GuruAnalytics.batchLimit + + /// event过期时间,默认7天 + private var eventExpiredIntervel: TimeInterval = GuruAnalytics.eventExpiredSeconds + + private var initializeTimeout: Double = GuruAnalytics.initializeTimeout + + /// 根据时差计算的当前服务端时间 + internal var serverNowMs: Int64 { serverInitialMs + (Date.absoluteTimeMs - serverSyncedAtAbsoluteMs)} + + // MARK: - private members + + private typealias PropertyName = GuruAnalytics.PropertyName + + private let bag = DisposeBag() + + private let db = Database() + + private let ntwkMgr = NetworkManager() + + /// 生成background 任务时,将 key 和当前任务的 disposeable 一一对应 + private var taskKeyDisposableMap: [Int: Disposable] = [:] + + /// 从数据库中一次性拉取最多条数 + private var maxEventFetchingCount: Int = 100 + + /// 工作队列 + private let workQueue = DispatchQueue.init(label: "com.guru.analytics.manager.work.queue", qos: .userInitiated) + ///网络服务队列 + private lazy var rxNetworkScheduler = SerialDispatchQueueScheduler(qos: .default, internalSerialQueueName: "com.guru.analytics.manager.rx.network.queue") + private lazy var rxConsumeScheduler = SerialDispatchQueueScheduler(qos: .default, internalSerialQueueName: "com.guru.analytics.manager.rx.consume.queue") + + private lazy var rxWorkScheduler = SerialDispatchQueueScheduler.init(queue: workQueue, internalSerialQueueName: "com.guru.analytics.manager.rx.work.queue") + private let bgWorkQueue = DispatchQueue.init(label: "com.guru.analytics.manager.background.work.queue", qos: .background) + private lazy var rxBgWorkScheduler = SerialDispatchQueueScheduler.init(queue: bgWorkQueue, internalSerialQueueName: "com.guru.analytics.manager.background.work.queue") + + /// 过期event记录已清除 + private let outdatedEventsCleared = BehaviorSubject(value: false) + + /// 服务端时间 + private var serverInitialMs = Date().msSince1970 { + didSet { + serverSyncedAtAbsoluteMs = Date.absoluteTimeMs + } + } + private var serverSyncedAtAbsoluteMs = Date.absoluteTimeMs + private let startAt = Date() + /// 服务器时间已同步信号 + private let _serverTimeSynced = BehaviorRelay(value: false) + private var serverNowMsSingle: Single { + + guard _serverTimeSynced.value == false else { + return .just(serverNowMs) + } + return _serverTimeSynced.observe(on: rxNetworkScheduler) + .filter { $0 } + .take(1).asSingle() + .timeout(.seconds(10), scheduler: rxNetworkScheduler) + .catchAndReturn(false) + .map({ [weak self] _ in + return self?.serverNowMs ?? 0 + }) + } + + /// 统计fg起始时间 + private var fgStartAtAbsoluteMs = Date.absoluteTimeMs + private var fgAccumulateTimer: Disposable? = nil + + /// 内存中user property 信息 + private var userProperty: Observable<[String : String]> { + let p = userPropertyUpdated.startWith(()).observe(on: rxWorkScheduler).flatMap { [weak self] _ -> Observable<[String : String]> in + guard let `self` = self else { return .just([:]) } + return .create({ subscriber in + subscriber.onNext(self._userProperty) + subscriber.onCompleted() +// debugPrint("userProperty thread queueName: \(Thread.current.queueName)") + return Disposables.create() + }) + } + let latency = self.initializeTimeout - Date().timeIntervalSince(self.startAt) + let intLatency = Int(latency) + + guard latency > 0 else { + return p + } + + return p.filter({ property in + /// 需要等待以下userproperty已设置 + /// PropertyName.deviceId + /// PropertyName.uid + /// PropertyName.firebaseId + guard let deviceId = property[PropertyName.deviceId.rawValue], !deviceId.isEmpty, + let uid = property[PropertyName.uid.rawValue], !uid.isEmpty, + let firebaseId = property[PropertyName.firebaseId.rawValue], !firebaseId.isEmpty else { + return false + } + return true + }) + .timeout(.milliseconds(intLatency), scheduler: rxNetworkScheduler) + .catch { _ in + return p + } + } + private var _userProperty: [String : String] = [:] { + didSet { + userPropertyUpdated.onNext(()) + } + } + private var userPropertyUpdated = PublishSubject() + + /// 同步服务器时间触发器 + private let syncServerTrigger = PublishSubject() + + /// 轮询上传event任务 + private var pollingUploadTask: Disposable? + + /// 重置轮询上传触发器 + private let reschedulePollingTrigger = BehaviorSubject(value: ()) + + /// 记录events相关的logger + private lazy var eventsLogger: LoggerManager = { + let l = LoggerManager(logCategoryName: "eventLogs") + return l + }() + + /// 将错误上报给上层的 + private typealias InternalEventReporter = ((_ eventCode: Int, _ info: String) -> Void) + private var internalEventReporter: InternalEventReporter? + + private init() { + + // first open + logFirstOpenIfNeeded() + + // 监听事件 + setupOberving() + + // 检查旧数据 + clearOutdatedEventsIfNeeded() + + // 设置轮询上传任务 + setupPollingUpload() + + // 先打一个fg + logFirstFgEvent() + + ntwkMgr.networkErrorReporter = self + } +} + +// MARK: - internal functions +internal extension Manager { + + func logEvent(_ eventName: String, parameters: [String : Any]?, priority: Entity.EventRecord.Priority = .DEFAULT) { + _ = _logEvent(eventName, parameters: parameters, priority: priority) + .subscribe() + .disposed(by: bag) + } + + func setUserProperty(_ value: String, forName name: String) { + eventsLogger.verbose(#function + "name: \(name) value: \(value)") + workQueue.async { [weak self] in + self?._userProperty[name] = value + } + } + + func removeUserProperties(forNames names: [String]) { + eventsLogger.verbose(#function + "names: \(names)") + workQueue.async { [weak self] in + guard let `self` = self else { return } + var temp = self._userProperty + for name in names { + temp.removeValue(forKey: name) + } + self._userProperty = temp + } + } + + func setScreen(_ name: String) { + setUserProperty(name, forName: PropertyName.screen.rawValue) + } + + private func constructEvent(_ eventName: String, + parameters: [String : Any]?, + timestamp: Int64, + priority: Entity.EventRecord.Priority) -> Single { + + return userProperty.take(1).observe(on: rxWorkScheduler).asSingle().flatMap { p in + .create { subscriber in + do { + debugPrint("userProperty thread queueName: \(Thread.current.queueName) count: \(p.count)") + var userProperty = p + var eventParam = parameters ?? [:] + + // append screen + if let screen = userProperty.removeValue(forKey: PropertyName.screen.rawValue) { + eventParam[PropertyName.screen.rawValue] = screen + } + + let userInfo = Entity.UserInfo( + uid: userProperty.removeValue(forKey: PropertyName.uid.rawValue), + deviceId: userProperty.removeValue(forKey: PropertyName.deviceId.rawValue), + adjustId: userProperty.removeValue(forKey: PropertyName.adjustId.rawValue), + adId: userProperty.removeValue(forKey: PropertyName.adId.rawValue), + firebaseId: userProperty.removeValue(forKey: PropertyName.firebaseId.rawValue) + ) + + let event = try Entity.Event(timestamp: timestamp, + event: eventName, + userInfo: userInfo, + parameters: eventParam, + properties: userProperty) + let eventRecord = Entity.EventRecord(eventName: event.event, event: event, priority: priority) + subscriber(.success(eventRecord)) + } catch { + subscriber(.failure(error)) + } + return Disposables.create() + } + } + } + + func eventsLogsArchive(_ callback: @escaping (URL?) -> Void) { + eventsLogger.logFilesZipArchive() + .subscribe(onSuccess: { url in + callback(url) + }, onFailure: { error in + callback(nil) + cdPrint("events logs archive error: \(error)") + }) + .disposed(by: bag) + } + + func eventsLogsDirURL(_ callback: @escaping (URL?) -> Void) { + eventsLogger.logFilesDirURL() + .subscribe(onSuccess: { url in + callback(url) + }, onFailure: { error in + callback(nil) + cdPrint("events logs archive error: \(error)") + }) + .disposed(by: bag) + } + + func registerInternalEventObserver(reportCallback: @escaping (_ eventCode: Int, _ info: String) -> Void) { + self.internalEventReporter = reportCallback + } + + func getUserProperties() -> [String : String] { + return _userProperty + } +} + +// MARK: - private functions +private extension Manager { + + func setupOberving() { + + syncServerTrigger + .debounce(.seconds(1), scheduler: rxConsumeScheduler) + .subscribe(onNext: { [weak self] _ in + self?.syncServerTime() + }) + .disposed(by: bag) + + var activeNoti = NotificationCenter.default.rx.notification(UIApplication.didBecomeActiveNotification) + + if UIApplication.shared.applicationState == .active { + activeNoti = activeNoti.startWith(.init(name: UIApplication.didBecomeActiveNotification)) + } + + activeNoti + .subscribe(onNext: { [weak self] _ in + self?.syncServerTrigger.onNext(()) + // fg计时器 + self?.setupFgAccumulateTimer() + }) + .disposed(by: bag) + + NotificationCenter.default.rx.notification(UIApplication.didEnterBackgroundNotification) + .subscribe(onNext: { [weak self] _ in + guard let `self` = self else { return } + //这里log fg和上传events任务并行关系改为前后依赖关系 + _ = self.logForegroundDuration() + .catchAndReturn(()) + .map { self.consumeEvents() } + .subscribe() + self._serverTimeSynced.accept(false) + self.invalidFgAccumulateTimer() + }) + .disposed(by: bag) + } + + func syncServerTime() { + //有网时立即同步,无网时等待有网后同步 + ntwkMgr.reachableObservable.filter { $0 }.map { _ in }.take(1).asSingle() + .flatMap { [weak self] _ -> Single in + guard let `self` = self else { return Observable.empty().asSingle()} + return self.ntwkMgr.syncServerTime() + } + .observe(on: rxNetworkScheduler) + .subscribe(onSuccess: { [weak self] ms in + self?.serverInitialMs = ms + self?._serverTimeSynced.accept(true) + }) + .disposed(by: bag) + } + + func logForegroundDuration() -> Single { + return _logEvent(GuruAnalytics.fgEvent.name, parameters: [GuruAnalytics.fgEvent.paramKeyType.duration.rawValue : fgDurationMs()]) + .observe(on: MainScheduler.asyncInstance) + .do(onSuccess: { _ in + UserDefaults.fgAccumulatedDuration = 0 + }) + } + + func clearOutdatedEventsIfNeeded() { + + /// 1. 删除过期的数据 + serverNowMsSingle + .flatMap({ [weak self] serverNowMs -> Single in + guard let `self` = self else { return .just(()) } + let earlierThan: Int64 = serverNowMs - self.eventExpiredIntervel.int64Ms + return self.db.removeOutdatedEventRecords(earlierThan: earlierThan) + }) + .catch({ error in + cdPrint("remove outdated records error: \(error)") + return .just(()) + }) + .subscribe(onSuccess: { [weak self] _ in + self?.outdatedEventsCleared.onNext(true) + }) + .disposed(by: bag) + } + + func logFirstOpenIfNeeded() { + + if let t = UserDefaults.defaults?.value(forKey: UserDefaults.firstOpenTimeKey), + let firstOpenTimeMs = t as? Int64 { + setUserProperty("\(firstOpenTimeMs)", forName: PropertyName.firstOpenTime.rawValue) + } else { + /// log first open event + logEvent(GuruAnalytics.firstOpenEvent.name, parameters: nil, priority: .EMERGENCE) + + /// save first open time + /// set to userProperty + let firstOpenAt = Date() + + let saveFirstOpenTime = { [weak self] (ms: Int64) -> Void in + UserDefaults.defaults?.set(ms, forKey: UserDefaults.firstOpenTimeKey) + self?.setUserProperty("\(ms)", forName: PropertyName.firstOpenTime.rawValue) + } + + serverNowMsSingle + .subscribe(onSuccess: { _ in + let latency = Date().timeIntervalSince(firstOpenAt) + let adjustedFirstOpenTimeMs = self.serverInitialMs - latency.int64Ms + saveFirstOpenTime(adjustedFirstOpenTimeMs) + }, onFailure: { error in + cdPrint("waiting for server time syncing error: \(error)") + saveFirstOpenTime(firstOpenAt.timeIntervalSince1970.int64Ms) + }) + .disposed(by: bag) + } + + } + + func _logEvent(_ eventName: String, parameters: [String : Any]?, priority: Entity.EventRecord.Priority = .DEFAULT) -> Single { + eventsLogger.verbose(#function + " eventName: \(eventName)" + " params: \(parameters?.jsonString() ?? "")") + return { [weak self] () -> Single in + guard let `self` = self else { return Observable.empty().asSingle() } + return self.serverNowMsSingle + .flatMap { self.constructEvent(eventName, parameters: parameters, timestamp: $0, priority: priority) } + .flatMap { self.db.addEventRecords($0) } + .do(onSuccess: { _ in + self.accumulateLoggedEventsCount(1) + self.eventsLogger.verbose("log event success") + }, onError: { error in + self.eventsLogger.error("log event error: \(error)") + }) + }() + } + +} + +// MARK: - 轮询上传相关 +private extension Manager { + + typealias TaskCallback = (() -> Void) + typealias Task = ((@escaping TaskCallback, Int) -> Void) + + func performBackgroundTask(task: @escaping Task) -> Single { + return Single.create { [weak self] subscriber in + var backgroundTaskID: UIBackgroundTaskIdentifier? + + let stopTaskHandler = { + ///结束任务时需要找到对应的 dispose 取消当前任务 + guard let taskId = backgroundTaskID, + let disposable = self?.taskKeyDisposableMap[taskId.rawValue] else { + return + } + cdPrint("[performBackgroundTask] performBackgroundTask expired: \(backgroundTaskID?.rawValue ?? -1)") + disposable.dispose() + } + + // Request the task assertion and save the ID. + backgroundTaskID = UIApplication.shared.beginBackgroundTask (withName: "com.guru.analytics.manager.background.task", expirationHandler: { + // End the task if time expires. + self?.eventsLogger.verbose("performBackgroundTask expirationHandler: \(backgroundTaskID?.rawValue ?? -1)") + stopTaskHandler() + }) + + self?.eventsLogger.verbose("performBackgroundTask start: \(backgroundTaskID?.rawValue ?? -1)") + if let taskID = backgroundTaskID { + task({ + self?.eventsLogger.verbose("performBackgroundTask finish: \(taskID.rawValue)") + subscriber(.success(())) + }, taskID.rawValue) + } + + return Disposables.create { + if var taskID = backgroundTaskID { + self?.eventsLogger.verbose("performBackgroundTask dispose: \(taskID.rawValue)") + UIApplication.shared.endBackgroundTask(taskID) + taskID = .invalid + backgroundTaskID = nil + } + } + } + .subscribe(on: rxBgWorkScheduler) + } + + /// 上传数据库中的event + func consumeEvents() { + guard GuruAnalytics.enableUpload else { + return + } + self.eventsLogger.verbose("consumeEvents start") + performBackgroundTask { [weak self] callback, taskId in + + guard let `self` = self else { return } + cdPrint("consumeEvents start background task") + // 等待清理过期记录完成 + let disposable = outdatedEventsCleared + .filter { $0 } + .take(1) + .observe(on: rxBgWorkScheduler) + .asSingle() + .flatMap { _ -> Single<[Entity.EventRecord]> in + self.eventsLogger.verbose("consumeEvents fetchEventRecordsToUpload") + ///step1: 拉取数据库记录 + return self.db.fetchEventRecordsToUpload(limit: self.maxEventFetchingCount) + } + .map { records -> [[Entity.EventRecord]] in + /// step2: 将event数组分割成若干批次,numberOfCountPerConsume个一批 + /// self.eventsLogger.verbose("consumeEvents fetchEventRecordsToUpload") + self.eventsLogger.verbose("consumeEvents fetchEventRecordsToUpload result: \(records.count)") + return records.chunked(into: self.numberOfCountPerConsume) + } + .flatMap({ batches -> Single<[[Entity.EventRecord]]> in + + guard batches.count > 0 else { return .just([]) } + + /// 监听网络信号 + return self.ntwkMgr.reachableObservable.filter { $0 } + .take(1).asSingle() + .map { _ in batches } + }) + .map { batches -> [Single<[String]>] in + /// step3: 转为批次上传任务 + self.eventsLogger.verbose("consumeEvents uploadEvents") + return batches.map { records in + return self.ntwkMgr.uploadEvents(records) + .do(onSuccess: { t in + self.eventsLogger.verbose("consumeEvents upload events succeed: \(t.eventsJson)") + }) + .catch({ error in + self.eventsLogger.error("consumeEvents upload events error: \(error)") + // 上传失败,移除对应的缓存ID + let recordIds = records.map { $0.recordId } + return self.db.resetTransitionStatus(for: recordIds) + .map { _ in ([], "") } + }) + .map { $0.recordIDs } + } + } + .flatMap { uploadBatches -> Single<[String]> in + guard uploadBatches.count > 0 else { return .just([]) } + /// 合并上传结果 + return Observable.from(uploadBatches) + .merge() + .toArray().map { batches -> [String] in batches.flatMap { $0 } } + } + .flatMap { recordIDs -> Single in + self.accumulateUploadedEventsCount(recordIDs.count) + /// step4: 删除数据库中对应记录 + return self.db.deleteEventRecords(recordIDs) + .catch { error in + cdPrint("consumeEvents delete events from DB error: \(error)") + return .just(()) + } + } + .observe(on: self.rxBgWorkScheduler) + .subscribe(onFailure: { error in + cdPrint("consumeEvents error: \(error)") + }, onDisposed: { [weak self] in + self?.taskKeyDisposableMap.removeValue(forKey: taskId) + cdPrint("consumeEvents onDisposed") + callback() + }) + + taskKeyDisposableMap[taskId] = disposable + } + .subscribe() + .disposed(by: bag) + + } + + func startPollingUpload() { + pollingUploadTask?.dispose() + pollingUploadTask = nil + + // 每scheduleInterval时间间隔启动一次,立即启动一次 + let timer = Observable.timer(.seconds(0), period: .milliseconds(Int(scheduleInterval.int64Ms)), + scheduler: rxConsumeScheduler) + .do(onNext: { _ in + cdPrint("consumeEvents timer") + }) + + // 每满numberOfCountPerConsume个数启动一次,立即启动一次 + let counter = db.uploadableEventRecordCountOb() + .distinctUntilChanged() + .compactMap({ [weak self] count -> Int? in + cdPrint("consumeEvents uploadableEventRecordCountOb count: \(count) numberOfCountPerConsume: \(self?.numberOfCountPerConsume)") + guard let `self` = self, + count >= self.numberOfCountPerConsume else { return nil } + return count + }) + .map { _ in } + .startWith(()) + + pollingUploadTask = Observable.combineLatest(timer, counter) + .throttle(.seconds(1), scheduler: rxConsumeScheduler) + .flatMap({ [weak self] t -> Single<(Int, Void)> in + guard let `self` = self else { return .just(t) } + return Observable.combineLatest(self.db.hasFgEventRecord().asObservable(), self.db.uploadableEventRecordCount().asObservable()) + .take(1).asSingle() + .flatMap({ (hasFgEventInDb, eventsCount) -> Single<(Int, Void)> in + guard !hasFgEventInDb, eventsCount > 0 else { + return .just(t) + } + return self.logForegroundDuration().catchAndReturn(()).map({ _ in t }) + }) + }) + .subscribe(onNext: { [weak self] (timer, counter) in + self?.consumeEvents() + }) + } + + func setupPollingUpload() { + reschedulePollingTrigger + .debounce(.seconds(1), scheduler: rxConsumeScheduler) + .subscribe(onNext: { [weak self] _ in + self?.startPollingUpload() + }) + .disposed(by: bag) + } + + func logFirstFgEvent() { + _ = Single.just(()).delay(.milliseconds(500), scheduler: MainScheduler.asyncInstance) + .flatMap({ [weak self] _ in + self?.logForegroundDuration() ?? .just(()) + }) + .subscribe() + } +} + +// MARK: - fg相关 +private extension Manager { + + func setupFgAccumulateTimer() { + invalidFgAccumulateTimer() + fgStartAtAbsoluteMs = Date.absoluteTimeMs + fgAccumulateTimer = Observable.timer(.seconds(0), period: .seconds(1), scheduler: MainScheduler.asyncInstance) + .subscribe(onNext: { [weak self] _ in + guard let `self` = self else { return } + UserDefaults.fgAccumulatedDuration = self.fgDurationMs() + }, onDisposed: { + cdPrint("fg accumulate timer disposed") + }) + } + + func invalidFgAccumulateTimer() { + fgAccumulateTimer?.dispose() + fgAccumulateTimer = nil + } + + /// 前台停留时长 + func fgDurationMs() -> Int64 { + let slice = Date.absoluteTimeMs - fgStartAtAbsoluteMs + fgStartAtAbsoluteMs = Date.absoluteTimeMs +// cdPrint("accumulate fg duration: \(slice)") + let totalDuration = UserDefaults.fgAccumulatedDuration + slice +// cdPrint("total fg duration: \(totalDuration)") + return totalDuration + } +} + +extension Manager: GuruAnalyticsNetworkErrorReportDelegate { + func reportError(networkError: GuruAnalyticsNetworkError) { + + enum UserInfoKey: String, Encodable { + case httpCode = "h_c" + case errorCode = "e_c" + case url, msg + } + + let errorCode = networkError.internalErrorCategory.rawValue + let userInfo = (networkError.originError as NSError).userInfo + var info: [UserInfoKey : String] = [ + .url : (userInfo[NSURLErrorFailingURLStringErrorKey] as? String) ?? "", + .msg : networkError.originError.localizedDescription, + ] + + if let httpCode = networkError.httpStatusCode { + info[.httpCode] = "\(httpCode)" + } else { + info[.errorCode] = "\((networkError.originError as NSError).code)" + } + + info = info.compactMapValues { $0.isEmpty ? nil : $0 } + + let jsonString = info.asString ?? "" + DispatchQueue.main.async { [weak self] in + self?.internalEventReporter?(errorCode, jsonString) + } + } +} diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database/Manager.swift.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database/Manager.swift.meta new file mode 100644 index 0000000..caefe5a --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database/Manager.swift.meta @@ -0,0 +1,49 @@ +fileFormatVersion: 2 +guid: dab7243d39b964205a4fa4446e95403b +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + iPhone: iOS + second: + enabled: 0 + settings: {} + - first: + tvOS: tvOS + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database/UserDefaults.swift b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database/UserDefaults.swift new file mode 100644 index 0000000..dd9fb51 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database/UserDefaults.swift @@ -0,0 +1,65 @@ +// +// UserDefaults.swift +// GuruAnalytics +// +// Created by mayue on 2022/11/21. +// + +import Foundation + +internal enum UserDefaults { + + static let defaults = Foundation.UserDefaults(suiteName: "com.guru.guru_analytics_lib") + + static var eventsServerHost: String? { + + get { + return defaults?.value(forKey: eventsServerHostKey) as? String + } + + set { + var host = newValue + let h_sch = "http://" + let hs_sch = "https://" + host?.deletePrefix(h_sch) + host?.deletePrefix(hs_sch) + host?.trimmed(in: .whitespacesAndNewlines.union(.init(charactersIn: "/"))) + defaults?.set(host, forKey: eventsServerHostKey) + } + + } + + static var fgAccumulatedDuration: Int64 { + get { + return defaults?.value(forKey: fgDurationKey) as? Int64 ?? 0 + } + + set { + defaults?.set(newValue, forKey: fgDurationKey) + } + } + +} + +extension UserDefaults { + + static var firstOpenTimeKey: String { + return "app.first.open.timestamp" + } + + static var dbVersionKey: String { + return "db.version" + } + + static var hostsMapKey: String { + return "hosts.map" + } + + static var eventsServerHostKey: String { + return "events.server.host" + } + + static var fgDurationKey: String { + return "fg.duration.ms" + } +} diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database/UserDefaults.swift.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database/UserDefaults.swift.meta new file mode 100644 index 0000000..809eafb --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Database/UserDefaults.swift.meta @@ -0,0 +1,49 @@ +fileFormatVersion: 2 +guid: e022f21a2d3a94edb87e7479aab2cfb9 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + iPhone: iOS + second: + enabled: 0 + settings: {} + - first: + tvOS: tvOS + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/ErrorHandling.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/ErrorHandling.meta new file mode 100644 index 0000000..52bc63b --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/ErrorHandling.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b2af081c64bfe4af7b232b8132d01544 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/ErrorHandling/GuruAnalyticsErrorHandleDelegate.swift b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/ErrorHandling/GuruAnalyticsErrorHandleDelegate.swift new file mode 100644 index 0000000..bb73fc6 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/ErrorHandling/GuruAnalyticsErrorHandleDelegate.swift @@ -0,0 +1,36 @@ +// +// GuruAnalyticsErrorHandleDelegate.swift +// Alamofire +// +// Created by mayue on 2023/10/27. +// + +import Foundation + +internal enum GuruAnalyticsNetworkLayerErrorCategory: Int { + case unknown = -100 + case serverAPIError = 101 + case responseParsingError = 102 + case googleDNSServiceError = 106 +} + +@objc internal protocol GuruAnalyticsNetworkErrorReportDelegate { + func reportError(networkError: GuruAnalyticsNetworkError) -> Void +} + +internal class GuruAnalyticsNetworkError: NSError { + private(set) var httpStatusCode: Int? + private(set) var originError: Error + private(set) var internalErrorCategory: GuruAnalyticsNetworkLayerErrorCategory + + init(httpStatusCode: Int? = nil, internalErrorCategory: GuruAnalyticsNetworkLayerErrorCategory, originError: Error) { + self.httpStatusCode = httpStatusCode + self.originError = originError + self.internalErrorCategory = internalErrorCategory + super.init(domain: "com.guru.analytics.network.layer", code: internalErrorCategory.rawValue, userInfo: (originError as NSError).userInfo) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/ErrorHandling/GuruAnalyticsErrorHandleDelegate.swift.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/ErrorHandling/GuruAnalyticsErrorHandleDelegate.swift.meta new file mode 100644 index 0000000..20e51e8 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/ErrorHandling/GuruAnalyticsErrorHandleDelegate.swift.meta @@ -0,0 +1,85 @@ +fileFormatVersion: 2 +guid: 77961084d08bf4074afb847d866d89e0 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Android: Android + second: + enabled: 0 + settings: + CPU: ARMv7 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + CPU: AnyCPU + DefaultValueInitialized: true + OS: AnyOS + - first: + Standalone: Linux64 + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + Standalone: OSXUniversal + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + Standalone: Win + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + Standalone: Win64 + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + iPhone: iOS + second: + enabled: 0 + settings: + AddToEmbeddedBinaries: false + CPU: AnyCPU + CompileFlags: + FrameworkDependencies: + - first: + tvOS: tvOS + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/GuruAnalytics+Internal.swift b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/GuruAnalytics+Internal.swift new file mode 100644 index 0000000..f62e47e --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/GuruAnalytics+Internal.swift @@ -0,0 +1,51 @@ +// +// GuruAnalytics+Internal.swift +// Pods +// +// Created by mayue on 2022/11/18. +// + +import Foundation + +internal extension GuruAnalytics { + + ///built-in user property keys + enum PropertyName: String { + case deviceId + case uid + case adjustId + case adId + case firebaseId + case screen = "screen_name" + case firstOpenTime = "first_open_time" + } + + ///built-in events + static let fgEvent: EventProto = { + var e = EventProto(paramKeyType: FgEventParametersKeys.self, name: "fg") + return e + }() + + static let firstOpenEvent: EventProto = { + var e = EventProto(paramKeyType: FgEventParametersKeys.self, name: "first_open") + return e + }() + + class func setUserProperty(_ value: String?, forName name: PropertyName) { + setUserProperty(value, forName: name.rawValue) + } + +} + +internal extension GuruAnalytics { + + struct EventProto { + var paramKeyType: ParametersKeys.Type + var name: String + } + + enum FgEventParametersKeys: String { + case duration + } + +} diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/GuruAnalytics+Internal.swift.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/GuruAnalytics+Internal.swift.meta new file mode 100644 index 0000000..38781ba --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/GuruAnalytics+Internal.swift.meta @@ -0,0 +1,85 @@ +fileFormatVersion: 2 +guid: 2623be1999d104830a332ddf24de490a +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Android: Android + second: + enabled: 0 + settings: + CPU: ARMv7 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + CPU: AnyCPU + DefaultValueInitialized: true + OS: AnyOS + - first: + Standalone: Linux64 + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + Standalone: OSXUniversal + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + Standalone: Win + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + Standalone: Win64 + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + iPhone: iOS + second: + enabled: 0 + settings: + AddToEmbeddedBinaries: false + CPU: AnyCPU + CompileFlags: + FrameworkDependencies: + - first: + tvOS: tvOS + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Network.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Network.meta new file mode 100644 index 0000000..3d6b244 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Network.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ef02d71253914417f9d007629ed0eb1d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Network/APIService.swift b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Network/APIService.swift new file mode 100644 index 0000000..d3697cb --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Network/APIService.swift @@ -0,0 +1,78 @@ +// +// APIService.swift +// GuruAnalytics_iOS +// +// Created by mayue on 2022/11/8. +// + +import Foundation +import Alamofire + +internal enum APIService {} + +extension APIService { + enum Backend: CaseIterable { + case event + case systemTime + } +} + +extension APIService.Backend { + + var scheme: String { + return "https" + } + + var host: String { + switch self { + case .systemTime: + return "saas.castbox.fm" + case .event: + return UserDefaults.eventsServerHost ?? "collect.saas.castbox.fm" + } + } + + var urlComponents: URLComponents { + var urlC = URLComponents() + urlC.host = self.host + urlC.scheme = self.scheme + urlC.path = self.path + return urlC + } + + var path: String { + switch self { + case .event: + return "/event" + case .systemTime: + return "/tool/api/v1/system/time" + } + } + + var method: HTTPMethod { + switch self { + case .event: + return .post + case .systemTime: + return .get + } + } + + var headers: HTTPHeaders { + HTTPHeaders( + ["Content-Type": "application/json", + "Content-Encoding": "gzip", + "x_event_type": "event"] + ) + } + + var version: Int { + /// 接口版本 + switch self { + case .event: + return 10 + case .systemTime: + return 0 + } + } +} diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Network/APIService.swift.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Network/APIService.swift.meta new file mode 100644 index 0000000..1795336 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Network/APIService.swift.meta @@ -0,0 +1,49 @@ +fileFormatVersion: 2 +guid: 09cfaaabf31cb4a73ab8d83aa65eb2de +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + iPhone: iOS + second: + enabled: 0 + settings: {} + - first: + tvOS: tvOS + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Network/NetworkManager.swift b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Network/NetworkManager.swift new file mode 100644 index 0000000..7217360 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Network/NetworkManager.swift @@ -0,0 +1,419 @@ +// +// Network.swift +// GuruAnalytics_iOS +// +// Created by mayue on 2022/11/3. +// Copyright © 2022 Guru Network Limited. All rights reserved. +// + +import Foundation +import Alamofire +import RxSwift +import RxRelay +import Gzip + +internal class NetworkManager { + + private static let ipErrorUserInfoKey = "failed_ip" + + internal var isReachable: Bool { + return _reachableObservable.value + } + + internal var reachableObservable: Observable { + return _reachableObservable.asObservable() + } + + private let _reachableObservable = BehaviorRelay(value: false) + + private let reachablity = NetworkReachabilityManager() + + private let networkQueue = DispatchQueue.init(label: "com.guru.analytics.network.queue", qos: .userInitiated) + private lazy var rxWorkScheduler = SerialDispatchQueueScheduler.init(queue: networkQueue, internalSerialQueueName: "com.guru.analytics.network.rx.work.queue") + + private lazy var session: Session = { + let trustManager = CertificatePinnerServerTrustManager() + trustManager.evaluator.hostWhiteList = hostsMap + return Session(serverTrustManager: trustManager) + }() + + private var hostsMap: [String : [String]] { + get { + return UserDefaults.defaults?.value(forKey: UserDefaults.hostsMapKey) as? [String : [String]] ?? [:] + } + + set { + UserDefaults.defaults?.set(newValue, forKey: UserDefaults.hostsMapKey) + (session.serverTrustManager as? CertificatePinnerServerTrustManager)?.evaluator.hostWhiteList = newValue + checkHostMap(newValue) + } + } + + internal weak var networkErrorReporter: GuruAnalyticsNetworkErrorReportDelegate? + + internal init() { + + reachablity?.startListening(onQueue: networkQueue, onUpdatePerforming: { [weak self] status in + var reachable: Bool + switch status { + case .reachable(_): + reachable = true + case .notReachable, .unknown: + reachable = false + } + self?._reachableObservable.accept(reachable) + }) + + APIService.Backend.allCases.forEach({ service in + _ = lookupHostRemote(service.host).subscribe() + }) + } + + /// 上报event请求 + /// - Parameter events: event record数组 + /// - Returns: 上报成功的event record ID数组 + internal func uploadEvents(_ events: [Entity.EventRecord]) -> Single<(recordIDs: [String], eventsJson: String)> { + guard !events.isEmpty else { + return .just(([], "")) + } + + let service = APIService.Backend.event + + return lookupHostLocal(service.host) + .flatMap { ip in + + Single.create { [weak self] subscriber in + guard let `self` = self else { + subscriber(.failure( + NSError(domain: "networkManager", code: 0, userInfo: [NSLocalizedDescriptionKey : "manager is released"]) + )) + return Disposables.create() + } + var postJson = [String : Any]() + postJson["version"] = service.version + postJson["deviceInfo"] = Constants.deviceInfo + let eventJsonArray = events.compactMap { $0.eventJson.jsonObject() } + postJson["events"] = eventJsonArray + + do { + let jsonData = try JSONSerialization.data(withJSONObject: postJson) + let jsonString = String(data: jsonData, encoding: .utf8) ?? "" + let gzippedJsonData = try jsonData.gzipped() + let httpBody = gzippedJsonData + + var urlRequest: URLRequest + var urlC = service.urlComponents + let session: Session + + if let ip = ip { + session = self.session + urlC.host = ip + urlRequest = try URLRequest(url: urlC, method: service.method, headers: service.headers) + urlRequest.setValue(service.host, forHTTPHeaderField: "host") + + } else { + session = AF + urlRequest = try URLRequest(url: urlC, method: service.method, headers: service.headers) + } + urlRequest.setValue(GuruAnalytics.saasXAPPID, forHTTPHeaderField: "X-APP-ID") + urlRequest.setValue(GuruAnalytics.saasXDEVICEINFO, forHTTPHeaderField: "X-DEVICE-INFO") + urlRequest.httpBody = httpBody + + var emptyResponseCodes = DataResponseSerializer.defaultEmptyResponseCodes + emptyResponseCodes.insert(200) + + let request = session.request(urlRequest).validate(statusCode: [200]) + .responseData( + queue: self.networkQueue, + emptyResponseCodes: emptyResponseCodes, + completionHandler: { response in + cdPrint("\(#function): request: \(urlRequest) \nheader:\(urlRequest.headers) \nhttpbody: \(jsonString) \nresponse: \(response)") + switch response.result { + case .failure(let error): + subscriber(.failure(self.mapError(error, for: ip))) + cdPrint("\(#function) error: \(error)") + case .success: + subscriber(.success((events.map { $0.recordId }, jsonString))) + } + }) + + return Disposables.create { + request.cancel() + } + } catch { + cdPrint("construct request failed: \(error)") + subscriber(.failure(error)) + return Disposables.create() + } + } + } + .do(onError: { [weak self] error in + self?.reportError(error: error, internalErrorCategory: .serverAPIError) + }) + .catch { [weak self] error in + + guard let `self` = self else { throw error } + + return try self.errorCatcher(error, for: service.host) { + self.uploadEvents(events) + } + } + .subscribe(on: rxWorkScheduler) + } + + /// 同步服务器时间请求 + /// - Returns: 毫秒整数 + internal func syncServerTime() -> Single { + let service = APIService.Backend.systemTime + + return lookupHostLocal(service.host) + .flatMap { ip in + + Single.create { [weak self] subscriber in + + guard let `self` = self else { + subscriber(.failure( + NSError(domain: "networkManager", code: 0, userInfo: [NSLocalizedDescriptionKey : "manager is released"]) + )) + return Disposables.create() + } + + do { + let start = Date() + var urlC = service.urlComponents + let session: Session + var urlReq: URLRequest + + if let ip = ip { + session = self.session + urlC.host = ip + urlReq = try URLRequest(url: urlC, method: service.method, headers: service.headers) + urlReq.setValue(service.host, forHTTPHeaderField: "host") + } else { + session = AF + urlReq = try URLRequest(url: urlC, method: service.method, headers: service.headers) + } + urlReq.setValue(GuruAnalytics.saasXAPPID, forHTTPHeaderField: "X-APP-ID") + urlReq.setValue(GuruAnalytics.saasXDEVICEINFO, forHTTPHeaderField: "X-DEVICE-INFO") + + let request = session.request(urlReq).validate(statusCode: [200]) + .responseDecodable(of: Entity.SystemTimeResult.self, + queue: self.networkQueue, + completionHandler: { response in + cdPrint("\(#function): request: \(urlReq) \nheaders:\(urlReq.headers) \nresponse: \(response)") + switch response.result { + case .success(let data): + let timespan = Date().timeIntervalSince(start).int64Ms + let systemTime = data.data - timespan / 2 + subscriber(.success(systemTime)) + case .failure(let error): + cdPrint("\(#function) error: \(error)") + subscriber(.failure(self.mapError(error, for: ip))) + } + }) + + return Disposables.create { + request.cancel() + } + + } catch { + cdPrint("construct request failed: \(error)") + subscriber(.failure(error)) + return Disposables.create() + } + + } + } + .do(onError: { [weak self] error in + self?.reportError(error: error, internalErrorCategory: .serverAPIError) + }) + .catch { [weak self] error in + + guard let `self` = self else { throw error } + + return try self.errorCatcher(error, for: service.host) { + self.syncServerTime() + } + } + .subscribe(on: rxWorkScheduler) + } + + private func _lookupHostRemote(_ host: String) -> Single<[IpAdress]> { + return Single.create { subscriber in + + do { + var urlC = URLComponents() + urlC.scheme = "https" + urlC.host = "dns.google" + urlC.path = "/resolve" + urlC.queryItems = [.init(name: "name", value: "\(host)")] + + let urlReq = try URLRequest(url: urlC, method: .get) + + let request = AF.request(urlReq) + .validate(statusCode: [200]) + .responseData(completionHandler: { response in + switch response.result { + case .success(let data): + + do { + guard let dict = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String : Any], + let answerDictArr = dict["Answer"] as? [[String : Any]] else { + let customError = NSError(domain: "com.guru.analytics.network.layer", code: 0, + userInfo: [NSLocalizedDescriptionKey : "dns.google service returned unexpected data"]) + subscriber(.failure(customError)) + return + } + + let ips = try JSONDecoder().decodeAnyData([IpAdress].self, from: answerDictArr) + subscriber(.success(ips)) + cdPrint("\(#function) success request: \(urlReq) \nresponse: \(ips)") + } catch { + subscriber(.failure(error)) + } + + case .failure(let error): + cdPrint("\(#function) error: \(error) request: \(urlReq)") + subscriber(.failure(error)) + } + }) + return Disposables.create { + request.cancel() + } + } catch { + cdPrint("construct request failed: \(error)") + subscriber(.failure(error)) + return Disposables.create() + } + } + .subscribe(on: rxWorkScheduler) + } + + private func lookupHostRemote(_ host: String) -> Single<[String]> { + return _lookupHostRemote(host) + .map { ipList -> [String] in + ipList.compactMap { ip in + guard ip.type == 1 else { return nil } + return ip.data + } + } + .do(onSuccess: { [weak self] ipList in + self?.hostsMap[host] = ipList + }, onError: { [weak self] error in + self?.reportError(error: error, internalErrorCategory: .googleDNSServiceError) + }) + } + + private func lookupHostLocal(_ host: String) -> Single { + return Single.create { [weak self] subscriber in + + guard let `self` = self else { + subscriber(.failure( + NSError(domain: "networkManager", code: 0, userInfo: [NSLocalizedDescriptionKey : "manager is released"]) + )) + return Disposables.create() + } + + subscriber(.success(self.hostsMap[host]?.first)) + + return Disposables.create() + } + .subscribe(on: rxWorkScheduler) + } + + private func mapError(_ error: AFError, for ip: String?) -> Error { + + guard let ip = ip else { return error } + + var e = (error.underlyingError ?? error) as NSError + var userInfo = e.userInfo + userInfo[Self.ipErrorUserInfoKey] = ip + e = NSError(domain: e.domain, code: e.code, userInfo: userInfo) + return e + } + + private func errorCatcher(_ error: Error, for host: String, returnValue: (() -> Single) ) throws -> Single { + + let e = error as NSError + guard let ip = e.userInfo[Self.ipErrorUserInfoKey] as? String else { + throw error + } + //FIX: https://console.firebase.google.com/u/1/project/ball-sort-dd4d0/crashlytics/app/ios:ball.sort.puzzle.color.sorting.bubble.games/issues/c1f6d36aeb7c105a32015504776adff5?time=last-ninety-days&sessionEventKey=27d699688a594f96a7b17003a3c49c84_1900062047348716162 + if var hosts = hostsMap[host] { + hosts.removeAll(where: { $0 == ip }) + hostsMap[host] = hosts + } + return returnValue() + } + + private func checkHostMap(_ hostMap: [String : [String]]) { + + hostMap.forEach { key, value in + guard value.count <= 0 else { return } + _ = lookupHostRemote(key).subscribe() + } + + } + + private func reportError(error: Error, internalErrorCategory: GuruAnalyticsNetworkLayerErrorCategory) { + let customError: GuruAnalyticsNetworkError + if let aferror = error.asAFError { + + if case let AFError.responseValidationFailed(reason) = aferror, + case let AFError.ResponseValidationFailureReason.unacceptableStatusCode(httpStatusCode) = reason { + customError = GuruAnalyticsNetworkError(httpStatusCode: httpStatusCode, internalErrorCategory: internalErrorCategory, originError: aferror.underlyingError ?? error) + } else { + customError = GuruAnalyticsNetworkError(internalErrorCategory: internalErrorCategory, originError: aferror.underlyingError ?? error) + } + + } else { + customError = GuruAnalyticsNetworkError(internalErrorCategory: internalErrorCategory, originError: error) + } + + networkErrorReporter?.reportError(networkError: customError) + } + +} + +internal final class CertificatePinnerTrustEvaluator: ServerTrustEvaluating { + + private let dftEvaluator = DefaultTrustEvaluator() + + init() {} + + var hostWhiteList: [String : [String]] = [:] + + func evaluate(_ trust: SecTrust, forHost host: String) throws { + + let originHostName: String = hostWhiteList.first { _, value in + value.contains { $0 == host } + }?.key ?? host + + try dftEvaluator.evaluate(trust, forHost: originHostName) + + cdPrint(#function + " \(trust) forHost: \(host) originHostName: \(originHostName)") + } +} + +internal class CertificatePinnerServerTrustManager: ServerTrustManager { + + let evaluator = CertificatePinnerTrustEvaluator() + + init() { + super.init(allHostsMustBeEvaluated: true, evaluators: [:]) + } + + override func serverTrustEvaluator(forHost host: String) throws -> ServerTrustEvaluating? { + + return evaluator + } +} + +extension NetworkManager { + struct IpAdress: Codable { + let name: String + let type: Int + let TTL: Int + let data: String + } +} diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Network/NetworkManager.swift.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Network/NetworkManager.swift.meta new file mode 100644 index 0000000..2b303e2 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Network/NetworkManager.swift.meta @@ -0,0 +1,49 @@ +fileFormatVersion: 2 +guid: f197036e5438741cea961bc00c23c775 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + iPhone: iOS + second: + enabled: 0 + settings: {} + - first: + tvOS: tvOS + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility.meta new file mode 100644 index 0000000..1000a4d --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8675c92422b8d45f49ced2e0f1602f32 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Constants.swift b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Constants.swift new file mode 100755 index 0000000..c4d8c78 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Constants.swift @@ -0,0 +1,160 @@ +// +// Constants.swift +// AgoraChatRoom +// +// Created by LXH on 2019/11/27. +// Copyright © 2019 CavanSu. All rights reserved. +// + +import UIKit + +internal struct Constants { + + private static let appVersion: String = { + guard let infoDict = Bundle.main.infoDictionary, + let currentVersion = infoDict["CFBundleShortVersionString"] as? String else { + return "" + } + return currentVersion + }() + + private static let appBundleIdentifier: String = { + guard let infoDictionary = Bundle.main.infoDictionary, + let shortVersion = infoDictionary["CFBundleIdentifier"] as? String else { + return "" + } + return shortVersion + }() + + private static let preferredLocale: Locale = { + guard let preferredIdentifier = Locale.preferredLanguages.first else { + return Locale.current + } + return Locale(identifier: preferredIdentifier) + }() + + private static let countryCode: String = { + return preferredLocale.regionCode?.uppercased() ?? "" + }() + + private static let timeZone: String = { + return TimeZone.current.identifier + }() + + private static let languageCode: String = { + return preferredLocale.languageCode ?? "" + }() + + private static let localeCode: String = { + return preferredLocale.identifier + }() + + private static let modelName: String = { + return platform().deviceType.rawValue + }() + + private static let model: String = { + return hardwareString() + }() + + private static let systemVersion: String = { + return UIDevice.current.systemVersion + }() + + private static let screenSize: (w: CGFloat, h: CGFloat) = { + return (UIScreen.main.bounds.width, UIScreen.main.bounds.height) + }() + + /// 时区偏移毫秒数 + private static let tzOffset: Int64 = { + return Int64(TimeZone.current.secondsFromGMT(for: Date())) * 1000 + }() + + static var deviceInfo: [String : Any] { + return [ + "country": countryCode, + "platform": "IOS", + "appId" : appBundleIdentifier, + "version" : appVersion, + "tzOffset": tzOffset, + "deviceType" : modelName, + "brand": "Apple", + "model": model, + "screenH": Int(screenSize.h), + "screenW": Int(screenSize.w), + "osVersion": systemVersion, + "language" : languageCode + ] + } + + /// This method returns the hardware type + /// + /// + /// - returns: raw `String` of device type, e.g. iPhone5,1 + /// + private static func hardwareString() -> String { + var name: [Int32] = [CTL_HW, HW_MACHINE] + var size: Int = 2 + sysctl(&name, 2, nil, &size, nil, 0) + var hw_machine = [CChar](repeating: 0, count: Int(size)) + sysctl(&name, 2, &hw_machine, &size, nil, 0) + + var hardware: String = String(cString: hw_machine) + + // Check for simulator + if hardware == "x86_64" || hardware == "i386" || hardware == "arm64" { + if let deviceID = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] { + hardware = deviceID + } + } + + return hardware + } + + /// This method returns the Platform enum depending upon harware string + /// + /// + /// - returns: `Platform` type of the device + /// + static func platform() -> Platform { + + let hardware = hardwareString() + + if (hardware.hasPrefix("iPhone")) { return .iPhone } + if (hardware.hasPrefix("iPod")) { return .iPodTouch } + if (hardware.hasPrefix("iPad")) { return .iPad } + if (hardware.hasPrefix("Watch")) { return .appleWatch } + if (hardware.hasPrefix("AppleTV")) { return .appleTV } + + return .unknown + } + + enum Platform { + case iPhone + case iPodTouch + case iPad + case appleWatch + case appleTV + case unknown + + enum DeviceType: String { + case mobile, tablet, desktop, smartTV, watch, other + } + + var deviceType: DeviceType { + switch self { + case .iPad: + return .tablet + case .iPhone, .iPodTouch: + return .mobile + case .appleTV: + return .smartTV + case .appleWatch: + return .watch + case .unknown: + return .other + } + } + } + +} diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Constants.swift.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Constants.swift.meta new file mode 100644 index 0000000..216738e --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Constants.swift.meta @@ -0,0 +1,49 @@ +fileFormatVersion: 2 +guid: b7b38f09cfd4b47dc8ffd0e05fd14523 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + iPhone: iOS + second: + enabled: 0 + settings: {} + - first: + tvOS: tvOS + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/EncodableExtension.swift b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/EncodableExtension.swift new file mode 100644 index 0000000..07e8760 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/EncodableExtension.swift @@ -0,0 +1,54 @@ +// +// EncodableExtension.swift +// Runner +// +// Created by 袁仕崇 on 2020/5/19. +// Copyright © 2020 Guru. All rights reserved. +// + +import Foundation + +internal extension Encodable { + func asDictionary() throws -> [String: Any] { + let data = try JSONEncoder().encode(self) + guard let dictionary = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] else { + throw NSError() + } + return dictionary + } + + var dictionary: [String: Any]? { + guard let data = try? JSONEncoder().encode(self) else { return nil } + return (try? JSONSerialization.jsonObject(with: data, options: .allowFragments)).flatMap { $0 as? [String: Any] } + } + + var asString: String? { + guard let data = try? JSONEncoder().encode(self) else { return nil } + return String(data: data, encoding: .utf8) + } +} + +internal extension String { + + func jsonObject() -> [String: Any]? { + + guard let data = data(using: .utf8) else { + return nil + } + guard let jsonData = try? JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) as? [String: Any] else { + return nil + } + return jsonData + } + + func jsonArrayObject() -> [[String: Any]]? { + + guard let data = data(using: .utf8) else { + return nil + } + guard let jsonData = try? JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) as? [[String: Any]] else { + return nil + } + return jsonData + } +} diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/EncodableExtension.swift.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/EncodableExtension.swift.meta new file mode 100644 index 0000000..570ce20 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/EncodableExtension.swift.meta @@ -0,0 +1,49 @@ +fileFormatVersion: 2 +guid: 8225294b372d84b579cecb97daca5100 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + iPhone: iOS + second: + enabled: 0 + settings: {} + - first: + tvOS: tvOS + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Helper.swift b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Helper.swift new file mode 100644 index 0000000..e3aacd1 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Helper.swift @@ -0,0 +1,25 @@ +// +// Helper.swift +// GuruAnalytics_iOS +// +// Created by mayue on 2022/11/4. +// + +import Foundation + +internal func cdPrint(_ items: Any..., context: String? = nil, separator: String = " ", terminator: String = "\n") { +#if DEBUG + guard GuruAnalytics.loggerDebug else { return } + let date = Date() + let df = DateFormatter() + df.dateFormat = "HH:mm:ss.SSSS" + let dateString = df.string(from: date) + + print("\(dateString) [GuruAnalytics] Thread: \(Thread.current.queueName) \(context ?? "") ", terminator: "") + for item in items { + print(item, terminator: " ") + } + print("", terminator: terminator) +#else +#endif +} diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Helper.swift.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Helper.swift.meta new file mode 100644 index 0000000..7940194 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Helper.swift.meta @@ -0,0 +1,49 @@ +fileFormatVersion: 2 +guid: bf62f6d630bd84c75834df5c964dee4f +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + iPhone: iOS + second: + enabled: 0 + settings: {} + - first: + tvOS: tvOS + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/JSONDecoder.Extension.swift b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/JSONDecoder.Extension.swift new file mode 100644 index 0000000..13388c3 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/JSONDecoder.Extension.swift @@ -0,0 +1,28 @@ +// +// JSONDecoder.Extension.swift +// Moya-Cuddle +// +// Created by Wilson-Yuan on 2019/12/25. +// Copyright © 2019 Guru. All rights reserved. +// + +import Foundation + +internal extension JSONDecoder { + func decodeAnyData(_ type: T.Type, from data: Any) throws -> T where T: Decodable { + var unwrappedData = Data() + if let data = data as? Data { + unwrappedData = data + } + else if let data = data as? [String: Any] { + unwrappedData = try JSONSerialization.data(withJSONObject: data, options: .prettyPrinted) + } + else if let data = data as? [[String: Any]] { + unwrappedData = try JSONSerialization.data(withJSONObject: data, options: .prettyPrinted) + } + else { + fatalError("error format of data ") + } + return try decode(type, from: unwrappedData) + } +} diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/JSONDecoder.Extension.swift.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/JSONDecoder.Extension.swift.meta new file mode 100644 index 0000000..1b6dfd2 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/JSONDecoder.Extension.swift.meta @@ -0,0 +1,49 @@ +fileFormatVersion: 2 +guid: a45044c2a092c455881ad4f39fdeec2b +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + iPhone: iOS + second: + enabled: 0 + settings: {} + - first: + tvOS: tvOS + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Logger.swift b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Logger.swift new file mode 100644 index 0000000..5805277 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Logger.swift @@ -0,0 +1,158 @@ +// +// Logger.swift +// GuruAnalyticsLib +// +// Created by mayue on 2022/12/21. +// + +import Foundation +import SwiftyBeaver +import CryptoSwift +import RxSwift + +internal class LoggerManager { + + private static let password: String = "Castbox123" + + private lazy var logger: SwiftyBeaver.Type = { + let logger = SwiftyBeaver.self + logger.addDestination(consoleOutputDestination) + logger.addDestination(fileOutputDestination) + return logger + }() + + private lazy var logFileDir: URL = { + let baseDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + return baseDir.appendingPathComponent("GuruAnalytics/Logs/\(logCategoryName)/", isDirectory: true) + }() + + private lazy var consoleOutputDestination: ConsoleDestination = { + let d = ConsoleDestination() + return d + }() + + private lazy var fileOutputDestination: FileDestination = { + let file = FileDestination() + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + let dateString = dateFormatter.string(from: Date()) + file.logFileURL = logFileDir.appendingPathComponent("\(dateString).log", isDirectory: false) + file.asynchronously = true + return file + }() + + private let logCategoryName: String + + internal init(logCategoryName: String) { + self.logCategoryName = logCategoryName + } +} + +internal extension LoggerManager { + + func logFilesZipArchive() -> Single { + + return Single.create { subscriber in + subscriber(.success(nil)) + return Disposables.create() + } + .observe(on: MainScheduler.asyncInstance) + } + + func logFilesDirURL() -> Single { + + return Single.create { subscriber in + + DispatchQueue.global().async { [weak self] in + guard let `self` = self else { + subscriber(.failure( + NSError(domain: "loggerManager", code: 0, userInfo: [NSLocalizedDescriptionKey : "manager is released"]) + )) + return + } + + do { + let filePaths = try FileManager.default.contentsOfDirectory(at: self.logFileDir, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles]) + .filter { $0.pathExtension == "log" } + .map { $0.path } + + guard filePaths.count > 0 else { + subscriber(.success(nil)) + return + } + subscriber(.success(self.logFileDir)) + } catch { + subscriber(.failure(error)) + } + + } + + return Disposables.create() + } + .observe(on: MainScheduler.asyncInstance) + } + + func clearAllLogFiles() { + + DispatchQueue.global().async { [weak self] in + guard let `self` = self else { return } + if let files = try? FileManager.default.contentsOfDirectory(at: self.logFileDir, includingPropertiesForKeys: [], options: [.skipsHiddenFiles]) { + files.forEach { url in + do { + try FileManager.default.removeItem(at: url) + } catch { + cdPrint("remove file: \(url.path) \n error: \(error)") + } + } + } + } + + } + + func verbose(_ message: Any, + _ file: String = #file, + _ function: String = #function, + line: Int = #line, + context: Any? = nil) { + guard GuruAnalytics.loggerDebug else { return } + logger.verbose(message, file, function, line: line, context: context) + } + + func debug(_ message: Any, + _ file: String = #file, + _ function: String = #function, + line: Int = #line, + context: Any? = nil) { + guard GuruAnalytics.loggerDebug else { return } + logger.debug(message, file, function, line: line, context: context) + } + + func info(_ message: Any, + _ file: String = #file, + _ function: String = #function, + line: Int = #line, + context: Any? = nil) { + guard GuruAnalytics.loggerDebug else { return } + logger.info(message, file, function, line: line, context: context) + } + + func warning(_ message: Any, + _ file: String = #file, + _ function: String = #function, + line: Int = #line, + context: Any? = nil) { + guard GuruAnalytics.loggerDebug else { return } + logger.warning(message, file, function, line: line, context: context) + } + + func error(_ message: Any, + _ file: String = #file, + _ function: String = #function, + line: Int = #line, + context: Any? = nil) { + guard GuruAnalytics.loggerDebug else { return } + logger.error(message, file, function, line: line, context: context) + } +} diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Logger.swift.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Logger.swift.meta new file mode 100644 index 0000000..976585e --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Logger.swift.meta @@ -0,0 +1,49 @@ +fileFormatVersion: 2 +guid: c0c260bf3d64e48688db3309a7ea46c3 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + iPhone: iOS + second: + enabled: 0 + settings: {} + - first: + tvOS: tvOS + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/ThreadExtension.swift b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/ThreadExtension.swift new file mode 100644 index 0000000..ec1fcab --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/ThreadExtension.swift @@ -0,0 +1,32 @@ +// +// ThreadExtension.swift +// GuruAnalyticsLib +// +// Created by 袁仕崇 on 17/02/23. +// + +import Foundation + +extension Thread { + var threadName: String { + if isMainThread { + return "main" + } else if let threadName = Thread.current.name, !threadName.isEmpty { + return threadName + } else { + return description + } + } + + var queueName: String { + if let queueName = String(validatingUTF8: __dispatch_queue_get_label(nil)) { + return queueName + } else if let operationQueueName = OperationQueue.current?.name, !operationQueueName.isEmpty { + return operationQueueName + } else if let dispatchQueueName = OperationQueue.current?.underlyingQueue?.label, !dispatchQueueName.isEmpty { + return dispatchQueueName + } else { + return "n/a" + } + } +} diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/ThreadExtension.swift.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/ThreadExtension.swift.meta new file mode 100644 index 0000000..28fc656 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/ThreadExtension.swift.meta @@ -0,0 +1,49 @@ +fileFormatVersion: 2 +guid: 7ae00a082cf8149ea808b748124345a4 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + iPhone: iOS + second: + enabled: 0 + settings: {} + - first: + tvOS: tvOS + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Utilities.swift b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Utilities.swift new file mode 100644 index 0000000..b733ce5 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Utilities.swift @@ -0,0 +1,158 @@ +// +// Utilities.swift +// GuruAnalytics_iOS +// +// Created by mayue on 2022/11/4. +// + +import Foundation +import RxSwift + +internal extension TimeInterval { + + var int64Ms: Int64 { + return Int64(self * 1000) + } + +} + +internal extension Date { + + var msSince1970: Int64 { + timeIntervalSince1970.int64Ms + } + + static var absoluteTimeMs: Int64 { + return CACurrentMediaTime().int64Ms + } + +} + +internal extension Dictionary { + + func jsonString(prettify: Bool = false) -> String? { + guard JSONSerialization.isValidJSONObject(self) else { return nil } + let options = (prettify == true) ? JSONSerialization.WritingOptions.prettyPrinted : JSONSerialization.WritingOptions() + guard let jsonData = try? JSONSerialization.data(withJSONObject: self, options: options) else { return nil } + return String(data: jsonData, encoding: .utf8) + } + +} + +internal extension String { + func convertToDictionary() -> [String: Any]? { + if let data = data(using: .utf8) { + return (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] + } + return nil + } + + mutating func deletePrefix(_ prefix: String) { + guard hasPrefix(prefix) else { return } + if #available(iOS 16.0, *) { + trimPrefix(prefix) + } else { + removeFirst(prefix.count) + } + } + + mutating func trimmed(in set: CharacterSet) { + self = trimmingCharacters(in: set) + } +} + +internal extension Array { + func chunked(into size: Int) -> [[Element]] { + return stride(from: 0, to: count, by: size).map { + Array(self[$0 ..< Swift.min($0 + size, count)]) + } + } +} + +internal class SafeValue { + + private var _value: T + + private let queue = DispatchQueue(label: "com.guru.analytics.safe.value.reader.writer.queue", attributes: .concurrent) + private let group = DispatchGroup() + + internal init(_ value: T) { + _value = value + } + + internal func setValue(_ value: T) { + queue.async(group: group, execute: .init(flags: .barrier, block: { [weak self] in + self?._value = value + })) + } + + internal func getValue(_ valueBlock: @escaping ((T) -> Void)) { + queue.async(group: group, execute: .init(block: { [weak self] in + guard let `self` = self else { return } + valueBlock(self._value) + })) + } + + internal var singleValue: Single { + return Single.create { [weak self] subscriber in + + self?.getValue { value in + subscriber(.success(value)) + } + + return Disposables.create() + } + } +} + +internal extension SafeValue where T == Dictionary { + + func mergeValue(_ value: T) -> Single { + return .create { [weak self] subscriber in + guard let `self` = self else { + subscriber(.failure( + NSError(domain: "safevalue", code: 0, userInfo: [NSLocalizedDescriptionKey : "safevalue object is released"]) + )) + return Disposables.create() + } + self.getValue { currentValue in + let newValue = currentValue.merging(value) { _, new in new } + self.setValue(newValue) + subscriber(.success(())) + } + + return Disposables.create() + } + } +} + +internal extension SafeValue where T == Array { + + func appendValue(_ value: T) { + getValue { [weak self] v in + var currentValue = v + currentValue.append(contentsOf: value) + self?.setValue(currentValue) + } + } + + func removeAll(where shouldBeRemoved: @escaping (Array.Element) -> Bool) { + getValue { [weak self] v in + var currentValue = v + currentValue.removeAll(where: shouldBeRemoved) + self?.setValue(currentValue) + } + } + +} + +internal extension Character { + + var isAlphabetic: Bool { + return (self >= "a" && self <= "z") || (self >= "A" && self <= "Z") + } + + var isDigit: Bool { + return self >= "0" && self <= "9" + } +} diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Utilities.swift.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Utilities.swift.meta new file mode 100644 index 0000000..6769ff2 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics/Classes/Internal/Utility/Utilities.swift.meta @@ -0,0 +1,49 @@ +fileFormatVersion: 2 +guid: 1f70e4103a1d748d4af94d840c55a281 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + iPhone: iOS + second: + enabled: 0 + settings: {} + - first: + tvOS: tvOS + second: + enabled: 1 + settings: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalyticsLib.podspec b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalyticsLib.podspec new file mode 100644 index 0000000..86f1b7e --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalyticsLib.podspec @@ -0,0 +1,54 @@ +# +# Be sure to run `pod lib lint GuruAnalytics.podspec' to ensure this is a +# valid spec before submitting. +# +# Any lines starting with a # are optional, but their use is encouraged +# To learn more about a Podspec see https://guides.cocoapods.org/syntax/podspec.html +# + +Pod::Spec.new do |s| + s.name = 'GuruAnalyticsLib' + s.version = '0.3.6' + s.summary = 'A short description of GuruAnalytics.' + +# This description is used to generate tags and improve search results. +# * Think: What does it do? Why did you write it? What is the focus? +# * Try to keep it short, snappy and to the point. +# * Write the description between the DESC delimiters below. +# * Finally, don't worry about the indent, CocoaPods strips it! + + s.description = <<-DESC +TODO: Add long description of the pod here. + DESC + + s.homepage = 'https://github.com/castbox/GuruAnalytics_iOS' + # s.screenshots = 'www.example.com/screenshots_1', 'www.example.com/screenshots_2' + s.license = { :type => 'MIT', :file => 'LICENSE' } + s.author = { 'devSC' => 'xiaochong2154@163.com' } + # s.source = { :git => 'git@github.com:castbox/GuruAnalytics_iOS.git', :tag => s.version.to_s } + s.source = { :tag => s.version.to_s } + # s.social_media_url = 'https://twitter.com/' + + s.ios.deployment_target = '11.0' + s.swift_version = '5' + s.source_files = 'GuruAnalytics/Classes/**/*' + # s.resource_bundles = { + # 'GuruAnalytics' => ['GuruAnalytics/Assets/*.png'] + # } + + # s.public_header_files = 'Pod/Classes/**/*.h' + # s.frameworks = 'UIKit', 'MapKit' + # s.dependency 'AFNetworking', '~> 2.3' + s.dependency 'RxCocoa', '~> 6.7.0' + s.dependency 'Alamofire', '~> 5.9' + s.dependency 'FMDB', '~> 2.0' + s.dependency 'GzipSwift', '~> 5.0' + s.dependency 'CryptoSwift', '~> 1.0' + s.dependency 'SwiftyBeaver', '~> 1.0' + + s.subspec 'Privacy' do |ss| + ss.resource_bundles = { + s.name => 'GuruAnalytics/Assets/PrivacyInfo.xcprivacy' + } + end +end diff --git a/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalyticsLib.podspec.meta b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalyticsLib.podspec.meta new file mode 100644 index 0000000..f412023 --- /dev/null +++ b/Runtime/GuruAnalytics/Plugins/iOS/GuruAnalyticsLib.podspec.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 09c4804bc0744da2bcc1f3fa6479b8b6 +timeCreated: 1717115698 \ No newline at end of file diff --git a/Runtime/GuruAnalytics/README.md b/Runtime/GuruAnalytics/README.md index 486da4b..4a47be0 100644 --- a/Runtime/GuruAnalytics/README.md +++ b/Runtime/GuruAnalytics/README.md @@ -2,10 +2,28 @@ GuruAnalyticsLib 的 Unity 插件库 +## 研发注意: +- **Android** + - 插件库内的 .aar 通过 [guru_analytics](https://github.com/castbox/guru_analytics) 项目直接构建 ( 命令 `gradle publishToMavenLocal` ) + - 构建后请改名为 `guru-analytics-{version}.aar` + - 请将 .aar 文件放置于 `./Runtime/GuruAnalytics/Plugins/Android` 目录下 +- **iOS** + - 插件库内的文件 通过 [GuruAnalytics_iOS](https://github.com/castbox/GuruAnalytics_iOS) 项目 + - (1) 请将 repo 内的两个文件夹 `Assets` 和 `Classses` 拷贝至 `./Runtime/GuruAnalytics/Plugins/iOS/GuruAnalytics` 目录下: + - (2) 请将部署到 Unity 内所有的 `.swift` 文件的 meta 属性内, 取消 iOS 文件属性. (因为打包时会按照 POD 导入) + - 注意及时更新 `GuruAnalyticsLib.podspec`文件内的更新内容 --- ## Change Logs +### 1.11.0 +- Android 端对齐 `1.0.3` + > Hash: 1978686dbcba38b7b0421d8b6b2bef111356366b +- iOS 端对齐 `0.3.6` + > Hash: 0cd5ce7aa64e12caa7413c938a3164687b973843 +- Pod 库改为 本地文件引用 (配合外部发行项目) + + ### 1.9.0 - Android 端对齐 0.3.1+. > Hash: 0457eba963a9049fb6a16708b921573ef36c99b1 diff --git a/Runtime/GuruAnalytics/Runtime/Script/GuruAnalytics.cs b/Runtime/GuruAnalytics/Runtime/Script/GuruAnalytics.cs index 6f48cfa..3fab73f 100644 --- a/Runtime/GuruAnalytics/Runtime/Script/GuruAnalytics.cs +++ b/Runtime/GuruAnalytics/Runtime/Script/GuruAnalytics.cs @@ -14,7 +14,7 @@ namespace Guru public class GuruAnalytics { // Plugin Version - public const string Version = "1.10.4"; + public const string Version = "1.11.0"; public static readonly string Tag = "[ANU]"; private static readonly string ActionName = "logger_error"; @@ -85,6 +85,8 @@ namespace Guru public static void Init(string appId, string deviceInfo, bool isDebug = false, bool enableErrorLog = false, bool syncProperties = false) { + Debug.Log($"{Tag} --- Guru Analytics [{Version}] initialing..."); + _autoSyncProperties = syncProperties; _enableErrorLog = enableErrorLog; Agent?.Init(appId, deviceInfo, isDebug);