jsdw_ios/QuickLocation/Manager/AppRestrict/AppRestrictManager.swift

432 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.

//
// 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 checkScreenTimePermissionAfterLogin() {
guard Thread.isMainThread else {
DispatchQueue.main.async { [weak self] in
self?.checkScreenTimePermissionAfterLogin()
}
return
}
let currentUserId = AppContextManager.shared.userId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !currentUserId.isEmpty else { return }
if center.authorizationStatus == .approved {
var approvedUserIds = Set(Defaults[\.appRestrictApprovedUserIds])
approvedUserIds.insert(currentUserId)
Defaults[\.appRestrictApprovedUserIds] = Array(approvedUserIds)
if AppRestrictSharedStore.hasPairingData,
Defaults[\.appRestrictPairingOwnerUserId].isEmpty {
Defaults[\.appRestrictPairingOwnerUserId] = currentUserId
}
retryPendingDeleteIfNeeded(for: currentUserId)
return
}
addPendingDeleteUserId(currentUserId)
clearLocalPairingData()
retryPendingDeleteIfNeeded(for: currentUserId)
}
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
}
}