420 lines
18 KiB
Swift
420 lines
18 KiB
Swift
//
|
|
// 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
|
|
}
|
|
}
|