jsdw_ios/QuickLocation/Section/AppRestrict/AppRestrictVC.swift

336 lines
13 KiB
Swift

//
// 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<String> = []
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(error)
})
.disposed(by: disposeBag)
}
private func resolveIconFileId(for item: AppCatalogItem) -> Observable<String> {
UserService.queryPhoneUsageIcon(package: item.name)
.catch { error -> Observable<PhoneUsageIconResponse> in
if Self.isTransportError(error) {
return .error(error)
}
return .just(.empty)
}
.flatMap { [weak self] response -> Observable<String> 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<String> {
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<Data> {
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(error)
})
.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()
}
}