diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 5aa17f24..4eedae8f 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -104,7 +104,6 @@ BF3432FB246B894F0052F4A1 /* BackupAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF3432FA246B894F0052F4A1 /* BackupAppOperation.swift */; }; BF3BEFBF2408673400DE7D55 /* FetchProvisioningProfilesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF3BEFBE2408673400DE7D55 /* FetchProvisioningProfilesOperation.swift */; }; BF3BEFC124086A1E00DE7D55 /* RefreshAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF3BEFC024086A1E00DE7D55 /* RefreshAppOperation.swift */; }; - BF3D649D22E7AC1B00E9056B /* PermissionPopoverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF3D649C22E7AC1B00E9056B /* PermissionPopoverViewController.swift */; }; BF3D649F22E7B24C00E9056B /* CollapsingTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF3D649E22E7B24C00E9056B /* CollapsingTextView.swift */; }; BF3D64B022E8D4B800E9056B /* AppContentViewControllerCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF3D64AF22E8D4B800E9056B /* AppContentViewControllerCells.swift */; }; BF41B806233423AE00C593A3 /* TabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF41B805233423AE00C593A3 /* TabBarController.swift */; }; @@ -349,10 +348,14 @@ D51AD27F29356B7B00967AAA /* ALTWrappedError.m in Sources */ = {isa = PBXBuildFile; fileRef = D51AD27D29356B7B00967AAA /* ALTWrappedError.m */; }; D51AD28029356B8000967AAA /* ALTWrappedError.m in Sources */ = {isa = PBXBuildFile; fileRef = D51AD27D29356B7B00967AAA /* ALTWrappedError.m */; }; D52C08EE28AEC37A006C4AE5 /* AppVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */; }; + D52EF2BE2A0594550096C377 /* AppDetailCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52EF2BD2A0594550096C377 /* AppDetailCollectionViewController.swift */; }; D533E8B72727841800A9B5DD /* libAppleArchive.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D533E8B62727841800A9B5DD /* libAppleArchive.tbd */; settings = {ATTRIBUTES = (Weak, ); }; }; D533E8BE2727BBF800A9B5DD /* libcurl.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D533E8BD2727BBF800A9B5DD /* libcurl.a */; }; + D54058B92A1D6269008CCC58 /* AppPermissionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54058B82A1D6269008CCC58 /* AppPermissionProtocol.swift */; }; + D54058BB2A1D8FE3008CCC58 /* UIColor+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */; }; D540E93828EE1BDE000F1B0F /* ErrorDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D540E93728EE1BDE000F1B0F /* ErrorDetailsViewController.swift */; }; D54DED1428CBC44B008B27A0 /* ErrorLogTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */; }; + D552B1D82A042A740066216F /* AppPermissionsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D552B1D72A042A740066216F /* AppPermissionsCard.swift */; }; D55E163728776CB700A627A1 /* ComplicationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55E163528776CB000A627A1 /* ComplicationView.swift */; }; D561B2EB28EF5A4F006752E4 /* AltSign-Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = D561B2EA28EF5A4F006752E4 /* AltSign-Dynamic */; }; D561B2ED28EF5A4F006752E4 /* AltSign-Dynamic in Embed Frameworks */ = {isa = PBXBuildFile; productRef = D561B2EA28EF5A4F006752E4 /* AltSign-Dynamic */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; @@ -673,7 +676,6 @@ BF3432FA246B894F0052F4A1 /* BackupAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupAppOperation.swift; sourceTree = ""; }; BF3BEFBE2408673400DE7D55 /* FetchProvisioningProfilesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchProvisioningProfilesOperation.swift; sourceTree = ""; }; BF3BEFC024086A1E00DE7D55 /* RefreshAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshAppOperation.swift; sourceTree = ""; }; - BF3D649C22E7AC1B00E9056B /* PermissionPopoverViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionPopoverViewController.swift; sourceTree = ""; }; BF3D649E22E7B24C00E9056B /* CollapsingTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsingTextView.swift; sourceTree = ""; }; BF3D64AF22E8D4B800E9056B /* AppContentViewControllerCells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppContentViewControllerCells.swift; sourceTree = ""; }; BF41B805233423AE00C593A3 /* TabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarController.swift; sourceTree = ""; }; @@ -910,12 +912,16 @@ D51AD27D29356B7B00967AAA /* ALTWrappedError.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ALTWrappedError.m; sourceTree = ""; }; D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = ""; }; D52E988928D002D30032BE6B /* AltStore 11.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 11.xcdatamodel"; sourceTree = ""; }; + D52EF2BD2A0594550096C377 /* AppDetailCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetailCollectionViewController.swift; sourceTree = ""; }; D533E8B62727841800A9B5DD /* libAppleArchive.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libAppleArchive.tbd; path = usr/lib/libAppleArchive.tbd; sourceTree = SDKROOT; }; D533E8B82727B61400A9B5DD /* fragmentzip.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = fragmentzip.h; sourceTree = ""; }; D533E8BB2727BBEE00A9B5DD /* libfragmentzip.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libfragmentzip.a; path = Dependencies/fragmentzip/libfragmentzip.a; sourceTree = SOURCE_ROOT; }; D533E8BD2727BBF800A9B5DD /* libcurl.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libcurl.a; path = Dependencies/libcurl/libcurl.a; sourceTree = SOURCE_ROOT; }; + D54058B82A1D6269008CCC58 /* AppPermissionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermissionProtocol.swift; sourceTree = ""; }; + D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+AltStore.swift"; sourceTree = ""; }; D540E93728EE1BDE000F1B0F /* ErrorDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorDetailsViewController.swift; sourceTree = ""; }; D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorLogTableViewCell.swift; sourceTree = ""; }; + D552B1D72A042A740066216F /* AppPermissionsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermissionsCard.swift; sourceTree = ""; }; D55E163528776CB000A627A1 /* ComplicationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComplicationView.swift; sourceTree = ""; }; D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OperatingSystemVersion+Comparable.swift"; sourceTree = ""; }; D571ADCD2A02FA7400B24B63 /* SourceError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceError.swift; sourceTree = ""; }; @@ -1237,7 +1243,8 @@ BF8F69C322E662D300049BA1 /* AppViewController.swift */, BF8F69C122E659F700049BA1 /* AppContentViewController.swift */, BF3D64AF22E8D4B800E9056B /* AppContentViewControllerCells.swift */, - BF3D649C22E7AC1B00E9056B /* PermissionPopoverViewController.swift */, + D52EF2BD2A0594550096C377 /* AppDetailCollectionViewController.swift */, + D552B1D72A042A740066216F /* AppPermissionsCard.swift */, ); path = "App Detail"; sourceTree = ""; @@ -1729,6 +1736,7 @@ BFD2478D2284C4C700981D42 /* Components */, BF3D648922E79A7700E9056B /* Types */, BFD2479D2284FBBD00981D42 /* Extensions */, + D54058B72A1D6251008CCC58 /* Previews */, BFD247962284D7C100981D42 /* Resources */, BF6C8FA8242935CA00125131 /* Dependencies */, BFD247972284D7D800981D42 /* Supporting Files */, @@ -1811,6 +1819,7 @@ B376FE3D29258C8900E18883 /* OSLog+SideStore.swift */, 0E13E5852CC8F55900E9C0DF /* ProcessInfo+SideStore.swift */, D5927D6529DCC89000D6898E /* UINavigationBarAppearance+TintColor.swift */, + D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */, ); path = Extensions; sourceTree = ""; @@ -1943,6 +1952,14 @@ path = Errors; sourceTree = ""; }; + D54058B72A1D6251008CCC58 /* Previews */ = { + isa = PBXGroup; + children = ( + D54058B82A1D6269008CCC58 /* AppPermissionProtocol.swift */, + ); + path = Previews; + sourceTree = ""; + }; D586D39928EF58B0000E101F /* AltTests */ = { isa = PBXGroup; children = ( @@ -2719,7 +2736,6 @@ BFDB6A0F22AB2776007EA6D6 /* SendAppOperation.swift in Sources */, BFDB6A0D22AAFC1A007EA6D6 /* OperationError.swift in Sources */, BF74989B23621C0700CED65F /* ForwardingNavigationController.swift in Sources */, - BF3D649D22E7AC1B00E9056B /* PermissionPopoverViewController.swift in Sources */, D57F2C9426E01BC700B9FA39 /* UIDevice+Vibration.swift in Sources */, BFD2478F2284C8F900981D42 /* Button.swift in Sources */, D540E93828EE1BDE000F1B0F /* ErrorDetailsViewController.swift in Sources */, @@ -2773,6 +2789,7 @@ BFE338E822F10E56002E24B9 /* LaunchViewController.swift in Sources */, BFA8172B23C5633D001B5953 /* FetchAnisetteDataOperation.swift in Sources */, BF9ABA4722DD0638008935CF /* BrowseCollectionViewCell.swift in Sources */, + D552B1D82A042A740066216F /* AppPermissionsCard.swift in Sources */, BFD6B03322DFF20800B86064 /* MyAppsComponents.swift in Sources */, BF41B808233433C100C593A3 /* LoadingState.swift in Sources */, BFF0B69A2322D7D0007A79E1 /* UIScreen+CompactHeight.swift in Sources */, @@ -2814,13 +2831,17 @@ BF9ABA4922DD0742008935CF /* ScreenshotCollectionViewCell.swift in Sources */, BF9ABA4D22DD16DE008935CF /* PillButton.swift in Sources */, D5DAE0962804DF430034D8D4 /* UpdatePatronsOperation.swift in Sources */, + D54058BB2A1D8FE3008CCC58 /* UIColor+AltStore.swift in Sources */, BFE6326C22A86FF300F30809 /* AuthenticationOperation.swift in Sources */, BFF435D8255CBDAB00DD724F /* ALTApplication+AltStoreApp.swift in Sources */, BF4B78FE24B3D1DB008AB4AC /* SceneDelegate.swift in Sources */, BF6C8FB02429599900125131 /* TextCollectionReusableView.swift in Sources */, BF663C4F2433ED8200DAA738 /* FileManager+DirectorySize.swift in Sources */, D57DF63F271E51E400677701 /* ALTAppPatcher.m in Sources */, + D54058B92A1D6269008CCC58 /* AppPermissionProtocol.swift in Sources */, BFB6B220231870B00022A802 /* NewsCollectionViewCell.swift in Sources */, + BFB3645A2325985F00CD0EB1 /* FindServerOperation.swift in Sources */, + D52EF2BE2A0594550096C377 /* AppDetailCollectionViewController.swift in Sources */, BF3BEFBF2408673400DE7D55 /* FetchProvisioningProfilesOperation.swift in Sources */, BFF0B69023219C6D007A79E1 /* PatreonComponents.swift in Sources */, BFBE0004250ACFFB0080826E /* ViewApp.intentdefinition in Sources */, diff --git a/AltStore/App Detail/AppContentViewController.swift b/AltStore/App Detail/AppContentViewController.swift index 5ad7f7bd..5df19c86 100644 --- a/AltStore/App Detail/AppContentViewController.swift +++ b/AltStore/App Detail/AppContentViewController.swift @@ -30,7 +30,6 @@ final class AppContentViewController: UITableViewController var app: StoreApp! private lazy var screenshotsDataSource = self.makeScreenshotsDataSource() - private lazy var permissionsDataSource = self.makePermissionsDataSource() private lazy var dateFormatter: DateFormatter = { let dateFormatter = DateFormatter() @@ -52,7 +51,9 @@ final class AppContentViewController: UITableViewController @IBOutlet private var sizeLabel: UILabel! @IBOutlet private var screenshotsCollectionView: UICollectionView! - @IBOutlet private var permissionsCollectionView: UICollectionView! + + @IBOutlet private(set) var appDetailCollectionViewController: AppDetailCollectionViewController! + @IBOutlet private var appDetailCollectionViewHeightConstraint: NSLayoutConstraint! var preferredScreenshotSize: CGSize? { let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout @@ -76,8 +77,6 @@ final class AppContentViewController: UITableViewController self.screenshotsCollectionView.dataSource = self.screenshotsDataSource self.screenshotsCollectionView.prefetchDataSource = self.screenshotsDataSource - self.permissionsCollectionView.dataSource = self.permissionsDataSource - self.subtitleLabel.text = self.app.subtitle self.descriptionTextView.text = self.app.localizedDescription @@ -112,28 +111,12 @@ final class AppContentViewController: UITableViewController let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout layout.itemSize = size - } - - override func prepare(for segue: UIStoryboardSegue, sender: Any?) - { - guard segue.identifier == "showPermission" else { return } - guard let cell = sender as? UICollectionViewCell, let indexPath = self.permissionsCollectionView.indexPath(for: cell) else { return } - - let permission = self.permissionsDataSource.item(at: indexPath) - - let maximumWidth = self.view.bounds.width - 20 - - let permissionPopoverViewController = segue.destination as! PermissionPopoverViewController - permissionPopoverViewController.permission = permission - permissionPopoverViewController.view.widthAnchor.constraint(lessThanOrEqualToConstant: maximumWidth).isActive = true - - let size = permissionPopoverViewController.view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) - permissionPopoverViewController.preferredContentSize = size - - permissionPopoverViewController.popoverPresentationController?.delegate = self - permissionPopoverViewController.popoverPresentationController?.sourceRect = cell.frame - permissionPopoverViewController.popoverPresentationController?.sourceView = self.permissionsCollectionView + let permissionsHeight = self.appDetailCollectionViewController.collectionView.contentSize.height + if self.appDetailCollectionViewHeightConstraint.constant != permissionsHeight && permissionsHeight > 0 + { + self.appDetailCollectionViewHeightConstraint.constant = permissionsHeight + } } } @@ -192,6 +175,14 @@ private extension AppContentViewController return dataSource } + + @IBSegueAction + func makeAppDetailCollectionViewController(_ coder: NSCoder, sender: Any?) -> UIViewController? + { + let appDetailViewController = AppDetailCollectionViewController(app: self.app, coder: coder) + self.appDetailCollectionViewController = appDetailViewController + return appDetailViewController + } } private extension AppContentViewController @@ -231,18 +222,10 @@ extension AppContentViewController case .permissions: guard !self.app.permissions.isEmpty else { return 0.0 } - return super.tableView(tableView, heightForRowAt: indexPath) + return UITableView.automaticDimension default: return super.tableView(tableView, heightForRowAt: indexPath) } } } - -extension AppContentViewController: UIPopoverPresentationControllerDelegate -{ - func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle - { - return .none - } -} diff --git a/AltStore/App Detail/AppDetailCollectionViewController.swift b/AltStore/App Detail/AppDetailCollectionViewController.swift new file mode 100644 index 00000000..f19cf089 --- /dev/null +++ b/AltStore/App Detail/AppDetailCollectionViewController.swift @@ -0,0 +1,249 @@ +// +// AppDetailCollectionViewController.swift +// AltStore +// +// Created by Riley Testut on 5/5/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit +import SwiftUI + +import AltStoreCore +import Roxas + +extension AppDetailCollectionViewController +{ + private enum Section: Int + { + case privacy + case entitlements + } + + private enum ElementKind: String + { + case title + case button + } + + @objc(SafeAreaIgnoringCollectionView) + private class SafeAreaIgnoringCollectionView: UICollectionView + { + override var safeAreaInsets: UIEdgeInsets { + get { + // Fixes incorrect layout if collection view height is taller than safe area height. + return .zero + } + set { + // There MUST be a setter for this to work, even if it does nothing ¯\_(ツ)_/¯ + } + } + } +} + +class AppDetailCollectionViewController: UICollectionViewController +{ + let app: StoreApp + private let privacyPermissions: [AppPermission] + private let entitlementPermissions: [AppPermission] + + private lazy var dataSource = self.makeDataSource() + private lazy var privacyDataSource = self.makePrivacyDataSource() + private lazy var entitlementsDataSource = self.makeEntitlementsDataSource() + + private var headerRegistration: UICollectionView.SupplementaryRegistration! + + override var collectionViewLayout: UICollectionViewCompositionalLayout { + return self.collectionView.collectionViewLayout as! UICollectionViewCompositionalLayout + } + + init?(app: StoreApp, coder: NSCoder) + { + self.app = app + + let comparator: (AppPermission, AppPermission) -> Bool = { (permissionA, permissionB) -> Bool in + switch (permissionA.localizedName, permissionB.localizedName) + { + case (let nameA?, let nameB?): + // Sort by localizedName, if both have one. + return nameA.localizedStandardCompare(nameB) == .orderedAscending + + case (nil, nil): + // Sort by raw permission value as fallback. + return permissionA.permission.rawValue < permissionB.permission.rawValue + + // Sort "known" permissions before "unknown" ones. + case (_?, nil): return true + case (nil, _?): return false + } + } + + self.privacyPermissions = app.permissions.filter { $0.type == .privacy }.sorted(by: comparator) + self.entitlementPermissions = app.permissions.filter { $0.type == .entitlement }.sorted(by: comparator) + + super.init(coder: coder) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() + { + super.viewDidLoad() + + // Allow parent background color to show through. + self.collectionView.backgroundColor = nil + + let collectionViewLayout = self.makeLayout() + self.collectionView.collectionViewLayout = collectionViewLayout + + self.collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "PrivacyCell") + self.collectionView.register(UICollectionViewListCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier) + + self.headerRegistration = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionHeader) { (headerView, elementKind, indexPath) in + var configuration = UIListContentConfiguration.plainHeader() + configuration.text = NSLocalizedString("Entitlements", comment: "") + configuration.directionalLayoutMargins.bottom = 15 + + headerView.contentConfiguration = configuration + headerView.backgroundConfiguration = UIBackgroundConfiguration.clear() + } + + self.dataSource.proxy = self + self.collectionView.dataSource = self.dataSource + self.collectionView.delegate = self + } +} + +private extension AppDetailCollectionViewController +{ + func makeLayout() -> UICollectionViewCompositionalLayout + { + let layout = UICollectionViewCompositionalLayout(sectionProvider: { [privacyPermissions, entitlementPermissions] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in + guard let section = Section(rawValue: sectionIndex) else { return nil } + switch section + { + case .privacy: + guard !privacyPermissions.isEmpty, #available(iOS 16, *) else { return nil } // Hide section pre-iOS 16. + + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(50)) // Underestimate height to prevent jumping size abruptly. + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(50)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + + let layoutSection = NSCollectionLayoutSection(group: group) + layoutSection.interGroupSpacing = 10 + return layoutSection + + case .entitlements: + guard !entitlementPermissions.isEmpty else { return nil } + + var configuration = UICollectionLayoutListConfiguration(appearance: .plain) + configuration.headerMode = .supplementary + configuration.backgroundColor = .altBackground + + let layoutSection = NSCollectionLayoutSection.list(using: configuration, layoutEnvironment: layoutEnvironment) + return layoutSection + } + }) + + return layout + } + + func makeDataSource() -> RSTCompositeCollectionViewDataSource + { + let dataSource = RSTCompositeCollectionViewDataSource(dataSources: [self.privacyDataSource, self.entitlementsDataSource]) + return dataSource + } + + func makePrivacyDataSource() -> RSTDynamicCollectionViewDataSource + { + let dataSource = RSTDynamicCollectionViewDataSource() + dataSource.cellIdentifierHandler = { _ in "PrivacyCell" } + dataSource.numberOfSectionsHandler = { 1 } + dataSource.cellConfigurationHandler = { [weak self] (cell, _, indexPath) in + guard let self, #available(iOS 16, *) else { return } + + cell.contentConfiguration = UIHostingConfiguration { + AppPermissionsCard(title: "Privacy", + description: "\(self.app.name) may request access to the following:", + tintColor: Color(uiColor: self.app.tintColor ?? .altPrimary), + permissions: self.privacyPermissions) + } + .margins(.horizontal, 20) + } + + if #available(iOS 16, *) + { + dataSource.numberOfItemsHandler = { [privacyPermissions] _ in !privacyPermissions.isEmpty ? 1 : 0 } + } + else + { + dataSource.numberOfItemsHandler = { _ in 0 } + } + + return dataSource + } + + func makeEntitlementsDataSource() -> RSTArrayCollectionViewDataSource + { + let dataSource = RSTArrayCollectionViewDataSource(items: self.entitlementPermissions) + dataSource.cellConfigurationHandler = { [weak self] (cell, appPermission, indexPath) in + let cell = cell as! UICollectionViewListCell + + var content = cell.defaultContentConfiguration() + content.image = UIImage(systemName: appPermission.effectiveSymbolName) + + let tintColor = self?.app.tintColor ?? .altPrimary + content.imageProperties.tintColor = tintColor + + if let name = appPermission.localizedName + { + content.text = name + content.secondaryText = appPermission.permission.rawValue + content.secondaryTextProperties.color = UIColor.secondaryLabel + } + else + { + content.text = appPermission.permission.rawValue + } + + cell.contentConfiguration = content + cell.backgroundConfiguration = UIBackgroundConfiguration.clear() + + if #available(iOS 15.4, *) /*, let self */ // Capturing self leads to strong-reference cycle. + { + let detailAccessory = UICellAccessory.detail(displayed: .always, options: .init(tintColor: tintColor)) { + let alertController = UIAlertController(title: appPermission.localizedDisplayName, message: appPermission.localizedDescription, preferredStyle: .alert) + alertController.addAction(.ok) + self?.present(alertController, animated: true) + } + + cell.accessories = [detailAccessory] + } + } + + return dataSource + } +} + +extension AppDetailCollectionViewController +{ + 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, shouldHighlightItemAt indexPath: IndexPath) -> Bool + { + return false + } + + override func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool + { + return false + } +} diff --git a/AltStore/App Detail/AppPermissionsCard.swift b/AltStore/App Detail/AppPermissionsCard.swift new file mode 100644 index 00000000..ef5edfa1 --- /dev/null +++ b/AltStore/App Detail/AppPermissionsCard.swift @@ -0,0 +1,276 @@ +// +// AppPermissionsCard.swift +// AltStore +// +// Created by Riley Testut on 5/4/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import SwiftUI + +import AltStoreCore + +@available(iOS 16, *) +extension AppPermissionsCard +{ + private struct TransitionKey: Hashable + { + static func name(_ permission: Permission) -> TransitionKey { + TransitionKey(key: "name", permission: permission) + } + + static func icon(_ permission: Permission) -> TransitionKey { + TransitionKey(key: "icon", permission: permission) + } + + let key: String + let permission: Permission + + private init(key: String, permission: Permission) + { + self.key = key + self.permission = permission + } + } +} + +@available(iOS 16, *) +struct AppPermissionsCard: View +{ + let title: LocalizedStringKey + let description: LocalizedStringKey + let tintColor: Color + + let permissions: [Permission] + + @State + private var selectedPermission: Permission? + + @Namespace + private var animation + + private var isTitleVisible: Bool { + if selectedPermission == nil + { + // Title should always be visible when showing all permissions. + return true + } + + // If showing permission details, only show title if there + // are more than 2 permissions total to save vertical space. + let isTitleVisible = permissions.count > 2 + return isTitleVisible + } + + var body: some View { + let title = Text(title) + .font(.title2) + .bold() + .minimumScaleFactor(0.1) // Avoid clipping during matchedGeometryEffect animation. + + VStack(spacing: 8) { + if isTitleVisible + { + // If title is visible, place _outside_ `content` + // to avoid being covered by permissionDetailView. + title + } + + let content = VStack(spacing: 8) { + if !isTitleVisible + { + // Place title inside `content` when not visible + // so it's covered by permissionDetailView. + title + } + + VStack(spacing: 20) { + Text(description) + .font(.subheadline) + .fixedSize(horizontal: false, vertical: true) + + Grid(verticalSpacing: 15) { + ForEach(permissions, id: \.self) { permission in + permissionRow(for: permission) + } + } + + Text("Tap a permission to learn more.") + .font(.subheadline) + .fixedSize(horizontal: false, vertical: true) + } + } + + if let selectedPermission + { + // Hide content with overlay to preserve existing size. + content.hidden().overlay { + permissionDetailView(for: selectedPermission) + } + } + else + { + content + } + } + .overlay(alignment: .topTrailing) { + if selectedPermission != nil + { + Image(systemName: "xmark.circle.fill") + .imageScale(.medium) + } + } + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(20) + .overlay { + if selectedPermission != nil + { + // Make entire view tappable when overlay is visible. + SwiftUI.Button(action: hidePermission) { + VStack {} + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + .foregroundColor(.secondary) // Vibrancy + .background(.regularMaterial) // Blur background for auto-legibility correction. + .background(tintColor, in: RoundedRectangle(cornerRadius: 30, style: .continuous)) + } + + @ViewBuilder + private func permissionRow(for permission: Permission) -> some View + { + GridRow { + SwiftUI.Button(action: { show(permission) }) { + HStack { + let text = Text(permission.localizedDisplayName) + .font(.body) + .bold() + .minimumScaleFactor(0.33) + .lineLimit(.max) // Setting lineLimit to anything fixes text wrapping at large text sizes. + + let image = Image(systemName: permission.effectiveSymbolName) + .gridColumnAlignment(.center) + + if selectedPermission != nil + { + Label(title: { text }, icon: { image }) + .hidden() + } + else + { + Label { + text.matchedGeometryEffect(id: TransitionKey.name(permission), in: animation) + } icon: { + image.matchedGeometryEffect(id: TransitionKey.icon(permission), in: animation) + } + } + + Spacer() + + Image(systemName: "info.circle") + .imageScale(.large) + } + .contentShape(Rectangle()) // Make entire HStack tappable. + } + } + .frame(minHeight: 30) // Make row tall enough to tap. + } + + @ViewBuilder + private func permissionDetailView(for permission: Permission) -> some View + { + VStack(spacing: 15) { + Image(systemName: permission.effectiveSymbolName) + .font(.largeTitle) + .fixedSize(horizontal: false, vertical: true) + .matchedGeometryEffect(id: TransitionKey.icon(permission), in: animation) + + Text(permission.localizedDisplayName) + .font(.title2) + .bold() + .minimumScaleFactor(0.1) // Avoid clipping during matchedGeometryEffect animation. + .matchedGeometryEffect(id: TransitionKey.name(permission), in: animation) + + if let usageDescription = permission.usageDescription + { + Text(usageDescription) + .font(.subheadline) + .minimumScaleFactor(0.75) + } + } + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + init(title: LocalizedStringKey, description: LocalizedStringKey, tintColor: Color, permissions: [Permission]) + { + self.init(title: title, description: description, tintColor: tintColor, permissions: permissions, selectedPermission: nil) + } + + fileprivate init(title: LocalizedStringKey, description: LocalizedStringKey, tintColor: Color, permissions: [Permission], selectedPermission: Permission? = nil) + { + self.title = title + self.description = description + self.tintColor = tintColor + self.permissions = permissions + + // Set _selectedPermission directly or else the preview won't detect it. + self._selectedPermission = State(initialValue: selectedPermission) + } +} + +@available(iOS 16, *) +private extension AppPermissionsCard +{ + func show(_ permission: Permission) + { + withAnimation { + self.selectedPermission = permission + } + } + + func hidePermission() + { + withAnimation { + self.selectedPermission = nil + } + } +} + +@available(iOS 16, *) +struct AppPermissionsCard_Previews: PreviewProvider +{ + static var previews: some View { + let appPermissions = [ + PreviewAppPermission(permission: ALTAppPrivacyPermission.localNetwork), + PreviewAppPermission(permission: ALTAppPrivacyPermission.microphone), + PreviewAppPermission(permission: ALTAppPrivacyPermission.photos), + PreviewAppPermission(permission: ALTAppPrivacyPermission.camera), + PreviewAppPermission(permission: ALTAppPrivacyPermission.faceID), + PreviewAppPermission(permission: ALTAppPrivacyPermission.appleMusic), + PreviewAppPermission(permission: ALTAppPrivacyPermission.bluetooth), + PreviewAppPermission(permission: ALTAppPrivacyPermission.calendars), + ] + + let tintColor = Color(uiColor: .deltaPrimary!) + + return ForEach(1...8, id: \.self) { index in + AppPermissionsCard(title: "Privacy", + description: "Delta may request access to the following:", + tintColor: tintColor, + permissions: Array(appPermissions.prefix(index))) + .frame(width: 350) + .previewLayout(.sizeThatFits) + + AppPermissionsCard(title: "Privacy", + description: "Delta may request access to the following:", + tintColor: tintColor, + permissions: Array(appPermissions.prefix(index)), + selectedPermission: appPermissions.first) + .frame(width: 350) + .previewLayout(.sizeThatFits) + } + } +} diff --git a/AltStore/App Detail/AppViewController.swift b/AltStore/App Detail/AppViewController.swift index 0c8f87e9..62cb7b8e 100644 --- a/AltStore/App Detail/AppViewController.swift +++ b/AltStore/App Detail/AppViewController.swift @@ -73,6 +73,7 @@ final class AppViewController: UIViewController self.contentViewController.view.layer.masksToBounds = true self.contentViewController.tableView.panGestureRecognizer.require(toFail: self.scrollView.panGestureRecognizer) + self.contentViewController.appDetailCollectionViewController.collectionView.panGestureRecognizer.require(toFail: self.scrollView.panGestureRecognizer) self.contentViewController.tableView.showsVerticalScrollIndicator = false // Bring to front so the scroll indicators are visible. diff --git a/AltStore/Base.lproj/Main.storyboard b/AltStore/Base.lproj/Main.storyboard index 5ac8118a..8511d102 100644 --- a/AltStore/Base.lproj/Main.storyboard +++ b/AltStore/Base.lproj/Main.storyboard @@ -392,15 +392,15 @@ - - + + - + - + - - - + + - + @@ -465,10 +464,14 @@ World + + + + - + @@ -494,8 +497,8 @@ World + - @@ -508,50 +511,25 @@ World - - + + - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + - + @@ -972,8 +950,5 @@ World - - - diff --git a/AltStore/Extensions/UIColor+AltStore.swift b/AltStore/Extensions/UIColor+AltStore.swift new file mode 100644 index 00000000..a88de14c --- /dev/null +++ b/AltStore/Extensions/UIColor+AltStore.swift @@ -0,0 +1,14 @@ +// +// UIColor+AltStore.swift +// AltStore +// +// Created by Riley Testut on 5/23/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit + +extension UIColor +{ + static let altBackground = UIColor(named: "Background")! +} diff --git a/AltStore/Previews/AppPermissionProtocol.swift b/AltStore/Previews/AppPermissionProtocol.swift new file mode 100644 index 00000000..bb221edf --- /dev/null +++ b/AltStore/Previews/AppPermissionProtocol.swift @@ -0,0 +1,44 @@ +// +// AppPermissionProtocol.swift +// AltStore +// +// Created by Riley Testut on 5/23/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import AltStoreCore + +@dynamicMemberLookup +protocol AppPermissionProtocol: Hashable +{ + var permission: any ALTAppPermission { get } + var usageDescription: String? { get } + + subscript(dynamicMember dynamicMember: KeyPath) -> T { get } +} + +extension AppPermission: AppPermissionProtocol {} + +struct PreviewAppPermission: AppPermissionProtocol +{ + var permission: any ALTAppPermission + var usageDescription: String? { "Allows Delta to use images from your Photo Library as game artwork." } + + subscript(dynamicMember dynamicMember: KeyPath) -> T + { + return self.permission[keyPath: dynamicMember] + } +} + +extension PreviewAppPermission +{ + static func ==(lhs: PreviewAppPermission, rhs: PreviewAppPermission) -> Bool + { + return lhs.permission.isEqual(rhs.permission) + } + + func hash(into hasher: inout Hasher) + { + hasher.combine(self.permission) + } +} diff --git a/AltStoreCore/Protocols/ALTAppPermission.swift b/AltStoreCore/Protocols/ALTAppPermission.swift index da1fd995..4ffdd533 100644 --- a/AltStoreCore/Protocols/ALTAppPermission.swift +++ b/AltStoreCore/Protocols/ALTAppPermission.swift @@ -28,11 +28,17 @@ public protocol ALTAppPermission: RawRepresentable, Hashable var symbolName: String? { get } var localizedName: String? { get } - var localizedDisplayName: String { get } // Default implementation + var localizedDescription: String? { get } + + // Default implementations + var effectiveSymbolName: String { get } + var localizedDisplayName: String { get } } public extension ALTAppPermission { + var effectiveSymbolName: String { self.symbolName ?? "lock" } + var localizedDisplayName: String { return self.localizedName ?? self.rawValue } @@ -55,6 +61,7 @@ public struct UnknownAppPermission: ALTAppPermission public var symbolName: String? { nil } public var localizedName: String? { nil } + public var localizedDescription: String? { nil } public var rawValue: String @@ -70,6 +77,7 @@ extension ALTEntitlement: ALTAppPermission public var symbolName: String? { nil } public var localizedName: String? { nil } + public var localizedDescription: String? { nil } } extension ALTAppPrivacyPermission: ALTAppPermission @@ -90,6 +98,8 @@ extension ALTAppPrivacyPermission: ALTAppPermission default: return nil } } + + public var localizedDescription: String? { nil } public var symbolName: String? { switch self @@ -113,4 +123,5 @@ extension ALTAppBackgroundMode: ALTAppPermission public var symbolName: String? { nil } public var localizedName: String? { nil } + public var localizedDescription: String? { nil } }