// // AppRestrictVC.swift // QuickLocation // import FamilyControls import UIKit import RxSwift import RxCocoa import ManagedSettings @available(iOS 16.0, *) final class AppRestrictVC: BaseViewController { private var rootView: AppRestrictView! private var tokens: [ApplicationToken] = [] private var linkingToken: ApplicationToken? private var lockedRecords: [PhoneLockRecord] = [] private var deletingTokenStrings: Set = [] override func loadView() { rootView = AppRestrictView(frame: UIScreen.main.bounds) view = rootView } override func viewDidLoad() { super.viewDidLoad() rootView.tableView.dataSource = self rootView.tableView.delegate = self rootView.tableView.register(AppRestrictCell.self, forCellReuseIdentifier: AppRestrictCell.reuseId) bind() reload() queryLockedApps() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) queryLockedApps() } private func bind() { rootView.navView.onBackTapped = { [weak self] in self?.navigationController?.popViewController(animated: true) } rootView.addBtn.rx.tap.subscribe(onNext: { [weak self] in self?.addApps() }).disposed(by: disposeBag) NotificationCenter.default.rx.notification(.appRestrictPairingDataDidChange) .subscribe(onNext: { [weak self] _ in self?.lockedRecords = [] self?.reload() }) .disposed(by: disposeBag) } private func reload() { tokens = AppRestrictManager.shared.applicationTokens rootView.setListVisible(!tokens.isEmpty, rowCount: tokens.count) rootView.tableView.reloadData() } private func addApps() { Task { @MainActor in do { if !AppRestrictManager.shared.isAuthorized { try await AppRestrictManager.shared.requestAuthorization() } guard AppRestrictManager.shared.isAuthorized else { DLToast.show(text: "需要屏幕使用时间权限") return } let initialSelection = AppRestrictManager.shared.selection let presenter = FamilyActivityPickerPresenter(selection: initialSelection) presenter.onComplete = { [weak self] selection in let addedApplications = selection.applicationTokens .subtracting(initialSelection.applicationTokens) AppRestrictManager.shared.mergeSelection(selection) self?.reload() if addedApplications.isEmpty, !selection.categoryTokens.isEmpty { self?.showCategorySelectionNotice() } } present(presenter, animated: false) } catch { DLToast.show(text: "授权失败,请在设置中开启屏幕使用时间权限") } } } private func showCategorySelectionNotice() { let alert = UIAlertController( title: "请选择具体应用", message: "系统不会返回分类“全部”中的具体应用。请展开分类,取消“全部”,再逐个勾选需要配对的应用。", preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "重新选择", style: .default) { [weak self] _ in self?.addApps() }) present(alert, animated: true) } private func openLink(for token: ApplicationToken) { linkingToken = token let vc = SelectActivityVC() vc.onSelect = { [weak self] item in guard let self, let token = self.linkingToken else { return } AppRestrictManager.shared.link( catalogId: item.id, token: token, displayName: item.name, iconURL: item.iconURL ) self.linkingToken = nil self.reload() self.syncPairedApp(token: token, item: item) } let nav = UINavigationController(rootViewController: vc) present(nav, animated: true) } private func encodedTokenString(_ token: ApplicationToken) -> String? { AppRestrictTokenCodec.encode(token)?.base64EncodedString() } private func syncPairedApp(token: ApplicationToken, item: AppCatalogItem) { guard let tokenString = encodedTokenString(token), !tokenString.isEmpty else { DLToast.show(text: "应用 token 编码失败") return } DLToast.showLoading() resolveIconFileId(for: item) .flatMap { fileId in UserService.phoneLockApp(token: tokenString, icon: fileId) } .subscribe(onNext: { _ in DLToast.show(text: "配对成功") }, onError: { error in DLToast.show(text: error.gatewayMessage ?? error.localizedDescription) }) .disposed(by: disposeBag) } private func resolveIconFileId(for item: AppCatalogItem) -> Observable { UserService.queryPhoneUsageIcon(package: item.name) .catch { error -> Observable in if Self.isTransportError(error) { return .error(error) } return .just(.empty) } .flatMap { [weak self] response -> Observable in let existing = response.fileId.trimmingCharacters(in: .whitespacesAndNewlines) if !existing.isEmpty { return .just(existing) } guard let self else { return .error(NSError(domain: "AppRestrict", code: -1, userInfo: [ NSLocalizedDescriptionKey: "配对已取消" ])) } return self.uploadAndRegisterIcon(for: item) } } private func uploadAndRegisterIcon(for item: AppCatalogItem) -> Observable { jpegData(for: item) .flatMap { data in UploadService.upload(data, kind: .jpeg, scene: "app_icon") } .flatMap { uploadedId in UserService.savePhoneUsageIcon(package: item.name, icon: uploadedId) .map { response in let registered = response.fileId.trimmingCharacters(in: .whitespacesAndNewlines) return registered.isEmpty ? uploadedId : registered } .catchAndReturn(uploadedId) } } private func jpegData(for item: AppCatalogItem) -> Observable { if let urlString = item.iconURL?.trimmingCharacters(in: .whitespacesAndNewlines), let url = URL(string: urlString) { return Observable.create { observer in let task = URLSession.shared.dataTask(with: url) { data, _, error in if let error { DispatchQueue.main.async { observer.onError(error) } return } guard let data, let image = UIImage(data: data), let jpeg = image.jpegData(compressionQuality: 0.8) else { DispatchQueue.main.async { observer.onError(NSError(domain: "AppRestrict", code: -1, userInfo: [ NSLocalizedDescriptionKey: "下载应用图标失败" ])) } return } DispatchQueue.main.async { observer.onNext(jpeg) observer.onCompleted() } } task.resume() return Disposables.create { task.cancel() } } } guard let image = AppCatalogStore.image(for: item), let jpeg = image.jpegData(compressionQuality: 0.8) else { return Observable.error(NSError(domain: "AppRestrict", code: -1, userInfo: [ NSLocalizedDescriptionKey: "缺少应用图标" ])) } return .just(jpeg) } private static func isTransportError(_ error: Error) -> Bool { let nsError = error.underlyingError ?? error as NSError return nsError.domain == NSURLErrorDomain } private func queryLockedApps() { let userId = AppContextManager.shared.userId.trimmed let lockStateRevision = AppUnlockCoordinator.shared.currentLockStateRevision() UserService.phoneLocked(os: "ios") .observe(on: MainScheduler.instance) .subscribe(onNext: { [weak self] response in guard let locks = AppUnlockCoordinator.shared.locksForRestoration( response.model?.locks ?? [], startedAt: lockStateRevision, userId: userId ) else { return } self?.lockedRecords = locks if !locks.isEmpty { PhoneLockSession.currentLocks = locks } }) .disposed(by: disposeBag) } private func lockRecord(for token: ApplicationToken) -> PhoneLockRecord? { guard let encoded = encodedTokenString(token), !encoded.isEmpty else { return nil } return lockedRecords.first { $0.tokens.contains(encoded) } ?? PhoneLockSession.currentLocks.first { $0.tokens.contains(encoded) } } private func deleteApp(_ token: ApplicationToken) { let records = lockedRecords.isEmpty ? PhoneLockSession.currentLocks : lockedRecords if !records.isEmpty { let record = lockRecord(for: token) ?? records.first if let record { LockedAppPopView.show(record) return } } guard let tokenString = encodedTokenString(token), !tokenString.isEmpty else { DLToast.show(text: "应用 token 编码失败") return } guard !deletingTokenStrings.contains(tokenString) else { return } deletingTokenStrings.insert(tokenString) DLToast.showLoading() UserService.phoneLockAppsDelete(tokens: [tokenString]) .observe(on: MainScheduler.instance) .subscribe(onNext: { [weak self] response in self?.deletingTokenStrings.remove(tokenString) guard response.code == "0" else { let message = response.message?.trimmingCharacters(in: .whitespacesAndNewlines) let displayMessage = message.flatMap { $0.isEmpty ? nil : $0 } ?? "删除失败,请稍后重试" DLToast.show(text: displayMessage) return } AppRestrictManager.shared.removeApplication(token) DLToast.show(text: "删除成功") }, onError: { [weak self] error in self?.deletingTokenStrings.remove(tokenString) DLToast.show(text: error.gatewayMessage ?? error.localizedDescription) }) .disposed(by: UserService.disposeBag) } } @available(iOS 16.0, *) extension AppRestrictVC: UITableViewDataSource, UITableViewDelegate { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { tokens.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: AppRestrictCell.reuseId, for: indexPath) as! AppRestrictCell let token = tokens[indexPath.row] let catalog = AppRestrictManager.shared.catalogItem(for: token) cell.configure(token: token, catalog: catalog) cell.onPair = { [weak self] in self?.openLink(for: token) } cell.onDelete = { [weak self] in self?.deleteApp(token) } return cell } } /// Fallback when system is below iOS 16. final class AppRestrictUnsupportedVC: BaseViewController { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .white let nav = BaseNavigationView(title: "应用配对库") view.addSubview(nav) nav.layoutChain.top().edgesHorzontal().height(kNaviHeight) nav.onBackTapped = { [weak self] in self?.navigationController?.popViewController(animated: true) } let lab = UILabel() lab.text = "该功能需要 iOS 16 及以上系统" lab.textAlignment = .center lab.textColor = UIColor(hexStr: "#767676") view.addSubview(lab) lab.layoutChain.center() } } enum AppRestrictFactory { static func make() -> UIViewController { if #available(iOS 16.0, *) { return AppRestrictVC() } return AppRestrictUnsupportedVC() } }