jsdw_ios/QuickLocation/Manager/AppRestrict/AppRestrictManager.swift

403 lines
15 KiB
Swift

//
// AppRestrictManager.swift
// QuickLocation
//
import DeviceActivity
import FamilyControls
import Foundation
import ManagedSettings
import RxSwift
import SwiftyUserDefaults
import UIKit
@available(iOS 16.0, *)
final class AppRestrictManager: NSObject {
static let shared = AppRestrictManager()
private let center = AuthorizationCenter.shared
private let activityCenter = DeviceActivityCenter()
private let activityName = DeviceActivityName(AppRestrictShared.activityName)
private let permissionConfirmationDelay: TimeInterval = 1
private var isPermissionMonitoringStarted = false
private var permissionCheckGeneration = 0
private var permissionCheckWorkItem: DispatchWorkItem?
private var isDeletingAllApps = false
private var deleteAllDisposable: Disposable?
private override init() {
super.init()
}
var authorizationStatus: AuthorizationStatus {
center.authorizationStatus
}
var isAuthorized: Bool {
center.authorizationStatus == .approved
}
var selection: FamilyActivitySelection {
get {
let stored = AppRestrictSharedStore.selection
let normalized = applicationOnlySelection(stored)
if !stored.categoryTokens.isEmpty || !stored.webDomainTokens.isEmpty {
AppRestrictSharedStore.selection = normalized
}
return normalized
}
set {
let normalized = applicationOnlySelection(newValue)
AppRestrictSharedStore.selection = normalized
// Drop enabled tokens that are no longer in selection
let apps = normalized.applicationTokens
AppRestrictSharedStore.enabledTokens = AppRestrictSharedStore.enabledTokens.intersection(apps)
recordPairingOwnerIfNeeded()
refreshMonitoringAndShield()
}
}
var applicationTokens: [ApplicationToken] {
Array(selection.applicationTokens)
}
var enabledTokens: Set<ApplicationToken> {
get { AppRestrictSharedStore.enabledTokens }
set {
AppRestrictSharedStore.enabledTokens = newValue
refreshMonitoringAndShield()
}
}
func requestAuthorization() async throws {
defer { synchronizePermissionState() }
try await center.requestAuthorization(for: .individual)
}
func startPermissionMonitoring() {
guard !isPermissionMonitoringStarted else { return }
isPermissionMonitoringStarted = true
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(currentAccountDidChange),
name: .RefreshUserConfigNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(protectedDataDidBecomeAvailable),
name: UIApplication.protectedDataDidBecomeAvailableNotification,
object: nil
)
}
func mergeSelection(_ incoming: FamilyActivitySelection) {
var current = selection
current.applicationTokens.formUnion(incoming.applicationTokens)
selection = current
}
private func applicationOnlySelection(_ source: FamilyActivitySelection) -> FamilyActivitySelection {
var result = FamilyActivitySelection(includeEntireCategory: true)
result.applicationTokens = source.applicationTokens
return result
}
func isEnabled(_ token: ApplicationToken) -> Bool {
enabledTokens.contains(token)
}
func setEnabled(_ token: ApplicationToken, enabled: Bool) {
var set = enabledTokens
if enabled {
set.insert(token)
} else {
set.remove(token)
}
enabledTokens = set
}
func catalogId(for token: ApplicationToken) -> String? {
AppRestrictSharedStore.catalogId(for: token)
}
func link(catalogId: String, token: ApplicationToken, displayName: String? = nil, iconURL: String? = nil) {
AppRestrictSharedStore.setLink(
catalogId: catalogId,
token: token,
displayName: displayName,
iconURL: iconURL
)
recordPairingOwnerIfNeeded()
}
func catalogItem(for token: ApplicationToken) -> AppCatalogItem? {
guard let record = AppRestrictSharedStore.linkRecord(for: token) else { return nil }
return AppCatalogStore.resolve(link: record)
}
func unlink(_ token: ApplicationToken) {
AppRestrictSharedStore.removeLink(for: token)
}
func removeApplication(_ token: ApplicationToken) {
unlink(token)
var set = enabledTokens
set.remove(token)
enabledTokens = set
var current = selection
current.applicationTokens.remove(token)
selection = current
if !AppRestrictSharedStore.hasPairingData {
Defaults[\.appRestrictPairingOwnerUserId] = ""
}
notifyPairingDataChanged()
}
var shieldConfig: AppRestrictShieldConfig {
get { AppRestrictSharedStore.shieldConfig }
set { AppRestrictSharedStore.shieldConfig = newValue }
}
@discardableResult
func saveCustomShieldImage(_ image: UIImage) -> Bool {
AppRestrictSharedStore.saveCustomImage(image)
}
func applyRemoteLock(tokens: [String], iconIndex: Int, message: String, groupName: String) {
applyRemoteLockAppearance(iconIndex: iconIndex, message: message, groupName: groupName)
guard isAuthorized else {
print("[AppRestrict] remote lock ignored: Screen Time authorization is not approved")
return
}
let incomingTokens = decodedRemoteTokens(tokens)
guard !incomingTokens.isEmpty else {
print("[AppRestrict] remote lock ignored: no decodable application tokens")
return
}
enabledTokens = enabledTokens.union(incomingTokens)
recordPairingOwnerIfNeeded()
print("[AppRestrict] remote lock applied: \(incomingTokens.count) application(s)")
}
func applyRemoteUnlock(tokens: [String]) {
if tokens.isEmpty {
enabledTokens = []
return
}
let incomingTokens = decodedRemoteTokens(tokens)
guard !incomingTokens.isEmpty else {
print("[AppRestrict] remote unlock ignored: no decodable application tokens")
return
}
enabledTokens = enabledTokens.subtracting(incomingTokens)
}
private func decodedRemoteTokens(_ values: [String]) -> Set<ApplicationToken> {
Set(values.compactMap(AppRestrictTokenCodec.decodeBase64))
}
func applyRemoteLockAppearance(iconIndex: Int, message: String, groupName: String) {
let name = groupName.trimmingCharacters(in: .whitespacesAndNewlines)
let displayName = name.isEmpty ? "圈子" : name
var config = shieldConfig
config.title = "APP已被 \(displayName) 锁定"
config.subtitle = message
config.primaryButtonLabel = "打开 极速定位 解锁"
config.imageSource = .album
let index = min(max(iconIndex, 0), 6) + 1
if let image = UIImage(named: "LockDistract/lock_icon_\(index)") {
_ = saveCustomShieldImage(image)
}
shieldConfig = config
}
func refreshMonitoringAndShield() {
let tokens = enabledTokens
AppRestrictSharedStore.applyShield(for: tokens)
activityCenter.stopMonitoring([activityName])
guard !tokens.isEmpty else { return }
// Near-daily schedule so Monitor can re-apply after reboot / schedule boundaries.
let schedule = DeviceActivitySchedule(
intervalStart: DateComponents(hour: 0, minute: 0),
intervalEnd: DateComponents(hour: 23, minute: 59),
repeats: true
)
do {
try activityCenter.startMonitoring(activityName, during: schedule)
} catch {
// Shield already applied above; monitoring is best-effort.
print("[AppRestrict] startMonitoring failed: \(error)")
}
}
@objc private func applicationDidBecomeActive() {
synchronizePermissionState()
}
@objc private func currentAccountDidChange() {
synchronizePermissionState()
}
@objc private func protectedDataDidBecomeAvailable() {
synchronizePermissionState()
}
private func synchronizePermissionState() {
guard Thread.isMainThread else {
DispatchQueue.main.async { [weak self] in
self?.synchronizePermissionState()
}
return
}
guard UIApplication.shared.applicationState == .active,
UIApplication.shared.isProtectedDataAvailable else { return }
let currentUserId = AppContextManager.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines)
var approvedUserIds = Set(Defaults[\.appRestrictApprovedUserIds])
if center.authorizationStatus == .approved {
cancelPendingPermissionCheck()
if !currentUserId.isEmpty {
approvedUserIds.insert(currentUserId)
Defaults[\.appRestrictApprovedUserIds] = Array(approvedUserIds)
if AppRestrictSharedStore.hasPairingData,
Defaults[\.appRestrictPairingOwnerUserId].isEmpty {
Defaults[\.appRestrictPairingOwnerUserId] = currentUserId
}
}
retryPendingDeleteIfNeeded(for: currentUserId)
return
}
retryPendingDeleteIfNeeded(for: currentUserId)
schedulePermissionConfirmation()
}
private func schedulePermissionConfirmation() {
permissionCheckWorkItem?.cancel()
permissionCheckGeneration += 1
let generation = permissionCheckGeneration
let workItem = DispatchWorkItem { [weak self] in
guard let self,
self.permissionCheckGeneration == generation else { return }
self.permissionCheckWorkItem = nil
self.confirmNonApprovedPermissionState()
}
permissionCheckWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + permissionConfirmationDelay, execute: workItem)
}
private func cancelPendingPermissionCheck() {
permissionCheckWorkItem?.cancel()
permissionCheckWorkItem = nil
permissionCheckGeneration += 1
}
private func confirmNonApprovedPermissionState() {
guard UIApplication.shared.applicationState == .active,
UIApplication.shared.isProtectedDataAvailable else { return }
guard center.authorizationStatus != .approved else {
synchronizePermissionState()
return
}
let currentUserId = AppContextManager.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines)
var approvedUserIds = Set(Defaults[\.appRestrictApprovedUserIds])
let pairingOwnerUserId = Defaults[\.appRestrictPairingOwnerUserId]
.trimmingCharacters(in: .whitespacesAndNewlines)
let hasPairingData = AppRestrictSharedStore.hasPairingData
let cleanupUserId: String
if hasPairingData, !pairingOwnerUserId.isEmpty {
cleanupUserId = pairingOwnerUserId
} else if !currentUserId.isEmpty,
hasPairingData || approvedUserIds.contains(currentUserId) {
cleanupUserId = currentUserId
} else if hasPairingData, approvedUserIds.count == 1 {
cleanupUserId = approvedUserIds.first ?? ""
} else if !pairingOwnerUserId.isEmpty,
approvedUserIds.contains(pairingOwnerUserId) {
cleanupUserId = pairingOwnerUserId
} else {
cleanupUserId = ""
}
let permissionWasApproved = !cleanupUserId.isEmpty && approvedUserIds.contains(cleanupUserId)
if hasPairingData || permissionWasApproved {
print("[AppRestrict] permission confirmed unavailable; clearing local pairing data")
if !cleanupUserId.isEmpty {
approvedUserIds.remove(cleanupUserId)
Defaults[\.appRestrictApprovedUserIds] = Array(approvedUserIds)
addPendingDeleteUserId(cleanupUserId)
}
clearLocalPairingData()
}
retryPendingDeleteIfNeeded(for: currentUserId)
}
private func recordPairingOwnerIfNeeded() {
guard AppRestrictSharedStore.hasPairingData,
Defaults[\.appRestrictPairingOwnerUserId].isEmpty else { return }
let userId = AppContextManager.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !userId.isEmpty else { return }
Defaults[\.appRestrictPairingOwnerUserId] = userId
}
private func clearLocalPairingData() {
activityCenter.stopMonitoring([activityName])
AppRestrictSharedStore.clearPairingData()
PhoneLockSession.currentLocks = []
Defaults[\.appRestrictPairingOwnerUserId] = ""
notifyPairingDataChanged()
}
private func notifyPairingDataChanged() {
NotificationCenter.default.post(name: .appRestrictPairingDataDidChange, object: nil)
}
private func addPendingDeleteUserId(_ userId: String) {
var pendingUserIds = Set(Defaults[\.appRestrictPendingDeleteUserIds])
pendingUserIds.insert(userId)
Defaults[\.appRestrictPendingDeleteUserIds] = Array(pendingUserIds)
}
private func retryPendingDeleteIfNeeded(for userId: String) {
guard !userId.isEmpty,
!isDeletingAllApps,
Defaults[\.appRestrictPendingDeleteUserIds].contains(userId) else { return }
isDeletingAllApps = true
deleteAllDisposable = UserService.phoneLockAppsDelete(tokens: [])
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] response in
guard let self else { return }
if response.code == "0" {
var pendingUserIds = Set(Defaults[\.appRestrictPendingDeleteUserIds])
pendingUserIds.remove(userId)
Defaults[\.appRestrictPendingDeleteUserIds] = Array(pendingUserIds)
} else {
print("[AppRestrict] delete all paired apps failed: \(response.message ?? "unknown error")")
}
self.finishDeleteAllRequest()
}, onError: { [weak self] error in
print("[AppRestrict] delete all paired apps failed: \(error.gatewayMessage ?? error.localizedDescription)")
self?.finishDeleteAllRequest()
})
}
private func finishDeleteAllRequest() {
isDeletingAllApps = false
deleteAllDisposable = nil
}
}