jsdw_ios/QuickLocation/Manager/MQTT/PhoneStatusCollector.swift

227 lines
7.1 KiB
Swift

//
// 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<URLResourceKey> = [
.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 "蜂窝"
}
}
}