227 lines
7.1 KiB
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 "蜂窝"
|
|
}
|
|
}
|
|
}
|