diff --git a/AltStore/Base.lproj/Main.storyboard b/AltStore/Base.lproj/Main.storyboard index de97007b..0fceedbf 100644 --- a/AltStore/Base.lproj/Main.storyboard +++ b/AltStore/Base.lproj/Main.storyboard @@ -927,7 +927,13 @@ World - + + + + + + + diff --git a/AltStore/Model/InstalledApp.swift b/AltStore/Model/InstalledApp.swift index 4810e231..4df25687 100644 --- a/AltStore/Model/InstalledApp.swift +++ b/AltStore/Model/InstalledApp.swift @@ -26,6 +26,10 @@ class InstalledApp: NSManagedObject, Fetchable /* Relationships */ @NSManaged var storeApp: App? + var isSideloaded: Bool { + return self.storeApp == nil + } + private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?) { super.init(entity: entity, insertInto: context) diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index 62f599da..f6795424 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -11,6 +11,8 @@ import UIKit import AltKit import Roxas +import AltSign + private let maximumCollapsedUpdatesCount = 2 extension MyAppsViewController @@ -43,12 +45,15 @@ class MyAppsViewController: UICollectionViewController 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() private var isRefreshingAllApps = false private var refreshGroup: OperationGroup? + private var sideloadingProgress: Progress? // Cache private var cachedUpdateSizes = [String: CGSize]() @@ -83,6 +88,23 @@ class MyAppsViewController: UICollectionViewController 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 = .altGreen + self.sideloadingProgressView.progress = 0 + + 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) } override func prepare(for segue: UIStoryboardSegue, sender: Any?) @@ -96,6 +118,16 @@ class MyAppsViewController: UICollectionViewController let appViewController = segue.destination as! AppViewController appViewController.app = installedApp.storeApp } + + 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 @@ -187,13 +219,12 @@ private extension MyAppsViewController let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext) dataSource.cellIdentifierHandler = { _ in "AppCell" } dataSource.cellConfigurationHandler = { (cell, installedApp, indexPath) in - guard let app = installedApp.storeApp else { return } - - let tintColor = app.tintColor ?? .altGreen + let tintColor = installedApp.storeApp?.tintColor ?? .altGreen let cell = cell as! InstalledAppCollectionViewCell cell.tintColor = tintColor - cell.appIconImageView.image = UIImage(named: app.iconName) + cell.appIconImageView.isIndicatingActivity = true + cell.refreshButton.isIndicatingActivity = false cell.refreshButton.addTarget(self, action: #selector(MyAppsViewController.refreshApp(_:)), for: .primaryActionTriggered) @@ -210,8 +241,8 @@ private extension MyAppsViewController cell.refreshButton.setTitle(String(format: NSLocalizedString("%@ DAYS", comment: ""), NSNumber(value: numberOfDays)), for: .normal) } - cell.nameLabel.text = app.name - cell.developerLabel.text = app.developerName + cell.nameLabel.text = installedApp.name + cell.developerLabel.text = installedApp.storeApp?.developerName ?? NSLocalizedString("Sideloaded", comment: "") // Make sure refresh button is correct size. cell.layoutIfNeeded() @@ -224,7 +255,7 @@ private extension MyAppsViewController default: cell.refreshButton.tintColor = .refreshRed } - if let refreshGroup = self.refreshGroup, let progress = refreshGroup.progress(for: app), progress.fractionCompleted < 1.0 + if let refreshGroup = self.refreshGroup, let progress = refreshGroup.progress(for: installedApp), progress.fractionCompleted < 1.0 { cell.refreshButton.progress = progress } @@ -233,6 +264,24 @@ private extension MyAppsViewController 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 } @@ -465,6 +514,35 @@ private extension MyAppsViewController self.collectionView.reloadItems(at: [indexPath]) } + @IBAction func sideloadApp(_ sender: UIBarButtonItem) + { + 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) + } + + @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 didFetchApps(_ notification: Notification) { DispatchQueue.main.async { @@ -477,6 +555,23 @@ private extension MyAppsViewController 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 @@ -638,3 +733,58 @@ extension MyAppsViewController: NSFetchedResultsControllerDelegate 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 + } + } + } +}