Files
SideStore/SideStoreApp/Sources/SideStoreUIKit/My Apps/MyAppsViewController.swift

1854 lines
81 KiB
Swift
Raw Normal View History

//
// MyAppsViewController.swift
// AltStore
//
// Created by Riley Testut on 7/16/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import Combine
2023-03-01 00:48:36 -05:00
import Intents
import MobileCoreServices
import UIKit
2019-07-28 15:51:36 -07:00
import AltSign
2023-03-01 00:48:36 -05:00
import SideStoreCore
2023-03-01 14:36:52 -05:00
import RoxasUIKit
import OSLog
#if canImport(Logging)
import Logging
#endif
2019-07-28 15:51:36 -07:00
import Nuke
private let maximumCollapsedUpdatesCount = 2
2023-03-01 00:48:36 -05:00
extension MyAppsViewController {
private enum Section: Int, CaseIterable {
case noUpdates
case updates
case activeApps
case inactiveApps
}
}
2023-03-01 00:48:36 -05:00
final class MyAppsViewController: UICollectionViewController {
private let coordinator = NSFileCoordinator()
private let operationQueue = OperationQueue()
2023-03-01 00:48:36 -05:00
private lazy var dataSource = self.makeDataSource()
private lazy var noUpdatesDataSource = self.makeNoUpdatesDataSource()
private lazy var updatesDataSource = self.makeUpdatesDataSource()
private lazy var activeAppsDataSource = self.makeActiveAppsDataSource()
private lazy var inactiveAppsDataSource = self.makeInactiveAppsDataSource()
2023-03-01 00:48:36 -05:00
private var prototypeUpdateCell: UpdateCollectionViewCell!
2019-07-28 15:51:36 -07:00
private var sideloadingProgressView: UIProgressView!
2023-03-01 00:48:36 -05:00
// State
private var isUpdateSectionCollapsed = true
private var expandedAppUpdates = Set<String>()
private var isRefreshingAllApps = false
private var refreshGroup: RefreshGroup?
2019-07-28 15:51:36 -07:00
private var sideloadingProgress: Progress?
private var dropDestinationIndexPath: IndexPath?
2023-03-01 00:48:36 -05:00
private var _imagePickerInstalledApp: InstalledApp?
2023-03-01 00:48:36 -05:00
// Cache
private var cachedUpdateSizes = [String: CGSize]()
2023-03-01 00:48:36 -05:00
private lazy var dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
dateFormatter.timeStyle = .none
return dateFormatter
}()
2023-03-01 00:48:36 -05:00
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
2023-03-01 00:48:36 -05:00
NotificationCenter.default.addObserver(self, selector: #selector(MyAppsViewController.didFetchSource(_:)), name: AppManager.didFetchSourceNotification, object: nil)
2023-03-01 19:09:33 -05:00
NotificationCenter.default.addObserver(self, selector: #selector(MyAppsViewController.importApp(_:)), name: SideStoreAppDelegate.importAppDeepLinkNotification, object: nil)
}
2023-03-01 00:48:36 -05:00
override func viewDidLoad() {
super.viewDidLoad()
2023-03-01 00:48:36 -05:00
// Allows us to intercept delegate callbacks.
2023-03-01 00:48:36 -05:00
updatesDataSource.fetchedResultsController.delegate = self
collectionView.dataSource = dataSource
collectionView.prefetchDataSource = dataSource
collectionView.dragDelegate = self
collectionView.dropDelegate = self
collectionView.dragInteractionEnabled = true
prototypeUpdateCell = UpdateCollectionViewCell.instantiate(with: UpdateCollectionViewCell.nib!)
prototypeUpdateCell.contentView.translatesAutoresizingMaskIntoConstraints = false
collectionView.register(UpdateCollectionViewCell.nib, forCellWithReuseIdentifier: "UpdateCell")
collectionView.register(UpdatesCollectionHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "UpdatesHeader")
collectionView.register(InstalledAppsCollectionHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "ActiveAppsHeader")
collectionView.register(InstalledAppsCollectionHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "InactiveAppsHeader")
sideloadingProgressView = UIProgressView(progressViewStyle: .bar)
sideloadingProgressView.translatesAutoresizingMaskIntoConstraints = false
sideloadingProgressView.progressTintColor = .altPrimary
sideloadingProgressView.progress = 0
if let navigationBar = navigationController?.navigationBar {
navigationBar.addSubview(sideloadingProgressView)
NSLayoutConstraint.activate([sideloadingProgressView.leadingAnchor.constraint(equalTo: navigationBar.leadingAnchor),
sideloadingProgressView.trailingAnchor.constraint(equalTo: navigationBar.trailingAnchor),
sideloadingProgressView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor)])
}
if #available(iOS 13, *) {} else {
registerForPreviewing(with: self, sourceView: collectionView)
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
2023-03-01 00:48:36 -05:00
updateDataSource()
fetchAppIDs()
}
2023-03-01 00:48:36 -05:00
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
2019-09-12 13:51:03 -07:00
guard let identifier = segue.identifier else { return }
2023-03-01 00:48:36 -05:00
switch identifier {
2019-09-12 13:51:03 -07:00
case "showApp", "showUpdate":
2023-03-01 00:48:36 -05:00
guard let cell = sender as? UICollectionViewCell, let indexPath = collectionView.indexPath(for: cell) else { return }
let installedApp = dataSource.item(at: indexPath)
2019-09-12 13:51:03 -07:00
let appViewController = segue.destination as! AppViewController
appViewController.app = installedApp.storeApp
2023-03-01 00:48:36 -05:00
2019-09-12 13:51:03 -07:00
default: break
}
}
2023-03-01 00:48:36 -05:00
override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool {
2019-07-28 15:51:36 -07:00
guard identifier == "showApp" else { return true }
2023-03-01 00:48:36 -05:00
guard let cell = sender as? UICollectionViewCell, let indexPath = collectionView.indexPath(for: cell) else { return true }
let installedApp = dataSource.item(at: indexPath)
2019-07-28 15:51:36 -07:00
return !installedApp.isSideloaded
}
2023-03-01 00:48:36 -05:00
@IBAction func unwindToMyAppsViewController(_: UIStoryboardSegue) {}
}
2023-03-01 00:48:36 -05:00
private extension MyAppsViewController {
func makeDataSource() -> RSTCompositeCollectionViewPrefetchingDataSource<InstalledApp, UIImage> {
let dataSource = RSTCompositeCollectionViewPrefetchingDataSource<InstalledApp, UIImage>(dataSources: [noUpdatesDataSource, updatesDataSource, activeAppsDataSource, inactiveAppsDataSource])
dataSource.proxy = self
return dataSource
}
2023-03-01 00:48:36 -05:00
func makeNoUpdatesDataSource() -> RSTDynamicCollectionViewPrefetchingDataSource<InstalledApp, UIImage> {
let dynamicDataSource = RSTDynamicCollectionViewPrefetchingDataSource<InstalledApp, UIImage>()
dynamicDataSource.numberOfSectionsHandler = { 1 }
dynamicDataSource.numberOfItemsHandler = { _ in self.updatesDataSource.itemCount == 0 ? 1 : 0 }
dynamicDataSource.cellIdentifierHandler = { _ in "NoUpdatesCell" }
2023-03-01 00:48:36 -05:00
dynamicDataSource.cellConfigurationHandler = { cell, _, _ in
2019-10-23 13:32:17 -07:00
let cell = cell as! NoUpdatesCollectionViewCell
cell.layoutMargins.left = self.view.layoutMargins.left
cell.layoutMargins.right = self.view.layoutMargins.right
2023-03-01 00:48:36 -05:00
2019-10-23 13:32:17 -07:00
cell.blurView.layer.cornerRadius = 20
cell.blurView.layer.masksToBounds = true
cell.blurView.backgroundColor = .altPrimary
}
2023-03-01 00:48:36 -05:00
return dynamicDataSource
}
2023-03-01 00:48:36 -05:00
func makeUpdatesDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage> {
let fetchRequest = InstalledApp.updatesFetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \InstalledApp.storeApp?.latestVersion?.date, ascending: true),
NSSortDescriptor(keyPath: \InstalledApp.name, ascending: true)]
fetchRequest.returnsObjectsAsFaults = false
2023-03-01 00:48:36 -05:00
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
dataSource.liveFetchLimit = maximumCollapsedUpdatesCount
dataSource.cellIdentifierHandler = { _ in "UpdateCell" }
2023-03-01 00:48:36 -05:00
dataSource.cellConfigurationHandler = { [weak self] cell, installedApp, _ in
2019-09-12 13:51:03 -07:00
guard let self = self else { return }
guard let app = installedApp.storeApp, let latestVersion = app.latestVersion else { return }
2023-03-01 00:48:36 -05:00
let cell = cell as! UpdateCollectionViewCell
2019-10-23 13:32:17 -07:00
cell.layoutMargins.left = self.view.layoutMargins.left
cell.layoutMargins.right = self.view.layoutMargins.right
2023-03-01 00:48:36 -05:00
2019-09-19 11:29:10 -07:00
cell.tintColor = app.tintColor ?? .altPrimary
cell.versionDescriptionTextView.text = app.versionDescription
2023-03-01 00:48:36 -05:00
2019-10-23 13:32:17 -07:00
cell.bannerView.iconImageView.image = nil
cell.bannerView.iconImageView.isIndicatingActivity = true
2023-03-01 00:48:36 -05:00
2020-08-27 15:23:21 -07:00
cell.bannerView.configure(for: app)
2023-03-01 00:48:36 -05:00
let versionDate = Date().relativeDateString(since: latestVersion.date, dateFormatter: self.dateFormatter)
2020-08-27 15:23:21 -07:00
cell.bannerView.subtitleLabel.text = versionDate
2023-03-01 00:48:36 -05:00
2020-08-27 15:23:21 -07:00
let appName: String
2023-03-01 00:48:36 -05:00
if app.isBeta {
2020-08-27 15:23:21 -07:00
appName = String(format: NSLocalizedString("%@ beta", comment: ""), app.name)
2023-03-01 00:48:36 -05:00
} else {
2020-08-27 15:23:21 -07:00
appName = app.name
}
2023-03-01 00:48:36 -05:00
cell.bannerView.accessibilityLabel = String(format: NSLocalizedString("%@ %@ update. Released on %@.", comment: ""), appName, latestVersion.version, versionDate)
2023-03-01 00:48:36 -05:00
2019-10-23 13:32:17 -07:00
cell.bannerView.button.isIndicatingActivity = false
cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.updateApp(_:)), for: .primaryActionTriggered)
2020-08-27 15:23:21 -07:00
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Update %@", comment: ""), installedApp.name)
2023-03-01 00:48:36 -05:00
if self.expandedAppUpdates.contains(app.bundleIdentifier) {
cell.mode = .expanded
2023-03-01 00:48:36 -05:00
} else {
cell.mode = .collapsed
}
2023-03-01 00:48:36 -05:00
cell.versionDescriptionTextView.moreButton.addTarget(self, action: #selector(MyAppsViewController.toggleUpdateCellMode(_:)), for: .primaryActionTriggered)
2023-03-01 00:48:36 -05:00
let progress = AppManager.shared.installationProgress(for: app)
2019-10-23 13:32:17 -07:00
cell.bannerView.button.progress = progress
2023-03-01 00:48:36 -05:00
cell.setNeedsLayout()
}
2023-03-01 00:48:36 -05:00
dataSource.prefetchHandler = { installedApp, _, completionHandler in
guard let iconURL = installedApp.storeApp?.iconURL else { return nil }
2023-03-01 00:48:36 -05:00
return RSTAsyncBlockOperation { operation in
ImagePipeline.shared.loadImage(with: iconURL, progress: nil, completion: { response, error in
guard !operation.isCancelled else { return operation.finish() }
2023-03-01 00:48:36 -05:00
if let image = response?.image {
completionHandler(image, nil)
2023-03-01 00:48:36 -05:00
} else {
completionHandler(nil, error)
}
})
}
}
2023-03-01 00:48:36 -05:00
dataSource.prefetchCompletionHandler = { cell, image, _, error in
let cell = cell as! UpdateCollectionViewCell
2019-10-23 13:32:17 -07:00
cell.bannerView.iconImageView.isIndicatingActivity = false
cell.bannerView.iconImageView.image = image
2023-03-01 00:48:36 -05:00
if let error = error {
2023-03-02 00:40:11 -05:00
os_log("Error loading image: %@", type: .error , error.localizedDescription)
}
}
2023-03-01 00:48:36 -05:00
return dataSource
}
2023-03-01 00:48:36 -05:00
func makeActiveAppsDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage> {
let fetchRequest = InstalledApp.activeAppsFetchRequest()
fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(InstalledApp.storeApp)]
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true),
NSSortDescriptor(keyPath: \InstalledApp.refreshedDate, ascending: false),
NSSortDescriptor(keyPath: \InstalledApp.name, ascending: true)]
fetchRequest.returnsObjectsAsFaults = false
2023-03-01 00:48:36 -05:00
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
dataSource.cellIdentifierHandler = { _ in "AppCell" }
2023-03-01 00:48:36 -05:00
dataSource.cellConfigurationHandler = { cell, installedApp, indexPath in
2019-09-19 11:29:10 -07:00
let tintColor = installedApp.storeApp?.tintColor ?? .altPrimary
2023-03-01 00:48:36 -05:00
let cell = cell as! InstalledAppCollectionViewCell
2019-10-23 13:32:17 -07:00
cell.layoutMargins.left = self.view.layoutMargins.left
cell.layoutMargins.right = self.view.layoutMargins.right
cell.tintColor = tintColor
2023-03-01 00:48:36 -05:00
cell.deactivateBadge?.isHidden = false
2023-03-01 00:48:36 -05:00
if let dropIndexPath = self.dropDestinationIndexPath, dropIndexPath.section == Section.activeApps.rawValue && dropIndexPath.item == indexPath.item {
cell.bannerView.alpha = 0.4
2023-03-01 00:48:36 -05:00
cell.deactivateBadge?.alpha = 1.0
cell.deactivateBadge?.transform = .identity
2023-03-01 00:48:36 -05:00
} else {
cell.bannerView.alpha = 1.0
2023-03-01 00:48:36 -05:00
cell.deactivateBadge?.alpha = 0.0
cell.deactivateBadge?.transform = CGAffineTransform.identity.scaledBy(x: 0.33, y: 0.33)
}
2023-03-01 00:48:36 -05:00
2020-08-27 15:23:21 -07:00
cell.bannerView.configure(for: installedApp)
2023-03-01 00:48:36 -05:00
2019-10-23 13:32:17 -07:00
cell.bannerView.iconImageView.isIndicatingActivity = true
2023-03-01 00:48:36 -05:00
cell.bannerView.buttonLabel.isHidden = false
cell.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "")
2023-03-01 00:48:36 -05:00
2019-10-23 13:32:17 -07:00
cell.bannerView.button.isIndicatingActivity = false
cell.bannerView.button.removeTarget(self, action: nil, for: .primaryActionTriggered)
2019-10-23 13:32:17 -07:00
cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.refreshApp(_:)), for: .primaryActionTriggered)
2023-03-01 00:48:36 -05:00
let currentDate = Date()
2023-03-01 00:48:36 -05:00
let numberOfDays = installedApp.expirationDate.numberOfCalendarDays(since: currentDate)
2020-08-27 15:23:21 -07:00
let numberOfDaysText: String
2023-03-01 00:48:36 -05:00
if numberOfDays == 1 {
2020-08-27 15:23:21 -07:00
numberOfDaysText = NSLocalizedString("1 day", comment: "")
2023-03-01 00:48:36 -05:00
} else {
2020-08-27 15:23:21 -07:00
numberOfDaysText = String(format: NSLocalizedString("%@ days", comment: ""), NSNumber(value: numberOfDays))
}
2023-03-01 00:48:36 -05:00
2020-08-27 15:23:21 -07:00
cell.bannerView.button.setTitle(numberOfDaysText.uppercased(), for: .normal)
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Refresh %@", comment: ""), installedApp.name)
2023-03-01 00:48:36 -05:00
2020-08-27 15:23:21 -07:00
cell.bannerView.accessibilityLabel? += ". " + String(format: NSLocalizedString("Expires in %@", comment: ""), numberOfDaysText)
2023-03-01 00:48:36 -05:00
// Make sure refresh button is correct size.
cell.layoutIfNeeded()
2023-03-01 00:48:36 -05:00
switch numberOfDays {
case 2 ... 3: cell.bannerView.button.tintColor = .refreshOrange
case 4 ... 5: cell.bannerView.button.tintColor = .refreshYellow
2019-10-23 13:32:17 -07:00
case 6...: cell.bannerView.button.tintColor = .refreshGreen
default: cell.bannerView.button.tintColor = .refreshRed
}
2023-03-01 00:48:36 -05:00
if let progress = AppManager.shared.refreshProgress(for: installedApp), progress.fractionCompleted < 1.0 {
2019-10-23 13:32:17 -07:00
cell.bannerView.button.progress = progress
2023-03-01 00:48:36 -05:00
} else {
2019-10-23 13:32:17 -07:00
cell.bannerView.button.progress = nil
}
}
2023-03-01 00:48:36 -05:00
dataSource.prefetchHandler = { item, _, completion in
RSTAsyncBlockOperation { _ in
item.managedObjectContext?.perform {
2023-03-01 00:48:36 -05:00
item.loadIcon { result in
switch result {
case let .failure(error): completion(nil, error)
case let .success(image): completion(image, nil)
}
}
2019-07-28 15:51:36 -07:00
}
}
}
2023-03-01 00:48:36 -05:00
dataSource.prefetchCompletionHandler = { cell, image, _, _ in
2019-07-28 15:51:36 -07:00
let cell = cell as! InstalledAppCollectionViewCell
2019-10-23 13:32:17 -07:00
cell.bannerView.iconImageView.image = image
cell.bannerView.iconImageView.isIndicatingActivity = false
2019-07-28 15:51:36 -07:00
}
2023-03-01 00:48:36 -05:00
return dataSource
}
2023-03-01 00:48:36 -05:00
func makeInactiveAppsDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage> {
let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest<InstalledApp>
fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(InstalledApp.storeApp)]
fetchRequest.predicate = NSPredicate(format: "%K == NO", #keyPath(InstalledApp.isActive))
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true),
NSSortDescriptor(keyPath: \InstalledApp.refreshedDate, ascending: false),
NSSortDescriptor(keyPath: \InstalledApp.name, ascending: true)]
fetchRequest.returnsObjectsAsFaults = false
2023-03-01 00:48:36 -05:00
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
dataSource.cellIdentifierHandler = { _ in "AppCell" }
2023-03-01 00:48:36 -05:00
dataSource.cellConfigurationHandler = { cell, installedApp, _ in
let tintColor = installedApp.storeApp?.tintColor ?? .altPrimary
2023-03-01 00:48:36 -05:00
let cell = cell as! InstalledAppCollectionViewCell
cell.layoutMargins.left = self.view.layoutMargins.left
cell.layoutMargins.right = self.view.layoutMargins.right
cell.tintColor = UIColor.gray
2023-03-01 00:48:36 -05:00
cell.bannerView.iconImageView.isIndicatingActivity = true
cell.bannerView.buttonLabel.isHidden = true
cell.bannerView.alpha = 1.0
2023-03-01 00:48:36 -05:00
cell.deactivateBadge?.isHidden = true
cell.deactivateBadge?.alpha = 0.0
cell.deactivateBadge?.transform = CGAffineTransform.identity.scaledBy(x: 0.5, y: 0.5)
2023-03-01 00:48:36 -05:00
2020-08-27 15:23:21 -07:00
cell.bannerView.configure(for: installedApp)
2023-03-01 00:48:36 -05:00
cell.bannerView.button.isIndicatingActivity = false
cell.bannerView.button.tintColor = tintColor
cell.bannerView.button.setTitle(NSLocalizedString("ACTIVATE", comment: ""), for: .normal)
cell.bannerView.button.removeTarget(self, action: nil, for: .primaryActionTriggered)
cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.activateApp(_:)), for: .primaryActionTriggered)
2020-08-27 15:23:21 -07:00
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Activate %@", comment: ""), installedApp.name)
2023-03-01 00:48:36 -05:00
// Make sure refresh button is correct size.
cell.layoutIfNeeded()
2023-03-01 00:48:36 -05:00
// Ensure no leftover progress from active apps cell reuse.
cell.bannerView.button.progress = nil
2023-03-01 00:48:36 -05:00
if let progress = AppManager.shared.refreshProgress(for: installedApp), progress.fractionCompleted < 1.0 {
cell.bannerView.button.progress = progress
2023-03-01 00:48:36 -05:00
} else {
cell.bannerView.button.progress = nil
}
}
2023-03-01 00:48:36 -05:00
dataSource.prefetchHandler = { item, _, completion in
RSTAsyncBlockOperation { _ in
item.managedObjectContext?.perform {
2023-03-01 00:48:36 -05:00
item.loadIcon { result in
switch result {
case let .failure(error): completion(nil, error)
case let .success(image): completion(image, nil)
}
}
}
}
}
2023-03-01 00:48:36 -05:00
dataSource.prefetchCompletionHandler = { cell, image, _, _ in
let cell = cell as! InstalledAppCollectionViewCell
cell.bannerView.iconImageView.image = image
cell.bannerView.iconImageView.isIndicatingActivity = false
}
2023-03-01 00:48:36 -05:00
return dataSource
}
2023-03-01 00:48:36 -05:00
func updateDataSource() {
dataSource.predicate = nil
}
}
2023-03-01 00:48:36 -05:00
private extension MyAppsViewController {
func update() {
if updatesDataSource.itemCount > 0 {
navigationController?.tabBarItem.badgeValue = String(describing: updatesDataSource.itemCount)
UIApplication.shared.applicationIconBadgeNumber = Int(updatesDataSource.itemCount)
} else {
navigationController?.tabBarItem.badgeValue = nil
UIApplication.shared.applicationIconBadgeNumber = 0
}
2023-03-01 00:48:36 -05:00
if isViewLoaded {
UIView.performWithoutAnimation {
self.collectionView.reloadSections(IndexSet(integer: Section.updates.rawValue))
}
2023-03-01 00:48:36 -05:00
}
}
2023-03-01 00:48:36 -05:00
func fetchAppIDs() {
AppManager.shared.fetchAppIDs { result in
do {
let (_, context) = try result.get()
try context.save()
2023-03-01 00:48:36 -05:00
} catch {
2023-03-02 00:40:11 -05:00
os_log("Failed to fetch App IDs. %@", type: .error , error.localizedDescription)
}
}
}
2023-03-01 00:48:36 -05:00
func refresh(_ installedApps: [InstalledApp], completionHandler: @escaping ([String: Result<InstalledApp, Error>]) -> Void) {
let group = AppManager.shared.refresh(installedApps, presentingViewController: self, group: refreshGroup)
group.completionHandler = { results in
DispatchQueue.main.async {
2023-03-01 00:48:36 -05:00
let failures = results.compactMapValues { result -> Error? in
switch result {
case .failure(OperationError.cancelled): return nil
2023-03-01 00:48:36 -05:00
case let .failure(error): return error
case .success: return nil
}
}
2023-03-01 00:48:36 -05:00
guard !failures.isEmpty else { return }
2023-03-01 00:48:36 -05:00
let toastView: ToastView
2023-03-01 00:48:36 -05:00
if let failure = failures.first, results.count == 1 {
toastView = ToastView(error: failure.value)
2023-03-01 00:48:36 -05:00
} else {
let localizedText: String
2023-03-01 00:48:36 -05:00
if failures.count == 1 {
localizedText = NSLocalizedString("Failed to refresh 1 app.", comment: "")
2023-03-01 00:48:36 -05:00
} else {
localizedText = String(format: NSLocalizedString("Failed to refresh %@ apps.", comment: ""), NSNumber(value: failures.count))
}
2023-03-01 00:48:36 -05:00
let error = failures.first?.value as NSError?
2020-03-19 15:02:35 -07:00
let detailText = error?.localizedFailure ?? error?.localizedFailureReason ?? error?.localizedDescription
2023-03-01 00:48:36 -05:00
toastView = ToastView(text: localizedText, detailText: detailText)
toastView.preferredDuration = 4.0
}
2023-03-01 00:48:36 -05:00
toastView.show(in: self)
}
2023-03-01 00:48:36 -05:00
self.refreshGroup = nil
completionHandler(results)
}
2023-03-01 00:48:36 -05:00
refreshGroup = group
UIView.performWithoutAnimation {
self.collectionView.reloadSections([Section.activeApps.rawValue, Section.inactiveApps.rawValue])
}
}
}
2023-03-01 00:48:36 -05:00
private extension MyAppsViewController {
@IBAction func toggleAppUpdates(_ sender: UIButton) {
let visibleCells = collectionView.visibleCells
collectionView.performBatchUpdates({
self.isUpdateSectionCollapsed.toggle()
2023-03-01 00:48:36 -05:00
UIView.animate(withDuration: 0.3, animations: {
2023-03-01 00:48:36 -05:00
if self.isUpdateSectionCollapsed {
self.updatesDataSource.liveFetchLimit = maximumCollapsedUpdatesCount
self.expandedAppUpdates.removeAll()
2023-03-01 00:48:36 -05:00
for case let cell as UpdateCollectionViewCell in visibleCells {
cell.mode = .collapsed
}
2023-03-01 00:48:36 -05:00
self.cachedUpdateSizes.removeAll()
2023-03-01 00:48:36 -05:00
sender.titleLabel?.transform = .identity
2023-03-01 00:48:36 -05:00
} else {
self.updatesDataSource.liveFetchLimit = 0
2023-03-01 00:48:36 -05:00
sender.titleLabel?.transform = CGAffineTransform.identity.rotated(by: .pi)
2019-06-05 11:03:49 -07:00
}
})
2023-03-01 00:48:36 -05:00
self.collectionView.collectionViewLayout.invalidateLayout()
2023-03-01 00:48:36 -05:00
}, completion: nil)
}
2023-03-01 00:48:36 -05:00
@IBAction func toggleUpdateCellMode(_ sender: UIButton) {
let point = collectionView.convert(sender.center, from: sender.superview)
guard let indexPath = collectionView.indexPathForItem(at: point) else { return }
let installedApp = dataSource.item(at: indexPath)
let cell = collectionView.cellForItem(at: indexPath) as? UpdateCollectionViewCell
if expandedAppUpdates.contains(installedApp.bundleIdentifier) {
expandedAppUpdates.remove(installedApp.bundleIdentifier)
cell?.mode = .collapsed
2023-03-01 00:48:36 -05:00
} else {
expandedAppUpdates.insert(installedApp.bundleIdentifier)
cell?.mode = .expanded
2019-06-05 11:03:49 -07:00
}
2023-03-01 00:48:36 -05:00
cachedUpdateSizes[installedApp.bundleIdentifier] = nil
collectionView.performBatchUpdates({
self.collectionView.collectionViewLayout.invalidateLayout()
}, completion: nil)
}
2023-03-01 00:48:36 -05:00
@IBAction func refreshApp(_ sender: UIButton) {
let point = collectionView.convert(sender.center, from: sender.superview)
guard let indexPath = collectionView.indexPathForItem(at: point) else { return }
let installedApp = dataSource.item(at: indexPath)
refresh(installedApp)
}
@IBAction func refreshAllApps(_: UIBarButtonItem) {
isRefreshingAllApps = true
collectionView.collectionViewLayout.invalidateLayout()
let installedApps = InstalledApp.fetchAppsForRefreshingAll(in: DatabaseManager.shared.viewContext)
2023-03-01 00:48:36 -05:00
refresh(installedApps) { _ in
DispatchQueue.main.async {
self.isRefreshingAllApps = false
self.collectionView.reloadSections([Section.activeApps.rawValue, Section.inactiveApps.rawValue])
}
}
2023-03-01 00:48:36 -05:00
if #available(iOS 14, *) {
let interaction = INInteraction.refreshAllApps()
2023-03-01 00:48:36 -05:00
interaction.donate { error in
guard let error = error else { return }
2023-03-02 00:40:11 -05:00
os_log("Failed to donate intent %@ . %@", type: .error , interaction.intent, error.localizedDescription)
}
}
}
2023-03-01 00:48:36 -05:00
@IBAction func updateApp(_ sender: UIButton) {
let point = collectionView.convert(sender.center, from: sender.superview)
guard let indexPath = collectionView.indexPathForItem(at: point) else { return }
let installedApp = dataSource.item(at: indexPath)
let previousProgress = AppManager.shared.installationProgress(for: installedApp)
guard previousProgress == nil else {
previousProgress?.cancel()
return
}
2023-03-01 00:48:36 -05:00
_ = AppManager.shared.update(installedApp, presentingViewController: self) { result in
DispatchQueue.main.async {
2023-03-01 00:48:36 -05:00
switch result {
case .failure(OperationError.cancelled):
self.collectionView.reloadItems(at: [indexPath])
2023-03-01 00:48:36 -05:00
case let .failure(error):
let toastView = ToastView(error: error)
toastView.show(in: self)
2023-03-01 00:48:36 -05:00
self.collectionView.reloadItems(at: [indexPath])
2023-03-01 00:48:36 -05:00
case .success:
2023-03-02 00:40:11 -05:00
os_log("Updated app: %@", type: .info , installedApp.bundleIdentifier)
// No need to reload, since the the update cell is gone now.
}
2023-03-01 00:48:36 -05:00
self.update()
}
}
2023-03-01 00:48:36 -05:00
collectionView.reloadItems(at: [indexPath])
}
2023-03-01 00:48:36 -05:00
@IBAction func sideloadApp(_: UIBarButtonItem) {
2020-03-30 13:25:14 -07:00
let supportedTypes: [String]
2023-03-01 00:48:36 -05:00
if let types = UTTypeCreateAllIdentifiersForTag(kUTTagClassFilenameExtension, "ipa" as CFString, nil)?.takeRetainedValue() {
2020-03-30 13:25:14 -07:00
supportedTypes = (types as NSArray).map { $0 as! String }
2023-03-01 00:48:36 -05:00
} else {
2020-03-30 13:25:14 -07:00
supportedTypes = ["com.apple.itunes.ipa"] // Declared by the system.
}
2023-03-01 00:48:36 -05:00
2020-03-30 13:25:14 -07:00
let documentPickerViewController = UIDocumentPickerViewController(documentTypes: supportedTypes, in: .import)
documentPickerViewController.delegate = self
2023-03-01 00:48:36 -05:00
present(documentPickerViewController, animated: true, completion: nil)
2019-07-28 15:51:36 -07:00
}
2023-03-01 00:48:36 -05:00
func sideloadApp(at url: URL, completion: @escaping (Result<Void, Error>) -> Void) {
let progress = Progress.discreteProgress(totalUnitCount: 100)
2023-03-01 00:48:36 -05:00
navigationItem.leftBarButtonItem?.isIndicatingActivity = true
class Context {
var fileURL: URL?
var application: ALTApplication?
var installedApp: InstalledApp? {
didSet {
2023-03-01 00:48:36 -05:00
installedAppContext = installedApp?.managedObjectContext
}
}
2023-03-01 00:48:36 -05:00
private var installedAppContext: NSManagedObjectContext?
2023-03-01 00:48:36 -05:00
var error: Error?
}
2023-03-01 00:48:36 -05:00
let temporaryDirectory = FileManager.default.uniqueTemporaryURL()
let unzippedAppDirectory = temporaryDirectory.appendingPathComponent("App")
2023-03-01 00:48:36 -05:00
let context = Context()
2023-03-01 00:48:36 -05:00
let downloadOperation: RSTAsyncBlockOperation?
2023-03-01 00:48:36 -05:00
if url.isFileURL {
downloadOperation = nil
context.fileURL = url
progress.totalUnitCount -= 20
2023-03-01 00:48:36 -05:00
} else {
let downloadProgress = Progress.discreteProgress(totalUnitCount: 100)
2023-03-01 00:48:36 -05:00
downloadOperation = RSTAsyncBlockOperation { operation in
let downloadTask = URLSession.shared.downloadTask(with: url) { fileURL, response, error in
do {
let (fileURL, _) = try Result((fileURL, response), error).get()
2023-03-01 00:48:36 -05:00
try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil)
2023-03-01 00:48:36 -05:00
let destinationURL = temporaryDirectory.appendingPathComponent("App.ipa")
try FileManager.default.moveItem(at: fileURL, to: destinationURL)
2023-03-01 00:48:36 -05:00
context.fileURL = destinationURL
2023-03-01 00:48:36 -05:00
} catch {
context.error = error
}
operation.finish()
}
downloadProgress.addChild(downloadTask.progress, withPendingUnitCount: 100)
downloadTask.resume()
}
progress.addChild(downloadProgress, withPendingUnitCount: 20)
}
2023-03-01 00:48:36 -05:00
let unzipProgress = Progress.discreteProgress(totalUnitCount: 1)
let unzipAppOperation = BlockOperation {
2023-03-01 00:48:36 -05:00
do {
if let error = context.error {
throw error
}
2023-03-01 00:48:36 -05:00
guard let fileURL = context.fileURL else { throw OperationError.invalidParameters }
defer {
try? FileManager.default.removeItem(at: fileURL)
}
2023-03-01 00:48:36 -05:00
try FileManager.default.createDirectory(at: unzippedAppDirectory, withIntermediateDirectories: true, attributes: nil)
let unzippedApplicationURL = try FileManager.default.unzipAppBundle(at: fileURL, toDirectory: unzippedAppDirectory)
2023-03-01 00:48:36 -05:00
guard let application = ALTApplication(fileURL: unzippedApplicationURL) else { throw OperationError.invalidApp }
context.application = application
2023-03-01 00:48:36 -05:00
unzipProgress.completedUnitCount = 1
2023-03-01 00:48:36 -05:00
} catch {
context.error = error
}
}
progress.addChild(unzipProgress, withPendingUnitCount: 10)
2023-03-01 00:48:36 -05:00
if let downloadOperation = downloadOperation {
unzipAppOperation.addDependency(downloadOperation)
}
2023-03-01 00:48:36 -05:00
let removeAppExtensionsProgress = Progress.discreteProgress(totalUnitCount: 1)
2023-03-01 00:48:36 -05:00
let removeAppExtensionsOperation = RSTAsyncBlockOperation { [weak self] operation in
do {
if let error = context.error {
throw error
}
2023-03-01 00:48:36 -05:00
guard let application = context.application else { throw OperationError.invalidParameters }
2023-03-01 00:48:36 -05:00
DispatchQueue.main.async {
2023-03-01 00:48:36 -05:00
self?.removeAppExtensions(from: application) { result in
switch result {
case .success: removeAppExtensionsProgress.completedUnitCount = 1
2023-03-01 00:48:36 -05:00
case let .failure(error): context.error = error
}
operation.finish()
}
}
2023-03-01 00:48:36 -05:00
} catch {
context.error = error
operation.finish()
}
}
removeAppExtensionsOperation.addDependency(unzipAppOperation)
progress.addChild(removeAppExtensionsProgress, withPendingUnitCount: 5)
2023-03-01 00:48:36 -05:00
let installProgress = Progress.discreteProgress(totalUnitCount: 100)
2023-03-01 00:48:36 -05:00
let installAppOperation = RSTAsyncBlockOperation { operation in
do {
if let error = context.error {
throw error
}
2023-03-01 00:48:36 -05:00
guard let application = context.application else { throw OperationError.invalidParameters }
2023-03-01 00:48:36 -05:00
let group = AppManager.shared.install(application, presentingViewController: self) { result in
switch result {
case let .success(installedApp): context.installedApp = installedApp
case let .failure(error): context.error = error
}
operation.finish()
}
installProgress.addChild(group.progress, withPendingUnitCount: 100)
2023-03-01 00:48:36 -05:00
} catch {
context.error = error
operation.finish()
}
}
installAppOperation.completionBlock = {
try? FileManager.default.removeItem(at: temporaryDirectory)
2023-03-01 00:48:36 -05:00
DispatchQueue.main.async {
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = false
self.sideloadingProgressView.observedProgress = nil
self.sideloadingProgressView.setHidden(true, animated: true)
2023-03-01 00:48:36 -05:00
switch Result(context.installedApp, context.error) {
case let .success(app):
completion(.success(()))
2023-03-01 00:48:36 -05:00
app.managedObjectContext?.perform {
2023-03-02 00:40:11 -05:00
os_log("Successfully installed app: %@", type: .info , app.bundleIdentifier)
}
2023-03-01 00:48:36 -05:00
case .failure(OperationError.cancelled):
2023-03-01 00:48:36 -05:00
completion(.failure(OperationError.cancelled))
case let .failure(error):
let toastView = ToastView(error: error)
toastView.show(in: self)
2023-03-01 00:48:36 -05:00
completion(.failure(error))
}
}
}
progress.addChild(installProgress, withPendingUnitCount: 65)
installAppOperation.addDependency(removeAppExtensionsOperation)
2023-03-01 00:48:36 -05:00
sideloadingProgress = progress
sideloadingProgressView.progress = 0
sideloadingProgressView.isHidden = false
sideloadingProgressView.observedProgress = sideloadingProgress
let operations = [downloadOperation, unzipAppOperation, removeAppExtensionsOperation, installAppOperation].compactMap { $0 }
2023-03-01 00:48:36 -05:00
operationQueue.addOperations(operations, waitUntilFinished: false)
}
2023-03-01 00:48:36 -05:00
@IBAction func activateApp(_ sender: UIButton) {
let point = collectionView.convert(sender.center, from: sender.superview)
guard let indexPath = collectionView.indexPathForItem(at: point) else { return }
let installedApp = dataSource.item(at: indexPath)
activate(installedApp)
}
@IBAction func deactivateApp(_ sender: UIButton) {
let point = collectionView.convert(sender.center, from: sender.superview)
guard let indexPath = collectionView.indexPathForItem(at: point) else { return }
let installedApp = dataSource.item(at: indexPath)
deactivate(installedApp)
}
@objc func presentInactiveAppsAlert() {
let message: String
2023-03-01 00:48:36 -05:00
if UserDefaults.standard.activeAppLimitIncludesExtensions {
message = NSLocalizedString("Non-developer Apple IDs are limited to 3 apps and app extensions. Inactive apps don't count towards your total, but cannot be opened until activated.", comment: "")
2023-03-01 00:48:36 -05:00
} else {
message = NSLocalizedString("Non-developer Apple IDs are limited to 3 apps. Inactive apps are backed up and uninstalled so they don't count towards your total, but will be reinstalled with all their data when activated again.", comment: "")
}
2023-03-01 00:48:36 -05:00
let alertController = UIAlertController(title: NSLocalizedString("What are inactive apps?", comment: ""), message: message, preferredStyle: .alert)
alertController.addAction(.ok)
2023-03-01 00:48:36 -05:00
present(alertController, animated: true, completion: nil)
}
2023-03-01 00:48:36 -05:00
func updateCell(at indexPath: IndexPath) {
guard let cell = collectionView.cellForItem(at: indexPath) as? InstalledAppCollectionViewCell else { return }
2023-03-01 00:48:36 -05:00
let installedApp = dataSource.item(at: indexPath)
dataSource.cellConfigurationHandler(cell, installedApp, indexPath)
cell.bannerView.iconImageView.isIndicatingActivity = false
}
2023-03-01 00:48:36 -05:00
func removeAppExtensions(from application: ALTApplication, completion: @escaping (Result<Void, Error>) -> Void) {
guard !application.appExtensions.isEmpty else { return completion(.success(())) }
2023-03-01 00:48:36 -05:00
let firstSentence: String
2023-03-01 00:48:36 -05:00
if UserDefaults.standard.activeAppLimitIncludesExtensions {
firstSentence = NSLocalizedString("Non-developer Apple IDs are limited to 3 active apps and app extensions.", comment: "")
2023-03-01 00:48:36 -05:00
} else {
firstSentence = NSLocalizedString("Non-developer Apple IDs are limited to creating 10 App IDs per week.", comment: "")
}
2023-03-01 00:48:36 -05:00
let message = firstSentence + " " + NSLocalizedString("Would you like to remove this app's extensions so they don't count towards your limit?", comment: "")
2023-03-01 00:48:36 -05:00
let alertController = UIAlertController(title: NSLocalizedString("App Contains Extensions", comment: ""), message: message, preferredStyle: .alert)
2023-03-01 00:48:36 -05:00
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style, handler: { _ in
completion(.failure(OperationError.cancelled))
}))
2023-03-01 00:48:36 -05:00
alertController.addAction(UIAlertAction(title: NSLocalizedString("Keep App Extensions", comment: ""), style: .default) { _ in
completion(.success(()))
})
2023-03-01 00:48:36 -05:00
alertController.addAction(UIAlertAction(title: NSLocalizedString("Remove App Extensions", comment: ""), style: .destructive) { _ in
do {
for appExtension in application.appExtensions {
try FileManager.default.removeItem(at: appExtension.fileURL)
}
2023-03-01 00:48:36 -05:00
completion(.success(()))
2023-03-01 00:48:36 -05:00
} catch {
completion(.failure(error))
}
})
2023-03-01 00:48:36 -05:00
present(alertController, animated: true, completion: nil)
}
}
2023-03-01 00:48:36 -05:00
private extension MyAppsViewController {
func open(_ installedApp: InstalledApp) {
UIApplication.shared.open(installedApp.openAppURL) { success in
guard !success else { return }
2023-03-01 00:48:36 -05:00
let toastView = ToastView(error: OperationError.openAppFailed(name: installedApp.name))
toastView.show(in: self)
}
}
2023-03-01 00:48:36 -05:00
func refresh(_ installedApp: InstalledApp) {
let previousProgress = AppManager.shared.refreshProgress(for: installedApp)
guard previousProgress == nil else {
previousProgress?.cancel()
return
}
2023-03-01 00:48:36 -05:00
refresh([installedApp]) { results in
// If an error occured, reload the section so the progress bar is no longer visible.
2023-03-01 00:48:36 -05:00
if results.values.contains(where: { $0.error != nil }) {
DispatchQueue.main.async {
self.collectionView.reloadSections([Section.activeApps.rawValue, Section.inactiveApps.rawValue])
}
}
2023-03-01 00:48:36 -05:00
2023-03-02 00:40:11 -05:00
let errors = results.filter({ $1.error != nil }).map{ ($0, $1.error?.localizedDescription ?? "no description") }
let successes = results.filter({ $1.error == nil }).map{ ($0, "success") }
if !errors.isEmpty {
os_log("Finished refreshing Errors: %@", type: .error, errors.map { "\($0.0) - \($0.1)" }.joined(separator: "\n"))
}
if !successes.isEmpty {
os_log("Finished refreshing success: %@", type: .info, successes.map { "\($0.0) - \($0.1)" }.joined(separator: "\n"))
}
}
}
2023-03-01 00:48:36 -05:00
func activate(_ installedApp: InstalledApp) {
func finish(_ result: Result<InstalledApp, Error>) {
do {
let app = try result.get()
app.managedObjectContext?.perform {
try? app.managedObjectContext?.save()
}
2023-03-01 00:48:36 -05:00
} catch OperationError.cancelled {
// Ignore
2023-03-01 00:48:36 -05:00
} catch {
2023-03-02 00:40:11 -05:00
os_log("Failed to activate app: %@", type: .error , error.localizedDescription)
2023-03-01 00:48:36 -05:00
DispatchQueue.main.async {
installedApp.isActive = false
2023-03-01 00:48:36 -05:00
let toastView = ToastView(error: error)
toastView.show(in: self)
}
}
}
2023-03-01 00:48:36 -05:00
if UserDefaults.standard.activeAppsLimit != nil, #available(iOS 13, *) {
// UserDefaults.standard.activeAppsLimit is only non-nil on iOS 13.3.1 or later, so the #available check is just so we can use Combine.
2023-03-01 00:48:36 -05:00
guard let app = ALTApplication(fileURL: installedApp.fileURL) else { return finish(.failure(OperationError.invalidApp)) }
2023-03-01 00:48:36 -05:00
var cancellable: AnyCancellable?
cancellable = DatabaseManager.shared.viewContext.registeredObjects.publisher
.compactMap { $0 as? InstalledApp }
.filter(\.isActive)
.map { $0.publisher(for: \.isActive) }
.collect()
.flatMap { publishers in
Publishers.MergeMany(publishers)
}
.first { isActive in !isActive }
.sink { _ in
// A previously active app is now inactive,
// which means there are now enough slots to activate the app,
// so pre-emptively mark it as active to provide visual feedback sooner.
installedApp.isActive = true
cancellable?.cancel()
}
2023-03-01 00:48:36 -05:00
AppManager.shared.deactivateApps(for: app, presentingViewController: self) { result in
cancellable?.cancel()
installedApp.managedObjectContext?.perform {
2023-03-01 00:48:36 -05:00
switch result {
case let .failure(error):
installedApp.isActive = false
finish(.failure(error))
2023-03-01 00:48:36 -05:00
case .success:
installedApp.isActive = true
AppManager.shared.activate(installedApp, presentingViewController: self, completionHandler: finish(_:))
}
}
}
2023-03-01 00:48:36 -05:00
} else {
installedApp.isActive = true
AppManager.shared.activate(installedApp, presentingViewController: self, completionHandler: finish(_:))
}
}
2023-03-01 00:48:36 -05:00
func deactivate(_ installedApp: InstalledApp, completionHandler: ((Result<InstalledApp, Error>) -> Void)? = nil) {
guard installedApp.isActive else { return }
installedApp.isActive = false
2023-03-01 00:48:36 -05:00
AppManager.shared.deactivate(installedApp, presentingViewController: self) { result in
do {
let app = try result.get()
try? app.managedObjectContext?.save()
2023-03-01 00:48:36 -05:00
2023-03-02 00:40:11 -05:00
os_log("Finished deactivating app: %@", type: .info , app.bundleIdentifier)
2023-03-01 00:48:36 -05:00
} catch {
2023-03-02 00:40:11 -05:00
os_log("Failed to activate app: %@", type: .error , error.localizedDescription)
2023-03-01 00:48:36 -05:00
DispatchQueue.main.async {
installedApp.isActive = true
2023-03-01 00:48:36 -05:00
let toastView = ToastView(error: error)
toastView.show(in: self)
}
}
2023-03-01 00:48:36 -05:00
completionHandler?(result)
}
}
2023-03-01 00:48:36 -05:00
func remove(_ installedApp: InstalledApp) {
let title = String(format: NSLocalizedString("Remove “%@” from SideStore?", comment: ""), installedApp.name)
let message: String
2023-03-01 00:48:36 -05:00
if UserDefaults.standard.isLegacyDeactivationSupported {
message = NSLocalizedString("You must also delete it from the home screen to fully uninstall the app.", comment: "")
2023-03-01 00:48:36 -05:00
} else {
message = NSLocalizedString("This will also erase all backup data for this app.", comment: "")
}
let alertController = UIAlertController(title: title, message: message, preferredStyle: .actionSheet)
2019-07-28 15:51:36 -07:00
alertController.addAction(.cancel)
2023-03-01 00:48:36 -05:00
alertController.addAction(UIAlertAction(title: NSLocalizedString("Remove", comment: ""), style: .destructive, handler: { _ in
AppManager.shared.remove(installedApp) { result in
switch result {
case .success: break
2023-03-01 00:48:36 -05:00
case let .failure(error):
DispatchQueue.main.async {
let toastView = ToastView(error: error)
toastView.show(in: self)
}
}
2019-07-28 15:51:36 -07:00
}
}))
2023-03-01 00:48:36 -05:00
present(alertController, animated: true, completion: nil)
2019-07-28 15:51:36 -07:00
}
2023-03-01 00:48:36 -05:00
func backup(_ installedApp: InstalledApp) {
let title = NSLocalizedString("Start Backup?", comment: "")
let message = NSLocalizedString("This will replace any previous backups. Please leave SideStore open until the backup is complete.", comment: "")
let alertController = UIAlertController(title: title, message: message, preferredStyle: .actionSheet)
alertController.addAction(.cancel)
2023-03-01 00:48:36 -05:00
let actionTitle = String(format: NSLocalizedString("Back Up %@", comment: ""), installedApp.name)
2023-03-01 00:48:36 -05:00
alertController.addAction(UIAlertAction(title: actionTitle, style: .default, handler: { _ in
AppManager.shared.backup(installedApp, presentingViewController: self) { result in
do {
let app = try result.get()
try? app.managedObjectContext?.save()
2023-03-01 00:48:36 -05:00
2023-03-02 00:40:11 -05:00
os_log("Finished backing up app: %@", type: .info , app.bundleIdentifier)
2023-03-01 00:48:36 -05:00
} catch {
2023-03-02 00:40:11 -05:00
os_log("Failed to back up app: %@", type: .error , error.localizedDescription)
2023-03-01 00:48:36 -05:00
DispatchQueue.main.async {
let toastView = ToastView(error: error)
toastView.show(in: self)
2023-03-01 00:48:36 -05:00
self.collectionView.reloadSections([Section.activeApps.rawValue, Section.inactiveApps.rawValue])
}
}
}
2023-03-01 00:48:36 -05:00
DispatchQueue.main.async {
self.collectionView.reloadSections([Section.activeApps.rawValue, Section.inactiveApps.rawValue])
}
}))
2023-03-01 00:48:36 -05:00
present(alertController, animated: true, completion: nil)
}
2023-03-01 00:48:36 -05:00
func restore(_ installedApp: InstalledApp) {
let message = String(format: NSLocalizedString("This will replace all data you currently have in %@.", comment: ""), installedApp.name)
let alertController = UIAlertController(title: NSLocalizedString("Are you sure you want to restore this backup?", comment: ""), message: message, preferredStyle: .actionSheet)
alertController.addAction(.cancel)
2023-03-01 00:48:36 -05:00
alertController.addAction(UIAlertAction(title: NSLocalizedString("Restore Backup", comment: ""), style: .destructive, handler: { _ in
AppManager.shared.restore(installedApp, presentingViewController: self) { result in
do {
let app = try result.get()
try? app.managedObjectContext?.save()
2023-03-01 00:48:36 -05:00
2023-03-02 00:40:11 -05:00
os_log("Finished restoring app: %@", type: .info , app.bundleIdentifier)
2023-03-01 00:48:36 -05:00
} catch {
2023-03-02 00:40:11 -05:00
os_log("Failed to restore app: %@", type: .error , error.localizedDescription)
2023-03-01 00:48:36 -05:00
DispatchQueue.main.async {
let toastView = ToastView(error: error)
toastView.show(in: self)
}
}
}
2023-03-01 00:48:36 -05:00
DispatchQueue.main.async {
self.collectionView.reloadSections([Section.activeApps.rawValue])
}
}))
2023-03-01 00:48:36 -05:00
present(alertController, animated: true, completion: nil)
}
2023-03-01 00:48:36 -05:00
func exportBackup(for installedApp: InstalledApp) {
guard let backupURL = FileManager.default.backupDirectoryURL(for: installedApp) else { return }
2023-03-01 00:48:36 -05:00
let documentPicker = UIDocumentPickerViewController(url: backupURL, in: .exportToService)
documentPicker.delegate = self
2023-03-01 00:48:36 -05:00
present(documentPicker, animated: true, completion: nil)
}
2023-03-01 00:48:36 -05:00
func chooseIcon(for installedApp: InstalledApp) {
_imagePickerInstalledApp = installedApp
let imagePicker = UIImagePickerController()
imagePicker.delegate = self
imagePicker.allowsEditing = true
2023-03-01 00:48:36 -05:00
present(imagePicker, animated: true, completion: nil)
}
2023-03-01 00:48:36 -05:00
func changeIcon(for installedApp: InstalledApp, to image: UIImage?) {
// Remove previous icon from cache.
2023-03-01 00:48:36 -05:00
activeAppsDataSource.prefetchItemCache.removeObject(forKey: installedApp)
inactiveAppsDataSource.prefetchItemCache.removeObject(forKey: installedApp)
DatabaseManager.shared.persistentContainer.performBackgroundTask { context in
do {
let tempApp = context.object(with: installedApp.objectID) as! InstalledApp
tempApp.needsResign = true
tempApp.hasAlternateIcon = (image != nil)
2023-03-01 00:48:36 -05:00
if let image = image {
guard let icon = image.resizing(toFill: CGSize(width: 256, height: 256)),
let iconData = icon.pngData()
else { return }
2023-03-01 00:48:36 -05:00
try iconData.write(to: tempApp.alternateIconURL, options: .atomic)
2023-03-01 00:48:36 -05:00
} else {
try FileManager.default.removeItem(at: tempApp.alternateIconURL)
}
2023-03-01 00:48:36 -05:00
try context.save()
2023-03-01 00:48:36 -05:00
if tempApp.isActive {
DispatchQueue.main.async {
self.refresh(installedApp)
}
}
2023-03-01 00:48:36 -05:00
} catch {
2023-03-02 00:40:11 -05:00
os_log("Failed to change app icon. %@", type: .error , error.localizedDescription)
2023-03-01 00:48:36 -05:00
DispatchQueue.main.async {
let toastView = ToastView(error: error)
toastView.show(in: self)
}
}
}
}
2023-03-01 00:48:36 -05:00
@available(iOS 14, *)
2023-03-01 00:48:36 -05:00
func enableJIT(for installedApp: InstalledApp) {
AppManager.shared.enableJIT(for: installedApp) { result in
DispatchQueue.main.async {
2023-03-01 00:48:36 -05:00
switch result {
case .success: break
2023-03-01 00:48:36 -05:00
case let .failure(error):
let toastView = ToastView(error: error)
toastView.show(in: self)
}
}
}
}
2019-07-28 15:51:36 -07:00
}
2023-03-01 00:48:36 -05:00
private extension MyAppsViewController {
@objc func didFetchSource(_: Notification) {
DispatchQueue.main.async {
2023-03-01 00:48:36 -05:00
if self.updatesDataSource.fetchedResultsController.fetchedObjects == nil {
2023-03-02 00:40:11 -05:00
do { try self.updatesDataSource.fetchedResultsController.performFetch() } catch { os_log("Error fetching: %@", type: .error , error.localizedDescription) }
}
2023-03-01 00:48:36 -05:00
self.update()
}
}
2023-03-01 00:48:36 -05:00
@objc func importApp(_ notification: Notification) {
2020-02-11 13:29:28 -08:00
// Make sure left UIBarButtonItem has been set.
2023-03-01 00:48:36 -05:00
loadViewIfNeeded()
2023-03-01 19:09:33 -05:00
guard let url = notification.userInfo?[SideStoreAppDelegate.importAppDeepLinkURLKey] as? URL else { return }
2023-03-01 00:48:36 -05:00
sideloadApp(at: url) { _ in
guard url.isFileURL else { return }
2023-03-01 00:48:36 -05:00
do {
try FileManager.default.removeItem(at: url)
2023-03-01 00:48:36 -05:00
} catch {
2023-03-02 00:40:11 -05:00
os_log("Unable to remove imported .ipa. %@", type: .error , error.localizedDescription)
}
}
}
}
2023-03-01 00:48:36 -05:00
extension MyAppsViewController {
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
let section = Section(rawValue: indexPath.section)!
2023-03-01 00:48:36 -05:00
switch section {
case .noUpdates: return UICollectionReusableView()
case .updates:
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "UpdatesHeader", for: indexPath) as! UpdatesCollectionHeaderView
2023-03-01 00:48:36 -05:00
UIView.performWithoutAnimation {
2019-09-19 11:29:10 -07:00
headerView.button.backgroundColor = UIColor.altPrimary.withAlphaComponent(0.15)
headerView.button.setTitle("", for: .normal)
headerView.button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 28)
2019-09-19 11:29:10 -07:00
headerView.button.setTitleColor(.altPrimary, for: .normal)
headerView.button.addTarget(self, action: #selector(MyAppsViewController.toggleAppUpdates), for: .primaryActionTriggered)
2023-03-01 00:48:36 -05:00
if self.isUpdateSectionCollapsed {
headerView.button.titleLabel?.transform = .identity
2023-03-01 00:48:36 -05:00
} else {
headerView.button.titleLabel?.transform = CGAffineTransform.identity.rotated(by: .pi)
}
2023-03-01 00:48:36 -05:00
headerView.isHidden = (self.updatesDataSource.itemCount <= 2)
2023-03-01 00:48:36 -05:00
headerView.button.layoutIfNeeded()
}
2023-03-01 00:48:36 -05:00
return headerView
2023-03-01 00:48:36 -05:00
case .activeApps where kind == UICollectionView.elementKindSectionHeader:
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "ActiveAppsHeader", for: indexPath) as! InstalledAppsCollectionHeaderView
2023-03-01 00:48:36 -05:00
UIView.performWithoutAnimation {
headerView.layoutMargins.left = self.view.layoutMargins.left
headerView.layoutMargins.right = self.view.layoutMargins.right
2023-03-01 00:48:36 -05:00
if UserDefaults.standard.activeAppsLimit == nil {
headerView.textLabel.text = NSLocalizedString("Installed", comment: "")
2023-03-01 00:48:36 -05:00
} else {
headerView.textLabel.text = NSLocalizedString("Active", comment: "")
}
2023-03-01 00:48:36 -05:00
headerView.button.isIndicatingActivity = false
2019-09-19 11:29:10 -07:00
headerView.button.activityIndicatorView.color = .altPrimary
headerView.button.setTitle(NSLocalizedString("Refresh All", comment: ""), for: .normal)
headerView.button.addTarget(self, action: #selector(MyAppsViewController.refreshAllApps(_:)), for: .primaryActionTriggered)
2023-03-01 00:48:36 -05:00
headerView.button.layoutIfNeeded()
2023-03-01 00:48:36 -05:00
if self.isRefreshingAllApps {
2020-08-27 15:25:52 -07:00
headerView.button.isIndicatingActivity = true
headerView.button.accessibilityLabel = NSLocalizedString("Refreshing", comment: "")
headerView.button.accessibilityTraits.remove(.notEnabled)
2023-03-01 00:48:36 -05:00
} else {
2020-08-27 15:25:52 -07:00
headerView.button.isIndicatingActivity = false
headerView.button.accessibilityLabel = nil
}
}
2023-03-01 00:48:36 -05:00
return headerView
2023-03-01 00:48:36 -05:00
case .inactiveApps where kind == UICollectionView.elementKindSectionHeader:
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "InactiveAppsHeader", for: indexPath) as! InstalledAppsCollectionHeaderView
2023-03-01 00:48:36 -05:00
UIView.performWithoutAnimation {
headerView.layoutMargins.left = self.view.layoutMargins.left
headerView.layoutMargins.right = self.view.layoutMargins.right
2023-03-01 00:48:36 -05:00
headerView.textLabel.text = NSLocalizedString("Inactive", comment: "")
headerView.button.setTitle(nil, for: .normal)
2023-03-01 00:48:36 -05:00
if #available(iOS 13.0, *) {
headerView.button.setImage(UIImage(systemName: "questionmark.circle"), for: .normal)
}
2023-03-01 00:48:36 -05:00
headerView.button.addTarget(self, action: #selector(MyAppsViewController.presentInactiveAppsAlert), for: .primaryActionTriggered)
}
2023-03-01 00:48:36 -05:00
return headerView
2023-03-01 00:48:36 -05:00
case .activeApps, .inactiveApps:
2020-01-24 14:54:52 -08:00
let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "InstalledAppsFooter", for: indexPath) as! InstalledAppsCollectionFooterView
2023-03-01 00:48:36 -05:00
guard let team = DatabaseManager.shared.activeTeam() else { return footerView }
2023-03-01 00:48:36 -05:00
switch team.type {
case .free:
let registeredAppIDs = team.appIDs.count
2023-03-01 00:48:36 -05:00
let maximumAppIDCount = 10
let remainingAppIDs = max(maximumAppIDCount - registeredAppIDs, 0)
2023-03-01 00:48:36 -05:00
if remainingAppIDs == 1 {
footerView.textLabel.text = String(format: NSLocalizedString("1 App ID Remaining", comment: ""))
2023-03-01 00:48:36 -05:00
} else {
footerView.textLabel.text = String(format: NSLocalizedString("%@ App IDs Remaining", comment: ""), NSNumber(value: remainingAppIDs))
}
2023-03-01 00:48:36 -05:00
footerView.textLabel.isHidden = false
2023-03-01 00:48:36 -05:00
case .individual, .organization, .unknown: footerView.textLabel.isHidden = true
@unknown default: break
2020-01-24 14:54:52 -08:00
}
2023-03-01 00:48:36 -05:00
2020-01-24 14:54:52 -08:00
return footerView
}
}
2023-03-01 00:48:36 -05:00
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
2019-09-12 13:51:03 -07:00
let section = Section.allCases[indexPath.section]
2023-03-01 00:48:36 -05:00
switch section {
2019-09-12 13:51:03 -07:00
case .updates:
guard let cell = collectionView.cellForItem(at: indexPath) else { break }
2023-03-01 00:48:36 -05:00
performSegue(withIdentifier: "showUpdate", sender: cell)
2019-09-12 13:51:03 -07:00
default: break
}
}
}
@available(iOS 13.0, *)
2023-03-01 00:48:36 -05:00
extension MyAppsViewController {
private func actions(for installedApp: InstalledApp) -> [UIMenuElement] {
var actions = [UIMenuElement]()
2023-03-01 00:48:36 -05:00
let openAction = UIAction(title: NSLocalizedString("Open", comment: ""), image: UIImage(systemName: "arrow.up.forward.app")) { _ in
self.open(installedApp)
}
2023-03-01 00:48:36 -05:00
let openMenu = UIMenu(title: "", options: .displayInline, children: [openAction])
2023-03-01 00:48:36 -05:00
let refreshAction = UIAction(title: NSLocalizedString("Refresh", comment: ""), image: UIImage(systemName: "arrow.clockwise")) { _ in
self.refresh(installedApp)
}
2023-03-01 00:48:36 -05:00
let activateAction = UIAction(title: NSLocalizedString("Activate", comment: ""), image: UIImage(systemName: "checkmark.circle")) { _ in
self.activate(installedApp)
}
2023-03-01 00:48:36 -05:00
let deactivateAction = UIAction(title: NSLocalizedString("Deactivate", comment: ""), image: UIImage(systemName: "xmark.circle"), attributes: .destructive) { _ in
self.deactivate(installedApp)
}
2023-03-01 00:48:36 -05:00
let removeAction = UIAction(title: NSLocalizedString("Remove", comment: ""), image: UIImage(systemName: "trash"), attributes: .destructive) { _ in
self.remove(installedApp)
}
2023-03-01 00:48:36 -05:00
let jitAction = UIAction(title: NSLocalizedString("Enable JIT", comment: ""), image: UIImage(systemName: "bolt")) { _ in
guard #available(iOS 14, *) else { return }
self.enableJIT(for: installedApp)
}
2023-03-01 00:48:36 -05:00
let backupAction = UIAction(title: NSLocalizedString("Back Up", comment: ""), image: UIImage(systemName: "doc.on.doc")) { _ in
self.backup(installedApp)
}
2023-03-01 00:48:36 -05:00
let exportBackupAction = UIAction(title: NSLocalizedString("Export Backup", comment: ""), image: UIImage(systemName: "arrow.up.doc")) { _ in
self.exportBackup(for: installedApp)
}
2023-03-01 00:48:36 -05:00
let restoreBackupAction = UIAction(title: NSLocalizedString("Restore Backup", comment: ""), image: UIImage(systemName: "arrow.down.doc")) { _ in
self.restore(installedApp)
}
2023-03-01 00:48:36 -05:00
let chooseIconAction = UIAction(title: NSLocalizedString("Photos", comment: ""), image: UIImage(systemName: "photo")) { _ in
self.chooseIcon(for: installedApp)
}
2023-03-01 00:48:36 -05:00
let removeIconAction = UIAction(title: NSLocalizedString("Remove Custom Icon", comment: ""), image: UIImage(systemName: "trash"), attributes: [.destructive]) { _ in
self.changeIcon(for: installedApp, to: nil)
}
2023-03-01 00:48:36 -05:00
var changeIconActions = [chooseIconAction]
2023-03-01 00:48:36 -05:00
if installedApp.hasAlternateIcon {
changeIconActions.append(removeIconAction)
}
2023-03-01 00:48:36 -05:00
let changeIconMenu = UIMenu(title: NSLocalizedString("Change Icon", comment: ""), image: UIImage(systemName: "photo"), children: changeIconActions)
2023-03-01 00:48:36 -05:00
guard installedApp.bundleIdentifier != StoreApp.altstoreAppID else {
#if BETA
2023-03-01 00:48:36 -05:00
return [refreshAction, changeIconMenu]
#else
2023-03-01 00:48:36 -05:00
return [refreshAction]
#endif
}
2023-03-01 00:48:36 -05:00
if installedApp.isActive {
actions.append(openMenu)
actions.append(refreshAction)
2023-03-01 00:48:36 -05:00
} else {
actions.append(activateAction)
}
2023-03-01 00:48:36 -05:00
if installedApp.isActive, #available(iOS 14, *) {
actions.append(jitAction)
}
2023-03-01 00:48:36 -05:00
#if BETA
2023-03-01 00:48:36 -05:00
actions.append(changeIconMenu)
#endif
2023-03-01 00:48:36 -05:00
if installedApp.isActive {
actions.append(backupAction)
2023-03-01 00:48:36 -05:00
} else if let _ = UTTypeCopyDeclaration(installedApp.installedAppUTI as CFString)?.takeRetainedValue() as NSDictionary?, !UserDefaults.standard.isLegacyDeactivationSupported {
// Allow backing up inactive apps if they are still installed,
// but on an iOS version that no longer supports legacy deactivation.
// This handles edge case where you can't install more apps until you
// delete some, but can't activate inactive apps again to back them up first.
actions.append(backupAction)
}
2023-03-01 00:48:36 -05:00
if let backupDirectoryURL = FileManager.default.backupDirectoryURL(for: installedApp) {
var backupExists = false
2023-03-01 00:48:36 -05:00
var outError: NSError?
coordinator.coordinate(readingItemAt: backupDirectoryURL, options: [.withoutChanges], error: &outError) { backupDirectoryURL in
#if DEBUG
2023-03-01 00:48:36 -05:00
backupExists = true
#else
2023-03-01 00:48:36 -05:00
backupExists = FileManager.default.fileExists(atPath: backupDirectoryURL.path)
#endif
}
2023-03-01 00:48:36 -05:00
if backupExists {
actions.append(exportBackupAction)
2023-03-01 00:48:36 -05:00
if installedApp.isActive {
actions.append(restoreBackupAction)
}
2023-03-01 00:48:36 -05:00
} else if let error = outError {
2023-03-02 00:40:11 -05:00
os_log("Unable to check if backup exists: %@", type: .error , error.localizedDescription)
}
}
2023-03-01 00:48:36 -05:00
if installedApp.isActive {
actions.append(deactivateAction)
}
2023-03-01 00:48:36 -05:00
#if DEBUG
2023-03-01 00:48:36 -05:00
if installedApp.bundleIdentifier != StoreApp.altstoreAppID {
actions.append(removeAction)
}
#else
2023-03-01 00:48:36 -05:00
if (UserDefaults.standard.legacySideloadedApps ?? []).contains(installedApp.bundleIdentifier) {
// Legacy sideloaded app, so can't detect if it's deleted.
actions.append(removeAction)
} else if !UserDefaults.standard.isLegacyDeactivationSupported, !installedApp.isActive {
// Inactive apps are actually deleted, so we need another way
// for user to remove them from AltStore.
actions.append(removeAction)
}
#endif
2023-03-01 00:48:36 -05:00
return actions
}
2023-03-01 00:48:36 -05:00
override func collectionView(_: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point _: CGPoint) -> UIContextMenuConfiguration?
{
let section = Section(rawValue: indexPath.section)!
2023-03-01 00:48:36 -05:00
switch section {
case .updates, .noUpdates: return nil
case .activeApps, .inactiveApps:
2023-03-01 00:48:36 -05:00
let installedApp = dataSource.item(at: indexPath)
return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: nil) { _ -> UIMenu? in
let actions = self.actions(for: installedApp)
2023-03-01 00:48:36 -05:00
let menu = UIMenu(title: "", children: actions)
return menu
}
}
}
2023-03-01 00:48:36 -05:00
override func collectionView(_ collectionView: UICollectionView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
guard let indexPath = configuration.identifier as? NSIndexPath else { return nil }
guard let cell = collectionView.cellForItem(at: indexPath as IndexPath) as? InstalledAppCollectionViewCell else { return nil }
2023-03-01 00:48:36 -05:00
let parameters = UIPreviewParameters()
parameters.backgroundColor = .clear
parameters.visiblePath = UIBezierPath(roundedRect: cell.bannerView.bounds, cornerRadius: cell.bannerView.layer.cornerRadius)
2023-03-01 00:48:36 -05:00
let preview = UITargetedPreview(view: cell.bannerView, parameters: parameters)
return preview
}
2023-03-01 00:48:36 -05:00
override func collectionView(_ collectionView: UICollectionView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
self.collectionView(collectionView, previewForHighlightingContextMenuWithConfiguration: configuration)
}
}
2023-03-01 00:48:36 -05:00
extension MyAppsViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let section = Section.allCases[indexPath.section]
2023-03-01 00:48:36 -05:00
switch section {
case .noUpdates:
2019-10-23 13:32:17 -07:00
let size = CGSize(width: collectionView.bounds.width, height: 44)
return size
2023-03-01 00:48:36 -05:00
case .updates:
2023-03-01 00:48:36 -05:00
let item = dataSource.item(at: indexPath)
if let previousHeight = cachedUpdateSizes[item.bundleIdentifier] {
return previousHeight
}
2023-03-01 00:48:36 -05:00
2019-10-23 13:32:17 -07:00
// Manually change cell's width to prevent conflicting with UIView-Encapsulated-Layout-Width constraints.
2023-03-01 00:48:36 -05:00
prototypeUpdateCell.frame.size.width = collectionView.bounds.width
let widthConstraint = prototypeUpdateCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width)
NSLayoutConstraint.activate([widthConstraint])
defer { NSLayoutConstraint.deactivate([widthConstraint]) }
2023-03-01 00:48:36 -05:00
dataSource.cellConfigurationHandler(prototypeUpdateCell, item, indexPath)
let size = prototypeUpdateCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
cachedUpdateSizes[item.bundleIdentifier] = size
return size
case .activeApps, .inactiveApps:
2019-10-23 13:32:17 -07:00
return CGSize(width: collectionView.bounds.width, height: 88)
}
}
2023-03-01 00:48:36 -05:00
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
let section = Section.allCases[section]
2023-03-01 00:48:36 -05:00
switch section {
case .noUpdates: return .zero
case .updates:
2023-03-01 00:48:36 -05:00
let height: CGFloat = updatesDataSource.itemCount > maximumCollapsedUpdatesCount ? 26 : 0
return CGSize(width: collectionView.bounds.width, height: height)
2023-03-01 00:48:36 -05:00
case .activeApps: return CGSize(width: collectionView.bounds.width, height: 29)
2023-03-01 00:48:36 -05:00
case .inactiveApps where inactiveAppsDataSource.itemCount == 0: return .zero
case .inactiveApps: return CGSize(width: collectionView.bounds.width, height: 29)
}
}
2023-03-01 00:48:36 -05:00
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize {
2020-01-24 14:54:52 -08:00
let section = Section.allCases[section]
2023-03-01 00:48:36 -05:00
func appIDsFooterSize() -> CGSize {
guard let _ = DatabaseManager.shared.activeTeam() else { return .zero }
2023-03-01 00:48:36 -05:00
let indexPath = IndexPath(row: 0, section: section.rawValue)
let footerView = self.collectionView(collectionView, viewForSupplementaryElementOfKind: UICollectionView.elementKindSectionFooter, at: indexPath) as! InstalledAppsCollectionFooterView
2023-03-01 00:48:36 -05:00
let size = footerView.systemLayoutSizeFitting(CGSize(width: collectionView.frame.width, height: UIView.layoutFittingExpandedSize.height),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel)
return size
2020-01-24 14:54:52 -08:00
}
2023-03-01 00:48:36 -05:00
switch section {
case .noUpdates: return .zero
case .updates: return .zero
2023-03-01 00:48:36 -05:00
case .activeApps where inactiveAppsDataSource.itemCount == 0: return appIDsFooterSize()
case .activeApps: return .zero
2023-03-01 00:48:36 -05:00
case .inactiveApps where inactiveAppsDataSource.itemCount == 0: return .zero
case .inactiveApps: return appIDsFooterSize()
}
2020-01-24 14:54:52 -08:00
}
2023-03-01 00:48:36 -05:00
func collectionView(_: UICollectionView, layout _: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
let section = Section.allCases[section]
2023-03-01 00:48:36 -05:00
switch section {
case .noUpdates where updatesDataSource.itemCount != 0: return .zero
case .updates where updatesDataSource.itemCount == 0: return .zero
2019-10-23 13:32:17 -07:00
default: return UIEdgeInsets(top: 12, left: 0, bottom: 20, right: 0)
}
}
}
2023-03-01 00:48:36 -05:00
extension MyAppsViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning _: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
switch Section(rawValue: indexPath.section)! {
case .updates, .noUpdates:
return []
2023-03-01 00:48:36 -05:00
case .activeApps, .inactiveApps:
guard UserDefaults.standard.activeAppsLimit != nil else { return [] }
guard let cell = collectionView.cellForItem(at: indexPath as IndexPath) as? InstalledAppCollectionViewCell else { return [] }
2023-03-01 00:48:36 -05:00
let item = dataSource.item(at: indexPath)
guard item.bundleIdentifier != StoreApp.altstoreAppID else { return [] }
2023-03-01 00:48:36 -05:00
let dragItem = UIDragItem(itemProvider: NSItemProvider(item: nil, typeIdentifier: nil))
dragItem.localObject = item
dragItem.previewProvider = {
let parameters = UIDragPreviewParameters()
parameters.backgroundColor = .clear
parameters.visiblePath = UIBezierPath(roundedRect: cell.bannerView.iconImageView.bounds, cornerRadius: cell.bannerView.iconImageView.layer.cornerRadius)
2023-03-01 00:48:36 -05:00
let preview = UIDragPreview(view: cell.bannerView.iconImageView, parameters: parameters)
return preview
}
2023-03-01 00:48:36 -05:00
return [dragItem]
}
}
2023-03-01 00:48:36 -05:00
func collectionView(_ collectionView: UICollectionView, dragPreviewParametersForItemAt indexPath: IndexPath) -> UIDragPreviewParameters? {
guard let cell = collectionView.cellForItem(at: indexPath as IndexPath) as? InstalledAppCollectionViewCell else { return nil }
2023-03-01 00:48:36 -05:00
let parameters = UIDragPreviewParameters()
parameters.backgroundColor = .clear
parameters.visiblePath = UIBezierPath(roundedRect: cell.bannerView.frame, cornerRadius: cell.bannerView.layer.cornerRadius)
2023-03-01 00:48:36 -05:00
return parameters
}
2023-03-01 00:48:36 -05:00
func collectionView(_: UICollectionView, dragSessionDidEnd _: UIDragSession) {
let previousDestinationIndexPath = dropDestinationIndexPath
dropDestinationIndexPath = nil
if let indexPath = previousDestinationIndexPath {
// Access cell directly to prevent UI glitches due to race conditions when refreshing
2023-03-01 00:48:36 -05:00
updateCell(at: indexPath)
}
}
}
2023-03-01 00:48:36 -05:00
extension MyAppsViewController: UICollectionViewDropDelegate {
func collectionView(_: UICollectionView, canHandle session: UIDropSession) -> Bool {
session.localDragSession != nil
}
2023-03-01 00:48:36 -05:00
func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath _: IndexPath?) -> UICollectionViewDropProposal {
guard
let activeAppsLimit = UserDefaults.standard.activeAppsLimit,
let installedApp = session.items.first?.localObject as? InstalledApp
else { return UICollectionViewDropProposal(operation: .cancel) }
2023-03-01 00:48:36 -05:00
// Retrieve header attributes for location calculations.
guard
let activeAppsHeaderAttributes = collectionView.layoutAttributesForSupplementaryElement(ofKind: UICollectionView.elementKindSectionHeader, at: IndexPath(item: 0, section: Section.activeApps.rawValue)),
let inactiveAppsHeaderAttributes = collectionView.layoutAttributesForSupplementaryElement(ofKind: UICollectionView.elementKindSectionHeader, at: IndexPath(item: 0, section: Section.inactiveApps.rawValue))
else { return UICollectionViewDropProposal(operation: .cancel) }
2023-03-01 00:48:36 -05:00
var dropDestinationIndexPath: IndexPath?
defer {
// Animate selection changes.
2023-03-01 00:48:36 -05:00
if dropDestinationIndexPath != self.dropDestinationIndexPath {
let previousIndexPath = self.dropDestinationIndexPath
self.dropDestinationIndexPath = dropDestinationIndexPath
2023-03-01 00:48:36 -05:00
let indexPaths = [previousIndexPath, dropDestinationIndexPath].compactMap { $0 }
2023-03-01 00:48:36 -05:00
let propertyAnimator = UIViewPropertyAnimator(springTimingParameters: UISpringTimingParameters()) {
2023-03-01 00:48:36 -05:00
for indexPath in indexPaths {
// Access cell directly so we can animate it correctly.
self.updateCell(at: indexPath)
}
}
propertyAnimator.startAnimation()
}
}
2023-03-01 00:48:36 -05:00
let point = session.location(in: collectionView)
2023-03-01 00:48:36 -05:00
if installedApp.isActive {
// Deactivating
2023-03-01 00:48:36 -05:00
if point.y > inactiveAppsHeaderAttributes.frame.minY {
// Inactive apps section.
return UICollectionViewDropProposal(operation: .copy, intent: .insertAtDestinationIndexPath)
2023-03-01 00:48:36 -05:00
} else if point.y > activeAppsHeaderAttributes.frame.minY {
// Active apps section.
return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
2023-03-01 00:48:36 -05:00
} else {
return UICollectionViewDropProposal(operation: .cancel)
}
2023-03-01 00:48:36 -05:00
} else {
// Activating
2023-03-01 00:48:36 -05:00
guard point.y > activeAppsHeaderAttributes.frame.minY else {
// Above active apps section.
return UICollectionViewDropProposal(operation: .cancel)
}
2023-03-01 00:48:36 -05:00
guard point.y < inactiveAppsHeaderAttributes.frame.minY else {
// Inactive apps section.
return UICollectionViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
}
2023-03-01 00:48:36 -05:00
let activeAppsCount = (activeAppsDataSource.fetchedResultsController.fetchedObjects ?? []).map { $0.requiredActiveSlots }.reduce(0, +)
let availableActiveApps = max(activeAppsLimit - activeAppsCount, 0)
2023-03-01 00:48:36 -05:00
if installedApp.requiredActiveSlots <= availableActiveApps {
// Enough active app slots, so no need to deactivate app first.
return UICollectionViewDropProposal(operation: .copy, intent: .insertAtDestinationIndexPath)
2023-03-01 00:48:36 -05:00
} else {
// Not enough active app slots, so we need to deactivate an app.
2023-03-01 00:48:36 -05:00
// Provided destinationIndexPath is inaccurate.
guard let indexPath = collectionView.indexPathForItem(at: point), indexPath.section == Section.activeApps.rawValue else {
// Invalid destination index path.
return UICollectionViewDropProposal(operation: .cancel)
}
2023-03-01 00:48:36 -05:00
let installedApp = dataSource.item(at: indexPath)
guard installedApp.bundleIdentifier != StoreApp.altstoreAppID else {
// Can't deactivate AltStore.
return UICollectionViewDropProposal(operation: .forbidden, intent: .insertIntoDestinationIndexPath)
}
2023-03-01 00:48:36 -05:00
// This app can be deactivated!
dropDestinationIndexPath = indexPath
return UICollectionViewDropProposal(operation: .move, intent: .insertIntoDestinationIndexPath)
}
}
}
2023-03-01 00:48:36 -05:00
func collectionView(_: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
guard let installedApp = coordinator.session.items.first?.localObject as? InstalledApp else { return }
guard let destinationIndexPath = coordinator.destinationIndexPath else { return }
2023-03-01 00:48:36 -05:00
if installedApp.isActive {
guard destinationIndexPath.section == Section.inactiveApps.rawValue else { return }
2023-03-01 00:48:36 -05:00
deactivate(installedApp)
} else {
guard destinationIndexPath.section == Section.activeApps.rawValue else { return }
2023-03-01 00:48:36 -05:00
switch coordinator.proposal.intent {
case .insertIntoDestinationIndexPath:
installedApp.isActive = true
2023-03-01 00:48:36 -05:00
let previousInstalledApp = dataSource.item(at: destinationIndexPath)
deactivate(previousInstalledApp) { result in
installedApp.managedObjectContext?.perform {
2023-03-01 00:48:36 -05:00
switch result {
case .failure: installedApp.isActive = false
case .success: self.activate(installedApp)
}
}
}
2023-03-01 00:48:36 -05:00
case .insertAtDestinationIndexPath:
2023-03-01 00:48:36 -05:00
activate(installedApp)
case .unspecified: break
@unknown default: break
}
}
}
}
2023-03-01 00:48:36 -05:00
extension MyAppsViewController: NSFetchedResultsControllerDelegate {
func controllerWillChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
// Responding to NSFetchedResultsController updates before the collection view has
// been shown may throw exceptions because the collection view cannot accurately
// count the number of items before the update. However, if we manually call
// performBatchUpdates _before_ responding to updates, the collection view can get
// an accurate pre-update item count.
2023-03-01 00:48:36 -05:00
collectionView.performBatchUpdates(nil, completion: nil)
updatesDataSource.controllerWillChangeContent(controller)
}
2023-03-01 00:48:36 -05:00
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType) {
updatesDataSource.controller(controller, didChange: sectionInfo, atSectionIndex: UInt(sectionIndex), for: type)
}
2023-03-01 00:48:36 -05:00
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
updatesDataSource.controller(controller, didChange: anObject, at: indexPath, for: type, newIndexPath: newIndexPath)
}
2023-03-01 00:48:36 -05:00
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
let previousUpdateCount = collectionView.numberOfItems(inSection: Section.updates.rawValue)
let updateCount = Int(updatesDataSource.itemCount)
if previousUpdateCount == 0 && updateCount > 0 {
// Remove "No Updates Available" cell.
let change = RSTCellContentChange(type: .delete, currentIndexPath: IndexPath(item: 0, section: Section.noUpdates.rawValue), destinationIndexPath: nil)
2023-03-01 00:48:36 -05:00
collectionView.add(change)
} else if previousUpdateCount > 0 && updateCount == 0 {
// Insert "No Updates Available" cell.
let change = RSTCellContentChange(type: .insert, currentIndexPath: nil, destinationIndexPath: IndexPath(item: 0, section: Section.noUpdates.rawValue))
2023-03-01 00:48:36 -05:00
collectionView.add(change)
}
2023-03-01 00:48:36 -05:00
updatesDataSource.controllerDidChangeContent(controller)
}
}
2019-07-28 15:51:36 -07:00
2023-03-01 00:48:36 -05:00
extension MyAppsViewController: UIDocumentPickerDelegate {
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
2019-07-28 15:51:36 -07:00
guard let fileURL = urls.first else { return }
2023-03-01 00:48:36 -05:00
switch controller.documentPickerMode {
case .import, .open:
2023-03-01 00:48:36 -05:00
sideloadApp(at: fileURL) { result in
2023-03-02 00:40:11 -05:00
os_log("Sideloaded app at %@ with result: %@", type: .info , fileURL.absoluteString, String(describing: result))
}
2023-03-01 00:48:36 -05:00
case .exportToService, .moveToService: break
@unknown default: break
2019-07-28 15:51:36 -07:00
}
}
}
2019-09-12 13:51:03 -07:00
2023-03-01 00:48:36 -05:00
extension MyAppsViewController: UIViewControllerPreviewingDelegate {
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? {
2019-09-12 13:51:03 -07:00
guard
2023-03-01 00:48:36 -05:00
let indexPath = collectionView.indexPathForItem(at: location),
let cell = collectionView.cellForItem(at: indexPath)
2019-09-12 13:51:03 -07:00
else { return nil }
2023-03-01 00:48:36 -05:00
2019-09-12 13:51:03 -07:00
let section = Section.allCases[indexPath.section]
2023-03-01 00:48:36 -05:00
switch section {
2019-09-12 13:51:03 -07:00
case .updates:
previewingContext.sourceRect = cell.frame
2023-03-01 00:48:36 -05:00
let app = dataSource.item(at: indexPath)
guard let storeApp = app.storeApp else { return nil }
2019-09-12 13:51:03 -07:00
let appViewController = AppViewController.makeAppViewController(app: storeApp)
return appViewController
2023-03-01 00:48:36 -05:00
2019-09-12 13:51:03 -07:00
default: return nil
}
}
2023-03-01 00:48:36 -05:00
func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit _: UIViewController) {
2019-09-12 13:51:03 -07:00
let point = CGPoint(x: previewingContext.sourceRect.midX, y: previewingContext.sourceRect.midY)
2023-03-01 00:48:36 -05:00
guard let indexPath = collectionView.indexPathForItem(at: point), let cell = collectionView.cellForItem(at: indexPath) else { return }
performSegue(withIdentifier: "showUpdate", sender: cell)
2019-09-12 13:51:03 -07:00
}
}
2023-03-01 00:48:36 -05:00
extension MyAppsViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
defer {
picker.dismiss(animated: true, completion: nil)
self._imagePickerInstalledApp = nil
}
2023-03-01 00:48:36 -05:00
guard let image = info[.editedImage] as? UIImage, let installedApp = _imagePickerInstalledApp else { return }
changeIcon(for: installedApp, to: image)
}
2023-03-01 00:48:36 -05:00
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true, completion: nil)
2023-03-01 00:48:36 -05:00
_imagePickerInstalledApp = nil
}
}