336 lines
13 KiB
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(text: error.gatewayMessage ?? error.localizedDescription)
|
|
})
|
|
.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(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()
|
|
}
|
|
}
|