Files
SideStore/AltStore/My Apps/MyAppsViewController.swift
2019-09-12 13:51:03 -07:00

919 lines
39 KiB
Swift

//
// MyAppsViewController.swift
// AltStore
//
// Created by Riley Testut on 7/16/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import AltKit
import Roxas
import AltSign
import Nuke
private let maximumCollapsedUpdatesCount = 2
extension MyAppsViewController
{
private enum Section: Int, CaseIterable
{
case noUpdates
case updates
case installedApps
}
}
class MyAppsViewController: UICollectionViewController
{
private lazy var dataSource = self.makeDataSource()
private lazy var noUpdatesDataSource = self.makeNoUpdatesDataSource()
private lazy var updatesDataSource = self.makeUpdatesDataSource()
private lazy var installedAppsDataSource = self.makeInstalledAppsDataSource()
private var prototypeUpdateCell: UpdateCollectionViewCell!
private var longPressGestureRecognizer: UILongPressGestureRecognizer!
private var sideloadingProgressView: UIProgressView!
// State
private var isUpdateSectionCollapsed = true
private var expandedAppUpdates = Set<String>()
private var isRefreshingAllApps = false
private var refreshGroup: OperationGroup?
private var sideloadingProgress: Progress?
// Cache
private var cachedUpdateSizes = [String: CGSize]()
private lazy var dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
dateFormatter.timeStyle = .none
return dateFormatter
}()
required init?(coder aDecoder: NSCoder)
{
super.init(coder: aDecoder)
NotificationCenter.default.addObserver(self, selector: #selector(MyAppsViewController.didFetchSource(_:)), name: AppManager.didFetchSourceNotification, object: nil)
}
override func viewDidLoad()
{
super.viewDidLoad()
// Allows us to intercept delegate callbacks.
self.updatesDataSource.fetchedResultsController.delegate = self
self.collectionView.dataSource = self.dataSource
self.collectionView.prefetchDataSource = self.dataSource
self.prototypeUpdateCell = UpdateCollectionViewCell.instantiate(with: UpdateCollectionViewCell.nib!)
self.prototypeUpdateCell.translatesAutoresizingMaskIntoConstraints = false
self.prototypeUpdateCell.contentView.translatesAutoresizingMaskIntoConstraints = false
self.collectionView.register(UpdateCollectionViewCell.nib, forCellWithReuseIdentifier: "UpdateCell")
self.collectionView.register(UpdatesCollectionHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "UpdatesHeader")
self.sideloadingProgressView = UIProgressView(progressViewStyle: .bar)
self.sideloadingProgressView.translatesAutoresizingMaskIntoConstraints = false
self.sideloadingProgressView.progressTintColor = .altRed
self.sideloadingProgressView.progress = 0
#if !BETA
self.navigationItem.leftBarButtonItem = nil
#endif
if let navigationBar = self.navigationController?.navigationBar
{
navigationBar.addSubview(self.sideloadingProgressView)
NSLayoutConstraint.activate([self.sideloadingProgressView.leadingAnchor.constraint(equalTo: navigationBar.leadingAnchor),
self.sideloadingProgressView.trailingAnchor.constraint(equalTo: navigationBar.trailingAnchor),
self.sideloadingProgressView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor)])
}
// Gestures
self.longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(MyAppsViewController.handleLongPressGesture(_:)))
self.collectionView.addGestureRecognizer(self.longPressGestureRecognizer)
self.registerForPreviewing(with: self, sourceView: self.collectionView)
}
override func viewWillAppear(_ animated: Bool)
{
super.viewWillAppear(animated)
self.updateDataSource()
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?)
{
guard let identifier = segue.identifier else { return }
switch identifier
{
case "showApp", "showUpdate":
guard let cell = sender as? UICollectionViewCell, let indexPath = self.collectionView.indexPath(for: cell) else { return }
let installedApp = self.dataSource.item(at: indexPath)
let appViewController = segue.destination as! AppViewController
appViewController.app = installedApp.storeApp
default: break
}
}
override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool
{
guard identifier == "showApp" else { return true }
guard let cell = sender as? UICollectionViewCell, let indexPath = self.collectionView.indexPath(for: cell) else { return true }
let installedApp = self.dataSource.item(at: indexPath)
return !installedApp.isSideloaded
}
}
private extension MyAppsViewController
{
func makeDataSource() -> RSTCompositeCollectionViewPrefetchingDataSource<InstalledApp, UIImage>
{
let dataSource = RSTCompositeCollectionViewPrefetchingDataSource<InstalledApp, UIImage>(dataSources: [self.noUpdatesDataSource, self.updatesDataSource, self.installedAppsDataSource])
dataSource.proxy = self
return dataSource
}
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" }
dynamicDataSource.cellConfigurationHandler = { (cell, _, indexPath) in
cell.layer.cornerRadius = 20
cell.layer.masksToBounds = true
cell.contentView.backgroundColor = UIColor.altRed.withAlphaComponent(0.15)
}
return dynamicDataSource
}
func makeUpdatesDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage>
{
let fetchRequest = InstalledApp.updatesFetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \InstalledApp.storeApp?.versionDate, ascending: true),
NSSortDescriptor(keyPath: \InstalledApp.name, ascending: true)]
fetchRequest.returnsObjectsAsFaults = false
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
dataSource.liveFetchLimit = maximumCollapsedUpdatesCount
dataSource.cellIdentifierHandler = { _ in "UpdateCell" }
dataSource.cellConfigurationHandler = { [weak self] (cell, installedApp, indexPath) in
guard let self = self else { return }
guard let app = installedApp.storeApp else { return }
let cell = cell as! UpdateCollectionViewCell
cell.tintColor = app.tintColor ?? .altRed
cell.nameLabel.text = app.name
cell.versionDescriptionTextView.text = app.versionDescription
cell.appIconImageView.image = nil
cell.appIconImageView.isIndicatingActivity = true
cell.betaBadgeView.isHidden = !app.isBeta
cell.updateButton.isIndicatingActivity = false
cell.updateButton.addTarget(self, action: #selector(MyAppsViewController.updateApp(_:)), for: .primaryActionTriggered)
if self.expandedAppUpdates.contains(app.bundleIdentifier)
{
cell.mode = .expanded
}
else
{
cell.mode = .collapsed
}
cell.versionDescriptionTextView.moreButton.addTarget(self, action: #selector(MyAppsViewController.toggleUpdateCellMode(_:)), for: .primaryActionTriggered)
let progress = AppManager.shared.installationProgress(for: app)
cell.updateButton.progress = progress
cell.dateLabel.text = Date().relativeDateString(since: app.versionDate, dateFormatter: self.dateFormatter)
cell.setNeedsLayout()
}
dataSource.prefetchHandler = { (installedApp, indexPath, completionHandler) in
guard let iconURL = installedApp.storeApp?.iconURL else { return nil }
return RSTAsyncBlockOperation() { (operation) in
ImagePipeline.shared.loadImage(with: iconURL, progress: nil, completion: { (response, error) in
guard !operation.isCancelled else { return operation.finish() }
if let image = response?.image
{
completionHandler(image, nil)
}
else
{
completionHandler(nil, error)
}
})
}
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! UpdateCollectionViewCell
cell.appIconImageView.isIndicatingActivity = false
cell.appIconImageView.image = image
if let error = error
{
print("Error loading image:", error)
}
}
return dataSource
}
func makeInstalledAppsDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage>
{
let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest<InstalledApp>
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
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
dataSource.cellIdentifierHandler = { _ in "AppCell" }
dataSource.cellConfigurationHandler = { (cell, installedApp, indexPath) in
let tintColor = installedApp.storeApp?.tintColor ?? .altRed
let cell = cell as! InstalledAppCollectionViewCell
cell.tintColor = tintColor
cell.appIconImageView.isIndicatingActivity = true
cell.betaBadgeView.isHidden = !(installedApp.storeApp?.isBeta ?? false)
cell.refreshButton.isIndicatingActivity = false
cell.refreshButton.addTarget(self, action: #selector(MyAppsViewController.refreshApp(_:)), for: .primaryActionTriggered)
let currentDate = Date()
let numberOfDays = installedApp.expirationDate.numberOfCalendarDays(since: currentDate)
if numberOfDays == 1
{
cell.refreshButton.setTitle(NSLocalizedString("1 DAY", comment: ""), for: .normal)
}
else
{
cell.refreshButton.setTitle(String(format: NSLocalizedString("%@ DAYS", comment: ""), NSNumber(value: numberOfDays)), for: .normal)
}
cell.nameLabel.text = installedApp.name
cell.developerLabel.text = installedApp.storeApp?.developerName ?? NSLocalizedString("Sideloaded", comment: "")
// Make sure refresh button is correct size.
cell.layoutIfNeeded()
switch numberOfDays
{
case 2...3: cell.refreshButton.tintColor = .refreshOrange
case 4...5: cell.refreshButton.tintColor = .refreshYellow
case 6...: cell.refreshButton.tintColor = .refreshGreen
default: cell.refreshButton.tintColor = .refreshRed
}
if let refreshGroup = self.refreshGroup, let progress = refreshGroup.progress(for: installedApp), progress.fractionCompleted < 1.0
{
cell.refreshButton.progress = progress
}
else
{
cell.refreshButton.progress = nil
}
}
dataSource.prefetchHandler = { (item, indexPath, completion) in
let fileURL = item.fileURL
return BlockOperation {
guard let application = ALTApplication(fileURL: fileURL) else {
completion(nil, OperationError.invalidApp)
return
}
let icon = application.icon
completion(icon, nil)
}
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! InstalledAppCollectionViewCell
cell.appIconImageView.image = image
cell.appIconImageView.isIndicatingActivity = false
}
return dataSource
}
func updateDataSource()
{
if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isPatron
{
self.dataSource.predicate = nil
}
else
{
self.dataSource.predicate = NSPredicate(format: "%K == nil OR %K == NO OR %K == %@",
#keyPath(InstalledApp.storeApp),
#keyPath(InstalledApp.storeApp.isBeta),
#keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID)
}
}
}
private extension MyAppsViewController
{
func update()
{
if self.updatesDataSource.itemCount > 0
{
self.navigationController?.tabBarItem.badgeValue = String(describing: self.updatesDataSource.itemCount)
UIApplication.shared.applicationIconBadgeNumber = Int(self.updatesDataSource.itemCount)
}
else
{
self.navigationController?.tabBarItem.badgeValue = nil
UIApplication.shared.applicationIconBadgeNumber = 0
}
if self.isViewLoaded
{
UIView.performWithoutAnimation {
self.collectionView.reloadSections(IndexSet(integer: Section.updates.rawValue))
}
}
}
func refresh(_ installedApps: [InstalledApp], completionHandler: @escaping (Result<[String : Result<InstalledApp, Error>], Error>) -> Void)
{
func refresh()
{
let group = AppManager.shared.refresh(installedApps, presentingViewController: self, group: self.refreshGroup)
group.completionHandler = { (result) in
DispatchQueue.main.async {
switch result
{
case .failure(let error):
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
toastView.setNeedsLayout()
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
case .success(let results):
let failures = results.compactMapValues { (result) -> Error? in
switch result
{
case .failure(OperationError.cancelled): return nil
case .failure(let error): return error
case .success: return nil
}
}
guard !failures.isEmpty else { break }
let localizedText: String
let detailText: String?
if let failure = failures.first, failures.count == 1
{
localizedText = failure.value.localizedDescription
detailText = nil
}
else
{
localizedText = String(format: NSLocalizedString("Failed to refresh %@ apps.", comment: ""), NSNumber(value: failures.count))
detailText = failures.first?.value.localizedDescription
}
let toastView = ToastView(text: localizedText, detailText: detailText)
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
}
self.refreshGroup = nil
completionHandler(result)
}
}
self.refreshGroup = group
UIView.performWithoutAnimation {
self.collectionView.reloadSections(IndexSet(integer: Section.installedApps.rawValue))
}
}
if installedApps.contains(where: { $0.bundleIdentifier == StoreApp.altstoreAppID })
{
let alertController = UIAlertController(title: NSLocalizedString("Refresh AltStore?", comment: ""), message: NSLocalizedString("AltStore will quit when it is finished refreshing.", comment: ""), preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: RSTSystemLocalizedString("Cancel"), style: .cancel) { (action) in
completionHandler(.failure(OperationError.cancelled))
})
alertController.addAction(UIAlertAction(title: NSLocalizedString("Refresh", comment: ""), style: .default) { (action) in
refresh()
})
self.present(alertController, animated: true, completion: nil)
}
else
{
refresh()
}
}
}
private extension MyAppsViewController
{
@IBAction func toggleAppUpdates(_ sender: UIButton)
{
let visibleCells = self.collectionView.visibleCells
self.collectionView.performBatchUpdates({
self.isUpdateSectionCollapsed.toggle()
UIView.animate(withDuration: 0.3, animations: {
if self.isUpdateSectionCollapsed
{
self.updatesDataSource.liveFetchLimit = maximumCollapsedUpdatesCount
self.expandedAppUpdates.removeAll()
for case let cell as UpdateCollectionViewCell in visibleCells
{
cell.mode = .collapsed
}
self.cachedUpdateSizes.removeAll()
sender.titleLabel?.transform = .identity
}
else
{
self.updatesDataSource.liveFetchLimit = 0
sender.titleLabel?.transform = CGAffineTransform.identity.rotated(by: .pi)
}
})
self.collectionView.collectionViewLayout.invalidateLayout()
}, completion: nil)
}
@IBAction func toggleUpdateCellMode(_ sender: UIButton)
{
let point = self.collectionView.convert(sender.center, from: sender.superview)
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
let installedApp = self.dataSource.item(at: indexPath)
let cell = self.collectionView.cellForItem(at: indexPath) as? UpdateCollectionViewCell
if self.expandedAppUpdates.contains(installedApp.bundleIdentifier)
{
self.expandedAppUpdates.remove(installedApp.bundleIdentifier)
cell?.mode = .collapsed
}
else
{
self.expandedAppUpdates.insert(installedApp.bundleIdentifier)
cell?.mode = .expanded
}
self.cachedUpdateSizes[installedApp.bundleIdentifier] = nil
self.collectionView.performBatchUpdates({
self.collectionView.collectionViewLayout.invalidateLayout()
}, completion: nil)
}
@IBAction func refreshApp(_ sender: UIButton)
{
let point = self.collectionView.convert(sender.center, from: sender.superview)
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
let installedApp = self.dataSource.item(at: indexPath)
let previousProgress = AppManager.shared.refreshProgress(for: installedApp)
guard previousProgress == nil else {
previousProgress?.cancel()
return
}
self.refresh([installedApp]) { (result) in
// If an error occured, reload the section so the progress bar is no longer visible.
if result.error != nil || result.value?.values.contains(where: { $0.error != nil }) == true
{
DispatchQueue.main.async {
self.collectionView.reloadSections(IndexSet(integer: Section.installedApps.rawValue))
}
}
print("Finished refreshing with result:", result.error?.localizedDescription ?? "success")
}
}
@IBAction func refreshAllApps(_ sender: UIBarButtonItem)
{
self.isRefreshingAllApps = true
self.collectionView.collectionViewLayout.invalidateLayout()
let installedApps = InstalledApp.fetchAppsForRefreshingAll(in: DatabaseManager.shared.viewContext)
self.refresh(installedApps) { (result) in
DispatchQueue.main.async {
self.isRefreshingAllApps = false
self.collectionView.reloadSections(IndexSet(integer: Section.installedApps.rawValue))
}
}
}
@IBAction func updateApp(_ sender: UIButton)
{
let point = self.collectionView.convert(sender.center, from: sender.superview)
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
guard let storeApp = self.dataSource.item(at: indexPath).storeApp else { return }
let previousProgress = AppManager.shared.installationProgress(for: storeApp)
guard previousProgress == nil else {
previousProgress?.cancel()
return
}
_ = AppManager.shared.install(storeApp, presentingViewController: self) { (result) in
DispatchQueue.main.async {
switch result
{
case .failure(OperationError.cancelled):
self.collectionView.reloadItems(at: [indexPath])
case .failure(let error):
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2)
self.collectionView.reloadItems(at: [indexPath])
case .success:
print("Updated app:", storeApp.bundleIdentifier)
// No need to reload, since the the update cell is gone now.
}
self.update()
}
}
self.collectionView.reloadItems(at: [indexPath])
}
@IBAction func sideloadApp(_ sender: UIBarButtonItem)
{
func sideloadApp()
{
let iOSAppUTI = "com.apple.itunes.ipa" // Declared by the system.
let documentPickerViewController = UIDocumentPickerViewController(documentTypes: [iOSAppUTI], in: .import)
documentPickerViewController.delegate = self
self.present(documentPickerViewController, animated: true, completion: nil)
}
let alertController = UIAlertController(title: NSLocalizedString("Sideload Apps (Beta)", comment: ""), message: NSLocalizedString("You may only install 10 apps + app extensions per week due to Apple's restrictions.\n\nIf you encounter an app that is not able to be sideloaded, please report the app to riley@rileytestut.com.", comment: ""), preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: RSTSystemLocalizedString("OK"), style: .default, handler: { (action) in
sideloadApp()
}))
alertController.addAction(.cancel)
self.present(alertController, animated: true, completion: nil)
}
@objc func presentAlert(for installedApp: InstalledApp)
{
let alertController = UIAlertController(title: nil, message: NSLocalizedString("Removing a sideloaded app only removes it from AltStore. You must also delete it from the home screen to fully uninstall the app.", comment: ""), preferredStyle: .actionSheet)
alertController.addAction(.cancel)
alertController.addAction(UIAlertAction(title: NSLocalizedString("Remove", comment: ""), style: .destructive, handler: { (action) in
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
let installedApp = context.object(with: installedApp.objectID) as! InstalledApp
context.delete(installedApp)
do { try context.save() }
catch { print("Failed to remove sideloaded app.", error) }
}
}))
self.present(alertController, animated: true, completion: nil)
}
}
private extension MyAppsViewController
{
@objc func didFetchSource(_ notification: Notification)
{
DispatchQueue.main.async {
if self.updatesDataSource.fetchedResultsController.fetchedObjects == nil
{
do { try self.updatesDataSource.fetchedResultsController.performFetch() }
catch { print("Error fetching:", error) }
}
self.update()
}
}
@objc func handleLongPressGesture(_ gestureRecognizer: UILongPressGestureRecognizer)
{
guard gestureRecognizer.state == .began else { return }
let point = gestureRecognizer.location(in: self.collectionView)
guard
let indexPath = self.collectionView.indexPathForItem(at: point),
indexPath.section == Section.installedApps.rawValue
else { return }
let installedApp = self.dataSource.item(at: indexPath)
guard installedApp.storeApp == nil else { return }
self.presentAlert(for: installedApp)
}
}
extension MyAppsViewController
{
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
{
let section = Section(rawValue: indexPath.section)!
switch section
{
case .noUpdates: return UICollectionReusableView()
case .updates:
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "UpdatesHeader", for: indexPath) as! UpdatesCollectionHeaderView
UIView.performWithoutAnimation {
headerView.button.backgroundColor = UIColor.altRed.withAlphaComponent(0.15)
headerView.button.setTitle("", for: .normal)
headerView.button.titleLabel?.font = UIFont.boldSystemFont(ofSize: 28)
headerView.button.setTitleColor(.altRed, for: .normal)
headerView.button.addTarget(self, action: #selector(MyAppsViewController.toggleAppUpdates), for: .primaryActionTriggered)
if self.isUpdateSectionCollapsed
{
headerView.button.titleLabel?.transform = .identity
}
else
{
headerView.button.titleLabel?.transform = CGAffineTransform.identity.rotated(by: .pi)
}
headerView.isHidden = (self.updatesDataSource.itemCount <= 2)
headerView.button.layoutIfNeeded()
}
return headerView
case .installedApps:
let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "InstalledAppsHeader", for: indexPath) as! InstalledAppsCollectionHeaderView
UIView.performWithoutAnimation {
headerView.textLabel.text = NSLocalizedString("Installed", comment: "")
headerView.button.isIndicatingActivity = false
headerView.button.activityIndicatorView.color = .altRed
headerView.button.setTitle(NSLocalizedString("Refresh All", comment: ""), for: .normal)
headerView.button.addTarget(self, action: #selector(MyAppsViewController.refreshAllApps(_:)), for: .primaryActionTriggered)
headerView.button.isIndicatingActivity = self.isRefreshingAllApps
headerView.button.layoutIfNeeded()
}
return headerView
}
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
{
let section = Section.allCases[indexPath.section]
switch section
{
case .updates:
guard let cell = collectionView.cellForItem(at: indexPath) else { break }
self.performSegue(withIdentifier: "showUpdate", sender: cell)
default: break
}
}
}
extension MyAppsViewController: UICollectionViewDelegateFlowLayout
{
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
{
let padding = 30 as CGFloat
let width = collectionView.bounds.width - padding
let section = Section.allCases[indexPath.section]
switch section
{
case .noUpdates:
let size = CGSize(width: width, height: 44)
return size
case .updates:
let item = self.dataSource.item(at: indexPath)
if let previousHeight = self.cachedUpdateSizes[item.bundleIdentifier]
{
return previousHeight
}
let widthConstraint = self.prototypeUpdateCell.contentView.widthAnchor.constraint(equalToConstant: width)
NSLayoutConstraint.activate([widthConstraint])
defer { NSLayoutConstraint.deactivate([widthConstraint]) }
self.dataSource.cellConfigurationHandler(self.prototypeUpdateCell, item, indexPath)
let size = self.prototypeUpdateCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
self.cachedUpdateSizes[item.bundleIdentifier] = size
return size
case .installedApps:
return CGSize(width: collectionView.bounds.width, height: 60)
}
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize
{
let section = Section.allCases[section]
switch section
{
case .noUpdates: return .zero
case .updates:
let height: CGFloat = self.updatesDataSource.itemCount > maximumCollapsedUpdatesCount ? 26 : 0
return CGSize(width: collectionView.bounds.width, height: height)
case .installedApps: return CGSize(width: collectionView.bounds.width, height: 29)
}
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets
{
let section = Section.allCases[section]
switch section
{
case .noUpdates:
guard self.updatesDataSource.itemCount == 0 else { return .zero }
return UIEdgeInsets(top: 12, left: 15, bottom: 20, right: 15)
case .updates:
guard self.updatesDataSource.itemCount > 0 else { return .zero }
return UIEdgeInsets(top: 12, left: 15, bottom: 20, right: 15)
case .installedApps: return UIEdgeInsets(top: 12, left: 0, bottom: 20, right: 0)
}
}
}
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.
self.collectionView.performBatchUpdates(nil, completion: nil)
self.updatesDataSource.controllerWillChangeContent(controller)
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange sectionInfo: NSFetchedResultsSectionInfo, atSectionIndex sectionIndex: Int, for type: NSFetchedResultsChangeType)
{
self.updatesDataSource.controller(controller, didChange: sectionInfo, atSectionIndex: UInt(sectionIndex), for: type)
}
func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?)
{
self.updatesDataSource.controller(controller, didChange: anObject, at: indexPath, for: type, newIndexPath: newIndexPath)
}
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>)
{
let previousUpdateCount = self.collectionView.numberOfItems(inSection: Section.updates.rawValue)
let updateCount = Int(self.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)
self.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))
self.collectionView.add(change)
}
self.updatesDataSource.controllerDidChangeContent(controller)
}
}
extension MyAppsViewController: UIDocumentPickerDelegate
{
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL])
{
guard let fileURL = urls.first else { return }
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = true
DispatchQueue.global().async {
let temporaryDirectory = FileManager.default.uniqueTemporaryURL()
do
{
try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil)
let unzippedApplicationURL = try FileManager.default.unzipAppBundle(at: fileURL, toDirectory: temporaryDirectory)
guard let application = ALTApplication(fileURL: unzippedApplicationURL) else { return }
self.sideloadingProgress = AppManager.shared.install(application, presentingViewController: self) { (result) in
try? FileManager.default.removeItem(at: temporaryDirectory)
DispatchQueue.main.async {
if let error = result.error
{
let toastView = ToastView(text: error.localizedDescription, detailText: nil)
toastView.show(in: self.navigationController?.view ?? self.view, duration: 2.0)
}
else
{
print("Successfully installed app:", application.bundleIdentifier)
}
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = false
self.sideloadingProgressView.observedProgress = nil
self.sideloadingProgressView.setHidden(true, animated: true)
}
}
DispatchQueue.main.async {
self.sideloadingProgressView.progress = 0
self.sideloadingProgressView.isHidden = false
self.sideloadingProgressView.observedProgress = self.sideloadingProgress
}
}
catch
{
try? FileManager.default.removeItem(at: temporaryDirectory)
self.navigationItem.leftBarButtonItem?.isIndicatingActivity = false
}
}
}
}
extension MyAppsViewController: UIViewControllerPreviewingDelegate
{
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController?
{
guard
let indexPath = self.collectionView.indexPathForItem(at: location),
let cell = self.collectionView.cellForItem(at: indexPath)
else { return nil }
let section = Section.allCases[indexPath.section]
switch section
{
case .updates:
previewingContext.sourceRect = cell.frame
let app = self.dataSource.item(at: indexPath)
guard let storeApp = app.storeApp else { return nil}
let appViewController = AppViewController.makeAppViewController(app: storeApp)
return appViewController
default: return nil
}
}
func previewingContext(_ previewingContext: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController)
{
let point = CGPoint(x: previewingContext.sourceRect.midX, y: previewingContext.sourceRect.midY)
guard let indexPath = self.collectionView.indexPathForItem(at: point), let cell = self.collectionView.cellForItem(at: indexPath) else { return }
self.performSegue(withIdentifier: "showUpdate", sender: cell)
}
}