diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index f6a2844c..791e2c9f 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -439,6 +439,7 @@ D5DAE0962804DF430034D8D4 /* UpdatePatronsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DAE0952804DF430034D8D4 /* UpdatePatronsOperation.swift */; }; D5DB145A28F9DC5A00A8F606 /* ALTLocalizedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DB145828F9DC1000A8F606 /* ALTLocalizedError.swift */; }; D5DB145B28F9DC5C00A8F606 /* ALTLocalizedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DB145828F9DC1000A8F606 /* ALTLocalizedError.swift */; }; + D5DB81642B0410BC003F5F8B /* AppSorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DB81632B0410BC003F5F8B /* AppSorting.swift */; }; D5E1E7C128077DE90016FC96 /* UpdateKnownSourcesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E1E7C028077DE90016FC96 /* UpdateKnownSourcesOperation.swift */; }; D5E3FB9828FDFAD90034B72C /* NSError+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF6C336124197D700034FD24 /* NSError+AltStore.swift */; }; D5F2F6A92720B7C20081CCF5 /* PatchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F2F6A82720B7C20081CCF5 /* PatchViewController.swift */; }; @@ -1115,6 +1116,7 @@ D5CF56812A0D83F9006D93E2 /* VerificationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationError.swift; sourceTree = ""; }; D5DAE0952804DF430034D8D4 /* UpdatePatronsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePatronsOperation.swift; sourceTree = ""; }; D5DB145828F9DC1000A8F606 /* ALTLocalizedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ALTLocalizedError.swift; sourceTree = ""; }; + D5DB81632B0410BC003F5F8B /* AppSorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSorting.swift; sourceTree = ""; }; D5E1E7C028077DE90016FC96 /* UpdateKnownSourcesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateKnownSourcesOperation.swift; sourceTree = ""; }; D5F2F6A82720B7C20081CCF5 /* PatchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchViewController.swift; sourceTree = ""; }; D5F48B4729CCF21B002B52A4 /* AltStore+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AltStore+Async.swift"; sourceTree = ""; }; @@ -1654,6 +1656,7 @@ BFB39B5B252BC10E00D1BE50 /* Managed.swift */, D5F48B4929CD0B67002B52A4 /* AsyncManaged.swift */, D5893F812A141E4900E767CD /* KnownSource.swift */, + D5DB81632B0410BC003F5F8B /* AppSorting.swift */, BF66EE8E2501AEBC007EE018 /* ALTAppPermissions.h */, BF66EE912501AEBC007EE018 /* ALTAppPermissions.m */, BF66EE922501AEBC007EE018 /* ALTPatreonBenefitID.h */, @@ -3158,6 +3161,7 @@ D5CA0C4E280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel in Sources */, D51AD27F29356B7B00967AAA /* ALTWrappedError.m in Sources */, BF989184250AACFC002ACF50 /* Date+RelativeDate.swift in Sources */, + D5DB81642B0410BC003F5F8B /* AppSorting.swift in Sources */, BF66EE962501AEBC007EE018 /* ALTPatreonBenefitID.m in Sources */, BFAECC5A2501B0A400528F27 /* NetworkConnection.swift in Sources */, D5F99A1828D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel in Sources */, diff --git a/AltStore/Browse/BrowseViewController.swift b/AltStore/Browse/BrowseViewController.swift index 52837d01..ab21a85b 100644 --- a/AltStore/Browse/BrowseViewController.swift +++ b/AltStore/Browse/BrowseViewController.swift @@ -25,6 +25,9 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing private let prototypeCell = AppCardCollectionViewCell(frame: .zero) + private var sortButton: UIBarButtonItem? + private var preferredAppSorting: AppSorting = UserDefaults.shared.preferredAppSorting + private var cancellables = Set() init?(source: Source?, coder: NSCoder) @@ -94,8 +97,14 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing self.navigationItem.preferredSearchBarPlacement = .inline } + if #available(iOS 15, *) + { + self.prepareAppSorting() + } + self.preparePipeline() + self.updateDataSource() self.update() } @@ -119,13 +128,9 @@ private extension BrowseViewController .store(in: &self.cancellables) } - func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource + func makeFetchRequest() -> NSFetchRequest { let fetchRequest = StoreApp.fetchRequest() as NSFetchRequest - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true), - NSSortDescriptor(keyPath: \StoreApp.sortIndex, ascending: true), - NSSortDescriptor(keyPath: \StoreApp.name, ascending: true), - NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true)] fetchRequest.returnsObjectsAsFaults = false let predicate = StoreApp.visibleAppsPredicate @@ -140,6 +145,38 @@ private extension BrowseViewController fetchRequest.predicate = predicate } + var sortDescriptors = [NSSortDescriptor(keyPath: \StoreApp.name, ascending: true), + NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true), + NSSortDescriptor(keyPath: \StoreApp.sourceIdentifier, ascending: true)] + + switch self.preferredAppSorting + { + case .default: + let descriptor = NSSortDescriptor(keyPath: \StoreApp.sortIndex, ascending: self.preferredAppSorting.isAscending) + sortDescriptors.insert(descriptor, at: 0) + + case .name: + // Already sorting by name, no need to prepend additional sort descriptor. + break + + case .developer: + let descriptor = NSSortDescriptor(keyPath: \StoreApp.developerName, ascending: self.preferredAppSorting.isAscending) + sortDescriptors.insert(descriptor, at: 0) + + case .lastUpdated: + let descriptor = NSSortDescriptor(keyPath: \StoreApp.latestSupportedVersion?.date, ascending: self.preferredAppSorting.isAscending) + sortDescriptors.insert(descriptor, at: 0) + } + + fetchRequest.sortDescriptors = sortDescriptors + + return fetchRequest + } + + func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource + { + let fetchRequest = self.makeFetchRequest() + let context = self.source?.managedObjectContext ?? DatabaseManager.shared.viewContext let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource(fetchRequest: fetchRequest, managedObjectContext: context) dataSource.cellConfigurationHandler = { (cell, app, indexPath) in @@ -192,7 +229,12 @@ private extension BrowseViewController func updateDataSource() { - self.dataSource.predicate = nil + self.dataSource.predicate = nil + let fetchRequest = self.makeFetchRequest() + + let context = self.source?.managedObjectContext ?? DatabaseManager.shared.viewContext + let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: context, sectionNameKeyPath: nil, cacheName: nil) + self.dataSource.fetchedResultsController = fetchedResultsController } func updateSources() @@ -239,6 +281,52 @@ private extension BrowseViewController self.placeholderView.activityIndicatorView.stopAnimating() } + + @available(iOS 15, *) + func prepareAppSorting() + { + if self.preferredAppSorting == .default && self.source == nil + { + // Only allow `default` sorting if source is non-nil. + // Otherwise, fall back to `lastUpdated` sorting. + self.preferredAppSorting = .lastUpdated + + // Don't update UserDefaults unless explicitly changed by user. + // UserDefaults.shared.preferredAppSorting = .lastUpdated + } + + let children = UIDeferredMenuElement.uncached { [weak self] completion in + guard let self else { return completion([]) } + + var sortingOptions = AppSorting.allCases + if self.source == nil + { + // Only allow `default` sorting when source is non-nil. + sortingOptions = sortingOptions.filter { $0 != .default } + } + + let actions = sortingOptions.map { sorting in + let state: UIMenuElement.State = (sorting == self.preferredAppSorting) ? .on : .off + let action = UIAction(title: sorting.localizedName, image: nil, state: state) { action in + self.preferredAppSorting = sorting + UserDefaults.shared.preferredAppSorting = sorting // Update separately to save change. + + self.updateDataSource() + } + + return action + } + + completion(actions) + } + + let sortMenu = UIMenu(title: NSLocalizedString("Sort by…", comment: ""), options: [.singleSelection], children: [children]) + let sortIcon = UIImage(systemName: "arrow.up.arrow.down") + + let sortButton = UIBarButtonItem(title: NSLocalizedString("Sort by…", comment: ""), image: sortIcon, primaryAction: nil, menu: sortMenu) + self.sortButton = sortButton + + self.navigationItem.rightBarButtonItems = [sortButton, .flexibleSpace()] // flexibleSpace() required to prevent showing full search bar inline. } } diff --git a/AltStoreCore/Extensions/UserDefaults+AltStore.swift b/AltStoreCore/Extensions/UserDefaults+AltStore.swift index fcff08b0..7f9b4202 100644 --- a/AltStoreCore/Extensions/UserDefaults+AltStore.swift +++ b/AltStoreCore/Extensions/UserDefaults+AltStore.swift @@ -53,6 +53,17 @@ public extension UserDefaults @NSManaged var trustedServerURL: String? @NSManaged var skipPatreonDownloads: Bool + @nonobjc var preferredAppSorting: AppSorting { + get { + let sorting = _preferredAppSorting.flatMap { AppSorting(rawValue: $0) } ?? .default + return sorting + } + set { + _preferredAppSorting = newValue.rawValue + } + } + @NSManaged @objc(preferredAppSorting) private var _preferredAppSorting: String? + @nonobjc var activeAppsLimit: Int? { get { @@ -103,6 +114,10 @@ public extension UserDefaults let permissionCheckingDisabled = false #endif + // Pre-iOS 15 doesn't support custom sorting, so default to sorting by name. + // Otherwise, default to `default` sorting (a.k.a. "source order"). + let preferredAppSorting: AppSorting = if #available(iOS 15, *) { .default } else { .name } + let defaults = [ #keyPath(UserDefaults.isAppLimitDisabled): false, #keyPath(UserDefaults.isBackgroundRefreshEnabled): true, @@ -117,7 +132,8 @@ public extension UserDefaults #keyPath(UserDefaults.ignoreActiveAppsLimit): false, #keyPath(UserDefaults.isMacDirtyCowSupported): isMacDirtyCowSupported #keyPath(UserDefaults.permissionCheckingDisabled): permissionCheckingDisabled, - ] as [String : Any] + #keyPath(UserDefaults._preferredAppSorting): preferredAppSorting.rawValue, + ] as [String: Any] UserDefaults.standard.register(defaults: defaults) UserDefaults.shared.register(defaults: defaults) diff --git a/AltStoreCore/Types/AppSorting.swift b/AltStoreCore/Types/AppSorting.swift new file mode 100644 index 00000000..bfa80d53 --- /dev/null +++ b/AltStoreCore/Types/AppSorting.swift @@ -0,0 +1,35 @@ +// +// AppSorting.swift +// AltStoreCore +// +// Created by Riley Testut on 11/14/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import Foundation + +public enum AppSorting: String, CaseIterable +{ + case `default` + case name + case developer + case lastUpdated + + public var localizedName: String { + switch self + { + case .default: return NSLocalizedString("Default", comment: "") + case .name: return NSLocalizedString("Name", comment: "") + case .developer: return NSLocalizedString("Developer", comment: "") + case .lastUpdated: return NSLocalizedString("Last Updated", comment: "") + } + } + + public var isAscending: Bool { + switch self + { + case .default, .name, .developer: return true + case .lastUpdated: return false + } + } +}