// // ReviewPermissionsViewController.swift // AltStore // // Created by Riley Testut on 11/6/23. // Copyright © 2023 Riley Testut. All rights reserved. // import UIKit import SwiftUI import AltSign import AltStoreCore import Roxas @available(iOS 15, *) extension ReviewPermissionsViewController { private enum Section: Int { case known case unknown case approve } } @available(iOS 15, *) class ReviewPermissionsViewController: UICollectionViewController { let app: AppProtocol let permissions: [ALTEntitlement] let permissionsMode: VerifyAppOperation.PermissionReviewMode var completionHandler: ((Result) -> Void)? private let knownPermissions: [any ALTAppPermission] private let unknownPermissions: [any ALTAppPermission] private lazy var dataSource = self.makeDataSource() private lazy var knownPermissionsDataSource = self.makeKnownPermissionsDataSource() private lazy var unknownPermissionsDataSource = self.makeUnknownPermissionsDataSource() private var headerRegistration: UICollectionView.SupplementaryRegistration! init(app: AppProtocol, permissions: [ALTEntitlement], mode: VerifyAppOperation.PermissionReviewMode) { self.app = app self.permissions = permissions self.permissionsMode = mode let sortedPermissions = permissions.sorted { $0.localizedDisplayName.localizedStandardCompare($1.localizedDisplayName) == .orderedAscending } let knownPermissions = sortedPermissions.filter { $0.isKnown } let unknownPermissions = sortedPermissions.filter { !$0.isKnown } self.knownPermissions = knownPermissions self.unknownPermissions = unknownPermissions super.init(collectionViewLayout: UICollectionViewFlowLayout()) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() let buttonAppearance = UIBarButtonItemAppearance(style: .plain) buttonAppearance.normal.titleTextAttributes = [.foregroundColor: UIColor.white] let appearance = UINavigationBarAppearance() appearance.configureWithOpaqueBackground() appearance.backgroundColor = UIColor(resource: .gradientTop) appearance.titleTextAttributes = [.foregroundColor: UIColor.white] appearance.buttonAppearance = buttonAppearance self.navigationItem.standardAppearance = appearance self.title = NSLocalizedString("Review Permissions", comment: "") let collectionViewLayout = self.makeLayout() self.collectionView.collectionViewLayout = collectionViewLayout if #available(iOS 16, *) { self.collectionView.backgroundView = UIHostingConfiguration { LinearGradient(colors: [Color(UIColor(resource: .gradientTop)), Color(.gradientBottom)], startPoint: .top, endPoint: .bottom) } .margins(.all, 0) .makeContentView() } else { self.collectionView.backgroundColor = UIColor(resource: .gradientBottom) } self.dataSource.proxy = self self.collectionView.dataSource = self.dataSource self.collectionView.register(UICollectionViewListCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier) self.collectionView.register(UICollectionViewListCell.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: UICollectionView.elementKindSectionHeader) let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(ReviewPermissionsViewController.cancel)) self.navigationItem.leftBarButtonItem = cancelButton self.navigationController?.isModalInPresentation = true self.prepareCollectionView() } } @available(iOS 15, *) extension ReviewPermissionsViewController { func makeLayout() -> UICollectionViewCompositionalLayout { let layout = UICollectionViewCompositionalLayout { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in guard let self, let section = Section(rawValue: sectionIndex) else { return nil } var configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped) configuration.showsSeparators = true configuration.separatorConfiguration.color = UIColor(resource: .gradientBottom).withAlphaComponent(0.7) configuration.separatorConfiguration.bottomSeparatorInsets.leading = 20 configuration.backgroundColor = .clear switch section { case .known: configuration.headerMode = .supplementary // Always show header even if no known permissions case .unknown: configuration.headerMode = self.unknownPermissionsDataSource.items.isEmpty ? .none : .supplementary case .approve: configuration.headerMode = .none } let layoutSection = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: layoutEnvironment) layoutSection.contentInsets.top = 15 switch section { case .known: if self.knownPermissions.isEmpty { layoutSection.contentInsets.top = 0 layoutSection.contentInsets.bottom = 20 } else if self.unknownPermissions.isEmpty { layoutSection.contentInsets.bottom = 20 } else { layoutSection.contentInsets.bottom = 44 } case .unknown: if self.unknownPermissions.isEmpty { layoutSection.contentInsets.top = 0 layoutSection.contentInsets.bottom = 0 } else { layoutSection.contentInsets.bottom = 20 } case .approve: layoutSection.contentInsets.bottom = 44 } return layoutSection } return layout } func prepareCollectionView() { self.headerRegistration = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader) { (headerView, elementKind, indexPath) in var configuration = UIListContentConfiguration.prominentInsetGroupedHeader() configuration.textProperties.color = .white configuration.secondaryTextProperties.color = .white.withAlphaComponent(0.8) configuration.textToSecondaryTextVerticalPadding = 8 switch Section(rawValue: indexPath.section)! { case .known: configuration.text = nil switch self.permissionsMode { case .all: configuration.secondaryText = String(localized: "“\(self.app.name)” will be automatically given these permissions once installed.") case .added: configuration.secondaryText = String(localized: "This version of “\(self.app.name)” requires additional permissions.") case .none: break } case .unknown: configuration.text = NSLocalizedString("Additional Permissions", comment: "") configuration.secondaryText = String(format: NSLocalizedString("These are permissions required by “%@” that SideStore does not recognize. Make sure you understand them before continuing.", comment: ""), self.app.name) case .approve: break } headerView.contentConfiguration = configuration headerView.backgroundConfiguration = .clear() } } func makeDataSource() -> RSTCompositeCollectionViewDataSource { let approveDataSource = RSTDynamicCollectionViewDataSource() approveDataSource.numberOfSectionsHandler = { 1 } approveDataSource.numberOfItemsHandler = { _ in 1 } approveDataSource.cellConfigurationHandler = { cell, _, indexPath in let cell = cell as! UICollectionViewListCell var config = cell.defaultContentConfiguration() config.text = NSLocalizedString("Approve", comment: "") config.textProperties.color = .white config.textProperties.font = .preferredFont(forTextStyle: .headline) config.textProperties.alignment = .center config.directionalLayoutMargins.top = 15 config.directionalLayoutMargins.bottom = 15 cell.contentConfiguration = config cell.configurationUpdateHandler = { cell, state in var content = config.updated(for: state) // Change text color when highlighted if state.isHighlighted { content.textProperties.color = .white.withAlphaComponent(0.5) } cell.contentConfiguration = content } var backgroundConfig = UIBackgroundConfiguration.listGroupedCell() backgroundConfig.backgroundColor = UIColor(resource: .darkButtonBackground) backgroundConfig.visualEffect = nil cell.backgroundConfiguration = backgroundConfig } let dataSource = RSTCompositeCollectionViewDataSource(dataSources: [self.knownPermissionsDataSource, self.unknownPermissionsDataSource, approveDataSource]) return dataSource } func makeKnownPermissionsDataSource() -> RSTArrayCollectionViewDataSource { let dataSource = RSTArrayCollectionViewDataSource(items: self.knownPermissions.map { $0.rawValue as NSString }) dataSource.cellConfigurationHandler = { [weak self] cell, permission, indexPath in let cell = cell as! UICollectionViewListCell let permission = ALTEntitlement(rawValue: permission as String) self?.configure(cell, permission: permission) } return dataSource } func makeUnknownPermissionsDataSource() -> RSTArrayCollectionViewDataSource { let dataSource = RSTArrayCollectionViewDataSource(items: self.unknownPermissions.map { $0.rawValue as NSString }) dataSource.cellConfigurationHandler = { [weak self] cell, permission, indexPath in let cell = cell as! UICollectionViewListCell let permission = ALTEntitlement(rawValue: permission as String) self?.configure(cell, permission: permission) } return dataSource } func configure(_ cell: UICollectionViewListCell, permission: ALTEntitlement) { var config = cell.defaultContentConfiguration() config.text = permission.localizedDisplayName config.textProperties.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body).bolded(), size: 0.0) config.textProperties.color = .label config.textToSecondaryTextVerticalPadding = 5.0 config.directionalLayoutMargins.top = 20 config.directionalLayoutMargins.bottom = 20 config.secondaryTextProperties.font = UIFont.preferredFont(forTextStyle: .subheadline) config.secondaryTextProperties.color = .white.withAlphaComponent(0.8) config.imageProperties.tintColor = .white config.imageToTextPadding = 20 config.directionalLayoutMargins.leading = 20 if permission.isKnown { let symbolConfig = UIImage.SymbolConfiguration(scale: .large) config.image = UIImage(systemName: permission.effectiveSymbolName, withConfiguration: symbolConfig) config.secondaryText = permission.localizedDescription } else { config.image = nil config.secondaryText = permission.rawValue } cell.contentConfiguration = config var backgroundConfiguration = UIBackgroundConfiguration.clear() backgroundConfiguration.backgroundColor = .white.withAlphaComponent(0.25) backgroundConfiguration.visualEffect = UIVibrancyEffect(blurEffect: .init(style: .systemMaterial), style: .fill) cell.backgroundConfiguration = backgroundConfiguration // Ensure text is legible on gradient background. cell.overrideUserInterfaceStyle = .dark } } @available(iOS 15, *) private extension ReviewPermissionsViewController { @objc func cancel() { self.completionHandler?(.failure(CancellationError())) self.completionHandler = nil } } @available(iOS 15, *) extension ReviewPermissionsViewController { override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { let headerView = self.collectionView.dequeueConfiguredReusableSupplementary(using: self.headerRegistration, for: indexPath) return headerView } override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard let section = Section(rawValue: indexPath.section), section == .approve else { return } self.completionHandler?(.success(())) self.completionHandler = nil } } @available(iOS 17, *) #Preview(traits: .portrait) { DatabaseManager.shared.startForPreview() let app = AnyApp(name: "Delta", bundleIdentifier: "com.rileytestut.Delta", url: nil, storeApp: nil) let permissions: [ALTEntitlement] = [ .getTaskAllow, .appGroups, .interAppAudio, .keychainAccessGroups, .init("com.apple.developer.extended-virtual-addressing"), .init("com.apple.developer.increased-memory-limit") ] let reviewPermissionsViewController = ReviewPermissionsViewController(app: app, permissions: permissions, mode: .all) let navigationController = UINavigationController(rootViewController: reviewPermissionsViewController) return navigationController }