update: GuruAnalytics 双端升级 a:1.0.3 i:3.6.0, iOS 内置 POD 库
--story=1020280 --user=yufei.hu 【中台】【发行】将 GuruAnalytics 库升级到最新的版本, 将线上的 Pods 依赖改为 UPM 内部文件依赖 https://www.tapd.cn/33527076/s/1147993 Signed-off-by: huyufei <yufei.hu@castbox.fm>hotfix/v1.0.12.2
							parent
							
								
									d5c02418c7
								
							
						
					
					
						commit
						6df7530aa8
					
				|  | @ -20,16 +20,11 @@ Sample Dependencies.xml: | |||
|         <androidPackage spec="com.squareup.retrofit2:converter-gson:2.7.1" /> | ||||
|         <androidPackage spec="com.squareup.retrofit2:adapter-rxjava2:2.7.1" /> | ||||
|         <androidPackage spec="com.squareup.okhttp3:okhttp:4.9.3" /> | ||||
|          | ||||
| <!--        <androidPackage spec="com.mapzen:on-the-road:0.8.1" />--> | ||||
| <!--        <androidPackage spec="com.squareup.retrofit2:retrofit:2.7.1" />--> | ||||
|     </androidPackages> | ||||
|     <iosPods> | ||||
|         <iosPod name="GuruAnalyticsLib" version="0.3.3" bitcodeEnabled="false"> | ||||
|             <sources> | ||||
|                 <source>git@github.com:castbox/GuruSpecs.git</source> | ||||
|             </sources> | ||||
|         </iosPod> | ||||
|         <iosPod name="GuruAnalyticsLib" bitcodeEnabled="false" path="Packages/com.guru.unity.sdk.core/Runtime/GuruAnalytics/Plugins/iOS" /> | ||||
|         <iosPod name="JJException" bitcodeEnabled="false" /> | ||||
|     </iosPods> | ||||
| </dependencies> | ||||
|  |  | |||
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							|  | @ -0,0 +1,3 @@ | |||
| fileFormatVersion: 2 | ||||
| guid: caf6ff09835a4a75bff1b4b068f664ef | ||||
| timeCreated: 1717117895 | ||||
|  | @ -0,0 +1,8 @@ | |||
| fileFormatVersion: 2 | ||||
| guid: e53e2bfca0fd949559d383674081f737 | ||||
| folderAsset: yes | ||||
| DefaultImporter: | ||||
|   externalObjects: {} | ||||
|   userData:  | ||||
|   assetBundleName:  | ||||
|   assetBundleVariant:  | ||||
|  | @ -0,0 +1,8 @@ | |||
| fileFormatVersion: 2 | ||||
| guid: 502f707bde2a24fadb6ec09ac5a3593f | ||||
| folderAsset: yes | ||||
| DefaultImporter: | ||||
|   externalObjects: {} | ||||
|   userData:  | ||||
|   assetBundleName:  | ||||
|   assetBundleVariant:  | ||||
|  | @ -0,0 +1,41 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||||
| <plist version="1.0"> | ||||
| <dict> | ||||
| 	<key>NSPrivacyAccessedAPITypes</key> | ||||
| 	<array> | ||||
| 		<dict> | ||||
| 			<key>NSPrivacyAccessedAPITypeReasons</key> | ||||
| 			<array> | ||||
| 				<string>CA92.1</string> | ||||
| 			</array> | ||||
| 			<key>NSPrivacyAccessedAPIType</key> | ||||
| 			<string>NSPrivacyAccessedAPICategoryUserDefaults</string> | ||||
| 		</dict> | ||||
| 		<dict> | ||||
| 			<key>NSPrivacyAccessedAPITypeReasons</key> | ||||
| 			<array> | ||||
| 				<string>C617.1</string> | ||||
| 			</array> | ||||
| 			<key>NSPrivacyAccessedAPIType</key> | ||||
| 			<string>NSPrivacyAccessedAPICategoryFileTimestamp</string> | ||||
| 		</dict> | ||||
| 		<dict> | ||||
| 			<key>NSPrivacyAccessedAPITypeReasons</key> | ||||
| 			<array> | ||||
| 				<string>35F9.1</string> | ||||
| 				<string>8FFB.1</string> | ||||
| 				<string>3D61.1</string> | ||||
| 			</array> | ||||
| 			<key>NSPrivacyAccessedAPIType</key> | ||||
| 			<string>NSPrivacyAccessedAPICategorySystemBootTime</string> | ||||
| 		</dict> | ||||
| 	</array> | ||||
| 	<key>NSPrivacyTracking</key> | ||||
| 	<false/> | ||||
| 	<key>NSPrivacyTrackingDomains</key> | ||||
| 	<array> | ||||
| 		<string></string> | ||||
| 	</array> | ||||
| </dict> | ||||
| </plist> | ||||
|  | @ -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:  | ||||
|  | @ -0,0 +1,8 @@ | |||
| fileFormatVersion: 2 | ||||
| guid: d30316515c87a4421bc7032194f888e1 | ||||
| folderAsset: yes | ||||
| DefaultImporter: | ||||
|   externalObjects: {} | ||||
|   userData:  | ||||
|   assetBundleName:  | ||||
|   assetBundleVariant:  | ||||
|  | @ -0,0 +1,8 @@ | |||
| fileFormatVersion: 2 | ||||
| guid: e0086576c1ac64707b788bef25dc9316 | ||||
| folderAsset: yes | ||||
| DefaultImporter: | ||||
|   externalObjects: {} | ||||
|   userData:  | ||||
|   assetBundleName:  | ||||
|   assetBundleVariant:  | ||||
|  | @ -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 | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -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:  | ||||
|  | @ -0,0 +1,8 @@ | |||
| fileFormatVersion: 2 | ||||
| guid: c6cbae57da78c46c7918b2bfd24d7335 | ||||
| folderAsset: yes | ||||
| DefaultImporter: | ||||
|   externalObjects: {} | ||||
|   userData:  | ||||
|   assetBundleName:  | ||||
|   assetBundleVariant:  | ||||
|  | @ -0,0 +1,8 @@ | |||
| fileFormatVersion: 2 | ||||
| guid: a9b9cc55c438041a7ae3ce46bd896d8d | ||||
| folderAsset: yes | ||||
| DefaultImporter: | ||||
|   externalObjects: {} | ||||
|   userData:  | ||||
|   assetBundleName:  | ||||
|   assetBundleVariant:  | ||||
|  | @ -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 | ||||
|     } | ||||
| } | ||||
|  | @ -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:  | ||||
|  | @ -0,0 +1,8 @@ | |||
| fileFormatVersion: 2 | ||||
| guid: bb7dde11f0ad6496ca231330136b7b61 | ||||
| folderAsset: yes | ||||
| DefaultImporter: | ||||
|   externalObjects: {} | ||||
|   userData:  | ||||
|   assetBundleName:  | ||||
|   assetBundleVariant:  | ||||
|  | @ -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<FMDatabaseQueue?>(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<Void> { | ||||
|         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<Void> { | ||||
|         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<Void> { | ||||
|         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<Void> { | ||||
|         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<Int> { | ||||
|         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<Int> { | ||||
|         return NotificationCenter.default.rx.notification(tableUpdateNotification(TableName.event.rawValue)) | ||||
|             .startWith(Notification(name: tableUpdateNotification(TableName.event.rawValue))) | ||||
|             .flatMap({ [weak self] (_) -> Observable<Int> in | ||||
|                 guard let `self` = self else { | ||||
|                     return Observable.empty() | ||||
|                 } | ||||
|                 return self.uploadableEventRecordCount().asObservable() | ||||
|             }) | ||||
|     } | ||||
|      | ||||
|     func hasFgEventRecord() -> Single<Bool> { | ||||
|         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<Bool> { | ||||
|         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<T>(_ transaction: @escaping ((FMDatabase) throws -> T)) -> Single<T> { | ||||
|         return dbQueueRelay.compactMap({ $0 }) | ||||
|             .take(1) | ||||
|             .asSingle() | ||||
|             .flatMap { [unowned self] queue -> Single<T> in | ||||
|                 return self.mapTransactionToSingle(queue: queue, transaction) | ||||
|             } | ||||
|     } | ||||
|      | ||||
|     func mapTransactionToSingle<T>(queue: FMDatabaseQueue, _ transaction: @escaping ((FMDatabase) throws -> T)) -> Single<T> { | ||||
|         return Single<T>.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<Void> { | ||||
|          | ||||
|         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<Void> { | ||||
|         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 | ||||
| } | ||||
|  | @ -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:  | ||||
|  | @ -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<Int64> { | ||||
|          | ||||
|         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<Void>() | ||||
|      | ||||
|     /// 同步服务器时间触发器 | ||||
|     private let syncServerTrigger = PublishSubject<Void>() | ||||
|      | ||||
|     /// 轮询上传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<Entity.EventRecord> { | ||||
|          | ||||
|         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<Int64> 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<Void> { | ||||
|         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<Void> 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<Void> { | ||||
|         eventsLogger.verbose(#function + " eventName: \(eventName)" + " params: \(parameters?.jsonString() ?? "")") | ||||
|         return { [weak self] () -> Single<Void> in | ||||
|             guard let `self` = self else { return Observable<Void>.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<Void> { | ||||
|         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<Void> 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<Int>.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<Int>.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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -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:  | ||||
|  | @ -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" | ||||
|     } | ||||
| } | ||||
|  | @ -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:  | ||||
|  | @ -0,0 +1,8 @@ | |||
| fileFormatVersion: 2 | ||||
| guid: b2af081c64bfe4af7b232b8132d01544 | ||||
| folderAsset: yes | ||||
| DefaultImporter: | ||||
|   externalObjects: {} | ||||
|   userData:  | ||||
|   assetBundleName:  | ||||
|   assetBundleVariant:  | ||||
|  | @ -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") | ||||
|     } | ||||
| } | ||||
|  | @ -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:  | ||||
|  | @ -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<ParametersKeys> { | ||||
|         var paramKeyType: ParametersKeys.Type | ||||
|         var name: String | ||||
|     } | ||||
|      | ||||
|     enum FgEventParametersKeys: String { | ||||
|         case duration | ||||
|     } | ||||
|      | ||||
| } | ||||
|  | @ -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:  | ||||
|  | @ -0,0 +1,8 @@ | |||
| fileFormatVersion: 2 | ||||
| guid: ef02d71253914417f9d007629ed0eb1d | ||||
| folderAsset: yes | ||||
| DefaultImporter: | ||||
|   externalObjects: {} | ||||
|   userData:  | ||||
|   assetBundleName:  | ||||
|   assetBundleVariant:  | ||||
|  | @ -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 | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -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:  | ||||
|  | @ -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<Bool> { | ||||
|         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<Int64> { | ||||
|         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<String?> { | ||||
|         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<T>(_ error: Error, for host: String, returnValue: (() -> Single<T>) ) throws -> Single<T> { | ||||
|          | ||||
|         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 | ||||
|     } | ||||
| } | ||||
|  | @ -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:  | ||||
|  | @ -0,0 +1,8 @@ | |||
| fileFormatVersion: 2 | ||||
| guid: 8675c92422b8d45f49ced2e0f1602f32 | ||||
| folderAsset: yes | ||||
| DefaultImporter: | ||||
|   externalObjects: {} | ||||
|   userData:  | ||||
|   assetBundleName:  | ||||
|   assetBundleVariant:  | ||||
|  | @ -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 | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|      | ||||
| } | ||||
|  | @ -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:  | ||||
|  | @ -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 | ||||
|     } | ||||
| } | ||||
|  | @ -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:  | ||||
|  | @ -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 | ||||
| } | ||||
|  | @ -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:  | ||||
|  | @ -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<T>(_ 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) | ||||
|     } | ||||
| } | ||||
|  | @ -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:  | ||||
|  | @ -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<URL?> { | ||||
|          | ||||
|         return Single.create { subscriber in | ||||
|             subscriber(.success(nil)) | ||||
|             return Disposables.create() | ||||
|         } | ||||
|         .observe(on: MainScheduler.asyncInstance) | ||||
|     } | ||||
|      | ||||
|     func logFilesDirURL() -> Single<URL?> { | ||||
|          | ||||
|         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) | ||||
|     } | ||||
| } | ||||
|  | @ -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:  | ||||
|  | @ -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" | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -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:  | ||||
|  | @ -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<T> { | ||||
|      | ||||
|     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<T> { | ||||
|         return Single.create { [weak self] subscriber in | ||||
|              | ||||
|             self?.getValue { value in | ||||
|                 subscriber(.success(value)) | ||||
|             } | ||||
|              | ||||
|             return Disposables.create() | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| internal extension SafeValue where T == Dictionary<String, String> { | ||||
|      | ||||
|     func mergeValue(_ value: T) -> Single<Void> { | ||||
|         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<String> { | ||||
|      | ||||
|     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<String>.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" | ||||
|     } | ||||
| } | ||||
|  | @ -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:  | ||||
|  | @ -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/<TWITTER_USERNAME>' | ||||
| 
 | ||||
|   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 | ||||
|  | @ -0,0 +1,3 @@ | |||
| fileFormatVersion: 2 | ||||
| guid: 09c4804bc0744da2bcc1f3fa6479b8b6 | ||||
| timeCreated: 1717115698 | ||||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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); | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue