403 lines
15 KiB
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
|
|
}
|
|
}
|