432 lines
16 KiB
Swift
432 lines
16 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 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
|
||
}
|
||
}
|