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);