// // PhoneStatusCollector.swift // QuickLocation // import AVFoundation import CoreTelephony import Foundation import Network import UIKit struct PhoneStatusSnapshot: Equatable { let model: String let batteryPercent: Int? let batteryText: String let isCharging: Bool let network: String let brightnessPercent: Int let brightnessText: String let volumePercent: Int let volumeText: String let memoryUsed: String? let memoryTotal: String? let storageRatio: CGFloat let storageText: String } final class PhoneStatusCollector: NSObject { static let shared = PhoneStatusCollector() private let networkMonitor = PhoneNetworkStatusMonitor() private var volumeObservation: NSKeyValueObservation? private var pendingNotify: DispatchWorkItem? private var started = false private override init() { super.init() } func start() { guard !started else { return } started = true UIDevice.current.isBatteryMonitoringEnabled = true networkMonitor.start { [weak self] in self?.emitChange() } let center = NotificationCenter.default center.addObserver( self, selector: #selector(handleChange), name: UIDevice.batteryLevelDidChangeNotification, object: nil ) center.addObserver( self, selector: #selector(handleChange), name: UIDevice.batteryStateDidChangeNotification, object: nil ) center.addObserver( self, selector: #selector(handleChange), name: UIScreen.brightnessDidChangeNotification, object: nil ) center.addObserver( self, selector: #selector(handleChange), name: UIApplication.didBecomeActiveNotification, object: nil ) volumeObservation = AVAudioSession.sharedInstance().observe(\.outputVolume, options: [.new]) { [weak self] _, _ in self?.emitChange() } } func snapshot() -> PhoneStatusSnapshot { start() let batteryPercent = UIDevice.batteryPercent let brightnessPercent = Int((UIScreen.main.brightness * 100).rounded()) let volumePercent = Int((AVAudioSession.sharedInstance().outputVolume * 100).rounded()) let storage = Self.storageInfo() let used = storage?.used let total = storage?.total let ratio: CGFloat if let usedValue = Self.firstNumber(in: used), let totalValue = Self.firstNumber(in: total), totalValue > 0 { ratio = CGFloat(max(0, min(1, usedValue / totalValue))) } else { ratio = 0 } let storageText: String if let used, let total { storageText = "\(used)/\(total)" } else { storageText = "**" } return PhoneStatusSnapshot( model: UIDevice.modelName, batteryPercent: batteryPercent, batteryText: batteryPercent.map { "\($0)%" } ?? "未知", isCharging: UIDevice.isBatteryCharging, network: networkMonitor.statusText, brightnessPercent: brightnessPercent, brightnessText: "\(brightnessPercent)%", volumePercent: volumePercent, volumeText: "\(volumePercent)%", memoryUsed: used, memoryTotal: total, storageRatio: ratio, storageText: storageText ) } @objc private func handleChange() { emitChange() } private func emitChange() { let work = { self.pendingNotify?.cancel() let item = DispatchWorkItem { NotificationCenter.default.post(name: .phoneStatusDidChange, object: nil) } self.pendingNotify = item DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: item) } if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) } } static func storageInfo() -> (used: String, total: String)? { let url = URL(fileURLWithPath: NSHomeDirectory()) let keys: Set = [ .volumeTotalCapacityKey, .volumeAvailableCapacityForImportantUsageKey ] guard let values = try? url.resourceValues(forKeys: keys), let total = values.volumeTotalCapacity, let available = values.volumeAvailableCapacityForImportantUsage, total > 0 else { return nil } let totalBytes = Int64(total) let usedBytes = max(0, totalBytes - available) return ( used: String(format: "%.1fGB", Double(usedBytes) / 1_000_000_000), total: String(format: "%.0fGB", Double(totalBytes) / 1_000_000_000) ) } private static func firstNumber(in value: String?) -> Double? { guard let value, let range = value.range(of: #"\d+(?:\.\d+)?"#, options: .regularExpression) else { return nil } return Double(value[range]) } } private final class PhoneNetworkStatusMonitor { private let monitor = NWPathMonitor() private let queue = DispatchQueue(label: "com.quicklocation.phone.network") private let lock = NSLock() private let telephonyInfo = CTTelephonyNetworkInfo() private var path: NWPath? private var onChange: (() -> Void)? func start(onChange: @escaping () -> Void) { self.onChange = onChange monitor.pathUpdateHandler = { [weak self] path in self?.lock.lock() self?.path = path self?.lock.unlock() onChange() } monitor.start(queue: queue) } var statusText: String { lock.lock() let currentPath = path lock.unlock() guard let currentPath, currentPath.status == .satisfied else { return "无网络" } if currentPath.usesInterfaceType(.wifi) { return "Wi-Fi" } guard currentPath.usesInterfaceType(.cellular) else { return "无网络" } return cellularGeneration } private var cellularGeneration: String { let technologies = telephonyInfo.serviceCurrentRadioAccessTechnology .map { Array($0.values) } ?? [] guard let technology = technologies.first else { return "蜂窝" } switch technology { case CTRadioAccessTechnologyNR, CTRadioAccessTechnologyNRNSA: return "5G" case CTRadioAccessTechnologyLTE: return "4G" case CTRadioAccessTechnologyWCDMA, CTRadioAccessTechnologyHSDPA, CTRadioAccessTechnologyHSUPA, CTRadioAccessTechnologyCDMAEVDORev0, CTRadioAccessTechnologyCDMAEVDORevA, CTRadioAccessTechnologyCDMAEVDORevB, CTRadioAccessTechnologyeHRPD: return "3G" case CTRadioAccessTechnologyGPRS, CTRadioAccessTechnologyEdge, CTRadioAccessTechnologyCDMA1x: return "2G" default: return "蜂窝" } } }