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

363 lines
13 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 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,
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) {
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)
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
UIDevice.current.isBatteryMonitoringEnabled = true
let batteryLevel = UIDevice.current.batteryLevel
let batteryValid = batteryLevel >= 0
let battery = Int(batteryLevel * 100)
let screenSec = mgr.todayScreenTimeSeconds
let unlock = mgr.todayCount
let usage = mgr.todayAppUsageCount
let batteryText = batteryValid ? "\(battery)%" : placeholder
let batteryPercent: Int? = batteryValid ? battery : nil
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,
estimateText: placeholder,
brightnessText: brightnessText,
volumeText: volumeText,
wifiText: wifiText,
deviceText: UIDevice.current.model,
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,
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,
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())
}
}
private static func storageText() -> String? {
guard
let attrs = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()),
let total = attrs[.systemSize] as? NSNumber,
let free = attrs[.systemFreeSize] as? NSNumber
else { return nil }
let used = total.doubleValue - free.doubleValue
let usedGB = used / 1_073_741_824
let totalGB = total.doubleValue / 1_073_741_824
return String(format: "%.1fGB/%.0fGB", usedGB, totalGB)
}
private static func storageRatio() -> CGFloat? {
guard
let attrs = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()),
let total = attrs[.systemSize] as? NSNumber,
let free = attrs[.systemFreeSize] as? NSNumber,
total.doubleValue > 0
else { return nil }
return CGFloat((total.doubleValue - free.doubleValue) / total.doubleValue)
}
private static func screenBars(from mgr: UnlockCountManager, mode: ChartMode) -> [ChartBar] {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
let byDay = Defaults[\.screenTimeByDay]
return (0..<7).map { offset -> ChartBar in
let dayOffset = offset - 6
let date = calendar.date(byAdding: .day, value: dayOffset, to: today) ?? today
let key = formatter.string(from: date)
let label = formatter.string(from: date).suffix(5).replacingOccurrences(of: "-", with: "/")
let value: Int
if dayOffset == 0 {
value = mgr.todayScreenTimeSeconds
} else {
value = byDay[key] ?? 0
}
// week mode: keep same day bars for now (UI toggle still works)
_ = mode
return ChartBar(label: String(label), value: value, isToday: dayOffset == 0)
}
}
private static func unlockBars(unlock: Int, mode: ChartMode) -> [ChartBar] {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
let formatter = DateFormatter()
formatter.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 isToday = dayOffset == 0
_ = mode
return ChartBar(
label: formatter.string(from: date),
value: isToday ? unlock : 0,
isToday: isToday
)
}
}
}