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
胡宇飞 2024-05-31 13:33:59 +08:00
parent d5c02418c7
commit 6df7530aa8
54 changed files with 3910 additions and 11 deletions

View File

@ -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>

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: caf6ff09835a4a75bff1b4b068f664ef
timeCreated: 1717117895

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e53e2bfca0fd949559d383674081f737
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 502f707bde2a24fadb6ec09ac5a3593f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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>

View File

@ -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:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: d30316515c87a4421bc7032194f888e1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e0086576c1ac64707b788bef25dc9316
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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: headerX-APP-ID
/// - saasXDEVICEINFO: headerX-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)
}
/// IDuid
@objc
public class func setUserID(_ userID: String?) {
setUserProperty(userID, forName: .uid)
}
/// IDIDiOSIDFVUUIDAndroidandroidID
@objc
public class func setDeviceId(_ deviceId: String?) {
setUserProperty(deviceId, forName: .deviceId)
}
/// adjust_idadjust
@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)
}
/// eventszip
/// zipCastbox123
@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
}
}

View File

@ -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:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c6cbae57da78c46c7918b2bfd24d7335
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a9b9cc55c438041a7ae3ce46bd896d8d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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 {
///IDuid
let uid: String?
///IDIDiOSIDFVUUIDAndroidandroidID
let deviceId: String?
///adjust_idadjust
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? // APPJSON
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
}
}

View File

@ -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:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bb7dde11f0ad6496ca231330136b7b61
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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
/// eventpriority
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
/// eventtransitionStatus
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
}

View File

@ -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:

View File

@ -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()
/// 11
private var scheduleInterval: TimeInterval = GuruAnalytics.uploadPeriodInSecond
/// 251
private var numberOfCountPerConsume: Int = GuruAnalytics.batchLimit
/// event7
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: ())
/// eventslogger
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 fgevents
_ = 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: eventnumberOfCountPerConsume
/// 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)
}
}
}

View File

@ -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:

View File

@ -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"
}
}

View File

@ -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:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b2af081c64bfe4af7b232b8132d01544
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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")
}
}

View File

@ -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:

View File

@ -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
}
}

View File

@ -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:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ef02d71253914417f9d007629ed0eb1d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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
}
}
}

View File

@ -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:

View File

@ -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
}
}

View File

@ -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:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8675c92422b8d45f49ced2e0f1602f32
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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
}
}
}
}

View File

@ -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:

View File

@ -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
}
}

View File

@ -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:

View File

@ -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
}

View File

@ -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:

View File

@ -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)
}
}

View File

@ -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:

View File

@ -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)
}
}

View File

@ -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:

View File

@ -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"
}
}
}

View File

@ -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:

View File

@ -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"
}
}

View File

@ -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:

View File

@ -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

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 09c4804bc0744da2bcc1f3fa6479b8b6
timeCreated: 1717115698

View File

@ -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

View File

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