jsdw_ios/QuickLocation/Section/Home/PhoneReportDetail/PhoneReportDetailViewModel....

457 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// PhoneReportDetailViewModel.swift
// QuickLocation
//
import Foundation
import RxSwift
import RxCocoa
import UIKit
import AVFoundation
import SystemConfiguration.CaptiveNetwork
import NetworkExtension
import CoreLocation
import SwiftyUserDefaults
final class PhoneReportDetailViewModel {
enum ChartMode: Equatable {
case day
case week
}
struct ChartBar {
let label: String
let value: Int
let isToday: Bool
}
struct StatusSnapshot {
let batteryText: String
let batteryPercent: Int?
let isCharging: Bool
let estimateText: String
let brightnessText: String
let volumeText: String
let wifiText: String
let deviceText: String
let storageText: String
let storageRatio: CGFloat
let screenTodayText: String
let unlockTodayText: String
let usageTodayText: String
let screenBars: [ChartBar]
let unlockBars: [ChartBar]
func replacing(wifiText: String? = nil, volumeText: String? = nil) -> StatusSnapshot {
StatusSnapshot(
batteryText: batteryText,
batteryPercent: batteryPercent,
isCharging: isCharging,
estimateText: estimateText,
brightnessText: brightnessText,
volumeText: volumeText ?? self.volumeText,
wifiText: wifiText ?? self.wifiText,
deviceText: deviceText,
storageText: storageText,
storageRatio: storageRatio,
screenTodayText: screenTodayText,
unlockTodayText: unlockTodayText,
usageTodayText: usageTodayText,
screenBars: screenBars,
unlockBars: unlockBars
)
}
}
let members: [GroupMemberModel]
let selectedMemberId: BehaviorRelay<String>
let screenMode = BehaviorRelay<ChartMode>(value: .day)
let unlockMode = BehaviorRelay<ChartMode>(value: .day)
let snapshot: BehaviorRelay<StatusSnapshot>
private let disposeBag = DisposeBag()
private let placeholder = "**"
private var volumeObservation: NSKeyValueObservation?
init(members: [GroupMemberModel], selectedUserId: String) {
UIDevice.current.isBatteryMonitoringEnabled = true
self.members = members
self.selectedMemberId = BehaviorRelay(value: selectedUserId)
self.snapshot = BehaviorRelay(value: Self.emptySnapshot())
bindInputs()
refresh(userId: selectedUserId)
}
private func bindInputs() {
selectedMemberId
.asObservable()
.skip(1)
.subscribe(onNext: { [weak self] userId in
self?.refresh(userId: userId)
})
.disposed(by: disposeBag)
screenMode
.asObservable()
.skip(1)
.subscribe(onNext: { [weak self] _ in
guard let self else { return }
self.refresh(userId: self.selectedMemberId.value)
})
.disposed(by: disposeBag)
unlockMode
.asObservable()
.skip(1)
.subscribe(onNext: { [weak self] _ in
guard let self else { return }
self.refresh(userId: self.selectedMemberId.value)
})
.disposed(by: disposeBag)
NotificationCenter.default.rx.notification(.unlockCountDidChange)
.subscribe(onNext: { [weak self] _ in
guard let self else { return }
self.refresh(userId: self.selectedMemberId.value)
})
.disposed(by: disposeBag)
// /
let batteryCenter = NotificationCenter.default
Observable.merge(
batteryCenter.rx.notification(UIDevice.batteryLevelDidChangeNotification),
batteryCenter.rx.notification(UIDevice.batteryStateDidChangeNotification)
)
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] _ in
guard let self else { return }
guard self.selectedMemberId.value == AppContextManager.shared.userId else { return }
self.refresh(userId: self.selectedMemberId.value)
})
.disposed(by: disposeBag)
observeSystemVolume()
}
private func observeSystemVolume() {
let session = AVAudioSession.sharedInstance()
try? session.setActive(true)
volumeObservation = session.observe(\.outputVolume, options: [.new]) { [weak self] _, _ in
DispatchQueue.main.async {
guard let self else { return }
guard self.selectedMemberId.value == AppContextManager.shared.userId else { return }
let volume = Self.volumeText() ?? self.placeholder
self.snapshot.accept(self.snapshot.value.replacing(volumeText: volume))
}
}
}
private func refresh(userId: String) {
let isSelf = userId == AppContextManager.shared.userId
let member = members.first { $0.user_id == userId }
if isSelf {
snapshot.accept(makeSelfSnapshot())
} else {
snapshot.accept(makeOtherSnapshot(member: member))
}
}
private func makeSelfSnapshot() -> StatusSnapshot {
let mgr = UnlockCountManager.shared
let batteryPercent = UIDevice.batteryPercent
let isCharging = UIDevice.isBatteryCharging
let screenSec = mgr.todayScreenTimeSeconds
let unlock = mgr.todayCount
let usage = mgr.todayAppUsageCount
let batteryText = batteryPercent.map { "\($0)%" } ?? placeholder
let brightnessText = Self.brightnessText() ?? placeholder
let volumeText = Self.volumeText() ?? placeholder
let wifiText = Self.wifiSSID() ?? placeholder
let storageText = Self.storageText() ?? placeholder
let storageRatio = Self.storageRatio() ?? 0
let screenTodayText = screenSec > 0 ? Self.formatDuration(screenSec) : placeholder
let screenBars = Self.screenBars(from: mgr, mode: screenMode.value)
let unlockBars = Self.unlockBars(unlock: unlock, mode: unlockMode.value)
// SSIDiOS 14+
Self.fetchWifiSSIDAsync { [weak self] ssid in
guard let self, let ssid, !ssid.isEmpty else { return }
guard self.selectedMemberId.value == AppContextManager.shared.userId else { return }
let current = self.snapshot.value
guard current.wifiText != ssid else { return }
self.snapshot.accept(current.replacing(wifiText: ssid))
}
return StatusSnapshot(
batteryText: batteryText,
batteryPercent: batteryPercent,
isCharging: isCharging,
estimateText: placeholder,
brightnessText: brightnessText,
volumeText: volumeText,
wifiText: wifiText,
deviceText: UIDevice.modelName,
storageText: storageText,
storageRatio: storageRatio,
screenTodayText: screenTodayText,
unlockTodayText: "\(unlock)",
usageTodayText: "\(usage)",
screenBars: screenBars,
unlockBars: unlockBars
)
}
private func makeOtherSnapshot(member: GroupMemberModel?) -> StatusSnapshot {
let battery = Int(member?.battery.int ?? 0)
let hasBattery = battery > 0
return StatusSnapshot(
batteryText: hasBattery ? "\(battery)%" : placeholder,
batteryPercent: hasBattery ? battery : nil,
isCharging: false,
estimateText: placeholder,
brightnessText: placeholder,
volumeText: placeholder,
wifiText: placeholder,
deviceText: placeholder,
storageText: placeholder,
storageRatio: 0,
screenTodayText: placeholder,
unlockTodayText: placeholder,
usageTodayText: placeholder,
screenBars: [],
unlockBars: []
)
}
private static func emptySnapshot() -> StatusSnapshot {
StatusSnapshot(
batteryText: "**",
batteryPercent: nil,
isCharging: false,
estimateText: "**",
brightnessText: "**",
volumeText: "**",
wifiText: "**",
deviceText: "**",
storageText: "**",
storageRatio: 0,
screenTodayText: "**",
unlockTodayText: "**",
usageTodayText: "**",
screenBars: [],
unlockBars: []
)
}
private static func formatDuration(_ seconds: Int) -> String {
let hours = seconds / 3600
let minutes = (seconds % 3600) / 60
if hours > 0 { return minutes > 0 ? "\(hours)h \(minutes)m" : "\(hours)h" }
return "\(minutes)m"
}
private static func brightnessText() -> String? {
let value = UIScreen.main.brightness
guard value >= 0 else { return nil }
return "\(Int(value * 100))%"
}
private static func volumeText() -> String? {
let session = AVAudioSession.sharedInstance()
do {
try session.setActive(true)
} catch {
//
}
let volume = session.outputVolume
guard volume >= 0 else { return nil }
return "\(Int((volume * 100).rounded()))%"
}
private static func hasLocationPermissionForWifi() -> Bool {
let status = CLLocationManager.authorizationStatus()
switch status {
case .authorizedAlways, .authorizedWhenInUse:
return true
default:
return false
}
}
private static func wifiSSID() -> String? {
guard hasLocationPermissionForWifi() else { return nil }
guard let interfaces = CNCopySupportedInterfaces() as? [String] else { return nil }
for name in interfaces {
if let info = CNCopyCurrentNetworkInfo(name as CFString) as? [String: Any],
let ssid = info[kCNNetworkInfoKeySSID as String] as? String,
!ssid.isEmpty {
return ssid
}
}
return nil
}
/// iOS 14+ WiFi SSID + Access WiFi Information
private static func fetchWifiSSIDAsync(completion: @escaping (String?) -> Void) {
guard hasLocationPermissionForWifi() else {
completion(nil)
return
}
if #available(iOS 14.0, *) {
NEHotspotNetwork.fetchCurrent { network in
DispatchQueue.main.async {
let ssid = network?.ssid
completion((ssid?.isEmpty == false) ? ssid : nil)
}
}
} else {
completion(wifiSSID())
}
}
/// iPhone GB + ImportantUsage
private static func storageBytes() -> (used: Int64, total: Int64)? {
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 used = max(0, Int64(total) - available)
return (used, Int64(total))
}
private static func formatStorageGB(_ bytes: Int64, fractionDigits: Int) -> String {
// Settings 使1GB = 1e9128G 128GB 119GB
let gb = Double(bytes) / 1_000_000_000
return String(format: "%.\(fractionDigits)fGB", gb)
}
private static func storageText() -> String? {
guard let info = storageBytes() else { return nil }
let usedText = formatStorageGB(info.used, fractionDigits: 1)
let totalText = formatStorageGB(info.total, fractionDigits: 0)
return "\(usedText)/\(totalText)"
}
private static func storageRatio() -> CGFloat? {
guard let info = storageBytes(), info.total > 0 else { return nil }
return CGFloat(Double(info.used) / Double(info.total))
}
private static func screenBars(from mgr: UnlockCountManager, mode: ChartMode) -> [ChartBar] {
let byDay = Defaults[\.screenTimeByDay]
let todayValue = mgr.todayScreenTimeSeconds
switch mode {
case .day:
return dayBars(byDay: byDay, todayValue: todayValue, labelStyle: .monthDaySlash)
case .week:
return weekBars(byDay: byDay, todayValue: todayValue)
}
}
private static func unlockBars(unlock: Int, mode: ChartMode) -> [ChartBar] {
var byDay = Defaults[\.unlockCountByDay]
let todayKey = dayKey(for: Date())
byDay[todayKey] = unlock
switch mode {
case .day:
return dayBars(byDay: byDay, todayValue: unlock, labelStyle: .monthDay)
case .week:
return weekBars(byDay: byDay, todayValue: unlock)
}
}
private enum DayLabelStyle {
case monthDaySlash // 08/04
case monthDay // 8/4
}
private static func dayKey(for date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.calendar = Calendar.current
formatter.timeZone = TimeZone.current
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
private static func dayBars(
byDay: [String: Int],
todayValue: Int,
labelStyle: DayLabelStyle
) -> [ChartBar] {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
let labelFormatter = DateFormatter()
labelFormatter.locale = Locale(identifier: "en_US_POSIX")
switch labelStyle {
case .monthDaySlash:
labelFormatter.dateFormat = "MM/dd"
case .monthDay:
labelFormatter.dateFormat = "M/d"
}
return (0..<7).map { offset -> ChartBar in
let dayOffset = offset - 6
let date = calendar.date(byAdding: .day, value: dayOffset, to: today) ?? today
let key = dayKey(for: date)
let isToday = dayOffset == 0
let value = isToday ? todayValue : (byDay[key] ?? 0)
return ChartBar(
label: labelFormatter.string(from: date),
value: value,
isToday: isToday
)
}
}
/// 7 label M/d
private static func weekBars(byDay: [String: Int], todayValue: Int) -> [ChartBar] {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
guard let currentWeek = calendar.dateInterval(of: .weekOfYear, for: today) else {
return dayBars(byDay: byDay, todayValue: todayValue, labelStyle: .monthDay)
}
let labelFormatter = DateFormatter()
labelFormatter.locale = Locale(identifier: "en_US_POSIX")
labelFormatter.dateFormat = "M/d"
let todayKey = dayKey(for: today)
return (0..<7).map { offset -> ChartBar in
let weekOffset = offset - 6
let weekStart = calendar.date(byAdding: .weekOfYear, value: weekOffset, to: currentWeek.start)
?? currentWeek.start
let weekInterval = calendar.dateInterval(of: .weekOfYear, for: weekStart)
?? DateInterval(start: weekStart, duration: 7 * 24 * 3600)
var sum = 0
var cursor = weekInterval.start
while cursor < weekInterval.end {
let key = dayKey(for: cursor)
if key == todayKey {
sum += todayValue
} else {
sum += byDay[key] ?? 0
}
guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
cursor = next
}
return ChartBar(
label: labelFormatter.string(from: weekInterval.start),
value: sum,
isToday: weekOffset == 0
)
}
}
}