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